From 8d5177d384f9ae117106a4e60dd7cae4e07a540e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 22:44:05 -0700 Subject: [PATCH 01/11] docs(openspec): propose launcher-local-cache --- .../launcher-local-cache/.openspec.yaml | 2 + .../changes/launcher-local-cache/design.md | 94 +++++++++++ .../changes/launcher-local-cache/proposal.md | 40 +++++ .../specs/cli-agent/spec.md | 27 ++++ .../specs/cli-init/spec.md | 37 +++++ .../specs/cli-project-launcher/spec.md | 149 ++++++++++++++++++ .../changes/launcher-local-cache/tasks.md | 33 ++++ 7 files changed, 382 insertions(+) create mode 100644 openspec/changes/launcher-local-cache/.openspec.yaml create mode 100644 openspec/changes/launcher-local-cache/design.md create mode 100644 openspec/changes/launcher-local-cache/proposal.md create mode 100644 openspec/changes/launcher-local-cache/specs/cli-agent/spec.md create mode 100644 openspec/changes/launcher-local-cache/specs/cli-init/spec.md create mode 100644 openspec/changes/launcher-local-cache/specs/cli-project-launcher/spec.md create mode 100644 openspec/changes/launcher-local-cache/tasks.md diff --git a/openspec/changes/launcher-local-cache/.openspec.yaml b/openspec/changes/launcher-local-cache/.openspec.yaml new file mode 100644 index 00000000..c1bc9027 --- /dev/null +++ b/openspec/changes/launcher-local-cache/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-19 diff --git a/openspec/changes/launcher-local-cache/design.md b/openspec/changes/launcher-local-cache/design.md new file mode 100644 index 00000000..b06d4c5c --- /dev/null +++ b/openspec/changes/launcher-local-cache/design.md @@ -0,0 +1,94 @@ +## Context + +The launcher is a template, `packages/cli/assets/launcher/launcher.mjs`, rendered once per package and written to `.taskless/bin/cli.mjs` (release) or `.taskless/bin/cli-nightly.mjs` (nightly). It reads `install.launchers[]` from `.taskless/taskless.json`, falls back to `install.cliVersion` when that version belongs to its package, and runs `npx --yes @`. + +`@taskless/cli` does not run from its own tarball. It depends on `tsx` (a range), and through it on esbuild, `get-tsconfig` and esbuild's per-platform binary, and on seven ast-grep and six Vale per-platform optional packages (exact versions). Runtime rules run through that `tsx`. + +Measurements behind the decisions below, all on 2026-10-19 with npm 11.8 on macOS arm64: + +| Install | Result | +| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| `npm install @taskless/cli@0.13.1` into an empty prefix | tsx 4.23.15, esbuild 0.28.x; only darwin-arm64 binaries; 110 MB | +| Same, from a registry serving a repacked 0.13.1 with a full `npm-shrinkwrap.json` | tsx 4.21.0, esbuild 0.27.3, but every platform's binaries; 1.0 GB | +| Same, shrinkwrap without the platform packages | tsx 4.21.0, no ast-grep, Vale or esbuild binary; 2.3 MB, broken | +| `npm ci` from a constructed lockfile (deps from the pinned tree, CLI entry without `integrity`) | tsx 4.21.0, esbuild 0.27.3, get-tsconfig 4.13.6; only darwin-arm64 binaries; 109 MB; 0.6 s | +| `npm ci` with that lockfile and a `package.json` asking for another version | refused: `EUSAGE`, lockfile and `package.json` not in sync | +| `npm config get registry` in a project with a `.npmrc`, with and without `--prefix .taskless/.cache/x` | the project's registry without `--prefix`; the public registry with it; the project's again once the `.npmrc` is copied into the prefix | + +## Goals / Non-Goals + +**Goals:** + +- A launcher install is the dependency tree we tested, on every machine. +- After the first run of a recorded version, a Taskless command touches neither the network nor npm. +- The installed tree and the lockfile that produced it are inspectable inside the project. +- Private registries configured in the project's `.npmrc` keep working. +- The cache never holds more than the versions the manifest records. + +**Non-Goals:** + +- Freezing `npx @taskless/cli` or `pnpm dlx` installs. They keep resolving ranges. +- Changing the CLI's `package.json` at build or publish time. +- A cache shared across projects. + +## Decisions + +### The lockfile is derived from `pnpm-lock.yaml` at build time, offline + +`pnpm-lock.yaml` is what CI installs and tests against, and it already holds what an npm lockfile needs: each package's `integrity`, its `os`, `cpu` and `optional` markers in `packages:`, and the resolved dependency graph in `snapshots:`. The nightly and release builds walk the CLI importer's production dependencies (`dependencies` and `optionalDependencies`) through `snapshots:` and emit a version 3 npm lockfile: + +- `""`: `{ dependencies: { : } }`, the same one-line `package.json` the launcher writes. +- `node_modules/`: version, registry `resolved` URL, and the package's own `dependencies`, `optionalDependencies`, `bin` and `engines`. No `integrity`: the lockfile ships inside this tarball, so it cannot hold the tarball's hash. +- `node_modules/` for every package in the walk, flat, with `resolved`, `integrity`, `os`, `cpu`, `optional` and `dependencies` from `pnpm-lock.yaml`. + +The build fails if two packages in the walk share a name at different versions, since a flat layout cannot hold both and a nested one is a different problem to solve when it appears. It also fails if a package in the walk has no integrity in `pnpm-lock.yaml`. `yaml` is already a dev dependency of the CLI. The lockfile is embedded in the bundle as a string, the way recipes are, so `init` needs no file lookup and no network. A `self` build emits none. + +`resolved` URLs name `registry.npmjs.org`. npm's default `replace-registry-host` rewrites that host to the configured registry, so a mirror still serves them. + +Alternatives considered: + +- **Publish an `npm-shrinkwrap.json`.** Measured above: 1.0 GB with platform packages, broken without them. +- **Rewrite `package.json` to exact versions at publish.** Rejected: the published manifest would differ from the repository's, and it would still leave transitive dependencies floating. +- **Resolve the lockfile at `init` time with npm, forcing the tested versions through overrides.** Needs the network at `init`, and npm's resolution, not our lockfile, decides the tree. + +### One lockfile per package, beside its launcher + +`init` writes `.taskless/bin/cli.lock.json` with `cli.mjs`, or `.taskless/bin/cli-nightly.lock.json` with `cli-nightly.mjs`, from the build that is running. It never writes the other package's pair. A lockfile whose bytes differ from this build's is rewritten and reported as restored, as the launcher is. Both files are committed, so an upgrade's dependency changes appear in the same diff as the version change. + +### The launcher installs with `npm ci` into a temporary directory, then renames + +On a cache miss the launcher: + +1. Reads its lockfile. If it is missing, or its `node_modules/` version is not the recorded version, it stops and says to rerun the installer. It never installs without the lockfile. +2. Creates `.taskless/.cache/.tmp--/`, writes `package.json` (`{ "private": true, "dependencies": { : } }`) and copies the lockfile in as `package-lock.json`. +3. Runs `npm prefix` from the project root and, if `/.npmrc` exists, copies it into the temporary directory. +4. Runs `npm ci --ignore-scripts --no-audit --no-fund --loglevel=error` there, with npm's output on standard error so a first run's `--json` output stays clean. +5. Deletes the copied `.npmrc`, checks the package's `bin` entry resolves, and renames the directory to `.taskless/.cache/@/`. If that already exists, another process won: it deletes its own directory and uses the existing one. + +The final directory's existence means a complete install; there is no lock file. A failed step removes the temporary directory and exits non-zero without running the CLI. + +`--ignore-scripts` is safe because every native piece arrives as a per-platform optional package; the measured install ran ast-grep, Vale and the CLI without scripts. + +The run is `spawnSync(process.execPath, [entry, ...args])`, no shell. `cmd.exe` quoting stays only for the two npm calls on Windows, whose arguments are fixed. + +### The cache ignores itself + +The launcher writes `.taskless/.cache/.gitignore` containing `*` when it creates the directory or finds it without one. No migration, no edit to `.taskless/.gitignore`. + +### Pruning + +An entry is kept when it is `@` for a version `install.launchers` records (or the `cliVersion` fallback the launcher would use). Everything else goes, including `.tmp-*` directories older than an hour. `init` prunes after recording versions; the launcher prunes after it installs a new version, which covers a teammate who pulls an upgrade and never runs `init`. Removal failures (a locked file on Windows) are ignored and retried next time. + +## Risks / Trade-offs + +- [The CLI's own tarball has no `integrity` in the lockfile] → It is pinned by exact version, and a published npm version cannot be republished with different contents. Every dependency is hash-pinned. +- [A future dependency tree needs nesting (two versions of one package)] → The build fails, so a release cannot ship a wrong lockfile; nesting support is added when that happens. +- [`pnpm-lock.yaml` and npm disagree about a package's metadata] → The launcher's `npm ci` validates the lockfile on every first run, and the end-to-end check below runs it before release. +- [A concurrent run of an old version while the launcher prunes it] → Pruning follows only a new install, which follows a manifest change; the window is one run. +- [About 110 MB per project per package version] → At most one version per package after pruning. +- [npm missing on a first run] → Same requirement as `npx` today; the error names npm and the version. +- [CI cache restored from another OS] → The key includes the runner OS, and the launcher trusts an entry only when its bin resolves. + +## Migration Plan + +The next `init` rewrites each launcher and writes its lockfile, both reported. Until then a #537 launcher keeps using `npx`. A new launcher without its lockfile (a manifest from before `install.launchers`) stops and asks for `init`. Rolling back is reinstalling the older CLI; a leftover `.taskless/.cache/` and lockfile are harmless and safe to delete. diff --git a/openspec/changes/launcher-local-cache/proposal.md b/openspec/changes/launcher-local-cache/proposal.md new file mode 100644 index 00000000..7681a5ad --- /dev/null +++ b/openspec/changes/launcher-local-cache/proposal.md @@ -0,0 +1,40 @@ +## Why + +The project launcher (#537) runs the CLI version recorded in `.taskless/taskless.json`, but every run still goes through `npx --yes @`. That puts npx's shared cache, its resolution rules and a registry lookup inside every Taskless command. It also leaves the CLI's own dependencies floating: a published package carries only its `package.json` ranges, so a fresh install of 0.13.1 on 2026-10-19 resolved `tsx` 4.23.15 and `esbuild` 0.28.x where we test 4.21.0 and 0.27.3 (#538). + +A published `npm-shrinkwrap.json` cannot fix that for this package. Measured through a registry with npm 11.8: a full shrinkwrap installs every platform's ast-grep, Vale and esbuild binaries (1.0 GB instead of 110 MB), because npm unpacks a dependency's shrinkwrap without its platform check, and a shrinkwrap without the platform packages installs no binaries at all. + +Installing the recorded version once into a git-ignored `.taskless/.cache/`, from a lockfile the release build derived from our own `pnpm-lock.yaml`, makes every run local and every install the tree we tested. npm is then only the download step, and the lockfile is committed in the project where a reviewer can read it. + +## What Changes + +- Each nightly and release build derives an npm lockfile for its own package from `pnpm-lock.yaml`, offline, and embeds it. The CLI's `package.json` keeps its ranges; development stays loose. A build whose dependency tree cannot be laid out as one flat npm tree fails rather than shipping a partial lockfile. +- `init` writes that lockfile beside the package's launcher, `.taskless/bin/cli.lock.json` for the release and `.taskless/bin/cli-nightly.lock.json` for the nightly. A nightly install leaves the release's pair alone, as it already leaves the release's launcher. +- The launcher installs into `.taskless/.cache/@/` with `npm ci --ignore-scripts` from its lockfile, into a temporary directory renamed into place once complete, then runs the installed bin with the same `node`. It no longer calls `npx`. A missing lockfile, or one that does not name the recorded version, stops the launcher with a message to rerun `init`. +- The install uses the project's `.npmrc`, the one `npx` would have read, so private registries keep working. +- The cache ignores itself (`.taskless/.cache/.gitignore` containing `*`). No scaffold migration. +- Cached versions neither package records are removed, by `init` and by the launcher after it installs a new version. +- The `ci` recipe caches `.taskless/.cache`, keyed on the runner OS and the hash of the package's lockfile. +- Not changed: `npx @taskless/cli` and `pnpm dlx` still resolve ranges. The launcher is the frozen path. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-project-launcher`: the launcher installs from its lockfile into `.taskless/.cache/` and runs it there; published builds carry that lockfile; the cache ignores itself and drops unrecorded versions. +- `cli-init`: `init` writes the package's lockfile beside its launcher and prunes the cache. +- `cli-agent`: the `ci` recipe caches `.taskless/.cache` between runs. + +## Impact + +- `packages/cli/assets/launcher/launcher.mjs`: lockfile install and local run replace the `npx` call. The next `init` rewrites existing launchers and reports them restored. +- `packages/cli/vite.config.ts` (or a build script it calls): derive the lockfile from `pnpm-lock.yaml` for nightly and release targets, and fail the build when it cannot. +- `packages/cli/src/install/launcher.ts` and the `init` flow: write the lockfile, prune the cache. +- `packages/cli/src/agent/ci.md`, `packages/cli/src/agent/hooks.md`, `skills/taskless/SKILL.md`: cache step and first-run wording. +- Projects gain a committed lockfile per installed package (about 22 KB) and a git-ignored cache, about 110 MB per version on macOS arm64, at most one version per package. npm must be on `PATH` for each version's first run, as it already is for `npx`. +- #538 is resolved by this change rather than by pinning `tsx` in `package.json`. +- Delivery shape: single PR, stacked on #537, merging forward once #537 lands. It depends on the launcher and is safe on its own after it. diff --git a/openspec/changes/launcher-local-cache/specs/cli-agent/spec.md b/openspec/changes/launcher-local-cache/specs/cli-agent/spec.md new file mode 100644 index 00000000..95f133ab --- /dev/null +++ b/openspec/changes/launcher-local-cache/specs/cli-agent/spec.md @@ -0,0 +1,27 @@ +## MODIFIED Requirements + +### Requirement: The ci recipe runs the launcher with Node supplied by the job + +The `ci` recipe SHALL generate CI configuration that runs `node .taskless/bin/cli.mjs check`, for a full scan, and `node .taskless/bin/cli.mjs check `, for a diff scan, from the repository root. It SHALL NOT generate a `npx`, `pnpm dlx`, `yarn dlx`, or `bunx` call to the CLI, and SHALL NOT ask the reader to choose a package manager launcher. It SHALL use `node ` rather than `./`, since a CI checkout can lose the executable bit and a Windows runner does not honour a shebang. + +The recipe SHALL require that the generated job make Node available itself, through the CI system's Node setup step or a Node container image, and SHALL say that the job does not need the project's own language toolchain or a `package.json`. It SHALL name the launcher's version source, `.taskless/taskless.json`, as what decides which CLI version CI runs, so an upgrade reaches CI through the same commit that updates `.taskless/`. + +The recipe SHALL have the generated job cache `.taskless/.cache` between runs where the CI system supports a cache, with a key that includes the runner's operating system and a hash of `.taskless/bin/cli.lock.json`, so a restored cache never crosses platforms and an upgrade starts a fresh one. + +#### Scenario: GitHub Actions template + +- **WHEN** the `ci` recipe is rendered +- **THEN** its GitHub Actions template SHALL contain `node .taskless/bin/cli.mjs check` +- **AND** SHALL contain a Node setup step +- **AND** SHALL NOT contain `%(PACKAGE_MANAGER_DLX)s` + +#### Scenario: A repository without package.json + +- **WHEN** the `ci` recipe is followed in a repository with no `package.json` +- **THEN** the recipe SHALL direct the job to supply Node itself, and SHALL NOT tell the reader to add a `package.json` + +#### Scenario: The launcher cache is restored between runs + +- **WHEN** the `ci` recipe is rendered +- **THEN** its GitHub Actions template SHALL cache the path `.taskless/.cache` +- **AND** the cache key SHALL include `runner.os` and `hashFiles('.taskless/bin/cli.lock.json')` diff --git a/openspec/changes/launcher-local-cache/specs/cli-init/spec.md b/openspec/changes/launcher-local-cache/specs/cli-init/spec.md new file mode 100644 index 00000000..503c79f6 --- /dev/null +++ b/openspec/changes/launcher-local-cache/specs/cli-init/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: Init writes the package's lockfile beside its launcher + +Every `init` that writes a launcher SHALL also write this build's lockfile for the same package beside it: `.taskless/bin/cli.lock.json` for the release and `.taskless/bin/cli-nightly.lock.json` for the nightly. It SHALL NOT write, change or remove the other package's lockfile. A lockfile whose bytes differ from this build's SHALL be rewritten, and the install summary SHALL name it as restored. A `self` build SHALL write no lockfile. No scaffold migration SHALL be added for this. + +#### Scenario: A release install writes its lockfile + +- **WHEN** the release's `init` runs at `0.13.1` +- **THEN** `.taskless/bin/cli.lock.json` SHALL hold this build's lockfile, naming `@taskless/cli` at `0.13.1` + +#### Scenario: A nightly install leaves the release's lockfile alone + +- **WHEN** `.taskless/bin/cli.lock.json` exists and a nightly `init` runs +- **THEN** `.taskless/bin/cli-nightly.lock.json` SHALL be written +- **AND** `.taskless/bin/cli.lock.json` SHALL be unchanged + +#### Scenario: An edited lockfile is restored and reported + +- **WHEN** `.taskless/bin/cli.lock.json` was edited and the release's `init` runs +- **THEN** the file SHALL hold this build's lockfile afterwards +- **AND** the summary SHALL name it as restored + +### Requirement: Init removes cached CLI versions nothing records + +After `init` records versions under `install.launchers`, it SHALL remove every entry in `.taskless/.cache/` that is not the version recorded for either package, and every temporary install directory more than an hour old, by the same rule the launcher uses. Removal SHALL be best effort: an entry that cannot be removed SHALL be left without failing `init`. `init` SHALL NOT install anything into `.taskless/.cache/`; the launcher does that on its first run. + +#### Scenario: An upgrade drops the previous release + +- **WHEN** `.taskless/.cache/` holds `@taskless/cli@0.13.0` and the release's `init` runs at `0.13.1` +- **THEN** `@taskless/cli@0.13.0` SHALL no longer be in the cache +- **AND** nothing SHALL be installed for `0.13.1` + +#### Scenario: A nightly install keeps the release's cache + +- **WHEN** `.taskless/.cache/` holds `@taskless/cli@0.13.0`, `install.launchers` records it, and a nightly `init` runs +- **THEN** `@taskless/cli@0.13.0` SHALL remain in the cache diff --git a/openspec/changes/launcher-local-cache/specs/cli-project-launcher/spec.md b/openspec/changes/launcher-local-cache/specs/cli-project-launcher/spec.md new file mode 100644 index 00000000..c25d2ca0 --- /dev/null +++ b/openspec/changes/launcher-local-cache/specs/cli-project-launcher/spec.md @@ -0,0 +1,149 @@ +## MODIFIED Requirements + +### Requirement: A launcher runs the version recorded for its package + +When run, a launcher SHALL read `.taskless/taskless.json` from the `.taskless/` directory that contains it, not from the working directory, and take its package's version from `install.launchers`. When that field has no version for its package, it SHALL fall back to `install.cliVersion`, but only when that version belongs to its package: a version containing `-` belongs to the nightly, and one without belongs to the release. It SHALL then run that version's installed entry point from `.taskless/.cache/@/`, installing it there first from the package's lockfile when it is absent, with the Node executable that is running the launcher and every argument it was given, unchanged and in order, inheriting standard input, output, and error, and the working directory and environment it was started with. It SHALL NOT run the CLI through `npx`. + +The launcher SHALL add one variable to the environment it passes on, naming its own path relative to the project root, so the CLI it starts can name the launcher in what it prints. + +It SHALL exit with the CLI's exit status. When the CLI is ended by a signal, the launcher SHALL exit as that signal would end it. When the recorded version is already installed, it SHALL make no network request and start no package manager. It SHALL NEVER fall back to an unpinned or dist-tagged version. + +#### Scenario: The release launcher runs its pin + +- **WHEN** `install.launchers` records `@taskless/cli` at `0.13.0`, `.taskless/.cache/@taskless/cli@0.13.0/` holds that version, and `./.taskless/bin/cli.mjs check src/a.ts` runs +- **THEN** it SHALL run that directory's `@taskless/cli` entry point with `check src/a.ts` +- **AND** no `npm` or `npx` process SHALL start + +#### Scenario: A nightly install does not move the release launcher + +- **WHEN** `install.launchers` records `@taskless/cli` at `0.13.0`, and a nightly `init` sets `install.cliVersion` to `0.14.0-20261020120000xabc1234` +- **THEN** `./.taskless/bin/cli.mjs` SHALL still run `@taskless/cli@0.13.0` + +#### Scenario: A manifest from an older CLI still works + +- **WHEN** `install.launchers` is absent and `install.cliVersion` is `0.13.0` +- **THEN** `./.taskless/bin/cli.mjs` SHALL run `@taskless/cli@0.13.0` +- **AND** `./.taskless/bin/cli-nightly.mjs` SHALL NOT run `0.13.0` + +#### Scenario: Run from a subdirectory + +- **WHEN** the working directory is `src/` and `../.taskless/bin/cli.mjs check` runs +- **THEN** the launcher SHALL read the version from, and use the cache in, the `.taskless/` that contains it +- **AND** the CLI SHALL start in `src/` + +#### Scenario: The exit status passes through + +- **WHEN** the CLI exits with status 1 because a rule failed +- **THEN** the launcher SHALL exit with status 1 + +### Requirement: A launcher without a version fails plainly + +When no version for its package can be read, because `.taskless/taskless.json` is missing or does not parse, or neither field holds a version for its package, the launcher SHALL install nothing, run nothing, print one message to standard error naming the file it read and the installer command that records a version, and exit with a non-zero status. + +#### Scenario: No version recorded + +- **WHEN** `install.launchers` and `install.cliVersion` hold no release version and `./.taskless/bin/cli.mjs check` runs +- **THEN** no `npm` or `npx` process SHALL start +- **AND** standard error SHALL name `.taskless/taskless.json` and the installer command +- **AND** the exit status SHALL be non-zero + +## ADDED Requirements + +### Requirement: Each published build carries the lockfile of its tested dependencies + +A nightly or release build SHALL carry an npm lockfile for its own package, derived from the repository's `pnpm-lock.yaml` without network access, that pins every package in the CLI's production dependency tree, including every platform's optional packages, to the version and integrity `pnpm-lock.yaml` records, with each package's platform constraints. The CLI's `package.json` SHALL NOT be changed to produce it. When the tree cannot be expressed as one flat lockfile, or a package in it has no recorded integrity, the build SHALL fail. A `self` build SHALL carry no lockfile. + +#### Scenario: The lockfile matches what CI tested + +- **WHEN** `pnpm-lock.yaml` resolves the CLI's `tsx` to `4.21.0` and its `esbuild` to `0.27.3`, and a release build runs +- **THEN** the build's lockfile SHALL pin `tsx` at `4.21.0` and `esbuild` at `0.27.3`, each with the integrity `pnpm-lock.yaml` records +- **AND** the CLI's `package.json` SHALL still declare its ranges + +#### Scenario: Two versions of one dependency + +- **WHEN** the CLI's production tree in `pnpm-lock.yaml` holds the same package at two versions +- **THEN** the nightly and release builds SHALL fail and name that package + +### Requirement: A launcher installs from its lockfile once, completely + +When `.taskless/.cache/@/` does not exist, the launcher SHALL install `@` with `npm ci` from the lockfile beside it (`.taskless/bin/cli.lock.json` for the release, `.taskless/bin/cli-nightly.lock.json` for the nightly), without running any package's install scripts, into a new temporary directory under `.taskless/.cache/`. It SHALL make that the final directory only after npm succeeded and the package's entry point resolves. An existing final directory SHALL be treated as a complete install. When another process created the final directory first, the launcher SHALL discard its own install and run the existing one. + +The launcher SHALL NOT install without its lockfile, and SHALL NOT install when the lockfile does not name the recorded version; in both cases it SHALL print one message to standard error naming the lockfile and the installer command that rewrites it, and exit with a non-zero status. + +The install SHALL use the `.npmrc` of the directory npm treats as the project when run from the project root, when one exists, and that file SHALL NOT remain in the cache afterwards. npm's output SHALL go to standard error, never standard output. + +#### Scenario: First run installs the tested tree, later runs do not install + +- **WHEN** `.taskless/.cache/` holds nothing for `@taskless/cli@0.13.0`, `.taskless/bin/cli.lock.json` pins `tsx` at `4.21.0`, and `./.taskless/bin/cli.mjs --version` runs twice +- **THEN** the first run SHALL install into `.taskless/.cache/@taskless/cli@0.13.0/` with `tsx` at `4.21.0` and only the current platform's binaries, and print `0.13.0` +- **AND** the second run SHALL start no `npm` process + +#### Scenario: Missing lockfile + +- **WHEN** `install.launchers` records `@taskless/cli` at `0.13.0` and `.taskless/bin/cli.lock.json` does not exist +- **THEN** no `npm` process SHALL start +- **AND** standard error SHALL name `.taskless/bin/cli.lock.json` and the installer command +- **AND** the exit status SHALL be non-zero + +#### Scenario: Lockfile for another version + +- **WHEN** `install.launchers` records `@taskless/cli` at `0.13.1` and `.taskless/bin/cli.lock.json` pins `@taskless/cli` at `0.13.0` +- **THEN** the launcher SHALL NOT install or run anything, and SHALL exit non-zero naming the lockfile + +#### Scenario: A project registry is used + +- **WHEN** the project root has an `.npmrc` setting `registry=` and the launcher installs +- **THEN** npm SHALL fetch from that registry +- **AND** the cache entry SHALL NOT contain an `.npmrc` afterwards + +#### Scenario: JSON output stays clean on a first run + +- **WHEN** the first run of a version is `./.taskless/bin/cli.mjs info --json` +- **THEN** standard output SHALL hold only the CLI's JSON + +#### Scenario: Two first runs at once + +- **WHEN** two launcher processes start with no cache entry for the recorded version +- **THEN** both SHALL run the CLI +- **AND** exactly one cache entry for that version SHALL remain, with no temporary directory left by either + +### Requirement: A failed install runs nothing + +When npm cannot be started, or the install fails, or the installed package's entry point does not resolve, the launcher SHALL remove its temporary directory, leave no final directory, print a message to standard error naming the package and version it tried to install, and exit with a non-zero status without running the CLI. + +#### Scenario: npm is not on PATH + +- **WHEN** no `npm` executable can be found and the recorded version is not installed +- **THEN** standard error SHALL say npm is needed to install `@taskless/cli@0.13.0` +- **AND** the exit status SHALL be non-zero +- **AND** `.taskless/.cache/` SHALL hold no entry for that version + +#### Scenario: The registry refuses + +- **WHEN** npm exits non-zero +- **THEN** npm's error SHALL appear on standard error +- **AND** the CLI SHALL NOT run + +### Requirement: The launcher cache ignores itself + +Whenever the launcher creates `.taskless/.cache/`, or finds it without one, it SHALL write `.taskless/.cache/.gitignore` containing `*`, so nothing in the cache is committed without any change to the project's other ignore files. + +#### Scenario: A fresh cache is not tracked + +- **WHEN** the launcher installs into a project with no `.taskless/.cache/` +- **THEN** `git status` SHALL show nothing under `.taskless/.cache/` + +### Requirement: The launcher drops versions nothing records + +After the launcher installs a new version, it SHALL remove every `.taskless/.cache/` entry that is not the version recorded for either package, including the fallback it would use, and every temporary install directory more than an hour old. Removal SHALL be best effort: an entry that cannot be removed SHALL be left, and SHALL NOT change the launcher's exit status or output. + +#### Scenario: An upgrade pulled from git + +- **WHEN** `.taskless/.cache/` holds `@taskless/cli@0.13.0`, `install.launchers` now records `@taskless/cli` at `0.13.1`, and the launcher runs +- **THEN** it SHALL install `0.13.1` and run it +- **AND** `@taskless/cli@0.13.0` SHALL no longer be in the cache + +#### Scenario: The other package's version is kept + +- **WHEN** `install.launchers` records both packages and the release launcher installs a new release +- **THEN** the cache entry for the recorded nightly version SHALL remain diff --git a/openspec/changes/launcher-local-cache/tasks.md b/openspec/changes/launcher-local-cache/tasks.md new file mode 100644 index 00000000..a7cbe797 --- /dev/null +++ b/openspec/changes/launcher-local-cache/tasks.md @@ -0,0 +1,33 @@ +## 1. Build-time lockfile + +- [ ] 1.1 Add a build step for nightly and release targets that walks the CLI importer's production dependencies through `pnpm-lock.yaml` (`yaml` is already a dev dependency) and emits a version 3 npm lockfile: root, the CLI entry without `integrity`, and every dependency flat with `resolved`, `integrity`, `os`, `cpu`, `optional` and `dependencies`; embed it in the bundle; emit none for `self`; verify with a unit test over a fixture lockfile +- [ ] 1.2 Fail the build when a name appears at two versions in the walk, or a package has no integrity; verify with fixture tests that each failure names the package +- [ ] 1.3 Verify against the real tree: build the release target, write the lockfile into a scratch directory with its one-line `package.json`, run `npm ci --ignore-scripts`, and confirm `tsx`/`esbuild` match `pnpm-lock.yaml` and only this platform's binaries are installed + +## 2. Init + +- [ ] 2.1 Write `.taskless/bin/cli.lock.json` or `cli-nightly.lock.json` with the launcher, unchanged/created/restored like the launcher, never touching the other package's; verify with tests for a release install, a nightly beside a release, and an edited lockfile +- [ ] 2.2 Prune `.taskless/.cache/` after recording versions (shared rule in `src/install/launcher.ts`; the template keeps its own copy since it ships standalone); verify with tests that an upgrade drops the old release and a nightly install keeps it + +## 3. Launcher + +- [ ] 3.1 Run from the cache: resolve `.taskless/.cache/@/`, read the bin from the installed `package.json`, run it with `process.execPath` and no shell; verify a pre-populated entry runs and no `npm` process starts +- [ ] 3.2 Refuse without a matching lockfile: missing, or naming another version; verify each with a test (no `npm` started, message names the lockfile and the installer command) +- [ ] 3.3 Install: temporary directory, one-line `package.json` plus the lockfile, `npm prefix` and the project `.npmrc` copy, `npm ci --ignore-scripts --no-audit --no-fund --loglevel=error` with output on standard error, remove the `.npmrc`, check the bin resolves, rename (discarding ours on a lost race); `cmd.exe` quoting only for the npm calls; verify first run, second run, lost race and clean `--json` output with a stub `npm` on `PATH` +- [ ] 3.4 Failure paths: npm missing, npm exiting non-zero, bin missing; each removes the temporary directory and exits non-zero naming `@`; verify with tests +- [ ] 3.5 Write `.taskless/.cache/.gitignore` (`*`); verify with a test and `git status` in a scratch repository +- [ ] 3.6 Prune after a new install by the shared rule, ignoring removal errors; verify with tests for an upgrade and for keeping the other package's entry + +## 4. Recipes and skill + +- [ ] 4.1 `ci` recipe: `actions/cache` for `.taskless/.cache` keyed on `runner.os` and `hashFiles('.taskless/bin/cli.lock.json')`, the same advice in the other-CI guidance, topic version bump; verify with the recipe tests +- [ ] 4.2 `hooks` recipe and `skills/taskless/SKILL.md`: the first run of a version installs it into `.taskless/.cache/`, later runs are local; the lockfile is committed with the launcher; verify the parity tests (no prettier over `src/agent/*.md`) + +## 5. Verification and release + +- [ ] 5.1 End to end from a built release-target CLI in a scratch project: `init` writes launcher and lockfile; `./.taskless/bin/cli.mjs check` runs an ast-grep and a Vale rule, and a runtime rule if available; the second run starts no `npm`; `git status` shows the lockfile and not the cache. Since the version under test is unpublished, serve it from a local scoped registry as in the design's measurements +- [ ] 5.2 Private registry: a project `.npmrc` pointing at an unreachable registry fails the first install with npm's error and leaves no `.npmrc` or temporary directory +- [ ] 5.3 Extend `.changeset/pinned-cli-wrapper.md` (no second changeset); update #538 with the shrinkwrap measurements and point it at this change +- [ ] 5.4 Run `pnpm typecheck`, `pnpm lint` and the test suite +- [ ] 5.5 Archive and check scenario counts for `cli-project-launcher`, `cli-init` and `cli-agent` against the main specs +- [ ] 5.6 Follow-up, not blocking: a Windows run of a first install and a later run From 635f8b19c355b0573760f7943445c5d00a5ec6b7 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 22:46:45 -0700 Subject: [PATCH 02/11] build: derive the launcher's npm lockfile from pnpm-lock.yaml --- .../changes/launcher-local-cache/tasks.md | 6 +- packages/cli/scripts/install-lockfile.ts | 228 ++++++++++++++++++ packages/cli/src/globals.d.ts | 2 + packages/cli/test/install-lockfile.test.ts | 146 +++++++++++ packages/cli/vite.config.ts | 37 +++ 5 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 packages/cli/scripts/install-lockfile.ts create mode 100644 packages/cli/test/install-lockfile.test.ts diff --git a/openspec/changes/launcher-local-cache/tasks.md b/openspec/changes/launcher-local-cache/tasks.md index a7cbe797..46aeb10b 100644 --- a/openspec/changes/launcher-local-cache/tasks.md +++ b/openspec/changes/launcher-local-cache/tasks.md @@ -1,8 +1,8 @@ ## 1. Build-time lockfile -- [ ] 1.1 Add a build step for nightly and release targets that walks the CLI importer's production dependencies through `pnpm-lock.yaml` (`yaml` is already a dev dependency) and emits a version 3 npm lockfile: root, the CLI entry without `integrity`, and every dependency flat with `resolved`, `integrity`, `os`, `cpu`, `optional` and `dependencies`; embed it in the bundle; emit none for `self`; verify with a unit test over a fixture lockfile -- [ ] 1.2 Fail the build when a name appears at two versions in the walk, or a package has no integrity; verify with fixture tests that each failure names the package -- [ ] 1.3 Verify against the real tree: build the release target, write the lockfile into a scratch directory with its one-line `package.json`, run `npm ci --ignore-scripts`, and confirm `tsx`/`esbuild` match `pnpm-lock.yaml` and only this platform's binaries are installed +- [x] 1.1 Add a build step for nightly and release targets that walks the CLI importer's production dependencies through `pnpm-lock.yaml` (`yaml` is already a dev dependency) and emits a version 3 npm lockfile: root, the CLI entry without `integrity`, and every dependency flat with `resolved`, `integrity`, `os`, `cpu`, `optional` and `dependencies`; embed it in the bundle; emit none for `self`; verify with a unit test over a fixture lockfile +- [x] 1.2 Fail the build when a name appears at two versions in the walk, or a package has no integrity; verify with fixture tests that each failure names the package +- [x] 1.3 Verify against the real tree: build the release target, write the lockfile into a scratch directory with its one-line `package.json`, run `npm ci --ignore-scripts`, and confirm `tsx`/`esbuild` match `pnpm-lock.yaml` and only this platform's binaries are installed ## 2. Init diff --git a/packages/cli/scripts/install-lockfile.ts b/packages/cli/scripts/install-lockfile.ts new file mode 100644 index 00000000..0a762a70 --- /dev/null +++ b/packages/cli/scripts/install-lockfile.ts @@ -0,0 +1,228 @@ +/** + * The npm lockfile a published build carries for its project launcher. + * + * The launcher installs its package with `npm ci` from this file, so what a + * project runs is the dependency tree this repository tested, not whatever the + * `package.json` ranges resolve to on the day of the install. It is derived + * from `pnpm-lock.yaml`, which is what CI installs, without the network and + * without touching `package.json`: development keeps its ranges, a release is + * frozen. + * + * NOT AN `npm-shrinkwrap.json`. npm reads a dependency's shrinkwrap after its + * platform check, so a full one installs every platform's ast-grep, Vale and + * esbuild binaries (measured: 1.0 GB instead of 110 MB), and one without the + * platform packages installs none. A lockfile at the root of an `npm ci` + * install is filtered by platform as usual. + * + * Pure over its inputs, like `build-target.ts`, so it is unit-testable over a + * fixture lockfile. + */ + +/** The parts of a parsed `pnpm-lock.yaml` (v9) this reads. */ +export interface PnpmLockfile { + importers: Record< + string, + { + dependencies?: Record; + optionalDependencies?: Record; + } + >; + packages: Record< + string, + { + resolution?: { integrity?: string }; + engines?: Record; + os?: string[]; + cpu?: string[]; + libc?: string[]; + } + >; + snapshots: Record< + string, + { + dependencies?: Record; + optionalDependencies?: Record; + } + >; +} + +/** The CLI package being locked: its own manifest fields, under its published name. */ +export interface LockedPackage { + name: string; + version: string; + license?: string; + bin?: Record; + engines?: Record; + dependencies?: Record; + optionalDependencies?: Record; +} + +/** One `packages` entry of an npm v3 lockfile. */ +export interface NpmLockEntry { + version?: string; + resolved?: string; + integrity?: string; + license?: string; + optional?: true; + bin?: Record; + engines?: Record; + os?: string[]; + cpu?: string[]; + libc?: string[]; + dependencies?: Record; + optionalDependencies?: Record; +} + +export interface NpmLockfile { + name: string; + lockfileVersion: 3; + requires: true; + packages: Record; +} + +/** The root `package.json` the launcher writes beside this lockfile. */ +export const INSTALL_ROOT_NAME = "taskless-launcher"; + +const REGISTRY = "https://registry.npmjs.org"; + +/** npm's tarball URL for a package version on the public registry. */ +function tarballUrl(name: string, version: string): string { + const base = name.startsWith("@") ? name.slice(name.indexOf("/") + 1) : name; + return `${REGISTRY}/${name}/-/${base}-${version}.tgz`; +} + +/** A snapshot reference's version, without pnpm's peer suffix `(...)`. */ +function bareVersion(reference: string): string { + const index = reference.indexOf("("); + return index === -1 ? reference : reference.slice(0, index); +} + +/** A snapshot's dependency map with every reference reduced to its version. */ +function pin( + references: Record | undefined +): Record | undefined { + return references === undefined + ? undefined + : Object.fromEntries( + Object.entries(references).map(([child, reference]) => [ + child, + bareVersion(reference), + ]) + ); +} + +/** + * Build the lockfile for `locked`, whose dependencies are the ones the + * `importer` (e.g. `packages/cli`) resolved in `lock`. + * + * Throws, naming the package, when the tree cannot be written as one flat + * `node_modules`: a name at two versions, or a package without an integrity. + * A build must fail then rather than ship a partial lockfile. + */ +export function buildInstallLockfile( + lock: PnpmLockfile, + importer: string, + locked: LockedPackage +): NpmLockfile { + const root = lock.importers[importer]; + if (root === undefined) { + throw new Error(`pnpm-lock.yaml has no importer ${importer}`); + } + + // name -> version, and whether every path to it runs through an optional edge. + const versions = new Map(); + const required = new Set(); + const snapshotKeys = new Map(); + + const visit = (name: string, reference: string, optional: boolean) => { + const version = bareVersion(reference); + const seen = versions.get(name); + if (seen !== undefined && seen !== version) { + throw new Error( + `${name} resolves to both ${seen} and ${version} in the ${locked.name} tree; ` + + `the install lockfile is flat and cannot hold two versions` + ); + } + const firstRequired = !optional && !required.has(name); + if (seen !== undefined && !firstRequired) return; + versions.set(name, version); + snapshotKeys.set(name, `${name}@${reference}`); + if (!optional) required.add(name); + const snapshot = lock.snapshots[`${name}@${reference}`] ?? {}; + for (const [child, childReference] of Object.entries( + snapshot.dependencies ?? {} + )) { + visit(child, childReference, optional); + } + for (const [child, childReference] of Object.entries( + snapshot.optionalDependencies ?? {} + )) { + visit(child, childReference, true); + } + }; + + for (const [name, { version }] of Object.entries(root.dependencies ?? {})) { + visit(name, version, false); + } + for (const [name, { version }] of Object.entries( + root.optionalDependencies ?? {} + )) { + visit(name, version, true); + } + + const packages: Record = { + "": { + dependencies: { [locked.name]: locked.version }, + }, + // No integrity: this lockfile ships inside this package's tarball, so it + // cannot hold that tarball's hash. The exact version pins it instead; a + // published npm version cannot be republished with other contents. + [`node_modules/${locked.name}`]: { + version: locked.version, + resolved: tarballUrl(locked.name, locked.version), + ...(locked.license === undefined ? {} : { license: locked.license }), + ...(locked.dependencies === undefined + ? {} + : { dependencies: locked.dependencies }), + ...(locked.optionalDependencies === undefined + ? {} + : { optionalDependencies: locked.optionalDependencies }), + ...(locked.bin === undefined ? {} : { bin: locked.bin }), + ...(locked.engines === undefined ? {} : { engines: locked.engines }), + }, + }; + + for (const name of [...versions.keys()].toSorted()) { + const version = versions.get(name) ?? ""; + const key = `${name}@${version}`; + const metadata = lock.packages[key]; + const integrity = metadata?.resolution?.integrity; + if (integrity === undefined) { + throw new Error( + `${key} has no integrity in pnpm-lock.yaml; the install lockfile pins every package by hash` + ); + } + const snapshot = lock.snapshots[snapshotKeys.get(name) ?? key] ?? {}; + const dependencies = pin(snapshot.dependencies); + const optionalDependencies = pin(snapshot.optionalDependencies); + packages[`node_modules/${name}`] = { + version, + resolved: tarballUrl(name, version), + integrity, + ...(required.has(name) ? {} : { optional: true }), + ...(metadata?.engines === undefined ? {} : { engines: metadata.engines }), + ...(metadata?.os === undefined ? {} : { os: metadata.os }), + ...(metadata?.cpu === undefined ? {} : { cpu: metadata.cpu }), + ...(metadata?.libc === undefined ? {} : { libc: metadata.libc }), + ...(dependencies === undefined ? {} : { dependencies }), + ...(optionalDependencies === undefined ? {} : { optionalDependencies }), + }; + } + + return { + name: INSTALL_ROOT_NAME, + lockfileVersion: 3, + requires: true, + packages, + }; +} diff --git a/packages/cli/src/globals.d.ts b/packages/cli/src/globals.d.ts index 96017b5e..4e0231a6 100644 --- a/packages/cli/src/globals.d.ts +++ b/packages/cli/src/globals.d.ts @@ -4,3 +4,5 @@ declare const __VERSION__: string; declare const __TASKLESS_CLI__: string; declare const __TASKLESS_CLI_NOTICE__: string; declare const __TASKLESS_BUILD_TARGET__: "prod" | "self" | "nightly"; +/** The npm lockfile this build's launcher installs from; `null` for `self`. */ +declare const __TASKLESS_INSTALL_LOCK__: string | null; diff --git a/packages/cli/test/install-lockfile.test.ts b/packages/cli/test/install-lockfile.test.ts new file mode 100644 index 00000000..44e2df49 --- /dev/null +++ b/packages/cli/test/install-lockfile.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; + +import type { PnpmLockfile } from "../scripts/install-lockfile"; +import { buildInstallLockfile } from "../scripts/install-lockfile"; + +/** A small `pnpm-lock.yaml` shaped like the CLI's: one runtime dep, platform optionals. */ +function fixture(): PnpmLockfile { + return { + importers: { + "packages/cli": { + dependencies: { tsx: { version: "4.21.0" } }, + optionalDependencies: { + "@ast-grep/cli-linux-x64-gnu": { version: "0.45.3" }, + }, + }, + }, + packages: { + "tsx@4.21.0": { + resolution: { integrity: "sha512-tsx" }, + engines: { node: ">=18.0.0" }, + }, + "esbuild@0.27.3": { resolution: { integrity: "sha512-esbuild" } }, + "@esbuild/linux-x64@0.27.3": { + resolution: { integrity: "sha512-esbuild-linux" }, + os: ["linux"], + cpu: ["x64"], + }, + "@ast-grep/cli-linux-x64-gnu@0.45.3": { + resolution: { integrity: "sha512-sg-linux" }, + os: ["linux"], + cpu: ["x64"], + }, + }, + snapshots: { + "tsx@4.21.0": { dependencies: { esbuild: "0.27.3" } }, + "esbuild@0.27.3": { + optionalDependencies: { "@esbuild/linux-x64": "0.27.3" }, + }, + "@esbuild/linux-x64@0.27.3": {}, + "@ast-grep/cli-linux-x64-gnu@0.45.3": {}, + }, + }; +} + +const CLI = { + name: "@taskless/cli", + version: "0.13.1", + license: "MIT", + bin: { taskless: "./dist/index.js" }, + dependencies: { tsx: "^4.21.0" }, + optionalDependencies: { "@ast-grep/cli-linux-x64-gnu": "0.45.3" }, +}; + +describe("buildInstallLockfile", () => { + it("roots the lockfile at the exact CLI version, with no integrity for the CLI itself", () => { + const lock = buildInstallLockfile(fixture(), "packages/cli", CLI); + expect(lock.lockfileVersion).toBe(3); + expect(lock.packages[""]).toEqual({ + dependencies: { "@taskless/cli": "0.13.1" }, + }); + const cli = lock.packages["node_modules/@taskless/cli"]; + expect(cli?.version).toBe("0.13.1"); + expect(cli?.resolved).toBe( + "https://registry.npmjs.org/@taskless/cli/-/cli-0.13.1.tgz" + ); + expect(cli?.integrity).toBeUndefined(); + // The package's own ranges, as published; the pins live in its dependencies' entries. + expect(cli?.dependencies).toEqual({ tsx: "^4.21.0" }); + }); + + it("pins every dependency to pnpm's version and integrity, flat", () => { + const lock = buildInstallLockfile(fixture(), "packages/cli", CLI); + expect(lock.packages["node_modules/tsx"]).toEqual({ + version: "4.21.0", + resolved: "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + integrity: "sha512-tsx", + engines: { node: ">=18.0.0" }, + dependencies: { esbuild: "0.27.3" }, + }); + expect(lock.packages["node_modules/esbuild"]?.optionalDependencies).toEqual( + { "@esbuild/linux-x64": "0.27.3" } + ); + }); + + it("keeps platform constraints and marks packages reached only through optional edges", () => { + const lock = buildInstallLockfile(fixture(), "packages/cli", CLI); + expect(lock.packages["node_modules/@esbuild/linux-x64"]).toMatchObject({ + os: ["linux"], + cpu: ["x64"], + optional: true, + }); + expect( + lock.packages["node_modules/@ast-grep/cli-linux-x64-gnu"] + ).toMatchObject({ + optional: true, + }); + expect(lock.packages["node_modules/esbuild"]?.optional).toBeUndefined(); + }); + + it("names the nightly's tarball under its own package", () => { + const lock = buildInstallLockfile(fixture(), "packages/cli", { + ...CLI, + name: "@taskless/cli-nightly", + version: "0.14.0-20261020120000xabc1234", + }); + expect(lock.packages["node_modules/@taskless/cli-nightly"]?.resolved).toBe( + "https://registry.npmjs.org/@taskless/cli-nightly/-/cli-nightly-0.14.0-20261020120000xabc1234.tgz" + ); + }); + + it("fails, naming the package, when one name resolves to two versions", () => { + const lock = fixture(); + lock.packages["esbuild@0.28.0"] = { + resolution: { integrity: "sha512-other" }, + }; + lock.snapshots["esbuild@0.28.0"] = {}; + lock.importers["packages/cli"]!.dependencies!.esbuild = { + version: "0.28.0", + }; + expect(() => buildInstallLockfile(lock, "packages/cli", CLI)).toThrow( + /esbuild resolves to both/ + ); + }); + + it("fails, naming the package, when a package has no integrity", () => { + const lock = fixture(); + delete lock.packages["esbuild@0.27.3"]!.resolution; + expect(() => buildInstallLockfile(lock, "packages/cli", CLI)).toThrow( + /esbuild@0\.27\.3 has no integrity/ + ); + }); + + it("drops pnpm's peer suffix from versions", () => { + const lock = fixture(); + lock.snapshots["tsx@4.21.0"] = { + dependencies: { esbuild: "0.27.3(typescript@5.9.3)" }, + }; + lock.snapshots["esbuild@0.27.3(typescript@5.9.3)"] = + lock.snapshots["esbuild@0.27.3"]!; + const result = buildInstallLockfile(lock, "packages/cli", CLI); + expect(result.packages["node_modules/esbuild"]?.version).toBe("0.27.3"); + expect(result.packages["node_modules/tsx"]?.dependencies).toEqual({ + esbuild: "0.27.3", + }); + }); +}); diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index 2d36dbf7..e467666b 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -16,6 +16,8 @@ import { resolveCliVersion, resolveOutputDirectory, } from "./scripts/build-target"; +import type { PnpmLockfile } from "./scripts/install-lockfile"; +import { buildInstallLockfile } from "./scripts/install-lockfile"; const pkg = JSON.parse( readFileSync(resolve(import.meta.dirname, "package.json"), "utf8") @@ -24,6 +26,11 @@ const pkg = JSON.parse( /** A conditions object for a built entry, or a bare path for a data asset. */ exports: Record; files: string[]; + license?: string; + bin?: Record; + engines?: Record; + dependencies?: Record; + optionalDependencies?: Record; }; // Target resolution lives in ./scripts/build-target.ts so it can be unit-tested @@ -45,6 +52,30 @@ if (buildTarget === "nightly") { ); } +/** + * The npm lockfile a published build's launcher installs from, as text, or + * `null` for a `self` build, which installs no launcher. Derived from + * `pnpm-lock.yaml` (see ./scripts/install-lockfile.ts); a tree it cannot + * express fails the build here. + */ +function installLockfile(target: string, version: string): string | null { + if (target === "self") return null; + const lock = parse( + readFileSync(resolve(import.meta.dirname, "../../pnpm-lock.yaml"), "utf8") + ) as PnpmLockfile; + const lockfile = buildInstallLockfile(lock, "packages/cli", { + // ast-grep-ignore: no-unrouted-cli-invocation + name: target === "nightly" ? "@taskless/cli-nightly" : "@taskless/cli", + version, + license: pkg.license, + bin: pkg.bin, + engines: pkg.engines, + dependencies: pkg.dependencies, + optionalDependencies: pkg.optionalDependencies, + }); + return JSON.stringify(lockfile, null, 2) + "\n"; +} + const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; function assertSkillVersions(): Plugin { @@ -472,6 +503,9 @@ export default defineConfig({ __TASKLESS_CLI__: JSON.stringify(cliInvocation), __TASKLESS_CLI_NOTICE__: JSON.stringify(cliNotice), __TASKLESS_BUILD_TARGET__: JSON.stringify(buildTarget), + __TASKLESS_INSTALL_LOCK__: JSON.stringify( + installLockfile(buildTarget, cliVersion) + ), }, plugins: [ tsconfigPaths(), @@ -548,6 +582,9 @@ export default defineConfig({ __TASKLESS_CLI__: JSON.stringify(NIGHTLY_TEST_INVOCATION), __TASKLESS_CLI_NOTICE__: JSON.stringify(NIGHTLY_TEST_NOTICE), __TASKLESS_BUILD_TARGET__: JSON.stringify("nightly"), + __TASKLESS_INSTALL_LOCK__: JSON.stringify( + installLockfile("nightly", NIGHTLY_TEST_VERSION) + ), }, test: { name: "nightly", include: ["test/nightly/**/*.test.ts"] }, }, From c96f1b6f0635da2464ad8ee3b3def1a02622762b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 22:48:24 -0700 Subject: [PATCH 03/11] feat(init): write the launcher's lockfile and prune the launcher cache --- .../changes/launcher-local-cache/tasks.md | 4 +- packages/cli/src/commands/init.ts | 18 ++- packages/cli/src/install/install.ts | 16 ++ packages/cli/src/install/launcher.ts | 151 +++++++++++++++++- packages/cli/src/wizard/index.ts | 12 +- packages/cli/test/install-launcher.test.ts | 95 +++++++++++ .../nightly/install-launcher-nightly.test.ts | 29 ++++ 7 files changed, 310 insertions(+), 15 deletions(-) diff --git a/openspec/changes/launcher-local-cache/tasks.md b/openspec/changes/launcher-local-cache/tasks.md index 46aeb10b..804684ab 100644 --- a/openspec/changes/launcher-local-cache/tasks.md +++ b/openspec/changes/launcher-local-cache/tasks.md @@ -6,8 +6,8 @@ ## 2. Init -- [ ] 2.1 Write `.taskless/bin/cli.lock.json` or `cli-nightly.lock.json` with the launcher, unchanged/created/restored like the launcher, never touching the other package's; verify with tests for a release install, a nightly beside a release, and an edited lockfile -- [ ] 2.2 Prune `.taskless/.cache/` after recording versions (shared rule in `src/install/launcher.ts`; the template keeps its own copy since it ships standalone); verify with tests that an upgrade drops the old release and a nightly install keeps it +- [x] 2.1 Write `.taskless/bin/cli.lock.json` or `cli-nightly.lock.json` with the launcher, unchanged/created/restored like the launcher, never touching the other package's; verify with tests for a release install, a nightly beside a release, and an edited lockfile +- [x] 2.2 Prune `.taskless/.cache/` after recording versions (shared rule in `src/install/launcher.ts`; the template keeps its own copy since it ships standalone); verify with tests that an upgrade drops the old release and a nightly install keeps it ## 3. Launcher diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 787987c7..1e7d1d28 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -152,6 +152,9 @@ export const initCommand = defineCommand({ // This build's launcher and what the run did to it; `null` for a // build that installs none. launcher: result.launcher, + // The lockfile beside the launcher, which decides what it installs; + // `null` for a build that installs no launcher. + lockfile: result.lockfile, // Absent when nothing ran, so a caller distinguishes "the tree was // rewritten" from "nothing happened" by presence, never by reading // empty arrays out of it. @@ -355,6 +358,8 @@ async function runNonInteractive( changed: boolean; /** This build's launcher, or `null` for a build that has none. */ launcher: LauncherWrite | null; + /** The lockfile beside the launcher, or `null` when there is none. */ + lockfile: LauncherWrite | null; /** Pins in `package.json`, CI, or hooks that run an older CLI than this. */ pinnedCli: PinnedCli[]; /** Harness permission files this run created, edited, or left alone. */ @@ -484,9 +489,11 @@ async function runNonInteractive( } } - const { launcher } = result; - if (launcher !== null && launcher.state !== "unchanged") { - log(describeLauncherWrite(launcher)); + const { launcher, lockfile } = result; + for (const write of [launcher, lockfile]) { + if (write !== null && write.state !== "unchanged") { + log(describeLauncherWrite(write)); + } } const selected = plan.targets @@ -518,13 +525,16 @@ async function runNonInteractive( permissions, globalTrust, launcher, + lockfile, // A version move rewrites `install.cliVersion` in the manifest, which is // a tracked file, so it is a change even when every skill byte matched. changed: migrated !== undefined || targets.some((target) => targetChanged(target)) || permissions.some((edit) => edit.skipped === null) || - (launcher !== null && launcher.state !== "unchanged") || + [launcher, lockfile].some( + (write) => write !== null && write.state !== "unchanged" + ) || launcherVersionMoved(previousState, result.state) || versionMoved({ previousCliVersion, cliVersion }), pinnedCli: await findStalePins(cwd, cliVersion), diff --git a/packages/cli/src/install/install.ts b/packages/cli/src/install/install.ts index a1bd7946..1799c255 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -29,7 +29,9 @@ import { } from "./state"; import { buildLauncherPackage, + pruneLauncherCache, writeLauncher, + writeLauncherLockfile, type LauncherWrite, } from "./launcher"; @@ -368,6 +370,8 @@ export interface ApplyInstallResult { state: InstallState; /** This build's launcher, or `null` for a build that has none. */ launcher: LauncherWrite | null; + /** The lockfile beside it, or `null` for a build that has no launcher. */ + lockfile: LauncherWrite | null; writtenSkills: Array<{ target: string; skill: string }>; removedSkills: Array<{ target: string; skill: string }>; removedCommands: Array<{ target: string; command: string }>; @@ -549,12 +553,24 @@ export async function applyInstallPlan( launcherPackage === undefined ? null : await writeLauncher(cwd, launcherPackage); + const lockfile = + launcherPackage === undefined || __TASKLESS_INSTALL_LOCK__ === null + ? null + : await writeLauncherLockfile( + cwd, + launcherPackage, + __TASKLESS_INSTALL_LOCK__ + ); await writeInstallState(cwd, nextState); + // Installed versions nothing records any more. The launcher installs the + // new one on its first run; init never downloads. + await pruneLauncherCache(cwd, nextState.launchers, nextState.cliVersion); return { state: nextState, launcher, + lockfile, writtenSkills, removedSkills, removedCommands, diff --git a/packages/cli/src/install/launcher.ts b/packages/cli/src/install/launcher.ts index e211cffa..1bfa600e 100644 --- a/packages/cli/src/install/launcher.ts +++ b/packages/cli/src/install/launcher.ts @@ -1,4 +1,12 @@ -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { dirname, join } from "node:path"; import template from "../../assets/launcher/launcher.mjs?raw"; @@ -56,6 +64,29 @@ export function launcherInvocation(packageName: LauncherPackage): string { return `./${launcherPath(packageName)}`; } +/** + * The lockfile a package's launcher installs from, relative to the project + * root: `.taskless/bin/cli.lock.json` beside `cli.mjs`. One per package, like + * the launcher, and written only by that package's builds. + */ +export function lockfilePath(packageName: LauncherPackage): string { + return launcherPath(packageName).replace(/\.mjs$/, ".lock.json"); +} + +/** Where launchers keep installed versions, relative to the project root. */ +export const LAUNCHER_CACHE = ".taskless/.cache"; + +/** + * A package version's directory in the cache, relative to the project root: + * `.taskless/.cache/@taskless/cli@0.13.1`. + */ +export function cacheEntryPath( + packageName: LauncherPackage, + version: string +): string { + return `${LAUNCHER_CACHE}/${packageName}@${version}`; +} + /** The launcher's full content for a package. */ export function renderLauncher(packageName: LauncherPackage): string { return template.replaceAll(PACKAGE_PLACEHOLDER, packageName); @@ -99,9 +130,22 @@ export async function writeLauncher( cwd: string, packageName: LauncherPackage ): Promise { - const path = launcherPath(packageName); + const write = await writeInstallFile( + cwd, + launcherPath(packageName), + renderLauncher(packageName) + ); + await chmod(join(cwd, write.path), 0o755); + return write; +} + +/** Write `content` to `path` under `cwd` only when the bytes differ. */ +async function writeInstallFile( + cwd: string, + path: string, + content: string +): Promise { const absolute = join(cwd, path); - const content = renderLauncher(packageName); let existing: string | undefined; try { existing = await readFile(absolute, "utf8"); @@ -112,7 +156,6 @@ export async function writeLauncher( await mkdir(dirname(absolute), { recursive: true }); await writeFile(absolute, content, "utf8"); } - await chmod(absolute, 0o755); return { path, state: @@ -124,6 +167,106 @@ export async function writeLauncher( }; } +/** + * Write this build's lockfile beside its package's launcher, only when its + * bytes differ. An edited lockfile is put back and reported like an edited + * launcher: it decides what the launcher installs. + */ +export function writeLauncherLockfile( + cwd: string, + packageName: LauncherPackage, + content: string +): Promise { + return writeInstallFile(cwd, lockfilePath(packageName), content); +} + +/** Remove a path, leaving it for the next prune when that fails. */ +async function removeQuietly(path: string): Promise { + await rm(path, { recursive: true, force: true }).catch(() => {}); +} + +/** How old a temporary install directory must be before a prune removes it. */ +const STALE_INSTALL_MS = 60 * 60 * 1000; + +/** + * The version a package's launcher would run: its `install.launchers` entry, + * or `install.cliVersion` when that belongs to the package (a nightly version + * always has a prerelease part, a release's never does). The template applies + * the same rule when it runs. + */ +export function recordedLauncherVersion( + packageName: LauncherPackage, + launchers: Record | undefined, + cliVersion: string | undefined +): string | undefined { + const recorded = launchers?.[packageName]; + if (recorded !== undefined) return recorded; + if ( + cliVersion !== undefined && + cliVersion.includes("-") === packageName.endsWith("-nightly") + ) { + return cliVersion; + } + return undefined; +} + +/** + * Remove cache entries no package records, and temporary install directories + * old enough that no install can still be writing them. Best effort: anything + * that cannot be removed now, such as a file Windows holds open, is left for + * the next prune. The launcher template carries its own copy of this rule, + * since it runs without the CLI. + */ +export async function pruneLauncherCache( + cwd: string, + launchers: Record | undefined, + cliVersion: string | undefined, + now: number = Date.now() +): Promise { + const keep = new Set(); + for (const packageName of LAUNCHER_PACKAGES) { + const version = recordedLauncherVersion(packageName, launchers, cliVersion); + if (version !== undefined) keep.add(cacheEntryPath(packageName, version)); + } + const cache = join(cwd, LAUNCHER_CACHE); + + let top: string[]; + try { + top = await readdir(cache); + } catch { + return; + } + for (const name of top) { + if (!name.startsWith(".tmp-")) continue; + const path = join(cache, name); + const modified = await stat(path).then( + (info) => info.mtimeMs, + () => now + ); + if (now - modified > STALE_INSTALL_MS) await removeQuietly(path); + } + + const scopes = new Set( + LAUNCHER_PACKAGES.map((packageName) => packageName.split("/")[0] ?? "") + ); + for (const scope of scopes) { + let entries: string[]; + try { + entries = await readdir(join(cache, scope)); + } catch { + continue; + } + for (const entry of entries) { + const relative = `${LAUNCHER_CACHE}/${scope}/${entry}`; + const ours = LAUNCHER_PACKAGES.some((packageName) => + relative.startsWith(`${LAUNCHER_CACHE}/${packageName}@`) + ); + if (ours && !keep.has(relative)) + await removeQuietly(join(cache, scope, entry)); + } + } +} + /** * What the installed skill names the CLI as: this build's launcher, so the * rendered skill is the same for every version of a package and agents run the diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index 8f23e54e..518eb80f 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -119,13 +119,15 @@ export async function runWizard( onNotice: (message) => log.info(message), }); const cliVersion = getCliVersion(); - const { launcher } = await applyInstallPlan(options.cwd, plan, { + const { launcher, lockfile } = await applyInstallPlan(options.cwd, plan, { cliVersion, }); - if (launcher !== null && launcher.state !== "unchanged") { - (launcher.state === "restored" ? log.warn : log.info)( - describeLauncherWrite(launcher) - ); + for (const write of [launcher, lockfile]) { + if (write !== null && write.state !== "unchanged") { + (write.state === "restored" ? log.warn : log.info)( + describeLauncherWrite(write) + ); + } } await syncPermissions(options.cwd, { ...permissionOptions, write: true }); // Confirmed with the project edits, in the summary's one question. diff --git a/packages/cli/test/install-launcher.test.ts b/packages/cli/test/install-launcher.test.ts index cf20ba29..cc924d2f 100644 --- a/packages/cli/test/install-launcher.test.ts +++ b/packages/cli/test/install-launcher.test.ts @@ -2,8 +2,10 @@ import { mkdir, mkdtemp, readFile, + readdir, rm, stat, + utimes, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -37,6 +39,8 @@ function install(cliVersion: string) { } const LAUNCHER = join(".taskless", "bin", "cli.mjs"); +const LOCKFILE = join(".taskless", "bin", "cli.lock.json"); +const CACHE = join(".taskless", ".cache"); const SKILL = join(".taskless", "skills", "taskless", "SKILL.md"); describe("the release's install writes its launcher", () => { @@ -113,3 +117,94 @@ describe("the release's install writes its launcher", () => { expect(skill).toContain("the directory that contains `.taskless/`"); }); }); + +describe("the release's install writes its lockfile", () => { + it("writes this build's lockfile beside cli.mjs", async () => { + const result = await install("0.13.0"); + + expect(result.lockfile).toEqual({ + path: ".taskless/bin/cli.lock.json", + state: "created", + }); + const lockfile = JSON.parse( + await readFile(join(cwd, LOCKFILE), "utf8") + ) as { + packages: Record; + }; + expect(lockfile.packages[""]).toEqual({ + dependencies: { "@taskless/cli": __VERSION__ }, + }); + expect(lockfile.packages["node_modules/tsx"]?.integrity).toMatch( + /^sha512-/ + ); + await expect( + stat(join(cwd, ".taskless", "bin", "cli-nightly.lock.json")) + ).rejects.toThrow(); + }); + + it("restores an edited lockfile and says so", async () => { + await install("0.13.0"); + await writeFile(join(cwd, LOCKFILE), "{}\n"); + + const result = await install("0.13.0"); + + expect(result.lockfile?.state).toBe("restored"); + expect(await readFile(join(cwd, LOCKFILE), "utf8")).toBe( + __TASKLESS_INSTALL_LOCK__ + ); + }); +}); + +async function cacheEntry(...segments: string[]) { + const path = join(cwd, CACHE, ...segments); + await mkdir(path, { recursive: true }); + return path; +} + +async function listing(path: string) { + const entries = await readdir(path); + return entries.toSorted(); +} + +describe("an install prunes the launcher cache", () => { + it("drops the release's previous version and keeps the nightly's", async () => { + await mkdir(join(cwd, ".taskless")); + await writeInstallState(cwd, { + cliVersion: "0.13.0", + launchers: { + "@taskless/cli": "0.13.0", + "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234", + }, + targets: {}, + }); + await cacheEntry("@taskless", "cli@0.13.0"); + await cacheEntry("@taskless", "cli-nightly@0.14.0-20261020120000xabc1234"); + await cacheEntry("@taskless", "cli-nightly@0.13.9-20261001000000xdeadbee"); + + await install("0.13.1"); + + expect(await listing(join(cwd, CACHE, "@taskless"))).toEqual([ + "cli-nightly@0.14.0-20261020120000xabc1234", + ]); + }); + + it("removes stale temporary installs and keeps fresh ones", async () => { + const stale = await cacheEntry(".tmp-1-stale"); + const old = new Date(Date.now() - 2 * 60 * 60 * 1000); + await utimes(stale, old, old); + await cacheEntry(".tmp-2-fresh"); + await writeFile(join(cwd, CACHE, ".gitignore"), "*\n"); + + await install("0.13.1"); + + expect(await listing(join(cwd, CACHE))).toEqual([ + ".gitignore", + ".tmp-2-fresh", + ]); + }); + + it("downloads nothing", async () => { + await install("0.13.1"); + await expect(stat(join(cwd, CACHE))).rejects.toThrow(); + }); +}); diff --git a/packages/cli/test/nightly/install-launcher-nightly.test.ts b/packages/cli/test/nightly/install-launcher-nightly.test.ts index 5e90b759..a9b3cc07 100644 --- a/packages/cli/test/nightly/install-launcher-nightly.test.ts +++ b/packages/cli/test/nightly/install-launcher-nightly.test.ts @@ -37,6 +37,8 @@ afterEach(async () => { const RELEASE_LAUNCHER = join(".taskless", "bin", "cli.mjs"); const NIGHTLY_LAUNCHER = join(".taskless", "bin", "cli-nightly.mjs"); +const RELEASE_LOCKFILE = join(".taskless", "bin", "cli.lock.json"); +const NIGHTLY_LOCKFILE = join(".taskless", "bin", "cli-nightly.lock.json"); describe("a nightly install beside the release", () => { it("writes only cli-nightly.mjs and keeps the release's launcher and version", async () => { @@ -89,3 +91,30 @@ describe("a nightly install beside the release", () => { expect(skill).not.toContain("./.taskless/bin/cli.mjs"); }); }); + +describe("a nightly install's lockfile", () => { + it("writes cli-nightly.lock.json and leaves the release's lockfile alone", async () => { + await mkdir(join(cwd, ".taskless", "bin"), { recursive: true }); + await writeFile(join(cwd, RELEASE_LOCKFILE), "release lockfile\n"); + + const result = await applyInstallPlan( + cwd, + buildInstallPlan([".claude"], getEmbeddedSkills()), + { cliVersion: getCliVersion() } + ); + + expect(result.lockfile).toEqual({ + path: ".taskless/bin/cli-nightly.lock.json", + state: "created", + }); + const lockfile = JSON.parse( + await readFile(join(cwd, NIGHTLY_LOCKFILE), "utf8") + ) as { packages: Record }; + expect( + lockfile.packages["node_modules/@taskless/cli-nightly"]?.resolved + ).toContain("/@taskless/cli-nightly/-/cli-nightly-"); + expect(await readFile(join(cwd, RELEASE_LOCKFILE), "utf8")).toBe( + "release lockfile\n" + ); + }); +}); From 168591b8555dc7dfde0376aa75253c98a8a853dc Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 22:51:09 -0700 Subject: [PATCH 04/11] feat: install the launcher's CLI version into .taskless/.cache from its lockfile --- .../changes/launcher-local-cache/tasks.md | 12 +- packages/cli/assets/launcher/launcher.mjs | 261 +++++++++++-- packages/cli/test/launcher.test.ts | 360 +++++++++++++----- 3 files changed, 490 insertions(+), 143 deletions(-) diff --git a/openspec/changes/launcher-local-cache/tasks.md b/openspec/changes/launcher-local-cache/tasks.md index 804684ab..2756e1d2 100644 --- a/openspec/changes/launcher-local-cache/tasks.md +++ b/openspec/changes/launcher-local-cache/tasks.md @@ -11,12 +11,12 @@ ## 3. Launcher -- [ ] 3.1 Run from the cache: resolve `.taskless/.cache/@/`, read the bin from the installed `package.json`, run it with `process.execPath` and no shell; verify a pre-populated entry runs and no `npm` process starts -- [ ] 3.2 Refuse without a matching lockfile: missing, or naming another version; verify each with a test (no `npm` started, message names the lockfile and the installer command) -- [ ] 3.3 Install: temporary directory, one-line `package.json` plus the lockfile, `npm prefix` and the project `.npmrc` copy, `npm ci --ignore-scripts --no-audit --no-fund --loglevel=error` with output on standard error, remove the `.npmrc`, check the bin resolves, rename (discarding ours on a lost race); `cmd.exe` quoting only for the npm calls; verify first run, second run, lost race and clean `--json` output with a stub `npm` on `PATH` -- [ ] 3.4 Failure paths: npm missing, npm exiting non-zero, bin missing; each removes the temporary directory and exits non-zero naming `@`; verify with tests -- [ ] 3.5 Write `.taskless/.cache/.gitignore` (`*`); verify with a test and `git status` in a scratch repository -- [ ] 3.6 Prune after a new install by the shared rule, ignoring removal errors; verify with tests for an upgrade and for keeping the other package's entry +- [x] 3.1 Run from the cache: resolve `.taskless/.cache/@/`, read the bin from the installed `package.json`, run it with `process.execPath` and no shell; verify a pre-populated entry runs and no `npm` process starts +- [x] 3.2 Refuse without a matching lockfile: missing, or naming another version; verify each with a test (no `npm` started, message names the lockfile and the installer command) +- [x] 3.3 Install: temporary directory, one-line `package.json` plus the lockfile, `npm prefix` and the project `.npmrc` copy, `npm ci --ignore-scripts --no-audit --no-fund --loglevel=error` with output on standard error, remove the `.npmrc`, check the bin resolves, rename (discarding ours on a lost race); `cmd.exe` quoting only for the npm calls; verify first run, second run, lost race and clean `--json` output with a stub `npm` on `PATH` +- [x] 3.4 Failure paths: npm missing, npm exiting non-zero, bin missing; each removes the temporary directory and exits non-zero naming `@`; verify with tests +- [x] 3.5 Write `.taskless/.cache/.gitignore` (`*`); verify with a test and `git status` in a scratch repository +- [x] 3.6 Prune after a new install by the shared rule, ignoring removal errors; verify with tests for an upgrade and for keeping the other package's entry ## 4. Recipes and skill diff --git a/packages/cli/assets/launcher/launcher.mjs b/packages/cli/assets/launcher/launcher.mjs index 340029b3..05043e44 100644 --- a/packages/cli/assets/launcher/launcher.mjs +++ b/packages/cli/assets/launcher/launcher.mjs @@ -1,32 +1,65 @@ #!/usr/bin/env node // Runs the Taskless CLI package %(TASKLESS_PACKAGE)s at the version this // project recorded for it in .taskless/taskless.json (`install.launchers`). +// The first run of a version installs it into .taskless/.cache/ with +// `npm ci`, from the lockfile beside this file, so every machine installs the +// same dependency tree. Later runs start it from there without the network. // Written by the Taskless installer, which replaces any edits on its next run. // Run it from the directory that contains .taskless/. import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { basename, dirname, join } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; const PACKAGE = "%(TASKLESS_PACKAGE)s"; +// Both launcher packages, so pruning keeps the other one's installed version. +const PACKAGES = ["@taskless/cli", "@taskless/cli-nightly"]; const self = fileURLToPath(import.meta.url); -const manifest = join(dirname(dirname(self)), "taskless.json"); +const tasklessDirectory = dirname(dirname(self)); +const projectRoot = dirname(tasklessDirectory); +const manifest = join(tasklessDirectory, "taskless.json"); +const lockfile = join( + dirname(self), + basename(self).replace(/\.mjs$/, ".lock.json") +); +const cache = join(tasklessDirectory, ".cache"); +const INSTALLER = `npx ${PACKAGE}@latest`; // A release version has no prerelease part; a nightly version always does. -const NIGHTLY = PACKAGE.endsWith("-nightly"); const VERSION = /^\d+\.\d+\.\d+(?:-[\da-z.-]+)?$/i; +// A temporary install this old is not still being written by anyone. +const STALE_INSTALL_MS = 60 * 60 * 1000; -/** This package's recorded version, or undefined when none is usable. */ -function recordedVersion() { - let install; +function fail(message) { + process.stderr.write(`taskless: ${message}\n`); + process.exit(1); +} + +/** The manifest's `install` block, or undefined when it cannot be read. */ +function readInstall() { try { - install = JSON.parse(readFileSync(manifest, "utf8"))?.install; + return JSON.parse(readFileSync(manifest, "utf8"))?.install; } catch { return undefined; } - const recorded = install?.launchers?.[PACKAGE]; +} + +/** A package's recorded version, or undefined when none is usable. */ +function versionFor(install, name) { + const recorded = install?.launchers?.[name]; if (typeof recorded === "string" && VERSION.test(recorded)) return recorded; // A manifest written before `install.launchers` existed, or rewritten by a // CLI that does not know it, still records the last installed version. @@ -34,14 +67,31 @@ function recordedVersion() { if ( typeof last === "string" && VERSION.test(last) && - last.includes("-") === NIGHTLY + last.includes("-") === name.endsWith("-nightly") ) { return last; } return undefined; } -// cmd.exe quoting for a batch file such as npx.cmd: each argument is quoted, +/** The installed package's bin script under `directory`, if it is there. */ +function entryPoint(directory) { + const packageDirectory = join(directory, "node_modules", PACKAGE); + try { + const { bin } = JSON.parse( + readFileSync(join(packageDirectory, "package.json"), "utf8") + ); + const relative = + typeof bin === "string" ? bin : Object.values(bin ?? {})[0]; + if (typeof relative !== "string") return undefined; + const path = join(packageDirectory, relative); + return existsSync(path) ? path : undefined; + } catch { + return undefined; + } +} + +// cmd.exe quoting for a batch file such as npm.cmd: each argument is quoted, // then every metacharacter is escaped twice, once for cmd.exe and once for // the batch file's own parse. The same approach as cross-spawn (MIT). const CMD_META = /([()\][%!^"`<>&|;, *?])/g; @@ -54,45 +104,186 @@ function quoteForCmd(argument) { return quoted.replaceAll(CMD_META, "^$1").replaceAll(CMD_META, "^$1"); } -function run(version) { - const command = "npx"; - const args = ["--yes", `${PACKAGE}@${version}`, ...process.argv.slice(2)]; - const options = { - stdio: "inherit", - env: { - ...process.env, - TASKLESS_LAUNCHER: `./.taskless/bin/${basename(self)}`, - }, - }; - if (process.platform !== "win32") return spawnSync(command, args, options); - const line = [command, ...args.map((argument) => quoteForCmd(argument))].join( +function npm(args, options) { + if (process.platform !== "win32") return spawnSync("npm", args, options); + const line = ["npm", ...args.map((argument) => quoteForCmd(argument))].join( " " ); return spawnSync( process.env.comspec ?? "cmd.exe", ["/d", "/s", "/c", `"${line}"`], - { - ...options, - windowsVerbatimArguments: true, + { ...options, windowsVerbatimArguments: true } + ); +} + +/** The `.npmrc` npx would read when run from the project root, if any. */ +function projectNpmrc() { + const result = npm(["prefix"], { + cwd: projectRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0) return undefined; + const path = join(result.stdout.trim(), ".npmrc"); + return existsSync(path) ? path : undefined; +} + +/** Install `version` into `target` from the lockfile; returns its entry point. */ +function installVersion(version, target) { + let lock; + try { + lock = readFileSync(lockfile, "utf8"); + } catch { + fail( + `${lockfile} is missing, so ${PACKAGE}@${version} cannot be installed ` + + `as this project pinned it. Run \`${INSTALLER}\` from the directory ` + + `that contains .taskless/ to write it.` + ); + } + let locked; + try { + locked = JSON.parse(lock)?.packages?.[`node_modules/${PACKAGE}`]?.version; + } catch { + locked = undefined; + } + if (locked !== version) { + fail( + `${lockfile} does not pin ${PACKAGE}@${version}, the version ` + + `${manifest} records. Run \`${INSTALLER}\` from the directory that ` + + `contains .taskless/ to rewrite both.` + ); + } + + mkdirSync(cache, { recursive: true }); + const ignore = join(cache, ".gitignore"); + if (!existsSync(ignore)) writeFileSync(ignore, "*\n"); + + const temporary = join( + cache, + `.tmp-${process.pid}-${randomBytes(4).toString("hex")}` + ); + mkdirSync(temporary); + const abandon = (message) => { + rmSync(temporary, { recursive: true, force: true }); + fail(message); + }; + + writeFileSync( + join(temporary, "package.json"), + JSON.stringify({ + name: "taskless-launcher", + private: true, + dependencies: { [PACKAGE]: version }, + }) + ); + writeFileSync(join(temporary, "package-lock.json"), lock); + // npm reads project settings only from the directory it installs into, so + // the project's registry and credentials are copied in, and removed again + // before the install is kept. + const npmrc = projectNpmrc(); + if (npmrc !== undefined) copyFileSync(npmrc, join(temporary, ".npmrc")); + + process.stderr.write(`taskless: installing ${PACKAGE}@${version}\n`); + // npm's output goes to standard error: standard output is the CLI's. + const result = npm( + ["ci", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error"], + { cwd: temporary, stdio: ["ignore", 2, 2] } + ); + rmSync(join(temporary, ".npmrc"), { force: true }); + if (result.error !== undefined) { + abandon( + `npm is needed to install ${PACKAGE}@${version}, and it could not be ` + + `started: ${result.error.message}` + ); + } + if (result.status !== 0) { + abandon(`npm could not install ${PACKAGE}@${version}.`); + } + if (entryPoint(temporary) === undefined) { + abandon(`${PACKAGE}@${version} installed without its command.`); + } + + mkdirSync(dirname(target), { recursive: true }); + try { + renameSync(temporary, target); + } catch (error) { + // Another run installed the same version first; use its copy. + rmSync(temporary, { recursive: true, force: true }); + if (entryPoint(target) === undefined) { + fail(`could not install ${PACKAGE}@${version}: ${error.message}`); } + } + return entryPoint(target); +} + +/** + * Remove installed versions no package records, and temporary installs no + * run is still writing. Anything that cannot be removed now, such as a file + * Windows holds open, is left for the next run that installs. + */ +function prune(install) { + const keep = new Set( + PACKAGES.map((name) => { + const version = versionFor(install, name); + return version === undefined ? undefined : `${name}@${version}`; + }) ); + const remove = (path) => { + try { + rmSync(path, { recursive: true, force: true }); + } catch { + // Left for the next prune. + } + }; + try { + for (const name of readdirSync(cache)) { + const path = join(cache, name); + if ( + name.startsWith(".tmp-") && + Date.now() - statSync(path).mtimeMs > STALE_INSTALL_MS + ) { + remove(path); + } + } + for (const scope of new Set(PACKAGES.map((name) => name.split("/")[0]))) { + const directory = join(cache, scope); + if (!existsSync(directory)) continue; + for (const name of readdirSync(directory)) { + const entry = `${scope}/${name}`; + const ours = PACKAGES.some((pkg) => entry.startsWith(`${pkg}@`)); + if (ours && !keep.has(entry)) remove(join(directory, name)); + } + } + } catch { + // Pruning never decides whether the CLI runs. + } } -const version = recordedVersion(); +const install = readInstall(); +const version = versionFor(install, PACKAGE); if (version === undefined) { - process.stderr.write( - `taskless: no ${PACKAGE} version is recorded in ${manifest}. ` + - `Run \`npx ${PACKAGE}@latest\` from the directory that contains .taskless/ to record one.\n` + fail( + `no ${PACKAGE} version is recorded in ${manifest}. ` + + `Run \`${INSTALLER}\` from the directory that contains .taskless/ to record one.` ); - process.exit(1); } -const result = run(version); +const target = join(cache, `${PACKAGE}@${version}`); +let entry = entryPoint(target); +if (entry === undefined) { + entry = installVersion(version, target); + prune(install); +} + +const result = spawnSync(process.execPath, [entry, ...process.argv.slice(2)], { + stdio: "inherit", + env: { + ...process.env, + TASKLESS_LAUNCHER: `./.taskless/bin/${basename(self)}`, + }, +}); if (result.error !== undefined) { - process.stderr.write( - `taskless: could not start npx: ${result.error.message}\n` - ); - process.exit(1); + fail(`could not start ${PACKAGE}@${version}: ${result.error.message}`); } if (result.signal !== null) { process.kill(process.pid, result.signal); diff --git a/packages/cli/test/launcher.test.ts b/packages/cli/test/launcher.test.ts index 09088f42..f94ca7ff 100644 --- a/packages/cli/test/launcher.test.ts +++ b/packages/cli/test/launcher.test.ts @@ -1,51 +1,117 @@ import { spawnSync } from "node:child_process"; -import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import process from "node:process"; -import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { launcherFileName, launcherInvocation, + lockfilePath, renderLauncher, type LauncherPackage, } from "../src/install/launcher"; /** - * A stand-in for `npx` that reports what it was given. The launcher finds it - * on `PATH` exactly as it would find the real one. + * A stand-in for the installed CLI that reports what it was given. The fake + * `npm ci` below installs it, so a test sees exactly what the launcher ran. */ -const FAKE_NPX = `#!/usr/bin/env node +const FAKE_CLI = ` const out = { argv: process.argv.slice(2), cwd: process.cwd(), launcher: process.env.TASKLESS_LAUNCHER, + version: require("./package.json").version, }; process.stdout.write(JSON.stringify(out)); -if (process.env.FAKE_NPX_SIGNAL) process.kill(process.pid, process.env.FAKE_NPX_SIGNAL); -process.exit(Number(process.env.FAKE_NPX_EXIT ?? 0)); +if (process.env.FAKE_CLI_SIGNAL) process.kill(process.pid, process.env.FAKE_CLI_SIGNAL); +process.exit(Number(process.env.FAKE_CLI_EXIT ?? 0)); +`; + +/** + * A stand-in for `npm`, found on `PATH` exactly as the real one would be. It + * logs each call, answers `npm prefix` with its working directory, and for + * `npm ci` checks the lockfile against package.json as npm does, then + * installs the fake CLI. It prints to standard output, as npm does, so a test + * can check the launcher keeps that off the CLI's standard output. + */ +const FAKE_NPM = String.raw`#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const [command] = process.argv.slice(2); +fs.appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify({ + command, + cwd: process.cwd(), + npmrc: fs.existsSync(".npmrc") ? fs.readFileSync(".npmrc", "utf8") : null, +}) + "\n"); +if (command === "prefix") { + process.stdout.write(process.cwd() + "\n"); + process.exit(0); +} +if (process.env.FAKE_NPM_FAIL) { + process.stderr.write("npm error fake failure\n"); + process.exit(1); +} +const manifest = JSON.parse(fs.readFileSync("package.json", "utf8")); +const lock = JSON.parse(fs.readFileSync("package-lock.json", "utf8")); +const [name, version] = Object.entries(manifest.dependencies)[0]; +if (lock.packages["node_modules/" + name]?.version !== version) { + process.stderr.write("npm error EUSAGE out of sync\n"); + process.exit(1); +} +const install = (root) => { + const directory = path.join(root, "node_modules", name); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(path.join(directory, "package.json"), JSON.stringify({ name, version, bin: { taskless: "./cli.cjs" } })); + fs.writeFileSync(path.join(directory, "cli.cjs"), process.env.FAKE_CLI_SOURCE); +}; +if (process.env.FAKE_NPM_RACE) install(process.env.FAKE_NPM_RACE); +if (!process.env.FAKE_NPM_NO_BIN) install(process.cwd()); +process.stdout.write("added 1 package\n"); `; let root: string; let fakeBin: string; +let npmLog: string; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "taskless-launcher-")); fakeBin = join(root, "fake-bin"); + npmLog = join(root, "npm.log"); await mkdir(fakeBin); - await writeFile(join(fakeBin, "npx"), FAKE_NPX); - await chmod(join(fakeBin, "npx"), 0o755); + await writeFile(join(fakeBin, "npm"), FAKE_NPM); + await chmod(join(fakeBin, "npm"), 0o755); }); afterEach(async () => { await rm(root, { recursive: true, force: true }); }); +/** A lockfile pinning `packageName` at `version`, as `init` writes it. */ +function lockfileFor(packageName: string, version: string): string { + return JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { dependencies: { [packageName]: version } }, + [`node_modules/${packageName}`]: { version }, + }, + }); +} + async function install( packageName: LauncherPackage, - manifest: unknown + manifest: unknown, + lockedVersion?: string ): Promise { const bin = join(root, ".taskless", "bin"); await mkdir(bin, { recursive: true }); @@ -55,6 +121,12 @@ async function install( JSON.stringify(manifest) ); } + if (lockedVersion !== undefined) { + await writeFile( + join(root, lockfilePath(packageName)), + lockfileFor(packageName, lockedVersion) + ); + } const path = join(bin, launcherFileName(packageName)); await writeFile(path, renderLauncher(packageName)); await chmod(path, 0o755); @@ -64,15 +136,17 @@ async function install( function launch( path: string, args: string[], - options: { cwd?: string; env?: Record } = {} + options: { cwd?: string; env?: Record; path?: string } = {} ) { - return spawnSync(path, args, { + return spawnSync(process.execPath, [path, ...args], { cwd: options.cwd ?? root, encoding: "utf8", env: { ...process.env, + FAKE_NPM_LOG: npmLog, + FAKE_CLI_SOURCE: FAKE_CLI, ...options.env, - PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + PATH: options.path ?? `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, }, }); } @@ -81,68 +155,103 @@ function reported(stdout: string): { argv: string[]; cwd: string; launcher: string; + version: string; } { return JSON.parse(stdout) as { argv: string[]; cwd: string; launcher: string; + version: string; }; } -/** One argument as the launcher quotes it for a batch file such as npx.cmd. */ -function quoted(inner: string): string { - return `^^^"${inner}^^^"`; +async function npmCalls(): Promise< + Array<{ command: string; cwd: string; npmrc: string | null }> +> { + const log = await readFile(npmLog, "utf8").catch(() => ""); + return log + .split("\n") + .filter(Boolean) + .map( + (line) => + JSON.parse(line) as { + command: string; + cwd: string; + npmrc: string | null; + } + ); +} + +const CACHE = () => join(root, ".taskless", ".cache"); + +async function npmCommands(): Promise { + const calls = await npmCalls(); + return calls.map((call) => call.command); +} + +async function listing(directory: string): Promise { + const entries = await readdir(directory); + return entries.toSorted(); } +const RELEASE = { install: { launchers: { "@taskless/cli": "0.13.0" } } }; describe.skipIf(process.platform === "win32")("project launcher", () => { - it("runs the release's recorded version with every argument", async () => { - const path = await install("@taskless/cli", { - install: { launchers: { "@taskless/cli": "0.13.0" } }, + it("installs the recorded version once, then runs it with every argument", async () => { + const path = await install("@taskless/cli", RELEASE, "0.13.0"); + + const first = launch(path, ["check", "src/a file.ts", "--json"]); + expect(first.status).toBe(0); + expect(reported(first.stdout)).toMatchObject({ + argv: ["check", "src/a file.ts", "--json"], + version: "0.13.0", }); - const result = launch(path, ["check", "src/a file.ts", "--json"]); - expect(result.status).toBe(0); - expect(reported(result.stdout).argv).toEqual([ - "--yes", - "@taskless/cli@0.13.0", - "check", - "src/a file.ts", - "--json", - ]); + expect(await npmCommands()).toEqual(["prefix", "ci"]); + await stat(join(CACHE(), "@taskless", "cli@0.13.0")); + + const second = launch(path, ["check"]); + expect(second.status).toBe(0); + expect(await npmCommands()).toHaveLength(2); + }); + + it("keeps npm's output off standard output", async () => { + const path = await install("@taskless/cli", RELEASE, "0.13.0"); + const result = launch(path, ["info", "--json"]); + expect(() => reported(result.stdout)).not.toThrow(); + expect(result.stderr).toContain("added 1 package"); }); it("names itself to the CLI it starts", async () => { - const path = await install("@taskless/cli-nightly", { - install: { - launchers: { "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234" }, - }, - }); + const version = "0.14.0-20261020120000xabc1234"; + const path = await install( + "@taskless/cli-nightly", + { install: { launchers: { "@taskless/cli-nightly": version } } }, + version + ); const result = launch(path, ["agent", "route"]); expect(reported(result.stdout).launcher).toBe( "./.taskless/bin/cli-nightly.mjs" ); - expect(reported(result.stdout).argv[1]).toBe( - "@taskless/cli-nightly@0.14.0-20261020120000xabc1234" - ); + expect(reported(result.stdout).version).toBe(version); }); it("is not moved by a nightly install of the other package", async () => { - const path = await install("@taskless/cli", { - install: { - cliVersion: "0.14.0-20261020120000xabc1234", - launchers: { "@taskless/cli": "0.13.0" }, + const path = await install( + "@taskless/cli", + { + install: { + cliVersion: "0.14.0-20261020120000xabc1234", + launchers: { "@taskless/cli": "0.13.0" }, + }, }, - }); - expect(reported(launch(path, []).stdout).argv[1]).toBe( - "@taskless/cli@0.13.0" + "0.13.0" ); + expect(reported(launch(path, []).stdout).version).toBe("0.13.0"); }); it("falls back to cliVersion only when it is its own package's", async () => { const manifest = { install: { cliVersion: "0.13.0" } }; - const release = await install("@taskless/cli", manifest); - expect(reported(launch(release, []).stdout).argv[1]).toBe( - "@taskless/cli@0.13.0" - ); + const release = await install("@taskless/cli", manifest, "0.13.0"); + expect(reported(launch(release, []).stdout).version).toBe("0.13.0"); const nightly = await install("@taskless/cli-nightly", manifest); const result = launch(nightly, []); @@ -150,81 +259,81 @@ describe.skipIf(process.platform === "win32")("project launcher", () => { expect(result.stdout).toBe(""); }); - it("reads the manifest beside it when run from a subdirectory", async () => { - const path = await install("@taskless/cli", { - install: { launchers: { "@taskless/cli": "0.13.0" } }, - }); + it("reads the manifest and cache beside it when run from a subdirectory", async () => { + const path = await install("@taskless/cli", RELEASE, "0.13.0"); const subdirectory = join(root, "src"); await mkdir(subdirectory); const result = launch(path, ["check"], { cwd: subdirectory }); expect(result.status).toBe(0); - expect(reported(result.stdout).argv[1]).toBe("@taskless/cli@0.13.0"); expect(reported(result.stdout).cwd).toMatch(/src$/); + await stat(join(CACHE(), "@taskless", "cli@0.13.0")); }); it("passes the CLI's exit status back", async () => { - const path = await install("@taskless/cli", { - install: { launchers: { "@taskless/cli": "0.13.0" } }, - }); + const path = await install("@taskless/cli", RELEASE, "0.13.0"); expect( - launch(path, ["check"], { env: { FAKE_NPX_EXIT: "1" } }).status + launch(path, ["check"], { env: { FAKE_CLI_EXIT: "1" } }).status ).toBe(1); }); it("ends by the same signal that ended the CLI", async () => { - const path = await install("@taskless/cli", { - install: { launchers: { "@taskless/cli": "0.13.0" } }, - }); + const path = await install("@taskless/cli", RELEASE, "0.13.0"); const result = launch(path, ["check"], { - env: { FAKE_NPX_SIGNAL: "SIGTERM" }, + env: { FAKE_CLI_SIGNAL: "SIGTERM" }, }); expect(result.signal).toBe("SIGTERM"); }); - it("quotes every argument for cmd.exe when it runs npx.cmd on Windows", async () => { - const path = await install("@taskless/cli", { - install: { launchers: { "@taskless/cli": "0.13.0" } }, - }); - // Pretend to be Windows, and stand in for cmd.exe with a script that - // reports the command line it was handed, so the quoting runs anywhere. - const preload = join(root, "as-windows.mjs"); - await writeFile( - preload, - 'Object.defineProperty(process, "platform", { value: "win32" });\n' + it("ignores its own cache in git", async () => { + const path = await install("@taskless/cli", RELEASE, "0.13.0"); + launch(path, []); + expect(await readFile(join(CACHE(), ".gitignore"), "utf8")).toBe("*\n"); + }); + + it("installs with the project's .npmrc and does not keep it", async () => { + await writeFile(join(root, ".npmrc"), "registry=http://127.0.0.1:9/\n"); + const path = await install("@taskless/cli", RELEASE, "0.13.0"); + launch(path, []); + const calls = await npmCalls(); + const ci = calls.find((call) => call.command === "ci"); + expect(ci?.npmrc).toBe("registry=http://127.0.0.1:9/\n"); + await expect( + stat(join(CACHE(), "@taskless", "cli@0.13.0", ".npmrc")) + ).rejects.toThrow(); + }); + + it("uses another run's install when it finished first", async () => { + const path = await install("@taskless/cli", RELEASE, "0.13.0"); + const target = join(CACHE(), "@taskless", "cli@0.13.0"); + const result = launch(path, [], { env: { FAKE_NPM_RACE: target } }); + expect(result.status).toBe(0); + expect(reported(result.stdout).version).toBe("0.13.0"); + const entries = await listing(CACHE()); + expect(entries.filter((name) => name.startsWith(".tmp-"))).toEqual([]); + }); + + it("drops the versions nothing records after installing a new one", async () => { + await mkdir(join(CACHE(), "@taskless", "cli@0.12.0"), { recursive: true }); + await mkdir( + join(CACHE(), "@taskless", "cli-nightly@0.14.0-20261020120000xabc1234"), + { recursive: true } ); - const comspec = join(fakeBin, "cmd"); - await writeFile(comspec, FAKE_NPX); - await chmod(comspec, 0o755); - - const result = spawnSync( - process.execPath, - [ - "--import", - pathToFileURL(preload).href, - path, - "check", - "src/a file.ts", - 'say "hi" & calc', - "C:\\dir\\", - ], - { cwd: root, encoding: "utf8", env: { ...process.env, comspec } } + const path = await install( + "@taskless/cli", + { + install: { + launchers: { + "@taskless/cli": "0.13.0", + "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234", + }, + }, + }, + "0.13.0" ); - expect(result.status).toBe(0); - // Each argument is quoted, then every metacharacter is escaped twice: - // once for cmd.exe and once for the batch file's own parse. - expect(reported(result.stdout).argv).toEqual([ - "/d", - "/s", - "/c", - `"${[ - "npx", - quoted("--yes"), - quoted("@taskless/cli@0.13.0"), - quoted("check"), - quoted("src/a^^^ file.ts"), - quoted(String.raw`say^^^ \^^^"hi\^^^"^^^ ^^^&^^^ calc`), - quoted("C:\\dir\\\\"), - ].join(" ")}"`, + launch(path, []); + expect(await listing(join(CACHE(), "@taskless"))).toEqual([ + "cli-nightly@0.14.0-20261020120000xabc1234", + "cli@0.13.0", ]); }); @@ -249,6 +358,52 @@ describe.skipIf(process.platform === "win32")("project launcher", () => { expect(result.stdout).toBe(""); expect(result.stderr).toContain(".taskless/taskless.json"); expect(result.stderr).toContain("npx @taskless/cli@latest"); + expect(await npmCalls()).toEqual([]); + }); + + it.each([ + ["no lockfile", undefined], + ["a lockfile for another version", "0.12.0"], + ])("installs nothing with %s", async (_label, lockedVersion) => { + const path = await install("@taskless/cli", RELEASE, lockedVersion); + const result = launch(path, ["check"]); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(".taskless/bin/cli.lock.json"); + expect(result.stderr).toContain("npx @taskless/cli@latest"); + expect(await npmCalls()).toEqual([]); + }); + + it.each([ + ["npm fails", { FAKE_NPM_FAIL: "1" }, "npm could not install"], + [ + "the install has no command", + { FAKE_NPM_NO_BIN: "1" }, + "installed without its command", + ], + ])( + "runs nothing and leaves no install when %s", + async (_label, env, message) => { + const path = await install("@taskless/cli", RELEASE, "0.13.0"); + const result = launch(path, ["check"], { env }); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(message); + expect(result.stderr).toContain("@taskless/cli@0.13.0"); + expect(await listing(CACHE())).toEqual([".gitignore"]); + } + ); + + it("says npm is needed when it is not on PATH", async () => { + const path = await install("@taskless/cli", RELEASE, "0.13.0"); + const empty = join(root, "empty-bin"); + await mkdir(empty); + const result = launch(path, ["check"], { path: empty }); + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "npm is needed to install @taskless/cli@0.13.0" + ); + expect(await listing(CACHE())).toEqual([".gitignore"]); }); }); @@ -269,6 +424,7 @@ describe("launcher source", () => { .filter((line) => line.trimStart().startsWith("//")) .join("\n"); expect(comments).toContain(".taskless/taskless.json"); + expect(comments).toContain(".taskless/.cache/"); expect(comments).toContain("@taskless/cli"); expect(comments).not.toMatch(/bypass|classifier|auto mode/i); }); From 905da8cbf5578cc5851bcd64c3e7d4771ac823f9 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 22:52:29 -0700 Subject: [PATCH 05/11] feat(agent): cache launcher installs in CI and document the lockfile --- openspec/changes/launcher-local-cache/tasks.md | 4 ++-- packages/cli/src/agent/ci.md | 17 ++++++++++++++++- packages/cli/src/agent/hooks.md | 14 +++++++++----- packages/cli/src/agent/init.md | 14 +++++++++++--- packages/cli/test/prompts.test.ts | 8 ++++++++ skills/taskless/SKILL.md | 8 +++++--- 6 files changed, 51 insertions(+), 14 deletions(-) diff --git a/openspec/changes/launcher-local-cache/tasks.md b/openspec/changes/launcher-local-cache/tasks.md index 2756e1d2..550d4319 100644 --- a/openspec/changes/launcher-local-cache/tasks.md +++ b/openspec/changes/launcher-local-cache/tasks.md @@ -20,8 +20,8 @@ ## 4. Recipes and skill -- [ ] 4.1 `ci` recipe: `actions/cache` for `.taskless/.cache` keyed on `runner.os` and `hashFiles('.taskless/bin/cli.lock.json')`, the same advice in the other-CI guidance, topic version bump; verify with the recipe tests -- [ ] 4.2 `hooks` recipe and `skills/taskless/SKILL.md`: the first run of a version installs it into `.taskless/.cache/`, later runs are local; the lockfile is committed with the launcher; verify the parity tests (no prettier over `src/agent/*.md`) +- [x] 4.1 `ci` recipe: `actions/cache` for `.taskless/.cache` keyed on `runner.os` and `hashFiles('.taskless/bin/cli.lock.json')`, the same advice in the other-CI guidance, topic version bump; verify with the recipe tests +- [x] 4.2 `hooks` recipe and `skills/taskless/SKILL.md`: the first run of a version installs it into `.taskless/.cache/`, later runs are local; the lockfile is committed with the launcher; verify the parity tests (no prettier over `src/agent/*.md`) ## 5. Verification and release diff --git a/packages/cli/src/agent/ci.md b/packages/cli/src/agent/ci.md index 95a9ce44..778decf0 100644 --- a/packages/cli/src/agent/ci.md +++ b/packages/cli/src/agent/ci.md @@ -1,4 +1,4 @@ -# Topic: ci (CLI v%(CLI_VERSION)s / topic v7) +# Topic: ci (CLI v%(CLI_VERSION)s / topic v8) %(ONE_COMMAND_PER_CALL)s @@ -129,6 +129,13 @@ executable bit, and a Windows runner does not honour the launcher's shebang. This is a different spelling from the one you run locally, on purpose; do not copy it into your own commands. +The launcher's first run of a version installs it into +`.taskless/.cache/` with npm, from the lockfile beside it, +`.taskless/bin/cli.lock.json`. Cache that directory between runs, keyed +on the runner's operating system and that lockfile's hash: the key +changes exactly when an upgrade does, and a cache never crosses +platforms, since the install holds platform-specific binaries. + ```yaml name: Taskless @@ -152,6 +159,11 @@ jobs: with: node-version: 22 + - uses: actions/cache@v4 + with: + path: .taskless/.cache + key: taskless-${{ runner.os }}-${{ hashFiles('.taskless/bin/cli.lock.json') }} + - name: Taskless check run: | if [ "${{ github.event_name }}" = "pull_request" ]; then @@ -181,6 +193,9 @@ The six universal steps: `package.json`, so in a Python, Go, or other non-Node repository it still runs as its own job with its own image. 2. Fetch with full depth (or enough to reach the target branch). + Where the CI system has a cache, cache `.taskless/.cache` with a + key built from the runner's operating system and the hash of + `.taskless/bin/cli.lock.json`. 3. Fetch the target branch. 4. Compute changed files with `git diff --name-only "origin/...HEAD"`. 5. Exit early if the diff is empty. diff --git a/packages/cli/src/agent/hooks.md b/packages/cli/src/agent/hooks.md index 2171cc49..e94c30b6 100644 --- a/packages/cli/src/agent/hooks.md +++ b/packages/cli/src/agent/hooks.md @@ -1,4 +1,4 @@ -# Topic: hooks (CLI v%(CLI_VERSION)s / topic v4) +# Topic: hooks (CLI v%(CLI_VERSION)s / topic v5) %(ONE_COMMAND_PER_CALL)s @@ -18,8 +18,9 @@ deciding when rules run. `%(TASKLESS_CLI)s agent ci` is the other half. very next commit. - The user has agreed to a hook. A hook runs on every developer's commits, so it is a team decision, not a setup detail. -- `.taskless/bin/cli.mjs` exists and is committed. It is the project - launcher `init` writes; the hook runs it. If it is missing, the +- `.taskless/bin/cli.mjs` and `.taskless/bin/cli.lock.json` exist and + are committed. They are the project launcher `init` writes and the + lockfile it installs from; the hook runs the launcher. If it is missing, the project was installed by an older CLI: tell the user to run `%(TASKLESS_CLI)s init` and commit the result first. @@ -65,8 +66,11 @@ platform. Git runs hooks from the repository root, which is where the path resolves. If `.taskless/` is not at the repository root (a package inside a monorepo), run the command from that package's directory, using the hook tool's own option for it, or ask the user how -their hooks reach that package. The first run downloads the recorded -version, so the first commit after an upgrade is slower than the rest. +their hooks reach that package. The first run of a recorded version +installs it into `.taskless/.cache/` from the lockfile beside the +launcher, `.taskless/bin/cli.lock.json`, so the first commit after an +upgrade is slower than the rest; later runs start it from there +without the network. ### 3. Write the pre-commit command diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index 31346ff5..6b755c61 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -1,4 +1,4 @@ -# Topic: init (CLI v%(CLI_VERSION)s / topic v11) +# Topic: init (CLI v%(CLI_VERSION)s / topic v12) %(ONE_COMMAND_PER_CALL)s @@ -58,7 +58,8 @@ first step, not the whole job. "key": "autoMode.allow", "state": "absent", "skipped": null, "labelled": 0 } ], - "launcher": { "path": ".taskless/bin/cli.mjs", "state": "unchanged" } + "launcher": { "path": ".taskless/bin/cli.mjs", "state": "unchanged" }, + "lockfile": { "path": ".taskless/bin/cli.lock.json", "state": "unchanged" } } ``` - `cliVersion.previous` is `null` on a project with no recorded @@ -67,7 +68,7 @@ first step, not the whole job. removed there, by name. `mode: "canonical"` is the `.taskless/` store; `reference` is a tool directory holding stubs. - `changed` is `true` when a migration ran, any target list is - non-empty, the launcher was created or restored, or `cliVersion` or a + non-empty, the launcher or its lockfile was created or restored, or `cliVersion` or a launcher's recorded version moved (both rewrite `.taskless/taskless.json`). When it is `false` AND `pinnedCli` is empty, stop here: nothing to commit, nothing to reconcile, nothing @@ -119,6 +120,13 @@ first step, not the whole job. `restored` (it had been edited, and was put back), or `unchanged`. It is `null` for a build that installs none. The launcher is a file to commit with the rest of `.taskless/`. + - `lockfile` is the npm lockfile beside the launcher, + `.taskless/bin/cli.lock.json` (`cli-nightly.lock.json` for a + nightly), with the same `state` values. It pins every package the + launcher installs, so it is committed with the launcher. The + launcher's first run of a version installs it into + `.taskless/.cache/`, which ignores itself in git; `init` downloads + nothing. 2. **Tell the user what needs committing.** Every `targets[].dir` with a non-empty list, plus `.taskless/`, any `migrated.files` entries, and diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index 19259d29..d3352b79 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -160,6 +160,14 @@ describe("prompt rendering", () => { expect(ci).not.toMatch(/\b(?:pnpm dlx|yarn dlx|bunx) @taskless/); }); + it("caches the launcher's installs between CI runs, per platform and lockfile", () => { + const ci = getRecipe("ci") ?? ""; + expect(ci).toContain("path: .taskless/.cache"); + expect(ci).toContain( + "key: taskless-${{ runner.os }}-${{ hashFiles('.taskless/bin/cli.lock.json') }}" + ); + }); + it("returns undefined for an unknown topic", () => { expect(getRecipe("no-such-topic")).toBeUndefined(); }); diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index 384a0705..e0f12204 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -188,6 +188,8 @@ flow (when one exists for that topic). ## First-run latency -The first invocation of `%(TASKLESS_CLI)s` on a machine pays an npm -cold-fetch (~5–15 seconds). This is normal — do not report it as a timeout -or failure. Subsequent invocations are cached and fast. +The first invocation of `%(TASKLESS_CLI)s` for a version installs that +version into `.taskless/.cache/` with npm (~5–15 seconds), and prints an +`installing` line to standard error. This is normal — do not report it as a +timeout or failure. Later invocations start from the cache without the +network. From e106baffc77343f85f42b347f2e6e270ef02a123 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 22:57:44 -0700 Subject: [PATCH 06/11] fix: find the project's .npmrc without npm prefix, which masks UUID path segments --- .../changes/launcher-local-cache/design.md | 2 +- .../changes/launcher-local-cache/proposal.md | 2 +- .../specs/cli-project-launcher/spec.md | 7 +++- .../changes/launcher-local-cache/tasks.md | 6 ++-- packages/cli/assets/launcher/launcher.mjs | 26 +++++++++----- packages/cli/test/launcher.test.ts | 34 ++++++++++++++----- 6 files changed, 53 insertions(+), 24 deletions(-) diff --git a/openspec/changes/launcher-local-cache/design.md b/openspec/changes/launcher-local-cache/design.md index b06d4c5c..82de7bfe 100644 --- a/openspec/changes/launcher-local-cache/design.md +++ b/openspec/changes/launcher-local-cache/design.md @@ -61,7 +61,7 @@ On a cache miss the launcher: 1. Reads its lockfile. If it is missing, or its `node_modules/` version is not the recorded version, it stops and says to rerun the installer. It never installs without the lockfile. 2. Creates `.taskless/.cache/.tmp--/`, writes `package.json` (`{ "private": true, "dependencies": { : } }`) and copies the lockfile in as `package-lock.json`. -3. Runs `npm prefix` from the project root and, if `/.npmrc` exists, copies it into the temporary directory. +3. Copies the nearest `.npmrc` at or above the project root into the temporary directory, if there is one. The walk stops short of the home directory, whose `.npmrc` npm already reads as user config. npm reads project settings only from the directory it installs into (measured in the table above), so without the copy a private registry would be ignored. The launcher finds the file itself rather than asking `npm prefix`: npm masks anything in its output that looks like a token, a UUID path segment included, so under such a path `npm prefix` printed `…/***/…`, and on 2026-10-20 the launcher then found no `.npmrc` and installed from the public registry without a word. The nearest-file rule also covers a monorepo package whose `.npmrc` sits at the repository root. 4. Runs `npm ci --ignore-scripts --no-audit --no-fund --loglevel=error` there, with npm's output on standard error so a first run's `--json` output stays clean. 5. Deletes the copied `.npmrc`, checks the package's `bin` entry resolves, and renames the directory to `.taskless/.cache/@/`. If that already exists, another process won: it deletes its own directory and uses the existing one. diff --git a/openspec/changes/launcher-local-cache/proposal.md b/openspec/changes/launcher-local-cache/proposal.md index 7681a5ad..e741740b 100644 --- a/openspec/changes/launcher-local-cache/proposal.md +++ b/openspec/changes/launcher-local-cache/proposal.md @@ -11,7 +11,7 @@ Installing the recorded version once into a git-ignored `.taskless/.cache/`, fro - Each nightly and release build derives an npm lockfile for its own package from `pnpm-lock.yaml`, offline, and embeds it. The CLI's `package.json` keeps its ranges; development stays loose. A build whose dependency tree cannot be laid out as one flat npm tree fails rather than shipping a partial lockfile. - `init` writes that lockfile beside the package's launcher, `.taskless/bin/cli.lock.json` for the release and `.taskless/bin/cli-nightly.lock.json` for the nightly. A nightly install leaves the release's pair alone, as it already leaves the release's launcher. - The launcher installs into `.taskless/.cache/@/` with `npm ci --ignore-scripts` from its lockfile, into a temporary directory renamed into place once complete, then runs the installed bin with the same `node`. It no longer calls `npx`. A missing lockfile, or one that does not name the recorded version, stops the launcher with a message to rerun `init`. -- The install uses the project's `.npmrc`, the one `npx` would have read, so private registries keep working. +- The install uses the nearest `.npmrc` at or above the project root, so private registries keep working. `npm install --prefix` alone ignores that file. - The cache ignores itself (`.taskless/.cache/.gitignore` containing `*`). No scaffold migration. - Cached versions neither package records are removed, by `init` and by the launcher after it installs a new version. - The `ci` recipe caches `.taskless/.cache`, keyed on the runner OS and the hash of the package's lockfile. diff --git a/openspec/changes/launcher-local-cache/specs/cli-project-launcher/spec.md b/openspec/changes/launcher-local-cache/specs/cli-project-launcher/spec.md index c25d2ca0..c66b806d 100644 --- a/openspec/changes/launcher-local-cache/specs/cli-project-launcher/spec.md +++ b/openspec/changes/launcher-local-cache/specs/cli-project-launcher/spec.md @@ -70,7 +70,7 @@ When `.taskless/.cache/@/` does not exist, the launcher SHALL The launcher SHALL NOT install without its lockfile, and SHALL NOT install when the lockfile does not name the recorded version; in both cases it SHALL print one message to standard error naming the lockfile and the installer command that rewrites it, and exit with a non-zero status. -The install SHALL use the `.npmrc` of the directory npm treats as the project when run from the project root, when one exists, and that file SHALL NOT remain in the cache afterwards. npm's output SHALL go to standard error, never standard output. +The install SHALL use the nearest `.npmrc` at or above the project root, short of the user's home directory (whose `.npmrc` npm already reads as user configuration), when one exists, so a registry configured there applies, and that file SHALL NOT remain in the cache afterwards. npm's output SHALL go to standard error, never standard output. #### Scenario: First run installs the tested tree, later runs do not install @@ -90,6 +90,11 @@ The install SHALL use the `.npmrc` of the directory npm treats as the project wh - **WHEN** `install.launchers` records `@taskless/cli` at `0.13.1` and `.taskless/bin/cli.lock.json` pins `@taskless/cli` at `0.13.0` - **THEN** the launcher SHALL NOT install or run anything, and SHALL exit non-zero naming the lockfile +#### Scenario: A registry configured above a monorepo package is used + +- **WHEN** `.taskless/` is in `packages/app/` and the repository root has an `.npmrc` setting `registry=` +- **THEN** the launcher's install SHALL use that `.npmrc` + #### Scenario: A project registry is used - **WHEN** the project root has an `.npmrc` setting `registry=` and the launcher installs diff --git a/openspec/changes/launcher-local-cache/tasks.md b/openspec/changes/launcher-local-cache/tasks.md index 550d4319..71d4dc5b 100644 --- a/openspec/changes/launcher-local-cache/tasks.md +++ b/openspec/changes/launcher-local-cache/tasks.md @@ -13,7 +13,7 @@ - [x] 3.1 Run from the cache: resolve `.taskless/.cache/@/`, read the bin from the installed `package.json`, run it with `process.execPath` and no shell; verify a pre-populated entry runs and no `npm` process starts - [x] 3.2 Refuse without a matching lockfile: missing, or naming another version; verify each with a test (no `npm` started, message names the lockfile and the installer command) -- [x] 3.3 Install: temporary directory, one-line `package.json` plus the lockfile, `npm prefix` and the project `.npmrc` copy, `npm ci --ignore-scripts --no-audit --no-fund --loglevel=error` with output on standard error, remove the `.npmrc`, check the bin resolves, rename (discarding ours on a lost race); `cmd.exe` quoting only for the npm calls; verify first run, second run, lost race and clean `--json` output with a stub `npm` on `PATH` +- [x] 3.3 Install: temporary directory, one-line `package.json` plus the lockfile, the nearest `.npmrc` at or above the project root copied in (not `npm prefix`, whose output masks UUID-like path segments), `npm ci --ignore-scripts --no-audit --no-fund --loglevel=error` with output on standard error, remove the `.npmrc`, check the bin resolves, rename (discarding ours on a lost race); `cmd.exe` quoting only for the npm calls; verify first run, second run, lost race and clean `--json` output with a stub `npm` on `PATH` - [x] 3.4 Failure paths: npm missing, npm exiting non-zero, bin missing; each removes the temporary directory and exits non-zero naming `@`; verify with tests - [x] 3.5 Write `.taskless/.cache/.gitignore` (`*`); verify with a test and `git status` in a scratch repository - [x] 3.6 Prune after a new install by the shared rule, ignoring removal errors; verify with tests for an upgrade and for keeping the other package's entry @@ -25,8 +25,8 @@ ## 5. Verification and release -- [ ] 5.1 End to end from a built release-target CLI in a scratch project: `init` writes launcher and lockfile; `./.taskless/bin/cli.mjs check` runs an ast-grep and a Vale rule, and a runtime rule if available; the second run starts no `npm`; `git status` shows the lockfile and not the cache. Since the version under test is unpublished, serve it from a local scoped registry as in the design's measurements -- [ ] 5.2 Private registry: a project `.npmrc` pointing at an unreachable registry fails the first install with npm's error and leaves no `.npmrc` or temporary directory +- [x] 5.1 End to end from a built release-target CLI in a scratch project: `init` writes launcher and lockfile; `./.taskless/bin/cli.mjs check` runs an ast-grep and a Vale rule, and a runtime rule if available; the second run starts no `npm`; `git status` shows the lockfile and not the cache. Since the version under test is unpublished, serve it from a local scoped registry as in the design's measurements +- [x] 5.2 Private registry: a project `.npmrc` pointing at an unreachable registry fails the first install with npm's error and leaves no `.npmrc` or temporary directory - [ ] 5.3 Extend `.changeset/pinned-cli-wrapper.md` (no second changeset); update #538 with the shrinkwrap measurements and point it at this change - [ ] 5.4 Run `pnpm typecheck`, `pnpm lint` and the test suite - [ ] 5.5 Archive and check scenario counts for `cli-project-launcher`, `cli-init` and `cli-agent` against the main specs diff --git a/packages/cli/assets/launcher/launcher.mjs b/packages/cli/assets/launcher/launcher.mjs index 05043e44..c5ac0d4a 100644 --- a/packages/cli/assets/launcher/launcher.mjs +++ b/packages/cli/assets/launcher/launcher.mjs @@ -19,6 +19,7 @@ import { statSync, writeFileSync, } from "node:fs"; +import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; @@ -116,16 +117,23 @@ function npm(args, options) { ); } -/** The `.npmrc` npx would read when run from the project root, if any. */ +/** + * The project's `.npmrc`: the nearest one at or above the project root, + * short of the home directory, whose `.npmrc` npm already reads as user + * config. Found here rather than asked of `npm prefix`, whose output masks + * path segments that look like tokens, such as a UUID. + */ function projectNpmrc() { - const result = npm(["prefix"], { - cwd: projectRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - if (result.status !== 0) return undefined; - const path = join(result.stdout.trim(), ".npmrc"); - return existsSync(path) ? path : undefined; + const home = homedir(); + let directory = projectRoot; + while (directory !== home) { + const path = join(directory, ".npmrc"); + if (existsSync(path)) return path; + const parent = dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } + return undefined; } /** Install `version` into `target` from the lockfile; returns its entry point. */ diff --git a/packages/cli/test/launcher.test.ts b/packages/cli/test/launcher.test.ts index f94ca7ff..6fdfb469 100644 --- a/packages/cli/test/launcher.test.ts +++ b/packages/cli/test/launcher.test.ts @@ -40,8 +40,7 @@ process.exit(Number(process.env.FAKE_CLI_EXIT ?? 0)); /** * A stand-in for `npm`, found on `PATH` exactly as the real one would be. It - * logs each call, answers `npm prefix` with its working directory, and for - * `npm ci` checks the lockfile against package.json as npm does, then + * logs each call, and for `npm ci` checks the lockfile against package.json as npm does, then * installs the fake CLI. It prints to standard output, as npm does, so a test * can check the launcher keeps that off the CLI's standard output. */ @@ -54,10 +53,6 @@ fs.appendFileSync(process.env.FAKE_NPM_LOG, JSON.stringify({ cwd: process.cwd(), npmrc: fs.existsSync(".npmrc") ? fs.readFileSync(".npmrc", "utf8") : null, }) + "\n"); -if (command === "prefix") { - process.stdout.write(process.cwd() + "\n"); - process.exit(0); -} if (process.env.FAKE_NPM_FAIL) { process.stderr.write("npm error fake failure\n"); process.exit(1); @@ -205,12 +200,12 @@ describe.skipIf(process.platform === "win32")("project launcher", () => { argv: ["check", "src/a file.ts", "--json"], version: "0.13.0", }); - expect(await npmCommands()).toEqual(["prefix", "ci"]); + expect(await npmCommands()).toEqual(["ci"]); await stat(join(CACHE(), "@taskless", "cli@0.13.0")); const second = launch(path, ["check"]); expect(second.status).toBe(0); - expect(await npmCommands()).toHaveLength(2); + expect(await npmCommands()).toHaveLength(1); }); it("keeps npm's output off standard output", async () => { @@ -290,7 +285,7 @@ describe.skipIf(process.platform === "win32")("project launcher", () => { expect(await readFile(join(CACHE(), ".gitignore"), "utf8")).toBe("*\n"); }); - it("installs with the project's .npmrc and does not keep it", async () => { + it("installs with the nearest .npmrc above the project and does not keep it", async () => { await writeFile(join(root, ".npmrc"), "registry=http://127.0.0.1:9/\n"); const path = await install("@taskless/cli", RELEASE, "0.13.0"); launch(path, []); @@ -302,6 +297,27 @@ describe.skipIf(process.platform === "win32")("project launcher", () => { ).rejects.toThrow(); }); + it("finds the .npmrc in a directory above the project", async () => { + const project = join(root, "packages", "app"); + await mkdir(join(project, ".taskless", "bin"), { recursive: true }); + await writeFile(join(root, ".npmrc"), "registry=http://127.0.0.1:9/\n"); + await writeFile( + join(project, ".taskless", "taskless.json"), + JSON.stringify(RELEASE) + ); + await writeFile( + join(project, lockfilePath("@taskless/cli")), + lockfileFor("@taskless/cli", "0.13.0") + ); + const path = join(project, ".taskless", "bin", "cli.mjs"); + await writeFile(path, renderLauncher("@taskless/cli")); + launch(path, [], { cwd: project }); + const calls = await npmCalls(); + expect(calls.find((call) => call.command === "ci")?.npmrc).toBe( + "registry=http://127.0.0.1:9/\n" + ); + }); + it("uses another run's install when it finished first", async () => { const path = await install("@taskless/cli", RELEASE, "0.13.0"); const target = join(CACHE(), "@taskless", "cli@0.13.0"); From 642612c59019070177794bc131baecc449994f4b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 22:59:03 -0700 Subject: [PATCH 07/11] chore: extend the launcher changeset with the local cache --- .changeset/pinned-cli-wrapper.md | 2 ++ openspec/changes/launcher-local-cache/tasks.md | 4 ++-- packages/cli/vite.config.ts | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.changeset/pinned-cli-wrapper.md b/.changeset/pinned-cli-wrapper.md index f664f79d..86024a4e 100644 --- a/.changeset/pinned-cli-wrapper.md +++ b/.changeset/pinned-cli-wrapper.md @@ -5,3 +5,5 @@ `init` now installs a project launcher, `.taskless/bin/cli.mjs` (`cli-nightly.mjs` for a nightly), that runs the CLI version recorded for it in `.taskless/taskless.json`. The installed skill and recipes fetched through the launcher name it instead of a pinned `npx` call, so the skill no longer changes on every release, and every harness's permission file gets a version-free rule for it. The `ci` recipe now generates `node .taskless/bin/cli.mjs check`, with Node supplied by the job, so a repository without a `package.json` can run Taskless in CI. The `hooks` recipe runs the launcher too, instead of adding `@taskless/cli` as a dev dependency. When the launcher started the CLI, the commands it prints for you to run next name the launcher, and `.taskless/README.md` lists it first. Each package's Claude Code `autoMode.allow` entry now also covers its own launcher, so one time only, the wizard offers to replace the existing entry. More than one string under a package's label now counts as stale, and the wizard folds them into one. Settings edits are colored as migrations: `ALLOW` green, `REMOVE` uncolored, `REPLACE` yellow, and `SKIPPED` red, which marks an edit you have to make yourself. A missing or stale global entry after `init` prints as a red `SKIPPED (global):` line. + +The launcher no longer runs the CLI through `npx`. The first run of a recorded version installs it into `.taskless/.cache/` with `npm ci`, from a lockfile `init` writes beside the launcher (`.taskless/bin/cli.lock.json`, or `cli-nightly.lock.json`). Later runs start it from there without the network. Each release derives that lockfile from the dependency tree its CI tested, so every machine installs the same versions, `tsx` and `esbuild` included, where a fresh `npx` install resolves whatever their ranges allow that day. Commit the lockfile with the launcher; the cache ignores itself in git, and `init` and the launcher remove versions nothing records any more. The install uses the nearest `.npmrc` at or above the project, so a private registry keeps working. The `ci` recipe now caches `.taskless/.cache` between runs. diff --git a/openspec/changes/launcher-local-cache/tasks.md b/openspec/changes/launcher-local-cache/tasks.md index 71d4dc5b..3b8613fe 100644 --- a/openspec/changes/launcher-local-cache/tasks.md +++ b/openspec/changes/launcher-local-cache/tasks.md @@ -27,7 +27,7 @@ - [x] 5.1 End to end from a built release-target CLI in a scratch project: `init` writes launcher and lockfile; `./.taskless/bin/cli.mjs check` runs an ast-grep and a Vale rule, and a runtime rule if available; the second run starts no `npm`; `git status` shows the lockfile and not the cache. Since the version under test is unpublished, serve it from a local scoped registry as in the design's measurements - [x] 5.2 Private registry: a project `.npmrc` pointing at an unreachable registry fails the first install with npm's error and leaves no `.npmrc` or temporary directory -- [ ] 5.3 Extend `.changeset/pinned-cli-wrapper.md` (no second changeset); update #538 with the shrinkwrap measurements and point it at this change -- [ ] 5.4 Run `pnpm typecheck`, `pnpm lint` and the test suite +- [x] 5.3 Extend `.changeset/pinned-cli-wrapper.md` (no second changeset); update #538 with the shrinkwrap measurements and point it at this change +- [x] 5.4 Run `pnpm typecheck`, `pnpm lint` and the test suite - [ ] 5.5 Archive and check scenario counts for `cli-project-launcher`, `cli-init` and `cli-agent` against the main specs - [ ] 5.6 Follow-up, not blocking: a Windows run of a first install and a later run diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index e467666b..94ad62f0 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -64,7 +64,6 @@ function installLockfile(target: string, version: string): string | null { readFileSync(resolve(import.meta.dirname, "../../pnpm-lock.yaml"), "utf8") ) as PnpmLockfile; const lockfile = buildInstallLockfile(lock, "packages/cli", { - // ast-grep-ignore: no-unrouted-cli-invocation name: target === "nightly" ? "@taskless/cli-nightly" : "@taskless/cli", version, license: pkg.license, From df20869000678a54a67ef657c563acdc787138c7 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 22:59:18 -0700 Subject: [PATCH 08/11] docs(openspec): archive launcher-local-cache --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/cli-agent/spec.md | 0 .../specs/cli-init/spec.md | 0 .../specs/cli-project-launcher/spec.md | 0 .../2026-10-19-launcher-local-cache}/tasks.md | 2 +- openspec/specs/cli-agent/spec.md | 8 ++ openspec/specs/cli-init/spec.md | 36 ++++++ openspec/specs/cli-project-launcher/spec.md | 119 ++++++++++++++++-- 10 files changed, 157 insertions(+), 8 deletions(-) rename openspec/changes/{launcher-local-cache => archive/2026-10-19-launcher-local-cache}/.openspec.yaml (100%) rename openspec/changes/{launcher-local-cache => archive/2026-10-19-launcher-local-cache}/design.md (100%) rename openspec/changes/{launcher-local-cache => archive/2026-10-19-launcher-local-cache}/proposal.md (100%) rename openspec/changes/{launcher-local-cache => archive/2026-10-19-launcher-local-cache}/specs/cli-agent/spec.md (100%) rename openspec/changes/{launcher-local-cache => archive/2026-10-19-launcher-local-cache}/specs/cli-init/spec.md (100%) rename openspec/changes/{launcher-local-cache => archive/2026-10-19-launcher-local-cache}/specs/cli-project-launcher/spec.md (100%) rename openspec/changes/{launcher-local-cache => archive/2026-10-19-launcher-local-cache}/tasks.md (98%) diff --git a/openspec/changes/launcher-local-cache/.openspec.yaml b/openspec/changes/archive/2026-10-19-launcher-local-cache/.openspec.yaml similarity index 100% rename from openspec/changes/launcher-local-cache/.openspec.yaml rename to openspec/changes/archive/2026-10-19-launcher-local-cache/.openspec.yaml diff --git a/openspec/changes/launcher-local-cache/design.md b/openspec/changes/archive/2026-10-19-launcher-local-cache/design.md similarity index 100% rename from openspec/changes/launcher-local-cache/design.md rename to openspec/changes/archive/2026-10-19-launcher-local-cache/design.md diff --git a/openspec/changes/launcher-local-cache/proposal.md b/openspec/changes/archive/2026-10-19-launcher-local-cache/proposal.md similarity index 100% rename from openspec/changes/launcher-local-cache/proposal.md rename to openspec/changes/archive/2026-10-19-launcher-local-cache/proposal.md diff --git a/openspec/changes/launcher-local-cache/specs/cli-agent/spec.md b/openspec/changes/archive/2026-10-19-launcher-local-cache/specs/cli-agent/spec.md similarity index 100% rename from openspec/changes/launcher-local-cache/specs/cli-agent/spec.md rename to openspec/changes/archive/2026-10-19-launcher-local-cache/specs/cli-agent/spec.md diff --git a/openspec/changes/launcher-local-cache/specs/cli-init/spec.md b/openspec/changes/archive/2026-10-19-launcher-local-cache/specs/cli-init/spec.md similarity index 100% rename from openspec/changes/launcher-local-cache/specs/cli-init/spec.md rename to openspec/changes/archive/2026-10-19-launcher-local-cache/specs/cli-init/spec.md diff --git a/openspec/changes/launcher-local-cache/specs/cli-project-launcher/spec.md b/openspec/changes/archive/2026-10-19-launcher-local-cache/specs/cli-project-launcher/spec.md similarity index 100% rename from openspec/changes/launcher-local-cache/specs/cli-project-launcher/spec.md rename to openspec/changes/archive/2026-10-19-launcher-local-cache/specs/cli-project-launcher/spec.md diff --git a/openspec/changes/launcher-local-cache/tasks.md b/openspec/changes/archive/2026-10-19-launcher-local-cache/tasks.md similarity index 98% rename from openspec/changes/launcher-local-cache/tasks.md rename to openspec/changes/archive/2026-10-19-launcher-local-cache/tasks.md index 3b8613fe..bda60a33 100644 --- a/openspec/changes/launcher-local-cache/tasks.md +++ b/openspec/changes/archive/2026-10-19-launcher-local-cache/tasks.md @@ -29,5 +29,5 @@ - [x] 5.2 Private registry: a project `.npmrc` pointing at an unreachable registry fails the first install with npm's error and leaves no `.npmrc` or temporary directory - [x] 5.3 Extend `.changeset/pinned-cli-wrapper.md` (no second changeset); update #538 with the shrinkwrap measurements and point it at this change - [x] 5.4 Run `pnpm typecheck`, `pnpm lint` and the test suite -- [ ] 5.5 Archive and check scenario counts for `cli-project-launcher`, `cli-init` and `cli-agent` against the main specs +- [x] 5.5 Archive and check scenario counts for `cli-project-launcher`, `cli-init` and `cli-agent` against the main specs - [ ] 5.6 Follow-up, not blocking: a Windows run of a first install and a later run diff --git a/openspec/specs/cli-agent/spec.md b/openspec/specs/cli-agent/spec.md index b5e87775..7b112f2d 100644 --- a/openspec/specs/cli-agent/spec.md +++ b/openspec/specs/cli-agent/spec.md @@ -493,6 +493,8 @@ The `ci` recipe SHALL generate CI configuration that runs `node .taskless/bin/cl The recipe SHALL require that the generated job make Node available itself, through the CI system's Node setup step or a Node container image, and SHALL say that the job does not need the project's own language toolchain or a `package.json`. It SHALL name the launcher's version source, `.taskless/taskless.json`, as what decides which CLI version CI runs, so an upgrade reaches CI through the same commit that updates `.taskless/`. +The recipe SHALL have the generated job cache `.taskless/.cache` between runs where the CI system supports a cache, with a key that includes the runner's operating system and a hash of `.taskless/bin/cli.lock.json`, so a restored cache never crosses platforms and an upgrade starts a fresh one. + #### Scenario: GitHub Actions template - **WHEN** the `ci` recipe is rendered @@ -505,6 +507,12 @@ The recipe SHALL require that the generated job make Node available itself, thro - **WHEN** the `ci` recipe is followed in a repository with no `package.json` - **THEN** the recipe SHALL direct the job to supply Node itself, and SHALL NOT tell the reader to add a `package.json` +#### Scenario: The launcher cache is restored between runs + +- **WHEN** the `ci` recipe is rendered +- **THEN** its GitHub Actions template SHALL cache the path `.taskless/.cache` +- **AND** the cache key SHALL include `runner.os` and `hashFiles('.taskless/bin/cli.lock.json')` + ### Requirement: The hooks recipe runs the launcher The `hooks` recipe SHALL generate pre-commit commands that run `node .taskless/bin/cli.mjs`, for every hook tool it covers. It SHALL NOT tell the reader to add `@taskless/cli` as a dev dependency for the hook, SHALL NOT resolve a binary from `node_modules/.bin`, and SHALL NOT call `npx`, `pnpm dlx`, `yarn dlx`, or `bunx`, since the launcher already runs the version recorded in `.taskless/taskless.json`. It SHALL require that `.taskless/bin/cli.mjs` exists and is committed, and SHALL say what to do when `.taskless/` is not at the repository root, where git runs hooks. diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 0b8a2c45..26527590 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -1185,3 +1185,39 @@ A harness whose rule format cannot match a command given as a relative path SHAL - **WHEN** a nightly `init` installs for `.claude/` - **THEN** `.claude/settings.json` SHALL contain `Bash(./.taskless/bin/cli-nightly.mjs *)` - **AND** SHALL NOT gain `Bash(./.taskless/bin/cli.mjs *)` + +### Requirement: Init writes the package's lockfile beside its launcher + +Every `init` that writes a launcher SHALL also write this build's lockfile for the same package beside it: `.taskless/bin/cli.lock.json` for the release and `.taskless/bin/cli-nightly.lock.json` for the nightly. It SHALL NOT write, change or remove the other package's lockfile. A lockfile whose bytes differ from this build's SHALL be rewritten, and the install summary SHALL name it as restored. A `self` build SHALL write no lockfile. No scaffold migration SHALL be added for this. + +#### Scenario: A release install writes its lockfile + +- **WHEN** the release's `init` runs at `0.13.1` +- **THEN** `.taskless/bin/cli.lock.json` SHALL hold this build's lockfile, naming `@taskless/cli` at `0.13.1` + +#### Scenario: A nightly install leaves the release's lockfile alone + +- **WHEN** `.taskless/bin/cli.lock.json` exists and a nightly `init` runs +- **THEN** `.taskless/bin/cli-nightly.lock.json` SHALL be written +- **AND** `.taskless/bin/cli.lock.json` SHALL be unchanged + +#### Scenario: An edited lockfile is restored and reported + +- **WHEN** `.taskless/bin/cli.lock.json` was edited and the release's `init` runs +- **THEN** the file SHALL hold this build's lockfile afterwards +- **AND** the summary SHALL name it as restored + +### Requirement: Init removes cached CLI versions nothing records + +After `init` records versions under `install.launchers`, it SHALL remove every entry in `.taskless/.cache/` that is not the version recorded for either package, and every temporary install directory more than an hour old, by the same rule the launcher uses. Removal SHALL be best effort: an entry that cannot be removed SHALL be left without failing `init`. `init` SHALL NOT install anything into `.taskless/.cache/`; the launcher does that on its first run. + +#### Scenario: An upgrade drops the previous release + +- **WHEN** `.taskless/.cache/` holds `@taskless/cli@0.13.0` and the release's `init` runs at `0.13.1` +- **THEN** `@taskless/cli@0.13.0` SHALL no longer be in the cache +- **AND** nothing SHALL be installed for `0.13.1` + +#### Scenario: A nightly install keeps the release's cache + +- **WHEN** `.taskless/.cache/` holds `@taskless/cli@0.13.0`, `install.launchers` records it, and a nightly `init` runs +- **THEN** `@taskless/cli@0.13.0` SHALL remain in the cache diff --git a/openspec/specs/cli-project-launcher/spec.md b/openspec/specs/cli-project-launcher/spec.md index 5766e418..e43a606a 100644 --- a/openspec/specs/cli-project-launcher/spec.md +++ b/openspec/specs/cli-project-launcher/spec.md @@ -31,16 +31,17 @@ Each launcher SHALL be an ES module (`.mjs`) whose first line is `#!/usr/bin/env ### Requirement: A launcher runs the version recorded for its package -When run, a launcher SHALL read `.taskless/taskless.json` from the `.taskless/` directory that contains it, not from the working directory, and take its package's version from `install.launchers`. When that field has no version for its package, it SHALL fall back to `install.cliVersion`, but only when that version belongs to its package: a version containing `-` belongs to the nightly, and one without belongs to the release. It SHALL then run `npx --yes @` with every argument it was given, unchanged and in order, inheriting standard input, output, and error, and the working directory and environment it was started with. +When run, a launcher SHALL read `.taskless/taskless.json` from the `.taskless/` directory that contains it, not from the working directory, and take its package's version from `install.launchers`. When that field has no version for its package, it SHALL fall back to `install.cliVersion`, but only when that version belongs to its package: a version containing `-` belongs to the nightly, and one without belongs to the release. It SHALL then run that version's installed entry point from `.taskless/.cache/@/`, installing it there first from the package's lockfile when it is absent, with the Node executable that is running the launcher and every argument it was given, unchanged and in order, inheriting standard input, output, and error, and the working directory and environment it was started with. It SHALL NOT run the CLI through `npx`. The launcher SHALL add one variable to the environment it passes on, naming its own path relative to the project root, so the CLI it starts can name the launcher in what it prints. -It SHALL exit with the CLI's exit status. When the CLI is ended by a signal, the launcher SHALL exit as that signal would end it. It SHALL make no network request of its own and SHALL NEVER fall back to an unpinned or dist-tagged version. +It SHALL exit with the CLI's exit status. When the CLI is ended by a signal, the launcher SHALL exit as that signal would end it. When the recorded version is already installed, it SHALL make no network request and start no package manager. It SHALL NEVER fall back to an unpinned or dist-tagged version. #### Scenario: The release launcher runs its pin -- **WHEN** `install.launchers` records `@taskless/cli` at `0.13.0` and `./.taskless/bin/cli.mjs check src/a.ts` runs -- **THEN** it SHALL run `npx --yes @taskless/cli@0.13.0 check src/a.ts` +- **WHEN** `install.launchers` records `@taskless/cli` at `0.13.0`, `.taskless/.cache/@taskless/cli@0.13.0/` holds that version, and `./.taskless/bin/cli.mjs check src/a.ts` runs +- **THEN** it SHALL run that directory's `@taskless/cli` entry point with `check src/a.ts` +- **AND** no `npm` or `npx` process SHALL start #### Scenario: A nightly install does not move the release launcher @@ -56,7 +57,7 @@ It SHALL exit with the CLI's exit status. When the CLI is ended by a signal, the #### Scenario: Run from a subdirectory - **WHEN** the working directory is `src/` and `../.taskless/bin/cli.mjs check` runs -- **THEN** the launcher SHALL read the version from the `.taskless/` that contains it +- **THEN** the launcher SHALL read the version from, and use the cache in, the `.taskless/` that contains it - **AND** the CLI SHALL start in `src/` #### Scenario: The exit status passes through @@ -66,12 +67,12 @@ It SHALL exit with the CLI's exit status. When the CLI is ended by a signal, the ### Requirement: A launcher without a version fails plainly -When no version for its package can be read, because `.taskless/taskless.json` is missing or does not parse, or neither field holds a version for its package, the launcher SHALL run nothing, print one message to standard error naming the file it read and the installer command that records a version, and exit with a non-zero status. +When no version for its package can be read, because `.taskless/taskless.json` is missing or does not parse, or neither field holds a version for its package, the launcher SHALL install nothing, run nothing, print one message to standard error naming the file it read and the installer command that records a version, and exit with a non-zero status. #### Scenario: No version recorded - **WHEN** `install.launchers` and `install.cliVersion` hold no release version and `./.taskless/bin/cli.mjs check` runs -- **THEN** no `npx` process SHALL start +- **THEN** no `npm` or `npx` process SHALL start - **AND** standard error SHALL name `.taskless/taskless.json` and the installer command - **AND** the exit status SHALL be non-zero @@ -103,3 +104,107 @@ When a project launcher started the CLI, as its environment variable reports wit - **WHEN** the launcher variable is `./evil.sh` - **THEN** printed commands SHALL NOT name `./evil.sh` + +### Requirement: Each published build carries the lockfile of its tested dependencies + +A nightly or release build SHALL carry an npm lockfile for its own package, derived from the repository's `pnpm-lock.yaml` without network access, that pins every package in the CLI's production dependency tree, including every platform's optional packages, to the version and integrity `pnpm-lock.yaml` records, with each package's platform constraints. The CLI's `package.json` SHALL NOT be changed to produce it. When the tree cannot be expressed as one flat lockfile, or a package in it has no recorded integrity, the build SHALL fail. A `self` build SHALL carry no lockfile. + +#### Scenario: The lockfile matches what CI tested + +- **WHEN** `pnpm-lock.yaml` resolves the CLI's `tsx` to `4.21.0` and its `esbuild` to `0.27.3`, and a release build runs +- **THEN** the build's lockfile SHALL pin `tsx` at `4.21.0` and `esbuild` at `0.27.3`, each with the integrity `pnpm-lock.yaml` records +- **AND** the CLI's `package.json` SHALL still declare its ranges + +#### Scenario: Two versions of one dependency + +- **WHEN** the CLI's production tree in `pnpm-lock.yaml` holds the same package at two versions +- **THEN** the nightly and release builds SHALL fail and name that package + +### Requirement: A launcher installs from its lockfile once, completely + +When `.taskless/.cache/@/` does not exist, the launcher SHALL install `@` with `npm ci` from the lockfile beside it (`.taskless/bin/cli.lock.json` for the release, `.taskless/bin/cli-nightly.lock.json` for the nightly), without running any package's install scripts, into a new temporary directory under `.taskless/.cache/`. It SHALL make that the final directory only after npm succeeded and the package's entry point resolves. An existing final directory SHALL be treated as a complete install. When another process created the final directory first, the launcher SHALL discard its own install and run the existing one. + +The launcher SHALL NOT install without its lockfile, and SHALL NOT install when the lockfile does not name the recorded version; in both cases it SHALL print one message to standard error naming the lockfile and the installer command that rewrites it, and exit with a non-zero status. + +The install SHALL use the nearest `.npmrc` at or above the project root, short of the user's home directory (whose `.npmrc` npm already reads as user configuration), when one exists, so a registry configured there applies, and that file SHALL NOT remain in the cache afterwards. npm's output SHALL go to standard error, never standard output. + +#### Scenario: First run installs the tested tree, later runs do not install + +- **WHEN** `.taskless/.cache/` holds nothing for `@taskless/cli@0.13.0`, `.taskless/bin/cli.lock.json` pins `tsx` at `4.21.0`, and `./.taskless/bin/cli.mjs --version` runs twice +- **THEN** the first run SHALL install into `.taskless/.cache/@taskless/cli@0.13.0/` with `tsx` at `4.21.0` and only the current platform's binaries, and print `0.13.0` +- **AND** the second run SHALL start no `npm` process + +#### Scenario: Missing lockfile + +- **WHEN** `install.launchers` records `@taskless/cli` at `0.13.0` and `.taskless/bin/cli.lock.json` does not exist +- **THEN** no `npm` process SHALL start +- **AND** standard error SHALL name `.taskless/bin/cli.lock.json` and the installer command +- **AND** the exit status SHALL be non-zero + +#### Scenario: Lockfile for another version + +- **WHEN** `install.launchers` records `@taskless/cli` at `0.13.1` and `.taskless/bin/cli.lock.json` pins `@taskless/cli` at `0.13.0` +- **THEN** the launcher SHALL NOT install or run anything, and SHALL exit non-zero naming the lockfile + +#### Scenario: A registry configured above a monorepo package is used + +- **WHEN** `.taskless/` is in `packages/app/` and the repository root has an `.npmrc` setting `registry=` +- **THEN** the launcher's install SHALL use that `.npmrc` + +#### Scenario: A project registry is used + +- **WHEN** the project root has an `.npmrc` setting `registry=` and the launcher installs +- **THEN** npm SHALL fetch from that registry +- **AND** the cache entry SHALL NOT contain an `.npmrc` afterwards + +#### Scenario: JSON output stays clean on a first run + +- **WHEN** the first run of a version is `./.taskless/bin/cli.mjs info --json` +- **THEN** standard output SHALL hold only the CLI's JSON + +#### Scenario: Two first runs at once + +- **WHEN** two launcher processes start with no cache entry for the recorded version +- **THEN** both SHALL run the CLI +- **AND** exactly one cache entry for that version SHALL remain, with no temporary directory left by either + +### Requirement: A failed install runs nothing + +When npm cannot be started, or the install fails, or the installed package's entry point does not resolve, the launcher SHALL remove its temporary directory, leave no final directory, print a message to standard error naming the package and version it tried to install, and exit with a non-zero status without running the CLI. + +#### Scenario: npm is not on PATH + +- **WHEN** no `npm` executable can be found and the recorded version is not installed +- **THEN** standard error SHALL say npm is needed to install `@taskless/cli@0.13.0` +- **AND** the exit status SHALL be non-zero +- **AND** `.taskless/.cache/` SHALL hold no entry for that version + +#### Scenario: The registry refuses + +- **WHEN** npm exits non-zero +- **THEN** npm's error SHALL appear on standard error +- **AND** the CLI SHALL NOT run + +### Requirement: The launcher cache ignores itself + +Whenever the launcher creates `.taskless/.cache/`, or finds it without one, it SHALL write `.taskless/.cache/.gitignore` containing `*`, so nothing in the cache is committed without any change to the project's other ignore files. + +#### Scenario: A fresh cache is not tracked + +- **WHEN** the launcher installs into a project with no `.taskless/.cache/` +- **THEN** `git status` SHALL show nothing under `.taskless/.cache/` + +### Requirement: The launcher drops versions nothing records + +After the launcher installs a new version, it SHALL remove every `.taskless/.cache/` entry that is not the version recorded for either package, including the fallback it would use, and every temporary install directory more than an hour old. Removal SHALL be best effort: an entry that cannot be removed SHALL be left, and SHALL NOT change the launcher's exit status or output. + +#### Scenario: An upgrade pulled from git + +- **WHEN** `.taskless/.cache/` holds `@taskless/cli@0.13.0`, `install.launchers` now records `@taskless/cli` at `0.13.1`, and the launcher runs +- **THEN** it SHALL install `0.13.1` and run it +- **AND** `@taskless/cli@0.13.0` SHALL no longer be in the cache + +#### Scenario: The other package's version is kept + +- **WHEN** `install.launchers` records both packages and the release launcher installs a new release +- **THEN** the cache entry for the recorded nightly version SHALL remain From b774a4cd8e174b6330928ac3fcdae2dc0170adba Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 23:07:09 -0700 Subject: [PATCH 09/11] docs(agents): run /opsx:apply through to an open stack and report once --- AGENTS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e99f9b34..dec62000 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -345,4 +345,7 @@ Every scenario present before must still be present, plus whatever you added. Tw ## OpenSpec Apply -When implementing changes via `/opsx:apply`, **pause after each task group** for user review before continuing. Commit between groups and wait for confirmation. +When implementing changes via `/opsx:apply`, **work through every task group without pausing for review**, and report once, after the whole stack is done: every group implemented, the checks passing, the change archived, and each PR in the stack open. + +- **Commit after each task group**, one signed commit per group, so a reviewer can read the change a group at a time. Running `/opsx:apply` is the confirmation for these commits; the general rule to wait before committing does not apply to them. +- **Stop early only for a real blocker:** a task the artifacts leave ambiguous, an implementation finding that changes the spec or the design, or a failure you cannot resolve. Then say what you found and what you need. A finding you can resolve yourself, such as a bug the end-to-end check catches, is fixed, recorded in the artifacts, and reported with everything else at the end. From fd51389fc3c611c457625773255fd7d1e0992a6e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 23:24:02 -0700 Subject: [PATCH 10/11] fix(launcher): survive a missing home directory and prune past an unreadable temp entry homedir() can throw in a container with no HOME and no passwd entry; the .npmrc walk now runs to the filesystem root instead. prune() now contains each step's failure, as pruneLauncherCache does, so one unreadable .tmp-* entry no longer skips dropping superseded versions. --- packages/cli/assets/launcher/launcher.mjs | 54 ++++++++++++++--------- packages/cli/test/launcher.test.ts | 9 ++++ 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/packages/cli/assets/launcher/launcher.mjs b/packages/cli/assets/launcher/launcher.mjs index c5ac0d4a..e01a468e 100644 --- a/packages/cli/assets/launcher/launcher.mjs +++ b/packages/cli/assets/launcher/launcher.mjs @@ -124,7 +124,14 @@ function npm(args, options) { * path segments that look like tokens, such as a UUID. */ function projectNpmrc() { - const home = homedir(); + let home; + try { + home = homedir(); + } catch { + // No HOME and no passwd entry, as in a rootless container: walk to the + // filesystem root instead. + home = undefined; + } let directory = projectRoot; while (directory !== home) { const path = join(directory, ".npmrc"); @@ -243,27 +250,34 @@ function prune(install) { // Left for the next prune. } }; - try { - for (const name of readdirSync(cache)) { - const path = join(cache, name); - if ( - name.startsWith(".tmp-") && - Date.now() - statSync(path).mtimeMs > STALE_INSTALL_MS - ) { - remove(path); - } + // Pruning never decides whether the CLI runs, and one unreadable entry + // never stops the rest: each step contains its own failure, as + // pruneLauncherCache does. + const list = (directory) => { + try { + return readdirSync(directory); + } catch { + return []; } - for (const scope of new Set(PACKAGES.map((name) => name.split("/")[0]))) { - const directory = join(cache, scope); - if (!existsSync(directory)) continue; - for (const name of readdirSync(directory)) { - const entry = `${scope}/${name}`; - const ours = PACKAGES.some((pkg) => entry.startsWith(`${pkg}@`)); - if (ours && !keep.has(entry)) remove(join(directory, name)); - } + }; + for (const name of list(cache)) { + if (!name.startsWith(".tmp-")) continue; + const path = join(cache, name); + let modified; + try { + modified = statSync(path).mtimeMs; + } catch { + continue; + } + if (Date.now() - modified > STALE_INSTALL_MS) remove(path); + } + for (const scope of new Set(PACKAGES.map((name) => name.split("/")[0]))) { + const directory = join(cache, scope); + for (const name of list(directory)) { + const entry = `${scope}/${name}`; + const ours = PACKAGES.some((pkg) => entry.startsWith(`${pkg}@`)); + if (ours && !keep.has(entry)) remove(join(directory, name)); } - } catch { - // Pruning never decides whether the CLI runs. } } diff --git a/packages/cli/test/launcher.test.ts b/packages/cli/test/launcher.test.ts index 6fdfb469..5e152b39 100644 --- a/packages/cli/test/launcher.test.ts +++ b/packages/cli/test/launcher.test.ts @@ -7,6 +7,7 @@ import { readdir, rm, stat, + symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -353,6 +354,14 @@ describe.skipIf(process.platform === "win32")("project launcher", () => { ]); }); + it("still drops old versions when a temporary install cannot be read", async () => { + await mkdir(join(CACHE(), "@taskless", "cli@0.12.0"), { recursive: true }); + await symlink(join(root, "missing"), join(CACHE(), ".tmp-dangling")); + const path = await install("@taskless/cli", RELEASE, "0.13.0"); + expect(launch(path, []).status).toBe(0); + expect(await listing(join(CACHE(), "@taskless"))).toEqual(["cli@0.13.0"]); + }); + it.each([ ["no manifest", undefined], ["a manifest that does not parse", "{"], From 12fe3ec0e138dbb77c5dfcf5c15d085479cdd0a0 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 19 Oct 2026 23:28:47 -0700 Subject: [PATCH 11/11] test(launcher): cover the cmd.exe quoting for npm.cmd off Windows --- packages/cli/test/launcher.test.ts | 82 ++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/packages/cli/test/launcher.test.ts b/packages/cli/test/launcher.test.ts index 5e152b39..cfa21de7 100644 --- a/packages/cli/test/launcher.test.ts +++ b/packages/cli/test/launcher.test.ts @@ -13,6 +13,7 @@ import { import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import process from "node:process"; +import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { @@ -191,6 +192,23 @@ async function listing(directory: string): Promise { } const RELEASE = { install: { launchers: { "@taskless/cli": "0.13.0" } } }; +/** One argument as the launcher quotes it for a batch file such as npm.cmd. */ +function quoted(inner: string): string { + return `^^^"${inner}^^^"`; +} + +/** + * A stand-in for cmd.exe: it logs the arguments it was handed, then does what + * the real one would, which is to run npm, here the fake one. + */ +const FAKE_CMD = String.raw`#!/usr/bin/env node +const fs = require("node:fs"); +const { spawnSync } = require("node:child_process"); +fs.appendFileSync(process.env.FAKE_CMD_LOG, JSON.stringify(process.argv.slice(2)) + "\n"); +const npm = spawnSync(process.execPath, [process.env.FAKE_NPM_PATH, "ci"], { stdio: "inherit" }); +process.exit(npm.status ?? 1); +`; + describe.skipIf(process.platform === "win32")("project launcher", () => { it("installs the recorded version once, then runs it with every argument", async () => { const path = await install("@taskless/cli", RELEASE, "0.13.0"); @@ -272,6 +290,70 @@ describe.skipIf(process.platform === "win32")("project launcher", () => { ).toBe(1); }); + it("quotes npm's arguments for cmd.exe on Windows, and passes the CLI's untouched", async () => { + const path = await install("@taskless/cli", RELEASE, "0.13.0"); + // Pretend to be Windows, and stand in for cmd.exe, so the quoting runs + // anywhere. npm.cmd is a batch file and goes through cmd.exe; the CLI is + // started with node directly, so its arguments are never quoted. + const preload = join(root, "as-windows.mjs"); + await writeFile( + preload, + 'Object.defineProperty(process, "platform", { value: "win32" });\n' + ); + const comspec = join(fakeBin, "cmd"); + await writeFile(comspec, FAKE_CMD); + await chmod(comspec, 0o755); + const cmdLog = join(root, "cmd.log"); + + const result = spawnSync( + process.execPath, + [ + "--import", + pathToFileURL(preload).href, + path, + "check", + "src/a file.ts", + 'say "hi" & calc', + "C:\\dir\\", + ], + { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + comspec, + FAKE_CMD_LOG: cmdLog, + FAKE_NPM_PATH: join(fakeBin, "npm"), + FAKE_NPM_LOG: npmLog, + FAKE_CLI_SOURCE: FAKE_CLI, + }, + } + ); + expect(result.status).toBe(0); + const calls = await readFile(cmdLog, "utf8"); + // Each argument is quoted, then every metacharacter is escaped twice: + // once for cmd.exe and once for the batch file's own parse. + expect(JSON.parse(calls.trim())).toEqual([ + "/d", + "/s", + "/c", + `"${[ + "npm", + quoted("ci"), + quoted("--ignore-scripts"), + quoted("--no-audit"), + quoted("--no-fund"), + quoted("--loglevel=error"), + ].join(" ")}"`, + ]); + expect(reported(result.stdout).argv).toEqual([ + "check", + "src/a file.ts", + 'say "hi" & calc', + "C:\\dir\\", + ]); + }); + it("ends by the same signal that ended the CLI", async () => { const path = await install("@taskless/cli", RELEASE, "0.13.0"); const result = launch(path, ["check"], {