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..d4e3072b4 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -453,6 +453,209 @@ 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 + container on Docker or Podman. + + 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 + + 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, 'apify runtime start' runs it, and 'apify runtime status' says + whether it is up. + + 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) + + 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 (shown for the default ports): + + export APIFY_CLIENT_BASE_URL=http://localhost:3333 + export APIFY_CONSOLE_URL=http://localhost:3000 + + 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 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. + runtime skill Prints the Actor runtime's Agent Skill - + the runtime's own instructions for how to use it. +``` + +##### `apify runtime install` + +```sh +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 + https://podman.io/docs/installation. + +USAGE + $ 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 + available locally. +``` + +##### `apify runtime start` + +```sh +DESCRIPTION + 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 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 [--api-port ] + [--console-port ] [--data-dir ] [-d] + +FLAGS + --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` + +```sh +DESCRIPTION + Stops the Actor runtime container started with 'apify runtime start --detach'. + +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 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 @@ -859,7 +1062,7 @@ DESCRIPTION info --input". USAGE - $ apify actors call [actorId] [-b ] + $ apify actors call [actorId] [-b ] [--dev-folder] [-i | -f ] [--json] [-m ] [-o] [-s] [-t ] @@ -872,6 +1075,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/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index d1e22bb0b..0f9b467ba 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -27,6 +27,15 @@ const categories: Record = { { command: Commands.run }, { command: Commands.validateSchema }, + { command: Commands.runtime }, + { command: Commands.runtimeInstall }, + { command: Commands.runtimeStart }, + { command: Commands.runtimeStop }, + { command: Commands.runtimeStatus }, + { command: Commands.runtimeConnect }, + { command: Commands.runtimeDisconnect }, + { command: Commands.runtimeSkill }, + { command: Commands.actor }, { command: Commands.actorCalculateMemory }, { command: Commands.actorCharge }, diff --git a/skills/apify/SKILL.md b/skills/apify/SKILL.md index 4634b988a..93620545c 100644 --- a/skills/apify/SKILL.md +++ b/skills/apify/SKILL.md @@ -87,6 +87,70 @@ 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 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. + +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: + - 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 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: + +```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 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 +``` + +`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/_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/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 new file mode 100644 index 000000000..be0e76b40 --- /dev/null +++ b/src/commands/runtime/_index.ts @@ -0,0 +1,88 @@ +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, + CONTAINER_ENGINE_ENV_VAR, + DOCKER_ENGINE_INSTALL_URL, + DOCKER_GET_DOCKER_URL, + 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 { RuntimeSkillCommand } from './skill.js'; +import { RuntimeStartCommand } from './start.js'; +import { RuntimeStatusCommand } from './status.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 container on Docker or Podman.', + '', + 'Prerequisite: Docker or Podman must be installed and running. Follow the official documentation to set one up:', + '', + ' Docker Desktop (macOS, Windows, Linux desktop):', + ` ${DOCKER_GET_DOCKER_URL}`, + ' Docker Engine (Linux servers, headless):', + ` ${DOCKER_ENGINE_INSTALL_URL}`, + ' Podman (rootful or rootless; its API socket must be served, e.g. via the podman.socket systemd unit):', + ` ${PODMAN_INSTALL_URL}`, + '', + `The first engine found on PATH is used, Docker before Podman. Set ${CONTAINER_ENGINE_ENV_VAR}=docker or =podman to choose.`, + '', + `'apify runtime install' checks that the engine is available and pulls the runtime image, 'apify runtime start' runs it, and 'apify runtime status' says whether it is up.`, + '', + `The runtime publishes two ports on localhost, by default (move them with 'apify runtime start --api-port --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 (shown for the default ports):', + '', + ...runtimeEnvExportLines().map((line) => ` ${line}`), + '', + `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'); + + 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 the Actors it knows about.', + command: 'apify runtime connect && apify actors ls', + }, + { + description: 'Point the CLI at the runtime for a single command instead.', + 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, + RuntimeStatusCommand, + RuntimeConnectCommand, + RuntimeDisconnectCommand, + RuntimeSkillCommand, + ]; + + async run() { + this.printHelp(); + } +} diff --git a/src/commands/runtime/connect.ts b/src/commands/runtime/connect.ts new file mode 100644 index 000000000..a5ec8bed2 --- /dev/null +++ b/src/commands/runtime/connect.ts @@ -0,0 +1,71 @@ +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { simpleLog, success, warning } from '../../lib/outputs.js'; +import { + findRunningRuntimeEngine, + runtimeApiUrl, + runtimeConsoleUrl, + runtimeSkillHintLines, +} from '../../lib/runtime/docker.js'; +import { + configuredRuntimePorts, + overridingRuntimeEnvVars, + setConnectedToActorRuntime, +} from '../../lib/runtime/target.js'; + +export class RuntimeConnectCommand 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); + const ports = configuredRuntimePorts(); + + success({ message: 'The Apify CLI now targets the local Actor runtime.' }); + simpleLog({ + message: [ + ` API: ${runtimeApiUrl(ports.api)}`, + ` Console: ${runtimeConsoleUrl(ports.console)}`, + '', + `Run ${chalk.white.bold('apify runtime disconnect')} to target the Apify platform again.`, + '', + ...runtimeSkillHintLines(), + ].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/install.ts b/src/commands/runtime/install.ts new file mode 100644 index 000000000..95e838b66 --- /dev/null +++ b/src/commands/runtime/install.ts @@ -0,0 +1,67 @@ +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 { + DEFAULT_ACTOR_RUNTIME_IMAGE, + DOCKER_GET_DOCKER_URL, + PODMAN_INSTALL_URL, + runtimeSkillHintLines, +} 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 ('${DEFAULT_ACTOR_RUNTIME_IMAGE}' unless another one is given).\n` + + `Tagged images are always re-checked against the registry, so an outdated local copy gets updated.\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'; + + 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', + }, + { + description: 'Install a specific runtime image, e.g. a pinned build or another implementation.', + command: 'apify runtime install apify/actor-runtime:master-5462005', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-runtime-install'; + + static override args = { + image: Args.string({ + description: `Container image to install as the Actor runtime. Defaults to '${DEFAULT_ACTOR_RUNTIME_IMAGE}'.`, + }), + }; + + 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({ + image: this.args.image ?? DEFAULT_ACTOR_RUNTIME_IMAGE, + forcePull: this.flags.force, + refreshFromRegistry: true, + }); + if (!installed) return; + + success({ message: 'Actor runtime is installed.' }); + simpleLog({ message: `Start it with 'apify runtime start'.` }); + simpleLog({ message: ['', ...runtimeSkillHintLines()].join('\n') }); + } +} diff --git a/src/commands/runtime/skill.ts b/src/commands/runtime/skill.ts new file mode 100644 index 000000000..95e7ca513 --- /dev/null +++ b/src/commands/runtime/skill.ts @@ -0,0 +1,110 @@ +import process from 'node:process'; + +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Flags } from '../../lib/command-framework/flags.js'; +import { error, info, simpleLog, success } from '../../lib/outputs.js'; +import { + describeSkillSource, + frameSkillForReading, + resolveRuntimeSkill, + skillTargets, + stampSkill, + writeSkillTo, +} from '../../lib/runtime/skill.js'; +import { tildify } from '../../lib/utils.js'; + +export class RuntimeSkillCommand 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 new file mode 100644 index 000000000..552d21d97 --- /dev/null +++ b/src/commands/runtime/start.ts @@ -0,0 +1,167 @@ +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 { updateActorRuntimeConfig } from '../../lib/runtime/config.js'; +import { + ACTOR_RUNTIME_API_PORT, + ACTOR_RUNTIME_API_URL, + ACTOR_RUNTIME_CONSOLE_PORT, + ACTOR_RUNTIME_CONSOLE_URL, + ACTOR_RUNTIME_CONTAINER_NAME, + buildRuntimeRunArgs, + findRunningRuntimeEngine, + resolveEngineSocketPath, + runtimeApiUrl, + runtimeConsoleUrl, + runtimeEnvExportLines, + runtimeSkillHintLines, +} from '../../lib/runtime/docker.js'; +import { ensureActorRuntimeImage, installedActorRuntimeImage } from '../../lib/runtime/ensure.js'; +import { configuredRuntimePorts } from '../../lib/runtime/target.js'; + +const isValidPort = (port: number) => Number.isInteger(port) && port >= 1 && port <= 65535; + +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 container on Docker or Podman.\n` + + `Installs the runtime first when needed (like 'apify runtime install'). The runtime API listens on ` + + `${ACTOR_RUNTIME_API_URL} and the console on ${ACTOR_RUNTIME_CONSOLE_URL} 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.`; + + 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', + }, + { + description: 'Start on other ports when 3333 or 3000 is already taken.', + command: 'apify runtime start --api-port 4333 --console-port 4000', + }, + ]; + + 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, + }), + 'api-port': Flags.integer({ + description: `Host port for the runtime API. Defaults to the port of the previous start, else ${ACTOR_RUNTIME_API_PORT}.`, + }), + 'console-port': Flags.integer({ + description: `Host port for the runtime console. Defaults to the port of the previous start, else ${ACTOR_RUNTIME_CONSOLE_PORT}.`, + }), + }; + + async run() { + if (await findRunningRuntimeEngine()) { + error({ + message: `The Actor runtime is already running (container '${ACTOR_RUNTIME_CONTAINER_NAME}'). Stop it with 'apify runtime stop' first.`, + }); + process.exitCode = 1; + return; + } + + const previous = configuredRuntimePorts(); + const ports = { + api: this.flags.apiPort ?? previous.api, + console: this.flags.consolePort ?? previous.console, + }; + if (!isValidPort(ports.api) || !isValidPort(ports.console)) { + error({ message: 'Ports must be integers between 1 and 65535.' }); + process.exitCode = 1; + return; + } + if (ports.api === ports.console) { + error({ message: `The API and console need different ports, both are ${ports.api}.` }); + process.exitCode = 1; + return; + } + + const image = installedActorRuntimeImage(); + const engine = await ensureActorRuntimeImage({ image }); + if (!engine) return; + + const hostSocketPath = await resolveEngineSocketPath(engine); + const dataDir = resolve(this.flags.dataDir ?? defaultDataDir()); + await mkdir(dataDir, { recursive: true }); + updateActorRuntimeConfig({ apiPort: ports.api, consolePort: ports.console }); + + info({ + message: [ + `Starting the Actor runtime (data directory: ${dataDir})...`, + '', + ` API: ${runtimeApiUrl(ports.api)}`, + ` Console: ${runtimeConsoleUrl(ports.console)}`, + '', + 'Point the Apify CLI at the runtime with:', + ...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, ports }); + run({ message: `${engine} ${args.join(' ')}` }); + + const child = execa(engine, 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/status.ts b/src/commands/runtime/status.ts new file mode 100644 index 000000000..683fd7a3d --- /dev/null +++ b/src/commands/runtime/status.ts @@ -0,0 +1,134 @@ +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_CONTAINER_NAME, + findRunningRuntimeEngine, + inspectRuntimeContainer, + type PublishedPort, + type RuntimePorts, + runtimeSkillHintLines, +} from '../../lib/runtime/docker.js'; +import { installedActorRuntimeImage } from '../../lib/runtime/ensure.js'; +import { + configuredRuntimePorts, + isConnectedToActorRuntime, + overridingRuntimeEnvVars, + resolveApiBaseUrl, +} from '../../lib/runtime/target.js'; +import { printJsonToStdout } from '../../lib/utils.js'; + +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, ports: RuntimePorts): string { + const role = portRole(containerPort, ports); + 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'); + const ports = configuredRuntimePorts(); + lines.push(...(container?.ports ?? []).map((port) => portLine(port, ports))); + } + + 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}`), + ); + } + + if (engine) { + lines.push('', ...runtimeSkillHintLines()); + } + + simpleLog({ message: lines.join('\n') }); + + if (!engine) process.exitCode = 1; + } +} diff --git a/src/commands/runtime/stop.ts b/src/commands/runtime/stop.ts new file mode 100644 index 000000000..539b112c3 --- /dev/null +++ b/src/commands/runtime/stop.ts @@ -0,0 +1,40 @@ +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, findRunningRuntimeEngine } 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() { + const engine = await findRunningRuntimeEngine(); + if (!engine) { + info({ message: 'The Actor runtime is not running.' }); + return; + } + + try { + await execWithLog({ cmd: engine, args: ['stop', ACTOR_RUNTIME_CONTAINER_NAME] }); + } catch { + process.exitCode = 1; + return; + } + + success({ message: 'The Actor runtime was stopped.' }); + } +} diff --git a/src/lib/commands/run-on-cloud.ts b/src/lib/commands/run-on-cloud.ts index 8c421cb99..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. @@ -92,7 +125,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/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/consts.ts b/src/lib/consts.ts index 6368135b0..3dbf995c4 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -58,6 +58,8 @@ export const STATE_FILE_PATH = () => 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/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..a2c6dbe71 --- /dev/null +++ b/src/lib/runtime/config.ts @@ -0,0 +1,33 @@ +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; + /** The ports the last `apify runtime start` published. */ + apiPort?: number; + consolePort?: number; +} + +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/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/src/lib/runtime/docker.ts b/src/lib/runtime/docker.ts new file mode 100644 index 000000000..e8d1fcf10 --- /dev/null +++ b/src/lib/runtime/docker.ts @@ -0,0 +1,375 @@ +import process from 'node:process'; + +import { execa } from 'execa'; +import which from 'which'; + +export const DEFAULT_ACTOR_RUNTIME_IMAGE = 'apify/actor-runtime: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/'; + +/** 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; + +/** 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 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) + * 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; + +/** 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'; + +/** Where the runtime container expects its data directory (storages, builds and run records). */ +export const RUNTIME_DATA_PATH = '/data'; + +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". */ +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 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/'; + 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 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': + 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'.`; + } +} + +/** + * 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 { + return false; + } +} + +/** + * 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]); + return true; + } catch { + return false; + } +} + +export async function isRuntimeContainerRunning(engine: ContainerEngine): Promise { + try { + const { stdout } = await execa(engine, [ + 'ps', + '--filter', + `name=^${ACTOR_RUNTIME_CONTAINER_NAME}$`, + '--format', + '{{.Names}}', + ]); + return stdout.trim().length > 0; + } catch { + return false; + } +} + +/** 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; +} + +/** + * 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' && hostSocketPath.startsWith('/') ? `/${hostSocketPath}` : hostSocketPath; + return `${hostSocket}:${RUNTIME_SOCKET_PATH}`; +} + +export interface RuntimeRunArgsOptions { + image: string; + dataDir: string; + detach: boolean; + hostSocketPath: string; + ports?: RuntimePorts; + platform?: NodeJS.Platform; +} + +export function buildRuntimeRunArgs({ + image, + 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. + const args = ['run', '--rm', '--init', '--name', ACTOR_RUNTIME_CONTAINER_NAME]; + + if (detach) { + 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', + `${ports.api}:${ports.api}`, + '-p', + `${ports.console}:${ports.console}`, + '-v', + socketMountArg(hostSocketPath, platform), + '-v', + `${dataDir}:${RUNTIME_DATA_PATH}`, + image, + ); + + return args; +} + +/** The one line that tells an agent this runtime documents itself, printed wherever the CLI has just + * given it a runtime ('runtime install', 'start', 'connect', 'status'). An agent reads the output of the + * command it ran and has no other reason to go looking, so this is the moment that reaches it. */ +export function runtimeSkillHintLines(): string[] { + return [ + `Agents: run 'apify runtime skill --install' to install this runtime's Agent Skill,`, + ` or 'apify runtime skill' to read it now.`, + ]; +} diff --git a/src/lib/runtime/ensure.ts b/src/lib/runtime/ensure.ts new file mode 100644 index 000000000..9a1314963 --- /dev/null +++ b/src/lib/runtime/ensure.ts @@ -0,0 +1,101 @@ +import process from 'node:process'; + +import chalk from 'chalk'; + +import { execWithLog } from '../exec.js'; +import { error, info } from '../outputs.js'; +import { readActorRuntimeConfig, updateActorRuntimeConfig } from './config.js'; +import { + CONTAINER_ENGINE_ENV_VAR, + DEFAULT_ACTOR_RUNTIME_IMAGE, + type ContainerEngine, + engineDaemonHint, + engineInstallHint, + findContainerEngine, + isDigestPinned, + imageExistsLocally, + requestedContainerEngine, +} from './docker.js'; + +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. */ +export function installedActorRuntimeImage(): string { + const { image } = readActorRuntimeConfig(); + return image || DEFAULT_ACTOR_RUNTIME_IMAGE; +} + +export function rememberInstalledActorRuntimeImage(image: string) { + updateActorRuntimeConfig({ image }); +} + +/** + * 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, + refreshFromRegistry = false, +}: EnsureActorRuntimeImageOptions): Promise { + const found = await findContainerEngine(); + if (!found) { + const requested = requestedContainerEngine(); + error({ + 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 null; + } + + const { engine, ready } = found; + if (!ready) { + error({ + 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 null; + } + + 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: 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] }); + rememberInstalledActorRuntimeImage(image); + return engine; + } catch { + error({ + message: [ + `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 ${image} .`), + ].join('\n'), + }); + process.exitCode = 1; + return null; + } +} diff --git a/src/lib/runtime/skill.ts b/src/lib/runtime/skill.ts new file mode 100644 index 000000000..593490f56 --- /dev/null +++ b/src/lib/runtime/skill.ts @@ -0,0 +1,150 @@ +import { existsSync } from 'node:fs'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; + +import { execa } from 'execa'; + +import { APIFY_CLIENT_DEFAULT_HEADERS } from '../consts.js'; +import { userHomeDir } from '../utils.js'; +import { findRunningRuntimeEngine, imageExistsLocally, runtimeApiUrl, type ContainerEngine } from './docker.js'; +import { installedActorRuntimeImage } from './ensure.js'; +import { configuredRuntimePorts, resolveApiBaseUrl } from './target.js'; + +/** Matches the `name` in the file's own frontmatter. */ +export const RUNTIME_SKILL_NAME = 'apify-actor-runtime'; + +/** Under the image's WORKDIR. */ +const SKILL_DIR_IN_IMAGE = '/usr/src/app/skills/actor-runtime'; + +const SKILL_FILE = 'SKILL.md'; + +export type SkillSource = { kind: 'runtime'; baseUrl: string } | { kind: 'image'; image: string }; + +export interface ResolvedSkill { + content: string; + source: SkillSource; +} + +export function describeSkillSource(source: SkillSource): string { + return source.kind === 'runtime' ? `the runtime at ${source.baseUrl}` : `the '${source.image}' image`; +} + +/** Unauthenticated by design, so it works before `apify login`. Null for anything that isn't a runtime. */ +async function fetchSkillFromRuntime(baseUrl: string): Promise { + 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() ?? runtimeApiUrl(configuredRuntimePorts().api); + + 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/src/lib/runtime/target.ts b/src/lib/runtime/target.ts new file mode 100644 index 000000000..ef4eac398 --- /dev/null +++ b/src/lib/runtime/target.ts @@ -0,0 +1,50 @@ +import process from 'node:process'; + +import { readActorRuntimeConfig, updateActorRuntimeConfig } from './config.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 { + return readActorRuntimeConfig().connected === true; +} + +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) + .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() ? 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() ? runtimeConsoleUrl(configuredRuntimePorts().console) : undefined) + ); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 99d8784e8..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)) { @@ -594,8 +595,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/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/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; 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.', + }); + }); +}); diff --git a/test/local/lib/runtime-docker.test.ts b/test/local/lib/runtime-docker.test.ts new file mode 100644 index 000000000..8a73352bb --- /dev/null +++ b/test/local/lib/runtime-docker.test.ts @@ -0,0 +1,217 @@ +import { + ACTOR_RUNTIME_CONTAINER_NAME, + buildRuntimeRunArgs, + DEFAULT_ACTOR_RUNTIME_IMAGE, + engineDaemonHint, + engineInstallHint, + isDigestPinned, + parseRuntimeContainerInfo, + requestedContainerEngine, + resolveEngineSocketPath, + socketMountArg, +} from '../../../src/lib/runtime/docker.js'; + +describe('runtime/docker', () => { + 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(socketMountArg('/var/run/docker.sock', 'win32')).toBe('//var/run/docker.sock:/var/run/docker.sock'); + }); + }); + + 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'); + 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(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(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('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( + buildRuntimeRunArgs({ + image: DEFAULT_ACTOR_RUNTIME_IMAGE, + dataDir: '/home/me/data', + detach: false, + hostSocketPath: '/var/run/docker.sock', + 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', + 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', + 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({ + 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(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-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', + }); + }); +}); 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 }); + } + }); +}); diff --git a/test/local/lib/runtime-target.test.ts b/test/local/lib/runtime-target.test.ts new file mode 100644 index 000000000..4ac06d364 --- /dev/null +++ b/test/local/lib/runtime-target.test.ts @@ -0,0 +1,68 @@ +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'; +import { rememberInstalledActorRuntimeImage } from '../../../src/lib/runtime/ensure.js'; +import { + configuredRuntimePorts, + 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('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'); + 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', () => { + 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([]); + }); +});