From 50bf9c63bb23bbc40a9f875a2f5a4dc2113570cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Proch=C3=A1zka?= Date: Mon, 7 Sep 2026 14:58:34 +0200 Subject: [PATCH 01/12] feat: Add custom releasing for runtime (#1399) Co-authored-by: Claude --- .github/scripts/before-beta-release.ts | 38 -------- .github/scripts/before-prerelease.ts | 102 ++++++++++++++++++++ .github/workflows/pre_release.yaml | 2 +- .github/workflows/publish_to_npm.yaml | 41 ++++++++- CONTRIBUTING.md | 29 ++++++ docs/reference.md | 63 +++++++++++++ scripts/generate-cli-docs.ts | 5 + src/commands/_register.ts | 2 + src/commands/runtime/_index.ts | 21 +++++ src/commands/runtime/install.ts | 42 +++++++++ src/commands/runtime/start.ts | 123 +++++++++++++++++++++++++ src/commands/runtime/stop.ts | 39 ++++++++ src/lib/runtime/docker.ts | 108 ++++++++++++++++++++++ src/lib/runtime/ensure.ts | 64 +++++++++++++ test/local/lib/runtime-docker.test.ts | 62 +++++++++++++ 15 files changed, 701 insertions(+), 40 deletions(-) delete mode 100644 .github/scripts/before-beta-release.ts create mode 100644 .github/scripts/before-prerelease.ts create mode 100644 src/commands/runtime/_index.ts create mode 100644 src/commands/runtime/install.ts create mode 100644 src/commands/runtime/start.ts create mode 100644 src/commands/runtime/stop.ts create mode 100644 src/lib/runtime/docker.ts create mode 100644 src/lib/runtime/ensure.ts create mode 100644 test/local/lib/runtime-docker.test.ts diff --git a/.github/scripts/before-beta-release.ts b/.github/scripts/before-beta-release.ts deleted file mode 100644 index e4202e581..000000000 --- a/.github/scripts/before-beta-release.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { execSync } from 'node:child_process'; -import { readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; - -const PKG_JSON_PATH = path.join(import.meta.dirname, '..', '..', 'package.json'); - -const pkgJson = JSON.parse(await readFile(PKG_JSON_PATH, { encoding: 'utf8' })); - -const PACKAGE_NAME = pkgJson.name; -const VERSION = pkgJson.version; - -const nextVersion = getNextVersion(VERSION); -console.log(`before-deploy: Setting version to ${nextVersion}`); -pkgJson.version = nextVersion; - -await writeFile(PKG_JSON_PATH, `${JSON.stringify(pkgJson, null, 4)}\n`); - -function getNextVersion(version: string) { - const versionString = execSync(`npm show ${PACKAGE_NAME} versions --json`, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }); - - const versions = JSON.parse(versionString) as string[]; - - if (versions.some((v) => v === VERSION)) { - console.error( - `before-deploy: A release with version ${VERSION} already exists. Please increment version accordingly.`, - ); - process.exit(1); - } - - const prereleaseNumbers = versions - .filter((v) => v.startsWith(VERSION) && v.includes('-')) - .map((v) => Number(v.match(/\.(\d+)$/)![1])); - const lastPrereleaseNumber = Math.max(-1, ...prereleaseNumbers); - return `${version}-beta.${lastPrereleaseNumber + 1}`; -} diff --git a/.github/scripts/before-prerelease.ts b/.github/scripts/before-prerelease.ts new file mode 100644 index 000000000..919d58707 --- /dev/null +++ b/.github/scripts/before-prerelease.ts @@ -0,0 +1,102 @@ +import { execSync } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; + +const PKG_JSON_PATH = path.join(import.meta.dirname, '..', '..', 'package.json'); + +const { values } = parseArgs({ + options: { + 'tag': { type: 'string', default: 'beta' }, + // Side channels are published from branches whose package.json version is usually the last + // released one, so the base version has to be moved forward instead of failing the release. + 'bump-base-if-published': { type: 'boolean', default: false }, + }, +}); + +const PRERELEASE_TAG = values.tag!; +const BUMP_BASE_IF_PUBLISHED = values['bump-base-if-published']; + +// The tag ends up both as an npm dist-tag and as a semver prerelease identifier. +if (!/^[a-z][a-z0-9-]*$/.test(PRERELEASE_TAG)) { + console.error( + `before-prerelease: '${PRERELEASE_TAG}' is not a usable prerelease tag - use lowercase letters, digits and hyphens, starting with a letter.`, + ); + process.exit(1); +} + +if (PRERELEASE_TAG === 'latest') { + console.error(`before-prerelease: 'latest' is the stable dist-tag and cannot be used for a prerelease.`); + process.exit(1); +} + +const pkgJson = JSON.parse(await readFile(PKG_JSON_PATH, { encoding: 'utf8' })); + +const PACKAGE_NAME = pkgJson.name; +const VERSION = pkgJson.version; + +const nextVersion = getNextVersion(VERSION); +console.log(`before-prerelease: Setting version to ${nextVersion}`); +pkgJson.version = nextVersion; + +await writeFile(PKG_JSON_PATH, `${JSON.stringify(pkgJson, null, 4)}\n`); + +function getPublishedVersions() { + const versionString = execSync(`npm show ${PACKAGE_NAME} versions --json`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + + const parsed = JSON.parse(versionString) as string[] | string; + + // npm returns a bare string when the package has exactly one published version. + return Array.isArray(parsed) ? parsed : [parsed]; +} + +function nextFreeBaseVersion(version: string, publishedVersions: string[]) { + const [major, minor, patch] = version.split('-')[0].split('.').map(Number); + + if ([major, minor, patch].some((part) => !Number.isInteger(part))) { + console.error(`before-prerelease: Cannot parse '${version}' in package.json as a semver version.`); + process.exit(1); + } + + let candidate = `${major}.${minor}.${patch}`; + let nextPatch = patch; + + while (publishedVersions.includes(candidate)) { + nextPatch += 1; + candidate = `${major}.${minor}.${nextPatch}`; + } + + return candidate; +} + +function getNextVersion(version: string) { + const versions = getPublishedVersions(); + + let baseVersion = version; + + if (versions.includes(baseVersion)) { + if (!BUMP_BASE_IF_PUBLISHED) { + console.error( + `before-prerelease: A release with version ${baseVersion} already exists. Please increment version accordingly.`, + ); + process.exit(1); + } + + baseVersion = nextFreeBaseVersion(baseVersion, versions); + console.log(`before-prerelease: ${version} is already published, basing the prerelease on ${baseVersion}`); + } + + const prereleasePattern = new RegExp(`^${baseVersion.replace(/\./g, '\\.')}-${PRERELEASE_TAG}\\.(\\d+)$`); + + const prereleaseNumbers = versions + .map((v) => v.match(prereleasePattern)?.[1]) + .filter((number) => number !== undefined) + .map(Number); + + const lastPrereleaseNumber = Math.max(-1, ...prereleaseNumbers); + + return `${baseVersion}-${PRERELEASE_TAG}.${lastPrereleaseNumber + 1}`; +} diff --git a/.github/workflows/pre_release.yaml b/.github/workflows/pre_release.yaml index f5c6173aa..eee4526c2 100644 --- a/.github/workflows/pre_release.yaml +++ b/.github/workflows/pre_release.yaml @@ -80,7 +80,7 @@ jobs: - name: Get pre-release version id: get-pre-release-version run: | - pnpm exec tsx ./.github/scripts/before-beta-release.ts + pnpm exec tsx ./.github/scripts/before-prerelease.ts --tag beta echo "pre_release_version=$(cat package.json | jq -r '.version')" >> $GITHUB_OUTPUT build-bundles: diff --git a/.github/workflows/publish_to_npm.yaml b/.github/workflows/publish_to_npm.yaml index 714c504af..d45530645 100644 --- a/.github/workflows/publish_to_npm.yaml +++ b/.github/workflows/publish_to_npm.yaml @@ -15,6 +15,12 @@ on: options: - latest - beta + - runtime + allow_unmerged_latest: + description: "Allow 'latest' from a ref that is not contained in master (deliberate releases only)" + required: false + type: boolean + default: false permissions: id-token: write # Required for OIDC @@ -28,6 +34,27 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref }} + # The master containment check below needs real history, not a shallow clone. + fetch-depth: 0 + + # 'latest' is what plain `npm install -g apify-cli` resolves to, so it must not come from a + # side channel branch by accident. Side channels (beta, runtime) are opt-in and unrestricted. + - name: Check the ref is in master before publishing 'latest' + if: ${{ inputs.tag == 'latest' }} + env: + ALLOW_UNMERGED_LATEST: ${{ inputs.allow_unmerged_latest }} + run: | + if [ "$ALLOW_UNMERGED_LATEST" = "true" ]; then + echo "allow_unmerged_latest is set, skipping the master containment check." + exit 0 + fi + + git fetch --no-tags origin master + + if ! git merge-base --is-ancestor HEAD origin/master; then + echo "::error::Refusing to publish the 'latest' dist-tag from $(git rev-parse HEAD) - it is not contained in origin/master. Publish a side channel (tag 'beta' or 'runtime') instead, or re-run with allow_unmerged_latest to release from another branch on purpose." + exit 1 + fi - name: Use Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -40,7 +67,13 @@ jobs: - name: Check version consistency and bump pre-release version (beta only) if: ${{ inputs.tag == 'beta' }} - run: pnpm exec tsx ./.github/scripts/before-beta-release.ts + run: pnpm exec tsx ./.github/scripts/before-prerelease.ts --tag beta + + # Side channels are published off long-lived branches whose package.json still carries the + # last released version, so the base version is moved forward instead of failing the build. + - name: Bump pre-release version (side channels) + if: ${{ inputs.tag != 'beta' && inputs.tag != 'latest' }} + run: pnpm exec tsx ./.github/scripts/before-prerelease.ts --tag "${{ inputs.tag }}" --bump-base-if-published - name: Build module run: pnpm run build @@ -53,3 +86,9 @@ jobs: - name: Publish to NPM run: pnpm publish --provenance --access public --no-git-checks --tag ${{ inputs.tag }} + + - name: Report what was published + run: | + echo "### Published \`apify-cli@$(jq -r .version package.json)\` under the \`${{ inputs.tag }}\` dist-tag" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Install it with \`npm install -g apify-cli@${{ inputs.tag }}\`" >> $GITHUB_STEP_SUMMARY diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8da9660ab..62e635ca2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -184,3 +184,32 @@ Releases are fully automated via GitHub Actions — **do not bump the version in - **Stable releases:** trigger the **Create a release** workflow (`.github/workflows/release.yaml`) manually from the GitHub Actions UI. It computes the next version (auto / patch / minor / major / custom), updates `CHANGELOG.md`, builds standalone bundles for Linux / macOS / Windows (x64 + ARM64), creates a GitHub release with the bundles attached, publishes to npm under `latest`, and opens a PR against the Homebrew formula. Only users with publish access to the [`apify-cli` npm package](https://www.npmjs.com/package/apify-cli) can trigger the stable release workflow. + +### Side channels (publishing a branch to npm) + +A feature that needs real-world testing before it lands on `master` can be published from its own branch +under its own npm dist-tag. Users on `latest` are unaffected — npm only installs a dist-tag when it is +asked for explicitly: + +```bash +npm install -g apify-cli@runtime +``` + +The `runtime` channel exists for [Actor runtime](https://docs.apify.com/cli) development. To publish one: + +1. Merge `master` into your branch. The workflow definition comes from the ref you dispatch from, but + `.github/scripts/` comes from the ref you publish, so a stale branch fails the version-bump step. +2. Run the **Publish to NPM** workflow (`.github/workflows/publish_to_npm.yaml`) from the Actions UI, + dispatching it **from `master`**, with `ref` set to your branch and `tag` set to `runtime`. Dispatching + from `master` also keeps the OIDC claim npm's trusted publisher sees stable. +3. The workflow derives the version itself: the base version moves forward to the first unpublished patch + and the channel name becomes the prerelease identifier, so the result looks like `1.10.1-runtime.0`, + then `.1`, `.2` on later publishes. Each channel counts independently of `beta`. + +Side channels publish to npm only — no GitHub release, no changelog entry, no standalone bundles. + +To add another channel, add its name to the `tag` input's `options` in the workflow. Channel names must be +lowercase and cannot be valid semver (they become both a dist-tag and a semver prerelease identifier). + +Publishing `latest` is refused unless the ref is contained in `origin/master`; `allow_unmerged_latest` +overrides that for a deliberate release from another branch. diff --git a/docs/reference.md b/docs/reference.md index c4591a868..05858c510 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -453,6 +453,69 @@ ARGUMENTS validates all schemas in '.actor/actor.json'. ``` +##### `apify runtime` + +```sh +DESCRIPTION + Manages the Actor runtime, a self-contained local Apify platform running as a + Docker container. + +SUBCOMMANDS + runtime install Installs the Actor runtime: verifies this + machine can run Docker images and downloads the Actor runtime + Docker image ('actor-runtime:latest'). + runtime start Starts the Actor runtime, a local Apify + platform running as a Docker container. + runtime stop Stops the Actor runtime container started with + 'apify runtime start --detach'. +``` + +##### `apify runtime install` + +```sh +DESCRIPTION + Installs the Actor runtime: verifies this machine can run Docker images and + downloads the Actor runtime Docker image ('actor-runtime:latest'). + +USAGE + $ apify runtime install [-f] + +FLAGS + -f, --force Download the Actor runtime image even when it is already + available locally. +``` + +##### `apify runtime start` + +```sh +DESCRIPTION + Starts the Actor runtime, a local Apify platform running as a Docker + container. + Installs the runtime first when needed (like 'apify runtime install'). The + runtime API listens on http://localhost:3333 and the console on + http://localhost:3000. + +USAGE + $ apify runtime start [--data-dir ] [-d] + +FLAGS + --data-dir= Host directory mounted as the runtime + data directory (storages, builds and run records). + Defaults to ~/.apify/actor-runtime/data. + -d, --detach Run the runtime container in the + background. Stop it with 'apify runtime stop'. +``` + +##### `apify runtime stop` + +```sh +DESCRIPTION + Stops the Actor runtime container started with 'apify runtime start --detach'. + +USAGE + $ apify runtime stop +``` + ##### `apify actor` ```sh diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index d1e22bb0b..e94dee85e 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -27,6 +27,11 @@ const categories: Record = { { command: Commands.run }, { command: Commands.validateSchema }, + { command: Commands.runtime }, + { command: Commands.runtimeInstall }, + { command: Commands.runtimeStart }, + { command: Commands.runtimeStop }, + { command: Commands.actor }, { command: Commands.actorCalculateMemory }, { command: Commands.actorCharge }, diff --git a/src/commands/_register.ts b/src/commands/_register.ts index b1cce10ce..eef6aa7cb 100644 --- a/src/commands/_register.ts +++ b/src/commands/_register.ts @@ -31,6 +31,7 @@ import { ToplevelPushCommand } from './push.js'; import { RequestQueuesIndexCommand } from './request-queues/_index.js'; import { RunCommand } from './run.js'; import { RunsIndexCommand } from './runs/_index.js'; +import { RuntimeIndexCommand } from './runtime/_index.js'; import { SecretsIndexCommand } from './secrets/_index.js'; import { TasksIndexCommand } from './task/_index.js'; import { TelemetryIndexCommand } from './telemetry/_index.js'; @@ -48,6 +49,7 @@ export const apifyCommands = [ MCPIndexCommand, RequestQueuesIndexCommand, RunsIndexCommand, + RuntimeIndexCommand, SecretsIndexCommand, TasksIndexCommand, TelemetryIndexCommand, diff --git a/src/commands/runtime/_index.ts b/src/commands/runtime/_index.ts new file mode 100644 index 000000000..4d4a52000 --- /dev/null +++ b/src/commands/runtime/_index.ts @@ -0,0 +1,21 @@ +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { RuntimeInstallCommand } from './install.js'; +import { RuntimeStartCommand } from './start.js'; +import { RuntimeStopCommand } from './stop.js'; + +export class RuntimeIndexCommand extends ApifyCommand { + static override name = 'runtime' as const; + + static override description = + 'Manages the Actor runtime, a self-contained local Apify platform running as a Docker container.'; + + static override group = 'Local Actor Development'; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-runtime'; + + static override subcommands = [RuntimeInstallCommand, RuntimeStartCommand, RuntimeStopCommand]; + + async run() { + this.printHelp(); + } +} diff --git a/src/commands/runtime/install.ts b/src/commands/runtime/install.ts new file mode 100644 index 000000000..59b5ea7a5 --- /dev/null +++ b/src/commands/runtime/install.ts @@ -0,0 +1,42 @@ +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Flags } from '../../lib/command-framework/flags.js'; +import { simpleLog, success } from '../../lib/outputs.js'; +import { ACTOR_RUNTIME_IMAGE } from '../../lib/runtime/docker.js'; +import { ensureActorRuntimeImage } from '../../lib/runtime/ensure.js'; + +export class RuntimeInstallCommand extends ApifyCommand { + static override name = 'install' as const; + + static override description = `Installs the Actor runtime: verifies this machine can run Docker images and downloads the Actor runtime Docker image ('${ACTOR_RUNTIME_IMAGE}').`; + + static override group = 'Local Actor Development'; + + static override examples = [ + { + description: 'Install the Actor runtime.', + command: 'apify runtime install', + }, + { + description: 'Re-download the Actor runtime image even if it is already present.', + command: 'apify runtime install --force', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-runtime-install'; + + static override flags = { + force: Flags.boolean({ + char: 'f', + description: 'Download the Actor runtime image even when it is already available locally.', + default: false, + }), + }; + + async run() { + const installed = await ensureActorRuntimeImage({ forcePull: this.flags.force }); + if (!installed) return; + + success({ message: 'Actor runtime is installed.' }); + simpleLog({ message: `Start it with 'apify runtime start'.` }); + } +} diff --git a/src/commands/runtime/start.ts b/src/commands/runtime/start.ts new file mode 100644 index 000000000..9e63bbdca --- /dev/null +++ b/src/commands/runtime/start.ts @@ -0,0 +1,123 @@ +import { mkdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import process from 'node:process'; + +import chalk from 'chalk'; +import { execa, type ExecaError } from 'execa'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Flags } from '../../lib/command-framework/flags.js'; +import { GLOBAL_CONFIGS_FOLDER, INTERRUPT_SIGNALS } from '../../lib/consts.js'; +import { error, info, run } from '../../lib/outputs.js'; +import { + ACTOR_RUNTIME_API_PORT, + ACTOR_RUNTIME_CONSOLE_PORT, + ACTOR_RUNTIME_CONTAINER_NAME, + buildRuntimeRunArgs, + isRuntimeContainerRunning, +} from '../../lib/runtime/docker.js'; +import { ensureActorRuntimeImage } from '../../lib/runtime/ensure.js'; + +const defaultDataDir = () => join(GLOBAL_CONFIGS_FOLDER(), 'actor-runtime', 'data'); + +export class RuntimeStartCommand extends ApifyCommand { + static override name = 'start' as const; + + static override description = + `Starts the Actor runtime, a local Apify platform running as a Docker container.\n` + + `Installs the runtime first when needed (like 'apify runtime install'). The runtime API listens on ` + + `http://localhost:${ACTOR_RUNTIME_API_PORT} and the console on http://localhost:${ACTOR_RUNTIME_CONSOLE_PORT}.`; + + static override group = 'Local Actor Development'; + + static override examples = [ + { + description: 'Start the Actor runtime in the foreground (Ctrl+C stops it).', + command: 'apify runtime start', + }, + { + description: 'Start the Actor runtime in the background.', + command: 'apify runtime start --detach', + }, + { + description: 'Start with runtime data stored in a custom directory.', + command: 'apify runtime start --data-dir ./data', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-runtime-start'; + + static override flags = { + 'data-dir': Flags.string({ + description: + 'Host directory mounted as the runtime data directory (storages, builds and run records). Defaults to ~/.apify/actor-runtime/data.', + }), + detach: Flags.boolean({ + char: 'd', + description: `Run the runtime container in the background. Stop it with 'apify runtime stop'.`, + default: false, + }), + }; + + async run() { + if (await isRuntimeContainerRunning()) { + error({ + message: `The Actor runtime is already running (container '${ACTOR_RUNTIME_CONTAINER_NAME}'). Stop it with 'apify runtime stop' first.`, + }); + process.exitCode = 1; + return; + } + + if (!(await ensureActorRuntimeImage())) return; + + const dataDir = resolve(this.flags.dataDir ?? defaultDataDir()); + await mkdir(dataDir, { recursive: true }); + + info({ + message: [ + `Starting the Actor runtime (data directory: ${dataDir})...`, + '', + ` API: http://localhost:${ACTOR_RUNTIME_API_PORT}`, + ` Console: http://localhost:${ACTOR_RUNTIME_CONSOLE_PORT}`, + '', + 'Point the Apify CLI at the runtime with:', + chalk.white.bold(` export APIFY_CLIENT_BASE_URL=http://localhost:${ACTOR_RUNTIME_API_PORT}`), + chalk.white.bold(` export APIFY_CONSOLE_URL=http://localhost:${ACTOR_RUNTIME_CONSOLE_PORT}`), + ].join('\n'), + }); + + // Spawned without a shell so interrupt signals reach 'docker run' directly instead of dying in 'sh -c'. + const args = buildRuntimeRunArgs({ dataDir, detach: this.flags.detach }); + run({ message: `docker ${args.join(' ')}` }); + + const child = execa('docker', args, { stdio: 'inherit' }); + + let interrupted = false; + const cleanupSignalHandlers = INTERRUPT_SIGNALS.map((signal) => { + const handler = () => { + interrupted = true; + child.kill(signal); + }; + process.on(signal, handler); + return () => process.off(signal, handler); + }); + + try { + await child; + } catch (err) { + if (!interrupted) { + error({ message: `The Actor runtime exited with an error: ${(err as ExecaError).shortMessage ?? err}` }); + process.exitCode = 1; + return; + } + } finally { + for (const cleanup of cleanupSignalHandlers) cleanup(); + } + + if (this.flags.detach) { + info({ + message: `The Actor runtime is running in the background. Stop it with 'apify runtime stop'.`, + }); + } + } +} diff --git a/src/commands/runtime/stop.ts b/src/commands/runtime/stop.ts new file mode 100644 index 000000000..bcf91609b --- /dev/null +++ b/src/commands/runtime/stop.ts @@ -0,0 +1,39 @@ +import process from 'node:process'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { execWithLog } from '../../lib/exec.js'; +import { info, success } from '../../lib/outputs.js'; +import { ACTOR_RUNTIME_CONTAINER_NAME, isRuntimeContainerRunning } from '../../lib/runtime/docker.js'; + +export class RuntimeStopCommand extends ApifyCommand { + static override name = 'stop' as const; + + static override description = `Stops the Actor runtime container started with 'apify runtime start --detach'.`; + + static override group = 'Local Actor Development'; + + static override examples = [ + { + description: 'Stop the running Actor runtime.', + command: 'apify runtime stop', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-runtime-stop'; + + async run() { + if (!(await isRuntimeContainerRunning())) { + info({ message: 'The Actor runtime is not running.' }); + return; + } + + try { + await execWithLog({ cmd: 'docker', args: ['stop', ACTOR_RUNTIME_CONTAINER_NAME] }); + } catch { + process.exitCode = 1; + return; + } + + success({ message: 'The Actor runtime was stopped.' }); + } +} diff --git a/src/lib/runtime/docker.ts b/src/lib/runtime/docker.ts new file mode 100644 index 000000000..c334cca1c --- /dev/null +++ b/src/lib/runtime/docker.ts @@ -0,0 +1,108 @@ +import process from 'node:process'; + +import { execa } from 'execa'; +import which from 'which'; + +// TODO: replace with the published image on Apify's Docker Hub (e.g. 'apify/actor-runtime') +// once it is available. Until then this is a local placeholder built from the actor-runtime repo. +export const ACTOR_RUNTIME_IMAGE = 'actor-runtime:latest'; + +export const ACTOR_RUNTIME_CONTAINER_NAME = 'apify-actor-runtime'; + +export const ACTOR_RUNTIME_API_PORT = 3333; + +export const ACTOR_RUNTIME_CONSOLE_PORT = 3000; + +export async function findDockerExecutable(): Promise { + return which('docker', { nothrow: true }); +} + +export function dockerInstallHint(platform: NodeJS.Platform = process.platform): string { + switch (platform) { + case 'darwin': + return 'Install Docker Desktop for Mac: https://docs.docker.com/desktop/setup/install/mac-install/'; + case 'win32': + return 'Install Docker Desktop for Windows (WSL 2 backend): https://docs.docker.com/desktop/setup/install/windows-install/'; + default: + return 'Install Docker Engine: https://docs.docker.com/engine/install/'; + } +} + +export function dockerDaemonHint(platform: NodeJS.Platform = process.platform): string { + switch (platform) { + case 'darwin': + case 'win32': + return 'Start Docker Desktop and wait until it reports "Docker Desktop is running".'; + default: + return `Start the Docker daemon, e.g. 'sudo systemctl start docker'.`; + } +} + +export async function isDockerDaemonRunning(): Promise { + try { + await execa('docker', ['info', '--format', '{{.ServerVersion}}']); + return true; + } catch { + return false; + } +} + +export async function imageExistsLocally(image: string): Promise { + try { + await execa('docker', ['image', 'inspect', image]); + return true; + } catch { + return false; + } +} + +export async function isRuntimeContainerRunning(): Promise { + try { + const { stdout } = await execa('docker', [ + 'ps', + '--filter', + `name=^${ACTOR_RUNTIME_CONTAINER_NAME}$`, + '--format', + '{{.Names}}', + ]); + return stdout.trim().length > 0; + } catch { + return false; + } +} + +export function dockerSocketMount(platform: NodeJS.Platform = process.platform): string { + // Docker Desktop on Windows exposes the Linux engine's socket to containers under the same + // path; the leading double slash prevents MSYS/Git Bash shells from mangling it. + const hostSocket = platform === 'win32' ? '//var/run/docker.sock' : '/var/run/docker.sock'; + return `${hostSocket}:/var/run/docker.sock`; +} + +export interface RuntimeRunArgsOptions { + dataDir: string; + detach: boolean; + platform?: NodeJS.Platform; +} + +export function buildRuntimeRunArgs({ dataDir, detach, platform = process.platform }: RuntimeRunArgsOptions): string[] { + // --init makes signals (Ctrl+C) reach the runtime process even though it runs as the container's PID 1. + const args = ['run', '--rm', '--init', '--name', ACTOR_RUNTIME_CONTAINER_NAME]; + + if (detach) { + args.push('--detach'); + } + + args.push( + '-p', + `${ACTOR_RUNTIME_API_PORT}:${ACTOR_RUNTIME_API_PORT}`, + '-p', + `${ACTOR_RUNTIME_CONSOLE_PORT}:${ACTOR_RUNTIME_CONSOLE_PORT}`, + '-v', + dockerSocketMount(platform), + '-v', + `${dataDir}:/data`, + ACTOR_RUNTIME_IMAGE, + ); + + return args; +} diff --git a/src/lib/runtime/ensure.ts b/src/lib/runtime/ensure.ts new file mode 100644 index 000000000..e09faaee1 --- /dev/null +++ b/src/lib/runtime/ensure.ts @@ -0,0 +1,64 @@ +import process from 'node:process'; + +import chalk from 'chalk'; + +import { execWithLog } from '../exec.js'; +import { error, info } from '../outputs.js'; +import { + ACTOR_RUNTIME_IMAGE, + dockerDaemonHint, + dockerInstallHint, + findDockerExecutable, + imageExistsLocally, + isDockerDaemonRunning, +} from './docker.js'; + +export interface EnsureActorRuntimeImageOptions { + forcePull?: boolean; +} + +/** + * Verifies this machine can run Docker images and makes the Actor runtime image available locally. + * Prints a user-facing error and sets the exit code when something is missing. + */ +export async function ensureActorRuntimeImage({ + forcePull = false, +}: EnsureActorRuntimeImageOptions = {}): Promise { + if (!(await findDockerExecutable())) { + error({ + message: `Docker is required to run the Actor runtime, but the 'docker' command was not found.\n ${dockerInstallHint()}`, + }); + process.exitCode = 1; + return false; + } + + if (!(await isDockerDaemonRunning())) { + error({ + message: `Docker is installed, but the Docker daemon is not running or not reachable.\n ${dockerDaemonHint()}`, + }); + process.exitCode = 1; + return false; + } + + if (!forcePull && (await imageExistsLocally(ACTOR_RUNTIME_IMAGE))) { + info({ message: `Actor runtime image '${ACTOR_RUNTIME_IMAGE}' is already available locally.` }); + return true; + } + + info({ message: `Downloading the Actor runtime image '${ACTOR_RUNTIME_IMAGE}'...` }); + + try { + await execWithLog({ cmd: 'docker', args: ['pull', ACTOR_RUNTIME_IMAGE] }); + return true; + } catch { + error({ + message: [ + `Could not pull '${ACTOR_RUNTIME_IMAGE}'. The image is not published to a registry yet.`, + 'Until it is, build it locally from your actor-runtime checkout:', + chalk.white.bold(` docker build -t ${ACTOR_RUNTIME_IMAGE} .`), + ].join('\n'), + }); + process.exitCode = 1; + return false; + } +} diff --git a/test/local/lib/runtime-docker.test.ts b/test/local/lib/runtime-docker.test.ts new file mode 100644 index 000000000..2a1a396c0 --- /dev/null +++ b/test/local/lib/runtime-docker.test.ts @@ -0,0 +1,62 @@ +import { + ACTOR_RUNTIME_CONTAINER_NAME, + ACTOR_RUNTIME_IMAGE, + buildRuntimeRunArgs, + dockerDaemonHint, + dockerInstallHint, + dockerSocketMount, +} from '../../../src/lib/runtime/docker.js'; + +describe('runtime/docker', () => { + describe('dockerSocketMount()', () => { + it('uses the plain socket path on Linux and macOS', () => { + expect(dockerSocketMount('linux')).toBe('/var/run/docker.sock:/var/run/docker.sock'); + expect(dockerSocketMount('darwin')).toBe('/var/run/docker.sock:/var/run/docker.sock'); + }); + + it('doubles the leading slash on Windows to prevent path mangling', () => { + expect(dockerSocketMount('win32')).toBe('//var/run/docker.sock:/var/run/docker.sock'); + }); + }); + + describe('install and daemon hints', () => { + it('points each platform at the right Docker distribution', () => { + expect(dockerInstallHint('darwin')).toContain('Docker Desktop for Mac'); + expect(dockerInstallHint('win32')).toContain('Docker Desktop for Windows'); + expect(dockerInstallHint('linux')).toContain('Docker Engine'); + }); + + it('tells desktop users to start Docker Desktop and Linux users to start the daemon', () => { + expect(dockerDaemonHint('darwin')).toContain('Docker Desktop'); + expect(dockerDaemonHint('win32')).toContain('Docker Desktop'); + expect(dockerDaemonHint('linux')).toContain('systemctl start docker'); + }); + }); + + describe('buildRuntimeRunArgs()', () => { + it('builds the canonical docker run command', () => { + expect(buildRuntimeRunArgs({ dataDir: '/home/me/data', detach: false, platform: 'linux' })).toEqual([ + 'run', + '--rm', + '--init', + '--name', + ACTOR_RUNTIME_CONTAINER_NAME, + '-p', + '3333:3333', + '-p', + '3000:3000', + '-v', + '/var/run/docker.sock:/var/run/docker.sock', + '-v', + '/home/me/data:/data', + ACTOR_RUNTIME_IMAGE, + ]); + }); + + it('adds --detach before the image when requested', () => { + const args = buildRuntimeRunArgs({ dataDir: '/data', detach: true, platform: 'linux' }); + expect(args).toContain('--detach'); + expect(args.indexOf('--detach')).toBeLessThan(args.indexOf(ACTOR_RUNTIME_IMAGE)); + }); + }); +}); From b1bc9a1bbe220b84e2a4b2cf3b44a80f3d1a7452 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:44:47 +0000 Subject: [PATCH 02/12] feat: pull the Actor runtime image from josefprochazka/actor-runtime-dev Points the runtime image at the development repository on Docker Hub, so 'apify runtime install' downloads it instead of expecting a locally built placeholder. Reworks the pull-failure hint accordingly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Mpiku6d313JRBVoTVKNCXv --- docs/reference.md | 5 +++-- src/lib/runtime/docker.ts | 4 ++-- src/lib/runtime/ensure.ts | 7 ++++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 05858c510..ebf6472c6 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -463,7 +463,7 @@ DESCRIPTION SUBCOMMANDS runtime install Installs the Actor runtime: verifies this machine can run Docker images and downloads the Actor runtime - Docker image ('actor-runtime:latest'). + Docker image ('josefprochazka/actor-runtime-dev:latest'). runtime start Starts the Actor runtime, a local Apify platform running as a Docker container. runtime stop Stops the Actor runtime container started with @@ -475,7 +475,8 @@ SUBCOMMANDS ```sh DESCRIPTION Installs the Actor runtime: verifies this machine can run Docker images and - downloads the Actor runtime Docker image ('actor-runtime:latest'). + downloads the Actor runtime Docker image + ('josefprochazka/actor-runtime-dev:latest'). USAGE $ apify runtime install [-f] diff --git a/src/lib/runtime/docker.ts b/src/lib/runtime/docker.ts index c334cca1c..c7dc191ac 100644 --- a/src/lib/runtime/docker.ts +++ b/src/lib/runtime/docker.ts @@ -4,8 +4,8 @@ import { execa } from 'execa'; import which from 'which'; // TODO: replace with the published image on Apify's Docker Hub (e.g. 'apify/actor-runtime') -// once it is available. Until then this is a local placeholder built from the actor-runtime repo. -export const ACTOR_RUNTIME_IMAGE = 'actor-runtime:latest'; +// once it is available. Until then the runtime ships from a development repository. +export const ACTOR_RUNTIME_IMAGE = 'josefprochazka/actor-runtime-dev:latest'; export const ACTOR_RUNTIME_CONTAINER_NAME = 'apify-actor-runtime'; diff --git a/src/lib/runtime/ensure.ts b/src/lib/runtime/ensure.ts index e09faaee1..c79466ffc 100644 --- a/src/lib/runtime/ensure.ts +++ b/src/lib/runtime/ensure.ts @@ -53,9 +53,10 @@ export async function ensureActorRuntimeImage({ } catch { error({ message: [ - `Could not pull '${ACTOR_RUNTIME_IMAGE}'. The image is not published to a registry yet.`, - 'Until it is, build it locally from your actor-runtime checkout:', - chalk.white.bold(` docker build -t ${ACTOR_RUNTIME_IMAGE} .`), + `Could not pull '${ACTOR_RUNTIME_IMAGE}'.`, + ` Check that you are online and can access the image - a private repository needs ${chalk.white.bold('docker login')} first.`, + ' You can also build the image locally from an actor-runtime checkout instead:', + chalk.white.bold(` docker build -t ${ACTOR_RUNTIME_IMAGE} .`), ].join('\n'), }); process.exitCode = 1; From dd655968a576df0338cad753ba1f009c11c8eebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Proch=C3=A1zka?= Date: Tue, 8 Sep 2026 10:54:56 +0200 Subject: [PATCH 03/12] docs: Update actor runtime help and agent instructions (#1401) Co-authored-by: Claude --- docs/reference.md | 30 +++++++++- skills/apify/SKILL.md | 57 +++++++++++++++++++ src/commands/runtime/_index.ts | 45 ++++++++++++++- src/commands/runtime/install.ts | 6 +- src/commands/runtime/start.ts | 15 ++--- src/lib/runtime/docker.ts | 23 ++++++++ test/local/lib/command-framework/help.test.ts | 30 ++++++++++ 7 files changed, 194 insertions(+), 12 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index ebf6472c6..e0b8e6404 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -460,6 +460,31 @@ DESCRIPTION Manages the Actor runtime, a self-contained local Apify platform running as a Docker container. + Prerequisite: Docker must be installed and running. Follow the official Docker + documentation to set it up: + + Docker Desktop (macOS, Windows, Linux desktop): + https://docs.docker.com/get-started/get-docker/ + Docker Engine (Linux servers, headless): + https://docs.docker.com/engine/install/ + + 'apify runtime install' checks that Docker is available and pulls the runtime + image. + + The runtime publishes two ports on localhost: + + 3333 API http://localhost:3333 (Apify API compatible endpoint) + 3000 Console http://localhost:3000 (web UI) + + Point the Apify CLI (and Apify SDKs and API clients that honour these + variables) at the runtime instead of the Apify cloud by setting: + + export APIFY_CLIENT_BASE_URL=http://localhost:3333 + export APIFY_CONSOLE_URL=http://localhost:3000 + + Unset them to talk to the Apify cloud again. 'apify runtime start' prints the + same values when the runtime boots. + SUBCOMMANDS runtime install Installs the Actor runtime: verifies this machine can run Docker images and downloads the Actor runtime @@ -477,6 +502,8 @@ DESCRIPTION Installs the Actor runtime: verifies this machine can run Docker images and downloads the Actor runtime Docker image ('josefprochazka/actor-runtime-dev:latest'). + Docker itself is a prerequisite and is not installed by this command - see + https://docs.docker.com/get-started/get-docker/. USAGE $ apify runtime install [-f] @@ -494,7 +521,8 @@ DESCRIPTION container. Installs the runtime first when needed (like 'apify runtime install'). The runtime API listens on http://localhost:3333 and the console on - http://localhost:3000. + http://localhost:3000. Run 'apify runtime -h' for the environment variables + that point the CLI at it. USAGE $ apify runtime start [--data-dir ] [-d] diff --git a/skills/apify/SKILL.md b/skills/apify/SKILL.md index 4634b988a..dffc96327 100644 --- a/skills/apify/SKILL.md +++ b/skills/apify/SKILL.md @@ -87,6 +87,63 @@ apify key-value-stores set-value apify key-value-stores keys --json ``` +## Local Actor runtime + +`apify runtime` runs a self-contained local Apify platform as a Docker container. Use it to develop and test Actors against a platform-compatible API without touching the user's cloud account. It ships on the `runtime` npm dist-tag, not on `latest`. + +**Prerequisite: Docker.** Docker must be installed and the daemon running before any `apify runtime` command works. The CLI does not install Docker. Check with `docker info` and act on what it tells you: + +- `docker info` succeeds - you are ready. +- It fails with "Cannot connect to the Docker daemon" (or similar) - Docker is installed but not running. Do not reinstall it; start the daemon: + - Docker Desktop (macOS, Windows): start the Docker Desktop app and wait until it reports running. + - Linux with systemd: `sudo systemctl start docker`. + - Sandboxes and containers without systemd (common for agent environments): `dockerd` is usually present but nothing starts it. iptables and IP forwarding are often unavailable there, so start it without them, then poll until the daemon answers: + + ```sh + if ! docker info >/dev/null 2>&1; then + nohup dockerd --iptables=false --ip6tables=false > dockerd.log 2>&1 & + until docker info >/dev/null 2>&1; do sleep 1; done + fi + ``` + + Keep the default bridge network - `apify runtime start` publishes ports 3333 and 3000 with `-p`, which needs it. If `docker info` never succeeds, read `dockerd.log` before trying anything else. +- The `docker` command is missing - installation differs per OS and can need admin rights, so do not improvise it. Point the user at the official Docker docs and let them pick the right path: + - Docker Desktop (macOS, Windows, Linux desktop): https://docs.docker.com/get-started/get-docker/ + - Docker Engine (Linux servers, headless): https://docs.docker.com/engine/install/ + +`apify runtime install` runs the same Docker checks and prints a platform-specific hint when something is missing. + +**Working directory.** Install the preview CLI locally in one dedicated directory rather than globally, so it cannot replace the user's stable `apify` install. Keep the runtime data and the Actor projects you create in the same directory - everything the session produced is then in one place and easy to clean up: + +```sh +mkdir -p apify-runtime-work && cd apify-runtime-work +npm init -y >/dev/null && npm i apify-cli@runtime +APIFY=./node_modules/.bin/apify # use $APIFY for every command below +``` + +**Start it and point the CLI at it.** The runtime publishes two ports on `localhost`. Export these variables in the shell you drive the CLI from (they are the same values `apify runtime -h` and `apify runtime start` print): + +| Port | Service | Environment variable | Value | +| ---- | ------- | -------------------- | ----- | +| 3333 | API (Apify API compatible) | `APIFY_CLIENT_BASE_URL` | `http://localhost:3333` | +| 3000 | Console (web UI) | `APIFY_CONSOLE_URL` | `http://localhost:3000` | + +```sh +export APIFY_CLIENT_BASE_URL=http://localhost:3333 +export APIFY_CONSOLE_URL=http://localhost:3000 +export APIFY_DISABLE_KEYRING=1 + +$APIFY runtime install +$APIFY runtime start --detach --data-dir ./runtime-data # omit --detach to run in the foreground (Ctrl+C stops it) +$APIFY login --token local-dev-token # the runtime accepts any token +$APIFY actors ls --json # now talks to the local runtime +$APIFY runtime stop +``` + +`APIFY_DISABLE_KEYRING=1` makes `apify login` store the token in `~/.apify/auth.json` instead of the OS keyring. Set it for agent flows: sandboxes rarely have a keyring, and the runtime token is a throwaway placeholder anyway, so there is nothing worth protecting. Note that this login still replaces whatever credentials `~/.apify/auth.json` held - fine in a throwaway sandbox, but on a developer's machine ask first or have the user run `apify login` with their real token afterwards. + +Every `apify` command in that shell (`push`, `call`, `actors`, `datasets`, `api`, ...) then targets the runtime. Unset the variables (or start a new shell) to talk to the Apify cloud again. Do not set them globally for the user without asking - they silently redirect all API traffic. + ## Scheduling and recurring runs For anything recurring or unattended (e.g. "run every 15 minutes"), use the Apify platform — **not** local `cron`, a `while` loop, or GitHub Actions. Apify Schedules run in the cloud, so they keep firing after your laptop, terminal, or agent session is shut down. diff --git a/src/commands/runtime/_index.ts b/src/commands/runtime/_index.ts index 4d4a52000..b9173a923 100644 --- a/src/commands/runtime/_index.ts +++ b/src/commands/runtime/_index.ts @@ -1,4 +1,13 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { + ACTOR_RUNTIME_API_PORT, + ACTOR_RUNTIME_API_URL, + ACTOR_RUNTIME_CONSOLE_PORT, + ACTOR_RUNTIME_CONSOLE_URL, + DOCKER_ENGINE_INSTALL_URL, + DOCKER_GET_DOCKER_URL, + runtimeEnvExportLines, +} from '../../lib/runtime/docker.js'; import { RuntimeInstallCommand } from './install.js'; import { RuntimeStartCommand } from './start.js'; import { RuntimeStopCommand } from './stop.js'; @@ -6,11 +15,43 @@ import { RuntimeStopCommand } from './stop.js'; export class RuntimeIndexCommand extends ApifyCommand { static override name = 'runtime' as const; - static override description = - 'Manages the Actor runtime, a self-contained local Apify platform running as a Docker container.'; + static override description = [ + 'Manages the Actor runtime, a self-contained local Apify platform running as a Docker container.', + '', + 'Prerequisite: Docker must be installed and running. Follow the official Docker documentation to set it up:', + '', + ' Docker Desktop (macOS, Windows, Linux desktop):', + ` ${DOCKER_GET_DOCKER_URL}`, + ' Docker Engine (Linux servers, headless):', + ` ${DOCKER_ENGINE_INSTALL_URL}`, + '', + `'apify runtime install' checks that Docker is available and pulls the runtime image.`, + '', + 'The runtime publishes two ports on localhost:', + '', + ` ${String(ACTOR_RUNTIME_API_PORT).padEnd(5)} API ${ACTOR_RUNTIME_API_URL} (Apify API compatible endpoint)`, + ` ${String(ACTOR_RUNTIME_CONSOLE_PORT).padEnd(5)} Console ${ACTOR_RUNTIME_CONSOLE_URL} (web UI)`, + '', + 'Point the Apify CLI (and Apify SDKs and API clients that honour these variables) at the runtime instead of the Apify cloud by setting:', + '', + ...runtimeEnvExportLines().map((line) => ` ${line}`), + '', + `Unset them to talk to the Apify cloud again. 'apify runtime start' prints the same values when the runtime boots.`, + ].join('\n'); static override group = 'Local Actor Development'; + static override examples = [ + { + description: 'Start the runtime in the background.', + command: 'apify runtime start --detach', + }, + { + description: 'Point the CLI at the runtime and list Actors it knows about.', + command: `APIFY_CLIENT_BASE_URL=${ACTOR_RUNTIME_API_URL} apify actors ls`, + }, + ]; + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-runtime'; static override subcommands = [RuntimeInstallCommand, RuntimeStartCommand, RuntimeStopCommand]; diff --git a/src/commands/runtime/install.ts b/src/commands/runtime/install.ts index 59b5ea7a5..8ac947b3a 100644 --- a/src/commands/runtime/install.ts +++ b/src/commands/runtime/install.ts @@ -1,13 +1,15 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { simpleLog, success } from '../../lib/outputs.js'; -import { ACTOR_RUNTIME_IMAGE } from '../../lib/runtime/docker.js'; +import { ACTOR_RUNTIME_IMAGE, DOCKER_GET_DOCKER_URL } from '../../lib/runtime/docker.js'; import { ensureActorRuntimeImage } from '../../lib/runtime/ensure.js'; export class RuntimeInstallCommand extends ApifyCommand { static override name = 'install' as const; - static override description = `Installs the Actor runtime: verifies this machine can run Docker images and downloads the Actor runtime Docker image ('${ACTOR_RUNTIME_IMAGE}').`; + static override description = + `Installs the Actor runtime: verifies this machine can run Docker images and downloads the Actor runtime Docker image ('${ACTOR_RUNTIME_IMAGE}').\n` + + `Docker itself is a prerequisite and is not installed by this command - see ${DOCKER_GET_DOCKER_URL}.`; static override group = 'Local Actor Development'; diff --git a/src/commands/runtime/start.ts b/src/commands/runtime/start.ts index 9e63bbdca..5b1ddcdbb 100644 --- a/src/commands/runtime/start.ts +++ b/src/commands/runtime/start.ts @@ -10,11 +10,12 @@ import { Flags } from '../../lib/command-framework/flags.js'; import { GLOBAL_CONFIGS_FOLDER, INTERRUPT_SIGNALS } from '../../lib/consts.js'; import { error, info, run } from '../../lib/outputs.js'; import { - ACTOR_RUNTIME_API_PORT, - ACTOR_RUNTIME_CONSOLE_PORT, + ACTOR_RUNTIME_API_URL, + ACTOR_RUNTIME_CONSOLE_URL, ACTOR_RUNTIME_CONTAINER_NAME, buildRuntimeRunArgs, isRuntimeContainerRunning, + runtimeEnvExportLines, } from '../../lib/runtime/docker.js'; import { ensureActorRuntimeImage } from '../../lib/runtime/ensure.js'; @@ -26,7 +27,8 @@ export class RuntimeStartCommand extends ApifyCommand chalk.white.bold(` ${line}`)), ].join('\n'), }); diff --git a/src/lib/runtime/docker.ts b/src/lib/runtime/docker.ts index c7dc191ac..e8a9d7aed 100644 --- a/src/lib/runtime/docker.ts +++ b/src/lib/runtime/docker.ts @@ -9,10 +9,33 @@ export const ACTOR_RUNTIME_IMAGE = 'josefprochazka/actor-runtime-dev:latest'; export const ACTOR_RUNTIME_CONTAINER_NAME = 'apify-actor-runtime'; +/** Official Docker documentation: Docker Desktop for macOS, Windows and Linux desktops. */ +export const DOCKER_GET_DOCKER_URL = 'https://docs.docker.com/get-started/get-docker/'; + +/** Official Docker documentation: Docker Engine (server/headless Linux installs). */ +export const DOCKER_ENGINE_INSTALL_URL = 'https://docs.docker.com/engine/install/'; + export const ACTOR_RUNTIME_API_PORT = 3333; export const ACTOR_RUNTIME_CONSOLE_PORT = 3000; +export const ACTOR_RUNTIME_API_URL = `http://localhost:${ACTOR_RUNTIME_API_PORT}`; + +export const ACTOR_RUNTIME_CONSOLE_URL = `http://localhost:${ACTOR_RUNTIME_CONSOLE_PORT}`; + +/** + * Environment variables that point the Apify CLI (and the Apify SDKs/clients that honour them) + * at a locally running Actor runtime instead of the Apify cloud. + */ +export const ACTOR_RUNTIME_ENV_VARS = { + APIFY_CLIENT_BASE_URL: ACTOR_RUNTIME_API_URL, + APIFY_CONSOLE_URL: ACTOR_RUNTIME_CONSOLE_URL, +} as const; + +export function runtimeEnvExportLines(): string[] { + return Object.entries(ACTOR_RUNTIME_ENV_VARS).map(([name, value]) => `export ${name}=${value}`); +} + export async function findDockerExecutable(): Promise { return which('docker', { nothrow: true }); } diff --git a/test/local/lib/command-framework/help.test.ts b/test/local/lib/command-framework/help.test.ts index 778f407b4..bdd98052f 100644 --- a/test/local/lib/command-framework/help.test.ts +++ b/test/local/lib/command-framework/help.test.ts @@ -1,6 +1,7 @@ /* eslint-disable max-classes-per-file */ import stripAnsi from 'strip-ansi'; +import { RuntimeIndexCommand } from '../../../../src/commands/runtime/_index.js'; import { ApifyCommand, type BuiltApifyCommand as _BuiltApifyCommand, @@ -11,6 +12,14 @@ import { renderHelpForCommand, renderMainHelpMenu, } from '../../../../src/lib/command-framework/help.js'; +import { + ACTOR_RUNTIME_API_PORT, + ACTOR_RUNTIME_API_URL, + ACTOR_RUNTIME_CONSOLE_PORT, + ACTOR_RUNTIME_CONSOLE_URL, + DOCKER_ENGINE_INSTALL_URL, + DOCKER_GET_DOCKER_URL, +} from '../../../../src/lib/runtime/docker.js'; const BuiltApifyCommand = ApifyCommand as typeof _BuiltApifyCommand; @@ -94,6 +103,7 @@ describe('Help rendering', () => { registerCommandForHelpGeneration('apify', FakeRunsIndex); registerCommandForHelpGeneration('apify', FakeUtility); registerCommandForHelpGeneration('apify', FakeInteractiveNamespace); + registerCommandForHelpGeneration('apify', RuntimeIndexCommand); // The `actor push-data` subcommand is registered twice with different entrypoints, // once as a subcommand of `apify actor` and once as a standalone `actor` command. @@ -161,6 +171,26 @@ describe('Help rendering', () => { }); }); + describe('apify runtime help', () => { + it('lists the runtime ports and the environment variables that point the CLI at them', () => { + const output = stripAnsi(renderHelpForCommand(RuntimeIndexCommand)); + + expect(output).toContain(`${ACTOR_RUNTIME_API_PORT}`); + expect(output).toContain(`${ACTOR_RUNTIME_CONSOLE_PORT}`); + expect(output).toContain(`export APIFY_CLIENT_BASE_URL=${ACTOR_RUNTIME_API_URL}`); + expect(output).toContain(`export APIFY_CONSOLE_URL=${ACTOR_RUNTIME_CONSOLE_URL}`); + expect(output).toContain('runtime start'); + }); + + it('names Docker as a prerequisite and links the official install docs', () => { + const output = stripAnsi(renderHelpForCommand(RuntimeIndexCommand)); + + expect(output).toContain('Prerequisite: Docker'); + expect(output).toContain(DOCKER_GET_DOCKER_URL); + expect(output).toContain(DOCKER_ENGINE_INSTALL_URL); + }); + }); + describe('Example command normalization by entrypoint', () => { class FakeActor extends BuiltApifyCommand { static override name = 'actor' as const; From f2e0e61b52b6271e39ad1fa5aecad66df9ccdc5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Proch=C3=A1zka?= Date: Wed, 9 Sep 2026 14:41:06 +0200 Subject: [PATCH 04/12] chore: Pull the Actor runtime image from apify/actor-runtime (#1406) Co-authored-by: Claude --- docs/reference.md | 5 ++--- src/lib/runtime/docker.ts | 4 +--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index e0b8e6404..7a674387d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -488,7 +488,7 @@ DESCRIPTION SUBCOMMANDS runtime install Installs the Actor runtime: verifies this machine can run Docker images and downloads the Actor runtime - Docker image ('josefprochazka/actor-runtime-dev:latest'). + Docker image ('apify/actor-runtime:latest'). runtime start Starts the Actor runtime, a local Apify platform running as a Docker container. runtime stop Stops the Actor runtime container started with @@ -500,8 +500,7 @@ SUBCOMMANDS ```sh DESCRIPTION Installs the Actor runtime: verifies this machine can run Docker images and - downloads the Actor runtime Docker image - ('josefprochazka/actor-runtime-dev:latest'). + downloads the Actor runtime Docker image ('apify/actor-runtime:latest'). Docker itself is a prerequisite and is not installed by this command - see https://docs.docker.com/get-started/get-docker/. diff --git a/src/lib/runtime/docker.ts b/src/lib/runtime/docker.ts index e8a9d7aed..a009033d8 100644 --- a/src/lib/runtime/docker.ts +++ b/src/lib/runtime/docker.ts @@ -3,9 +3,7 @@ import process from 'node:process'; import { execa } from 'execa'; import which from 'which'; -// TODO: replace with the published image on Apify's Docker Hub (e.g. 'apify/actor-runtime') -// once it is available. Until then the runtime ships from a development repository. -export const ACTOR_RUNTIME_IMAGE = 'josefprochazka/actor-runtime-dev:latest'; +export const ACTOR_RUNTIME_IMAGE = 'apify/actor-runtime:latest'; export const ACTOR_RUNTIME_CONTAINER_NAME = 'apify-actor-runtime'; From 0d511d42689c734c110f0b403ad7aa48dc6566c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Proch=C3=A1zka?= Date: Thu, 10 Sep 2026 14:56:54 +0200 Subject: [PATCH 05/12] feat: Add Podman compatibility for runtime (#1411) Update the CLI to accept both Docker or Podman. --------- Co-authored-by: Claude Fable 5.1 --- docs/reference.md | 37 ++++--- skills/apify/SKILL.md | 10 +- src/commands/runtime/_index.ts | 12 ++- src/commands/runtime/install.ts | 6 +- src/commands/runtime/start.ts | 19 ++-- src/commands/runtime/stop.ts | 7 +- src/lib/commands/run-on-cloud.ts | 5 +- src/lib/runtime/docker.ts | 136 +++++++++++++++++++++++--- src/lib/runtime/ensure.ts | 50 ++++++---- src/lib/utils.ts | 3 +- test/local/lib/runtime-docker.test.ts | 92 +++++++++++++---- 11 files changed, 290 insertions(+), 87 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 7a674387d..24bf9fa9d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -458,18 +458,24 @@ ARGUMENTS ```sh DESCRIPTION Manages the Actor runtime, a self-contained local Apify platform running as a - Docker container. + container on Docker or Podman. - Prerequisite: Docker must be installed and running. Follow the official Docker - documentation to set it up: + Prerequisite: Docker or Podman must be installed and running. Follow the + official documentation to set one up: Docker Desktop (macOS, Windows, Linux desktop): https://docs.docker.com/get-started/get-docker/ Docker Engine (Linux servers, headless): https://docs.docker.com/engine/install/ + Podman (rootful or rootless; its API socket must be served, e.g. via the + podman.socket systemd unit): + https://podman.io/docs/installation - 'apify runtime install' checks that Docker is available and pulls the runtime - image. + The first engine found on PATH is used, Docker before Podman. Set + APIFY_CONTAINER_ENGINE=docker or =podman to choose. + + 'apify runtime install' checks that the engine is available and pulls the + runtime image. The runtime publishes two ports on localhost: @@ -487,10 +493,11 @@ DESCRIPTION SUBCOMMANDS runtime install Installs the Actor runtime: verifies this - machine can run Docker images and downloads the Actor runtime - Docker image ('apify/actor-runtime:latest'). + machine has a working container engine (Docker or Podman) and + downloads the Actor runtime image + ('apify/actor-runtime:latest'). runtime start Starts the Actor runtime, a local Apify - platform running as a Docker container. + platform running as a container on Docker or Podman. runtime stop Stops the Actor runtime container started with 'apify runtime start --detach'. ``` @@ -499,10 +506,12 @@ SUBCOMMANDS ```sh DESCRIPTION - Installs the Actor runtime: verifies this machine can run Docker images and - downloads the Actor runtime Docker image ('apify/actor-runtime:latest'). - Docker itself is a prerequisite and is not installed by this command - see - https://docs.docker.com/get-started/get-docker/. + Installs the Actor runtime: verifies this machine has a working container + engine (Docker or Podman) and downloads the Actor runtime image + ('apify/actor-runtime:latest'). + The engine itself is a prerequisite and is not installed by this command - see + https://docs.docker.com/get-started/get-docker/ or + https://podman.io/docs/installation. USAGE $ apify runtime install [-f] @@ -516,8 +525,8 @@ FLAGS ```sh DESCRIPTION - Starts the Actor runtime, a local Apify platform running as a Docker - container. + Starts the Actor runtime, a local Apify platform running as a container on + Docker or Podman. Installs the runtime first when needed (like 'apify runtime install'). The runtime API listens on http://localhost:3333 and the console on http://localhost:3000. Run 'apify runtime -h' for the environment variables diff --git a/skills/apify/SKILL.md b/skills/apify/SKILL.md index dffc96327..943bb8a2b 100644 --- a/skills/apify/SKILL.md +++ b/skills/apify/SKILL.md @@ -89,9 +89,13 @@ apify key-value-stores keys --json ## Local Actor runtime -`apify runtime` runs a self-contained local Apify platform as a Docker container. Use it to develop and test Actors against a platform-compatible API without touching the user's cloud account. It ships on the `runtime` npm dist-tag, not on `latest`. +`apify runtime` runs a self-contained local Apify platform as a container on Docker or Podman. Use it to develop and test Actors against a platform-compatible API without touching the user's cloud account. It ships on the `runtime` npm dist-tag, not on `latest`. -**Prerequisite: Docker.** Docker must be installed and the daemon running before any `apify runtime` command works. The CLI does not install Docker. Check with `docker info` and act on what it tells you: +**Prerequisite: Docker or Podman.** One of them must be installed and running before any `apify runtime` command works; the CLI does not install either. It uses the first engine found on PATH (Docker before Podman); `APIFY_CONTAINER_ENGINE=podman` forces Podman. + +With Podman, the API socket must be served: check with `podman info --format '{{.Host.RemoteSocket.Exists}}'` (must print `true`). If it does not, run `systemctl --user enable --now podman.socket` (rootless) or `sudo systemctl enable --now podman.socket` (rootful); without systemd, `podman system service --time=0 &`. On macOS/Windows, `podman machine start` first. Rootful and rootless Podman both work. + +With Docker, check with `docker info` and act on what it tells you: - `docker info` succeeds - you are ready. - It fails with "Cannot connect to the Docker daemon" (or similar) - Docker is installed but not running. Do not reinstall it; start the daemon: @@ -111,7 +115,7 @@ apify key-value-stores keys --json - Docker Desktop (macOS, Windows, Linux desktop): https://docs.docker.com/get-started/get-docker/ - Docker Engine (Linux servers, headless): https://docs.docker.com/engine/install/ -`apify runtime install` runs the same Docker checks and prints a platform-specific hint when something is missing. +`apify runtime install` runs the same engine checks and prints a platform-specific hint when something is missing. **Working directory.** Install the preview CLI locally in one dedicated directory rather than globally, so it cannot replace the user's stable `apify` install. Keep the runtime data and the Actor projects you create in the same directory - everything the session produced is then in one place and easy to clean up: diff --git a/src/commands/runtime/_index.ts b/src/commands/runtime/_index.ts index b9173a923..07093b0dd 100644 --- a/src/commands/runtime/_index.ts +++ b/src/commands/runtime/_index.ts @@ -4,8 +4,10 @@ import { ACTOR_RUNTIME_API_URL, ACTOR_RUNTIME_CONSOLE_PORT, ACTOR_RUNTIME_CONSOLE_URL, + CONTAINER_ENGINE_ENV_VAR, DOCKER_ENGINE_INSTALL_URL, DOCKER_GET_DOCKER_URL, + PODMAN_INSTALL_URL, runtimeEnvExportLines, } from '../../lib/runtime/docker.js'; import { RuntimeInstallCommand } from './install.js'; @@ -16,16 +18,20 @@ export class RuntimeIndexCommand extends ApifyCommand { static override name = 'install' as const; static override description = - `Installs the Actor runtime: verifies this machine can run Docker images and downloads the Actor runtime Docker image ('${ACTOR_RUNTIME_IMAGE}').\n` + - `Docker itself is a prerequisite and is not installed by this command - see ${DOCKER_GET_DOCKER_URL}.`; + `Installs the Actor runtime: verifies this machine has a working container engine (Docker or Podman) and downloads the Actor runtime image ('${ACTOR_RUNTIME_IMAGE}').\n` + + `The engine itself is a prerequisite and is not installed by this command - see ${DOCKER_GET_DOCKER_URL} or ${PODMAN_INSTALL_URL}.`; static override group = 'Local Actor Development'; diff --git a/src/commands/runtime/start.ts b/src/commands/runtime/start.ts index 5b1ddcdbb..974c6881e 100644 --- a/src/commands/runtime/start.ts +++ b/src/commands/runtime/start.ts @@ -14,7 +14,8 @@ import { ACTOR_RUNTIME_CONSOLE_URL, ACTOR_RUNTIME_CONTAINER_NAME, buildRuntimeRunArgs, - isRuntimeContainerRunning, + findRunningRuntimeEngine, + resolveEngineSocketPath, runtimeEnvExportLines, } from '../../lib/runtime/docker.js'; import { ensureActorRuntimeImage } from '../../lib/runtime/ensure.js'; @@ -25,7 +26,7 @@ export class RuntimeStartCommand extends ApifyCommand { diff --git a/src/commands/runtime/stop.ts b/src/commands/runtime/stop.ts index bcf91609b..539b112c3 100644 --- a/src/commands/runtime/stop.ts +++ b/src/commands/runtime/stop.ts @@ -3,7 +3,7 @@ import process from 'node:process'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { execWithLog } from '../../lib/exec.js'; import { info, success } from '../../lib/outputs.js'; -import { ACTOR_RUNTIME_CONTAINER_NAME, isRuntimeContainerRunning } from '../../lib/runtime/docker.js'; +import { ACTOR_RUNTIME_CONTAINER_NAME, findRunningRuntimeEngine } from '../../lib/runtime/docker.js'; export class RuntimeStopCommand extends ApifyCommand { static override name = 'stop' as const; @@ -22,13 +22,14 @@ export class RuntimeStopCommand extends ApifyCommand static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-runtime-stop'; async run() { - if (!(await isRuntimeContainerRunning())) { + const engine = await findRunningRuntimeEngine(); + if (!engine) { info({ message: 'The Actor runtime is not running.' }); return; } try { - await execWithLog({ cmd: 'docker', args: ['stop', ACTOR_RUNTIME_CONTAINER_NAME] }); + await execWithLog({ cmd: engine, args: ['stop', ACTOR_RUNTIME_CONTAINER_NAME] }); } catch { process.exitCode = 1; return; diff --git a/src/lib/commands/run-on-cloud.ts b/src/lib/commands/run-on-cloud.ts index 8c421cb99..dfbddfef6 100644 --- a/src/lib/commands/run-on-cloud.ts +++ b/src/lib/commands/run-on-cloud.ts @@ -92,7 +92,10 @@ export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options: } catch (err: any) { // TODO: Better error message in apify-client-js if (err.type === 'record-not-found') { - throw new Error(`${type} ${actorOrTaskData.userFriendlyId} (${actorOrTaskData.id}) not found!`); + // The API's own message says what exactly is missing - e.g. a local runtime reports an Actor that + // exists but has no build under the requested tag with this same error type. + const reason = typeof err.message === 'string' && err.message ? `: ${err.message}` : '!'; + throw new Error(`${type} ${actorOrTaskData.userFriendlyId} (${actorOrTaskData.id}) not found${reason}`); } if (err.type === 'full-permission-actor-not-approved') { diff --git a/src/lib/runtime/docker.ts b/src/lib/runtime/docker.ts index a009033d8..4d595e45a 100644 --- a/src/lib/runtime/docker.ts +++ b/src/lib/runtime/docker.ts @@ -13,6 +13,9 @@ export const DOCKER_GET_DOCKER_URL = 'https://docs.docker.com/get-started/get-do /** Official Docker documentation: Docker Engine (server/headless Linux installs). */ export const DOCKER_ENGINE_INSTALL_URL = 'https://docs.docker.com/engine/install/'; +/** Official Podman documentation: installation on every platform. */ +export const PODMAN_INSTALL_URL = 'https://podman.io/docs/installation'; + export const ACTOR_RUNTIME_API_PORT = 3333; export const ACTOR_RUNTIME_CONSOLE_PORT = 3000; @@ -30,15 +33,66 @@ export const ACTOR_RUNTIME_ENV_VARS = { APIFY_CONSOLE_URL: ACTOR_RUNTIME_CONSOLE_URL, } as const; +/** The container engines the runtime can run on. Both serve the Docker-compatible API the runtime uses. */ +export type ContainerEngine = 'docker' | 'podman'; + +const CONTAINER_ENGINES: readonly ContainerEngine[] = ['docker', 'podman']; + +/** Set to `docker` or `podman` to pick the engine instead of taking the first one found on PATH. */ +export const CONTAINER_ENGINE_ENV_VAR = 'APIFY_CONTAINER_ENGINE'; + +/** Where the runtime container expects the engine's API socket. */ +export const RUNTIME_SOCKET_PATH = '/var/run/docker.sock'; + export function runtimeEnvExportLines(): string[] { return Object.entries(ACTOR_RUNTIME_ENV_VARS).map(([name, value]) => `export ${name}=${value}`); } -export async function findDockerExecutable(): Promise { - return which('docker', { nothrow: true }); +/** The engine the user asked for via `APIFY_CONTAINER_ENGINE`, or undefined for "whichever is installed". */ +export function requestedContainerEngine(env: NodeJS.ProcessEnv = process.env): ContainerEngine | undefined { + const value = env[CONTAINER_ENGINE_ENV_VAR]?.trim().toLowerCase(); + return CONTAINER_ENGINES.find((engine) => engine === value); +} + +/** + * The engines whose command is on PATH, in preference order: only the requested one when + * `APIFY_CONTAINER_ENGINE` is set, else Docker before Podman. + */ +export async function installedContainerEngines(env: NodeJS.ProcessEnv = process.env): Promise { + const requested = requestedContainerEngine(env); + const candidates = requested ? [requested] : CONTAINER_ENGINES; + const installed: ContainerEngine[] = []; + for (const engine of candidates) { + if (await which(engine, { nothrow: true })) installed.push(engine); + } + return installed; +} + +/** The first installed engine that is actually ready to run containers, else the first installed one + * (so its problem gets reported), else null when no engine command is on PATH. */ +export async function findContainerEngine( + env: NodeJS.ProcessEnv = process.env, +): Promise<{ engine: ContainerEngine; ready: boolean } | null> { + const installed = await installedContainerEngines(env); + for (const engine of installed) { + if (await isEngineReady(engine)) return { engine, ready: true }; + } + return installed[0] ? { engine: installed[0], ready: false } : null; +} + +/** The engine on which the runtime container is currently running, if any - checked on every installed + * engine, since the container may live on Podman while Docker is also on PATH. */ +export async function findRunningRuntimeEngine(env: NodeJS.ProcessEnv = process.env): Promise { + for (const engine of await installedContainerEngines(env)) { + if (await isRuntimeContainerRunning(engine)) return engine; + } + return null; } -export function dockerInstallHint(platform: NodeJS.Platform = process.platform): string { +export function engineInstallHint(engine: ContainerEngine, platform: NodeJS.Platform = process.platform): string { + if (engine === 'podman') { + return `Install Podman: ${PODMAN_INSTALL_URL}`; + } switch (platform) { case 'darwin': return 'Install Docker Desktop for Mac: https://docs.docker.com/desktop/setup/install/mac-install/'; @@ -49,7 +103,19 @@ export function dockerInstallHint(platform: NodeJS.Platform = process.platform): } } -export function dockerDaemonHint(platform: NodeJS.Platform = process.platform): string { +export function engineDaemonHint(engine: ContainerEngine, platform: NodeJS.Platform = process.platform): string { + if (engine === 'podman') { + switch (platform) { + case 'darwin': + case 'win32': + return `Start the Podman machine: 'podman machine start'.`; + default: + return ( + `Serve Podman's API socket: 'systemctl --user enable --now podman.socket' (rootless) or ` + + `'sudo systemctl enable --now podman.socket' (rootful); without systemd, 'podman system service --time=0 &'.` + ); + } + } switch (platform) { case 'darwin': case 'win32': @@ -59,8 +125,17 @@ export function dockerDaemonHint(platform: NodeJS.Platform = process.platform): } } -export async function isDockerDaemonRunning(): Promise { +/** + * True when the engine can run containers for us. For Podman that also means its API socket is being + * served - the runtime container needs to mount it - which `podman info` reports separately from Podman + * itself working. + */ +export async function isEngineReady(engine: ContainerEngine): Promise { try { + if (engine === 'podman') { + const { stdout } = await execa('podman', ['info', '--format', '{{.Host.RemoteSocket.Exists}}']); + return stdout.trim() === 'true'; + } await execa('docker', ['info', '--format', '{{.ServerVersion}}']); return true; } catch { @@ -68,18 +143,18 @@ export async function isDockerDaemonRunning(): Promise { } } -export async function imageExistsLocally(image: string): Promise { +export async function imageExistsLocally(engine: ContainerEngine, image: string): Promise { try { - await execa('docker', ['image', 'inspect', image]); + await execa(engine, ['image', 'inspect', image]); return true; } catch { return false; } } -export async function isRuntimeContainerRunning(): Promise { +export async function isRuntimeContainerRunning(engine: ContainerEngine): Promise { try { - const { stdout } = await execa('docker', [ + const { stdout } = await execa(engine, [ 'ps', '--filter', `name=^${ACTOR_RUNTIME_CONTAINER_NAME}$`, @@ -92,20 +167,53 @@ export async function isRuntimeContainerRunning(): Promise { } } -export function dockerSocketMount(platform: NodeJS.Platform = process.platform): string { +function unixSocketPath(url: string | undefined): string | undefined { + return url?.startsWith('unix://') ? url.slice('unix://'.length) : undefined; +} + +/** + * Host path of the engine's API socket, to be mounted into the runtime container. Docker: the `DOCKER_HOST` + * socket when it is a unix socket (rootless Docker), else the default one. Podman: the socket `podman info` + * reports serving, which is the path on the machine the containers run on (rootful, rootless, or inside a + * `podman machine` VM alike). + */ +export async function resolveEngineSocketPath( + engine: ContainerEngine, + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (engine === 'docker') { + return unixSocketPath(env.DOCKER_HOST) ?? RUNTIME_SOCKET_PATH; + } + + try { + const { stdout } = await execa('podman', ['info', '--format', '{{.Host.RemoteSocket.Path}}']); + const reported = stdout.trim(); + return unixSocketPath(reported) ?? reported; + } catch { + return '/run/podman/podman.sock'; + } +} + +export function socketMountArg(hostSocketPath: string, platform: NodeJS.Platform = process.platform): string { // Docker Desktop on Windows exposes the Linux engine's socket to containers under the same // path; the leading double slash prevents MSYS/Git Bash shells from mangling it. - const hostSocket = platform === 'win32' ? '//var/run/docker.sock' : '/var/run/docker.sock'; - return `${hostSocket}:/var/run/docker.sock`; + const hostSocket = platform === 'win32' && hostSocketPath.startsWith('/') ? `/${hostSocketPath}` : hostSocketPath; + return `${hostSocket}:${RUNTIME_SOCKET_PATH}`; } export interface RuntimeRunArgsOptions { dataDir: string; detach: boolean; + hostSocketPath: string; platform?: NodeJS.Platform; } -export function buildRuntimeRunArgs({ dataDir, detach, platform = process.platform }: RuntimeRunArgsOptions): string[] { +export function buildRuntimeRunArgs({ + dataDir, + detach, + hostSocketPath, + platform = process.platform, +}: RuntimeRunArgsOptions): string[] { // --init makes signals (Ctrl+C) reach the runtime process even though it runs as the container's PID 1. const args = ['run', '--rm', '--init', '--name', ACTOR_RUNTIME_CONTAINER_NAME]; @@ -119,7 +227,7 @@ export function buildRuntimeRunArgs({ dataDir, detach, platform = process.platfo '-p', `${ACTOR_RUNTIME_CONSOLE_PORT}:${ACTOR_RUNTIME_CONSOLE_PORT}`, '-v', - dockerSocketMount(platform), + socketMountArg(hostSocketPath, platform), '-v', `${dataDir}:/data`, ACTOR_RUNTIME_IMAGE, diff --git a/src/lib/runtime/ensure.ts b/src/lib/runtime/ensure.ts index c79466ffc..a3b18a121 100644 --- a/src/lib/runtime/ensure.ts +++ b/src/lib/runtime/ensure.ts @@ -6,11 +6,13 @@ import { execWithLog } from '../exec.js'; import { error, info } from '../outputs.js'; import { ACTOR_RUNTIME_IMAGE, - dockerDaemonHint, - dockerInstallHint, - findDockerExecutable, + CONTAINER_ENGINE_ENV_VAR, + type ContainerEngine, + engineDaemonHint, + engineInstallHint, + findContainerEngine, imageExistsLocally, - isDockerDaemonRunning, + requestedContainerEngine, } from './docker.js'; export interface EnsureActorRuntimeImageOptions { @@ -18,48 +20,56 @@ export interface EnsureActorRuntimeImageOptions { } /** - * Verifies this machine can run Docker images and makes the Actor runtime image available locally. - * Prints a user-facing error and sets the exit code when something is missing. + * Finds a working container engine (Docker or Podman) and makes the Actor runtime image available on it. + * Resolves to the engine to drive, or null after printing a user-facing error and setting the exit code. */ export async function ensureActorRuntimeImage({ forcePull = false, -}: EnsureActorRuntimeImageOptions = {}): Promise { - if (!(await findDockerExecutable())) { +}: EnsureActorRuntimeImageOptions = {}): Promise { + const found = await findContainerEngine(); + if (!found) { + const requested = requestedContainerEngine(); error({ - message: `Docker is required to run the Actor runtime, but the 'docker' command was not found.\n ${dockerInstallHint()}`, + message: requested + ? `${CONTAINER_ENGINE_ENV_VAR}=${requested} is set, but the '${requested}' command was not found.\n ${engineInstallHint(requested)}` + : `Docker or Podman is required to run the Actor runtime, but neither the 'docker' nor the 'podman' command was found.\n ${engineInstallHint('docker')}\n ${engineInstallHint('podman')}`, }); process.exitCode = 1; - return false; + return null; } - if (!(await isDockerDaemonRunning())) { + const { engine, ready } = found; + if (!ready) { error({ - message: `Docker is installed, but the Docker daemon is not running or not reachable.\n ${dockerDaemonHint()}`, + message: + engine === 'podman' + ? `Podman is installed, but its API socket is not being served.\n ${engineDaemonHint(engine)}` + : `Docker is installed, but the Docker daemon is not running or not reachable.\n ${engineDaemonHint(engine)}`, }); process.exitCode = 1; - return false; + return null; } - if (!forcePull && (await imageExistsLocally(ACTOR_RUNTIME_IMAGE))) { + if (!forcePull && (await imageExistsLocally(engine, ACTOR_RUNTIME_IMAGE))) { info({ message: `Actor runtime image '${ACTOR_RUNTIME_IMAGE}' is already available locally.` }); - return true; + return engine; } info({ message: `Downloading the Actor runtime image '${ACTOR_RUNTIME_IMAGE}'...` }); try { - await execWithLog({ cmd: 'docker', args: ['pull', ACTOR_RUNTIME_IMAGE] }); - return true; + await execWithLog({ cmd: engine, args: ['pull', ACTOR_RUNTIME_IMAGE] }); + return engine; } catch { error({ message: [ `Could not pull '${ACTOR_RUNTIME_IMAGE}'.`, - ` Check that you are online and can access the image - a private repository needs ${chalk.white.bold('docker login')} first.`, + ` Check that you are online and can access the image - a private repository needs ${chalk.white.bold(`${engine} login`)} first.`, ' You can also build the image locally from an actor-runtime checkout instead:', - chalk.white.bold(` docker build -t ${ACTOR_RUNTIME_IMAGE} .`), + chalk.white.bold(` ${engine} build -t ${ACTOR_RUNTIME_IMAGE} .`), ].join('\n'), }); process.exitCode = 1; - return false; + return null; } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 99d8784e8..1d6de0299 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -594,8 +594,9 @@ export const outputJobLog = async ({ return; } + // Undefined when the job has no log at all (it failed before its container ever started). const log = await client.log(logId).get(); - process.stderr.write(log!); + if (log) process.stderr.write(log); return; } diff --git a/test/local/lib/runtime-docker.test.ts b/test/local/lib/runtime-docker.test.ts index 2a1a396c0..2df57f3a4 100644 --- a/test/local/lib/runtime-docker.test.ts +++ b/test/local/lib/runtime-docker.test.ts @@ -2,40 +2,83 @@ import { ACTOR_RUNTIME_CONTAINER_NAME, ACTOR_RUNTIME_IMAGE, buildRuntimeRunArgs, - dockerDaemonHint, - dockerInstallHint, - dockerSocketMount, + engineDaemonHint, + engineInstallHint, + requestedContainerEngine, + resolveEngineSocketPath, + socketMountArg, } from '../../../src/lib/runtime/docker.js'; describe('runtime/docker', () => { - describe('dockerSocketMount()', () => { - it('uses the plain socket path on Linux and macOS', () => { - expect(dockerSocketMount('linux')).toBe('/var/run/docker.sock:/var/run/docker.sock'); - expect(dockerSocketMount('darwin')).toBe('/var/run/docker.sock:/var/run/docker.sock'); + describe('socketMountArg()', () => { + it('mounts the host socket at the path the runtime expects, on Linux and macOS', () => { + expect(socketMountArg('/var/run/docker.sock', 'linux')).toBe('/var/run/docker.sock:/var/run/docker.sock'); + expect(socketMountArg('/run/podman/podman.sock', 'darwin')).toBe('/run/podman/podman.sock:/var/run/docker.sock'); }); it('doubles the leading slash on Windows to prevent path mangling', () => { - expect(dockerSocketMount('win32')).toBe('//var/run/docker.sock:/var/run/docker.sock'); + expect(socketMountArg('/var/run/docker.sock', 'win32')).toBe('//var/run/docker.sock:/var/run/docker.sock'); + }); + }); + + describe('requestedContainerEngine()', () => { + it('reads APIFY_CONTAINER_ENGINE case-insensitively and ignores anything but docker or podman', () => { + expect(requestedContainerEngine({ APIFY_CONTAINER_ENGINE: 'podman' })).toBe('podman'); + expect(requestedContainerEngine({ APIFY_CONTAINER_ENGINE: ' Docker ' })).toBe('docker'); + expect(requestedContainerEngine({ APIFY_CONTAINER_ENGINE: 'nerdctl' })).toBeUndefined(); + expect(requestedContainerEngine({})).toBeUndefined(); + }); + }); + + describe('resolveEngineSocketPath()', () => { + it('uses the default Docker socket unless DOCKER_HOST names a unix socket (rootless Docker)', async () => { + await expect(resolveEngineSocketPath('docker', {})).resolves.toBe('/var/run/docker.sock'); + await expect( + resolveEngineSocketPath('docker', { DOCKER_HOST: 'unix:///run/user/1000/docker.sock' }), + ).resolves.toBe('/run/user/1000/docker.sock'); + await expect(resolveEngineSocketPath('docker', { DOCKER_HOST: 'tcp://localhost:2375' })).resolves.toBe( + '/var/run/docker.sock', + ); }); }); describe('install and daemon hints', () => { it('points each platform at the right Docker distribution', () => { - expect(dockerInstallHint('darwin')).toContain('Docker Desktop for Mac'); - expect(dockerInstallHint('win32')).toContain('Docker Desktop for Windows'); - expect(dockerInstallHint('linux')).toContain('Docker Engine'); + expect(engineInstallHint('docker', 'darwin')).toContain('Docker Desktop for Mac'); + expect(engineInstallHint('docker', 'win32')).toContain('Docker Desktop for Windows'); + expect(engineInstallHint('docker', 'linux')).toContain('Docker Engine'); + }); + + it('points Podman users at the Podman installation docs on every platform', () => { + for (const platform of ['darwin', 'win32', 'linux'] as const) { + expect(engineInstallHint('podman', platform)).toContain('podman.io'); + } }); it('tells desktop users to start Docker Desktop and Linux users to start the daemon', () => { - expect(dockerDaemonHint('darwin')).toContain('Docker Desktop'); - expect(dockerDaemonHint('win32')).toContain('Docker Desktop'); - expect(dockerDaemonHint('linux')).toContain('systemctl start docker'); + expect(engineDaemonHint('docker', 'darwin')).toContain('Docker Desktop'); + expect(engineDaemonHint('docker', 'win32')).toContain('Docker Desktop'); + expect(engineDaemonHint('docker', 'linux')).toContain('systemctl start docker'); + }); + + it('tells Podman users to serve the API socket on Linux and to start the machine on desktops', () => { + expect(engineDaemonHint('podman', 'linux')).toContain('podman.socket'); + expect(engineDaemonHint('podman', 'linux')).toContain('podman system service'); + expect(engineDaemonHint('podman', 'darwin')).toContain('podman machine start'); + expect(engineDaemonHint('podman', 'win32')).toContain('podman machine start'); }); }); describe('buildRuntimeRunArgs()', () => { - it('builds the canonical docker run command', () => { - expect(buildRuntimeRunArgs({ dataDir: '/home/me/data', detach: false, platform: 'linux' })).toEqual([ + it('builds the canonical run command around the resolved host socket', () => { + expect( + buildRuntimeRunArgs({ + dataDir: '/home/me/data', + detach: false, + hostSocketPath: '/var/run/docker.sock', + platform: 'linux', + }), + ).toEqual([ 'run', '--rm', '--init', @@ -53,8 +96,23 @@ describe('runtime/docker', () => { ]); }); + it("mounts a rootless Podman socket at the runtime's expected path", () => { + const args = buildRuntimeRunArgs({ + dataDir: '/data', + detach: false, + hostSocketPath: '/run/user/1000/podman/podman.sock', + platform: 'linux', + }); + expect(args).toContain('/run/user/1000/podman/podman.sock:/var/run/docker.sock'); + }); + it('adds --detach before the image when requested', () => { - const args = buildRuntimeRunArgs({ dataDir: '/data', detach: true, platform: 'linux' }); + const args = buildRuntimeRunArgs({ + dataDir: '/data', + detach: true, + hostSocketPath: '/var/run/docker.sock', + platform: 'linux', + }); expect(args).toContain('--detach'); expect(args.indexOf('--detach')).toBeLessThan(args.indexOf(ACTOR_RUNTIME_IMAGE)); }); From 93205b67197e96a2d699a51f792b029b32a387f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Proch=C3=A1zka?= Date: Thu, 10 Sep 2026 17:53:29 +0200 Subject: [PATCH 06/12] feat: Made dev folder for Actor runtime default (#1415) Actors running through local actor runtime will by default work in local dev folder mode. Depends on: https://github.com/apify/actor-runtime/pull/43 --------- Co-authored-by: Claude Fable 5.1 --- docs/reference.md | 12 +- src/commands/actors/call.ts | 22 +++- src/commands/actors/push.ts | 32 ++++- src/commands/runtime/_index.ts | 2 + src/lib/commands/run-on-cloud.ts | 37 +++++- src/lib/runtime/dev-folder.ts | 48 +++++++ test/local/commands/push-dev-folder.test.ts | 133 ++++++++++++++++++++ test/local/lib/run-on-cloud.test.ts | 48 +++++++ test/local/lib/runtime-dev-folder.test.ts | 51 ++++++++ 9 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 src/lib/runtime/dev-folder.ts create mode 100644 test/local/commands/push-dev-folder.test.ts create mode 100644 test/local/lib/runtime-dev-folder.test.ts diff --git a/docs/reference.md b/docs/reference.md index 24bf9fa9d..c4ca9d4fb 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -491,6 +491,10 @@ DESCRIPTION Unset them to talk to the Apify cloud again. 'apify runtime start' prints the same values when the runtime boots. + Pointed at the runtime, 'apify push' also registers the pushed directory as + the Actor's live dev folder, so runs pick up local edits without another push; + 'apify call --no-dev-folder' runs from the built image alone. + SUBCOMMANDS runtime install Installs the Actor runtime: verifies this machine has a working container engine (Docker or Podman) and @@ -959,7 +963,7 @@ DESCRIPTION info --input". USAGE - $ apify actors call [actorId] [-b ] + $ apify actors call [actorId] [-b ] [--dev-folder] [-i | -f ] [--json] [-m ] [-o] [-s] [-t ] @@ -972,6 +976,12 @@ ARGUMENTS FLAGS -b, --build= Tag or number of the build to run (e.g. "latest" or "1.2.34"). + --dev-folder Local Actor runtime only: mount + the Actor's registered live dev folder into the run, so + it picks up local edits (the default). Use + --no-dev-folder to run from the built image alone this + once; the registration itself stays. Ignored when + calling on the Apify platform. -i, --input= Optional inline JSON object input for the Actor. To avoid shell parsing issues, wrap the JSON in quotes. For JSON files, use --input-file. diff --git a/src/commands/actors/call.ts b/src/commands/actors/call.ts index e5f31e25d..67184aee1 100644 --- a/src/commands/actors/call.ts +++ b/src/commands/actors/call.ts @@ -19,7 +19,8 @@ import { getInputOverride } from '../../lib/commands/resolve-input.js'; import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; import { finalizeRun, runUrl } from '../../lib/commands/run-result.js'; import { CommandExitCodes, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; -import { error, simpleLog } from '../../lib/outputs.js'; +import { error, simpleLog, warning } from '../../lib/outputs.js'; +import { mayTargetActorRuntime } from '../../lib/runtime/dev-folder.js'; import { getLocalConfig, getLocalUserInfo, getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js'; export class ActorsCallCommand extends ApifyCommand { @@ -84,6 +85,12 @@ export class ActorsCallCommand extends ApifyCommand { char: 'o', description: 'Prints out the entire default dataset on successful run of the Actor.', }), + 'dev-folder': Flags.boolean({ + description: + "Local Actor runtime only: mount the Actor's registered live dev folder into the run, so it picks up local edits (the default). " + + 'Use --no-dev-folder to run from the built image alone this once; the registration itself stays. Ignored when calling on the Apify platform.', + default: true, + }), // TODO: do we want to do the --stream-x flags? Can we even do them? }; @@ -142,6 +149,18 @@ export class ActorsCallCommand extends ApifyCommand { runOpts.memory = this.flags.memory; } + let extraStartParams: Record | undefined; + if (!this.flags.devFolder) { + if (mayTargetActorRuntime(apifyClient)) { + extraStartParams = { devFolder: 'false' }; + } else { + warning({ + message: + '--no-dev-folder only applies to runs on a local Actor runtime; the Apify platform has no live dev folder to skip. Ignoring it.', + }); + } + } + const inputOverride = await getInputOverride(cwd, this.flags.input, this.flags.inputFile, { schemaHint: `Run "apify actors info ${userFriendlyId} --input" to inspect the Actor input schema.`, }); @@ -166,6 +185,7 @@ export class ActorsCallCommand extends ApifyCommand { waitForRunToFinish: true, printRunLogs: true, suppressFinalStatus: true, + extraStartParams, }); for await (const yieldedRun of iterator) { diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 637e86649..5093f6c80 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -2,7 +2,7 @@ import { readFileSync, statSync, unlinkSync } from 'node:fs'; import { join, resolve } from 'node:path'; import process from 'node:process'; -import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions } from 'apify-client'; +import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions, ApifyClient } from 'apify-client'; import open from 'open'; import { fetchManifest } from '@apify/actor-templates'; @@ -24,6 +24,7 @@ import { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js'; import { useActorConfig } from '../../lib/hooks/useActorConfig.js'; import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; import { error, info, run, simpleLog, warning } from '../../lib/outputs.js'; +import { mayTargetActorRuntime, registerActorRuntimeDevFolder } from '../../lib/runtime/dev-folder.js'; import { transformEnvToEnvVars } from '../../lib/secrets.js'; import { createActZip, @@ -484,6 +485,8 @@ Skipping push. Use --force to override.`, info({ message: `${isEnabled ? 'Enabled' : 'Disabled'} standby mode for Actor ${actor.name}.` }); } + await registerDevFolderOnActorRuntime(apifyClient, actorId, actor.name, cwd); + // Build Actor on Apify and wait for build to finish run({ message: `Building Actor ${actor.name}` }); // Anchor the deadline at build start so log streaming + status polling @@ -619,3 +622,30 @@ Skipping push. Use --force to override.`, } } } + +/** + * Against a local Actor runtime, registers the pushed directory as the Actor's live dev folder, so later + * runs mount it over the built image. Against the Apify platform this is a no-op without a request. + */ +async function registerDevFolderOnActorRuntime( + apifyClient: ApifyClient, + actorId: string, + actorName: string, + cwd: string, +) { + if (!mayTargetActorRuntime(apifyClient)) return; + + const result = await registerActorRuntimeDevFolder(apifyClient, actorId, cwd); + if (result.ok) { + info({ + message: + `Registered ${cwd} as the live dev folder of Actor ${actorName} on the local Actor runtime: runs mount it over the built image, ` + + `so local edits apply on the next 'apify call' without another push. A compiled Actor (e.g. TypeScript) needs its local build first. ` + + `Use 'apify call --no-dev-folder' to run from the built image alone.`, + }); + } else if (result.error) { + warning({ + message: `Could not register ${cwd} as the live dev folder of Actor ${actorName} on the local Actor runtime: ${result.error}`, + }); + } +} diff --git a/src/commands/runtime/_index.ts b/src/commands/runtime/_index.ts index 07093b0dd..e8c583ffd 100644 --- a/src/commands/runtime/_index.ts +++ b/src/commands/runtime/_index.ts @@ -43,6 +43,8 @@ export class RuntimeIndexCommand extends ApifyCommand ` ${line}`), '', `Unset them to talk to the Apify cloud again. 'apify runtime start' prints the same values when the runtime boots.`, + '', + `Pointed at the runtime, 'apify push' also registers the pushed directory as the Actor's live dev folder, so runs pick up local edits without another push; 'apify call --no-dev-folder' runs from the built image alone.`, ].join('\n'); static override group = 'Local Actor Development'; diff --git a/src/lib/commands/run-on-cloud.ts b/src/lib/commands/run-on-cloud.ts index dfbddfef6..8c62c8161 100644 --- a/src/lib/commands/run-on-cloud.ts +++ b/src/lib/commands/run-on-cloud.ts @@ -1,6 +1,6 @@ import process from 'node:process'; -import type { ActorRun, ApifyClient, TaskStartOptions } from 'apify-client'; +import type { ActorRun, ActorStartOptions, ApifyClient, TaskStartOptions } from 'apify-client'; import chalk from 'chalk'; import { ACTOR_JOB_STATUSES } from '@apify/consts'; @@ -38,6 +38,36 @@ export interface RunOnCloudOptions { * the caller renders its own final result summary and owns the exit code. */ suppressFinalStatus?: boolean; + /** + * Extra query parameters for the run-start request that the Apify API itself does not know - local + * Actor runtime extensions such as `devFolder=false`. apify-client's `start()` rejects unknown + * options, so a run with these is started through a raw request instead. Actors only. + */ + extraStartParams?: Record; +} + +/** + * `POST .../actors/:actorId/runs` by hand, with `extraStartParams` alongside the standard run options, + * then re-read through the client so the result has the exact shape `start()` would have returned. + */ +async function startActorWithExtraParams( + apifyClient: ApifyClient, + actorId: string, + input: { inputToUse: unknown; contentType: string } | null, + runOptions: ActorStartOptions, + extraStartParams: Record, +): Promise { + const { waitForFinish, timeout, memory, build } = runOptions; + + const response = await apifyClient.httpClient.call<{ data: { id: string } }>({ + url: `${apifyClient.actor(actorId).url}/runs`, + method: 'POST', + data: input?.inputToUse, + headers: input ? { 'content-type': input.contentType } : undefined, + params: { waitForFinish, timeout, memory, build, ...extraStartParams }, + }); + + return (await apifyClient.run(response.data.data.id).get())!; } export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options: RunOnCloudOptions) { @@ -52,6 +82,7 @@ export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options: waitForRunToFinish, printRunLogs, suppressFinalStatus, + extraStartParams, } = options; const clientMethod = type === 'Actor' ? 'actor' : 'task'; @@ -78,7 +109,9 @@ export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options: let run: ActorRun; try { - if (actorInput && type === 'Actor') { + if (extraStartParams && type === 'Actor') { + run = await startActorWithExtraParams(apifyClient, actorOrTaskData.id, actorInput, runOptions, extraStartParams); + } else if (actorInput && type === 'Actor') { // TODO: For some reason we cannot pass json as buffer with right contentType into apify-client. // It will save malformed JSON which looks like buffer as INPUT. // We need to fix this in v1 during removing call under Actor namespace. diff --git a/src/lib/runtime/dev-folder.ts b/src/lib/runtime/dev-folder.ts new file mode 100644 index 000000000..ff0878f95 --- /dev/null +++ b/src/lib/runtime/dev-folder.ts @@ -0,0 +1,48 @@ +import type { ApifyClient } from 'apify-client'; + +import { APIFY_CLIENT_DEFAULT_HEADERS } from '../consts.js'; + +/** + * Whether the client talks to something other than the Apify cloud API - the only case in which it can be + * a local Actor runtime. Runtime-only calls are skipped without a request otherwise. + */ +export function mayTargetActorRuntime(client: Pick): boolean { + try { + const { hostname } = new URL(client.baseUrl); + return hostname !== 'apify.com' && !hostname.endsWith('.apify.com'); + } catch { + return false; + } +} + +/** + * `POST /actor-runtime/dev-folder/:actorId` - registers `path` as the Actor's live dev folder on a local + * Actor runtime. On failure, `error` carries the runtime's reason; it is absent when the target has no such + * endpoint at all (not an Actor runtime), which is not worth reporting. + */ +export async function registerActorRuntimeDevFolder( + client: Pick, + actorId: string, + path: string, +): Promise<{ ok: true } | { ok: false; error?: string }> { + try { + // `baseUrl` already ends in `/v2`; the runtime serves `/v2/actor-runtime/*` as an alias of `/actor-runtime/*`. + const response = await fetch(`${client.baseUrl}/actor-runtime/dev-folder/${actorId}`, { + method: 'POST', + headers: { + ...APIFY_CLIENT_DEFAULT_HEADERS, + 'Authorization': `Bearer ${client.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(path), + }); + + if (response.ok) return { ok: true }; + if (response.status === 404) return { ok: false }; + + const payload = (await response.json().catch(() => null)) as { error?: { message?: string } } | null; + return { ok: false, error: payload?.error?.message ?? `${response.status} ${response.statusText}` }; + } catch (err) { + return { ok: false, error: (err as Error).message }; + } +} diff --git a/test/local/commands/push-dev-folder.test.ts b/test/local/commands/push-dev-folder.test.ts new file mode 100644 index 000000000..d0e031d69 --- /dev/null +++ b/test/local/commands/push-dev-folder.test.ts @@ -0,0 +1,133 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { ACTOR_SOURCE_TYPES } from '@apify/consts'; + +import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; +import { LOCAL_CONFIG_PATH } from '../../../src/lib/consts.js'; +import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; +import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; + +const actName = 'push-dev-folder-actor'; +const ACTOR_ID = 'aBcD1234efGh'; +const RUNTIME_BASE_URL = 'http://localhost:3333/v2'; +const CLOUD_BASE_URL = 'https://api.apify.com/v2'; + +const actor = { + id: ACTOR_ID, + name: actName, + // Older than any local file, so the "modified on the platform" check never trips. + modifiedAt: new Date(0), + taggedBuilds: { latest: { buildId: 'build1' } }, +}; +const build = { id: 'build1', actId: ACTOR_ID, buildNumber: '0.0.1', status: 'SUCCEEDED' }; +const currentVersion = { versionNumber: '0.0', sourceType: ACTOR_SOURCE_TYPES.SOURCE_FILES }; + +// Which API the mocked client "talks to" - the runtime, or the Apify cloud. +let baseUrl = RUNTIME_BASE_URL; + +vitest.mock('../../../src/lib/utils.js', async (importOriginal) => ({ + ...(await importOriginal()), + getLocalUserInfo: vitest.fn(async () => ({ id: 'userId', username: 'user' })), + outputJobLog: vitest.fn(async () => {}), + getLoggedClientOrThrow: vitest.fn(async () => ({ + baseUrl, + token: 'my-token', + actor: () => ({ + get: async () => actor, + version: () => ({ get: async () => currentVersion, update: async () => ({}) }), + build: async () => build, + }), + build: () => ({ get: async () => build }), + })), +})); + +// The mocked `node:process` copy has no signal handlers; nothing here needs them. +vitest.mock('../../../src/lib/hooks/useAbortJobOnSignal.js', () => ({ + useAbortJobOnSignal: () => ({ [Symbol.dispose]() {} }), +})); + +const { beforeAllCalls, afterAllCalls, joinPath, tmpPath } = useTempPath(actName, { + create: true, + remove: true, + cwd: true, + cwdParent: false, +}); + +const { logMessages } = useConsoleSpy(); + +const { ActorsPushCommand } = await import('../../../src/commands/actors/push.js'); + +// Stands in for the runtime's `POST /actor-runtime/dev-folder/:actorId`: echoes back what it was sent. +const fetchMock = vitest.fn(async (_url, init) => { + const requested = JSON.parse(init?.body as string) as string; + return new Response(JSON.stringify({ data: { localDevFolder: requested || null } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +}); + +const devFolderCalls = () => + fetchMock.mock.calls.map(([url, init]) => ({ url: String(url), body: JSON.parse(init?.body as string) as string })); + +let originalExitCode: typeof process.exitCode; + +beforeEach(async () => { + originalExitCode = process.exitCode; + baseUrl = RUNTIME_BASE_URL; + fetchMock.mockClear(); + vitest.stubGlobal('fetch', fetchMock); + await beforeAllCalls(); + await mkdir(joinPath('.actor'), { recursive: true }); + await writeFile( + joinPath(LOCAL_CONFIG_PATH), + JSON.stringify({ actorSpecification: 1, name: actName, version: '0.0', buildTag: 'latest' }), + ); + await writeFile(join(joinPath('.actor'), 'main.js'), 'console.log("hi")'); +}); + +afterEach(async () => { + vitest.unstubAllGlobals(); + process.exitCode = originalExitCode; + await afterAllCalls(); +}); + +describe('apify push against a local Actor runtime', () => { + it('registers the pushed directory as the live dev folder by default', async () => { + await testRunCommand(ActorsPushCommand, {}); + + expect(process.exitCode).toBeFalsy(); + expect(devFolderCalls()).toEqual([ + { url: `${RUNTIME_BASE_URL}/actor-runtime/dev-folder/${ACTOR_ID}`, body: tmpPath }, + ]); + expect(logMessages.error.join('\n')).toContain(`Registered ${tmpPath} as the live dev folder`); + }); + + it('warns, but still reports a successful push, when the runtime refuses the path', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { type: 'dev-folder-path-not-found', message: 'The submitted path does not exist on the host.' }, + }), + { status: 400, headers: { 'content-type': 'application/json' } }, + ), + ); + + await testRunCommand(ActorsPushCommand, {}); + + expect(process.exitCode).toBeFalsy(); + expect(logMessages.error.join('\n')).toContain('The submitted path does not exist on the host.'); + expect(logMessages.log.join('\n')).toContain('Apify push result: SUCCEEDED'); + }); +}); + +describe('apify push against the Apify platform', () => { + it('never calls the runtime endpoint', async () => { + baseUrl = CLOUD_BASE_URL; + + await testRunCommand(ActorsPushCommand, {}); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(logMessages.error.join('\n')).not.toContain('dev folder'); + }); +}); diff --git a/test/local/lib/run-on-cloud.test.ts b/test/local/lib/run-on-cloud.test.ts index c7b22cb43..c068c1dc5 100644 --- a/test/local/lib/run-on-cloud.test.ts +++ b/test/local/lib/run-on-cloud.test.ts @@ -54,4 +54,52 @@ describe('runActorOrTaskOnCloud', () => { expect(err.message).toMatch(/has not been approved yet/); expect(err.message).not.toMatch(/Approve here/); }); + + describe('extraStartParams (local Actor runtime extensions)', () => { + const startedRun = { id: 'run1', status: 'RUNNING' }; + const fetchedRun = { id: 'run1', status: 'RUNNING', startedAt: new Date(0) }; + + const fakeClient = () => { + const start = vitest.fn(); + const call = vitest.fn(async () => ({ data: { data: startedRun } })); + const client = { + httpClient: { call }, + actor: () => ({ url: 'http://localhost:3333/v2/actors/abc', start }), + run: () => ({ get: async () => fetchedRun }), + } as unknown as ApifyClient; + return { client, start, call }; + }; + + const startOnce = async (client: ApifyClient, extraStartParams?: Record) => { + const iterator = runActorOrTaskOnCloud(client, { + actorOrTaskData: { id: 'abc', userFriendlyId: 'apify/test-actor' }, + runOptions: { waitForFinish: 2, build: 'latest', memory: 256 }, + inputOverride: { url: 'https://example.com' }, + type: 'Actor', + silent: true, + suppressFinalStatus: true, + extraStartParams, + }); + const { value } = await iterator.next(); + await iterator.return(undefined); + return value; + }; + + it('starts the run through a raw request carrying the standard options plus the extras, then re-reads it', async () => { + const { client, start, call } = fakeClient(); + + const run = await startOnce(client, { devFolder: 'false' }); + + expect(start).not.toHaveBeenCalled(); + expect(call).toHaveBeenCalledTimes(1); + expect(call.mock.calls[0][0 as never]).toMatchObject({ + url: 'http://localhost:3333/v2/actors/abc/runs', + method: 'POST', + data: { url: 'https://example.com' }, + headers: { 'content-type': 'application/json' }, + params: { waitForFinish: 2, build: 'latest', memory: 256, devFolder: 'false' }, + }); + expect(run).toBe(fetchedRun); + }); + }); }); diff --git a/test/local/lib/runtime-dev-folder.test.ts b/test/local/lib/runtime-dev-folder.test.ts new file mode 100644 index 000000000..b5e7cf65a --- /dev/null +++ b/test/local/lib/runtime-dev-folder.test.ts @@ -0,0 +1,51 @@ +import { mayTargetActorRuntime, registerActorRuntimeDevFolder } from '../../../src/lib/runtime/dev-folder.js'; + +const client = { baseUrl: 'http://localhost:3333/v2', token: 'my-token' }; + +describe('mayTargetActorRuntime', () => { + it('is false for the Apify cloud API and true for anything else', () => { + expect(mayTargetActorRuntime({ baseUrl: 'https://api.apify.com/v2' })).toBe(false); + expect(mayTargetActorRuntime({ baseUrl: 'http://localhost:3333/v2' })).toBe(true); + }); +}); + +describe('registerActorRuntimeDevFolder', () => { + const fetchMock = vitest.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + vitest.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vitest.unstubAllGlobals(); + }); + + it('POSTs the path as a JSON string to the runtime endpoint, with the token', async () => { + fetchMock.mockResolvedValueOnce(new Response('{"data":{"localDevFolder":"/abs/actor"}}', { status: 200 })); + + expect(await registerActorRuntimeDevFolder(client, 'actor123', '/abs/actor')).toEqual({ ok: true }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('http://localhost:3333/v2/actor-runtime/dev-folder/actor123'); + expect(init?.method).toBe('POST'); + expect(init?.body).toBe('"/abs/actor"'); + expect(init?.headers).toMatchObject({ Authorization: 'Bearer my-token' }); + }); + + it('treats a 404 as "not an Actor runtime", with nothing to report', async () => { + fetchMock.mockResolvedValueOnce(new Response('{"error":{"type":"record-not-found"}}', { status: 404 })); + + expect(await registerActorRuntimeDevFolder(client, 'actor123', '/abs/actor')).toEqual({ ok: false }); + }); + + it("reports the runtime's own reason when it refuses the path", async () => { + fetchMock.mockResolvedValueOnce( + new Response('{"error":{"message":"The submitted path does not exist on the host."}}', { status: 400 }), + ); + + expect(await registerActorRuntimeDevFolder(client, 'actor123', '/abs/missing')).toEqual({ + ok: false, + error: 'The submitted path does not exist on the host.', + }); + }); +}); From 1d16e043240616783280c5cad5df2a6f38abf3ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Proch=C3=A1zka?= Date: Fri, 11 Sep 2026 09:39:43 +0200 Subject: [PATCH 07/12] feat: allow installing a specific Actor runtime image (#1423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apify runtime install [image]` pulls the given image instead of the default `apify/actor-runtime:latest`, so a pinned build or another runtime implementation can be used: ```sh apify runtime install apify/actor-runtime:master-5462005 ``` - The installed image is recorded in `~/.apify/actor-runtime/config.json`. - `apify runtime start` runs whatever was installed last; no flags or checks there. - Without an argument, `install` keeps using the default image. - No new dependencies. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01U7Bi6BtaXTmLFxAiV3gRZ3 Co-authored-by: Claude --- docs/reference.md | 13 ++++++--- skills/apify/SKILL.md | 2 +- src/commands/runtime/install.ts | 21 ++++++++++++--- src/commands/runtime/start.ts | 7 ++--- src/lib/consts.ts | 2 ++ src/lib/runtime/docker.ts | 6 +++-- src/lib/runtime/ensure.ts | 38 +++++++++++++++++++++------ test/local/lib/runtime-docker.test.ts | 9 ++++--- test/local/lib/runtime-ensure.test.ts | 23 ++++++++++++++++ 9 files changed, 97 insertions(+), 24 deletions(-) create mode 100644 test/local/lib/runtime-ensure.test.ts diff --git a/docs/reference.md b/docs/reference.md index c4ca9d4fb..bdaf45d49 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -498,8 +498,8 @@ DESCRIPTION SUBCOMMANDS runtime install Installs the Actor runtime: verifies this machine has a working container engine (Docker or Podman) and - downloads the Actor runtime image - ('apify/actor-runtime:latest'). + downloads the Actor runtime image ('apify/actor-runtime:latest' + unless another one is given). runtime start Starts the Actor runtime, a local Apify platform running as a container on Docker or Podman. runtime stop Stops the Actor runtime container started with @@ -512,13 +512,18 @@ SUBCOMMANDS DESCRIPTION Installs the Actor runtime: verifies this machine has a working container engine (Docker or Podman) and downloads the Actor runtime image - ('apify/actor-runtime:latest'). + ('apify/actor-runtime:latest' unless another one is given). + 'apify runtime start' then runs the image installed last. The engine itself is a prerequisite and is not installed by this command - see https://docs.docker.com/get-started/get-docker/ or https://podman.io/docs/installation. USAGE - $ apify runtime install [-f] + $ apify runtime install [image] [-f] + +ARGUMENTS + image Container image to install as the Actor runtime. Defaults to + 'apify/actor-runtime:latest'. FLAGS -f, --force Download the Actor runtime image even when it is already diff --git a/skills/apify/SKILL.md b/skills/apify/SKILL.md index 943bb8a2b..f8e15ab26 100644 --- a/skills/apify/SKILL.md +++ b/skills/apify/SKILL.md @@ -115,7 +115,7 @@ With Docker, check with `docker info` and act on what it tells you: - Docker Desktop (macOS, Windows, Linux desktop): https://docs.docker.com/get-started/get-docker/ - Docker Engine (Linux servers, headless): https://docs.docker.com/engine/install/ -`apify runtime install` runs the same engine checks and prints a platform-specific hint when something is missing. +`apify runtime install` runs the same engine checks and prints a platform-specific hint when something is missing. It installs `apify/actor-runtime:latest` unless another image is given (for example `apify runtime install apify/actor-runtime:master-5462005` for a pinned build); `apify runtime start` runs whichever image was installed last. Only name an image when the user asks for a specific one. **Working directory.** Install the preview CLI locally in one dedicated directory rather than globally, so it cannot replace the user's stable `apify` install. Keep the runtime data and the Actor projects you create in the same directory - everything the session produced is then in one place and easy to clean up: diff --git a/src/commands/runtime/install.ts b/src/commands/runtime/install.ts index b5322c7cb..aa460339d 100644 --- a/src/commands/runtime/install.ts +++ b/src/commands/runtime/install.ts @@ -1,14 +1,16 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { simpleLog, success } from '../../lib/outputs.js'; -import { ACTOR_RUNTIME_IMAGE, DOCKER_GET_DOCKER_URL, PODMAN_INSTALL_URL } from '../../lib/runtime/docker.js'; +import { DEFAULT_ACTOR_RUNTIME_IMAGE, DOCKER_GET_DOCKER_URL, PODMAN_INSTALL_URL } from '../../lib/runtime/docker.js'; import { ensureActorRuntimeImage } from '../../lib/runtime/ensure.js'; export class RuntimeInstallCommand extends ApifyCommand { static override name = 'install' as const; static override description = - `Installs the Actor runtime: verifies this machine has a working container engine (Docker or Podman) and downloads the Actor runtime image ('${ACTOR_RUNTIME_IMAGE}').\n` + + `Installs the Actor runtime: verifies this machine has a working container engine (Docker or Podman) and downloads the Actor runtime image ('${DEFAULT_ACTOR_RUNTIME_IMAGE}' unless another one is given).\n` + + `'apify runtime start' then runs the image installed last.\n` + `The engine itself is a prerequisite and is not installed by this command - see ${DOCKER_GET_DOCKER_URL} or ${PODMAN_INSTALL_URL}.`; static override group = 'Local Actor Development'; @@ -22,10 +24,20 @@ export class RuntimeInstallCommand extends ApifyCommand join(GLOBAL_CONFIGS_FOLDER(), 'actor-runtime', 'data'); @@ -71,7 +71,8 @@ export class RuntimeStartCommand extends ApifyCommand join(GLOBAL_CONFIGS_FOLDER(), 'state.json') export const TELEMETRY_FILE_PATH = () => join(GLOBAL_CONFIGS_FOLDER(), 'telemetry.json'); +export const ACTOR_RUNTIME_CONFIG_FILE_PATH = () => join(GLOBAL_CONFIGS_FOLDER(), 'actor-runtime', 'config.json'); + export const DEPRECATED_LOCAL_CONFIG_NAME = 'apify.json'; export const ACTOR_SPECIFICATION_FOLDER = '.actor'; diff --git a/src/lib/runtime/docker.ts b/src/lib/runtime/docker.ts index 4d595e45a..047803f40 100644 --- a/src/lib/runtime/docker.ts +++ b/src/lib/runtime/docker.ts @@ -3,7 +3,7 @@ import process from 'node:process'; import { execa } from 'execa'; import which from 'which'; -export const ACTOR_RUNTIME_IMAGE = 'apify/actor-runtime:latest'; +export const DEFAULT_ACTOR_RUNTIME_IMAGE = 'apify/actor-runtime:latest'; export const ACTOR_RUNTIME_CONTAINER_NAME = 'apify-actor-runtime'; @@ -202,6 +202,7 @@ export function socketMountArg(hostSocketPath: string, platform: NodeJS.Platform } export interface RuntimeRunArgsOptions { + image: string; dataDir: string; detach: boolean; hostSocketPath: string; @@ -209,6 +210,7 @@ export interface RuntimeRunArgsOptions { } export function buildRuntimeRunArgs({ + image, dataDir, detach, hostSocketPath, @@ -230,7 +232,7 @@ export function buildRuntimeRunArgs({ socketMountArg(hostSocketPath, platform), '-v', `${dataDir}:/data`, - ACTOR_RUNTIME_IMAGE, + image, ); return args; diff --git a/src/lib/runtime/ensure.ts b/src/lib/runtime/ensure.ts index a3b18a121..f61feb9ca 100644 --- a/src/lib/runtime/ensure.ts +++ b/src/lib/runtime/ensure.ts @@ -1,12 +1,15 @@ +import { readFileSync, writeFileSync } from 'node:fs'; import process from 'node:process'; import chalk from 'chalk'; +import { ACTOR_RUNTIME_CONFIG_FILE_PATH } from '../consts.js'; import { execWithLog } from '../exec.js'; +import { ensureApifyDirectory } from '../files.js'; import { error, info } from '../outputs.js'; import { - ACTOR_RUNTIME_IMAGE, CONTAINER_ENGINE_ENV_VAR, + DEFAULT_ACTOR_RUNTIME_IMAGE, type ContainerEngine, engineDaemonHint, engineInstallHint, @@ -16,16 +19,33 @@ import { } from './docker.js'; export interface EnsureActorRuntimeImageOptions { + image: string; forcePull?: boolean; } +/** The image the last 'apify runtime install' fetched, or the default when nothing was installed yet. */ +export function installedActorRuntimeImage(): string { + try { + const { image } = JSON.parse(readFileSync(ACTOR_RUNTIME_CONFIG_FILE_PATH(), 'utf-8')); + return typeof image === 'string' && image ? image : DEFAULT_ACTOR_RUNTIME_IMAGE; + } catch { + return DEFAULT_ACTOR_RUNTIME_IMAGE; + } +} + +export function rememberInstalledActorRuntimeImage(image: string) { + ensureApifyDirectory(ACTOR_RUNTIME_CONFIG_FILE_PATH()); + writeFileSync(ACTOR_RUNTIME_CONFIG_FILE_PATH(), JSON.stringify({ image }, null, '\t')); +} + /** * Finds a working container engine (Docker or Podman) and makes the Actor runtime image available on it. * Resolves to the engine to drive, or null after printing a user-facing error and setting the exit code. */ export async function ensureActorRuntimeImage({ + image, forcePull = false, -}: EnsureActorRuntimeImageOptions = {}): Promise { +}: EnsureActorRuntimeImageOptions): Promise { const found = await findContainerEngine(); if (!found) { const requested = requestedContainerEngine(); @@ -50,23 +70,25 @@ export async function ensureActorRuntimeImage({ return null; } - if (!forcePull && (await imageExistsLocally(engine, ACTOR_RUNTIME_IMAGE))) { - info({ message: `Actor runtime image '${ACTOR_RUNTIME_IMAGE}' is already available locally.` }); + if (!forcePull && (await imageExistsLocally(engine, image))) { + info({ message: `Actor runtime image '${image}' is already available locally.` }); + rememberInstalledActorRuntimeImage(image); return engine; } - info({ message: `Downloading the Actor runtime image '${ACTOR_RUNTIME_IMAGE}'...` }); + info({ message: `Downloading the Actor runtime image '${image}'...` }); try { - await execWithLog({ cmd: engine, args: ['pull', ACTOR_RUNTIME_IMAGE] }); + await execWithLog({ cmd: engine, args: ['pull', image] }); + rememberInstalledActorRuntimeImage(image); return engine; } catch { error({ message: [ - `Could not pull '${ACTOR_RUNTIME_IMAGE}'.`, + `Could not pull '${image}'.`, ` Check that you are online and can access the image - a private repository needs ${chalk.white.bold(`${engine} login`)} first.`, ' You can also build the image locally from an actor-runtime checkout instead:', - chalk.white.bold(` ${engine} build -t ${ACTOR_RUNTIME_IMAGE} .`), + chalk.white.bold(` ${engine} build -t ${image} .`), ].join('\n'), }); process.exitCode = 1; diff --git a/test/local/lib/runtime-docker.test.ts b/test/local/lib/runtime-docker.test.ts index 2df57f3a4..3a1e338f9 100644 --- a/test/local/lib/runtime-docker.test.ts +++ b/test/local/lib/runtime-docker.test.ts @@ -1,7 +1,7 @@ import { ACTOR_RUNTIME_CONTAINER_NAME, - ACTOR_RUNTIME_IMAGE, buildRuntimeRunArgs, + DEFAULT_ACTOR_RUNTIME_IMAGE, engineDaemonHint, engineInstallHint, requestedContainerEngine, @@ -73,6 +73,7 @@ describe('runtime/docker', () => { it('builds the canonical run command around the resolved host socket', () => { expect( buildRuntimeRunArgs({ + image: DEFAULT_ACTOR_RUNTIME_IMAGE, dataDir: '/home/me/data', detach: false, hostSocketPath: '/var/run/docker.sock', @@ -92,12 +93,13 @@ describe('runtime/docker', () => { '/var/run/docker.sock:/var/run/docker.sock', '-v', '/home/me/data:/data', - ACTOR_RUNTIME_IMAGE, + DEFAULT_ACTOR_RUNTIME_IMAGE, ]); }); it("mounts a rootless Podman socket at the runtime's expected path", () => { const args = buildRuntimeRunArgs({ + image: DEFAULT_ACTOR_RUNTIME_IMAGE, dataDir: '/data', detach: false, hostSocketPath: '/run/user/1000/podman/podman.sock', @@ -108,13 +110,14 @@ describe('runtime/docker', () => { it('adds --detach before the image when requested', () => { const args = buildRuntimeRunArgs({ + image: DEFAULT_ACTOR_RUNTIME_IMAGE, dataDir: '/data', detach: true, hostSocketPath: '/var/run/docker.sock', platform: 'linux', }); expect(args).toContain('--detach'); - expect(args.indexOf('--detach')).toBeLessThan(args.indexOf(ACTOR_RUNTIME_IMAGE)); + expect(args.indexOf('--detach')).toBeLessThan(args.indexOf(DEFAULT_ACTOR_RUNTIME_IMAGE)); }); }); }); diff --git a/test/local/lib/runtime-ensure.test.ts b/test/local/lib/runtime-ensure.test.ts new file mode 100644 index 000000000..544be392d --- /dev/null +++ b/test/local/lib/runtime-ensure.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; + +import { ACTOR_RUNTIME_CONFIG_FILE_PATH } from '../../../src/lib/consts.js'; +import { DEFAULT_ACTOR_RUNTIME_IMAGE } from '../../../src/lib/runtime/docker.js'; +import { installedActorRuntimeImage, rememberInstalledActorRuntimeImage } from '../../../src/lib/runtime/ensure.js'; +import { useAuthSetup } from '../../__setup__/hooks/useAuthSetup.js'; + +useAuthSetup(); + +describe('runtime/ensure installed image', () => { + it('falls back to the default image when nothing was installed yet', () => { + expect(installedActorRuntimeImage()).toBe(DEFAULT_ACTOR_RUNTIME_IMAGE); + }); + + it('remembers the installed image for the next start', () => { + rememberInstalledActorRuntimeImage('apify/actor-runtime:master-5462005'); + + expect(installedActorRuntimeImage()).toBe('apify/actor-runtime:master-5462005'); + expect(JSON.parse(readFileSync(ACTOR_RUNTIME_CONFIG_FILE_PATH(), 'utf-8'))).toEqual({ + image: 'apify/actor-runtime:master-5462005', + }); + }); +}); From edaa1c9a5d1e2aab71e97e600c6353fcc33f8b78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Proch=C3=A1zka?= Date: Fri, 11 Sep 2026 10:57:32 +0200 Subject: [PATCH 08/12] feat: add 'apify runtime status', 'connect' and 'disconnect' (#1430) 'apify runtime status' reports whether the runtime container is running, on which engine and image, the ports it publishes, the host directory mounted as its data directory, and which API the CLI currently targets. It exits 1 when the runtime is down, and takes --json. 'apify runtime connect' makes every CLI command target the local runtime, and 'apify runtime disconnect' reverts to the Apify platform. The connection is stored next to the installed image in ~/.apify/actor-runtime/config.json, so it holds across terminals and does not touch the login. APIFY_CLIENT_BASE_URL and APIFY_CONSOLE_URL keep taking precedence wherever they are set, so the current per-shell behavior is unchanged; both commands say so when they are set. Claude-Session: https://claude.ai/code/session_01PaySaAvWxMBNuVMSXXLqno Co-authored-by: Claude Opus 5 --- docs/reference.md | 84 ++++++++++++++--- scripts/generate-cli-docs.ts | 3 + src/commands/runtime/_index.ts | 26 +++++- src/commands/runtime/connect.ts | 63 +++++++++++++ src/commands/runtime/disconnect.ts | 49 ++++++++++ src/commands/runtime/status.ts | 124 +++++++++++++++++++++++++ src/lib/console-url.ts | 9 +- src/lib/hooks/useRentalSunsetNotice.ts | 3 +- src/lib/runtime/config.ts | 30 ++++++ src/lib/runtime/docker.ts | 86 ++++++++++++++++- src/lib/runtime/ensure.ts | 15 +-- src/lib/runtime/target.ts | 33 +++++++ src/lib/utils.ts | 5 +- test/local/lib/runtime-docker.test.ts | 54 +++++++++++ test/local/lib/runtime-target.test.ts | 51 ++++++++++ 15 files changed, 598 insertions(+), 37 deletions(-) create mode 100644 src/commands/runtime/connect.ts create mode 100644 src/commands/runtime/disconnect.ts create mode 100644 src/commands/runtime/status.ts create mode 100644 src/lib/runtime/config.ts create mode 100644 src/lib/runtime/target.ts create mode 100644 test/local/lib/runtime-target.test.ts diff --git a/docs/reference.md b/docs/reference.md index bdaf45d49..22215ee48 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -475,35 +475,50 @@ DESCRIPTION APIFY_CONTAINER_ENGINE=docker or =podman to choose. 'apify runtime install' checks that the engine is available and pulls the - runtime image. + runtime image, 'apify runtime start' runs it, and 'apify runtime status' says + whether it is up. The runtime publishes two ports on localhost: 3333 API http://localhost:3333 (Apify API compatible endpoint) 3000 Console http://localhost:3000 (web UI) - Point the Apify CLI (and Apify SDKs and API clients that honour these - variables) at the runtime instead of the Apify cloud by setting: + Run 'apify runtime connect' to send every Apify CLI command to the runtime + instead of the Apify cloud, and 'apify runtime disconnect' to go back. The + connection is remembered across terminals and does not touch your login. + + These environment variables point the CLI (and the Apify SDKs and API clients + that honour them) at the runtime for one shell only, and take precedence over + the connection wherever they are set: export APIFY_CLIENT_BASE_URL=http://localhost:3333 export APIFY_CONSOLE_URL=http://localhost:3000 - Unset them to talk to the Apify cloud again. 'apify runtime start' prints the - same values when the runtime boots. + Unset them to let 'apify runtime connect' decide where commands go. Pointed at the runtime, 'apify push' also registers the pushed directory as the Actor's live dev folder, so runs pick up local edits without another push; 'apify call --no-dev-folder' runs from the built image alone. SUBCOMMANDS - runtime install Installs the Actor runtime: verifies this - machine has a working container engine (Docker or Podman) and - downloads the Actor runtime image ('apify/actor-runtime:latest' - unless another one is given). - runtime start Starts the Actor runtime, a local Apify - platform running as a container on Docker or Podman. - runtime stop Stops the Actor runtime container started with - 'apify runtime start --detach'. + runtime install Installs the Actor runtime: verifies + this machine has a working container engine (Docker or + Podman) and downloads the Actor runtime image + ('apify/actor-runtime:latest' unless another one is given). + runtime start Starts the Actor runtime, a local Apify + platform running as a container on Docker or Podman. + runtime stop Stops the Actor runtime container + started with 'apify runtime start --detach'. + runtime status Prints whether the Actor runtime is + running, the ports it publishes, the host directory it keeps + its data in, and which API the Apify CLI currently talks to. + runtime connect Points the Apify CLI at the local Actor + runtime instead of the Apify platform, for every command + from now on and in every terminal. + runtime disconnect Reverts 'apify runtime connect': the + Apify CLI targets the Apify platform again, unless the API + and console URL environment variables point it somewhere + else. ``` ##### `apify runtime install` @@ -562,6 +577,49 @@ USAGE $ apify runtime stop ``` +##### `apify runtime status` + +```sh +DESCRIPTION + Prints whether the Actor runtime is running, the ports it publishes, the host + directory it keeps its data in, and which API the Apify CLI currently talks + to. + Exits with code 1 when the runtime is not running, so scripts can test for it. + +USAGE + $ apify runtime status [--json] + +FLAGS + --json Format the command output as JSON. +``` + +##### `apify runtime connect` + +```sh +DESCRIPTION + Points the Apify CLI at the local Actor runtime instead of the Apify platform, + for every command from now on and in every terminal. + The API and console URL environment variables keep taking precedence where + they are set, so a shell that exports them is unaffected. Your login is + untouched - run 'apify runtime disconnect' to target the Apify platform again. + +USAGE + $ apify runtime connect +``` + +##### `apify runtime disconnect` + +```sh +DESCRIPTION + Reverts 'apify runtime connect': the Apify CLI targets the Apify platform + again, unless the API and console URL environment variables point it somewhere + else. + The runtime itself keeps running - stop it with 'apify runtime stop'. + +USAGE + $ apify runtime disconnect +``` + ##### `apify actor` ```sh diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index e94dee85e..2b36b10c7 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -31,6 +31,9 @@ const categories: Record = { { command: Commands.runtimeInstall }, { command: Commands.runtimeStart }, { command: Commands.runtimeStop }, + { command: Commands.runtimeStatus }, + { command: Commands.runtimeConnect }, + { command: Commands.runtimeDisconnect }, { command: Commands.actor }, { command: Commands.actorCalculateMemory }, diff --git a/src/commands/runtime/_index.ts b/src/commands/runtime/_index.ts index e8c583ffd..2d4d18068 100644 --- a/src/commands/runtime/_index.ts +++ b/src/commands/runtime/_index.ts @@ -10,8 +10,11 @@ import { PODMAN_INSTALL_URL, runtimeEnvExportLines, } from '../../lib/runtime/docker.js'; +import { RuntimeConnectCommand } from './connect.js'; +import { RuntimeDisconnectCommand } from './disconnect.js'; import { RuntimeInstallCommand } from './install.js'; import { RuntimeStartCommand } from './start.js'; +import { RuntimeStatusCommand } from './status.js'; import { RuntimeStopCommand } from './stop.js'; export class RuntimeIndexCommand extends ApifyCommand { @@ -31,18 +34,20 @@ export class RuntimeIndexCommand extends ApifyCommand ` ${line}`), '', - `Unset them to talk to the Apify cloud again. 'apify runtime start' prints the same values when the runtime boots.`, + `Unset them to let 'apify runtime connect' decide where commands go.`, '', `Pointed at the runtime, 'apify push' also registers the pushed directory as the Actor's live dev folder, so runs pick up local edits without another push; 'apify call --no-dev-folder' runs from the built image alone.`, ].join('\n'); @@ -55,14 +60,25 @@ export class RuntimeIndexCommand extends ApifyCommand { + static override name = 'connect' as const; + + static override description = + `Points the Apify CLI at the local Actor runtime instead of the Apify platform, for every command from now on ` + + `and in every terminal.\n` + + `The API and console URL environment variables keep taking precedence where they are set, so a shell that ` + + `exports them is unaffected. Your login is untouched - run 'apify runtime disconnect' to target the Apify ` + + `platform again.`; + + static override group = 'Local Actor Development'; + + static override examples = [ + { + description: 'Send every Apify CLI command to the local Actor runtime.', + command: 'apify runtime connect', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-runtime-connect'; + + async run() { + setConnectedToActorRuntime(true); + + success({ message: 'The Apify CLI now targets the local Actor runtime.' }); + simpleLog({ + message: [ + ` API: ${ACTOR_RUNTIME_API_URL}`, + ` Console: ${ACTOR_RUNTIME_CONSOLE_URL}`, + '', + `Run ${chalk.white.bold('apify runtime disconnect')} to target the Apify platform again.`, + ].join('\n'), + }); + + const overrides = overridingRuntimeEnvVars(); + if (overrides.length) { + warning({ + message: [ + 'These environment variables are set in this shell and take precedence over the connection:', + ...overrides.map(([name, value]) => ` ${name}=${value}`), + 'Unset them to let the connection decide where commands go.', + ].join('\n'), + }); + } + + if (!(await findRunningRuntimeEngine())) { + warning({ + message: `The Actor runtime is not running - commands will fail until you start it with ${chalk.white.bold('apify runtime start --detach')}.`, + }); + } + } +} diff --git a/src/commands/runtime/disconnect.ts b/src/commands/runtime/disconnect.ts new file mode 100644 index 000000000..eaca6f3e6 --- /dev/null +++ b/src/commands/runtime/disconnect.ts @@ -0,0 +1,49 @@ +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { info, success, warning } from '../../lib/outputs.js'; +import { + isConnectedToActorRuntime, + overridingRuntimeEnvVars, + setConnectedToActorRuntime, +} from '../../lib/runtime/target.js'; + +export class RuntimeDisconnectCommand extends ApifyCommand { + static override name = 'disconnect' as const; + + static override description = + `Reverts 'apify runtime connect': the Apify CLI targets the Apify platform again, unless the API and console URL ` + + `environment variables point it somewhere else.\n` + + `The runtime itself keeps running - stop it with 'apify runtime stop'.`; + + static override group = 'Local Actor Development'; + + static override examples = [ + { + description: 'Send Apify CLI commands to the Apify platform again.', + command: 'apify runtime disconnect', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-runtime-disconnect'; + + async run() { + const wasConnected = isConnectedToActorRuntime(); + setConnectedToActorRuntime(false); + + if (wasConnected) { + success({ message: 'The Apify CLI no longer targets the local Actor runtime.' }); + } else { + info({ message: 'The Apify CLI was not connected to the local Actor runtime.' }); + } + + const overrides = overridingRuntimeEnvVars(); + if (overrides.length) { + warning({ + message: [ + 'These environment variables are still set in this shell and keep pointing the CLI away from the Apify platform:', + ...overrides.map(([name, value]) => ` ${name}=${value}`), + 'Unset them to talk to the Apify platform.', + ].join('\n'), + }); + } + } +} diff --git a/src/commands/runtime/status.ts b/src/commands/runtime/status.ts new file mode 100644 index 000000000..0cce332e2 --- /dev/null +++ b/src/commands/runtime/status.ts @@ -0,0 +1,124 @@ +import process from 'node:process'; + +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { simpleLog } from '../../lib/outputs.js'; +import { + ACTOR_RUNTIME_API_PORT, + ACTOR_RUNTIME_CONSOLE_PORT, + ACTOR_RUNTIME_CONTAINER_NAME, + findRunningRuntimeEngine, + inspectRuntimeContainer, + type PublishedPort, +} from '../../lib/runtime/docker.js'; +import { installedActorRuntimeImage } from '../../lib/runtime/ensure.js'; +import { isConnectedToActorRuntime, overridingRuntimeEnvVars, resolveApiBaseUrl } from '../../lib/runtime/target.js'; +import { printJsonToStdout } from '../../lib/utils.js'; + +function portRole(containerPort: number): string { + if (containerPort === ACTOR_RUNTIME_API_PORT) return 'API'; + if (containerPort === ACTOR_RUNTIME_CONSOLE_PORT) return 'Console'; + return ''; +} + +function portLine({ containerPort, protocol, hostAddress }: PublishedPort): string { + const role = portRole(containerPort); + return ` ${`${containerPort}/${protocol}`.padEnd(10)} -> ${hostAddress}${role ? ` (${role})` : ''}`; +} + +export class RuntimeStatusCommand extends ApifyCommand { + static override name = 'status' as const; + + static override description = + `Prints whether the Actor runtime is running, the ports it publishes, the host directory it keeps its data in, ` + + `and which API the Apify CLI currently talks to.\n` + + `Exits with code 1 when the runtime is not running, so scripts can test for it.`; + + static override group = 'Local Actor Development'; + + static override examples = [ + { + description: 'Show what the Actor runtime is doing.', + command: 'apify runtime status', + }, + { + description: 'Read the status as JSON.', + command: 'apify runtime status --json', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-runtime-status'; + + static override enableJsonFlag = true; + + async run() { + const engine = await findRunningRuntimeEngine(); + const container = engine ? await inspectRuntimeContainer(engine) : null; + const connected = isConnectedToActorRuntime(); + const overrides = overridingRuntimeEnvVars(); + + if (this.flags.json) { + if (!engine) process.exitCode = 1; + + printJsonToStdout({ + running: Boolean(engine), + engine, + container: engine ? ACTOR_RUNTIME_CONTAINER_NAME : undefined, + image: container?.image ?? (engine ? undefined : installedActorRuntimeImage()), + status: container?.status, + startedAt: container?.startedAt, + dataDir: container?.dataDir, + ports: container?.ports ?? [], + connected, + apiBaseUrl: resolveApiBaseUrl() ?? null, + envOverrides: Object.fromEntries(overrides), + }); + return; + } + + const lines: string[] = []; + + if (!engine) { + lines.push( + `Actor runtime: ${chalk.yellow('not running')}`, + '', + ` Installed image: ${installedActorRuntimeImage()}`, + '', + `Start it with ${chalk.white.bold('apify runtime start --detach')}.`, + ); + } else { + lines.push( + `Actor runtime: ${chalk.green(container?.status ?? 'running')} on ${engine} (container '${ACTOR_RUNTIME_CONTAINER_NAME}')`, + '', + ` Image: ${container?.image ?? installedActorRuntimeImage()}`, + ` Data directory: ${container?.dataDir ?? 'unknown'}`, + ); + + if (container?.startedAt) { + lines.push(` Started at: ${container.startedAt}`); + } + + lines.push('', container?.ports.length ? 'Published ports:' : 'Published ports: none'); + lines.push(...(container?.ports ?? []).map(portLine)); + } + + lines.push('', 'Apify CLI target:'); + lines.push( + ` ${connected ? `the Actor runtime (${chalk.white.bold('apify runtime connect')})` : `the Apify platform (connect with ${chalk.white.bold('apify runtime connect')})`}`, + ); + lines.push(` API base URL: ${resolveApiBaseUrl() ?? 'https://api.apify.com (default)'}`); + + if (overrides.length) { + lines.push( + '', + 'Set in this shell, taking precedence over the connection above:', + ...overrides.map(([name, value]) => ` ${name}=${value}`), + ); + } + + simpleLog({ message: lines.join('\n') }); + + if (!engine) process.exitCode = 1; + } +} diff --git a/src/lib/console-url.ts b/src/lib/console-url.ts index e422d8dbb..13ff56ce3 100644 --- a/src/lib/console-url.ts +++ b/src/lib/console-url.ts @@ -1,14 +1,15 @@ -import process from 'node:process'; +import { resolveConsoleUrl } from './runtime/target.js'; const DEFAULT_CONSOLE_URL = 'https://console.apify.com'; /** * Resolves the base URL of the Apify Console used whenever the CLI prints links. Set - * `APIFY_CONSOLE_URL` to point at a non-production Console (staging, a local instance, ...); - * otherwise the production Console is used. + * `APIFY_CONSOLE_URL` to point at a non-production Console (staging, a local instance, ...), or run + * `apify runtime connect` to point at the local Actor runtime's console; otherwise the production + * Console is used. */ export function getConsoleUrl(): string { - const explicit = process.env.APIFY_CONSOLE_URL; + const explicit = resolveConsoleUrl(); if (explicit) { const stripped = stripTrailingSlash(explicit); if (!URL.canParse(stripped)) { diff --git a/src/lib/hooks/useRentalSunsetNotice.ts b/src/lib/hooks/useRentalSunsetNotice.ts index 9d2467b74..06440e7f8 100644 --- a/src/lib/hooks/useRentalSunsetNotice.ts +++ b/src/lib/hooks/useRentalSunsetNotice.ts @@ -13,6 +13,7 @@ import { RENTAL_SUNSET_NOTICE_UNTIL, } from '../consts.js'; import { simpleLog, warning } from '../outputs.js'; +import { resolveApiBaseUrl } from '../runtime/target.js'; import type { AuthJSON } from '../types.js'; import { cliDebugPrint } from '../utils/cliDebugPrint.js'; import { useCLIMetadata } from './useCLIMetadata.js'; @@ -114,7 +115,7 @@ async function getLocalUsername() { async function fetchRentalActorCount(username: string) { const metadata = useCLIMetadata(); - const url = new URL('/v2/store', process.env.APIFY_CLIENT_BASE_URL || DEFAULT_API_BASE_URL); + const url = new URL('/v2/store', resolveApiBaseUrl() || DEFAULT_API_BASE_URL); try { // axios rather than `fetch`, so the lookup honors HTTP_PROXY/HTTPS_PROXY/NO_PROXY like every diff --git a/src/lib/runtime/config.ts b/src/lib/runtime/config.ts new file mode 100644 index 000000000..3934d25b4 --- /dev/null +++ b/src/lib/runtime/config.ts @@ -0,0 +1,30 @@ +import { readFileSync, writeFileSync } from 'node:fs'; + +import { ACTOR_RUNTIME_CONFIG_FILE_PATH } from '../consts.js'; +import { ensureApifyDirectory } from '../files.js'; + +/** What the CLI remembers about the Actor runtime between commands, stored in `~/.apify/actor-runtime/config.json`. */ +export interface ActorRuntimeConfig { + /** The image the last `apify runtime install` fetched. */ + image?: string; + /** Whether `apify runtime connect` pointed the CLI at the runtime. */ + connected?: boolean; +} + +export function readActorRuntimeConfig(): ActorRuntimeConfig { + try { + const parsed = JSON.parse(readFileSync(ACTOR_RUNTIME_CONFIG_FILE_PATH(), 'utf-8')) as unknown; + return parsed && typeof parsed === 'object' ? (parsed as ActorRuntimeConfig) : {}; + } catch { + return {}; + } +} + +/** Merges `patch` into the stored config, so writing one field never drops the others. */ +export function updateActorRuntimeConfig(patch: ActorRuntimeConfig) { + ensureApifyDirectory(ACTOR_RUNTIME_CONFIG_FILE_PATH()); + writeFileSync( + ACTOR_RUNTIME_CONFIG_FILE_PATH(), + JSON.stringify({ ...readActorRuntimeConfig(), ...patch }, null, '\t'), + ); +} diff --git a/src/lib/runtime/docker.ts b/src/lib/runtime/docker.ts index 047803f40..cdd80e097 100644 --- a/src/lib/runtime/docker.ts +++ b/src/lib/runtime/docker.ts @@ -44,6 +44,9 @@ export const CONTAINER_ENGINE_ENV_VAR = 'APIFY_CONTAINER_ENGINE'; /** Where the runtime container expects the engine's API socket. */ export const RUNTIME_SOCKET_PATH = '/var/run/docker.sock'; +/** Where the runtime container expects its data directory (storages, builds and run records). */ +export const RUNTIME_DATA_PATH = '/data'; + export function runtimeEnvExportLines(): string[] { return Object.entries(ACTOR_RUNTIME_ENV_VARS).map(([name, value]) => `export ${name}=${value}`); } @@ -167,6 +170,87 @@ export async function isRuntimeContainerRunning(engine: ContainerEngine): Promis } } +/** A port the runtime container publishes: `containerPort/protocol` reachable at `hostAddress`. */ +export interface PublishedPort { + containerPort: number; + protocol: string; + hostAddress: string; +} + +export interface RuntimeContainerInfo { + engine: ContainerEngine; + image?: string; + status?: string; + startedAt?: string; + /** The host directory mounted as the runtime's `/data`, where storages, builds and run records live. */ + dataDir?: string; + ports: PublishedPort[]; +} + +interface InspectedContainer { + Name?: string; + ImageName?: string; + Config?: { Image?: string }; + State?: { Status?: string; StartedAt?: string }; + Mounts?: { Destination?: string; Source?: string }[]; + NetworkSettings?: { Ports?: Record }; + HostConfig?: { PortBindings?: Record }; +} + +function parsePublishedPorts(container: InspectedContainer): PublishedPort[] { + const bindings = container.NetworkSettings?.Ports ?? container.HostConfig?.PortBindings ?? {}; + const ports: PublishedPort[] = []; + + for (const [portAndProtocol, hostBindings] of Object.entries(bindings)) { + const [port, protocol = 'tcp'] = portAndProtocol.split('/'); + const containerPort = Number(port); + if (!Number.isInteger(containerPort)) continue; + + for (const binding of hostBindings ?? []) { + if (!binding?.HostPort) continue; + // An empty HostIp means every interface, which both engines print as 0.0.0.0. + ports.push({ containerPort, protocol, hostAddress: `${binding.HostIp || '0.0.0.0'}:${binding.HostPort}` }); + } + } + + return ports.sort((a, b) => a.containerPort - b.containerPort); +} + +/** Reads one `inspect --format '{{json .}}'` payload, in either engine's shape. Null when it is unusable. */ +export function parseRuntimeContainerInfo(engine: ContainerEngine, raw: string): RuntimeContainerInfo | null { + let container: InspectedContainer; + try { + container = JSON.parse(raw) as InspectedContainer; + } catch { + return null; + } + + if (!container || typeof container !== 'object') return null; + + return { + engine, + // Docker reports the image under Config, Podman at the top level. + image: container.Config?.Image ?? container.ImageName, + status: container.State?.Status, + startedAt: container.State?.StartedAt, + dataDir: container.Mounts?.find((mount) => mount.Destination === RUNTIME_DATA_PATH)?.Source, + ports: parsePublishedPorts(container), + }; +} + +/** + * What the engine knows about the runtime container - its image, published ports and data directory. + * Null when the container is gone or the engine cannot be asked about it. + */ +export async function inspectRuntimeContainer(engine: ContainerEngine): Promise { + try { + const { stdout } = await execa(engine, ['inspect', ACTOR_RUNTIME_CONTAINER_NAME, '--format', '{{json .}}']); + return parseRuntimeContainerInfo(engine, stdout); + } catch { + return null; + } +} + function unixSocketPath(url: string | undefined): string | undefined { return url?.startsWith('unix://') ? url.slice('unix://'.length) : undefined; } @@ -231,7 +315,7 @@ export function buildRuntimeRunArgs({ '-v', socketMountArg(hostSocketPath, platform), '-v', - `${dataDir}:/data`, + `${dataDir}:${RUNTIME_DATA_PATH}`, image, ); diff --git a/src/lib/runtime/ensure.ts b/src/lib/runtime/ensure.ts index f61feb9ca..a647d9356 100644 --- a/src/lib/runtime/ensure.ts +++ b/src/lib/runtime/ensure.ts @@ -1,12 +1,10 @@ -import { readFileSync, writeFileSync } from 'node:fs'; import process from 'node:process'; import chalk from 'chalk'; -import { ACTOR_RUNTIME_CONFIG_FILE_PATH } from '../consts.js'; import { execWithLog } from '../exec.js'; -import { ensureApifyDirectory } from '../files.js'; import { error, info } from '../outputs.js'; +import { readActorRuntimeConfig, updateActorRuntimeConfig } from './config.js'; import { CONTAINER_ENGINE_ENV_VAR, DEFAULT_ACTOR_RUNTIME_IMAGE, @@ -25,17 +23,12 @@ export interface EnsureActorRuntimeImageOptions { /** The image the last 'apify runtime install' fetched, or the default when nothing was installed yet. */ export function installedActorRuntimeImage(): string { - try { - const { image } = JSON.parse(readFileSync(ACTOR_RUNTIME_CONFIG_FILE_PATH(), 'utf-8')); - return typeof image === 'string' && image ? image : DEFAULT_ACTOR_RUNTIME_IMAGE; - } catch { - return DEFAULT_ACTOR_RUNTIME_IMAGE; - } + const { image } = readActorRuntimeConfig(); + return image || DEFAULT_ACTOR_RUNTIME_IMAGE; } export function rememberInstalledActorRuntimeImage(image: string) { - ensureApifyDirectory(ACTOR_RUNTIME_CONFIG_FILE_PATH()); - writeFileSync(ACTOR_RUNTIME_CONFIG_FILE_PATH(), JSON.stringify({ image }, null, '\t')); + updateActorRuntimeConfig({ image }); } /** diff --git a/src/lib/runtime/target.ts b/src/lib/runtime/target.ts new file mode 100644 index 000000000..aac5017ca --- /dev/null +++ b/src/lib/runtime/target.ts @@ -0,0 +1,33 @@ +import process from 'node:process'; + +import { readActorRuntimeConfig, updateActorRuntimeConfig } from './config.js'; +import { ACTOR_RUNTIME_API_URL, ACTOR_RUNTIME_CONSOLE_URL, ACTOR_RUNTIME_ENV_VARS } from './docker.js'; + +/** Whether `apify runtime connect` pointed the CLI at the local Actor runtime. */ +export function isConnectedToActorRuntime(): boolean { + return readActorRuntimeConfig().connected === true; +} + +export function setConnectedToActorRuntime(connected: boolean) { + updateActorRuntimeConfig({ connected }); +} + +/** The environment variables from {@link ACTOR_RUNTIME_ENV_VARS} the user set themselves, with their values. */ +export function overridingRuntimeEnvVars(env: NodeJS.ProcessEnv = process.env): [string, string][] { + return Object.keys(ACTOR_RUNTIME_ENV_VARS) + .filter((name) => env[name]) + .map((name) => [name, env[name]!]); +} + +/** + * The API the CLI talks to: `APIFY_CLIENT_BASE_URL` when set, else the local Actor runtime while + * `apify runtime connect` is in effect, else undefined for the Apify platform default. + */ +export function resolveApiBaseUrl(env: NodeJS.ProcessEnv = process.env): string | undefined { + return env.APIFY_CLIENT_BASE_URL || (isConnectedToActorRuntime() ? ACTOR_RUNTIME_API_URL : undefined); +} + +/** The Console the CLI links to, resolved the same way as {@link resolveApiBaseUrl}. */ +export function resolveConsoleUrl(env: NodeJS.ProcessEnv = process.env): string | undefined { + return env.APIFY_CONSOLE_URL || (isConnectedToActorRuntime() ? ACTOR_RUNTIME_CONSOLE_URL : undefined); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 1d6de0299..42005d5d9 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -45,6 +45,7 @@ import { ensureMigrated, getBackend, getProxyPassword, getToken, setProxyPasswor import { deleteFile, ensureApifyDirectory, ensureFolderExistsSync, rimrafPromised } from './files.js'; import { useCLIMetadata } from './hooks/useCLIMetadata.js'; import { inputFileRegExp, TEMP_INPUT_KEY_PREFIX } from './input-key.js'; +import { resolveApiBaseUrl } from './runtime/target.js'; import type { AuthJSON } from './types.js'; import { cliDebugPrint } from './utils/cliDebugPrint.js'; @@ -145,7 +146,7 @@ export const getApifyClientOptions = async (token?: string, apiBaseUrl?: string) return { token: resolvedToken, - baseUrl: apiBaseUrl || process.env.APIFY_CLIENT_BASE_URL, + baseUrl: apiBaseUrl || resolveApiBaseUrl(), requestInterceptors: [ (config) => { config.headers ??= new AxiosHeaders() as CJSAxiosHeaders; @@ -586,7 +587,7 @@ export const outputJobLog = async ({ apifyClient?: ApifyClient; }) => { const { id: logId, status } = job; - const client = apifyClient || new ApifyClient({ baseUrl: process.env.APIFY_CLIENT_BASE_URL }); + const client = apifyClient || new ApifyClient({ baseUrl: resolveApiBaseUrl() }); // In case job was already done just output log if (ACTOR_JOB_TERMINAL_STATUSES.includes(status as never)) { diff --git a/test/local/lib/runtime-docker.test.ts b/test/local/lib/runtime-docker.test.ts index 3a1e338f9..8667e472e 100644 --- a/test/local/lib/runtime-docker.test.ts +++ b/test/local/lib/runtime-docker.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_ACTOR_RUNTIME_IMAGE, engineDaemonHint, engineInstallHint, + parseRuntimeContainerInfo, requestedContainerEngine, resolveEngineSocketPath, socketMountArg, @@ -69,6 +70,59 @@ describe('runtime/docker', () => { }); }); + describe('parseRuntimeContainerInfo()', () => { + it("reads the image, data directory and published ports out of either engine's inspect payload", () => { + expect( + parseRuntimeContainerInfo( + 'docker', + JSON.stringify({ + Config: { Image: 'apify/actor-runtime:latest' }, + State: { Status: 'running', StartedAt: '2026-09-11T08:00:00Z' }, + Mounts: [ + { Destination: '/var/run/docker.sock', Source: '/var/run/docker.sock' }, + { Destination: '/data', Source: '/home/me/.apify/actor-runtime/data' }, + ], + NetworkSettings: { + Ports: { + '3000/tcp': [{ HostIp: '0.0.0.0', HostPort: '3000' }], + '3333/tcp': [{ HostIp: '127.0.0.1', HostPort: '3333' }], + }, + }, + }), + ), + ).toEqual({ + engine: 'docker', + image: 'apify/actor-runtime:latest', + status: 'running', + startedAt: '2026-09-11T08:00:00Z', + dataDir: '/home/me/.apify/actor-runtime/data', + ports: [ + { containerPort: 3000, protocol: 'tcp', hostAddress: '0.0.0.0:3000' }, + { containerPort: 3333, protocol: 'tcp', hostAddress: '127.0.0.1:3333' }, + ], + }); + + // Podman names the image at the top level and can report the bindings under HostConfig. + expect( + parseRuntimeContainerInfo( + 'podman', + JSON.stringify({ + ImageName: 'docker.io/apify/actor-runtime:latest', + State: { Status: 'running' }, + Mounts: [{ Destination: '/data', Source: '/home/me/data' }], + HostConfig: { PortBindings: { '3333/tcp': [{ HostIp: '', HostPort: '3333' }] } }, + }), + ), + ).toMatchObject({ + image: 'docker.io/apify/actor-runtime:latest', + dataDir: '/home/me/data', + ports: [{ containerPort: 3333, protocol: 'tcp', hostAddress: '0.0.0.0:3333' }], + }); + + expect(parseRuntimeContainerInfo('docker', 'not json')).toBeNull(); + }); + }); + describe('buildRuntimeRunArgs()', () => { it('builds the canonical run command around the resolved host socket', () => { expect( diff --git a/test/local/lib/runtime-target.test.ts b/test/local/lib/runtime-target.test.ts new file mode 100644 index 000000000..ea8140f80 --- /dev/null +++ b/test/local/lib/runtime-target.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from 'node:fs'; + +import { ACTOR_RUNTIME_CONFIG_FILE_PATH } from '../../../src/lib/consts.js'; +import { ACTOR_RUNTIME_API_URL, ACTOR_RUNTIME_CONSOLE_URL } from '../../../src/lib/runtime/docker.js'; +import { rememberInstalledActorRuntimeImage } from '../../../src/lib/runtime/ensure.js'; +import { + overridingRuntimeEnvVars, + resolveApiBaseUrl, + resolveConsoleUrl, + setConnectedToActorRuntime, +} from '../../../src/lib/runtime/target.js'; +import { useAuthSetup } from '../../__setup__/hooks/useAuthSetup.js'; + +useAuthSetup(); + +describe('runtime/target', () => { + it('targets the runtime while connected and the platform otherwise, keeping the installed image', () => { + rememberInstalledActorRuntimeImage('apify/actor-runtime:master-5462005'); + + expect(resolveApiBaseUrl({})).toBeUndefined(); + expect(resolveConsoleUrl({})).toBeUndefined(); + + setConnectedToActorRuntime(true); + + expect(resolveApiBaseUrl({})).toBe(ACTOR_RUNTIME_API_URL); + expect(resolveConsoleUrl({})).toBe(ACTOR_RUNTIME_CONSOLE_URL); + expect(JSON.parse(readFileSync(ACTOR_RUNTIME_CONFIG_FILE_PATH(), 'utf-8'))).toEqual({ + image: 'apify/actor-runtime:master-5462005', + connected: true, + }); + + setConnectedToActorRuntime(false); + + expect(resolveApiBaseUrl({})).toBeUndefined(); + expect(resolveConsoleUrl({})).toBeUndefined(); + }); + + it('lets the environment variables win over the connection', () => { + setConnectedToActorRuntime(true); + + const env = { APIFY_CLIENT_BASE_URL: 'https://api.apify.com', APIFY_CONSOLE_URL: 'https://console.apify.com' }; + + expect(resolveApiBaseUrl(env)).toBe('https://api.apify.com'); + expect(resolveConsoleUrl(env)).toBe('https://console.apify.com'); + expect(overridingRuntimeEnvVars(env)).toEqual([ + ['APIFY_CLIENT_BASE_URL', 'https://api.apify.com'], + ['APIFY_CONSOLE_URL', 'https://console.apify.com'], + ]); + expect(overridingRuntimeEnvVars({})).toEqual([]); + }); +}); From be5b233e73d6fa2194b143a070e9501f3bbe55aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Proch=C3=A1zka?= Date: Tue, 15 Sep 2026 08:35:11 +0200 Subject: [PATCH 09/12] docs: Add `apify runtime skill` command for Agent Skills (#1444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `apify runtime skill [--install]`, which reads the runtime's own Agent Skill over HTTP when the runtime is running, or straight out of the installed image when it is not — so `apify runtime install` is the only prerequisite. No copy is bundled with the CLI: one could only ever describe the image the CLI was released against. `runtime install`, `start`, `connect` and `status` now print one line pointing at the command. Refs apify/actor-runtime#53. Needs apify/actor-runtime#54 to land first, since the image must carry the file. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ExpRUXDzyTmT828ugqkjAP --------- Co-authored-by: Claude --- docs/reference.md | 26 +++++ scripts/generate-cli-docs.ts | 1 + skills/apify/SKILL.md | 3 + src/commands/runtime/_index.ts | 2 + src/commands/runtime/connect.ts | 3 + src/commands/runtime/install.ts | 8 +- src/commands/runtime/skill.ts | 110 ++++++++++++++++++++ src/commands/runtime/start.ts | 3 + src/commands/runtime/status.ts | 5 + src/lib/runtime/docker.ts | 10 ++ src/lib/runtime/skill.ts | 150 +++++++++++++++++++++++++++ test/local/lib/runtime-skill.test.ts | 114 ++++++++++++++++++++ 12 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 src/commands/runtime/skill.ts create mode 100644 src/lib/runtime/skill.ts create mode 100644 test/local/lib/runtime-skill.test.ts diff --git a/docs/reference.md b/docs/reference.md index 22215ee48..06474e1c5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -519,6 +519,8 @@ SUBCOMMANDS Apify CLI targets the Apify platform again, unless the API and console URL environment variables point it somewhere else. + runtime skill Prints the Actor runtime's Agent Skill - + the runtime's own instructions for how to use it. ``` ##### `apify runtime install` @@ -620,6 +622,30 @@ USAGE $ apify runtime disconnect ``` +##### `apify runtime skill` + +```sh +DESCRIPTION + Prints the Actor runtime's Agent Skill - the runtime's own instructions for + how to use it. + The skill ships inside the runtime image, so it always describes the runtime + you actually have. It is read over HTTP when the runtime is running, and + straight out of the installed image when it is not - so 'apify runtime + install' is all it needs. + Use --install to put it in your agent's skills directory, where it loads on + demand in this and later sessions instead of only the terminal it was printed + into. + +USAGE + $ apify runtime skill [--install] [--raw] + +FLAGS + --install Write the skill into every agent skills directory found, + instead of printing it. + --raw Print the file exactly as it ships, frontmatter and all, + with no added header. +``` + ##### `apify actor` ```sh diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index 2b36b10c7..0f9b467ba 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -34,6 +34,7 @@ const categories: Record = { { command: Commands.runtimeStatus }, { command: Commands.runtimeConnect }, { command: Commands.runtimeDisconnect }, + { command: Commands.runtimeSkill }, { command: Commands.actor }, { command: Commands.actorCalculateMemory }, diff --git a/skills/apify/SKILL.md b/skills/apify/SKILL.md index f8e15ab26..93620545c 100644 --- a/skills/apify/SKILL.md +++ b/skills/apify/SKILL.md @@ -91,6 +91,8 @@ apify key-value-stores keys --json `apify runtime` runs a self-contained local Apify platform as a container on Docker or Podman. Use it to develop and test Actors against a platform-compatible API without touching the user's cloud account. It ships on the `runtime` npm dist-tag, not on `latest`. +This section covers only how to *get* a runtime. Once you have one, **run `apify runtime skill --install`** (or `apify runtime skill` to read it now) — the runtime ships its own Agent Skill on how to use it. That skill lives in the runtime image, so it always matches the runtime you actually have; read it rather than reconstructing it from memory. + **Prerequisite: Docker or Podman.** One of them must be installed and running before any `apify runtime` command works; the CLI does not install either. It uses the first engine found on PATH (Docker before Podman); `APIFY_CONTAINER_ENGINE=podman` forces Podman. With Podman, the API socket must be served: check with `podman info --format '{{.Host.RemoteSocket.Exists}}'` (must print `true`). If it does not, run `systemctl --user enable --now podman.socket` (rootless) or `sudo systemctl enable --now podman.socket` (rootful); without systemd, `podman system service --time=0 &`. On macOS/Windows, `podman machine start` first. Rootful and rootless Podman both work. @@ -139,6 +141,7 @@ export APIFY_DISABLE_KEYRING=1 $APIFY runtime install $APIFY runtime start --detach --data-dir ./runtime-data # omit --detach to run in the foreground (Ctrl+C stops it) +$APIFY runtime skill --install # install the runtime's own skill, then follow it $APIFY login --token local-dev-token # the runtime accepts any token $APIFY actors ls --json # now talks to the local runtime $APIFY runtime stop diff --git a/src/commands/runtime/_index.ts b/src/commands/runtime/_index.ts index 2d4d18068..04b51aa78 100644 --- a/src/commands/runtime/_index.ts +++ b/src/commands/runtime/_index.ts @@ -13,6 +13,7 @@ import { import { RuntimeConnectCommand } from './connect.js'; import { RuntimeDisconnectCommand } from './disconnect.js'; import { RuntimeInstallCommand } from './install.js'; +import { RuntimeSkillCommand } from './skill.js'; import { RuntimeStartCommand } from './start.js'; import { RuntimeStatusCommand } from './status.js'; import { RuntimeStopCommand } from './stop.js'; @@ -78,6 +79,7 @@ export class RuntimeIndexCommand extends ApifyCommand { @@ -55,5 +60,6 @@ export class RuntimeInstallCommand extends ApifyCommand { + static override name = 'skill' as const; + + static override description = + `Prints the Actor runtime's Agent Skill - the runtime's own instructions for how to use it.\n` + + `The skill ships inside the runtime image, so it always describes the runtime you actually have. ` + + `It is read over HTTP when the runtime is running, and straight out of the installed image when ` + + `it is not - so 'apify runtime install' is all it needs.\n` + + `Use --install to put it in your agent's skills directory, where it loads on demand in this and ` + + `later sessions instead of only the terminal it was printed into.`; + + static override group = 'Local Actor Development'; + + static override examples = [ + { + description: `Install the skill for the agents on this machine.`, + command: 'apify runtime skill --install', + }, + { + description: 'Print the skill to read it now.', + command: 'apify runtime skill', + }, + { + description: `Save it somewhere else.`, + command: 'apify runtime skill > SKILL.md', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-runtime-skill'; + + static override flags = { + install: Flags.boolean({ + description: `Write the skill into every agent skills directory found, instead of printing it.`, + default: false, + }), + raw: Flags.boolean({ + description: `Print the file exactly as it ships, frontmatter and all, with no added header.`, + default: false, + }), + }; + + async run() { + const resolved = await resolveRuntimeSkill(); + + if (!resolved) { + error({ + message: [ + `Could not read the Actor runtime's Agent Skill.`, + ` The skill ships inside the runtime image, so install it first:`, + chalk.white.bold(` apify runtime install`), + ].join('\n'), + }); + process.exitCode = 1; + return; + } + + const { content, source } = resolved; + + if (!this.flags.install) { + // stdout only, so 'apify runtime skill > SKILL.md' stays clean, like 'apify help --skill'. + simpleLog({ + stdout: true, + message: (this.flags.raw ? content : frameSkillForReading(content, source)).trimEnd(), + }); + return; + } + + const stamped = stampSkill(content, source); + const written: string[] = []; + + for (const target of skillTargets()) { + try { + await writeSkillTo(target, stamped); + written.push(` ${tildify(target.directory)} ${chalk.gray(`(${target.label})`)}`); + } catch (err) { + // One unwritable location must not lose the installs that did work. + info({ message: chalk.gray(`Skipped ${tildify(target.directory)}: ${(err as Error).message}`) }); + } + } + + if (!written.length) { + error({ message: `Could not write the skill to any agent skills directory.` }); + process.exitCode = 1; + return; + } + + success({ message: `Installed the Actor runtime skill from ${describeSkillSource(source)}:` }); + simpleLog({ message: written.join('\n') }); + simpleLog({ + message: chalk.gray(`\nAgents pick it up on their next start. Re-run this after upgrading the runtime image.`), + }); + } +} diff --git a/src/commands/runtime/start.ts b/src/commands/runtime/start.ts index 969ab05b8..7f15dd5ce 100644 --- a/src/commands/runtime/start.ts +++ b/src/commands/runtime/start.ts @@ -17,6 +17,7 @@ import { findRunningRuntimeEngine, resolveEngineSocketPath, runtimeEnvExportLines, + runtimeSkillHintLines, } from '../../lib/runtime/docker.js'; import { ensureActorRuntimeImage, installedActorRuntimeImage } from '../../lib/runtime/ensure.js'; @@ -88,6 +89,8 @@ export class RuntimeStartCommand extends ApifyCommand chalk.white.bold(` ${line}`)), + '', + ...runtimeSkillHintLines(), ].join('\n'), }); diff --git a/src/commands/runtime/status.ts b/src/commands/runtime/status.ts index 0cce332e2..72c114129 100644 --- a/src/commands/runtime/status.ts +++ b/src/commands/runtime/status.ts @@ -11,6 +11,7 @@ import { findRunningRuntimeEngine, inspectRuntimeContainer, type PublishedPort, + runtimeSkillHintLines, } from '../../lib/runtime/docker.js'; import { installedActorRuntimeImage } from '../../lib/runtime/ensure.js'; import { isConnectedToActorRuntime, overridingRuntimeEnvVars, resolveApiBaseUrl } from '../../lib/runtime/target.js'; @@ -117,6 +118,10 @@ export class RuntimeStatusCommand extends ApifyCommand { + try { + const response = await fetch(`${baseUrl.replace(/\/v2\/?$/, '')}/actor-runtime/skill`, { + headers: APIFY_CLIENT_DEFAULT_HEADERS, + }); + if (!response.ok) return null; + + const content = await response.text(); + return content.trim() ? content : null; + } catch { + return null; + } +} + +/** Without starting the image, so 'apify runtime install' is the prerequisite rather than a running + * container. Copies the whole directory so material added to the skill later comes along unchanged. */ +async function readSkillFromImage(engine: ContainerEngine, image: string): Promise { + const scratch = await mkdtemp(join(tmpdir(), 'apify-runtime-skill-')); + let container: string | undefined; + + try { + const { stdout } = await execa(engine, ['create', image]); + container = stdout.trim().split('\n').at(-1)?.trim(); + if (!container) return null; + + await execa(engine, ['cp', `${container}:${SKILL_DIR_IN_IMAGE}/.`, scratch]); + return await readFile(join(scratch, SKILL_FILE), 'utf8'); + } catch { + return null; + } finally { + if (container) await execa(engine, ['rm', '-f', container]).catch(() => {}); + await rm(scratch, { recursive: true, force: true }); + } +} + +/** Cheapest-first: HTTP, then the installed image. Deliberately no copy bundled with the CLI - one could + * only ever describe the image the CLI was released against, not the one present. */ +export async function resolveRuntimeSkill(): Promise { + const baseUrl = resolveApiBaseUrl() ?? ACTOR_RUNTIME_API_URL; + + const overHttp = await fetchSkillFromRuntime(baseUrl); + if (overHttp) return { content: overHttp, source: { kind: 'runtime', baseUrl } }; + + const engine = await findRunningRuntimeEngine(); + const image = installedActorRuntimeImage(); + + // A running container that did not answer means an older runtime without the endpoint; its image is + // still worth reading. Otherwise try whichever engine has the image on disk. + for (const candidate of engine ? [engine] : (['docker', 'podman'] as const)) { + if (!(await imageExistsLocally(candidate, image))) continue; + + const fromImage = await readSkillFromImage(candidate, image); + if (fromImage) return { content: fromImage, source: { kind: 'image', image } }; + } + + return null; +} + +/** Frontmatter is metadata for a skill loader; a caller that prints has none, so swap it for a line + * saying what the reader is holding. The body is passed through untouched. */ +export function frameSkillForReading(content: string, source: SkillSource): string { + const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trimStart(); + + return [ + ``, + '', + `> The local Actor runtime's own instructions for using it. To keep them loaded in later sessions`, + `> instead of only this one, run \`apify runtime skill --install\`.`, + '', + body, + ].join('\n'); +} + +export interface SkillTarget { + directory: string; + label: string; +} + +/** The directories of the Agent Skills convention. */ +export function skillTargets(home = userHomeDir(), cwd = process.cwd()): SkillTarget[] { + const targets: SkillTarget[] = []; + + if (home) { + targets.push( + { directory: join(home, '.claude', 'skills', RUNTIME_SKILL_NAME), label: 'Claude Code' }, + { directory: join(home, '.agents', 'skills', RUNTIME_SKILL_NAME), label: 'Codex and other agents' }, + ); + } + + // Only where the project already keeps skills: creating these in whatever repository the user happens + // to be standing in is a tracked, committable change they did not ask for. + for (const [directory, label] of [ + ['.claude', 'this project (Claude Code)'], + ['.agents', 'this project'], + ] as const) { + if (existsSync(join(cwd, directory, 'skills'))) { + targets.push({ directory: join(cwd, directory, 'skills', RUNTIME_SKILL_NAME), label }); + } + } + + return targets; +} + +/** Under the frontmatter, so a loader still reads that first. An installed skill is a snapshot; this is + * what makes a stale one recognisable. */ +export function stampSkill(content: string, source: SkillSource, now = new Date()): string { + const stamp = ``; + const frontmatter = /^(---\r?\n[\s\S]*?\r?\n---\r?\n)/.exec(content); + + return frontmatter ? `${frontmatter[1]}\n${stamp}\n${content.slice(frontmatter[1].length)}` : `${stamp}\n${content}`; +} + +export async function writeSkillTo(target: SkillTarget, content: string): Promise { + await mkdir(target.directory, { recursive: true }); + await writeFile(join(target.directory, SKILL_FILE), content); +} diff --git a/test/local/lib/runtime-skill.test.ts b/test/local/lib/runtime-skill.test.ts new file mode 100644 index 000000000..050fa00e0 --- /dev/null +++ b/test/local/lib/runtime-skill.test.ts @@ -0,0 +1,114 @@ +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + describeSkillSource, + frameSkillForReading, + RUNTIME_SKILL_NAME, + skillTargets, + stampSkill, + writeSkillTo, +} from '../../../src/lib/runtime/skill.js'; + +const SKILL = ['---', 'name: apify-actor-runtime', 'description: drives the runtime', '---', '', '# Body', 'text'].join( + '\n', +); + +describe('frameSkillForReading', () => { + it('replaces the frontmatter with a header saying what the reader is holding', () => { + const framed = frameSkillForReading(SKILL, { kind: 'runtime', baseUrl: 'http://localhost:3333' }); + + // Frontmatter is for a skill loader; a caller that prints has none. + expect(framed).not.toContain('description: drives the runtime'); + expect(framed).toContain(`The local Actor runtime's own instructions for using it`); + expect(framed).toContain('apify runtime skill --install'); + expect(framed).toContain('# Body'); + }); + + it('names where the skill came from', () => { + expect(frameSkillForReading(SKILL, { kind: 'image', image: 'apify/actor-runtime:latest' })).toContain( + `the 'apify/actor-runtime:latest' image`, + ); + }); + + it('passes through a file that has no frontmatter', () => { + expect(frameSkillForReading('# Body only', { kind: 'image', image: 'x' })).toContain('# Body only'); + }); +}); + +describe('stampSkill', () => { + it('records the source and date below the frontmatter, leaving the frontmatter first', () => { + const stamped = stampSkill(SKILL, { kind: 'image', image: 'apify/actor-runtime:latest' }, new Date('2026-09-11')); + + // A loader reads the frontmatter from the top of the file. + expect(stamped.startsWith('---\nname: apify-actor-runtime')).toBe(true); + expect(stamped).toContain('2026-09-11'); + expect(stamped).toContain(`the 'apify/actor-runtime:latest' image`); + expect(stamped).toContain('# Body'); + }); + + it('still stamps a file with no frontmatter', () => { + const stamped = stampSkill('# Body only', { kind: 'runtime', baseUrl: 'http://localhost:3333' }); + + expect(stamped).toContain('Installed by'); + expect(stamped).toContain('# Body only'); + }); +}); + +describe('describeSkillSource', () => { + it('distinguishes a live runtime from a stopped image', () => { + expect(describeSkillSource({ kind: 'runtime', baseUrl: 'http://localhost:3333' })).toBe( + 'the runtime at http://localhost:3333', + ); + expect(describeSkillSource({ kind: 'image', image: 'apify/actor-runtime:latest' })).toBe( + `the 'apify/actor-runtime:latest' image`, + ); + }); +}); + +describe('skillTargets', () => { + it('covers Claude Code and the open-standard location under the home directory', () => { + const targets = skillTargets('/home/someone', '/work/project-without-agent-dirs'); + + expect(targets.map((target) => target.directory)).toEqual([ + join('/home/someone', '.claude', 'skills', RUNTIME_SKILL_NAME), + join('/home/someone', '.agents', 'skills', RUNTIME_SKILL_NAME), + ]); + }); + + it('adds a project location only when that project already keeps skills of its own', async () => { + const project = await mkdtemp(join(tmpdir(), 'apify-skill-project-')); + + try { + expect(skillTargets('/home/someone', project)).toHaveLength(2); + + await mkdir(join(project, '.agents', 'skills'), { recursive: true }); + + const targets = skillTargets('/home/someone', project); + expect(targets).toHaveLength(3); + expect(targets[2].directory).toBe(join(project, '.agents', 'skills', RUNTIME_SKILL_NAME)); + } finally { + await rm(project, { recursive: true, force: true }); + } + }); + + it('returns nothing rather than writing into an unrelated directory when there is no home', () => { + expect(skillTargets('', '/work/project-without-agent-dirs')).toEqual([]); + }); +}); + +describe('writeSkillTo', () => { + it('creates the whole skill directory and writes SKILL.md into it', async () => { + const root = await mkdtemp(join(tmpdir(), 'apify-skill-test-')); + + try { + const directory = join(root, 'nested', '.claude', 'skills', RUNTIME_SKILL_NAME); + await writeSkillTo({ directory, label: 'test' }, SKILL); + + expect(await readFile(join(directory, 'SKILL.md'), 'utf8')).toBe(SKILL); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); From c8742136cfd985dc5555469e3852e3921262fa98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Proch=C3=A1zka?= Date: Wed, 16 Sep 2026 07:30:39 -0700 Subject: [PATCH 10/12] feat(runtime): re-check the registry on install so outdated images get updated (#1448) `apify runtime install` no longer skips the pull when the image exists locally, so an outdated `apify/actor-runtime:latest` gets updated from the registry. Only `@sha256:` digests short-circuit; `apify runtime start` still uses the local copy. --------- Co-authored-by: Claude --- docs/reference.md | 2 ++ src/commands/runtime/install.ts | 2 ++ src/lib/runtime/docker.ts | 8 ++++++++ src/lib/runtime/ensure.ts | 15 +++++++++++++-- test/local/lib/runtime-docker.test.ts | 11 +++++++++++ 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 06474e1c5..403db8035 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -530,6 +530,8 @@ DESCRIPTION Installs the Actor runtime: verifies this machine has a working container engine (Docker or Podman) and downloads the Actor runtime image ('apify/actor-runtime:latest' unless another one is given). + Tagged images are always re-checked against the registry, so an outdated local + copy gets updated. 'apify runtime start' then runs the image installed last. The engine itself is a prerequisite and is not installed by this command - see https://docs.docker.com/get-started/get-docker/ or diff --git a/src/commands/runtime/install.ts b/src/commands/runtime/install.ts index b018a8b33..95e838b66 100644 --- a/src/commands/runtime/install.ts +++ b/src/commands/runtime/install.ts @@ -15,6 +15,7 @@ export class RuntimeInstallCommand extends ApifyCommand { } } +/** + * Whether the image reference is pinned to a digest. Any tag can be moved to a new build in the + * registry, so a local copy of a tagged image can be outdated - a digest always is what it names. + */ +export function isDigestPinned(image: string): boolean { + return image.includes('@'); +} + export async function imageExistsLocally(engine: ContainerEngine, image: string): Promise { try { await execa(engine, ['image', 'inspect', image]); diff --git a/src/lib/runtime/ensure.ts b/src/lib/runtime/ensure.ts index a647d9356..9a1314963 100644 --- a/src/lib/runtime/ensure.ts +++ b/src/lib/runtime/ensure.ts @@ -12,6 +12,7 @@ import { engineDaemonHint, engineInstallHint, findContainerEngine, + isDigestPinned, imageExistsLocally, requestedContainerEngine, } from './docker.js'; @@ -19,6 +20,8 @@ import { export interface EnsureActorRuntimeImageOptions { image: string; forcePull?: boolean; + /** Pull tagged images even when they exist locally, so the registry decides whether the local copy is current. */ + refreshFromRegistry?: boolean; } /** The image the last 'apify runtime install' fetched, or the default when nothing was installed yet. */ @@ -38,6 +41,7 @@ export function rememberInstalledActorRuntimeImage(image: string) { export async function ensureActorRuntimeImage({ image, forcePull = false, + refreshFromRegistry = false, }: EnsureActorRuntimeImageOptions): Promise { const found = await findContainerEngine(); if (!found) { @@ -63,13 +67,20 @@ export async function ensureActorRuntimeImage({ return null; } - if (!forcePull && (await imageExistsLocally(engine, image))) { + const existsLocally = await imageExistsLocally(engine, image); + const localCopyIsCurrent = existsLocally && (isDigestPinned(image) || !refreshFromRegistry); + + if (localCopyIsCurrent && !forcePull) { info({ message: `Actor runtime image '${image}' is already available locally.` }); rememberInstalledActorRuntimeImage(image); return engine; } - info({ message: `Downloading the Actor runtime image '${image}'...` }); + info({ + message: existsLocally + ? `Checking the registry for a newer Actor runtime image '${image}'...` + : `Downloading the Actor runtime image '${image}'...`, + }); try { await execWithLog({ cmd: engine, args: ['pull', image] }); diff --git a/test/local/lib/runtime-docker.test.ts b/test/local/lib/runtime-docker.test.ts index 8667e472e..f9b90fec1 100644 --- a/test/local/lib/runtime-docker.test.ts +++ b/test/local/lib/runtime-docker.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_ACTOR_RUNTIME_IMAGE, engineDaemonHint, engineInstallHint, + isDigestPinned, parseRuntimeContainerInfo, requestedContainerEngine, resolveEngineSocketPath, @@ -22,6 +23,16 @@ describe('runtime/docker', () => { }); }); + describe('isDigestPinned()', () => { + it('only treats digest references as fixed - any tag can be moved to a new build', () => { + expect(isDigestPinned('apify/actor-runtime@sha256:abc')).toBe(true); + expect(isDigestPinned('apify/actor-runtime:latest@sha256:abc')).toBe(true); + expect(isDigestPinned(DEFAULT_ACTOR_RUNTIME_IMAGE)).toBe(false); + expect(isDigestPinned('apify/actor-runtime:master-5462005')).toBe(false); + expect(isDigestPinned('localhost:5000/apify/actor-runtime')).toBe(false); + }); + }); + describe('requestedContainerEngine()', () => { it('reads APIFY_CONTAINER_ENGINE case-insensitively and ignores anything but docker or podman', () => { expect(requestedContainerEngine({ APIFY_CONTAINER_ENGINE: 'podman' })).toBe('podman'); From 5a8d0a90ffdc8ec8c06d57941207fd9d26cbdbed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 08:01:22 +0000 Subject: [PATCH 11/12] feat(runtime): add --api-port and --console-port to 'apify runtime start' Lets the local Actor runtime run when 3333 or 3000 is taken. The chosen ports are passed to the runtime (ACTOR_RUNTIME_API_PORT / ACTOR_RUNTIME_CONSOLE_PORT), published under the same number, and remembered for later starts, 'connect', 'status' and 'skill'. Refs apify/actor-runtime#77 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ac6RaZAULDqhZMW7UhSeHD --- docs/reference.md | 28 ++++++++++------ src/commands/runtime/_index.ts | 4 +-- src/commands/runtime/connect.ts | 15 ++++++--- src/commands/runtime/start.ts | 48 +++++++++++++++++++++++---- src/commands/runtime/status.ts | 23 ++++++++----- src/lib/runtime/config.ts | 3 ++ src/lib/runtime/docker.ts | 46 +++++++++++++++++++++---- src/lib/runtime/skill.ts | 6 ++-- src/lib/runtime/target.ts | 23 +++++++++++-- test/local/lib/runtime-docker.test.ts | 29 ++++++++++++++++ test/local/lib/runtime-target.test.ts | 13 ++++++++ 11 files changed, 194 insertions(+), 44 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 403db8035..d4e3072b4 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -478,7 +478,8 @@ DESCRIPTION runtime image, 'apify runtime start' runs it, and 'apify runtime status' says whether it is up. - The runtime publishes two ports on localhost: + The runtime publishes two ports on localhost, by default (move them with + 'apify runtime start --api-port --console-port '): 3333 API http://localhost:3333 (Apify API compatible endpoint) 3000 Console http://localhost:3000 (web UI) @@ -489,7 +490,7 @@ DESCRIPTION These environment variables point the CLI (and the Apify SDKs and API clients that honour them) at the runtime for one shell only, and take precedence over - the connection wherever they are set: + the connection wherever they are set (shown for the default ports): export APIFY_CLIENT_BASE_URL=http://localhost:3333 export APIFY_CONSOLE_URL=http://localhost:3000 @@ -557,18 +558,25 @@ DESCRIPTION Docker or Podman. Installs the runtime first when needed (like 'apify runtime install'). The runtime API listens on http://localhost:3333 and the console on - http://localhost:3000. Run 'apify runtime -h' for the environment variables - that point the CLI at it. + http://localhost:3000 unless moved with --api-port and --console-port, which + are remembered for later starts and for 'apify runtime connect'. Run 'apify + runtime -h' for the environment variables that point the CLI at it. USAGE - $ apify runtime start [--data-dir ] [-d] + $ apify runtime start [--api-port ] + [--console-port ] [--data-dir ] [-d] FLAGS - --data-dir= Host directory mounted as the runtime - data directory (storages, builds and run records). - Defaults to ~/.apify/actor-runtime/data. - -d, --detach Run the runtime container in the - background. Stop it with 'apify runtime stop'. + --api-port= Host port for the runtime API. + Defaults to the port of the previous start, else 3333. + --console-port= Host port for the runtime + console. Defaults to the port of the previous start, + else 3000. + --data-dir= Host directory mounted as the + runtime data directory (storages, builds and run + records). Defaults to ~/.apify/actor-runtime/data. + -d, --detach Run the runtime container + in the background. Stop it with 'apify runtime stop'. ``` ##### `apify runtime stop` diff --git a/src/commands/runtime/_index.ts b/src/commands/runtime/_index.ts index 04b51aa78..be0e76b40 100644 --- a/src/commands/runtime/_index.ts +++ b/src/commands/runtime/_index.ts @@ -37,14 +37,14 @@ export class RuntimeIndexCommand extends ApifyCommand --console-port '):`, '', ` ${String(ACTOR_RUNTIME_API_PORT).padEnd(5)} API ${ACTOR_RUNTIME_API_URL} (Apify API compatible endpoint)`, ` ${String(ACTOR_RUNTIME_CONSOLE_PORT).padEnd(5)} Console ${ACTOR_RUNTIME_CONSOLE_URL} (web UI)`, '', `Run 'apify runtime connect' to send every Apify CLI command to the runtime instead of the Apify cloud, and 'apify runtime disconnect' to go back. The connection is remembered across terminals and does not touch your login.`, '', - 'These environment variables point the CLI (and the Apify SDKs and API clients that honour them) at the runtime for one shell only, and take precedence over the connection wherever they are set:', + 'These environment variables point the CLI (and the Apify SDKs and API clients that honour them) at the runtime for one shell only, and take precedence over the connection wherever they are set (shown for the default ports):', '', ...runtimeEnvExportLines().map((line) => ` ${line}`), '', diff --git a/src/commands/runtime/connect.ts b/src/commands/runtime/connect.ts index 3fa3ccd23..a5ec8bed2 100644 --- a/src/commands/runtime/connect.ts +++ b/src/commands/runtime/connect.ts @@ -3,12 +3,16 @@ import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { simpleLog, success, warning } from '../../lib/outputs.js'; import { - ACTOR_RUNTIME_API_URL, - ACTOR_RUNTIME_CONSOLE_URL, findRunningRuntimeEngine, + runtimeApiUrl, + runtimeConsoleUrl, runtimeSkillHintLines, } from '../../lib/runtime/docker.js'; -import { overridingRuntimeEnvVars, setConnectedToActorRuntime } from '../../lib/runtime/target.js'; +import { + configuredRuntimePorts, + overridingRuntimeEnvVars, + setConnectedToActorRuntime, +} from '../../lib/runtime/target.js'; export class RuntimeConnectCommand extends ApifyCommand { static override name = 'connect' as const; @@ -33,12 +37,13 @@ export class RuntimeConnectCommand extends ApifyCommand Number.isInteger(port) && port >= 1 && port <= 65535; const defaultDataDir = () => join(GLOBAL_CONFIGS_FOLDER(), 'actor-runtime', 'data'); @@ -29,8 +37,9 @@ export class RuntimeStartCommand extends ApifyCommand chalk.white.bold(` ${line}`)), + ...runtimeEnvExportLines(ports).map((line) => chalk.white.bold(` ${line}`)), '', ...runtimeSkillHintLines(), ].join('\n'), }); // Spawned without a shell so interrupt signals reach the engine's 'run' directly instead of dying in 'sh -c'. - const args = buildRuntimeRunArgs({ image, dataDir, detach: this.flags.detach, hostSocketPath }); + const args = buildRuntimeRunArgs({ image, dataDir, detach: this.flags.detach, hostSocketPath, ports }); run({ message: `${engine} ${args.join(' ')}` }); const child = execa(engine, args, { stdio: 'inherit' }); diff --git a/src/commands/runtime/status.ts b/src/commands/runtime/status.ts index 72c114129..683fd7a3d 100644 --- a/src/commands/runtime/status.ts +++ b/src/commands/runtime/status.ts @@ -5,26 +5,30 @@ import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { simpleLog } from '../../lib/outputs.js'; import { - ACTOR_RUNTIME_API_PORT, - ACTOR_RUNTIME_CONSOLE_PORT, ACTOR_RUNTIME_CONTAINER_NAME, findRunningRuntimeEngine, inspectRuntimeContainer, type PublishedPort, + type RuntimePorts, runtimeSkillHintLines, } from '../../lib/runtime/docker.js'; import { installedActorRuntimeImage } from '../../lib/runtime/ensure.js'; -import { isConnectedToActorRuntime, overridingRuntimeEnvVars, resolveApiBaseUrl } from '../../lib/runtime/target.js'; +import { + configuredRuntimePorts, + isConnectedToActorRuntime, + overridingRuntimeEnvVars, + resolveApiBaseUrl, +} from '../../lib/runtime/target.js'; import { printJsonToStdout } from '../../lib/utils.js'; -function portRole(containerPort: number): string { - if (containerPort === ACTOR_RUNTIME_API_PORT) return 'API'; - if (containerPort === ACTOR_RUNTIME_CONSOLE_PORT) return 'Console'; +function portRole(containerPort: number, ports: RuntimePorts): string { + if (containerPort === ports.api) return 'API'; + if (containerPort === ports.console) return 'Console'; return ''; } -function portLine({ containerPort, protocol, hostAddress }: PublishedPort): string { - const role = portRole(containerPort); +function portLine({ containerPort, protocol, hostAddress }: PublishedPort, ports: RuntimePorts): string { + const role = portRole(containerPort, ports); return ` ${`${containerPort}/${protocol}`.padEnd(10)} -> ${hostAddress}${role ? ` (${role})` : ''}`; } @@ -101,7 +105,8 @@ export class RuntimeStatusCommand extends ApifyCommand portLine(port, ports))); } lines.push('', 'Apify CLI target:'); diff --git a/src/lib/runtime/config.ts b/src/lib/runtime/config.ts index 3934d25b4..a2c6dbe71 100644 --- a/src/lib/runtime/config.ts +++ b/src/lib/runtime/config.ts @@ -9,6 +9,9 @@ export interface ActorRuntimeConfig { image?: string; /** Whether `apify runtime connect` pointed the CLI at the runtime. */ connected?: boolean; + /** The ports the last `apify runtime start` published. */ + apiPort?: number; + consolePort?: number; } export function readActorRuntimeConfig(): ActorRuntimeConfig { diff --git a/src/lib/runtime/docker.ts b/src/lib/runtime/docker.ts index 1469f8ac2..e8d1fcf10 100644 --- a/src/lib/runtime/docker.ts +++ b/src/lib/runtime/docker.ts @@ -16,13 +16,33 @@ export const DOCKER_ENGINE_INSTALL_URL = 'https://docs.docker.com/engine/install /** Official Podman documentation: installation on every platform. */ export const PODMAN_INSTALL_URL = 'https://podman.io/docs/installation'; +/** Default ports; 'apify runtime start --api-port/--console-port' moves them. */ export const ACTOR_RUNTIME_API_PORT = 3333; export const ACTOR_RUNTIME_CONSOLE_PORT = 3000; -export const ACTOR_RUNTIME_API_URL = `http://localhost:${ACTOR_RUNTIME_API_PORT}`; +/** The runtime's own environment variables that move its ports; the same number is published on the host. */ +export const RUNTIME_API_PORT_ENV_VAR = 'ACTOR_RUNTIME_API_PORT'; -export const ACTOR_RUNTIME_CONSOLE_URL = `http://localhost:${ACTOR_RUNTIME_CONSOLE_PORT}`; +export const RUNTIME_CONSOLE_PORT_ENV_VAR = 'ACTOR_RUNTIME_CONSOLE_PORT'; + +export interface RuntimePorts { + api: number; + console: number; +} + +export const DEFAULT_RUNTIME_PORTS: RuntimePorts = { + api: ACTOR_RUNTIME_API_PORT, + console: ACTOR_RUNTIME_CONSOLE_PORT, +}; + +export const runtimeApiUrl = (port: number) => `http://localhost:${port}`; + +export const runtimeConsoleUrl = (port: number) => `http://localhost:${port}`; + +export const ACTOR_RUNTIME_API_URL = runtimeApiUrl(ACTOR_RUNTIME_API_PORT); + +export const ACTOR_RUNTIME_CONSOLE_URL = runtimeConsoleUrl(ACTOR_RUNTIME_CONSOLE_PORT); /** * Environment variables that point the Apify CLI (and the Apify SDKs/clients that honour them) @@ -47,8 +67,11 @@ export const RUNTIME_SOCKET_PATH = '/var/run/docker.sock'; /** Where the runtime container expects its data directory (storages, builds and run records). */ export const RUNTIME_DATA_PATH = '/data'; -export function runtimeEnvExportLines(): string[] { - return Object.entries(ACTOR_RUNTIME_ENV_VARS).map(([name, value]) => `export ${name}=${value}`); +export function runtimeEnvExportLines(ports: RuntimePorts = DEFAULT_RUNTIME_PORTS): string[] { + return [ + `export APIFY_CLIENT_BASE_URL=${runtimeApiUrl(ports.api)}`, + `export APIFY_CONSOLE_URL=${runtimeConsoleUrl(ports.console)}`, + ]; } /** The engine the user asked for via `APIFY_CONTAINER_ENGINE`, or undefined for "whichever is installed". */ @@ -298,6 +321,7 @@ export interface RuntimeRunArgsOptions { dataDir: string; detach: boolean; hostSocketPath: string; + ports?: RuntimePorts; platform?: NodeJS.Platform; } @@ -306,6 +330,7 @@ export function buildRuntimeRunArgs({ dataDir, detach, hostSocketPath, + ports = DEFAULT_RUNTIME_PORTS, platform = process.platform, }: RuntimeRunArgsOptions): string[] { // --init makes signals (Ctrl+C) reach the runtime process even though it runs as the container's PID 1. @@ -315,11 +340,20 @@ export function buildRuntimeRunArgs({ args.push('--detach'); } + // Same number inside and out: the runtime builds its URLs, and Actors may reach its API through the + // host, from the port it listens on. Defaults are left implicit so older runtime images keep working. + if (ports.api !== ACTOR_RUNTIME_API_PORT) { + args.push('-e', `${RUNTIME_API_PORT_ENV_VAR}=${ports.api}`); + } + if (ports.console !== ACTOR_RUNTIME_CONSOLE_PORT) { + args.push('-e', `${RUNTIME_CONSOLE_PORT_ENV_VAR}=${ports.console}`); + } + args.push( '-p', - `${ACTOR_RUNTIME_API_PORT}:${ACTOR_RUNTIME_API_PORT}`, + `${ports.api}:${ports.api}`, '-p', - `${ACTOR_RUNTIME_CONSOLE_PORT}:${ACTOR_RUNTIME_CONSOLE_PORT}`, + `${ports.console}:${ports.console}`, '-v', socketMountArg(hostSocketPath, platform), '-v', diff --git a/src/lib/runtime/skill.ts b/src/lib/runtime/skill.ts index 3b9970260..593490f56 100644 --- a/src/lib/runtime/skill.ts +++ b/src/lib/runtime/skill.ts @@ -8,9 +8,9 @@ import { execa } from 'execa'; import { APIFY_CLIENT_DEFAULT_HEADERS } from '../consts.js'; import { userHomeDir } from '../utils.js'; -import { ACTOR_RUNTIME_API_URL, findRunningRuntimeEngine, imageExistsLocally, type ContainerEngine } from './docker.js'; +import { findRunningRuntimeEngine, imageExistsLocally, runtimeApiUrl, type ContainerEngine } from './docker.js'; import { installedActorRuntimeImage } from './ensure.js'; -import { resolveApiBaseUrl } from './target.js'; +import { configuredRuntimePorts, resolveApiBaseUrl } from './target.js'; /** Matches the `name` in the file's own frontmatter. */ export const RUNTIME_SKILL_NAME = 'apify-actor-runtime'; @@ -70,7 +70,7 @@ async function readSkillFromImage(engine: ContainerEngine, image: string): Promi /** Cheapest-first: HTTP, then the installed image. Deliberately no copy bundled with the CLI - one could * only ever describe the image the CLI was released against, not the one present. */ export async function resolveRuntimeSkill(): Promise { - const baseUrl = resolveApiBaseUrl() ?? ACTOR_RUNTIME_API_URL; + const baseUrl = resolveApiBaseUrl() ?? runtimeApiUrl(configuredRuntimePorts().api); const overHttp = await fetchSkillFromRuntime(baseUrl); if (overHttp) return { content: overHttp, source: { kind: 'runtime', baseUrl } }; diff --git a/src/lib/runtime/target.ts b/src/lib/runtime/target.ts index aac5017ca..ef4eac398 100644 --- a/src/lib/runtime/target.ts +++ b/src/lib/runtime/target.ts @@ -1,7 +1,13 @@ import process from 'node:process'; import { readActorRuntimeConfig, updateActorRuntimeConfig } from './config.js'; -import { ACTOR_RUNTIME_API_URL, ACTOR_RUNTIME_CONSOLE_URL, ACTOR_RUNTIME_ENV_VARS } from './docker.js'; +import { + ACTOR_RUNTIME_ENV_VARS, + DEFAULT_RUNTIME_PORTS, + runtimeApiUrl, + runtimeConsoleUrl, + type RuntimePorts, +} from './docker.js'; /** Whether `apify runtime connect` pointed the CLI at the local Actor runtime. */ export function isConnectedToActorRuntime(): boolean { @@ -12,6 +18,12 @@ export function setConnectedToActorRuntime(connected: boolean) { updateActorRuntimeConfig({ connected }); } +/** The ports the runtime was last started with, which `connect` and the URLs below follow. */ +export function configuredRuntimePorts(): RuntimePorts { + const { apiPort, consolePort } = readActorRuntimeConfig(); + return { api: apiPort ?? DEFAULT_RUNTIME_PORTS.api, console: consolePort ?? DEFAULT_RUNTIME_PORTS.console }; +} + /** The environment variables from {@link ACTOR_RUNTIME_ENV_VARS} the user set themselves, with their values. */ export function overridingRuntimeEnvVars(env: NodeJS.ProcessEnv = process.env): [string, string][] { return Object.keys(ACTOR_RUNTIME_ENV_VARS) @@ -24,10 +36,15 @@ export function overridingRuntimeEnvVars(env: NodeJS.ProcessEnv = process.env): * `apify runtime connect` is in effect, else undefined for the Apify platform default. */ export function resolveApiBaseUrl(env: NodeJS.ProcessEnv = process.env): string | undefined { - return env.APIFY_CLIENT_BASE_URL || (isConnectedToActorRuntime() ? ACTOR_RUNTIME_API_URL : undefined); + return ( + env.APIFY_CLIENT_BASE_URL || (isConnectedToActorRuntime() ? runtimeApiUrl(configuredRuntimePorts().api) : undefined) + ); } /** The Console the CLI links to, resolved the same way as {@link resolveApiBaseUrl}. */ export function resolveConsoleUrl(env: NodeJS.ProcessEnv = process.env): string | undefined { - return env.APIFY_CONSOLE_URL || (isConnectedToActorRuntime() ? ACTOR_RUNTIME_CONSOLE_URL : undefined); + return ( + env.APIFY_CONSOLE_URL || + (isConnectedToActorRuntime() ? runtimeConsoleUrl(configuredRuntimePorts().console) : undefined) + ); } diff --git a/test/local/lib/runtime-docker.test.ts b/test/local/lib/runtime-docker.test.ts index f9b90fec1..8a73352bb 100644 --- a/test/local/lib/runtime-docker.test.ts +++ b/test/local/lib/runtime-docker.test.ts @@ -184,5 +184,34 @@ describe('runtime/docker', () => { expect(args).toContain('--detach'); expect(args.indexOf('--detach')).toBeLessThan(args.indexOf(DEFAULT_ACTOR_RUNTIME_IMAGE)); }); + + it('publishes moved ports under the same number and tells the runtime to listen on them', () => { + const args = buildRuntimeRunArgs({ + image: DEFAULT_ACTOR_RUNTIME_IMAGE, + dataDir: '/data', + detach: false, + hostSocketPath: '/var/run/docker.sock', + ports: { api: 4333, console: 4000 }, + platform: 'linux', + }); + expect(args.join(' ')).toContain( + '-e ACTOR_RUNTIME_API_PORT=4333 -e ACTOR_RUNTIME_CONSOLE_PORT=4000 -p 4333:4333 -p 4000:4000', + ); + expect(args.indexOf('-e')).toBeLessThan(args.indexOf(DEFAULT_ACTOR_RUNTIME_IMAGE)); + }); + + it('passes only the moved port to the runtime', () => { + const args = buildRuntimeRunArgs({ + image: DEFAULT_ACTOR_RUNTIME_IMAGE, + dataDir: '/data', + detach: false, + hostSocketPath: '/var/run/docker.sock', + ports: { api: 3333, console: 4000 }, + platform: 'linux', + }); + expect(args).toContain('ACTOR_RUNTIME_CONSOLE_PORT=4000'); + expect(args.join(' ')).not.toContain('ACTOR_RUNTIME_API_PORT'); + expect(args).toContain('3333:3333'); + }); }); }); diff --git a/test/local/lib/runtime-target.test.ts b/test/local/lib/runtime-target.test.ts index ea8140f80..053c166c6 100644 --- a/test/local/lib/runtime-target.test.ts +++ b/test/local/lib/runtime-target.test.ts @@ -1,9 +1,11 @@ import { readFileSync } from 'node:fs'; import { ACTOR_RUNTIME_CONFIG_FILE_PATH } from '../../../src/lib/consts.js'; +import { updateActorRuntimeConfig } from '../../../src/lib/runtime/config.js'; import { ACTOR_RUNTIME_API_URL, ACTOR_RUNTIME_CONSOLE_URL } from '../../../src/lib/runtime/docker.js'; import { rememberInstalledActorRuntimeImage } from '../../../src/lib/runtime/ensure.js'; import { + configuredRuntimePorts, overridingRuntimeEnvVars, resolveApiBaseUrl, resolveConsoleUrl, @@ -35,6 +37,17 @@ describe('runtime/target', () => { expect(resolveConsoleUrl({})).toBeUndefined(); }); + it('follows the ports the runtime was last started with', () => { + expect(configuredRuntimePorts()).toEqual({ api: 3333, console: 3000 }); + + updateActorRuntimeConfig({ apiPort: 4333, consolePort: 4000 }); + setConnectedToActorRuntime(true); + + expect(configuredRuntimePorts()).toEqual({ api: 4333, console: 4000 }); + expect(resolveApiBaseUrl({})).toBe('http://localhost:4333'); + expect(resolveConsoleUrl({})).toBe('http://localhost:4000'); + }); + it('lets the environment variables win over the connection', () => { setConnectedToActorRuntime(true); From 1374cb592f37895cbd636cda3d3245df37943b47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:46:42 +0000 Subject: [PATCH 12/12] test(runtime): console links follow the configured runtime ports Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ac6RaZAULDqhZMW7UhSeHD --- test/local/lib/runtime-target.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/local/lib/runtime-target.test.ts b/test/local/lib/runtime-target.test.ts index 053c166c6..4ac06d364 100644 --- a/test/local/lib/runtime-target.test.ts +++ b/test/local/lib/runtime-target.test.ts @@ -1,5 +1,7 @@ import { readFileSync } from 'node:fs'; +import { runUrl } from '../../../src/lib/commands/run-result.js'; +import { getConsoleUrl } from '../../../src/lib/console-url.js'; import { ACTOR_RUNTIME_CONFIG_FILE_PATH } from '../../../src/lib/consts.js'; import { updateActorRuntimeConfig } from '../../../src/lib/runtime/config.js'; import { ACTOR_RUNTIME_API_URL, ACTOR_RUNTIME_CONSOLE_URL } from '../../../src/lib/runtime/docker.js'; @@ -46,6 +48,8 @@ describe('runtime/target', () => { expect(configuredRuntimePorts()).toEqual({ api: 4333, console: 4000 }); expect(resolveApiBaseUrl({})).toBe('http://localhost:4333'); expect(resolveConsoleUrl({})).toBe('http://localhost:4000'); + expect(runUrl('actor', 'run')).toBe('http://localhost:4000/actors/actor/runs/run'); + expect(getConsoleUrl()).toBe('http://localhost:4000'); }); it('lets the environment variables win over the connection', () => {