diff --git a/.changeset/init-stale-cli-pins.md b/.changeset/init-stale-cli-pins.md new file mode 100644 index 00000000..7a2f65a3 --- /dev/null +++ b/.changeset/init-stale-cli-pins.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`taskless init` names any `package.json` pin of `@taskless/cli` or `@taskless/cli-nightly` that would run an older CLI than the one that just ran (a dependency whose installed build or range is behind, or a script spelling out an older version), with the version to move it to, and offers the bump. Scripts, CI and git hooks run that pin, and a CLI older than the project's `.taskless/` refuses the layout. The install does not edit `package.json`. `init --json` carries the pins as `pinnedCli`, and the `init` (topic v3) and `update` (topic v13) recipes tell an agent to offer the bump. diff --git a/openspec/changes/archive/2026-10-05-init-stale-cli-pins/proposal.md b/openspec/changes/archive/2026-10-05-init-stale-cli-pins/proposal.md new file mode 100644 index 00000000..4504784b --- /dev/null +++ b/openspec/changes/archive/2026-10-05-init-stale-cli-pins/proposal.md @@ -0,0 +1,67 @@ +## Why + +An upgrade is usually run through a launcher, `npx @taskless/cli@latest init`, +and the launcher leaves the project's own pins alone. A `devDependencies` entry +of `^0.10.2`, or a script spelling out `npx @taskless/cli@0.10.2 check`, is +what CI, a git hook and `pnpm lint` actually run. After the upgrade those run a +CLI older than the `.taskless/` it now finds, and an older CLI refuses a layout +newer than it understands ("Upgrade the CLI to continue"). Nothing about the +upgrade itself fails, so the first sign is a red CI run on the next push, and it +does not read as an upgrade problem when it arrives. + +The upgrade trailer already tells the caller what to commit and that `update` +exists. It says nothing about the one other place the upgrade is incomplete. + +## What Changes + +- `taskless init`, both the batch path and the wizard, reads `package.json` in + the working directory and names every pin of `@taskless/cli` or + `@taskless/cli-nightly` whose ceiling sits below the running CLI: an exact + version, or a `^`/`~` range that cannot reach it, in `dependencies`, + `devDependencies`, `optionalDependencies`, or spelled out in a script. It + offers the bump; it does not make it. +- A dependency is judged by the version installed under `node_modules/` as + well as by its range. `pnpm add -D` writes `^0.11.0` and locks 0.11.0; the + range admits 0.11.2, but CI runs the locked 0.11.0. Exact and installed + versions compare with semver precedence, so an older nightly of the same + base is stale. +- Each pin is named with the version to move it to, on the package that + publishes it: no `@taskless/cli-nightly@` exists, so a nightly pin + under a release CLI moves to `@taskless/cli`, and the reverse. +- When the same run migrated an EXISTING `.taskless/`, the notice states the + breakage as certain rather than likely: a CLI that predates the new schema + refuses the project with `SCAFFOLD_VERSION_MISMATCH`, so CI breaks on the + push carrying the migrated files, and the bump belongs in that same commit. + A fresh install migrates from schema 0 and is not called an upgrade. +- The `init` recipe goes to topic v3: its envelope example and field list + carry `pinnedCli`, "stop when `changed` is false" now also requires no + stale pins, and a step offers the bump. +- `init --json` carries the pins as `pinnedCli`, always present, empty when + nothing is stale. +- The `update` recipe goes to topic v13 with a step telling the agent to offer + the bump as part of the upgrade, without making it silently. +- `compareVersions` moves from `reconcile-marker.ts` to + `util/version-compare.ts`, unchanged, so both callers share it. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-init`: one ADDED requirement. No standing requirement is restated, + renamed or removed. The notice is separate from the upgrade trailer, so the + standing "a no-op re-install prints no upgrade trailer" scenario still holds. + +## Impact + +Additive output on `init`, plus one envelope field. `patch`: the package is +pre-1.0. A spec this change cannot bound (`latest`, `*`, `>=`, `workspace:`, a +URL) is not reported, so a project that floats its pin sees nothing new. + +## Delivery shape + +**Single PR.** The spec, detector, wiring, recipe step and tests are one small +reviewable diff. It is the tip, so the change is archived here. diff --git a/openspec/changes/archive/2026-10-05-init-stale-cli-pins/specs/cli-init/spec.md b/openspec/changes/archive/2026-10-05-init-stale-cli-pins/specs/cli-init/spec.md new file mode 100644 index 00000000..038a14c4 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-init-stale-cli-pins/specs/cli-init/spec.md @@ -0,0 +1,82 @@ +## ADDED Requirements + +### 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 report every pin of `@taskless/cli` or `@taskless/cli-nightly` that would run a CLI older than the running one. 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 at `node_modules//package.json` is older than the running version, because that, and the lockfile it came from, is what runs; 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. + +The install SHALL NOT modify `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 location (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 `{ location, name, spec, installed }`, where `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 `{ 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 diff --git a/openspec/changes/archive/2026-10-05-init-stale-cli-pins/tasks.md b/openspec/changes/archive/2026-10-05-init-stale-cli-pins/tasks.md new file mode 100644 index 00000000..63dc2c88 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-init-stale-cli-pins/tasks.md @@ -0,0 +1,50 @@ +## 1. Spec + +- [x] 1.1 Add the requirement to `cli-init` as an ADDED block, separate from + the upgrade trailer, so no standing requirement is restated and the + no-op scenario keeps holding. +- [x] 1.2 Dry-run `openspec archive` and compare the scenario count in + `cli-init` before and after. + +## 2. Detector + +- [x] 2.1 Move `compareVersions` to `util/version-compare.ts`, unchanged. +- [x] 2.2 `install/pinned-cli.ts`: read `package.json`, report bounded pins + whose ceiling is at or below the running version, in the three + dependency fields and in scripts. +- [x] 2.3 Treat an absent or unparseable `package.json` as no pins. + +## 3. Wiring + +- [x] 3.1 Batch `init`: print the notice after the upgrade trailer, not gated + on the run having changed anything; add `pinnedCli` to the envelope. +- [x] 3.2 Wizard: print the notice after the outro. +- [x] 3.3 When the run migrated, state the breakage as certain: name the + schema version and `SCAFFOLD_VERSION_MISMATCH`, and put the bump in the + same commit as `.taskless/`. + +## 4. Recipe + +- [x] 4.1 `update` topic v13: a step offering the bump, without making it + silently, before recording the walk. + +## 5. Tests + +- [x] 5.1 Detector: the spec table in both directions, every dependency + field, the nightly name, scripts, an unreadable `package.json`. +- [x] 5.2 Integration: notice order and content, `package.json` untouched, + the no-op re-install, and the `--json` field. + +## 6. Review fixes + +- [x] 6.1 Judge a dependency by its installed version as well as its range. +- [x] 6.2 Compare exact and installed versions with semver precedence, so an + older nightly of the same base is stale. +- [x] 6.3 Name each pin's target on the package that publishes it. +- [x] 6.4 Do not call a fresh install (migration from schema 0) an upgrade. +- [x] 6.5 `init` recipe topic v3: `pinnedCli` in the envelope and field list, + the stop rule, and a bump step. +- [x] 6.6 Script regex: left boundary, punctuation-terminated versions, one + report per repeated pin. +- [x] 6.7 Tests: the ordering guard, wizard coverage, fresh install, nightly + ordering, installed versions. diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 58af4ab9..9f887be3 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -802,3 +802,84 @@ If the user cancels the wizard at any step (Ctrl-C, Esc, or equivalent clack can - **WHEN** the user declines the summary confirm - **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 report every pin of `@taskless/cli` or `@taskless/cli-nightly` that would run a CLI older than the running one. 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 at `node_modules//package.json` is older than the running version, because that, and the lockfile it came from, is what runs; 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. + +The install SHALL NOT modify `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 location (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 `{ location, name, spec, installed }`, where `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 `{ 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 diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index 6f26e356..7c43706d 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 v2) +# Topic: init (CLI v%(CLI_VERSION)s / topic v3) ## Goal Install or update the Taskless skill in this project, and migrate the @@ -7,9 +7,10 @@ arrive here because `check`, `verify`, or `test` refused with `SCAFFOLD_MIGRATION_REQUIRED`: those commands only read, so the rewrite is left to `init`, which is the one command that migrates. -An install rewrites files under version control and can change what an -upgrade means for the rules already in the project. Running the command -is the first of three steps, not the whole job. +An install rewrites files under version control, can leave a pinned CLI +in `package.json` behind the project, and can change what an upgrade +means for the rules already in the project. Running the command is the +first step, not the whole job. ## Preconditions - None at the project level. The command works in any directory and @@ -40,7 +41,11 @@ is the first of three steps, not the whole job. ], "changed": true, "migrated": { "from": 3, "to": 4, "applied": [4], - "files": { "added": [], "modified": [], "removed": [] } } + "files": { "added": [], "modified": [], "removed": [] } }, + "pinnedCli": [ + { "location": "devDependencies", "name": "@taskless/cli", + "spec": "^0.10.0", "installed": "0.10.2" } + ] } ``` - `cliVersion.previous` is `null` on a project with no recorded @@ -50,14 +55,22 @@ is the first of three steps, not the whole job. store; `reference` is a tool directory holding stubs. - `changed` is `true` when a migration ran, any target list is non-empty, or `cliVersion` moved (that rewrites - `.taskless/taskless.json`). When it is `false`, stop here: nothing - to commit, nothing to reconcile. + `.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 `package.json` 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 spelling out an older version. It is + reported even when `changed` is `false`, because the pin and the + project still disagree. Without `--json`, the same facts print as prose: a per-target summary, then a trailer naming the directories that changed and, after a - version move, pointing at `update`. + version move, pointing at `update`, then a notice naming each stale + pin and the version to move it to. 2. **Tell the user what needs committing.** Every `targets[].dir` with a non-empty list, plus `.taskless/` and any `migrated.files` entries, @@ -66,7 +79,18 @@ is the first of three steps, 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. **After a version move, reconcile the rules.** When +3. **Offer to bump every stale pin.** For each `pinnedCli` entry, offer + the user the update to the installed CLI, along with reinstalling + dependencies. 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 `package.json` on your own; a pin can + be deliberate, and the bump changes the lockfile. + +4. **After a version move, reconcile the rules.** When `cliVersion.previous` is non-null and differs from `installed`, run ``` %(TASKLESS_CLI)s update @@ -77,14 +101,14 @@ is the first of three steps, not the whole job. engine). `update` is how to find out, and the only way to record that the walk was done. -4. **Treat your own session as stale.** A tool loads its skill list once, +5. **Treat your own session as stale.** A tool loads its skill list once, at startup. If Taskless was installed or upgraded during this session, the skill text in your context is the previous version. Tell the user the skills changed and that a new session, or a skill reload, picks them up. Recipes are unaffected: every `agent ` fetch reads the installed CLI. -5. **Return to what sent you here.** Re-run the command that refused. +6. **Return to what sent you here.** Re-run the command that refused. ## For a person at a terminal diff --git a/packages/cli/src/agent/update.md b/packages/cli/src/agent/update.md index bf31bcaf..8b22deae 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 v12) +# Topic: update (CLI v%(CLI_VERSION)s / topic v13) ## You are here This is `update`. It tells you what an upgrade changed for the rules @@ -54,7 +54,31 @@ that you finished. it means for existing rules. A section that says there is nothing to do means exactly that; it is a claim, not an oversight. -4. **Record that you finished.** Run: +4. **Offer to bump a pinned CLI.** If `package.json` pins + `@taskless/cli` or `@taskless/cli-nightly` (a dependency entry, or a + script spelling out `@taskless/cli@`) behind the installed + CLI, `init` names each pin and the version to move it to, and + `init --json` carries them as `pinnedCli`. Re-running `init --json` + is safe if you did not see that output yourself: on a current + project it changes nothing and still reports the pins. Those pins are + what scripts, CI, and git hooks run. + + If the upgrade also migrated an existing `.taskless/` (`init` printed + a migration, or `init --json` carried `migrated` with `from` above + `0`), this is not optional advice: a + CLI that predates the new schema refuses the project with + `SCAFFOLD_VERSION_MISMATCH`, so CI breaks on the push that carries the + migrated files. The bump belongs in that same commit. Without a + migration the pin still reads the layout, but checks rules against + engines this walk has moved past. + + 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 + depend on the answer, so record it either way, but if they decline + after a migration, tell them plainly that CI will fail. + +5. **Record that you finished.** Run: ``` %(TASKLESS_CLI)s update --rules ``` diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 74bfaccc..f1141b63 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -16,6 +16,11 @@ import { getMandatorySkillNames } from "../install/catalog"; import type { InstallMode } from "../install/state"; import { getReloadNotice, versionMoved } from "../install/reload-notice"; import { getUpgradeTrailer } from "../install/upgrade-trailer"; +import { + findStalePins, + getPinnedCliNotice, + type PinnedCli, +} from "../install/pinned-cli"; import { readInstallState } from "../install/state"; import { getTelemetry } from "../telemetry"; import { getCliVersion } from "../wizard/intro"; @@ -120,6 +125,9 @@ export const initCommand = defineCommand({ // is the one value an agent gates its commit step on, and folding // four lists and a presence check is how a consumer gets it wrong. changed: result.changed, + // Always present, empty when nothing is stale, for the same reason + // `cliVersion.previous` is `null` rather than absent. + pinnedCli: result.pinnedCli, // Absent when nothing ran, so a caller distinguishes "the tree was // rewritten" from "nothing happened" by presence, never by reading // empty arrays out of it. @@ -147,6 +155,17 @@ export const initCommand = defineCommand({ if (upgradeTrailer !== undefined) { console.log(upgradeTrailer); } + // Not gated on the run having changed anything. A re-install that wrote + // nothing still leaves the pin and the project disagreeing, and this is + // the one place that looks. + const pinnedNotice = getPinnedCliNotice( + result.pinnedCli, + result.cliVersion, + { migrated: result.migrated } + ); + if (pinnedNotice !== undefined) { + console.log(pinnedNotice); + } if (result.reloadNotice !== undefined) { console.log(result.reloadNotice); } @@ -315,6 +334,8 @@ async function runNonInteractive( targets: TargetOutcome[]; /** Whether a migration ran or any target wrote or removed anything. */ changed: boolean; + /** `package.json` pins that cannot resolve to the CLI that ran this. */ + pinnedCli: PinnedCli[]; }> { // Under `--json`, stdout carries only the envelope printed by the caller. // This per-target summary is not on that envelope (it is finer-grained than @@ -466,6 +487,7 @@ async function runNonInteractive( migrated !== undefined || targets.some((target) => targetChanged(target)) || versionMoved({ previousCliVersion, cliVersion }), + pinnedCli: await findStalePins(cwd, cliVersion), }; } diff --git a/packages/cli/src/install/pinned-cli.ts b/packages/cli/src/install/pinned-cli.ts new file mode 100644 index 00000000..9d6f7530 --- /dev/null +++ b/packages/cli/src/install/pinned-cli.ts @@ -0,0 +1,287 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { isRecord } from "../util/is-record"; +import { compareSemver, compareVersions } from "../util/version-compare"; + +/** + * Pins in `package.json` that would run a Taskless CLI older than the one that + * just upgraded this project. + * + * 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. + * + * 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. + * + * 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. + */ + +/** + * The package names a pin may name. + * + * Keys looked up in someone else's `package.json`, never text this CLI emits, + * so there is nothing for `applyCliInvocation()` to rewrite: a nightly build + * still has to recognise a project that pins the release. + */ +// ast-grep-ignore: no-unrouted-cli-invocation +const PACKAGE_NAMES = ["@taskless/cli", "@taskless/cli-nightly"] as const; + +/** The `package.json` fields a dependency pin may live in. */ +const DEPENDENCY_FIELDS = [ + "dependencies", + "devDependencies", + "optionalDependencies", +] as const; + +/** + * A launcher spelling in a script: the package name, then `@` and a version. + * The nightly name is tried first so `@taskless/cli` cannot claim its prefix. + * The lookbehind keeps a longer name (`foo@taskless/cli@…`) from matching, + * and the version stops at shell punctuation, so `@0.10.2,` or `@0.10.2>log` + * still reads as `0.10.2`. + */ +const SCRIPT_PIN = + /(?,:]+)/g; + +/** + * `1.2.3`, optionally prefixed `^` or `~`, or `=`, and `v`, with optional + * prerelease and build metadata. Space after the operator is allowed, as npm + * allows it. + */ +const BOUNDED_SPEC = + /^([\^~=])?\s*v?(\d+)\.(\d+)\.(\d+)(-[\w.-]+)?(\+[\w.-]+)?$/; + +/** One pin that would run a CLI older than the one that just ran. */ +export interface PinnedCli { + /** Where the pin lives: `devDependencies`, or `scripts.`. */ + location: string; + /** The package the pin names. */ + name: string; + /** The pin as written, e.g. `^0.10.2`. */ + 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. + */ + installed: string | null; +} + +/** + * Whether a spec provably cannot resolve to `cliVersion` or anything newer. + * + * An exact pin is compared with prerelease precedence, so an older nightly of + * the same base is stale. A range is compared by its exclusive ceiling on the + * numeric core: `^0.10.2` admits nothing from 0.11.0 up, and npm does not + * resolve a range to a prerelease of another base anyway. + */ +function isStale(spec: string, cliVersion: string): boolean { + const match = BOUNDED_SPEC.exec(spec.trim()); + if (match === null) return false; + const [, operator, majorText, minorText, patchText, prerelease] = match; + const major = Number(majorText); + const minor = Number(minorText); + const patch = Number(patchText); + + if (operator === "~") { + return ( + compareVersions(`${String(major)}.${String(minor + 1)}.0`, cliVersion) <= + 0 + ); + } + if (operator === "^") { + // Caret holds the left-most non-zero part, which is why `^0.10.2` never + // reaches 0.11: before 1.0 every minor is a break. + const ceiling = + major > 0 + ? `${String(major + 1)}.0.0` + : minor > 0 + ? `0.${String(minor + 1)}.0` + : `0.0.${String(patch + 1)}`; + return compareVersions(ceiling, cliVersion) <= 0; + } + const exact = `${String(major)}.${String(minor)}.${String(patch)}${prerelease ?? ""}`; + return compareSemver(exact, cliVersion) < 0; +} + +/** + * The version of `name` installed under `/node_modules/`, or `undefined`. + * pnpm links the directory into its store; reading through the link is what + * resolves the version the project actually runs. + */ +async function readInstalledVersion( + cwd: string, + name: string +): Promise { + try { + const parsed: unknown = JSON.parse( + await readFile(join(cwd, "node_modules", name, "package.json"), "utf8") + ); + return isRecord(parsed) && typeof parsed.version === "string" + ? parsed.version + : undefined; + } catch { + return undefined; + } +} + +/** + * Every stale pin in `/package.json`, in file order: dependency fields + * first, then scripts. An absent or unreadable `package.json` has no pins. + * + * Unreadable is not an error here. This is advice attached to a successful + * install, and a malformed manifest is something the package manager will + * report on its own terms; failing the install over it would be the wrong + * tool refusing. + */ +export async function findStalePins( + cwd: string, + cliVersion: string +): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")); + } catch { + return []; + } + if (!isRecord(parsed)) return []; + + const pins: PinnedCli[] = []; + for (const field of DEPENDENCY_FIELDS) { + const dependencies = parsed[field]; + if (!isRecord(dependencies)) continue; + for (const name of PACKAGE_NAMES) { + const spec = dependencies[name]; + if (typeof spec !== "string") continue; + const installed = await readInstalledVersion(cwd, 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({ + location: field, + name, + spec, + installed: installed ?? null, + }); + } + } + } + + const scripts = parsed.scripts; + if (isRecord(scripts)) { + for (const [script, command] of Object.entries(scripts)) { + if (typeof command !== "string") continue; + const seen = new Set(); + for (const match of command.matchAll(SCRIPT_PIN)) { + const [, suffix, spec] = match; + const name = `@taskless/${suffix ?? "cli"}`; + // `a && npx @taskless/cli@0.10.2 x && npx @taskless/cli@0.10.2 y` + // is one pin to change, not two. + if (spec === undefined || seen.has(`${name}@${spec}`)) continue; + seen.add(`${name}@${spec}`); + if (isStale(spec, cliVersion)) { + pins.push({ + location: `scripts.${script}`, + name, + spec, + installed: null, + }); + } + } + } + } + + 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. + */ +function bumpTarget(cliVersion: string): { name: string; version: string } { + const [release, nightly] = PACKAGE_NAMES; + return { + name: cliVersion.includes("-") ? nightly : release, + version: cliVersion, + }; +} + +/** One notice line: where the pin is, what it says, and what to change it to. */ +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.location}: ${pin.name} ${pin.spec}${installed} -> ${move}`; +} + +/** + * The notice an install prints for stale pins, or `undefined` when there are + * none. + * + * Worded as something to offer, not something done. The install never edits + * `package.json`: 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. + * + * A migration of an EXISTING scaffold changes how sure that disagreement 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. + */ +export function getPinnedCliNotice( + pins: readonly PinnedCli[], + cliVersion: string, + options: { migrated?: { from: number; to: number } } = {} +): string | undefined { + if (pins.length === 0) return undefined; + const upgraded = + options.migrated !== undefined && options.migrated.from > 0 + ? options.migrated + : undefined; + const consequence = + 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. ` + + `Offer to update them as shown as part of this change, then reinstall dependencies.` + : `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. ` + + `Offer to update them as shown and reinstall dependencies, in the same commit as .taskless/.`; + return [ + `package.json pins a Taskless CLI older than ${cliVersion}, the version that just ran here:`, + ...pins.map((pin) => describePin(pin, cliVersion)), + consequence, + ].join("\n"); +} diff --git a/packages/cli/src/rules/reconcile-marker.ts b/packages/cli/src/rules/reconcile-marker.ts index be387443..ee8214a7 100644 --- a/packages/cli/src/rules/reconcile-marker.ts +++ b/packages/cli/src/rules/reconcile-marker.ts @@ -5,6 +5,7 @@ import { AST_GREP_VERSION, VALE_VERSION } from "./capabilities"; import { readManifest, writeManifest } from "../filesystem/manifest"; import { TASKLESS_DIRECTORY } from "./vale/formats"; import { CLIError } from "../util/cli-error"; +import { compareVersions } from "../util/version-compare"; import { getCliVersion } from "../wizard/intro"; /** @@ -27,32 +28,6 @@ export interface ReconcileResult { previous: string | undefined; } -/** - * Compare two dotted version strings numerically, ignoring any prerelease - * suffix. - * - * A nightly is `0.11.0-20260826062304x3c78ffe`, so a plain string comparison - * would sort it after `0.11.0` and let a nightly-built project refuse a - * release-built one. Only the numeric core is compared, which makes a nightly - * and its release equal for this purpose. That is the right answer: they carry - * the same ledger entries. - */ -function versionCore(version: string): number[] { - return (version.split("-")[0] ?? "") - .split(".") - .map((part) => Number.parseInt(part, 10) || 0); -} - -function compareVersions(a: string, b: string): number { - const left = versionCore(a); - const right = versionCore(b); - for (let index = 0; index < Math.max(left.length, right.length); index++) { - const delta = (left[index] ?? 0) - (right[index] ?? 0); - if (delta !== 0) return delta; - } - return 0; -} - /** * Record that the ledger walk completed up to `reconciledTo`. * @@ -64,7 +39,7 @@ function compareVersions(a: string, b: string): number { /** * A dotted numeric version, with an optional prerelease suffix. * - * Checked BEFORE either comparison, because `versionCore` coerces an + * Checked BEFORE either comparison, because `compareVersions` coerces an * unparseable segment to `0`: without this, `abc` parses as `[0]`, compares * lower than any real version, sails past both guards, and is written to the * manifest verbatim. A pasted SHA or a truncated interpolation would corrupt diff --git a/packages/cli/src/util/version-compare.ts b/packages/cli/src/util/version-compare.ts new file mode 100644 index 00000000..c5066b2f --- /dev/null +++ b/packages/cli/src/util/version-compare.ts @@ -0,0 +1,84 @@ +/** + * Compare two dotted version strings numerically, ignoring any prerelease + * suffix. + * + * A nightly is `0.11.0-20260826062304x3c78ffe`, so a plain string comparison + * would sort it after `0.11.0` and let a nightly-built project refuse a + * release-built one. Only the numeric core is compared, which makes a nightly + * and its release equal for this purpose. That is the right answer: they carry + * the same ledger entries. + */ +function versionCore(version: string): number[] { + return (version.split("-")[0] ?? "") + .split(".") + .map((part) => Number.parseInt(part, 10) || 0); +} + +export function compareVersions(a: string, b: string): number { + const left = versionCore(a); + const right = versionCore(b); + for (let index = 0; index < Math.max(left.length, right.length); index++) { + const delta = (left[index] ?? 0) - (right[index] ?? 0); + if (delta !== 0) return delta; + } + return 0; +} + +/** + * Compare two versions with semver precedence, prerelease included. + * + * `compareVersions` is right for the reconciliation ledger and wrong for + * asking "is this build older than that one". A nightly is stamped with the + * release it ANTICIPATES (`0.12.0-20261002181147x023048f` while 0.11.2 is the + * latest), so it sorts before that release, and two nightlies of one base can + * sit weeks and several migrations apart. Semver precedence answers both: a + * prerelease sorts before its release, and two prereleases compare identifier + * by identifier, which orders nightlies by build time because the timestamp + * leads the stamp. Build metadata (`+…`) carries no precedence. + */ +export function compareSemver(a: string, b: string): number { + const core = compareVersions(a, b); + if (core !== 0) return core; + const left = prerelease(a); + const right = prerelease(b); + if (left === right) return 0; + if (left === undefined) return 1; + if (right === undefined) return -1; + return comparePrerelease(left, right); +} + +/** + * Semver's prerelease precedence: dot-separated identifiers compared left to + * right, a numeric identifier by value and below any alphanumeric one, and a + * shorter list below a longer one it is a prefix of. A plain string + * comparison agrees for a nightly stamp, whose leading timestamp is + * fixed-width, and disagrees for `rc.9` against `rc.10`, which it orders + * backwards. + */ +function comparePrerelease(a: string, b: string): number { + const left = a.split("."); + const right = b.split("."); + for (let index = 0; index < Math.min(left.length, right.length); index++) { + const delta = compareIdentifier(left[index] ?? "", right[index] ?? ""); + if (delta !== 0) return delta; + } + return left.length - right.length; +} + +const NUMERIC_IDENTIFIER = /^\d+$/; + +function compareIdentifier(a: string, b: string): number { + const aNumeric = NUMERIC_IDENTIFIER.test(a); + const bNumeric = NUMERIC_IDENTIFIER.test(b); + if (aNumeric && bNumeric) return Number(a) - Number(b); + if (aNumeric) return -1; + if (bNumeric) return 1; + if (a === b) return 0; + return a < b ? -1 : 1; +} + +function prerelease(version: string): string | undefined { + const withoutBuild = version.split("+")[0] ?? ""; + const dash = withoutBuild.indexOf("-"); + return dash === -1 ? undefined : withoutBuild.slice(dash + 1); +} diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index bee4db5a..993b7476 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -9,6 +9,7 @@ import { getEmbeddedSkills, planToStateTargets, } from "../install/install"; +import { findStalePins, getPinnedCliNotice } from "../install/pinned-cli"; import { getReloadNotice } from "../install/reload-notice"; import { computeInstallDiff, readInstallState } from "../install/state"; import { getTelemetry } from "../telemetry"; @@ -68,13 +69,19 @@ export async function runWizard( return finish({ status: "cancelled" }); } - await ensureTasklessDirectory(options.cwd, { + const migrated = await ensureTasklessDirectory(options.cwd, { onNotice: (message) => log.info(message), }); const cliVersion = getCliVersion(); await applyInstallPlan(options.cwd, plan, { cliVersion }); outro("Taskless is ready to go."); + const pinnedNotice = getPinnedCliNotice( + await findStalePins(options.cwd, cliVersion), + cliVersion, + { migrated } + ); + if (pinnedNotice !== undefined) console.log(pinnedNotice); const commandsInstalled = plan.targets.some( (t) => t.mode === "reference" && t.commands.length > 0 ); diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index 531b51dc..644adc2a 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -277,6 +277,149 @@ 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 () => { + // 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({ + devDependencies: { "@taskless/cli": "0.0.1" }, + scripts: { lint: "npx @taskless/cli@0.0.1 check" }, + }); + await writeFile(join(cwd, "package.json"), packageJson); + await installAtVersion(cwd, "0.0.1-previous"); + + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "-d", + cwd, + ]); + + expect(stdout).toContain("devDependencies: @taskless/cli 0.0.1"); + expect(stdout).toContain("scripts.lint: @taskless/cli 0.0.1"); + expect(stdout).toMatch(/-> @taskless\/cli@\S+/); + expect(stdout).toContain("Offer to update them as shown"); + expect(await readFile(join(cwd, "package.json"), "utf8")).toBe(packageJson); + + // After the upgrade trailer, which it follows from; the onboarding line + // stays last. + const lines = stdout.trimEnd().split("\n"); + const trailerAt = lines.findIndex((line) => line.includes("next commit")); + const pinAt = lines.findIndex((line) => line.includes("package.json pins")); + // Both present first: `findIndex` is -1 for a missing line, and -1 sorts + // before everything, so the ordering check alone passes on an absent + // trailer. + expect(trailerAt).toBeGreaterThan(-1); + expect(pinAt).toBeGreaterThan(trailerAt); + expect(lines.at(-1)).toMatch(/^Next:/); + }); + + it("says CI will break when the same run migrated .taskless/", async () => { + // The pinned CLI refuses a scaffold newer than it knows, and CI meets + // that on the push carrying the migrated files. No hedging. + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ devDependencies: { "@taskless/cli": "0.0.1" } }) + ); + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await writeFile( + join(cwd, ".taskless", "taskless.json"), + JSON.stringify({ version: 2 }) + ); + + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "-d", + cwd, + ]); + + expect(stdout).toContain( + `from schema version 2 to ${String(LATEST_SCHEMA_VERSION)}` + ); + expect(stdout).toContain("SCAFFOLD_VERSION_MISMATCH"); + expect(stdout).toContain("same commit as .taskless/"); + expect(stdout).not.toContain("will likely fail"); + }); + + it("does not call a fresh install an upgrade", async () => { + // A fresh `init` creates `.taskless/` by migrating from schema 0. Nothing + // 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" } }) + ); + + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "-d", + cwd, + ]); + + expect(stdout).toContain("devDependencies: @taskless/cli 0.0.1"); + expect(stdout).toContain("will likely fail"); + expect(stdout).not.toContain("SCAFFOLD_VERSION_MISMATCH"); + expect(stdout).not.toContain("This upgrade migrated"); + }); + + it("names a stale pin even when the re-install changed nothing", async () => { + // Nothing to commit is not the same as nothing to fix: the pin and the + // project still disagree. + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ devDependencies: { "@taskless/cli": "^0.0.1" } }) + ); + await execFileAsync("node", [binPath, "init", "-d", cwd]); + + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "-d", + cwd, + ]); + + expect(stdout).not.toContain("next commit"); + expect(stdout).toContain("devDependencies: @taskless/cli ^0.0.1"); + // Nothing migrated, so the layout the pin reads did not move. + expect(stdout).toContain("will likely fail"); + expect(stdout).not.toContain("SCAFFOLD_VERSION_MISMATCH"); + }); + + it("carries stale pins on the --json envelope, as an empty list when there are none", async () => { + const bare = await execFileAsync("node", [ + binPath, + "init", + "--json", + "-d", + cwd, + ]); + expect( + (JSON.parse(bare.stdout) as { pinnedCli: unknown }).pinnedCli + ).toEqual([]); + + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ dependencies: { "@taskless/cli": "0.0.1" } }) + ); + const pinned = await execFileAsync("node", [ + binPath, + "init", + "--json", + "-d", + cwd, + ]); + expect( + (JSON.parse(pinned.stdout) as { pinnedCli: unknown }).pinnedCli + ).toEqual([ + { + location: "dependencies", + name: "@taskless/cli", + spec: "0.0.1", + installed: null, + }, + ]); + }); + it("`taskless update` does NOT print the onboarding trailer", async () => { // Update is the same install plumbing but the trailer is scoped to init. await mkdir(join(cwd, ".claude"), { recursive: true }); diff --git a/packages/cli/test/pinned-cli.test.ts b/packages/cli/test/pinned-cli.test.ts new file mode 100644 index 00000000..3388cba5 --- /dev/null +++ b/packages/cli/test/pinned-cli.test.ts @@ -0,0 +1,329 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + findStalePins, + getPinnedCliNotice, + type PinnedCli, +} from "../src/install/pinned-cli"; + +describe("findStalePins", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-pinned-cli-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + async function writePackage(contents: unknown): Promise { + await writeFile(join(cwd, "package.json"), JSON.stringify(contents)); + } + + async function install(name: string, version: string): Promise { + const directory = join(cwd, "node_modules", ...name.split("/")); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "package.json"), + JSON.stringify({ name, version }) + ); + } + + it("finds nothing without a package.json", async () => { + expect(await findStalePins(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([]); + }); + + describe("a dependency range, with nothing installed", () => { + 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], + ["0.12.0", false], + // Pre-1.0 caret holds the minor, so `^0.10.2` never reaches 0.11. + ["^0.10.2", true], + ["^v0.10.2", true], + ["^0.11.0", false], + ["^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. + ["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( + stale + ? [ + { + location: "devDependencies", + name: "@taskless/cli", + spec, + installed: null, + }, + ] + : [] + ); + }); + }); + + 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. + 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], + // 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. + ["0.11.0-rc.9", "0.11.0-rc.10", true], + ["0.11.0-rc.10", "0.11.0-rc.9", false], + ["0.11.0-rc", "0.11.0-rc.1", true], + ["0.11.0-1", "0.11.0-alpha", true], + ])( + "pin %s against running %s is stale: %s", + async (pin, running, stale) => { + await writePackage({ + devDependencies: { "@taskless/cli-nightly": pin }, + }); + expect(await findStalePins(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" } }); + expect( + await findStalePins(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([ + { + location: "devDependencies", + name: "@taskless/cli", + spec: "^0.11.0", + installed: "0.11.0", + }, + ]); + }); + + 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("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([ + { + location: "devDependencies", + name: "@taskless/cli", + spec: "^0.10.0", + installed: "0.11.2", + }, + ]); + }); + + it("reads an older installed nightly of the same base", async () => { + await writePackage({ + devDependencies: { "@taskless/cli-nightly": "^0.12.0-0" }, + }); + await install("@taskless/cli-nightly", "0.12.0-20260901000000xaaaaaaa"); + expect( + await findStalePins(cwd, "0.12.0-20261005000000xbbbbbbb") + ).toHaveLength(1); + }); + }); + + 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" }, + }); + expect(await findStalePins(cwd, "0.11.2")).toEqual([ + { + location: "dependencies", + name: "@taskless/cli", + spec: "0.9.0", + installed: null, + }, + { + 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 () => { + await writePackage({ + scripts: { + lint: "eslint . && npx @taskless/cli@0.10.2 check", + nightly: "pnpm dlx @taskless/cli-nightly@0.10.0-2026x0 check", + fresh: "npx @taskless/cli@latest check", + bare: "taskless check", + current: "npx @taskless/cli@0.11.2 check", + }, + }); + expect(await findStalePins(cwd, "0.11.2")).toEqual([ + { + location: "scripts.lint", + name: "@taskless/cli", + spec: "0.10.2", + installed: null, + }, + { + location: "scripts.nightly", + name: "@taskless/cli-nightly", + spec: "0.10.0-2026x0", + installed: null, + }, + ]); + }); + + it.each([ + ["npx @taskless/cli@0.10.2,check", "0.10.2"], + ["npx @taskless/cli@0.10.2>out.txt", "0.10.2"], + ["npx @taskless/cli@0.10.2:check", "0.10.2"], + ["(npx @taskless/cli@0.10.2)", "0.10.2"], + ])( + "stops a script version at shell punctuation: %s", + async (command, spec) => { + await writePackage({ scripts: { lint: command } }); + expect(await findStalePins(cwd, "0.11.2")).toEqual([ + { + location: "scripts.lint", + name: "@taskless/cli", + spec, + installed: null, + }, + ]); + } + ); + + 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([]); + }); + + it("reports a pin repeated in one script once", async () => { + await writePackage({ + scripts: { + lint: "npx @taskless/cli@0.10.2 check && npx @taskless/cli@0.10.2 verify", + }, + }); + expect(await findStalePins(cwd, "0.11.2")).toHaveLength(1); + }); +}); + +describe("getPinnedCliNotice", () => { + const releasePin: PinnedCli = { + location: "devDependencies", + name: "@taskless/cli", + spec: "0.10.2", + installed: null, + }; + + it("is absent when nothing is stale", () => { + expect(getPinnedCliNotice([], "0.11.2")).toBeUndefined(); + }); + + it("names every pin with its target, and offers the bump rather than claiming it", () => { + const notice = getPinnedCliNotice( + [ + { ...releasePin, spec: "^0.11.0", installed: "0.11.0" }, + { ...releasePin, location: "scripts.lint" }, + ], + "0.11.2" + ); + expect(notice).toContain( + "devDependencies: @taskless/cli ^0.11.0 (installed 0.11.0) -> @taskless/cli@0.11.2" + ); + expect(notice).toContain( + "scripts.lint: @taskless/cli 0.10.2 -> @taskless/cli@0.11.2" + ); + expect(notice).toContain("Offer to update them as shown"); + }); + + 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 = getPinnedCliNotice( + [{ ...releasePin, name: "@taskless/cli-nightly", spec: "0.11.2-2026x0" }], + "0.11.2" + ); + expect(notice).toContain( + "-> @taskless/cli@0.11.2, replacing @taskless/cli-nightly" + ); + }); + + it("moves a release pin to the nightly package when a nightly is running", () => { + const notice = getPinnedCliNotice( + [releasePin], + "0.12.0-20261002181147x023048f" + ); + expect(notice).toContain( + "-> @taskless/cli-nightly@0.12.0-20261002181147x023048f, replacing @taskless/cli" + ); + }); + + it("hedges without a migration: the layout the pin reads did not move", () => { + const notice = getPinnedCliNotice([releasePin], "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", () => { + // 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 = getPinnedCliNotice([releasePin], "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("same commit as .taskless/"); + expect(notice).not.toContain("likely"); + }); + + it("does not call a fresh install an upgrade", () => { + // A fresh `init` creates `.taskless/` by migrating from schema 0. + const notice = getPinnedCliNotice([releasePin], "0.11.2", { + migrated: { from: 0, to: 9 }, + }); + expect(notice).not.toContain("upgrade"); + expect(notice).not.toContain("SCAFFOLD_VERSION_MISMATCH"); + expect(notice).toContain("will likely fail"); + }); +}); diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index a31f24f3..716e87c0 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -370,3 +370,56 @@ describe("the wizard's restart-your-agents banner", () => { } }); }); + +/** + * The wizard prints the same stale-pin notice the batch path does. The + * detection and wording are covered in `pinned-cli.test.ts`; this is whether + * the wizard calls it at all, which is where a dropped line fails silently. + */ +describe("the wizard's stale-pin notice", () => { + it("names a package.json pin older than the running CLI", async () => { + clackResponses.locations = [".claude"]; + clackResponses.summary = true; + const packageJson = JSON.stringify({ + devDependencies: { "@taskless/cli": "0.0.1" }, + }); + await writeFile(join(cwd, "package.json"), packageJson); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const { runWizard } = await import("../src/wizard"); + const result = await runWizard({ cwd }); + expect(result.status).toBe("completed"); + + const printed = logSpy.mock.calls.map((call) => String(call[0])); + expect( + printed.some((line) => + line.includes("devDependencies: @taskless/cli 0.0.1") + ) + ).toBe(true); + // Offered, never applied. + expect(await readFile(join(cwd, "package.json"), "utf8")).toBe( + packageJson + ); + } finally { + logSpy.mockRestore(); + } + }); + + it("stays quiet without a stale pin", async () => { + clackResponses.locations = [".claude"]; + clackResponses.summary = true; + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const { runWizard } = await import("../src/wizard"); + await runWizard({ cwd }); + const printed = logSpy.mock.calls.map((call) => String(call[0])); + expect(printed.some((line) => line.includes("package.json pins"))).toBe( + false + ); + } finally { + logSpy.mockRestore(); + } + }); +});