diff --git a/.changeset/nightly-alongside-release.md b/.changeset/nightly-alongside-release.md new file mode 100644 index 00000000..4755ded6 --- /dev/null +++ b/.changeset/nightly-alongside-release.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +A nightly `init` installs beside the release and no longer offers to move `@taskless/cli` pins in scripts, CI, or hooks onto `@taskless/cli-nightly`. It also keeps the release's recorded version when the manifest predates `install.launchers`, so `.taskless/bin/cli.mjs` still has a version to run. A release `init` now retires the nightly: it removes `.taskless/bin/cli-nightly.mjs` and its lockfile, drops the nightly's recorded version and permission rules, and names any `@taskless/cli-nightly` pin left in scripts, CI, or hooks. Any `package.json` dependency on the CLI is now raised for removal in favour of the project launcher, and `pinnedCli` entries carry a `reason`. diff --git a/openspec/changes/archive/2026-10-21-nightly-alongside-release/proposal.md b/openspec/changes/archive/2026-10-21-nightly-alongside-release/proposal.md new file mode 100644 index 00000000..658f759a --- /dev/null +++ b/openspec/changes/archive/2026-10-21-nightly-alongside-release/proposal.md @@ -0,0 +1,23 @@ +## Why + +Moving a production project from the nightly back to mainline showed the two channels are not symmetric in the way they should be. Installing a nightly should put it beside the release without switching what scripts, CI, and hooks run; installing the release should clean the nightly up. Neither held. A nightly `init` offered to move the project's `@taskless/cli` script, CI, and hook pins onto `@taskless/cli-nightly`, and on a project whose manifest predates `install.launchers` it overwrote the only record of the release's version, leaving `.taskless/bin/cli.mjs` with nothing to run. A release `init` left the nightly's launcher, lockfile, recorded version, and permission rules in place, and said nothing about a nightly pin newer than the release, since staleness alone never names a newer version. Separately, the CLI runs through the project launcher, yet any `package.json` dependency on it was judged only by version, so a current dependency, which drifts behind `.taskless/` on the next upgrade, went unmentioned. + +## What Changes + +- **A nightly installs beside the release.** It never offers to move a `@taskless/cli` pin in a script, CI file, or hook. When that nightly migrated an existing `.taskless/`, a release pin is still named, marked "leave as is", with a warning that a release predating the new schema refuses the project. A nightly install carries the release's version into `install.launchers` even when only `install.cliVersion` recorded it. +- **A release install retires the nightly.** It removes `.taskless/bin/cli-nightly.mjs` and `cli-nightly.lock.json`, drops the nightly's `install.launchers` entry (so its cached build is pruned), removes the nightly's rules from harness permission files, and raises every `@taskless/cli-nightly` pin in a script, CI file, or hook, whatever its version. `init --json` gains `retired`, the launcher files removed. The user-level global trust entry is not touched: other projects may still use the nightly. +- **A dependency on the CLI is raised whatever its version.** Any `@taskless/cli` or `@taskless/cli-nightly` entry in `dependencies`, `devDependencies`, or `optionalDependencies` is named for removal in favour of the project launcher. Version judgement now applies to script, CI, and hook lines only. +- **`pinnedCli` entries carry a `reason`**: `dependency`, `stale`, `nightly`, or `unreleased`. `findStalePins` becomes `findCliPins` and takes the run's migration. The notice is headed `TASKLESS CLI PINS`, gives each entry its action, and adds one paragraph per reason present. +- **Recipes**: `init` (v15), `update` (v20), and `info` (v4) explain the reasons and the release's retirement of the nightly. + +## Capabilities + +### Modified Capabilities + +- `cli-init`: the two pin requirements are replaced by "Init raises Taskless CLI pins"; "Each permission file holds exactly one Taskless rule" is replaced by "Each permission file holds one Taskless rule set per installed package"; the launcher and lockfile requirements gain the release's retirement of the nightly. +- `cli-project-launcher`: "Each package has its own launcher" lets a release remove the nightly's launcher. +- `cli`: "Info reports stale CLI pins" is restated for the new entry shape. + +## Delivery Shape + +**Single PR.** Detection, its notice, the JSON shape, the retirement, and the recipes that read them only make sense together. diff --git a/openspec/changes/archive/2026-10-21-nightly-alongside-release/specs/cli-init/spec.md b/openspec/changes/archive/2026-10-21-nightly-alongside-release/specs/cli-init/spec.md new file mode 100644 index 00000000..e9b4e305 --- /dev/null +++ b/openspec/changes/archive/2026-10-21-nightly-alongside-release/specs/cli-init/spec.md @@ -0,0 +1,277 @@ +## REMOVED Requirements + +### Requirement: Init names a package.json pin older than the running CLI + +**Reason**: Replaced by "Init raises Taskless CLI pins". A dependency on the CLI is now raised whatever its version, and a version spelled out in a command is judged by channel as well as by version, so "older than the running CLI" no longer describes what is reported. + +**Migration**: `pinnedCli` entries gain a `reason` field; every other field keeps its meaning. + +### Requirement: Init names a CI or hook pin older than the running CLI + +**Reason**: Folded into "Init raises Taskless CLI pins", which reads `package.json`, CI files, and hooks under one set of rules. + +**Migration**: None; CI and hook entries keep their `manifest` and `line ` location. + +### Requirement: Each permission file holds exactly one Taskless rule + +**Reason**: Replaced by "Each permission file holds one Taskless rule set per installed package". A release install now removes the nightly's rules, so the two packages no longer keep each other's rules in both directions. + +**Migration**: None for the release's rules. A release install removes `@taskless/cli-nightly` rules from the files it manages. + +## ADDED Requirements + +### Requirement: Init raises Taskless CLI pins + +After a successful install, `taskless init` SHALL read `package.json` in the working directory, the `package.json` of every workspace package the working directory declares, and the project's CI and git-hook configuration files, and report every entry naming `@taskless/cli` or `@taskless/cli-nightly` that this requirement raises, each with a `reason`. + +Workspace packages are declared by the `packages` list of `pnpm-workspace.yaml` and by the `workspaces` field of the root `package.json`, as an array or as `{ packages }`; both sources SHALL be read. A pattern SHALL name each directory it matches that holds a `package.json`. A pattern prefixed `!` SHALL remove the directories it matches. A pattern that is absolute or contains a `..` segment SHALL be skipped. Expansion SHALL NOT descend into `node_modules` or `.git`, and SHALL stop after a fixed number of manifests. The root SHALL be read once even when a pattern names it. An absent or malformed workspace declaration SHALL declare nothing, and a malformed root `package.json` SHALL NOT stop `pnpm-workspace.yaml` from being read. + +The CI and hook files read SHALL be the ones `detect` recognizes as CI and commit-time configuration at the project root: `.github/workflows/*.yml` and `*.yaml`, `.gitlab-ci.yml`, `.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml` and `.yaml`, `bitbucket-pipelines.yml`, `.drone.yml`, `.travis.yml`, the files directly in `.buildkite/` and `.husky/`, the lefthook, pre-commit, simple-git-hooks, and lint-staged config files. + +An entry is: + +- a dependency: an entry in `dependencies`, `devDependencies`, or `optionalDependencies`; or +- a command pin: a `package.json` script, or a line of a CI or hook file, that spells out `@taskless/cli@` or `@taskless/cli-nightly@`, where the name is not the tail of a longer name and the spec ends at whitespace, a quote, or shell punctuation (`;&|()<>,:`). A pin repeated within one script SHALL be reported once, and a pin repeated within one CI or hook file once, at its first line. + +Every dependency SHALL be reported with reason `dependency`, whatever its spec, its package, or the running channel, because the CLI runs through the project launcher and a dependency stays on the build its lockfile resolved. Its `installed` SHALL be the version read from `node_modules//package.json` in the directory holding it, then in each parent up to the working directory, the first found winning, as Node resolves it; `null` when none is found. + +A command pin SHALL be judged by the running channel first. The running CLI is a nightly when its version carries a prerelease, and a release otherwise. + +- Under a nightly, a `@taskless/cli` command pin SHALL NOT be reported, because a nightly installs beside the release, except that when the same run migrated an existing `.taskless/` (the migration's `from` is above `0`) it SHALL be reported with reason `unreleased`. +- Under a release, a `@taskless/cli-nightly` command pin SHALL be reported with reason `nightly`, whatever its version, because a release install is the move back to mainline. +- Any other command pin SHALL be reported with reason `stale` when its spec is bounded and cannot reach the running version. + +A bounded spec is an exact version (optionally prefixed `=` or `v`, prerelease, and build metadata), or a `^` or `~` range. An exact version SHALL be compared with semver precedence, so a prerelease sorts before its release and two prereleases of one base compare by their prerelease text, which orders nightlies by build time. A range SHALL be compared by its exclusive ceiling on the numeric core, with caret ranges holding the left-most non-zero part as npm does. A spec the CLI cannot bound (`latest`, `*`, a comparator range) SHALL NOT be reported as `stale`. An absent or unparseable file, or an unreadable installed manifest, SHALL produce no report from that source and SHALL NOT fail the install. + +Entries SHALL be reported root `package.json` first, then workspace manifests by path, then CI and hook files by path; within a `package.json`, dependency fields, then scripts. A `package.json` entry's `manifest` is that file's `/`-separated path relative to the working directory and its `location` the dependency field or `scripts.`; a CI or hook entry's `manifest` is the file's root-relative `/`-separated path, its `location` `line `, and its `installed` `null`. + +The install SHALL NOT modify any `package.json`, CI file, or hook. The report SHALL offer each change rather than claim it. + +On the human path (batch and wizard), when at least one entry is reported, the CLI SHALL print a notice, introduced without naming `package.json` alone, naming for each entry its manifest, location, package, spec, installed version when known, and what to do: remove the dependency for `dependency`; leave it as is for `unreleased`; for `stale` and `nightly`, the running version on the package that publishes it (`@taskless/cli-nightly` when the running version carries a prerelease, `@taskless/cli` otherwise), with the switch named when the pin is on the other package. The notice SHALL add one paragraph per reason present: for `dependency`, that Taskless runs through the project launcher, and the offer to remove it, point callers of its binary at the launcher, and reinstall dependencies; for `nightly`, that the pin keeps a prerelease after the move back to the release; for `unreleased`, that a nightly leaves release pins alone but a release predating the new schema refuses the project, and that the user should be told before committing `.taskless/`. Only a `dependency` entry SHALL ask for dependencies to be reinstalled. The notice SHALL print whether or not the run changed anything. On the batch path it SHALL print after the upgrade trailer, and the onboarding trailer SHALL remain the final line. + +When the same run migrated an existing `.taskless/` and a `stale` or `dependency` entry is reported, the notice SHALL NOT hedge. It SHALL name the schema versions the run moved between, state that a CLI predating the new schema refuses the project with `SCAFFOLD_VERSION_MISMATCH` so CI running those entries will break on the push carrying the migrated files, and say the change belongs in the same commit as `.taskless/`. A migration from `0` is how a fresh install creates `.taskless/`; it SHALL NOT be described as an upgrade, and like a run with no migration the notice SHALL describe a `stale` entry's failure as likely, not certain. + +Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ manifest, location, name, spec, installed, reason }`, `reason` one of `dependency`, `stale`, `nightly`, `unreleased`, present on every successful run and empty when nothing is raised. + +#### Scenario: A dependency and a stale script pin are named and left alone + +- **WHEN** `taskless init` runs at release `V` in a project whose `package.json` has `devDependencies["@taskless/cli"]` set, and a script running `npx @taskless/cli@` +- **THEN** stdout SHALL name the dependency for removal and the script with the target `@taskless/cli@V` +- **AND** `package.json` SHALL be byte-identical afterwards +- **AND** the notice SHALL appear after the upgrade trailer, with the onboarding trailer still the final line + +#### Scenario: A current dependency is still raised + +- **WHEN** `package.json` pins `@taskless/cli` at the running version, or at `latest`, `^0.11.0`, or `workspace:*` +- **THEN** it SHALL be reported with reason `dependency` + +#### Scenario: A stale workflow pin is named by file and line + +- **WHEN** `.github/workflows/taskless.yml` runs `npx @taskless/cli@0.11.0 check` on line 4 and the running CLI is `0.12.0` +- **THEN** `pinnedCli` SHALL contain `{ "manifest": ".github/workflows/taskless.yml", "location": "line 4", "name": "@taskless/cli", "spec": "0.11.0", "installed": null, "reason": "stale" }` +- **AND** the workflow file SHALL be byte-identical afterwards + +#### Scenario: Hook files are read + +- **WHEN** `.husky/pre-commit` or `lefthook.yml` spells out an older `@taskless/cli` or `@taskless/cli-nightly` version +- **THEN** each SHALL be reported with its path + +#### Scenario: An older nightly pin is stale under a nightly + +- **WHEN** a script pins `@taskless/cli-nightly` at `0.12.0-20260901000000xaaaaaaa` and the running CLI is `0.12.0-20261005000000xbbbbbbb` +- **THEN** it SHALL be reported with reason `stale` + +#### Scenario: A nightly leaves a release pin alone + +- **WHEN** a nightly runs `init` without migrating an existing `.taskless/`, and a script, CI file, or hook pins `@taskless/cli` at any version +- **THEN** that pin SHALL NOT be reported +- **AND** no notice SHALL offer to move a `@taskless/cli` pin onto `@taskless/cli-nightly` + +#### Scenario: A nightly migration names the release pin without moving it + +- **WHEN** a nightly `init` migrates an existing `.taskless/` from schema `M` above `0` to `N`, and a script pins `@taskless/cli` +- **THEN** that pin SHALL be reported with reason `unreleased`, its action "leave as is" +- **AND** the notice SHALL state that a release predating schema `N` refuses the project, and that the user should be told before committing `.taskless/` + +#### Scenario: A release install raises a newer nightly pin + +- **WHEN** a release `V` runs `init` and a CI file pins `@taskless/cli-nightly` at a version newer than `V` +- **THEN** it SHALL be reported with reason `nightly` and the target `@taskless/cli@V`, naming the package switch + +#### Scenario: A floating or current command pin is not reported + +- **WHEN** a script or CI file runs `@taskless/cli@latest`, or `@taskless/cli@` +- **THEN** no entry SHALL be reported for it + +#### Scenario: A migration of an existing scaffold makes the breakage definite + +- **WHEN** `taskless init` migrates an existing `.taskless/` from schema version `M` above `0` to `N` in a project with a `stale` or `dependency` entry +- **THEN** the notice SHALL name schema versions `M` and `N` and `SCAFFOLD_VERSION_MISMATCH` +- **AND** SHALL state that CI running the entries will break, and that the change belongs in the same commit as `.taskless/` +- **AND** SHALL NOT describe the failure as merely likely + +#### Scenario: A fresh install is not called an upgrade + +- **WHEN** `taskless init` creates `.taskless/` in a project with a `stale` script pin +- **THEN** the notice SHALL describe the failure as likely +- **AND** SHALL NOT mention `SCAFFOLD_VERSION_MISMATCH` or describe the run as an upgrade + +#### Scenario: An entry is named on a re-install that changed nothing + +- **WHEN** `taskless init` runs against a project that is already current and holds a raised entry +- **THEN** stdout SHALL NOT contain the upgrade trailer +- **AND** stdout SHALL name the entry + +#### Scenario: The JSON envelope carries the entries + +- **WHEN** `taskless init --json` runs +- **THEN** the envelope SHALL contain `pinnedCli`, one `{ manifest, location, name, spec, installed, reason }` entry per raised entry +- **AND** `pinnedCli` SHALL be an empty array when nothing is raised + +#### Scenario: A pin in a declared workspace package is named with its manifest + +- **WHEN** `pnpm-workspace.yaml` declares `packages/*`, or the root `package.json` declares `workspaces` as `["packages/*"]` or `{ "packages": ["packages/*"] }`, and `packages/app/package.json` has `devDependencies["@taskless/cli"]` +- **THEN** the entry SHALL be reported with `manifest` set to `packages/app/package.json` +- **AND** the notice line SHALL name `packages/app/package.json` and the field + +#### Scenario: A workspace dependency names the version it runs + +- **WHEN** `packages/linked/package.json` and `packages/hoisted/package.json` both depend on `@taskless/cli`, `packages/linked/node_modules/@taskless/cli` is `0.11.0`, and only the root `node_modules/@taskless/cli` exists for `packages/hoisted` at `0.11.1` +- **THEN** `packages/linked/package.json` SHALL be reported with `installed` `0.11.0` +- **AND** `packages/hoisted/package.json` SHALL be reported with `installed` `0.11.1` + +#### Scenario: Workspace expansion stays inside the project + +- **WHEN** a workspace pattern is negated, is absolute, climbs out with `..`, or is `**` over a tree that contains `node_modules` +- **THEN** a directory the negation matches SHALL NOT be reported +- **AND** nothing outside the working directory, and no `package.json` under `node_modules`, SHALL be read as a workspace package + +### Requirement: Each permission file holds one Taskless rule set per installed package + +Each harness permission file `init` manages SHALL hold, afterwards, the installed invocation's package's rules: the rules for the installed invocation, from the table in the requirement that init allows the installed CLI, and the rule for its launcher, from the requirement that init allows the installed launcher. A Taskless rule is an allow entry, in that harness's format, whose command is an invocation of `@taskless/cli` or `@taskless/cli-nightly` through `npx` or `pnpm dlx`, at any version, with or without a version, or whose command is a launcher path `./.taskless/bin/cli.mjs` or `./.taskless/bin/cli-nightly.mjs`. A rule belongs to the package its command names; `cli.mjs` belongs to `@taskless/cli` and `cli-nightly.mjs` to `@taskless/cli-nightly`. For Claude Code this includes both the `Bash( *)` form and the `Bash(:*)` form. On every `init` the installer SHALL remove each Taskless rule of the installed invocation's package that is not one of the installed invocation's rules, whether or not the manifest recorded it, and write the current ones. + +The two packages are not symmetric. A nightly `init` SHALL NOT remove a `@taskless/cli` rule, so a nightly installs beside the release. A release `init` is the move back to mainline: it SHALL remove every `@taskless/cli-nightly` rule in the files it visits, whether or not the manifest recorded it. An invocation that names neither package, such as a `self` build's path, SHALL remove no Taskless rule. The installer SHALL NOT remove or edit a deny entry, or any entry that is not a Taskless rule. + +The manifest SHALL record, under `install.permissions`, each file Taskless wrote a rule to, the exact rule entries it added, and whether Taskless created the file. The record decides only whether a file may be deleted: a file Taskless created SHALL be deleted when removing its entries leaves it holding nothing else; a file that existed before SHALL never be deleted. A recorded file for a harness no longer selected SHALL have the installed package's Taskless rules removed, and, on a release `init`, the nightly's. A nightly `init` SHALL keep the release's recorded rules, so a file Taskless created is deleted once the last package's rules leave it. + +#### Scenario: An upgrade swaps the version + +- **WHEN** the manifest records `Bash(npx @taskless/cli@0.13.0 *)` in `.claude/settings.json` and `init` runs at `0.13.1` +- **THEN** the file SHALL contain `Bash(npx @taskless/cli@0.13.1 *)` and not the `0.13.0` rule +- **AND** the manifest SHALL record only the `0.13.1` rule + +#### Scenario: An unrecorded Taskless rule is replaced + +- **WHEN** `.claude/settings.json` also holds `Bash(npx @taskless/cli@0.12.0 *)`, which the manifest does not record +- **THEN** an upgrade SHALL remove that entry +- **AND** the file SHALL hold exactly one Taskless rule, for the installed invocation + +#### Scenario: The colon form the skill proposed is replaced + +- **WHEN** `.claude/settings.json` holds `Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23:*)` and a nightly `init` runs +- **THEN** `init` SHALL remove it and write the installed invocation's rule + +#### Scenario: Other entries are untouched + +- **WHEN** `.claude/settings.json` holds `Bash(npx prettier *)` and a deny entry `Bash(npx @taskless/cli@0.11.0 *)` +- **THEN** `init` SHALL leave both entries in place + +#### Scenario: A pre-existing file is never deleted + +- **WHEN** removing Taskless rules from a `.claude/settings.json` that Taskless did not create leaves its `permissions.allow` empty +- **THEN** the file SHALL remain + +#### Scenario: A nightly installs beside the release's rules + +- **WHEN** `init` at `npx @taskless/cli@0.13.0` has written its rule, and a nightly `init` then runs +- **THEN** `.claude/settings.json` SHALL hold the release's rule and the nightly's rule +- **AND** running the nightly's `init` again SHALL report no edit + +#### Scenario: A release install removes the nightly's rules + +- **WHEN** `.claude/settings.json` holds the release's rules and a nightly's, recorded or not, and the release's `init` runs +- **THEN** every `@taskless/cli-nightly` rule, including `Bash(./.taskless/bin/cli-nightly.mjs *)`, SHALL be removed and reported as removed +- **AND** the release's rules SHALL remain + +#### Scenario: A nightly upgrade swaps only the nightly's rule + +- **WHEN** `.claude/settings.json` holds the release's rule and an older nightly's rule, and a newer nightly `init` runs +- **THEN** the older nightly rule SHALL be removed and the newer one written +- **AND** the release's rule SHALL remain + +#### Scenario: A created file outlives the nightly's removal + +- **WHEN** Taskless created `.claude/settings.json` holding the release's and a nightly's rules, and the nightly's `init` runs with `.claude/` no longer selected +- **THEN** the file SHALL remain, holding the release's rule +- **AND** once the release's `init` runs the same way, the file SHALL be deleted + +#### Scenario: Removing a harness removes its launcher rule + +- **WHEN** the manifest records `Bash(./.taskless/bin/cli-nightly.mjs *)` in `.claude/settings.json` and the nightly's `init` runs with `.claude/` no longer selected +- **THEN** that rule SHALL be removed with the nightly's other Taskless rules +- **AND** `Bash(./.taskless/bin/cli.mjs *)` SHALL remain + +## MODIFIED Requirements + +### Requirement: Init installs the package's launcher and records its version + +Every `init` that writes the canonical skill, in the wizard and in `--no-interactive`, SHALL also write this build's launcher, as defined by the `cli-project-launcher` capability, and record this build's version for its package under `install.launchers` in `.taskless/taskless.json`, keyed by package name. `install.cliVersion` SHALL keep recording the build that last installed. + +A nightly `init` SHALL keep the release's recorded version. Where the release's version was recorded only as `install.cliVersion` (a manifest written before `install.launchers` existed), the nightly `init` SHALL carry it into `install.launchers` before overwriting `install.cliVersion`, so `.taskless/bin/cli.mjs` keeps a version to run. + +A release `init` is the move back to mainline, and SHALL retire the nightly: it SHALL drop `@taskless/cli-nightly` from `install.launchers`, and remove `.taskless/bin/cli-nightly.mjs` and `.taskless/bin/cli-nightly.lock.json` when they exist. The cached nightly build is then pruned, since nothing records it. Each file removed SHALL be named in the install summary and listed in `init --json` as `retired`, an array of root-relative paths present on every successful run and empty when nothing was removed, and a removal SHALL count as a change. The release `init` SHALL NOT touch the user-level global trust entry, which other projects may still use. + +A launcher whose bytes differ from this build's launcher SHALL be rewritten, and the install summary SHALL name it as restored, since a launcher runs whatever it contains under any rule that allows it. A `self` build SHALL write no launcher, record no version under `install.launchers`, and retire nothing. + +No scaffold migration SHALL be added for this: the launcher and its record are install output, written by the same step as the canonical skill. + +#### Scenario: A release install records its version + +- **WHEN** `init` runs from the release at `0.13.0` in a project with no `install.launchers` +- **THEN** `install.launchers` SHALL be `{ "@taskless/cli": "0.13.0" }` +- **AND** `install.cliVersion` SHALL be `0.13.0` + +#### Scenario: A nightly install keeps the release's version + +- **WHEN** `install.launchers` is `{ "@taskless/cli": "0.13.0" }` and a nightly `init` runs at `0.14.0-20261020120000xabc1234` +- **THEN** `install.launchers` SHALL be `{ "@taskless/cli": "0.13.0", "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234" }` +- **AND** `install.cliVersion` SHALL be `0.14.0-20261020120000xabc1234` + +#### Scenario: A nightly install keeps a release version only cliVersion recorded + +- **WHEN** `.taskless/taskless.json` records `install.cliVersion` `0.13.0` and no `install.launchers`, and a nightly `init` runs at `0.14.0-20261020120000xabc1234` +- **THEN** `install.launchers` SHALL be `{ "@taskless/cli": "0.13.0", "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234" }` + +#### Scenario: A release install retires the nightly + +- **WHEN** `install.launchers` records `@taskless/cli-nightly`, `.taskless/bin/cli-nightly.mjs` and `cli-nightly.lock.json` exist, and the release's `init` runs at `0.13.0` +- **THEN** `install.launchers` SHALL be `{ "@taskless/cli": "0.13.0" }` +- **AND** both nightly files SHALL be gone and listed in `retired` +- **AND** the nightly's cache entry SHALL be pruned + +#### Scenario: An edited launcher is restored and reported + +- **WHEN** `.taskless/bin/cli.mjs` was edited and the release's `init` runs +- **THEN** the file SHALL hold this build's launcher afterwards +- **AND** the summary SHALL name `.taskless/bin/cli.mjs` as restored + +### 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. A nightly `init` SHALL NOT write, change or remove the release's lockfile. A release `init` SHALL NOT write or change the nightly's lockfile, and SHALL remove it as part of retiring the nightly, under the requirement that init installs the package's launcher. 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 diff --git a/openspec/changes/archive/2026-10-21-nightly-alongside-release/specs/cli-project-launcher/spec.md b/openspec/changes/archive/2026-10-21-nightly-alongside-release/specs/cli-project-launcher/spec.md new file mode 100644 index 00000000..6fbe2fbc --- /dev/null +++ b/openspec/changes/archive/2026-10-21-nightly-alongside-release/specs/cli-project-launcher/spec.md @@ -0,0 +1,30 @@ +## MODIFIED Requirements + +### Requirement: Each package has its own launcher + +The release package SHALL install its launcher at `.taskless/bin/cli.mjs`, and the nightly package at `.taskless/bin/cli-nightly.mjs`. A build SHALL write and rewrite only its own package's launcher. A nightly build SHALL NEVER read, edit, or remove the release's launcher, so a nightly installs beside the release. A release build SHALL NOT read or edit the nightly's launcher, and SHALL remove it when it installs, because installing the release is the move back to mainline. A build whose invocation names neither package, such as a `self` build, SHALL write no launcher and remove none. + +Each launcher SHALL be an ES module (`.mjs`) whose first line is `#!/usr/bin/env node`, and SHALL be written with its executable bits set where the filesystem supports them. Its content SHALL be identical for every version of its package, so an upgrade never changes its bytes unless the launcher itself changed. + +#### Scenario: The release installs cli.mjs + +- **WHEN** `init` runs from the release package +- **THEN** `.taskless/bin/cli.mjs` SHALL exist, begin with `#!/usr/bin/env node`, and be executable +- **AND** `.taskless/bin/cli-nightly.mjs` SHALL NOT be created + +#### Scenario: A nightly leaves the release's launcher alone + +- **WHEN** `.taskless/bin/cli.mjs` exists and `init` runs from a nightly +- **THEN** `.taskless/bin/cli.mjs` SHALL be byte-identical afterwards +- **AND** `.taskless/bin/cli-nightly.mjs` SHALL exist + +#### Scenario: The release removes the nightly's launcher + +- **WHEN** `.taskless/bin/cli-nightly.mjs` exists and `init` runs from the release +- **THEN** `.taskless/bin/cli-nightly.mjs` SHALL NOT exist afterwards +- **AND** `.taskless/bin/cli.mjs` SHALL exist + +#### Scenario: An upgrade does not rewrite an unchanged launcher + +- **WHEN** `init` runs at `0.13.1` over a project installed at `0.13.0`, and the launcher template did not change between them +- **THEN** the launcher's bytes SHALL be unchanged diff --git a/openspec/changes/archive/2026-10-21-nightly-alongside-release/specs/cli/spec.md b/openspec/changes/archive/2026-10-21-nightly-alongside-release/specs/cli/spec.md new file mode 100644 index 00000000..8b07f4f3 --- /dev/null +++ b/openspec/changes/archive/2026-10-21-nightly-alongside-release/specs/cli/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Info reports stale CLI pins + +`taskless info` SHALL report the same entries `taskless init` reports under "Init raises Taskless CLI pins", judged against the running version, read from the working directory, its declared workspace packages, and its CI and hook files by the same rules. `info` runs no migration, so it SHALL never report reason `unreleased`. Under `--json` the payload SHALL carry `pinnedCli` in the shape `init --json` uses, `{ manifest, location, name, spec, installed, reason }`, present on every successful run and empty when nothing is raised. Without `--json`, when at least one entry is reported, the output SHALL list each entry with its manifest, location, spec, installed version when known, and what to do with it. + +`info` is read-only: it SHALL NOT modify any `package.json`, CI file, or hook, and an unreadable file SHALL NOT make it fail. `--anonymous` SHALL NOT suppress the entries, since they are local state. + +The `update` recipe SHALL read the entries from `info --json`, the read-only command it already runs, rather than directing an agent to re-run `init`. + +#### Scenario: info --json carries a workspace package's stale pin + +- **WHEN** `taskless info --json` runs in a project whose root `package.json` declares `workspaces: ["packages/*"]` and `packages/app/package.json` has `devDependencies["@taskless/cli"]` +- **THEN** `pinnedCli` SHALL contain `{ manifest: "packages/app/package.json", location: "devDependencies", name: "@taskless/cli", spec, installed: null, reason: "dependency" }` + +#### Scenario: info --json reports no pins as an empty list + +- **WHEN** `taskless info --json` runs where nothing is raised +- **THEN** `pinnedCli` SHALL be an empty array + +#### Scenario: Plain info lists the pins + +- **WHEN** `taskless info` runs without `--json` and an entry is raised +- **THEN** stdout SHALL name the entry's manifest, location, and spec, and what to do with it diff --git a/openspec/changes/archive/2026-10-21-nightly-alongside-release/tasks.md b/openspec/changes/archive/2026-10-21-nightly-alongside-release/tasks.md new file mode 100644 index 00000000..b4787c2a --- /dev/null +++ b/openspec/changes/archive/2026-10-21-nightly-alongside-release/tasks.md @@ -0,0 +1,15 @@ +## 1. Pins + +- [x] 1.1 In `install/pinned-cli.ts`, raise every dependency as `dependency`; judge script, CI, and hook lines by channel (`nightly`, `unreleased`, or `stale`). Rename `findStalePins` to `findCliPins` and pass the run's migration from `init` and the wizard. +- [x] 1.2 Rewrite `getPinnedCliNotice` with a per-entry action and one paragraph per reason; add `reason` to the `pinnedCli` schema and relabel plain `info`. + +## 2. Launchers and permissions + +- [x] 2.1 In `applyInstallPlan`, carry both packages' versions from the previous record, including one only `cliVersion` implied; on a release, drop the nightly's entry and remove its launcher and lockfile, reported as `retired`. +- [x] 2.2 In `syncPermissions`, have a release invocation remove the nightly's rules. + +## 3. Recipes and tests + +- [x] 3.1 `init`, `update`, `info`: describe `reason` and `retired`. +- [x] 3.2 Tests for pins, retirement (release and nightly projects), and permissions. +- [x] 3.3 `pnpm typecheck`, `pnpm lint`, and the CLI test suite pass. diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index af216ebb..78f392f0 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -695,111 +695,6 @@ If the user cancels the wizard at any step (Ctrl-C, Esc, or equivalent clack can - **THEN** no files SHALL be written - **AND** the CLI SHALL exit non-zero -### Requirement: Init names a package.json pin older than the running CLI - -After a successful install, `taskless init` SHALL read `package.json` in the working directory, and the `package.json` of every workspace package the working directory declares, and report every pin of `@taskless/cli` or `@taskless/cli-nightly` that would run a CLI older than the running one. - -Workspace packages are declared by the `packages` list of `pnpm-workspace.yaml` and by the `workspaces` field of the root `package.json`, as an array or as `{ packages }`; both sources SHALL be read. A pattern SHALL name each directory it matches that holds a `package.json`. A pattern prefixed `!` SHALL remove the directories it matches. A pattern that is absolute or contains a `..` segment SHALL be skipped. Expansion SHALL NOT descend into `node_modules` or `.git`, and SHALL stop after a fixed number of manifests. The root SHALL be read once even when a pattern names it. An absent or malformed workspace declaration SHALL declare nothing, and a malformed root `package.json` SHALL NOT stop `pnpm-workspace.yaml` from being read. - -A pin is: - -- an entry in `dependencies`, `devDependencies`, or `optionalDependencies`; or -- a script in `scripts` that spells out `@taskless/cli@` or `@taskless/cli-nightly@`, where the name is not the tail of a longer name and the spec ends at whitespace, a quote, or shell punctuation (`;&|()<>,:`). A pin repeated within one script SHALL be reported once. - -A dependency pin SHALL be reported when either holds: - -- the version installed for that package is older than the running version, because that, and the lockfile it came from, is what runs. It SHALL be read from `node_modules//package.json` in the directory holding the pin, then in each parent up to the working directory, the first found winning, as Node resolves it: a workspace package's own link under pnpm, the hoisted root copy under npm or yarn; or -- its spec is bounded and cannot reach the running version. - -A script pin SHALL be reported when its spec is bounded and cannot reach the running version. - -A bounded spec is an exact version (optionally prefixed `=` or `v`, with optional whitespace after the operator, prerelease, and build metadata), or a `^` or `~` range. An exact version, and an installed version, SHALL be compared with semver precedence, so a prerelease sorts before its release and two prereleases of one base compare by their prerelease text, which orders nightlies by build time. A range SHALL be compared by its exclusive ceiling on the numeric core, with caret ranges holding the left-most non-zero part as npm does. A spec the CLI cannot bound (`latest`, `*`, a comparator range, `workspace:`, a URL or git spec) SHALL NOT be reported on its own. An absent or unparseable `package.json`, or an unreadable installed manifest, SHALL produce no report from that source and SHALL NOT fail the install. - -Pins SHALL be reported root first, then by workspace manifest path, each manifest's in file order: dependency fields, then scripts. - -The install SHALL NOT modify any `package.json`. The report SHALL offer the bump rather than claim it. - -On the human path (batch and wizard), when at least one pin is reported, the CLI SHALL print a notice naming, for each pin, its manifest path relative to the working directory, its location in that manifest (the dependency field, or `scripts.`), package, spec, installed version when known, and the package and version to move it to. The target SHALL be the running version on the package that publishes it: `@taskless/cli-nightly` when the running version carries a prerelease, `@taskless/cli` otherwise, with the switch named when the pin is on the other package. The notice SHALL print whether or not the run changed anything. On the batch path it SHALL print after the upgrade trailer, and the onboarding trailer SHALL remain the final line. - -When the same run migrated an existing `.taskless/` (the migration's `from` is above `0`), the notice SHALL NOT hedge. It SHALL name the schema versions the run moved between, state that a CLI predating the new schema refuses the project with `SCAFFOLD_VERSION_MISMATCH` so CI running the pins will break on the push carrying the migrated files, and say the bump belongs in the same commit as `.taskless/`. A migration from `0` is how a fresh install creates `.taskless/`; it SHALL NOT be described as an upgrade, and like a run with no migration the notice SHALL describe the failure as likely, not certain. - -Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ manifest, location, name, spec, installed }`, where `manifest` is the `/`-separated path of the `package.json` holding the pin relative to the working directory, `installed` is the installed version or `null`, present on every successful run and empty when nothing is stale. - -#### Scenario: A stale dependency and script pin are named and left alone - -- **WHEN** `taskless init` runs at version `V` in a project whose `package.json` has `devDependencies["@taskless/cli"]` set to a version below `V`, and a script running `npx @taskless/cli@` -- **THEN** stdout SHALL name both pins with their location and spec, and the target `@taskless/cli@V` -- **AND** `package.json` SHALL be byte-identical afterwards -- **AND** the notice SHALL appear after the upgrade trailer, with the onboarding trailer still the final line - -#### Scenario: An installed build older than the running CLI is stale even when its range admits the running version - -- **WHEN** `package.json` pins `@taskless/cli` at `^0.11.0`, `node_modules/@taskless/cli` is `0.11.0`, and the running CLI is `0.11.2` -- **THEN** the pin SHALL be reported with `installed` set to `0.11.0` - -#### Scenario: A pre-1.0 caret range that cannot reach the running version is stale - -- **WHEN** `package.json` pins `@taskless/cli` at `^0.10.2`, nothing is installed, and the running CLI is `0.11.2` -- **THEN** the pin SHALL be reported - -#### Scenario: An older nightly of the same base is stale - -- **WHEN** `package.json` pins `@taskless/cli-nightly` at `0.12.0-20260901000000xaaaaaaa` and the running CLI is `0.12.0-20261005000000xbbbbbbb` or `0.12.0` -- **THEN** the pin SHALL be reported - -#### Scenario: A nightly pin moves to the release package when a release is running - -- **WHEN** a stale pin names `@taskless/cli-nightly` and the running CLI is a release `V` -- **THEN** the notice SHALL give `@taskless/cli@V` as the target and name the package switch - -#### Scenario: A floating or current pin is not reported - -- **WHEN** nothing older than the running version is installed, and `package.json` pins `@taskless/cli` at `latest`, `*`, `>=0.10.0`, `workspace:*`, or a range that admits the running version, or a script runs `@taskless/cli@latest` -- **THEN** no pin SHALL be reported - -#### Scenario: A migration of an existing scaffold makes the breakage definite - -- **WHEN** `taskless init` migrates an existing `.taskless/` from schema version `M` above `0` to `N` in a project with a stale pin -- **THEN** the notice SHALL name schema versions `M` and `N` and `SCAFFOLD_VERSION_MISMATCH` -- **AND** SHALL state that CI running the pins will break, and that the bump belongs in the same commit as `.taskless/` -- **AND** SHALL NOT describe the failure as merely likely - -#### Scenario: A fresh install is not called an upgrade - -- **WHEN** `taskless init` creates `.taskless/` in a project with a stale pin -- **THEN** the notice SHALL describe the failure as likely -- **AND** SHALL NOT mention `SCAFFOLD_VERSION_MISMATCH` or describe the run as an upgrade - -#### Scenario: A stale pin is named on a re-install that changed nothing - -- **WHEN** `taskless init` runs against a project that is already current and whose `package.json` holds a stale pin -- **THEN** stdout SHALL NOT contain the upgrade trailer -- **AND** stdout SHALL name the stale pin - -#### Scenario: The JSON envelope carries the pins - -- **WHEN** `taskless init --json` runs -- **THEN** the envelope SHALL contain `pinnedCli`, an array with one `{ manifest, location, name, spec, installed }` entry per stale pin -- **AND** `pinnedCli` SHALL be an empty array when there is no `package.json` or nothing in it is stale - -#### Scenario: A pin in a declared workspace package is named with its manifest - -- **WHEN** `pnpm-workspace.yaml` declares `packages/*`, or the root `package.json` declares `workspaces` as `["packages/*"]` or `{ "packages": ["packages/*"] }`, and `packages/app/package.json` pins `@taskless/cli` at a version below the running one -- **THEN** the pin SHALL be reported with `manifest` set to `packages/app/package.json` -- **AND** the notice line SHALL name `packages/app/package.json` and the field - -#### Scenario: A workspace package is judged by the version it runs - -- **WHEN** `packages/linked/package.json` and `packages/hoisted/package.json` both pin `@taskless/cli` at `^0.11.0`, `packages/linked/node_modules/@taskless/cli` is `0.11.0`, only the root `node_modules/@taskless/cli` exists for `packages/hoisted` at `0.11.1`, and the running CLI is `0.11.2` -- **THEN** `packages/linked/package.json` SHALL be reported with `installed` `0.11.0` -- **AND** `packages/hoisted/package.json` SHALL be reported with `installed` `0.11.1` - -#### Scenario: Workspace expansion stays inside the project - -- **WHEN** a workspace pattern is negated, is absolute, climbs out with `..`, or is `**` over a tree that contains `node_modules` -- **THEN** a directory the negation matches SHALL NOT be reported -- **AND** nothing outside the working directory, and no `package.json` under `node_modules`, SHALL be read as a workspace package - ### Requirement: An upgrade removes the retired tskl command Taskless no longer ships a slash command. The install manifest SHALL keep recording a `commands` list per target, so that an install over an older one removes any `tskl.md` command the prior manifest recorded, from the canonical `.taskless/` store and from each tool directory. Each removal SHALL be confined to the entries the prior manifest recorded for that target. @@ -833,31 +728,6 @@ The `taskless init --json` envelope SHALL report each removed command in that ta - **WHEN** `taskless init --json` removes a recorded command - **THEN** the affected target's `removedCommands` SHALL list `tskl.md` -### Requirement: Init names a CI or hook pin older than the running CLI - -Alongside the `package.json` pins it already reports, `taskless init` SHALL read the project's CI and git-hook configuration files and report every pin of `@taskless/cli` or `@taskless/cli-nightly` in them that would run a CLI older than the running one. Every build pins its own version, so the invocation the `ci` and `hooks` recipes write names that version, and an upgrade leaves it behind. - -The files read SHALL be the ones `detect` recognizes as CI and commit-time configuration at the project root: `.github/workflows/*.yml` and `*.yaml`, `.gitlab-ci.yml`, `.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml` and `.yaml`, `bitbucket-pipelines.yml`, `.drone.yml`, `.travis.yml`, the files directly in `.buildkite/` and `.husky/`, the lefthook, pre-commit, simple-git-hooks, and lint-staged config files. They SHALL be read as text with the script-pin grammar, a pin repeated in one file SHALL be reported once at its first line, and only a bounded spec SHALL be judged, as for a script. - -Each pin SHALL be reported through the same notice and the same `pinnedCli` envelope entry as a `package.json` pin, with `manifest` the file's root-relative `/`-separated path, `location` `line `, and `installed` `null`, after every `package.json` pin, ordered by path. The notice SHALL introduce its list without naming `package.json` alone. The install SHALL NOT modify any of these files. - -#### Scenario: A stale workflow pin is named by file and line - -- **WHEN** `.github/workflows/taskless.yml` runs `npx @taskless/cli@0.11.0 check` on line 4 and the running CLI is `0.12.0` -- **THEN** `pinnedCli` SHALL contain `{ "manifest": ".github/workflows/taskless.yml", "location": "line 4", "name": "@taskless/cli", "spec": "0.11.0", "installed": null }` -- **AND** the notice SHALL name `.github/workflows/taskless.yml line 4` and the target `@taskless/cli@0.12.0` -- **AND** the workflow file SHALL be byte-identical afterwards - -#### Scenario: Hook files are read - -- **WHEN** `.husky/pre-commit` or `lefthook.yml` spells out an older `@taskless/cli` or `@taskless/cli-nightly` version -- **THEN** each SHALL be reported with its path - -#### Scenario: A current or floating CI pin is not reported - -- **WHEN** a CI file runs `@taskless/cli` at the running version or at `latest` -- **THEN** no pin SHALL be reported for it - ### Requirement: Init allows the installed CLI in each harness's permission file `taskless init`, in the wizard and in `--no-interactive`, SHALL write a rule allowing exactly the installed CLI invocation to the project permission file of each harness it installs the skill for, from this table: @@ -1013,68 +883,15 @@ Gemini CLI SHALL be detected when a `.gemini/` directory or a `GEMINI.md` file e - **THEN** Crush SHALL be detected - **AND** skills SHALL be installed to `.agents/skills/` -### Requirement: Each permission file holds exactly one Taskless rule - -Each harness permission file `init` manages SHALL hold exactly one Taskless rule set per package afterwards: for the installed invocation's package, the rules for the installed invocation, from the table in the requirement that init allows the installed CLI, and the rule for its launcher, from the requirement that init allows the installed launcher. A Taskless rule is an allow entry, in that harness's format, whose command is an invocation of `@taskless/cli` or `@taskless/cli-nightly` through `npx` or `pnpm dlx`, at any version, with or without a version, or whose command is a launcher path `./.taskless/bin/cli.mjs` or `./.taskless/bin/cli-nightly.mjs`. A rule belongs to the package its command names; `cli.mjs` belongs to `@taskless/cli` and `cli-nightly.mjs` to `@taskless/cli-nightly`. For Claude Code this includes both the `Bash( *)` form and the `Bash(:*)` form. On every `init` the installer SHALL remove each Taskless rule of the installed invocation's package that is not one of the installed invocation's rules, whether or not the manifest recorded it, and write the current ones. It SHALL NOT remove a Taskless rule of the other package, so the release and a nightly installed in one project keep each other's rules. An invocation that names neither package, such as a `self` build's path, SHALL remove no Taskless rule. It SHALL NOT remove or edit a deny entry, or any entry that is not a Taskless rule. - -The manifest SHALL record, under `install.permissions`, each file Taskless wrote a rule to, the exact rule entries it added, and whether Taskless created the file. The record decides only whether a file may be deleted: a file Taskless created SHALL be deleted when removing its entries leaves it holding nothing else; a file that existed before SHALL never be deleted. A recorded file for a harness no longer selected SHALL have the installed package's Taskless rules removed. The record SHALL keep the other package's recorded rules, so a file Taskless created is deleted once the last package's rules leave it. - -#### Scenario: An upgrade swaps the version - -- **WHEN** the manifest records `Bash(npx @taskless/cli@0.13.0 *)` in `.claude/settings.json` and `init` runs at `0.13.1` -- **THEN** the file SHALL contain `Bash(npx @taskless/cli@0.13.1 *)` and not the `0.13.0` rule -- **AND** the manifest SHALL record only the `0.13.1` rule - -#### Scenario: An unrecorded Taskless rule is replaced - -- **WHEN** `.claude/settings.json` also holds `Bash(npx @taskless/cli@0.12.0 *)`, which the manifest does not record -- **THEN** an upgrade SHALL remove that entry -- **AND** the file SHALL hold exactly one Taskless rule, for the installed invocation - -#### Scenario: The colon form the skill proposed is replaced - -- **WHEN** `.claude/settings.json` holds `Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23:*)` and a nightly `init` runs -- **THEN** `init` SHALL remove it and write the installed invocation's rule - -#### Scenario: Other entries are untouched - -- **WHEN** `.claude/settings.json` holds `Bash(npx prettier *)` and a deny entry `Bash(npx @taskless/cli@0.11.0 *)` -- **THEN** `init` SHALL leave both entries in place - -#### Scenario: A pre-existing file is never deleted - -- **WHEN** removing Taskless rules from a `.claude/settings.json` that Taskless did not create leaves its `permissions.allow` empty -- **THEN** the file SHALL remain - -#### Scenario: The release and a nightly keep each other's rules - -- **WHEN** `init` at `npx @taskless/cli@0.13.0` has written its rule, and a nightly `init` then runs -- **THEN** `.claude/settings.json` SHALL hold the release's rule and the nightly's rule -- **AND** running either `init` again SHALL report no edit - -#### Scenario: A nightly upgrade swaps only the nightly's rule - -- **WHEN** `.claude/settings.json` holds the release's rule and an older nightly's rule, and a newer nightly `init` runs -- **THEN** the older nightly rule SHALL be removed and the newer one written -- **AND** the release's rule SHALL remain - -#### Scenario: A created file outlives one package's removal - -- **WHEN** Taskless created `.claude/settings.json` holding the release's and a nightly's rules, and the release's `init` runs with `.claude/` no longer selected -- **THEN** the file SHALL remain, holding the nightly's rule -- **AND** once the nightly's `init` runs the same way, the file SHALL be deleted - -#### Scenario: Removing a harness removes its launcher rule +### Requirement: Init installs the package's launcher and records its version -- **WHEN** the manifest records `Bash(./.taskless/bin/cli.mjs *)` in `.claude/settings.json` and the release's `init` runs with `.claude/` no longer selected -- **THEN** that rule SHALL be removed with the release's other Taskless rules -- **AND** `Bash(./.taskless/bin/cli-nightly.mjs *)` SHALL remain +Every `init` that writes the canonical skill, in the wizard and in `--no-interactive`, SHALL also write this build's launcher, as defined by the `cli-project-launcher` capability, and record this build's version for its package under `install.launchers` in `.taskless/taskless.json`, keyed by package name. `install.cliVersion` SHALL keep recording the build that last installed. -### Requirement: Init installs the package's launcher and records its version +A nightly `init` SHALL keep the release's recorded version. Where the release's version was recorded only as `install.cliVersion` (a manifest written before `install.launchers` existed), the nightly `init` SHALL carry it into `install.launchers` before overwriting `install.cliVersion`, so `.taskless/bin/cli.mjs` keeps a version to run. -Every `init` that writes the canonical skill, in the wizard and in `--no-interactive`, SHALL also write this build's launcher, as defined by the `cli-project-launcher` capability, and record this build's version for its package under `install.launchers` in `.taskless/taskless.json`, keyed by package name. It SHALL keep the other package's recorded version. `install.cliVersion` SHALL keep recording the build that last installed. +A release `init` is the move back to mainline, and SHALL retire the nightly: it SHALL drop `@taskless/cli-nightly` from `install.launchers`, and remove `.taskless/bin/cli-nightly.mjs` and `.taskless/bin/cli-nightly.lock.json` when they exist. The cached nightly build is then pruned, since nothing records it. Each file removed SHALL be named in the install summary and listed in `init --json` as `retired`, an array of root-relative paths present on every successful run and empty when nothing was removed, and a removal SHALL count as a change. The release `init` SHALL NOT touch the user-level global trust entry, which other projects may still use. -A launcher whose bytes differ from this build's launcher SHALL be rewritten, and the install summary SHALL name it as restored, since a launcher runs whatever it contains under any rule that allows it. A `self` build SHALL write no launcher and record no version under `install.launchers`. +A launcher whose bytes differ from this build's launcher SHALL be rewritten, and the install summary SHALL name it as restored, since a launcher runs whatever it contains under any rule that allows it. A `self` build SHALL write no launcher, record no version under `install.launchers`, and retire nothing. No scaffold migration SHALL be added for this: the launcher and its record are install output, written by the same step as the canonical skill. @@ -1090,6 +907,18 @@ No scaffold migration SHALL be added for this: the launcher and its record are i - **THEN** `install.launchers` SHALL be `{ "@taskless/cli": "0.13.0", "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234" }` - **AND** `install.cliVersion` SHALL be `0.14.0-20261020120000xabc1234` +#### Scenario: A nightly install keeps a release version only cliVersion recorded + +- **WHEN** `.taskless/taskless.json` records `install.cliVersion` `0.13.0` and no `install.launchers`, and a nightly `init` runs at `0.14.0-20261020120000xabc1234` +- **THEN** `install.launchers` SHALL be `{ "@taskless/cli": "0.13.0", "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234" }` + +#### Scenario: A release install retires the nightly + +- **WHEN** `install.launchers` records `@taskless/cli-nightly`, `.taskless/bin/cli-nightly.mjs` and `cli-nightly.lock.json` exist, and the release's `init` runs at `0.13.0` +- **THEN** `install.launchers` SHALL be `{ "@taskless/cli": "0.13.0" }` +- **AND** both nightly files SHALL be gone and listed in `retired` +- **AND** the nightly's cache entry SHALL be pruned + #### Scenario: An edited launcher is restored and reported - **WHEN** `.taskless/bin/cli.mjs` was edited and the release's `init` runs @@ -1128,7 +957,7 @@ A harness whose rule format cannot match a command given as a relative path SHAL ### 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. +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. A nightly `init` SHALL NOT write, change or remove the release's lockfile. A release `init` SHALL NOT write or change the nightly's lockfile, and SHALL remove it as part of retiring the nightly, under the requirement that init installs the package's launcher. 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 @@ -1360,3 +1189,194 @@ The checked rows SHALL resolve to the set of directories they install into, each - **WHEN** the wizard renders the tool-selection step - **THEN** `.taskless/` SHALL NOT appear as a selectable option - **AND** no row SHALL be pre-checked because the manifest records `.taskless/` + +### Requirement: Init raises Taskless CLI pins + +After a successful install, `taskless init` SHALL read `package.json` in the working directory, the `package.json` of every workspace package the working directory declares, and the project's CI and git-hook configuration files, and report every entry naming `@taskless/cli` or `@taskless/cli-nightly` that this requirement raises, each with a `reason`. + +Workspace packages are declared by the `packages` list of `pnpm-workspace.yaml` and by the `workspaces` field of the root `package.json`, as an array or as `{ packages }`; both sources SHALL be read. A pattern SHALL name each directory it matches that holds a `package.json`. A pattern prefixed `!` SHALL remove the directories it matches. A pattern that is absolute or contains a `..` segment SHALL be skipped. Expansion SHALL NOT descend into `node_modules` or `.git`, and SHALL stop after a fixed number of manifests. The root SHALL be read once even when a pattern names it. An absent or malformed workspace declaration SHALL declare nothing, and a malformed root `package.json` SHALL NOT stop `pnpm-workspace.yaml` from being read. + +The CI and hook files read SHALL be the ones `detect` recognizes as CI and commit-time configuration at the project root: `.github/workflows/*.yml` and `*.yaml`, `.gitlab-ci.yml`, `.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml` and `.yaml`, `bitbucket-pipelines.yml`, `.drone.yml`, `.travis.yml`, the files directly in `.buildkite/` and `.husky/`, the lefthook, pre-commit, simple-git-hooks, and lint-staged config files. + +An entry is: + +- a dependency: an entry in `dependencies`, `devDependencies`, or `optionalDependencies`; or +- a command pin: a `package.json` script, or a line of a CI or hook file, that spells out `@taskless/cli@` or `@taskless/cli-nightly@`, where the name is not the tail of a longer name and the spec ends at whitespace, a quote, or shell punctuation (`;&|()<>,:`). A pin repeated within one script SHALL be reported once, and a pin repeated within one CI or hook file once, at its first line. + +Every dependency SHALL be reported with reason `dependency`, whatever its spec, its package, or the running channel, because the CLI runs through the project launcher and a dependency stays on the build its lockfile resolved. Its `installed` SHALL be the version read from `node_modules//package.json` in the directory holding it, then in each parent up to the working directory, the first found winning, as Node resolves it; `null` when none is found. + +A command pin SHALL be judged by the running channel first. The running CLI is a nightly when its version carries a prerelease, and a release otherwise. + +- Under a nightly, a `@taskless/cli` command pin SHALL NOT be reported, because a nightly installs beside the release, except that when the same run migrated an existing `.taskless/` (the migration's `from` is above `0`) it SHALL be reported with reason `unreleased`. +- Under a release, a `@taskless/cli-nightly` command pin SHALL be reported with reason `nightly`, whatever its version, because a release install is the move back to mainline. +- Any other command pin SHALL be reported with reason `stale` when its spec is bounded and cannot reach the running version. + +A bounded spec is an exact version (optionally prefixed `=` or `v`, prerelease, and build metadata), or a `^` or `~` range. An exact version SHALL be compared with semver precedence, so a prerelease sorts before its release and two prereleases of one base compare by their prerelease text, which orders nightlies by build time. A range SHALL be compared by its exclusive ceiling on the numeric core, with caret ranges holding the left-most non-zero part as npm does. A spec the CLI cannot bound (`latest`, `*`, a comparator range) SHALL NOT be reported as `stale`. An absent or unparseable file, or an unreadable installed manifest, SHALL produce no report from that source and SHALL NOT fail the install. + +Entries SHALL be reported root `package.json` first, then workspace manifests by path, then CI and hook files by path; within a `package.json`, dependency fields, then scripts. A `package.json` entry's `manifest` is that file's `/`-separated path relative to the working directory and its `location` the dependency field or `scripts.`; a CI or hook entry's `manifest` is the file's root-relative `/`-separated path, its `location` `line `, and its `installed` `null`. + +The install SHALL NOT modify any `package.json`, CI file, or hook. The report SHALL offer each change rather than claim it. + +On the human path (batch and wizard), when at least one entry is reported, the CLI SHALL print a notice, introduced without naming `package.json` alone, naming for each entry its manifest, location, package, spec, installed version when known, and what to do: remove the dependency for `dependency`; leave it as is for `unreleased`; for `stale` and `nightly`, the running version on the package that publishes it (`@taskless/cli-nightly` when the running version carries a prerelease, `@taskless/cli` otherwise), with the switch named when the pin is on the other package. The notice SHALL add one paragraph per reason present: for `dependency`, that Taskless runs through the project launcher, and the offer to remove it, point callers of its binary at the launcher, and reinstall dependencies; for `nightly`, that the pin keeps a prerelease after the move back to the release; for `unreleased`, that a nightly leaves release pins alone but a release predating the new schema refuses the project, and that the user should be told before committing `.taskless/`. Only a `dependency` entry SHALL ask for dependencies to be reinstalled. The notice SHALL print whether or not the run changed anything. On the batch path it SHALL print after the upgrade trailer, and the onboarding trailer SHALL remain the final line. + +When the same run migrated an existing `.taskless/` and a `stale` or `dependency` entry is reported, the notice SHALL NOT hedge. It SHALL name the schema versions the run moved between, state that a CLI predating the new schema refuses the project with `SCAFFOLD_VERSION_MISMATCH` so CI running those entries will break on the push carrying the migrated files, and say the change belongs in the same commit as `.taskless/`. A migration from `0` is how a fresh install creates `.taskless/`; it SHALL NOT be described as an upgrade, and like a run with no migration the notice SHALL describe a `stale` entry's failure as likely, not certain. + +Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ manifest, location, name, spec, installed, reason }`, `reason` one of `dependency`, `stale`, `nightly`, `unreleased`, present on every successful run and empty when nothing is raised. + +#### Scenario: A dependency and a stale script pin are named and left alone + +- **WHEN** `taskless init` runs at release `V` in a project whose `package.json` has `devDependencies["@taskless/cli"]` set, and a script running `npx @taskless/cli@` +- **THEN** stdout SHALL name the dependency for removal and the script with the target `@taskless/cli@V` +- **AND** `package.json` SHALL be byte-identical afterwards +- **AND** the notice SHALL appear after the upgrade trailer, with the onboarding trailer still the final line + +#### Scenario: A current dependency is still raised + +- **WHEN** `package.json` pins `@taskless/cli` at the running version, or at `latest`, `^0.11.0`, or `workspace:*` +- **THEN** it SHALL be reported with reason `dependency` + +#### Scenario: A stale workflow pin is named by file and line + +- **WHEN** `.github/workflows/taskless.yml` runs `npx @taskless/cli@0.11.0 check` on line 4 and the running CLI is `0.12.0` +- **THEN** `pinnedCli` SHALL contain `{ "manifest": ".github/workflows/taskless.yml", "location": "line 4", "name": "@taskless/cli", "spec": "0.11.0", "installed": null, "reason": "stale" }` +- **AND** the workflow file SHALL be byte-identical afterwards + +#### Scenario: Hook files are read + +- **WHEN** `.husky/pre-commit` or `lefthook.yml` spells out an older `@taskless/cli` or `@taskless/cli-nightly` version +- **THEN** each SHALL be reported with its path + +#### Scenario: An older nightly pin is stale under a nightly + +- **WHEN** a script pins `@taskless/cli-nightly` at `0.12.0-20260901000000xaaaaaaa` and the running CLI is `0.12.0-20261005000000xbbbbbbb` +- **THEN** it SHALL be reported with reason `stale` + +#### Scenario: A nightly leaves a release pin alone + +- **WHEN** a nightly runs `init` without migrating an existing `.taskless/`, and a script, CI file, or hook pins `@taskless/cli` at any version +- **THEN** that pin SHALL NOT be reported +- **AND** no notice SHALL offer to move a `@taskless/cli` pin onto `@taskless/cli-nightly` + +#### Scenario: A nightly migration names the release pin without moving it + +- **WHEN** a nightly `init` migrates an existing `.taskless/` from schema `M` above `0` to `N`, and a script pins `@taskless/cli` +- **THEN** that pin SHALL be reported with reason `unreleased`, its action "leave as is" +- **AND** the notice SHALL state that a release predating schema `N` refuses the project, and that the user should be told before committing `.taskless/` + +#### Scenario: A release install raises a newer nightly pin + +- **WHEN** a release `V` runs `init` and a CI file pins `@taskless/cli-nightly` at a version newer than `V` +- **THEN** it SHALL be reported with reason `nightly` and the target `@taskless/cli@V`, naming the package switch + +#### Scenario: A floating or current command pin is not reported + +- **WHEN** a script or CI file runs `@taskless/cli@latest`, or `@taskless/cli@` +- **THEN** no entry SHALL be reported for it + +#### Scenario: A migration of an existing scaffold makes the breakage definite + +- **WHEN** `taskless init` migrates an existing `.taskless/` from schema version `M` above `0` to `N` in a project with a `stale` or `dependency` entry +- **THEN** the notice SHALL name schema versions `M` and `N` and `SCAFFOLD_VERSION_MISMATCH` +- **AND** SHALL state that CI running the entries will break, and that the change belongs in the same commit as `.taskless/` +- **AND** SHALL NOT describe the failure as merely likely + +#### Scenario: A fresh install is not called an upgrade + +- **WHEN** `taskless init` creates `.taskless/` in a project with a `stale` script pin +- **THEN** the notice SHALL describe the failure as likely +- **AND** SHALL NOT mention `SCAFFOLD_VERSION_MISMATCH` or describe the run as an upgrade + +#### Scenario: An entry is named on a re-install that changed nothing + +- **WHEN** `taskless init` runs against a project that is already current and holds a raised entry +- **THEN** stdout SHALL NOT contain the upgrade trailer +- **AND** stdout SHALL name the entry + +#### Scenario: The JSON envelope carries the entries + +- **WHEN** `taskless init --json` runs +- **THEN** the envelope SHALL contain `pinnedCli`, one `{ manifest, location, name, spec, installed, reason }` entry per raised entry +- **AND** `pinnedCli` SHALL be an empty array when nothing is raised + +#### Scenario: A pin in a declared workspace package is named with its manifest + +- **WHEN** `pnpm-workspace.yaml` declares `packages/*`, or the root `package.json` declares `workspaces` as `["packages/*"]` or `{ "packages": ["packages/*"] }`, and `packages/app/package.json` has `devDependencies["@taskless/cli"]` +- **THEN** the entry SHALL be reported with `manifest` set to `packages/app/package.json` +- **AND** the notice line SHALL name `packages/app/package.json` and the field + +#### Scenario: A workspace dependency names the version it runs + +- **WHEN** `packages/linked/package.json` and `packages/hoisted/package.json` both depend on `@taskless/cli`, `packages/linked/node_modules/@taskless/cli` is `0.11.0`, and only the root `node_modules/@taskless/cli` exists for `packages/hoisted` at `0.11.1` +- **THEN** `packages/linked/package.json` SHALL be reported with `installed` `0.11.0` +- **AND** `packages/hoisted/package.json` SHALL be reported with `installed` `0.11.1` + +#### Scenario: Workspace expansion stays inside the project + +- **WHEN** a workspace pattern is negated, is absolute, climbs out with `..`, or is `**` over a tree that contains `node_modules` +- **THEN** a directory the negation matches SHALL NOT be reported +- **AND** nothing outside the working directory, and no `package.json` under `node_modules`, SHALL be read as a workspace package + +### Requirement: Each permission file holds one Taskless rule set per installed package + +Each harness permission file `init` manages SHALL hold, afterwards, the installed invocation's package's rules: the rules for the installed invocation, from the table in the requirement that init allows the installed CLI, and the rule for its launcher, from the requirement that init allows the installed launcher. A Taskless rule is an allow entry, in that harness's format, whose command is an invocation of `@taskless/cli` or `@taskless/cli-nightly` through `npx` or `pnpm dlx`, at any version, with or without a version, or whose command is a launcher path `./.taskless/bin/cli.mjs` or `./.taskless/bin/cli-nightly.mjs`. A rule belongs to the package its command names; `cli.mjs` belongs to `@taskless/cli` and `cli-nightly.mjs` to `@taskless/cli-nightly`. For Claude Code this includes both the `Bash( *)` form and the `Bash(:*)` form. On every `init` the installer SHALL remove each Taskless rule of the installed invocation's package that is not one of the installed invocation's rules, whether or not the manifest recorded it, and write the current ones. + +The two packages are not symmetric. A nightly `init` SHALL NOT remove a `@taskless/cli` rule, so a nightly installs beside the release. A release `init` is the move back to mainline: it SHALL remove every `@taskless/cli-nightly` rule in the files it visits, whether or not the manifest recorded it. An invocation that names neither package, such as a `self` build's path, SHALL remove no Taskless rule. The installer SHALL NOT remove or edit a deny entry, or any entry that is not a Taskless rule. + +The manifest SHALL record, under `install.permissions`, each file Taskless wrote a rule to, the exact rule entries it added, and whether Taskless created the file. The record decides only whether a file may be deleted: a file Taskless created SHALL be deleted when removing its entries leaves it holding nothing else; a file that existed before SHALL never be deleted. A recorded file for a harness no longer selected SHALL have the installed package's Taskless rules removed, and, on a release `init`, the nightly's. A nightly `init` SHALL keep the release's recorded rules, so a file Taskless created is deleted once the last package's rules leave it. + +#### Scenario: An upgrade swaps the version + +- **WHEN** the manifest records `Bash(npx @taskless/cli@0.13.0 *)` in `.claude/settings.json` and `init` runs at `0.13.1` +- **THEN** the file SHALL contain `Bash(npx @taskless/cli@0.13.1 *)` and not the `0.13.0` rule +- **AND** the manifest SHALL record only the `0.13.1` rule + +#### Scenario: An unrecorded Taskless rule is replaced + +- **WHEN** `.claude/settings.json` also holds `Bash(npx @taskless/cli@0.12.0 *)`, which the manifest does not record +- **THEN** an upgrade SHALL remove that entry +- **AND** the file SHALL hold exactly one Taskless rule, for the installed invocation + +#### Scenario: The colon form the skill proposed is replaced + +- **WHEN** `.claude/settings.json` holds `Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23:*)` and a nightly `init` runs +- **THEN** `init` SHALL remove it and write the installed invocation's rule + +#### Scenario: Other entries are untouched + +- **WHEN** `.claude/settings.json` holds `Bash(npx prettier *)` and a deny entry `Bash(npx @taskless/cli@0.11.0 *)` +- **THEN** `init` SHALL leave both entries in place + +#### Scenario: A pre-existing file is never deleted + +- **WHEN** removing Taskless rules from a `.claude/settings.json` that Taskless did not create leaves its `permissions.allow` empty +- **THEN** the file SHALL remain + +#### Scenario: A nightly installs beside the release's rules + +- **WHEN** `init` at `npx @taskless/cli@0.13.0` has written its rule, and a nightly `init` then runs +- **THEN** `.claude/settings.json` SHALL hold the release's rule and the nightly's rule +- **AND** running the nightly's `init` again SHALL report no edit + +#### Scenario: A release install removes the nightly's rules + +- **WHEN** `.claude/settings.json` holds the release's rules and a nightly's, recorded or not, and the release's `init` runs +- **THEN** every `@taskless/cli-nightly` rule, including `Bash(./.taskless/bin/cli-nightly.mjs *)`, SHALL be removed and reported as removed +- **AND** the release's rules SHALL remain + +#### Scenario: A nightly upgrade swaps only the nightly's rule + +- **WHEN** `.claude/settings.json` holds the release's rule and an older nightly's rule, and a newer nightly `init` runs +- **THEN** the older nightly rule SHALL be removed and the newer one written +- **AND** the release's rule SHALL remain + +#### Scenario: A created file outlives the nightly's removal + +- **WHEN** Taskless created `.claude/settings.json` holding the release's and a nightly's rules, and the nightly's `init` runs with `.claude/` no longer selected +- **THEN** the file SHALL remain, holding the release's rule +- **AND** once the release's `init` runs the same way, the file SHALL be deleted + +#### Scenario: Removing a harness removes its launcher rule + +- **WHEN** the manifest records `Bash(./.taskless/bin/cli-nightly.mjs *)` in `.claude/settings.json` and the nightly's `init` runs with `.claude/` no longer selected +- **THEN** that rule SHALL be removed with the nightly's other Taskless rules +- **AND** `Bash(./.taskless/bin/cli.mjs *)` SHALL remain diff --git a/openspec/specs/cli-project-launcher/spec.md b/openspec/specs/cli-project-launcher/spec.md index e43a606a..928e8bfa 100644 --- a/openspec/specs/cli-project-launcher/spec.md +++ b/openspec/specs/cli-project-launcher/spec.md @@ -8,7 +8,7 @@ A project-local launcher that runs the Taskless CLI version the project recorded ### Requirement: Each package has its own launcher -The release package SHALL install its launcher at `.taskless/bin/cli.mjs`, and the nightly package at `.taskless/bin/cli-nightly.mjs`. A build SHALL write, rewrite, or remove only its own package's launcher, and SHALL NEVER read, edit, or remove the other package's. A build whose invocation names neither package, such as a `self` build, SHALL write no launcher. +The release package SHALL install its launcher at `.taskless/bin/cli.mjs`, and the nightly package at `.taskless/bin/cli-nightly.mjs`. A build SHALL write and rewrite only its own package's launcher. A nightly build SHALL NEVER read, edit, or remove the release's launcher, so a nightly installs beside the release. A release build SHALL NOT read or edit the nightly's launcher, and SHALL remove it when it installs, because installing the release is the move back to mainline. A build whose invocation names neither package, such as a `self` build, SHALL write no launcher and remove none. Each launcher SHALL be an ES module (`.mjs`) whose first line is `#!/usr/bin/env node`, and SHALL be written with its executable bits set where the filesystem supports them. Its content SHALL be identical for every version of its package, so an upgrade never changes its bytes unless the launcher itself changed. @@ -24,6 +24,12 @@ Each launcher SHALL be an ES module (`.mjs`) whose first line is `#!/usr/bin/env - **THEN** `.taskless/bin/cli.mjs` SHALL be byte-identical afterwards - **AND** `.taskless/bin/cli-nightly.mjs` SHALL exist +#### Scenario: The release removes the nightly's launcher + +- **WHEN** `.taskless/bin/cli-nightly.mjs` exists and `init` runs from the release +- **THEN** `.taskless/bin/cli-nightly.mjs` SHALL NOT exist afterwards +- **AND** `.taskless/bin/cli.mjs` SHALL exist + #### Scenario: An upgrade does not rewrite an unchanged launcher - **WHEN** `init` runs at `0.13.1` over a project installed at `0.13.0`, and the launcher template did not change between them diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index 74c78822..6bfe0081 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -553,26 +553,26 @@ Where detection reports unknown, the printed command SHALL name `npx` with the c ### Requirement: Info reports stale CLI pins -`taskless info` SHALL report the same pins `taskless init` reports under "Init names a package.json pin older than the running CLI", judged against the running version, read from the working directory and its declared workspace packages by the same rules. Under `--json` the payload SHALL carry `pinnedCli` in the shape `init --json` uses, `{ manifest, location, name, spec, installed }`, present on every successful run and empty when nothing is stale. Without `--json`, when at least one pin is reported, the output SHALL list each pin with its manifest, location, spec, installed version when known, and the package and version to move it to. +`taskless info` SHALL report the same entries `taskless init` reports under "Init raises Taskless CLI pins", judged against the running version, read from the working directory, its declared workspace packages, and its CI and hook files by the same rules. `info` runs no migration, so it SHALL never report reason `unreleased`. Under `--json` the payload SHALL carry `pinnedCli` in the shape `init --json` uses, `{ manifest, location, name, spec, installed, reason }`, present on every successful run and empty when nothing is raised. Without `--json`, when at least one entry is reported, the output SHALL list each entry with its manifest, location, spec, installed version when known, and what to do with it. -`info` is read-only: it SHALL NOT modify any `package.json`, and an unreadable manifest SHALL NOT make it fail. `--anonymous` SHALL NOT suppress the pins, since they are local state. +`info` is read-only: it SHALL NOT modify any `package.json`, CI file, or hook, and an unreadable file SHALL NOT make it fail. `--anonymous` SHALL NOT suppress the entries, since they are local state. -The `update` recipe SHALL read the pins from `info --json`, the read-only command it already runs, rather than directing an agent to re-run `init`. +The `update` recipe SHALL read the entries from `info --json`, the read-only command it already runs, rather than directing an agent to re-run `init`. #### Scenario: info --json carries a workspace package's stale pin -- **WHEN** `taskless info --json` runs in a project whose root `package.json` declares `workspaces: ["packages/*"]` and `packages/app/package.json` pins `@taskless/cli` at a version below the running one -- **THEN** `pinnedCli` SHALL contain `{ manifest: "packages/app/package.json", location: "devDependencies", name: "@taskless/cli", spec, installed: null }` +- **WHEN** `taskless info --json` runs in a project whose root `package.json` declares `workspaces: ["packages/*"]` and `packages/app/package.json` has `devDependencies["@taskless/cli"]` +- **THEN** `pinnedCli` SHALL contain `{ manifest: "packages/app/package.json", location: "devDependencies", name: "@taskless/cli", spec, installed: null, reason: "dependency" }` #### Scenario: info --json reports no pins as an empty list -- **WHEN** `taskless info --json` runs where there is no `package.json`, or nothing in one is stale +- **WHEN** `taskless info --json` runs where nothing is raised - **THEN** `pinnedCli` SHALL be an empty array #### Scenario: Plain info lists the pins -- **WHEN** `taskless info` runs without `--json` and a pin is stale -- **THEN** stdout SHALL name the pin's manifest, location, and spec, and the version to move it to +- **WHEN** `taskless info` runs without `--json` and an entry is raised +- **THEN** stdout SHALL name the entry's manifest, location, and spec, and what to do with it ### Requirement: The CLI runs only as the version the project recorded diff --git a/packages/cli/src/agent/info.md b/packages/cli/src/agent/info.md index d5487e9f..d44359cc 100644 --- a/packages/cli/src/agent/info.md +++ b/packages/cli/src/agent/info.md @@ -1,4 +1,4 @@ -# Topic: info (CLI v%(CLI_VERSION)s / topic v3) +# Topic: info (CLI v%(CLI_VERSION)s / topic v4) %(ONE_COMMAND_PER_CALL)s @@ -49,7 +49,8 @@ which version of the CLI/skills the agent is talking to. "pinnedCli": [ { "manifest": "packages/app/package.json", "location": "devDependencies", "name": "@taskless/cli", - "spec": "^0.6.0", "installed": "0.6.3" } + "spec": "^0.6.0", "installed": "0.6.3", + "reason": "dependency" } ] } ``` @@ -71,14 +72,16 @@ which version of the CLI/skills the agent is talking to. suggest `%(TASKLESS_CLI)s` to reinstall and pull the latest bundle. -5. **Name stale CLI pins.** `pinnedCli` lists every pin of - `@taskless/cli` or `@taskless/cli-nightly` that runs a CLI older - than `version`, in the root `package.json` or a workspace package's - (`manifest`), with the field it is in (`location`). `installed` is - the version that pin actually runs, or `null` when nothing is - installed or the pin is a script. Scripts, CI, and git hooks run - these pins, so report them and offer the bump to `version`. Do not - edit a `package.json` on your own; a pin can be deliberate. +5. **Name CLI pins.** `pinnedCli` lists every place the project names + the Taskless CLI that needs a look: the file (`manifest`), where in + it (`location`), and why (`reason`). A `dependency` is any + `package.json` dependency on the CLI, whatever its version, since + Taskless runs through the project launcher; offer to remove it. + `installed` is the version it runs, or `null`. A `stale` script, CI, + or hook line names a version older than `version`; offer to move it. + A `nightly` one is left over after a release install; offer to move + it back to `@taskless/cli`. Do not edit any of these files on your + own; a pin can be deliberate. ## Errors diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index ff9caa5a..2babe265 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 v14) +# Topic: init (CLI v%(CLI_VERSION)s / topic v15) %(ONE_COMMAND_PER_CALL)s @@ -46,7 +46,8 @@ first step, not the whole job. "pinnedCli": [ { "manifest": "packages/app/package.json", "location": "devDependencies", "name": "@taskless/cli", - "spec": "^0.10.0", "installed": "0.10.2" } + "spec": "^0.10.0", "installed": "0.10.2", + "reason": "dependency" } ], "permissions": [ { "file": ".claude/settings.json", "created": false, @@ -60,6 +61,7 @@ first step, not the whole job. ], "launcher": { "path": ".taskless/bin/cli.mjs", "state": "unchanged" }, "lockfile": { "path": ".taskless/bin/cli.lock.json", "state": "unchanged" }, + "retired": [], "telemetry": { "enabled": true } } ``` @@ -69,22 +71,26 @@ 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 or its lockfile was created or restored, or `cliVersion` or a + non-empty, the launcher or its lockfile was created or restored, a + nightly launcher was retired, 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 to bump. - `migrated` is present only when a migration ran, with the paths it added, rewrote, or deleted. - - `pinnedCli` is always present. Each entry is a pin that runs a - Taskless CLI older than `installed`: a dependency whose installed - build (`installed`, `null` when nothing is installed) or range is - behind, or a script, CI file, or git hook spelling out an older - version. `manifest` is the file it lives in: the root's or a - workspace package's `package.json`, with `location` the field in it, - or a CI or hook file such as `.github/workflows/ci.yml`, with - `location` the line. It is reported even when `changed` is `false`, - because the pin and the project still disagree. + - `pinnedCli` is always present. Each entry names the Taskless CLI + somewhere the project runs it. `manifest` is the file it lives in: + the root's or a workspace package's `package.json`, with `location` + the field in it, or a CI or hook file such as + `.github/workflows/ci.yml`, with `location` the line. `reason` says + what to do with it: `dependency` is any `package.json` dependency on + the CLI, whatever its version (`installed` is the build it runs, + `null` when nothing is installed); `stale` is a script, CI, or hook + line naming an older version; `nightly` is a nightly left in one of + those after a release install; `unreleased` is a release pin after a + nightly migrated `.taskless/`. It is reported even when `changed` is + `false`, because the entry and the project still disagree. - `permissions` is always present. Each entry is a harness settings file this run created, edited, or left alone: the rule allowing exactly this CLI version (`allowed`), every other Taskless rule it @@ -116,8 +122,8 @@ first step, not the whole job. lines (or `SKIPPED:` with the reason), a `SKIPPED (global):` line for each missing or stale global trust entry, then a trailer naming the directories that changed and, after a - version move, pointing at `update`, then a notice naming each stale - pin and the version to move it to, and, when telemetry is on, a + version move, pointing at `update`, then a notice naming each + `pinnedCli` entry and what to do with it, and, when telemetry is on, a notice naming its opt-outs. - `launcher` is this CLI's project launcher, `.taskless/bin/cli.mjs` @@ -133,6 +139,14 @@ first step, not the whole job. launcher's first run of a version installs it into `.taskless/.cache/`, which ignores itself in git; `init` downloads nothing. + - `retired` is always present, empty when nothing was removed. A + nightly installs beside the release and never touches the release's + launcher, lockfile, recorded version, or permission rules. A release + install is the move back to mainline: it removes + `.taskless/bin/cli-nightly.mjs` and `cli-nightly.lock.json`, listed + here, along with the nightly's recorded version (its cached build is + pruned) and its rules in `permissions[].removed`. The deletions are + changes to commit. 2. **Tell the user what needs committing.** Every `targets[].dir` with a non-empty list, plus `.taskless/`, any `migrated.files` entries, and @@ -142,19 +156,28 @@ first step, not the whole job. so the user can include them in the commit they choose. Do not stage or commit on your own; the git operations are theirs. -3. **Offer to bump every stale pin.** For each `pinnedCli` entry, offer - the user the update to the installed CLI. A pin in a `package.json` - dependency field also needs dependencies reinstalled; a script, CI, or - hook pin is a launcher line, and editing it is the whole fix. When the - installed CLI is a nightly and the pin names - `@taskless/cli`, or the reverse, the move switches package, since a - nightly version only exists on the nightly package. When `migrated` - is present with `from` above `0`, say plainly that CI breaks without - it: a CLI that predates the new schema refuses the project with - `SCAFFOLD_VERSION_MISMATCH`, so the bump belongs in the same commit as - the migrated files. Do not edit a `package.json`, CI file, or hook on - your own; a pin can be deliberate, and a dependency bump changes the - lockfile. +3. **Offer the `pinnedCli` changes.** Go by each entry's `reason`: + - `dependency`: Taskless runs through the project launcher, not as a + project dependency. Offer to remove the dependency, point anything + that calls its `taskless` binary at `node .taskless/bin/cli.mjs`, + and reinstall dependencies. + - `stale`: offer to move the line to the installed version, or to the + launcher. Editing the line is the whole fix. + - `nightly`: offer to move the line back to `@taskless/cli`. A release + install is the move back to mainline, so this is raised even when + the nightly is newer by version. + - `unreleased`: change nothing. A nightly installs beside the release, + so never move a `@taskless/cli` pin onto the nightly. But the + nightly migrated `.taskless/`, and a release that predates the new + schema refuses the project with `SCAFFOLD_VERSION_MISMATCH`. Tell + the user before they commit `.taskless/`, so they can hold the + migration until a release that reads it ships. + + When `migrated` is present with `from` above `0`, say plainly that CI + on an older CLI breaks: a `dependency` or `stale` change belongs in the + same commit as the migrated files. Do not edit a `package.json`, CI + file, or hook on your own; a pin can be deliberate, and removing a + dependency changes the lockfile. 4. **Tell the user about telemetry.** When `telemetry.enabled` is `true`, tell the user that Taskless collects anonymous telemetry diff --git a/packages/cli/src/agent/update.md b/packages/cli/src/agent/update.md index ad482ec9..ca49a3d7 100644 --- a/packages/cli/src/agent/update.md +++ b/packages/cli/src/agent/update.md @@ -1,4 +1,4 @@ -# Topic: update (CLI v%(CLI_VERSION)s / topic v19) +# Topic: update (CLI v%(CLI_VERSION)s / topic v20) %(ONE_COMMAND_PER_CALL)s @@ -66,16 +66,18 @@ that you finished. applied yet at that point, so a rule a later section says step 5 rewrites can still fail to load. -4. **Offer to bump a pinned CLI.** Read `pinnedCli` from the same - `info --json` payload as step 1. Each entry is a pin of - `@taskless/cli` or `@taskless/cli-nightly` behind the installed CLI: - a dependency entry, or a script spelling out - `@taskless/cli@`, in the root `package.json` or a workspace - package's (`manifest` names which, `location` the field). An empty - list means there is nothing to bump. Those pins are what scripts, CI, - and git hooks run. Move each to the installed version on the package - that publishes it: a nightly version exists only on - `@taskless/cli-nightly`, a release only on `@taskless/cli`. +4. **Offer the `pinnedCli` changes.** Read `pinnedCli` from the same + `info --json` payload as step 1. Each entry names the Taskless CLI in + a `package.json`, CI file, or hook (`manifest` names which, + `location` where in it), and `reason` says what to do. An empty list + means there is nothing to change. A `dependency` is any dependency on + the CLI: Taskless runs through the project launcher, so offer to + remove it and point what called its `taskless` binary at + `node .taskless/bin/cli.mjs`. A `stale` line names an older build: + offer to move it to the installed version. A `nightly` line is left + over after a release install: offer to move it back to + `@taskless/cli`. Never move a `@taskless/cli` pin onto the nightly; a + nightly installs beside the release. This is not optional advice. A CLI that is not the version `.taskless/taskless.json` records refuses every command but `init`, @@ -83,9 +85,9 @@ that you finished. pin left behind breaks CI on the push that carries `init`'s changes. The bump belongs in that same commit. - Offer the user the bump to the installed version, along with - reinstalling dependencies. Do not make it silently: a pin can be - deliberate, and the bump changes the lockfile. The walk does not + Offer each change to the user, with reinstalling dependencies when a + dependency is removed. Do not make it silently: a pin can be + deliberate, and removing a dependency changes the lockfile. The walk does not depend on the answer, so record it either way, but if they decline, tell them plainly that whatever runs that pin will fail. diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index 91f76c11..ce8ea360 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -4,7 +4,7 @@ import { defineCommand } from "citty"; import { detectHostTools } from "../detect/host-tools"; import { checkStaleness } from "../install/install"; -import { describePin, findStalePins } from "../install/pinned-cli"; +import { describePin, findCliPins } from "../install/pinned-cli"; import { getToken } from "../auth/token"; import { fetchWhoami } from "../auth/whoami"; import { outputSchema as infoOutputSchema } from "../schemas/info"; @@ -61,7 +61,7 @@ export const infoCommand = defineCommand({ // Read here as well as on `init`: the `update` recipe reaches this // after a version move, possibly in a later session that never saw // `init`'s output, and `info` is the read-only command it runs. - findStalePins(cwd, __VERSION__), + findCliPins(cwd, __VERSION__), ]); let auth: { user: string; email?: string; orgs: string[] } | undefined; @@ -181,7 +181,7 @@ export const infoCommand = defineCommand({ if (pinnedCli.length > 0) { console.log(""); - console.log(`Pinned CLI older than v${__VERSION__}:`); + console.log(`Taskless CLI pins (running v${__VERSION__}):`); for (const pin of pinnedCli) console.log(` - ${describePin(pin, __VERSION__)}`); } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 20149c81..c74a57ed 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -16,12 +16,13 @@ import type { InstallMode, InstallState } from "../install/state"; import { buildLauncherInvocation, describeLauncherWrite, + describeRetired, type LauncherWrite, } from "../install/launcher"; import { getReloadNotice, versionMoved } from "../install/reload-notice"; import { getUpgradeTrailer } from "../install/upgrade-trailer"; import { - findStalePins, + findCliPins, getPinnedCliNotice, type PinnedCli, } from "../install/pinned-cli"; @@ -165,6 +166,9 @@ export const initCommand = defineCommand({ // The lockfile beside the launcher, which decides what it installs; // `null` for a build that installs no launcher. lockfile: result.lockfile, + // Another package's launcher files this run removed: a release + // install retires the nightly's. Always present, empty when none. + retired: result.retired, // Always present, so the agent can tell the user telemetry is on // and how to turn it off; the human path prints the notice instead. telemetry: { enabled: telemetryEnabled }, @@ -409,6 +413,8 @@ async function runNonInteractive( launcher: LauncherWrite | null; /** The lockfile beside the launcher, or `null` when there is none. */ lockfile: LauncherWrite | null; + /** Another package's launcher files this run removed. */ + retired: string[]; /** 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. */ @@ -538,12 +544,13 @@ async function runNonInteractive( } } - const { launcher, lockfile } = result; + const { launcher, lockfile, retired } = result; for (const write of [launcher, lockfile]) { if (write !== null && write.state !== "unchanged") { log(describeLauncherWrite(write)); } } + for (const path of retired) log(describeRetired(path)); const selected = plan.targets .filter((target) => target.mode === "reference") @@ -577,6 +584,7 @@ async function runNonInteractive( globalTrust, launcher, lockfile, + retired, // 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: @@ -586,9 +594,10 @@ async function runNonInteractive( [launcher, lockfile].some( (write) => write !== null && write.state !== "unchanged" ) || + retired.length > 0 || launcherVersionMoved(previousState, result.state) || versionMoved({ previousCliVersion, cliVersion }), - pinnedCli: await findStalePins(cwd, cliVersion), + pinnedCli: await findCliPins(cwd, cliVersion, { migrated }), }; } diff --git a/packages/cli/src/install/install.ts b/packages/cli/src/install/install.ts index 8e988dbd..f6cd4262 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -30,7 +30,11 @@ import { } from "./state"; import { buildLauncherPackage, + LAUNCHER_PACKAGES, pruneLauncherCache, + recordedLauncherVersion, + retiredLauncherPackage, + retireLauncher, writeLauncher, writeLauncherLockfile, type LauncherWrite, @@ -459,6 +463,11 @@ export interface ApplyInstallResult { launcher: LauncherWrite | null; /** The lockfile beside it, or `null` for a build that has no launcher. */ lockfile: LauncherWrite | null; + /** + * Another package's launcher files this install removed: a release install + * retires the nightly's. Empty when there were none. + */ + retired: string[]; writtenSkills: Array<{ target: string; skill: string }>; removedSkills: Array<{ target: string; skill: string }>; removedCommands: Array<{ target: string; command: string }>; @@ -581,12 +590,29 @@ export async function applyInstallPlan( const previousState = await readInstallState(cwd); // Each package records only its own launcher's version and keeps the - // other's, so the release and a nightly never move each other. + // other's, so a nightly never moves the release. The other's version is + // carried even when it was only ever implied by `cliVersion`, as it is in a + // manifest written before `launchers` existed: this run is about to + // overwrite `cliVersion`, and the other launcher would lose its version. const launcherPackage = buildLauncherPackage(); + const retiredPackage = retiredLauncherPackage(launcherPackage); const launchers = { ...previousState.launchers }; + for (const packageName of LAUNCHER_PACKAGES) { + const version = recordedLauncherVersion( + packageName, + previousState.launchers, + previousState.cliVersion + ); + if (version !== undefined) launchers[packageName] = version; + } if (launcherPackage !== undefined) { launchers[launcherPackage] = options.cliVersion; } + // A release install is the move back to mainline, so it drops the + // nightly's record; the prune below then removes its cached build. + if (retiredPackage !== undefined) { + Reflect.deleteProperty(launchers, retiredPackage); + } const nextState: InstallState = { cliVersion: options.cliVersion, ...(Object.keys(launchers).length > 0 ? { launchers } : {}), @@ -649,6 +675,11 @@ export async function applyInstallPlan( __TASKLESS_INSTALL_LOCK__ ); + const retired = + retiredPackage === undefined + ? [] + : await retireLauncher(cwd, retiredPackage); + await writeInstallState(cwd, nextState); // Installed versions nothing records any more. The launcher installs the // new one on its first run; init never downloads. @@ -658,6 +689,7 @@ export async function applyInstallPlan( state: nextState, launcher, lockfile, + retired, writtenSkills, removedSkills, removedCommands, diff --git a/packages/cli/src/install/launcher.ts b/packages/cli/src/install/launcher.ts index 1bfa600e..3dda2553 100644 --- a/packages/cli/src/install/launcher.ts +++ b/packages/cli/src/install/launcher.ts @@ -18,7 +18,9 @@ import { buildInvocation } from "../util/invocation"; * * Each package has its own file and is the only build that writes it, so a * nightly installed beside the release never changes what the release's - * launcher runs, and the reverse. The version is read from + * launcher runs. The reverse holds only until the release is installed again: + * that is the move back to mainline, and it retires the nightly's launcher + * (see {@link retiredLauncherPackage}). The version is read from * `.taskless/taskless.json` when the launcher runs rather than written into * it, so the file's bytes are the same for every version of its package and an * upgrade only moves a line of JSON. @@ -180,6 +182,40 @@ export function writeLauncherLockfile( return writeInstallFile(cwd, lockfilePath(packageName), content); } +/** + * The package a build retires when it installs, or `undefined`. A release + * install is the move back to mainline, so it removes the nightly. A nightly + * installs alongside the release and retires nothing. + */ +export function retiredLauncherPackage( + packageName: LauncherPackage | undefined +): LauncherPackage | undefined { + const [release, nightly] = LAUNCHER_PACKAGES; + return packageName === release ? nightly : undefined; +} + +/** + * Remove a package's launcher and its lockfile from `cwd`, returning the paths + * that were there. Its cache entry goes with the next prune, once nothing + * records its version. + */ +export async function retireLauncher( + cwd: string, + packageName: LauncherPackage +): Promise { + const removed: string[] = []; + for (const path of [launcherPath(packageName), lockfilePath(packageName)]) { + const present = await stat(join(cwd, path)).then( + () => true, + () => false + ); + if (!present) continue; + await rm(join(cwd, path), { force: true }); + removed.push(path); + } + return removed; +} + /** 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(() => {}); @@ -285,6 +321,11 @@ export function buildLauncherInvocation(): string | undefined { : launcherInvocation(packageName); } +/** The line an install prints for a launcher file it retired. */ +export function describeRetired(path: string): string { + return `Removed ${path} (this release replaces the nightly)`; +} + /** * The line an install prints for a launcher it wrote. A restored launcher is * named as such: it had been edited, and it runs under any rule that allows diff --git a/packages/cli/src/install/permissions.ts b/packages/cli/src/install/permissions.ts index 02416b73..7e378266 100644 --- a/packages/cli/src/install/permissions.ts +++ b/packages/cli/src/install/permissions.ts @@ -19,6 +19,7 @@ import { type TasklessPermissionRecord, } from "../filesystem/manifest"; import { isRecord } from "../util/is-record"; +import { LAUNCHER_PACKAGES, retiredLauncherPackage } from "./launcher"; /** * Harness permission rules that allow exactly the installed Taskless CLI. @@ -36,8 +37,9 @@ import { isRecord } from "../util/is-record"; * invocation's. Every other allow rule for the same package is removed, * however it got there, since a pinned rule for a version that is no longer * installed allows nothing anyone wants. The other package's rules are left - * to the other package, so a nightly and the release installed side by side - * do not undo each other. Deny rules, and rules for anything else, are + * to the other package, so a nightly installed beside the release does not + * undo it. The one exception runs the other way: a release install is the + * move back to mainline, so it removes the nightly's rules too. Deny rules, and rules for anything else, are * never touched. The manifest record decides only whether a file Taskless * created may be deleted once it is empty. * - A file that denies the invocation, or does not parse, is left alone. @@ -852,7 +854,8 @@ export interface SyncPermissionsOptions { * * Rules for the other package are neither removed nor dropped from the * record: they belong to the other build, which keeps them current on its own - * runs. An invocation that names neither package (a `self` build's path) + * runs. A release install is the exception: it retires the nightly (see + * `retiredLauncherPackage`), so it removes the nightly's rules as well. An invocation that names neither package (a `self` build's path) * removes no package rules at all. * * With `write: false` it reads the same files and reports the same edits @@ -866,9 +869,14 @@ export async function syncPermissions( const own = tasklessPackage(options.invocation); const packageOf = (rule: string) => tasklessPackage(commandOfRule(rule)); const isOwn = (rule: string) => own !== undefined && packageOf(rule) === own; + const retired = retiredLauncherPackage( + LAUNCHER_PACKAGES.find((name) => name === own) + ); + const isRetired = (rule: string) => + retired !== undefined && packageOf(rule) === retired; const isOther = (rule: string) => { const name = packageOf(rule); - return name !== undefined && name !== own; + return name !== undefined && name !== own && name !== retired; }; const desired = new Map(); for (const harness of harnessesFor(options.selected, options.codex)) { @@ -900,7 +908,9 @@ export async function syncPermissions( const had = recorded?.rules ?? []; const remove = harness .found(text) - .filter((rule) => isOwn(rule) && !want.includes(rule)); + .filter( + (rule) => (isOwn(rule) && !want.includes(rule)) || isRetired(rule) + ); const add = want.filter((rule) => !had.includes(rule)); if (add.length === 0 && remove.length === 0) { if (recorded !== undefined) next[file] = recorded; diff --git a/packages/cli/src/install/pinned-cli.ts b/packages/cli/src/install/pinned-cli.ts index b442bb73..a0d7fc2c 100644 --- a/packages/cli/src/install/pinned-cli.ts +++ b/packages/cli/src/install/pinned-cli.ts @@ -9,35 +9,33 @@ import { compareSemver, compareVersions } from "../util/version-compare"; import { renderNoticeBox } from "./notice-box"; /** - * Pins in `package.json` that would run a Taskless CLI older than the one that - * just upgraded this project. + * Entries in `package.json`, and in the project's CI and hook files, that name + * the Taskless CLI and that this install should raise, each with the reason it + * is raised (see {@link PinReason}). * - * An upgrade is usually run through a launcher (`npx @taskless/cli@latest - * init`), which leaves the project's own pins alone: a `devDependencies` entry, - * or a script that spells out `@taskless/cli@0.10.2`. Those are what CI, a git - * hook, and `pnpm lint` actually run, so after the upgrade they run a CLI that - * predates the `.taskless/` layout it now finds. An older CLI refuses a layout - * newer than it understands, or, where the layout did not move, runs rules - * against engines the ledger has since moved past. Nothing about the upgrade - * itself fails, so the first sign is a red CI run on the next push. + * The Taskless CLI is meant to run through the project launcher + * (`.taskless/bin/cli.mjs`), which runs the version `.taskless/taskless.json` + * records, not as a project dependency. A dependency is what CI, a git hook, + * and `pnpm lint` then run, and it stays on whatever the lockfile resolved + * while every upgrade moves `.taskless/` forward. An older CLI refuses a + * layout newer than it understands, or, where the layout did not move, runs + * rules against engines the ledger has since moved past. Nothing about the + * upgrade itself fails, so the first sign is a red CI run on the next push. + * So every dependency entry is raised, whatever its version. * - * A dependency is judged twice, because the range and what runs can - * disagree. `pnpm add -D @taskless/cli` writes `^0.11.0` and locks 0.11.0; - * the range admits 0.11.2, but CI installs from the lockfile and runs 0.11.0. - * So the version installed under `node_modules/` is read first, and is stale - * when it is older than the running CLI. The range is the fallback for a - * checkout with nothing installed, and is stale only when it cannot reach the - * running version at all. + * A script, CI, or hook line that spells out a version (`npx + * @taskless/cli@0.10.2 check`) names one build, so it is raised only when that + * version is wrong for this project: + * older than the running CLI, or a nightly left over after a release install. * - * In a monorepo the pin usually lives in a workspace package rather than at + * In a monorepo the entry usually lives in a workspace package rather than at * the root, and that package's CI job is the one that breaks, so the * workspace packages the root declares are read too. See * `listWorkspaceManifests()` for what counts as declared. * - * Otherwise detection is deliberately narrow. A spec this module cannot bound - * (`latest`, `*`, `>=`, a `workspace:` or URL spec) is not reported, because - * "this might be old" is a guess, and a notice that guesses is one an agent - * learns to skip. + * Otherwise script detection is deliberately narrow. A spec this module cannot + * bound (`latest`, `*`, `>=`) is not reported, because "this might be old" is + * a guess, and a notice that guesses is one an agent learns to skip. */ /** @@ -50,6 +48,32 @@ import { renderNoticeBox } from "./notice-box"; // ast-grep-ignore: no-unrouted-cli-invocation const PACKAGE_NAMES = ["@taskless/cli", "@taskless/cli-nightly"] as const; +/** + * Whether `version` is a nightly. A nightly is always `-x` + * and a release never has a prerelease, so the version alone says which. + */ +function isNightly(version: string): boolean { + return version.includes("-"); +} + +/** + * Whether a script pin of `name` belongs in the report at all, before its + * version is looked at: `true` to report it whatever its version, `false` to + * leave it out whatever its version, `undefined` to judge it by staleness. + * + * The two channels are deliberately not symmetric. A nightly is installed + * ALONGSIDE the release, to try a build before it ships, so a nightly run + * never offers to move a release pin onto the nightly: CI, scripts, and git + * hooks keep running the release they chose. A release install is the move + * back to mainline, so it offers to clean up every nightly pin, including one + * newer than the release by version, which staleness alone would never name. + */ +function channelVerdict(name: string, cliVersion: string): boolean | undefined { + const [release, nightly] = PACKAGE_NAMES; + if (isNightly(cliVersion)) return name === release ? false : undefined; + return name === nightly ? true : undefined; +} + /** The `package.json` fields a dependency pin may live in. */ const DEPENDENCY_FIELDS = [ "dependencies", @@ -75,7 +99,23 @@ const SCRIPT_PIN = const BOUNDED_SPEC = /^([\^~=])?\s*v?(\d+)\.(\d+)\.(\d+)(-[\w.-]+)?(\+[\w.-]+)?$/; -/** One pin that would run a CLI older than the one that just ran. */ +/** + * Why an entry is raised, which is also what to do about it: + * + * - `dependency`: the CLI is a project dependency. Remove it, and point what + * called its binary at the launcher. + * - `stale`: a script names an older build. Move it to the running version. + * - `nightly`: a script still names the nightly after a release install. Move + * it back to the release. + * - `unreleased`: a nightly migrated an existing `.taskless/`, and a script + * names a release. A nightly installs alongside the release, so the pin is + * left as it is, but a release that predates the new schema refuses the + * project. Nothing to change in `package.json`; the person decides whether + * to commit the migration before a release that reads it ships. + */ +export type PinReason = "dependency" | "stale" | "nightly" | "unreleased"; + +/** One `package.json` entry naming the Taskless CLI that an install raises. */ export interface PinnedCli { /** * The file holding the pin, relative to the directory searched and @@ -95,10 +135,28 @@ export interface PinnedCli { spec: string; /** * The version installed under `node_modules/`, for a dependency pin whose - * package is installed; `null` otherwise. When present it is what was - * judged, since it is what runs. + * package is installed; `null` otherwise. It is what runs. */ installed: string | null; + /** Why the entry is raised, and so what to do about it. */ + reason: PinReason; +} + +/** The migration an install ran, as `getPinnedCliNotice` reads it. */ +export interface PinMigration { + from: number; + to: number; +} + +/** + * The migration that moved an EXISTING scaffold, or `undefined`. A migration + * from schema 0 is how a fresh `init` creates `.taskless/`, so there was no + * layout any pin used to read, and calling it an upgrade would be false. + */ +function upgradeOf( + migrated: PinMigration | undefined +): PinMigration | undefined { + return migrated !== undefined && migrated.from > 0 ? migrated : undefined; } /** @@ -269,12 +327,42 @@ async function readInstalledVersion( } } -/** Every stale pin in one parsed manifest: dependency fields, then scripts. */ +/** + * Why a version spelled out in a command (a `package.json` script, a CI or hook + * line) is raised, or `undefined` when it is not. Judged by channel first; see + * {@link channelVerdict}. + */ +function scriptPinReason( + name: string, + spec: string, + cliVersion: string, + upgraded: PinMigration | undefined +): PinReason | undefined { + const [release] = PACKAGE_NAMES; + switch (channelVerdict(name, cliVersion)) { + case true: { + return "nightly"; + } + case false: { + // Left alone, and raised only when the nightly moved the layout out + // from under it. + return upgraded !== undefined && name === release + ? "unreleased" + : undefined; + } + case undefined: { + return isStale(spec, cliVersion) ? "stale" : undefined; + } + } +} + +/** Every raised entry in one parsed manifest: dependency fields, then scripts. */ async function findPinsIn( cwd: string, manifest: string, parsed: unknown, - cliVersion: string + cliVersion: string, + upgraded: PinMigration | undefined ): Promise { if (!isRecord(parsed)) return []; const directory = join(cwd, dirname(manifest)); @@ -287,21 +375,14 @@ async function findPinsIn( const spec = dependencies[name]; if (typeof spec !== "string") continue; const installed = await readInstalledVersion(cwd, directory, name); - // Either one is a real failure: an installed build older than this one - // is what runs today, and a range that cannot reach this version is - // what a fresh install will resolve. - const stale = - (installed !== undefined && compareSemver(installed, cliVersion) < 0) || - isStale(spec, cliVersion); - if (stale) { - pins.push({ - manifest, - location: field, - name, - spec, - installed: installed ?? null, - }); - } + pins.push({ + manifest, + location: field, + name, + spec, + installed: installed ?? null, + reason: "dependency", + }); } } @@ -317,13 +398,15 @@ async function findPinsIn( // is one pin to change, not two. if (spec === undefined || seen.has(`${name}@${spec}`)) continue; seen.add(`${name}@${spec}`); - if (isStale(spec, cliVersion)) { + const reason = scriptPinReason(name, spec, cliVersion, upgraded); + if (reason !== undefined) { pins.push({ manifest, location: `scripts.${script}`, name, spec, installed: null, + reason, }); } } @@ -334,19 +417,18 @@ async function findPinsIn( } /** - * Every stale pin spelled out in one CI or hook file, by line. + * Every raised pin spelled out in one CI or hook file, by line. * * Read as text with the same grammar as a `package.json` script, because that - * is what these files are: commands, wrapped in YAML or a shell script. Once - * every build pins its own version, the invocation the `ci` and `hooks` - * recipes write names that version, and an upgrade leaves it behind. Only a - * bounded spec is judged, as for scripts, and a pin repeated in one file is - * reported once, at its first line. + * is what these files are: commands, wrapped in YAML or a shell script. A pin + * is judged as a script pin is, and a pin repeated in one file is reported + * once, at its first line. */ async function findPinsInAutomationFile( cwd: string, file: string, - cliVersion: string + cliVersion: string, + upgraded: PinMigration | undefined ): Promise { let text: string; try { @@ -362,13 +444,15 @@ async function findPinsInAutomationFile( const name = `@taskless/${suffix ?? "cli"}`; if (spec === undefined || seen.has(`${name}@${spec}`)) continue; seen.add(`${name}@${spec}`); - if (isStale(spec, cliVersion)) { + const reason = scriptPinReason(name, spec, cliVersion, upgraded); + if (reason !== undefined) { pins.push({ manifest: file, location: `line ${String(index + 1)}`, name, spec, installed: null, + reason, }); } } @@ -377,120 +461,160 @@ async function findPinsInAutomationFile( } /** - * Every stale pin in `/package.json`, in each workspace package it + * Every raised entry in `/package.json`, in each workspace package it * declares, and in the project's CI and hook files: root first, then * workspace packages by path, then CI and hook files by path, each in file - * order. An absent or unreadable file has no pins. + * order. An absent or unreadable file has none. + * + * `migrated` is the migration this run applied, when there was one. Only an + * install knows it, so `info` passes nothing and never sees `unreleased`. * * Unreadable is not an error here. This is advice attached to a successful * command, and a malformed manifest is something the package manager will * report on its own terms; failing an install over it would be the wrong * tool refusing. */ -export async function findStalePins( +export async function findCliPins( cwd: string, - cliVersion: string + cliVersion: string, + options: { migrated?: PinMigration } = {} ): Promise { + const upgraded = upgradeOf(options.migrated); const root = await readJson(join(cwd, "package.json")); - const pins = await findPinsIn(cwd, "package.json", root, cliVersion); + const pins = await findPinsIn( + cwd, + "package.json", + root, + cliVersion, + upgraded + ); for (const manifest of await listWorkspaceManifests(cwd, root)) { pins.push( ...(await findPinsIn( cwd, manifest, await readJson(join(cwd, manifest)), - cliVersion + cliVersion, + upgraded )) ); } for (const file of automationFiles(cwd)) { - pins.push(...(await findPinsInAutomationFile(cwd, file, cliVersion))); + pins.push( + ...(await findPinsInAutomationFile(cwd, file, cliVersion, upgraded)) + ); } return pins; } /** * The package and version to move a pin to: the package that actually carries - * `cliVersion`. A nightly is always `-x` and a release never - * has a prerelease, so the version alone says which one it is, and a pin on - * the other package has to switch names rather than name a version its own - * package never published. + * `cliVersion`. A pin on the other package has to switch names rather than + * name a version its own package never published. Only a release run reports + * a pin on the other package (see {@link channelVerdict}), so in practice the + * switch is always a nightly pin moving back to the release. */ function bumpTarget(cliVersion: string): { name: string; version: string } { const [release, nightly] = PACKAGE_NAMES; return { - name: cliVersion.includes("-") ? nightly : release, + name: isNightly(cliVersion) ? nightly : release, version: cliVersion, }; } +/** What to do with one raised entry, as the arrow side of its line. */ +function pinAction(pin: PinnedCli, cliVersion: string): string { + switch (pin.reason) { + case "dependency": { + return "remove the dependency"; + } + case "unreleased": { + return "leave as is"; + } + case "stale": + case "nightly": { + const target = bumpTarget(cliVersion); + return pin.name === target.name + ? `${target.name}@${target.version}` + : `${target.name}@${target.version}, replacing ${pin.name}`; + } + } +} + /** - * One pin, unbulleted: which manifest and field the pin is in, what it says, and - * what to change it to. The manifest is named even at the root, so a list + * One entry, unbulleted: which manifest and field it is in, what it says, and + * what to do with it. The manifest is named even at the root, so a list * mixing the root with workspace packages reads the same way throughout. */ export function describePin(pin: PinnedCli, cliVersion: string): string { - const target = bumpTarget(cliVersion); const installed = pin.installed === null ? "" : ` (installed ${pin.installed})`; - const move = - pin.name === target.name - ? `${target.name}@${target.version}` - : `${target.name}@${target.version}, replacing ${pin.name}`; - return `${pin.manifest} ${pin.location}: ${pin.name} ${pin.spec}${installed} -> ${move}`; + return `${pin.manifest} ${pin.location}: ${pin.name} ${pin.spec}${installed} -> ${pinAction(pin, cliVersion)}`; } /** - * The notice an install prints for stale pins, or `undefined` when there are - * none. + * The notice an install prints for raised entries, or `undefined` when there + * are none. One paragraph per reason present, after the list. * * Worded as something to offer, not something done. The install never edits * `package.json`, a CI file, or a hook: a pin is often deliberate (a CI image, a reproducible - * build), and bumping it changes a lockfile the person has not seen. What the - * notice owes them is that the pin and the project now disagree, and which - * version would agree. + * build), and changing it changes a lockfile the person has not seen. * - * A migration of an EXISTING scaffold changes how sure that disagreement is. - * Every CLI refuses a scaffold newer than its own highest migration + * A migration of an EXISTING scaffold changes how sure the breakage is. Every + * CLI refuses a scaffold newer than its own highest migration * (`SCAFFOLD_VERSION_MISMATCH`), so once this run has moved the project to - * schema `to`, a pin that predates that schema fails on its first run. That - * run is CI on the push that carries the migrated files, so the bump has to - * ride in the same commit, not a later one. - * - * A migration from schema 0 is not that. It is how a fresh `init` creates - * `.taskless/`, so there was no upgrade and no layout the pin used to read; - * calling it one would be false on the face of it. That case, like a run with - * no migration, says the failure is likely rather than certain. + * schema `to`, anything that predates that schema fails on its first run. + * That run is CI on the push that carries the migrated files, so the change + * has to ride in the same commit, not a later one. A fresh install (a + * migration from schema 0) is not that; see {@link upgradeOf}. */ export function getPinnedCliNotice( pins: readonly PinnedCli[], cliVersion: string, - options: { migrated?: { from: number; to: number } } = {} + options: { migrated?: PinMigration } = {} ): string | undefined { if (pins.length === 0) return undefined; - const upgraded = - options.migrated !== undefined && options.migrated.from > 0 - ? options.migrated - : undefined; - // Only a dependency pin has anything to reinstall. A script, CI, or hook pin - // is a launcher line, and editing it is the whole fix. - const reinstall = pins.some((pin) => - (DEPENDENCY_FIELDS as readonly string[]).includes(pin.location) - ); - const consequence = + const upgraded = upgradeOf(options.migrated); + const has = (reason: PinReason): boolean => + pins.some((pin) => pin.reason === reason); + const migration = upgraded === undefined - ? `Anything that runs these pins (a script, CI, a git hook) runs a CLI older than this project expects and will likely fail. ` + - (reinstall - ? `Offer to update them as shown as part of this change, then reinstall dependencies.` - : `Offer to update them as shown as part of this change.`) - : `This upgrade migrated .taskless/ from schema version ${String(upgraded.from)} to ${String(upgraded.to)}, and a CLI that predates that schema refuses the project (SCAFFOLD_VERSION_MISMATCH). ` + - `CI, scripts, and git hooks that run these pins will break on the push that carries the migrated files. ` + - (reinstall - ? `Offer to update them as shown and reinstall dependencies, in the same commit as .taskless/.` - : `Offer to update them as shown, in the same commit as .taskless/.`); - return renderNoticeBox("UPDATE PINNED TASKLESS VERSIONS", [ - `This project pins a Taskless CLI older than ${cliVersion}, the version that just ran here:`, + ? undefined + : `migrated .taskless/ from schema version ${String(upgraded.from)} to ${String(upgraded.to)}, and a CLI that predates that schema refuses the project (SCAFFOLD_VERSION_MISMATCH)`; + const breakage = + migration === undefined + ? `Anything that runs them (a script, CI, a git hook) runs a CLI older than this project expects and will likely fail.` + : `This upgrade ${migration}, so CI, scripts, and git hooks that run them break on the push that carries the migrated files. Make the change in the same commit as .taskless/.`; + + const paragraphs: string[] = []; + if (has("dependency")) { + paragraphs.push( + `Taskless runs through the project launcher, node .taskless/bin/cli.mjs, which runs the version .taskless/taskless.json records, not as a project dependency. ` + + `A dependency stays on the build the lockfile resolved while upgrades move .taskless/ forward, and that build is what scripts, CI, and git hooks run. ` + + `Offer to remove it, point anything that calls its \`taskless\` binary at the launcher, then reinstall dependencies.` + ); + } + if (has("nightly")) { + paragraphs.push( + `A nightly pin keeps a script, CI, or a git hook on a prerelease build after this project moved back to the release. Offer to replace it as shown.` + ); + } + if (has("stale") || (has("dependency") && migration !== undefined)) { + paragraphs.push( + `${has("stale") ? "Offer to update the older pins as shown. " : ""}${breakage}` + ); + } + if (has("unreleased") && migration !== undefined) { + paragraphs.push( + `This nightly ${migration}. A nightly installs alongside the release, so the release pins marked "leave as is" stay as they are, ` + + `but one that predates schema version ${String(upgraded?.to)} fails on the push that carries the migrated files. ` + + `Tell the user before they commit .taskless/, so they can hold the migration until a release that reads it ships.` + ); + } + + return renderNoticeBox("TASKLESS CLI PINS", [ + `This project names the Taskless CLI in ways that need a look after ${cliVersion} ran here:`, { items: pins.map((pin) => describePin(pin, cliVersion)) }, - consequence, + ...paragraphs, ]); } diff --git a/packages/cli/src/schemas/info.ts b/packages/cli/src/schemas/info.ts index 3cc97455..f3497f13 100644 --- a/packages/cli/src/schemas/info.ts +++ b/packages/cli/src/schemas/info.ts @@ -35,7 +35,8 @@ const hostToolSchema = z.object({ }); /** - * A `package.json` pin that would run a CLI older than this one. The same + * A pin of the Taskless CLI an install raises, with the reason it is raised + * (see `PinReason` in `install/pinned-cli.ts`). The same * shape `init --json` carries as `pinnedCli`, so an agent reading either one * reads the same thing. */ @@ -58,6 +59,11 @@ const pinnedCliSchema = z.object({ .describe( "The installed version the pin runs, or null when nothing is installed or the pin is in a script, CI file, or hook" ), + reason: z + .enum(["dependency", "stale", "nightly", "unreleased"]) + .describe( + "Why it is raised, and so what to do: `dependency` a package.json dependency on the CLI, to remove in favour of the project launcher; `stale` a version older than this one, to move to it; `nightly` a nightly left over after a release install, to move to the release; `unreleased` a release pin a nightly migration left behind, to leave as is while telling the user (install only)" + ), }); const authSchema = z.object({ @@ -126,7 +132,7 @@ export const outputSchema = z.object({ pinnedCli: z .array(pinnedCliSchema) .describe( - "Pins of the Taskless CLI, release or nightly, in package.json and its workspace packages, that would run a CLI older than this one. Empty when nothing is stale" + "Pins of the Taskless CLI in package.json, its workspace packages, and CI and hook files that need a look: every dependency on the CLI, and spelled-out versions that are wrong for this project. Empty when there are none" ), }); diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index 2c7ad071..4c5bb387 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -14,6 +14,7 @@ import { import { buildLauncherInvocation, describeLauncherWrite, + describeRetired, } from "../install/launcher"; import { globalTrustWarnings, @@ -22,7 +23,7 @@ import { type GlobalTrustEnvironment, } from "../install/global-trust"; import { syncPermissions } from "../install/permissions"; -import { findStalePins, getPinnedCliNotice } from "../install/pinned-cli"; +import { findCliPins, getPinnedCliNotice } from "../install/pinned-cli"; import { getReloadNotice } from "../install/reload-notice"; import { computeInstallDiff, readInstallState } from "../install/state"; import { findUncommitted, installedPaths } from "../install/uncommitted"; @@ -140,9 +141,11 @@ export async function runWizard( }, }); const cliVersion = getCliVersion(); - const { launcher, lockfile } = await applyInstallPlan(options.cwd, plan, { - cliVersion, - }); + const { launcher, lockfile, retired } = await applyInstallPlan( + options.cwd, + plan, + { cliVersion } + ); const created: string[] = []; for (const write of [launcher, lockfile]) { if (write === null || write.state === "unchanged") continue; @@ -152,6 +155,7 @@ export async function runWizard( created.push(describeLauncherWrite(write)); } } + created.push(...retired.map((path) => describeRetired(path))); if (created.length > 0) log.message(created.join("\n")); await syncPermissions(options.cwd, { ...permissionOptions, write: true }); // Confirmed with the project edits, in the summary's one question. @@ -199,7 +203,7 @@ export async function runWizard( : "Taskless is ready to go." ); const pinnedNotice = getPinnedCliNotice( - await findStalePins(options.cwd, cliVersion), + await findCliPins(options.cwd, cliVersion, { migrated }), cliVersion, { migrated } ); diff --git a/packages/cli/test/info.test.ts b/packages/cli/test/info.test.ts index 9e78db2b..8438399f 100644 --- a/packages/cli/test/info.test.ts +++ b/packages/cli/test/info.test.ts @@ -186,12 +186,13 @@ describe("taskless info --json", () => { name: "@taskless/cli", spec: "0.0.1", installed: null, + reason: "dependency", }, ]); const { stdout } = await runCli(["info", "--anonymous", "-d", cwd], cwd); expect(stdout).toContain( - "packages/app/package.json devDependencies: @taskless/cli 0.0.1 ->" + "packages/app/package.json devDependencies: @taskless/cli 0.0.1 -> remove the dependency" ); }); }); diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index 960e6a5c..67b90feb 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -311,7 +311,7 @@ describe("taskless init (the batch install)", () => { expect(stdout).not.toContain("moved from"); }); - it("names a package.json pin older than the running CLI, and leaves the pin alone", async () => { + it("names package.json entries for the CLI, and leaves them alone", async () => { // The pin is what CI and `pnpm lint` run after an upgrade made through a // launcher. Offered, not applied: the install never edits package.json. const packageJson = JSON.stringify({ @@ -329,11 +329,13 @@ describe("taskless init (the batch install)", () => { ]); const notice = boxedText(stdout); - expect(notice).toContain("UPDATE PINNED TASKLESS VERSIONS"); - expect(notice).toContain("devDependencies: @taskless/cli 0.0.1"); + expect(notice).toContain("TASKLESS CLI PINS"); + expect(notice).toContain( + "devDependencies: @taskless/cli 0.0.1 -> remove the dependency" + ); expect(notice).toContain("scripts.lint: @taskless/cli 0.0.1"); expect(notice).toMatch(/-> @taskless\/cli@\S+/); - expect(notice).toContain("Offer to update them as shown"); + expect(notice).toContain("Offer to update the older pins as shown"); expect(await readFile(join(cwd, "package.json"), "utf8")).toBe(packageJson); // After the upgrade trailer, which it follows from; the onboarding line @@ -341,7 +343,7 @@ describe("taskless init (the batch install)", () => { const lines = stdout.trimEnd().split("\n"); const trailerAt = lines.findIndex((line) => line.includes("next commit")); const pinAt = lines.findIndex((line) => - line.includes("pins a Taskless CLI older") + line.includes("names the Taskless CLI") ); // Both present first: `findIndex` is -1 for a missing line, and -1 sorts // before everything, so the ordering check alone passes on an absent @@ -384,7 +386,7 @@ describe("taskless init (the batch install)", () => { // was upgraded, and no layout the pin used to read has moved. await writeFile( join(cwd, "package.json"), - JSON.stringify({ devDependencies: { "@taskless/cli": "0.0.1" } }) + JSON.stringify({ scripts: { lint: "npx @taskless/cli@0.0.1 check" } }) ); const { stdout } = await execFileAsync("node", [ @@ -394,7 +396,7 @@ describe("taskless init (the batch install)", () => { cwd, ]); - expect(stdout).toContain("devDependencies: @taskless/cli 0.0.1"); + expect(stdout).toContain("scripts.lint: @taskless/cli 0.0.1"); expect(boxedText(stdout)).toContain("will likely fail"); expect(boxedText(stdout)).not.toContain("SCAFFOLD_VERSION_MISMATCH"); expect(stdout).not.toContain("This upgrade migrated"); @@ -405,7 +407,7 @@ describe("taskless init (the batch install)", () => { // project still disagree. await writeFile( join(cwd, "package.json"), - JSON.stringify({ devDependencies: { "@taskless/cli": "^0.0.1" } }) + JSON.stringify({ scripts: { lint: "npx @taskless/cli@^0.0.1 check" } }) ); await execFileAsync("node", [binPath, "init", "-d", cwd]); @@ -417,7 +419,7 @@ describe("taskless init (the batch install)", () => { ]); expect(stdout).not.toContain("next commit"); - expect(stdout).toContain("devDependencies: @taskless/cli ^0.0.1"); + expect(stdout).toContain("scripts.lint: @taskless/cli ^0.0.1"); // Nothing migrated, so the layout the pin reads did not move. expect(boxedText(stdout)).toContain("will likely fail"); expect(boxedText(stdout)).not.toContain("SCAFFOLD_VERSION_MISMATCH"); @@ -455,6 +457,7 @@ describe("taskless init (the batch install)", () => { name: "@taskless/cli", spec: "0.0.1", installed: null, + reason: "dependency", }, ]); }); diff --git a/packages/cli/test/install-launcher.test.ts b/packages/cli/test/install-launcher.test.ts index cc924d2f..3d241523 100644 --- a/packages/cli/test/install-launcher.test.ts +++ b/packages/cli/test/install-launcher.test.ts @@ -93,21 +93,36 @@ describe("the release's install writes its launcher", () => { ); }); - it("keeps the nightly's recorded version", async () => { - await mkdir(join(cwd, ".taskless")); + it("retires the nightly: its record, launcher, and lockfile", async () => { + // Installing the release is the move back to mainline. + await mkdir(join(cwd, ".taskless", "bin"), { recursive: true }); await writeInstallState(cwd, { cliVersion: "0.14.0-20261020120000xabc1234", launchers: { "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234" }, targets: {}, }); + await writeFile(join(cwd, ".taskless", "bin", "cli-nightly.mjs"), ""); + await writeFile(join(cwd, ".taskless", "bin", "cli-nightly.lock.json"), ""); - await install("0.13.0"); + const result = await install("0.13.0"); + expect(result.retired).toEqual([ + ".taskless/bin/cli-nightly.mjs", + ".taskless/bin/cli-nightly.lock.json", + ]); + await expect( + stat(join(cwd, ".taskless", "bin", "cli-nightly.mjs")) + ).rejects.toThrow(); + await expect( + stat(join(cwd, ".taskless", "bin", "cli-nightly.lock.json")) + ).rejects.toThrow(); const { launchers } = await readInstallState(cwd); - expect(launchers).toEqual({ - "@taskless/cli-nightly": "0.14.0-20261020120000xabc1234", - "@taskless/cli": "0.13.0", - }); + expect(launchers).toEqual({ "@taskless/cli": "0.13.0" }); + }); + + it("retires nothing when there is no nightly", async () => { + const result = await install("0.13.0"); + expect(result.retired).toEqual([]); }); it("names the launcher in the installed skill", async () => { @@ -167,7 +182,7 @@ async function listing(path: string) { } describe("an install prunes the launcher cache", () => { - it("drops the release's previous version and keeps the nightly's", async () => { + it("drops the release's previous version and the retired nightly's", async () => { await mkdir(join(cwd, ".taskless")); await writeInstallState(cwd, { cliVersion: "0.13.0", @@ -183,9 +198,7 @@ describe("an install prunes the launcher cache", () => { await install("0.13.1"); - expect(await listing(join(cwd, CACHE, "@taskless"))).toEqual([ - "cli-nightly@0.14.0-20261020120000xabc1234", - ]); + expect(await listing(join(cwd, CACHE, "@taskless"))).toEqual([]); }); it("removes stale temporary installs and keeps fresh ones", async () => { diff --git a/packages/cli/test/nightly/install-launcher-nightly.test.ts b/packages/cli/test/nightly/install-launcher-nightly.test.ts index a9b3cc07..56968fc1 100644 --- a/packages/cli/test/nightly/install-launcher-nightly.test.ts +++ b/packages/cli/test/nightly/install-launcher-nightly.test.ts @@ -77,6 +77,32 @@ describe("a nightly install beside the release", () => { expect(state.cliVersion).toBe(version); }); + it("keeps a release version only cliVersion implied, and retires nothing", async () => { + // A manifest written before `launchers` existed records the release only + // as `cliVersion`, which this install overwrites. + await mkdir(join(cwd, ".taskless", "bin"), { recursive: true }); + await writeFile( + join(cwd, RELEASE_LAUNCHER), + renderLauncher("@taskless/cli") + ); + await writeInstallState(cwd, { cliVersion: "0.13.0", targets: {} }); + + const version = getCliVersion(); + const result = await applyInstallPlan( + cwd, + buildInstallPlan([".claude"], getEmbeddedSkills()), + { cliVersion: version } + ); + + expect(result.retired).toEqual([]); + await stat(join(cwd, RELEASE_LAUNCHER)); + const state = await readInstallState(cwd); + expect(state.launchers).toEqual({ + "@taskless/cli": "0.13.0", + "@taskless/cli-nightly": version, + }); + }); + it("names its own launcher in the installed skill", async () => { await applyInstallPlan( cwd, diff --git a/packages/cli/test/permissions.test.ts b/packages/cli/test/permissions.test.ts index c715d01f..c24e3ea2 100644 --- a/packages/cli/test/permissions.test.ts +++ b/packages/cli/test/permissions.test.ts @@ -271,7 +271,7 @@ describe("upgrades", () => { }); describe("the release and the nightly side by side", () => { - it("keeps each other's rules", async () => { + it("installs a nightly beside the release's rules", async () => { await sync({ selected: [".claude"], invocation: V1 }); const edits = await sync({ selected: [".claude"], invocation: N1 }); @@ -279,13 +279,27 @@ describe("the release and the nightly side by side", () => { allowed: [`Bash(${N1} *)`], removed: [], }); - expect(await sync({ selected: [".claude"], invocation: V1 })).toEqual([]); expect(await sync({ selected: [".claude"], invocation: N1 })).toEqual([]); expect(await json(".claude/settings.json")).toEqual({ permissions: { allow: [`Bash(${V1} *)`, `Bash(${N1} *)`] }, }); }); + it("removes the nightly's rules on a release install", async () => { + // Installing the release is the move back to mainline. + await sync({ selected: [".claude"], invocation: V1 }); + await sync({ selected: [".claude"], invocation: N1 }); + const edits = await sync({ selected: [".claude"], invocation: V1 }); + + expect(edits[0]).toMatchObject({ + allowed: [], + removed: [`Bash(${N1} *)`], + }); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: [`Bash(${V1} *)`] }, + }); + }); + it("upgrades only its own package's rule", async () => { await sync({ selected: [".claude"], invocation: V1 }); await sync({ selected: [".claude"], invocation: N1 }); @@ -300,7 +314,21 @@ describe("the release and the nightly side by side", () => { }); }); - it("leaves an unrecorded rule for the other package alone", async () => { + it("leaves an unrecorded release rule alone on a nightly install", async () => { + const release = "Bash(npx @taskless/cli@0.12.0:*)"; + await put( + ".claude/settings.json", + JSON.stringify({ permissions: { allow: [release] } }) + ); + const edits = await sync({ selected: [".claude"], invocation: N1 }); + + expect(edits[0]?.removed).toEqual([]); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: [release, `Bash(${N1} *)`] }, + }); + }); + + it("removes an unrecorded nightly rule on a release install", async () => { const nightly = "Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23:*)"; await put( @@ -309,22 +337,22 @@ describe("the release and the nightly side by side", () => { ); const edits = await sync({ selected: [".claude"], invocation: V1 }); - expect(edits[0]?.removed).toEqual([]); + expect(edits[0]?.removed).toEqual([nightly]); expect(await json(".claude/settings.json")).toEqual({ - permissions: { allow: [nightly, `Bash(${V1} *)`] }, + permissions: { allow: [`Bash(${V1} *)`] }, }); }); - it("keeps the other package's record, so a created file is still deleted", async () => { + it("keeps the release's record, so a created file is still deleted", async () => { await sync({ selected: [".claude"], invocation: V1 }); await sync({ selected: [".claude"], invocation: N1 }); - await sync({ selected: [], invocation: V1 }); + await sync({ selected: [], invocation: N1 }); expect(await json(".claude/settings.json")).toEqual({ - permissions: { allow: [`Bash(${N1} *)`] }, + permissions: { allow: [`Bash(${V1} *)`] }, }); - await sync({ selected: [], invocation: N1 }); + await sync({ selected: [], invocation: V1 }); expect(await exists(".claude/settings.json")).toBe(false); }); @@ -365,7 +393,7 @@ describe("the release and the nightly side by side", () => { files: [".codex/rules/taskless.rules"], }, ])( - "keeps each other's rules in $harness", + "installs a nightly beside the release, and a release removes it, in $harness", async ({ selected, codex = false, files, seed }) => { if (seed !== undefined) await put(files[0] ?? "", seed); const run = (invocation: string) => sync({ selected, codex, invocation }); @@ -373,7 +401,6 @@ describe("the release and the nightly side by side", () => { await run(N1); await run(N2); - expect(await run(V1)).toEqual([]); expect(await run(N2)).toEqual([]); for (const file of files) { const contents = await text(file); @@ -381,6 +408,13 @@ describe("the release and the nightly side by side", () => { expect(contents).toContain("0.13.2-20261017000000xbbbbbbb"); expect(contents).not.toContain("0.13.2-20261016000000xaaaaaaa"); } + + await run(V1); + for (const file of files) { + const contents = await text(file); + expect(contents).toContain("@taskless/cli@0.13.0"); + expect(contents).not.toContain("cli-nightly"); + } } ); }); @@ -805,10 +839,20 @@ describe("launcher rules", () => { it("removes the launcher rule with its package when the harness is deselected", async () => { await sync({ selected: [".claude"], launcher: RL }); await sync({ selected: [".claude"], invocation: N1, launcher: NL }); - const edits = await sync({ selected: [], launcher: RL }); - expect(edits[0]?.removed).toEqual([`Bash(${V1} *)`, `Bash(${RL} *)`]); + const edits = await sync({ selected: [], invocation: N1, launcher: NL }); + expect(edits[0]?.removed).toEqual([`Bash(${N1} *)`, `Bash(${NL} *)`]); expect(await json(".claude/settings.json")).toEqual({ - permissions: { allow: [`Bash(${N1} *)`, `Bash(${NL} *)`] }, + permissions: { allow: [`Bash(${V1} *)`, `Bash(${RL} *)`] }, + }); + }); + + it("removes the nightly's launcher rule on a release install", async () => { + await sync({ selected: [".claude"], launcher: RL }); + await sync({ selected: [".claude"], invocation: N1, launcher: NL }); + const edits = await sync({ selected: [".claude"], launcher: RL }); + expect(edits[0]?.removed).toEqual([`Bash(${N1} *)`, `Bash(${NL} *)`]); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: [`Bash(${V1} *)`, `Bash(${RL} *)`] }, }); }); diff --git a/packages/cli/test/pinned-cli.test.ts b/packages/cli/test/pinned-cli.test.ts index 861288b0..ea91ee21 100644 --- a/packages/cli/test/pinned-cli.test.ts +++ b/packages/cli/test/pinned-cli.test.ts @@ -5,12 +5,12 @@ import chalk from "chalk"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - findStalePins, + findCliPins, getPinnedCliNotice, type PinnedCli, } from "../src/install/pinned-cli"; -describe("findStalePins", () => { +describe("findCliPins", () => { let cwd: string; beforeEach(async () => { @@ -37,7 +37,7 @@ describe("findStalePins", () => { /** The manifest of every stale pin, in report order. */ async function manifestsOf(cliVersion: string): Promise { - const pins = await findStalePins(cwd, cliVersion); + const pins = await findCliPins(cwd, cliVersion); return pins.map((pin) => pin.manifest); } @@ -55,20 +55,68 @@ describe("findStalePins", () => { } it("finds nothing without a package.json", async () => { - expect(await findStalePins(cwd, "0.11.2")).toEqual([]); + expect(await findCliPins(cwd, "0.11.2")).toEqual([]); }); it("finds nothing in a package.json that is not JSON", async () => { // Advice on a successful install; the package manager reports this. await writeFile(join(cwd, "package.json"), "{ not json"); - expect(await findStalePins(cwd, "0.11.2")).toEqual([]); + expect(await findCliPins(cwd, "0.11.2")).toEqual([]); }); - describe("a dependency range, with nothing installed", () => { + describe("dependencies", () => { + // The CLI runs through a launcher, so a dependency on it is raised + // whatever it names: current, older, unbounded, either package. + it.each(["0.11.2", "0.10.2", "^0.11.0", "latest", "workspace:*"])( + "raises %s", + async (spec) => { + await writePackage({ devDependencies: { "@taskless/cli": spec } }); + expect(await findCliPins(cwd, "0.11.2")).toEqual([ + { + manifest: "package.json", + location: "devDependencies", + name: "@taskless/cli", + spec, + installed: null, + reason: "dependency", + }, + ]); + } + ); + + it("reads every dependency field and the nightly package name", async () => { + await writePackage({ + dependencies: { "@taskless/cli": "0.9.0" }, + optionalDependencies: { "@taskless/cli-nightly": "0.10.0-2026x0" }, + peerDependencies: { "@taskless/cli": "0.9.0" }, + }); + const pins = await findCliPins(cwd, "0.11.2"); + expect(pins.map((pin) => [pin.location, pin.name, pin.reason])).toEqual([ + ["dependencies", "@taskless/cli", "dependency"], + ["optionalDependencies", "@taskless/cli-nightly", "dependency"], + ]); + }); + + it("raises a release dependency when a nightly is running", async () => { + // A nightly leaves release pins alone, but a dependency is raised for + // being a dependency, not for its channel. + await writePackage({ devDependencies: { "@taskless/cli": "0.11.2" } }); + const pins = await findCliPins(cwd, "0.12.0-20261002181147x023048f"); + expect(pins.map((pin) => pin.reason)).toEqual(["dependency"]); + }); + + it("names the installed build, since that is what runs", async () => { + await writePackage({ devDependencies: { "@taskless/cli": "^0.11.0" } }); + await install("@taskless/cli", "0.11.0"); + const [pin] = await findCliPins(cwd, "0.11.2"); + expect(pin?.installed).toBe("0.11.0"); + }); + }); + + describe("a script version, against a running release", () => { it.each([ ["0.10.2", true], ["=0.10.2", true], - ["= 0.10.2", true], ["v0.11.1", true], ["0.10.2+build.5", true], ["0.11.2", false], @@ -80,24 +128,25 @@ describe("findStalePins", () => { ["^0.0.3", true], ["~0.10.9", true], ["~0.11.0", false], - // Unbounded or unknowable: not reported, since a guess is a notice an - // agent learns to skip. + // Unbounded: not reported, since a guess is a notice an agent learns to + // skip. ["latest", false], ["*", false], [">=0.10.0", false], - ["workspace:*", false], - ["github:taskless/cli", false], ])("%s is stale against 0.11.2: %s", async (spec, stale) => { - await writePackage({ devDependencies: { "@taskless/cli": spec } }); - expect(await findStalePins(cwd, "0.11.2")).toEqual( + await writePackage({ + scripts: { lint: `npx @taskless/cli@${spec} check` }, + }); + expect(await findCliPins(cwd, "0.11.2")).toEqual( stale ? [ { manifest: "package.json", - location: "devDependencies", + location: "scripts.lint", name: "@taskless/cli", spec, installed: null, + reason: "stale", }, ] : [] @@ -106,14 +155,14 @@ describe("findStalePins", () => { }); describe("nightly ordering", () => { - // A nightly is stamped with the release it anticipates, so it sorts - // before that release, and two nightlies of one base by build time. + // Two nightlies of one base sort by build time. Only a running nightly + // judges a nightly pin by version: a running release raises every + // nightly pin (see "channels"). it.each([ ["0.11.3-20260901000000xaaaaaaa", "0.11.3-20261005000000xbbbbbbb", true], ["0.11.3-20261005000000xbbbbbbb", "0.11.3-20261005000000xbbbbbbb", false], ["0.11.3-20261005000000xbbbbbbb", "0.11.3-20260901000000xaaaaaaa", false], - ["0.12.0-20261002181147x023048f", "0.12.0", true], - ["0.12.0-20261002181147x023048f", "0.11.2", false], + ["0.11.3-20261005000000xbbbbbbb", "0.12.0-20260901000000xaaaaaaa", true], // Semver precedence, not string order: numeric identifiers compare by // value, so rc.9 predates rc.10, and a numeric identifier sorts below // an alphanumeric one. @@ -125,92 +174,74 @@ describe("findStalePins", () => { "pin %s against running %s is stale: %s", async (pin, running, stale) => { await writePackage({ - devDependencies: { "@taskless/cli-nightly": pin }, + scripts: { lint: `npx @taskless/cli-nightly@${pin} check` }, }); - expect(await findStalePins(cwd, running)).toHaveLength(stale ? 1 : 0); + expect(await findCliPins(cwd, running)).toHaveLength(stale ? 1 : 0); } ); it("judges a range by its ceiling, not by a prerelease of the next base", async () => { - await writePackage({ devDependencies: { "@taskless/cli": "^0.11.0" } }); + await writePackage({ + scripts: { lint: "npx @taskless/cli-nightly@^0.11.0 check" }, + }); expect( - await findStalePins(cwd, "0.12.0-20261002181147x023048f") + await findCliPins(cwd, "0.12.0-20261002181147x023048f") ).toHaveLength(1); }); }); - describe("the installed version", () => { - it("reports a range that admits the running version when the installed build is older", async () => { - // `pnpm add -D` writes `^0.11.0` and locks 0.11.0, which is what CI - // runs. The range alone would say nothing. - await writePackage({ devDependencies: { "@taskless/cli": "^0.11.0" } }); - await install("@taskless/cli", "0.11.0"); - expect(await findStalePins(cwd, "0.11.2")).toEqual([ - { - manifest: "package.json", - location: "devDependencies", - name: "@taskless/cli", - spec: "^0.11.0", - installed: "0.11.0", - }, - ]); - }); + describe("channels", () => { + // A nightly is installed alongside the release, so it never offers to + // move the release's pins; a release is the move back, so it offers to + // clean up every nightly pin. + const nightly = "0.12.0-20261002181147x023048f"; - it("is quiet when the installed build is current and the range can reach it", async () => { - await writePackage({ devDependencies: { "@taskless/cli": "^0.11.0" } }); - await install("@taskless/cli", "0.11.2"); - expect(await findStalePins(cwd, "0.11.2")).toEqual([]); + it("leaves a release script pin alone when a nightly is running, however old", async () => { + await writePackage({ + scripts: { lint: "npx @taskless/cli@0.9.0 check" }, + }); + expect(await findCliPins(cwd, nightly)).toEqual([]); }); - it("still reports a range that cannot reach the running version, whatever is installed", async () => { - // The next fresh install resolves the range, not what happens to be - // in node_modules today. - await writePackage({ devDependencies: { "@taskless/cli": "^0.10.0" } }); - await install("@taskless/cli", "0.11.2"); - expect(await findStalePins(cwd, "0.11.2")).toEqual([ - { - manifest: "package.json", - location: "devDependencies", - name: "@taskless/cli", - spec: "^0.10.0", - installed: "0.11.2", + it("still raises an older nightly script pin when a nightly is running", async () => { + await writePackage({ + scripts: { + lint: "npx @taskless/cli@0.11.2 check", + try: "npx @taskless/cli-nightly@0.12.0-20260901000000xaaaaaaa check", }, + }); + const pins = await findCliPins(cwd, nightly); + expect(pins.map((pin) => [pin.location, pin.reason])).toEqual([ + ["scripts.try", "stale"], ]); }); - it("reads an older installed nightly of the same base", async () => { + it("raises a nightly script pin newer than the running release", async () => { await writePackage({ - devDependencies: { "@taskless/cli-nightly": "^0.12.0-0" }, + scripts: { lint: `npx @taskless/cli-nightly@${nightly} check` }, }); - await install("@taskless/cli-nightly", "0.12.0-20260901000000xaaaaaaa"); - expect( - await findStalePins(cwd, "0.12.0-20261005000000xbbbbbbb") - ).toHaveLength(1); + const pins = await findCliPins(cwd, "0.11.2"); + expect(pins.map((pin) => pin.reason)).toEqual(["nightly"]); }); - }); - it("reads every dependency field and the nightly package name", async () => { - await writePackage({ - dependencies: { "@taskless/cli": "0.9.0" }, - optionalDependencies: { "@taskless/cli-nightly": "0.10.0-2026x0" }, - peerDependencies: { "@taskless/cli": "0.9.0" }, + it("raises a release script pin as unreleased after a nightly migrated the project", async () => { + await writePackage({ + scripts: { lint: "npx @taskless/cli@0.11.2 check" }, + }); + const pins = await findCliPins(cwd, nightly, { + migrated: { from: 8, to: 9 }, + }); + expect(pins.map((pin) => pin.reason)).toEqual(["unreleased"]); + }); + + it("does not treat a nightly's fresh install as a migration", async () => { + await writePackage({ + scripts: { lint: "npx @taskless/cli@0.11.2 check" }, + }); + expect( + await findCliPins(cwd, nightly, { migrated: { from: 0, to: 9 } }) + ).toEqual([]); }); - expect(await findStalePins(cwd, "0.11.2")).toEqual([ - { - manifest: "package.json", - location: "dependencies", - name: "@taskless/cli", - spec: "0.9.0", - installed: null, - }, - { - manifest: "package.json", - location: "optionalDependencies", - name: "@taskless/cli-nightly", - spec: "0.10.0-2026x0", - installed: null, - }, - ]); }); it("finds a version spelled out in a script, and leaves @latest alone", async () => { @@ -223,13 +254,14 @@ describe("findStalePins", () => { current: "npx @taskless/cli@0.11.2 check", }, }); - expect(await findStalePins(cwd, "0.11.2")).toEqual([ + expect(await findCliPins(cwd, "0.11.2")).toEqual([ { manifest: "package.json", location: "scripts.lint", name: "@taskless/cli", spec: "0.10.2", installed: null, + reason: "stale", }, { manifest: "package.json", @@ -237,6 +269,7 @@ describe("findStalePins", () => { name: "@taskless/cli-nightly", spec: "0.10.0-2026x0", installed: null, + reason: "nightly", }, ]); }); @@ -250,21 +283,14 @@ describe("findStalePins", () => { "stops a script version at shell punctuation: %s", async (command, spec) => { await writePackage({ scripts: { lint: command } }); - expect(await findStalePins(cwd, "0.11.2")).toEqual([ - { - manifest: "package.json", - location: "scripts.lint", - name: "@taskless/cli", - spec, - installed: null, - }, - ]); + const pins = await findCliPins(cwd, "0.11.2"); + expect(pins.map((pin) => pin.spec)).toEqual([spec]); } ); it("does not match a longer name that ends in the package name", async () => { await writePackage({ scripts: { lint: "npx foo@taskless/cli@0.10.2" } }); - expect(await findStalePins(cwd, "0.11.2")).toEqual([]); + expect(await findCliPins(cwd, "0.11.2")).toEqual([]); }); it("reports a pin repeated in one script once", async () => { @@ -273,24 +299,25 @@ describe("findStalePins", () => { lint: "npx @taskless/cli@0.10.2 check && npx @taskless/cli@0.10.2 verify", }, }); - expect(await findStalePins(cwd, "0.11.2")).toHaveLength(1); + expect(await findCliPins(cwd, "0.11.2")).toHaveLength(1); }); describe("CI and hook files", () => { - // Every build pins its own version now, so the `ci` and `hooks` recipes - // write `npx @taskless/cli@`, and the next upgrade leaves it behind. + // The `ci` and `hooks` recipes once wrote `npx @taskless/cli@`, + // and the next upgrade leaves it behind. it("names a stale pin in a workflow by file and line", async () => { await writeFileAt( ".github/workflows/taskless.yml", "jobs:\n check:\n steps:\n - run: npx @taskless/cli@0.11.0 check\n" ); - expect(await findStalePins(cwd, "0.12.0")).toEqual([ + expect(await findCliPins(cwd, "0.12.0")).toEqual([ { manifest: ".github/workflows/taskless.yml", location: "line 4", name: "@taskless/cli", spec: "0.11.0", installed: null, + reason: "stale", }, ]); }); @@ -315,7 +342,7 @@ describe("findStalePins", () => { ".gitlab-ci.yml", "check:\n script:\n - npx @taskless/cli@0.12.0 check\n - npx @taskless/cli@latest check\n" ); - expect(await findStalePins(cwd, "0.12.0")).toEqual([]); + expect(await findCliPins(cwd, "0.12.0")).toEqual([]); }); it("reports a pin repeated in one file once, at its first line", async () => { @@ -323,7 +350,7 @@ describe("findStalePins", () => { ".github/workflows/ci.yml", "- run: npx @taskless/cli@0.11.0 check\n- run: npx @taskless/cli@0.11.0 report\n" ); - const pins = await findStalePins(cwd, "0.12.0"); + const pins = await findCliPins(cwd, "0.12.0"); expect(pins.map((pin) => pin.location)).toEqual(["line 1"]); }); @@ -335,6 +362,25 @@ describe("findStalePins", () => { ".gitlab-ci.yml", ]); }); + + it("judges a CI pin by channel, as a script pin is", async () => { + // A nightly leaves the release's CI alone; a release cleans up the + // nightly's. + await writeFileAt( + ".github/workflows/ci.yml", + "- run: npx @taskless/cli@0.11.0 check\n- run: npx @taskless/cli-nightly@0.13.0-20261002181147x023048f check\n" + ); + const underNightly = await findCliPins( + cwd, + "0.12.0-20261002181147x023048f" + ); + expect(underNightly).toEqual([]); + const underRelease = await findCliPins(cwd, "0.12.0"); + expect(underRelease.map((pin) => [pin.location, pin.reason])).toEqual([ + ["line 1", "stale"], + ["line 2", "nightly"], + ]); + }); }); describe("workspace packages", () => { @@ -348,13 +394,14 @@ describe("findStalePins", () => { ); await writeAt("packages/app", stalePin); await writeAt("packages/lib", { name: "lib" }); - expect(await findStalePins(cwd, "0.11.2")).toEqual([ + expect(await findCliPins(cwd, "0.11.2")).toEqual([ { manifest: "packages/app/package.json", location: "devDependencies", name: "@taskless/cli", spec: "^0.10.2", installed: null, + reason: "dependency", }, ]); }); @@ -404,7 +451,7 @@ describe("findStalePins", () => { const inner = join(cwd, "inner"); await writeAt("inner", { workspaces: ["../outside", "/abs/*"] }); await writeAt("outside", stalePin); - expect(await findStalePins(inner, "0.11.2")).toEqual([]); + expect(await findCliPins(inner, "0.11.2")).toEqual([]); }); it("reads the version the workspace package runs: its own link first, then the root's", async () => { @@ -416,28 +463,18 @@ describe("findStalePins", () => { await install("@taskless/cli", "0.11.0", "packages/linked"); // npm/yarn hoisting: only the root has it. await install("@taskless/cli", "0.11.1"); - const pins = await findStalePins(cwd, "0.11.2"); + const pins = await findCliPins(cwd, "0.11.2"); expect(pins.map((pin) => [pin.manifest, pin.installed])).toEqual([ ["packages/hoisted/package.json", "0.11.1"], ["packages/linked/package.json", "0.11.0"], ]); }); - it("is quiet when a workspace package's own link is current", async () => { - await writePackage({ workspaces: ["packages/*"] }); - await writeAt("packages/app", { - devDependencies: { "@taskless/cli": "^0.11.0" }, - }); - await install("@taskless/cli", "0.11.2", "packages/app"); - await install("@taskless/cli", "0.11.0"); - expect(await findStalePins(cwd, "0.11.2")).toEqual([]); - }); - it("still reads pnpm-workspace.yaml when the root package.json is malformed", async () => { await writeFile(join(cwd, "package.json"), "{ not json"); await writeFile(join(cwd, "pnpm-workspace.yaml"), "packages: [app]\n"); await writeAt("app", stalePin); - expect(await findStalePins(cwd, "0.11.2")).toHaveLength(1); + expect(await findCliPins(cwd, "0.11.2")).toHaveLength(1); }); }); }); @@ -464,15 +501,24 @@ function pinnedNotice( } describe("getPinnedCliNotice", () => { - const releasePin: PinnedCli = { + const dependency: PinnedCli = { manifest: "package.json", location: "devDependencies", name: "@taskless/cli", + spec: "^0.11.0", + installed: "0.11.0", + reason: "dependency", + }; + const script: PinnedCli = { + manifest: "packages/app/package.json", + location: "scripts.lint", + name: "@taskless/cli", spec: "0.10.2", installed: null, + reason: "stale", }; - it("is absent when nothing is stale", () => { + it("is absent when nothing is raised", () => { expect(getPinnedCliNotice([], "0.11.2")).toBeUndefined(); }); @@ -485,11 +531,10 @@ describe("getPinnedCliNotice", () => { const notice = getPinnedCliNotice( [ { - ...releasePin, + ...script, manifest: "packages/some-long-workspace-name/package.json", name: "@taskless/cli-nightly", spec: "0.11.1-20260831132610x088fa7c", - installed: "0.11.1-20260831132610x088fa7c", }, ], "0.12.0-20261006162512x92b3715" @@ -498,7 +543,7 @@ describe("getPinnedCliNotice", () => { const rows = plain(notice) .split("\n") .filter((line) => line !== ""); - expect(rows[1]).toContain("UPDATE PINNED TASKLESS VERSIONS"); + expect(rows[1]).toContain("TASKLESS CLI PINS"); const widths = new Set(rows.map((line) => line.length)); expect(widths, `rows: ${[...widths].join(", ")}`).toHaveLength(1); // Neither version is split across rows, so both can be copied. @@ -514,101 +559,109 @@ describe("getPinnedCliNotice", () => { }); it("names a CI pin by file and line", () => { - const pin: PinnedCli = { - manifest: ".github/workflows/ci.yml", - location: "line 12", - name: "@taskless/cli", - spec: "0.11.0", - installed: null, - }; - const text = pinnedNotice([pin], "0.12.0"); - expect(text).toContain( - ".github/workflows/ci.yml line 12: @taskless/cli 0.11.0 -> @taskless/cli@0.12.0" + const text = pinnedNotice( + [ + { + ...script, + manifest: ".github/workflows/ci.yml", + location: "line 12", + spec: "0.11.0", + }, + ], + "0.12.0" ); expect(text).toContain( - "This project pins a Taskless CLI older than 0.12.0" + ".github/workflows/ci.yml line 12: @taskless/cli 0.11.0 -> @taskless/cli@0.12.0" ); + expect(text).toContain("This project names the Taskless CLI"); }); - it("names every pin with its target, and offers the bump rather than claiming it", () => { - const notice = pinnedNotice( - [ - { ...releasePin, spec: "^0.11.0", installed: "0.11.0" }, - { - ...releasePin, - manifest: "packages/app/package.json", - location: "scripts.lint", - }, - ], - "0.11.2" + it("asks for a reinstall only when a dependency is raised", () => { + expect(pinnedNotice([dependency], "0.11.2")).toContain( + "reinstall dependencies" ); + // A script, CI, or hook pin is a launcher line: nothing to reinstall. + expect(pinnedNotice([script], "0.11.2")).not.toContain("reinstall"); + expect( + pinnedNotice([script], "0.11.2", { migrated: { from: 5, to: 9 } }) + ).not.toContain("reinstall"); + }); + + it("names every entry with what to do, and offers it rather than claiming it", () => { + const notice = pinnedNotice([dependency, script], "0.11.2"); expect(notice).toContain( - "- package.json devDependencies: @taskless/cli ^0.11.0 (installed 0.11.0) -> @taskless/cli@0.11.2" + "- package.json devDependencies: @taskless/cli ^0.11.0 (installed 0.11.0) -> remove the dependency" ); expect(notice).toContain( "- packages/app/package.json scripts.lint: @taskless/cli 0.10.2 -> @taskless/cli@0.11.2" ); - expect(notice).toContain("Offer to update them as shown"); + expect(notice).toContain("Offer to remove it"); + expect(notice).toContain("Offer to update the older pins as shown"); + }); + + it("points a removed dependency at the launcher", () => { + const notice = pinnedNotice([dependency], "0.11.2"); + expect(notice).toContain("runs through the project launcher"); + expect(notice).toContain("node .taskless/bin/cli.mjs"); + // Nothing older to bump, and no migration: no breakage claim. + expect(notice).not.toContain("will likely fail"); }); it("moves a nightly pin to the release package when a release is running", () => { // There is no @taskless/cli-nightly@0.11.2; nightlies always carry a stamp. const notice = pinnedNotice( - [{ ...releasePin, name: "@taskless/cli-nightly", spec: "0.11.2-2026x0" }], + [ + { + ...script, + name: "@taskless/cli-nightly", + spec: "0.12.0-20261002181147x023048f", + reason: "nightly", + }, + ], "0.11.2" ); expect(notice).toContain( "-> @taskless/cli@0.11.2, replacing @taskless/cli-nightly" ); + expect(notice).toContain("A nightly pin keeps"); + expect(notice).not.toContain("older than this project expects"); }); - it("moves a release pin to the nightly package when a nightly is running", () => { - const notice = pinnedNotice([releasePin], "0.12.0-20261002181147x023048f"); - expect(notice).toContain( - "-> @taskless/cli-nightly@0.12.0-20261002181147x023048f, replacing @taskless/cli" + it("leaves a release pin as is after a nightly migration, and says why it matters", () => { + const notice = pinnedNotice( + [{ ...script, spec: "0.11.2", reason: "unreleased" }], + "0.12.0-20261002181147x023048f", + { migrated: { from: 8, to: 9 } } ); + expect(notice).toContain("-> leave as is"); + expect(notice).not.toContain("replacing"); + expect(notice).toContain("installs alongside the release"); + expect(notice).toContain("predates schema version 9"); + expect(notice).toContain("Tell the user before they commit .taskless/"); }); it("hedges without a migration: the layout the pin reads did not move", () => { - const notice = pinnedNotice([releasePin], "0.11.2"); + const notice = pinnedNotice([script], "0.11.2"); expect(notice).toContain("will likely fail"); expect(notice).not.toContain("SCAFFOLD_VERSION_MISMATCH"); }); - it("states the breakage as certain after a migration, and ties the bump to the commit", () => { + it("states the breakage as certain after a migration, and ties the change to the commit", () => { // A CLI refuses a scaffold newer than its own highest migration, so the // pin fails on CI's first run against the migrated files. - const notice = pinnedNotice([releasePin], "0.11.2", { + const notice = pinnedNotice([dependency], "0.11.2", { migrated: { from: 5, to: 9 }, }); expect(notice).toContain("from schema version 5 to 9"); expect(notice).toContain("SCAFFOLD_VERSION_MISMATCH"); - expect(notice).toContain("will break"); + expect(notice).toContain("break on the push"); expect(notice).toContain("same commit as .taskless/"); expect(notice).not.toContain("likely"); }); - it("asks for a reinstall only when a dependency pin moves", () => { - expect(pinnedNotice([releasePin], "0.11.2")).toContain( - "reinstall dependencies" - ); - // A CI or hook pin is a launcher line: there is nothing to reinstall. - const ciPin: PinnedCli = { - manifest: ".github/workflows/ci.yml", - location: "line 12", - name: "@taskless/cli", - spec: "0.10.2", - installed: null, - }; - expect(pinnedNotice([ciPin], "0.11.2")).not.toContain("reinstall"); - expect( - pinnedNotice([ciPin], "0.11.2", { migrated: { from: 5, to: 9 } }) - ).not.toContain("reinstall"); - }); - it("does not call a fresh install an upgrade", () => { // A fresh `init` creates `.taskless/` by migrating from schema 0. - const notice = pinnedNotice([releasePin], "0.11.2", { + const notice = pinnedNotice([script], "0.11.2", { migrated: { from: 0, to: 9 }, }); expect(notice).not.toContain("upgrade"); diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index fd25176d..17a89a74 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -854,9 +854,9 @@ describe("the wizard's stale-pin notice", () => { const { runWizard } = await import("../src/wizard"); await runWizard({ cwd, globalTrustEnvironment }); const printed = logSpy.mock.calls.map((call) => String(call[0])); - expect( - printed.some((line) => line.includes("pins a Taskless CLI older")) - ).toBe(false); + expect(printed.some((line) => line.includes("TASKLESS CLI PINS"))).toBe( + false + ); } finally { logSpy.mockRestore(); }