From 15d2a75d6f930c23bd4ba4cb2b156afd96e579fa Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 12:23:24 -0700 Subject: [PATCH 1/3] feat(init): name a package.json pin older than the running CLI An upgrade through a launcher leaves the project's own pins alone, and those pins are what CI, scripts and git hooks run. After a migration, a pin that predates the new schema refuses the project with SCAFFOLD_VERSION_MISMATCH, so CI breaks on the push carrying the migrated files. init (batch and wizard) now names every bounded pin of @taskless/cli or @taskless/cli-nightly that cannot reach the running version, in the dependency fields or spelled out in a script, and offers the bump without editing package.json. After a migration the notice states the breakage as certain and ties the bump to the same commit as .taskless/. init --json carries the pins as pinnedCli, and the update recipe (topic v11) tells an agent to offer the bump. --- .changeset/init-stale-cli-pins.md | 5 + .../proposal.md | 55 ++++++ .../specs/cli-init/spec.md | 54 +++++ .../2026-10-05-init-stale-cli-pins/tasks.md | 36 ++++ openspec/specs/cli-init/spec.md | 53 +++++ packages/cli/src/agent/update.md | 24 ++- packages/cli/src/commands/init.ts | 22 +++ packages/cli/src/install/pinned-cli.ts | 186 ++++++++++++++++++ packages/cli/src/rules/reconcile-marker.ts | 29 +-- packages/cli/src/util/version-compare.ts | 25 +++ packages/cli/src/wizard/index.ts | 9 +- packages/cli/test/init-no-interactive.test.ts | 110 +++++++++++ packages/cli/test/pinned-cli.test.ts | 142 +++++++++++++ 13 files changed, 720 insertions(+), 30 deletions(-) create mode 100644 .changeset/init-stale-cli-pins.md create mode 100644 openspec/changes/archive/2026-10-05-init-stale-cli-pins/proposal.md create mode 100644 openspec/changes/archive/2026-10-05-init-stale-cli-pins/specs/cli-init/spec.md create mode 100644 openspec/changes/archive/2026-10-05-init-stale-cli-pins/tasks.md create mode 100644 packages/cli/src/install/pinned-cli.ts create mode 100644 packages/cli/src/util/version-compare.ts create mode 100644 packages/cli/test/pinned-cli.test.ts diff --git a/.changeset/init-stale-cli-pins.md b/.changeset/init-stale-cli-pins.md new file mode 100644 index 00000000..0f0f798e --- /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` (a dependency entry, or a script spelling out `@taskless/cli@`) that cannot resolve to the CLI that just ran, and offers bumping it as part of the upgrade. 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 `update` recipe (topic v13) tells 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..8215be41 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-init-stale-cli-pins/proposal.md @@ -0,0 +1,55 @@ +## 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. +- When the same run migrated `.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. +- `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..9928bc43 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-init-stale-cli-pins/specs/cli-init/spec.md @@ -0,0 +1,54 @@ +## 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 cannot resolve to the running CLI's version or later. A pin is: + +- an entry in `dependencies`, `devDependencies`, or `optionalDependencies`; or +- a script in `scripts` that spells out `@taskless/cli@` or `@taskless/cli-nightly@`. + +A pin SHALL be reported only when its spec is bounded and its bound sits below the running version: an exact version (optionally prefixed `=` or `v`), or a `^` or `~` range whose exclusive ceiling is at or below the running version, with caret ranges holding the left-most non-zero part as npm does. Versions SHALL be compared on their numeric core, ignoring a prerelease suffix. A spec the CLI cannot bound (`latest`, `*`, a comparator range, `workspace:`, a URL or git spec) SHALL NOT be reported. An absent or unparseable `package.json` SHALL produce no report 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 each pin's location (the dependency field, or `scripts.`), package and spec, stating that what runs those pins runs a CLI older than the project, and offering to update them to the running version. 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 `.taskless/`, the notice SHALL NOT hedge. It SHALL name the schema version the run wrote, state that a CLI predating that 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/`. Without a migration the notice SHALL describe the failure as likely, not certain. + +Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ location, name, spec }`, 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 offer updating them to `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 pre-1.0 caret range that cannot reach the running version is stale + +- **WHEN** `package.json` pins `@taskless/cli` at `^0.10.2` and the running CLI is `0.11.2` +- **THEN** the pin SHALL be reported + +#### Scenario: A floating or current pin is not reported + +- **WHEN** `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 makes the breakage definite + +- **WHEN** `taskless init` migrates `.taskless/` to schema version `N` in a project with a stale pin +- **THEN** the notice SHALL name schema version `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 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 }` 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..375b2d52 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-init-stale-cli-pins/tasks.md @@ -0,0 +1,36 @@ +## 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. diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 58af4ab9..b517c6cb 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -802,3 +802,56 @@ 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 cannot resolve to the running CLI's version or later. A pin is: + +- an entry in `dependencies`, `devDependencies`, or `optionalDependencies`; or +- a script in `scripts` that spells out `@taskless/cli@` or `@taskless/cli-nightly@`. + +A pin SHALL be reported only when its spec is bounded and its bound sits below the running version: an exact version (optionally prefixed `=` or `v`), or a `^` or `~` range whose exclusive ceiling is at or below the running version, with caret ranges holding the left-most non-zero part as npm does. Versions SHALL be compared on their numeric core, ignoring a prerelease suffix. A spec the CLI cannot bound (`latest`, `*`, a comparator range, `workspace:`, a URL or git spec) SHALL NOT be reported. An absent or unparseable `package.json` SHALL produce no report 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 each pin's location (the dependency field, or `scripts.`), package and spec, stating that what runs those pins runs a CLI older than the project, and offering to update them to the running version. 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 `.taskless/`, the notice SHALL NOT hedge. It SHALL name the schema version the run wrote, state that a CLI predating that 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/`. Without a migration the notice SHALL describe the failure as likely, not certain. + +Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ location, name, spec }`, 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 offer updating them to `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 pre-1.0 caret range that cannot reach the running version is stale + +- **WHEN** `package.json` pins `@taskless/cli` at `^0.10.2` and the running CLI is `0.11.2` +- **THEN** the pin SHALL be reported + +#### Scenario: A floating or current pin is not reported + +- **WHEN** `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 makes the breakage definite + +- **WHEN** `taskless init` migrates `.taskless/` to schema version `N` in a project with a stale pin +- **THEN** the notice SHALL name schema version `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 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 }` 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/update.md b/packages/cli/src/agent/update.md index bf31bcaf..dcdb9b0e 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,27 @@ 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` (a dependency entry, or a script spelling out + `@taskless/cli@`) below the installed CLI, `init` named each + pin when it upgraded the project, and `init --json` carries them as + `pinnedCli`. Those pins are what scripts, CI, and git hooks run. + + If the upgrade also migrated `.taskless/` (`init` printed a migration, + or `init --json` carried `migrated`), 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..7e79f884 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, + { migratedTo: result.migrated?.to } + ); + 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..15ac5d01 --- /dev/null +++ b/packages/cli/src/install/pinned-cli.ts @@ -0,0 +1,186 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { isRecord } from "../util/is-record"; +import { 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. + * + * Detection is deliberately narrow. Only a pin whose ceiling sits below the + * running version is reported: an exact version, or a `^`/`~` range that + * cannot reach it. 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. + */ +const SCRIPT_PIN = /@taskless\/(cli-nightly|cli)@([^\s"'`;&|)]+)/g; + +/** `1.2.3`, optionally prefixed `^`, `~`, `=` or `v`, optionally prerelease. */ +const BOUNDED_SPEC = /^([\^~]|=?v?)(\d+)\.(\d+)\.(\d+)(-[\w.-]+)?$/; + +/** One pin that cannot resolve to the running CLI. */ +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 exclusive upper bound a spec admits, or `undefined` when the spec is not + * one this module can bound. An exact version is its own (inclusive) bound, + * which the caller treats the same way: below the running version is stale. + */ +function specCeiling(spec: string): string | undefined { + const match = BOUNDED_SPEC.exec(spec.trim()); + if (match === null) return undefined; + const [, operator, majorText, minorText, patchText] = match; + const major = Number(majorText); + const minor = Number(minorText); + const patch = Number(patchText); + + if (operator === "~") return `${String(major)}.${String(minor + 1)}.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. + if (major > 0) return `${String(major + 1)}.0.0`; + if (minor > 0) return `0.${String(minor + 1)}.0`; + return `0.0.${String(patch + 1)}`; + } + // An exact pin. One past its own patch is the exclusive form of "exactly + // this", so both shapes share the comparison below. The prerelease is + // dropped, as `compareVersions` drops it: a nightly and its release carry + // the same layout. + return `${String(major)}.${String(minor)}.${String(patch + 1)}`; +} + +/** Whether a spec provably cannot resolve to `cliVersion` or anything newer. */ +function isStale(spec: string, cliVersion: string): boolean { + const ceiling = specCeiling(spec); + return ceiling !== undefined && compareVersions(ceiling, cliVersion) <= 0; +} + +/** + * 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" && isStale(spec, cliVersion)) { + pins.push({ location: field, name, spec }); + } + } + } + + const scripts = parsed.scripts; + if (isRecord(scripts)) { + for (const [script, command] of Object.entries(scripts)) { + if (typeof command !== "string") continue; + for (const match of command.matchAll(SCRIPT_PIN)) { + const [, suffix, spec] = match; + if (spec !== undefined && isStale(spec, cliVersion)) { + pins.push({ + location: `scripts.${script}`, + name: `@taskless/${suffix ?? "cli"}`, + spec, + }); + } + } + } + } + + return pins; +} + +/** + * 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 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 written schema `migratedTo`, 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. + * Without a migration the layout the pin reads is unchanged and the failure is + * only probable: the rules may lean on engine behavior the pin does not have. + */ +export function getPinnedCliNotice( + pins: readonly PinnedCli[], + cliVersion: string, + options: { migratedTo?: number } = {} +): string | undefined { + if (pins.length === 0) return undefined; + const consequence = + options.migratedTo === 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 to ${cliVersion} as part of this upgrade, then reinstall dependencies.` + : `This upgrade migrated .taskless/ to schema version ${String(options.migratedTo)}, 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 to ${cliVersion} and reinstall dependencies, in the same commit as .taskless/.`; + return [ + `package.json pins a Taskless CLI older than ${cliVersion}, which just upgraded this project:`, + ...pins.map((pin) => ` - ${pin.location}: ${pin.name} ${pin.spec}`), + 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..addde4a7 --- /dev/null +++ b/packages/cli/src/util/version-compare.ts @@ -0,0 +1,25 @@ +/** + * 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; +} diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index bee4db5a..2fc23e08 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, + { migratedTo: migrated?.to } + ); + 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..032f43df 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -277,6 +277,116 @@ 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(/Offer to update them to /); + 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")); + 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(`schema version ${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("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" }, + ]); + }); + 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..a8a92176 --- /dev/null +++ b/packages/cli/test/pinned-cli.test.ts @@ -0,0 +1,142 @@ +import { 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 } 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)); + } + + 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([]); + }); + + it.each([ + ["0.10.2", true], + ["=0.10.2", true], + ["v0.11.1", 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], + ["^0.11.0", false], + ["~0.10.9", true], + ["~0.11.0", false], + // A nightly carries the same layout as its release. + ["0.11.2-20260901000000xabcdef0", false], + ["0.11.1-20260901000000xabcdef0", true], + // 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], + ])("a devDependency of %s is stale: %s", async (spec, stale) => { + await writePackage({ devDependencies: { "@taskless/cli": spec } }); + const pins = await findStalePins(cwd, "0.11.2"); + expect(pins).toEqual( + stale + ? [{ location: "devDependencies", name: "@taskless/cli", spec }] + : [] + ); + }); + + 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" }, + { + location: "optionalDependencies", + name: "@taskless/cli-nightly", + spec: "0.10.0-2026x0", + }, + ]); + }); + + 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" }, + { + location: "scripts.nightly", + name: "@taskless/cli-nightly", + spec: "0.10.0-2026x0", + }, + ]); + }); +}); + +describe("getPinnedCliNotice", () => { + it("is absent when nothing is stale", () => { + expect(getPinnedCliNotice([], "0.11.2")).toBeUndefined(); + }); + + it("names every pin and offers the bump rather than claiming it", () => { + const notice = getPinnedCliNotice( + [ + { location: "devDependencies", name: "@taskless/cli", spec: "^0.10.2" }, + { location: "scripts.lint", name: "@taskless/cli", spec: "0.10.2" }, + ], + "0.11.2" + ); + expect(notice).toContain("devDependencies: @taskless/cli ^0.10.2"); + expect(notice).toContain("scripts.lint: @taskless/cli 0.10.2"); + expect(notice).toContain("Offer to update them to 0.11.2"); + }); + + it("hedges without a migration: the layout the pin reads did not move", () => { + const notice = getPinnedCliNotice( + [{ location: "devDependencies", name: "@taskless/cli", spec: "0.10.2" }], + "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( + [{ location: "devDependencies", name: "@taskless/cli", spec: "0.10.2" }], + "0.11.2", + { migratedTo: 9 } + ); + expect(notice).toContain("schema version 9"); + expect(notice).toContain("SCAFFOLD_VERSION_MISMATCH"); + expect(notice).toContain("will break"); + expect(notice).toContain("same commit as .taskless/"); + expect(notice).not.toContain("likely"); + }); +}); From 5e4053535f8a59cf655c6de502c2a13aedf73710 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 15:08:17 -0700 Subject: [PATCH 2/3] fix(init): judge a pin by what runs, and say only what is true Review fixes for the stale-pin notice: - read the version installed under node_modules as well as the range; ^0.11.0 admits 0.11.2 but CI runs the locked 0.11.0 - compare exact and installed versions with semver precedence, so an older nightly of the same base is stale - name each pin's target on the package that publishes that version - a migration from schema 0 is a fresh install, not an upgrade - init recipe topic v3: pinnedCli in the envelope, the stop rule, and a bump step; update recipe points at re-running init --json - script regex: left boundary, punctuation-terminated versions, one report per repeated pin - tests: ordering guard, wizard, fresh install, nightly ordering --- .changeset/init-stale-cli-pins.md | 2 +- .../proposal.md | 20 +- .../specs/cli-init/spec.md | 54 +++- .../2026-10-05-init-stale-cli-pins/tasks.md | 14 + openspec/specs/cli-init/spec.md | 54 +++- packages/cli/src/agent/init.md | 46 ++- packages/cli/src/agent/update.md | 18 +- packages/cli/src/commands/init.ts | 2 +- packages/cli/src/install/pinned-cli.ts | 195 ++++++++++--- packages/cli/src/util/version-compare.ts | 29 ++ packages/cli/src/wizard/index.ts | 2 +- packages/cli/test/init-no-interactive.test.ts | 39 ++- packages/cli/test/pinned-cli.test.ts | 272 +++++++++++++++--- packages/cli/test/wizard-integration.test.ts | 53 ++++ 14 files changed, 653 insertions(+), 147 deletions(-) diff --git a/.changeset/init-stale-cli-pins.md b/.changeset/init-stale-cli-pins.md index 0f0f798e..7a2f65a3 100644 --- a/.changeset/init-stale-cli-pins.md +++ b/.changeset/init-stale-cli-pins.md @@ -2,4 +2,4 @@ "@taskless/cli": patch --- -`taskless init` names any `package.json` pin of `@taskless/cli` (a dependency entry, or a script spelling out `@taskless/cli@`) that cannot resolve to the CLI that just ran, and offers bumping it as part of the upgrade. 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 `update` recipe (topic v13) tells an agent to offer the bump. +`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 index 8215be41..4504784b 100644 --- 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 @@ -20,10 +20,22 @@ exists. It says nothing about the one other place the upgrade is incomplete. 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. -- When the same run migrated `.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 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 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 index 9928bc43..038a14c4 100644 --- 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 @@ -2,45 +2,73 @@ ### 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 cannot resolve to the running CLI's version or later. A pin is: +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@`. +- 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 pin SHALL be reported only when its spec is bounded and its bound sits below the running version: an exact version (optionally prefixed `=` or `v`), or a `^` or `~` range whose exclusive ceiling is at or below the running version, with caret ranges holding the left-most non-zero part as npm does. Versions SHALL be compared on their numeric core, ignoring a prerelease suffix. A spec the CLI cannot bound (`latest`, `*`, a comparator range, `workspace:`, a URL or git spec) SHALL NOT be reported. An absent or unparseable `package.json` SHALL produce no report and SHALL NOT fail the install. +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 each pin's location (the dependency field, or `scripts.`), package and spec, stating that what runs those pins runs a CLI older than the project, and offering to update them to the running version. 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. +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 `.taskless/`, the notice SHALL NOT hedge. It SHALL name the schema version the run wrote, state that a CLI predating that 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/`. Without a migration the notice SHALL describe the failure as likely, not certain. +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 }`, present on every successful run and empty when nothing is stale. +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 offer updating them to `V` +- **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` and the running CLI is `0.11.2` +- **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** `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` +- **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 makes the breakage definite +#### Scenario: A migration of an existing scaffold makes the breakage definite -- **WHEN** `taskless init` migrates `.taskless/` to schema version `N` in a project with a stale pin -- **THEN** the notice SHALL name schema version `N` and `SCAFFOLD_VERSION_MISMATCH` +- **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 @@ -50,5 +78,5 @@ Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ location, n #### 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 }` entry per stale pin +- **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 index 375b2d52..63dc2c88 100644 --- 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 @@ -34,3 +34,17 @@ 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 b517c6cb..9f887be3 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -805,45 +805,73 @@ If the user cancels the wizard at any step (Ctrl-C, Esc, or equivalent clack can ### 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 cannot resolve to the running CLI's version or later. A pin is: +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@`. +- 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 pin SHALL be reported only when its spec is bounded and its bound sits below the running version: an exact version (optionally prefixed `=` or `v`), or a `^` or `~` range whose exclusive ceiling is at or below the running version, with caret ranges holding the left-most non-zero part as npm does. Versions SHALL be compared on their numeric core, ignoring a prerelease suffix. A spec the CLI cannot bound (`latest`, `*`, a comparator range, `workspace:`, a URL or git spec) SHALL NOT be reported. An absent or unparseable `package.json` SHALL produce no report and SHALL NOT fail the install. +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 each pin's location (the dependency field, or `scripts.`), package and spec, stating that what runs those pins runs a CLI older than the project, and offering to update them to the running version. 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. +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 `.taskless/`, the notice SHALL NOT hedge. It SHALL name the schema version the run wrote, state that a CLI predating that 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/`. Without a migration the notice SHALL describe the failure as likely, not certain. +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 }`, present on every successful run and empty when nothing is stale. +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 offer updating them to `V` +- **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` and the running CLI is `0.11.2` +- **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** `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` +- **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 makes the breakage definite +#### Scenario: A migration of an existing scaffold makes the breakage definite -- **WHEN** `taskless init` migrates `.taskless/` to schema version `N` in a project with a stale pin -- **THEN** the notice SHALL name schema version `N` and `SCAFFOLD_VERSION_MISMATCH` +- **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 @@ -853,5 +881,5 @@ Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ location, n #### 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 }` entry per stale pin +- **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 dcdb9b0e..8b22deae 100644 --- a/packages/cli/src/agent/update.md +++ b/packages/cli/src/agent/update.md @@ -55,13 +55,17 @@ that you finished. do means exactly that; it is a claim, not an oversight. 4. **Offer to bump a pinned CLI.** If `package.json` pins - `@taskless/cli` (a dependency entry, or a script spelling out - `@taskless/cli@`) below the installed CLI, `init` named each - pin when it upgraded the project, and `init --json` carries them as - `pinnedCli`. Those pins are what scripts, CI, and git hooks run. - - If the upgrade also migrated `.taskless/` (`init` printed a migration, - or `init --json` carried `migrated`), this is not optional advice: a + `@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 diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 7e79f884..f1141b63 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -161,7 +161,7 @@ export const initCommand = defineCommand({ const pinnedNotice = getPinnedCliNotice( result.pinnedCli, result.cliVersion, - { migratedTo: result.migrated?.to } + { migrated: result.migrated } ); if (pinnedNotice !== undefined) { console.log(pinnedNotice); diff --git a/packages/cli/src/install/pinned-cli.ts b/packages/cli/src/install/pinned-cli.ts index 15ac5d01..9d6f7530 100644 --- a/packages/cli/src/install/pinned-cli.ts +++ b/packages/cli/src/install/pinned-cli.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { isRecord } from "../util/is-record"; -import { compareVersions } from "../util/version-compare"; +import { compareSemver, compareVersions } from "../util/version-compare"; /** * Pins in `package.json` that would run a Taskless CLI older than the one that @@ -17,11 +17,18 @@ import { compareVersions } from "../util/version-compare"; * 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. * - * Detection is deliberately narrow. Only a pin whose ceiling sits below the - * running version is reported: an exact version, or a `^`/`~` range that - * cannot reach it. 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. + * 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. */ /** @@ -44,13 +51,22 @@ const DEPENDENCY_FIELDS = [ /** * 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 = /@taskless\/(cli-nightly|cli)@([^\s"'`;&|)]+)/g; +const SCRIPT_PIN = + /(?,:]+)/g; -/** `1.2.3`, optionally prefixed `^`, `~`, `=` or `v`, optionally prerelease. */ -const BOUNDED_SPEC = /^([\^~]|=?v?)(\d+)\.(\d+)\.(\d+)(-[\w.-]+)?$/; +/** + * `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 cannot resolve to the running CLI. */ +/** 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; @@ -58,40 +74,70 @@ export interface PinnedCli { 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; } /** - * The exclusive upper bound a spec admits, or `undefined` when the spec is not - * one this module can bound. An exact version is its own (inclusive) bound, - * which the caller treats the same way: below the running version is stale. + * 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 specCeiling(spec: string): string | undefined { +function isStale(spec: string, cliVersion: string): boolean { const match = BOUNDED_SPEC.exec(spec.trim()); - if (match === null) return undefined; - const [, operator, majorText, minorText, patchText] = match; + 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 `${String(major)}.${String(minor + 1)}.0`; + 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. - if (major > 0) return `${String(major + 1)}.0.0`; - if (minor > 0) return `0.${String(minor + 1)}.0`; - return `0.0.${String(patch + 1)}`; + 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; } - // An exact pin. One past its own patch is the exclusive form of "exactly - // this", so both shapes share the comparison below. The prerelease is - // dropped, as `compareVersions` drops it: a nightly and its release carry - // the same layout. - return `${String(major)}.${String(minor)}.${String(patch + 1)}`; + const exact = `${String(major)}.${String(minor)}.${String(patch)}${prerelease ?? ""}`; + return compareSemver(exact, cliVersion) < 0; } -/** Whether a spec provably cannot resolve to `cliVersion` or anything newer. */ -function isStale(spec: string, cliVersion: string): boolean { - const ceiling = specCeiling(spec); - return ceiling !== undefined && compareVersions(ceiling, 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; + } } /** @@ -121,8 +167,21 @@ export async function findStalePins( if (!isRecord(dependencies)) continue; for (const name of PACKAGE_NAMES) { const spec = dependencies[name]; - if (typeof spec === "string" && isStale(spec, cliVersion)) { - pins.push({ location: field, name, spec }); + 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, + }); } } } @@ -131,13 +190,20 @@ export async function findStalePins( 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; - if (spec !== undefined && isStale(spec, cliVersion)) { + 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: `@taskless/${suffix ?? "cli"}`, + name, spec, + installed: null, }); } } @@ -147,6 +213,33 @@ export async function findStalePins( 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. @@ -157,30 +250,38 @@ export async function findStalePins( * notice owes them is that the pin and the project now disagree, and which * version would agree. * - * A migration 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 written schema `migratedTo`, 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. - * Without a migration the layout the pin reads is unchanged and the failure is - * only probable: the rules may lean on engine behavior the pin does not have. + * 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: { migratedTo?: number } = {} + 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 = - options.migratedTo === undefined + 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 to ${cliVersion} as part of this upgrade, then reinstall dependencies.` - : `This upgrade migrated .taskless/ to schema version ${String(options.migratedTo)}, and a CLI that predates that schema refuses the project (SCAFFOLD_VERSION_MISMATCH). ` + + `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 to ${cliVersion} and reinstall dependencies, in the same commit as .taskless/.`; + `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}, which just upgraded this project:`, - ...pins.map((pin) => ` - ${pin.location}: ${pin.name} ${pin.spec}`), + `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/util/version-compare.ts b/packages/cli/src/util/version-compare.ts index addde4a7..b0343e4c 100644 --- a/packages/cli/src/util/version-compare.ts +++ b/packages/cli/src/util/version-compare.ts @@ -23,3 +23,32 @@ export function compareVersions(a: string, b: string): number { } 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 as strings, + * which orders nightlies by build time because the timestamp leads the stamp + * and is fixed-width. 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 left < right ? -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 2fc23e08..993b7476 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -79,7 +79,7 @@ export async function runWizard( const pinnedNotice = getPinnedCliNotice( await findStalePins(options.cwd, cliVersion), cliVersion, - { migratedTo: migrated?.to } + { migrated } ); if (pinnedNotice !== undefined) console.log(pinnedNotice); const commandsInstalled = plan.targets.some( diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index 032f43df..644adc2a 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -296,7 +296,8 @@ describe("taskless init (the batch install)", () => { expect(stdout).toContain("devDependencies: @taskless/cli 0.0.1"); expect(stdout).toContain("scripts.lint: @taskless/cli 0.0.1"); - expect(stdout).toMatch(/Offer to update them to /); + 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 @@ -304,6 +305,10 @@ 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("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:/); }); @@ -328,12 +333,35 @@ describe("taskless init (the batch install)", () => { cwd, ]); - expect(stdout).toContain(`schema version ${String(LATEST_SCHEMA_VERSION)}`); + 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. @@ -383,7 +411,12 @@ describe("taskless init (the batch install)", () => { expect( (JSON.parse(pinned.stdout) as { pinnedCli: unknown }).pinnedCli ).toEqual([ - { location: "dependencies", name: "@taskless/cli", spec: "0.0.1" }, + { + location: "dependencies", + name: "@taskless/cli", + spec: "0.0.1", + installed: null, + }, ]); }); diff --git a/packages/cli/test/pinned-cli.test.ts b/packages/cli/test/pinned-cli.test.ts index a8a92176..e97b6732 100644 --- a/packages/cli/test/pinned-cli.test.ts +++ b/packages/cli/test/pinned-cli.test.ts @@ -1,9 +1,13 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +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 } from "../src/install/pinned-cli"; +import { + findStalePins, + getPinnedCliNotice, + type PinnedCli, +} from "../src/install/pinned-cli"; describe("findStalePins", () => { let cwd: string; @@ -20,6 +24,15 @@ describe("findStalePins", () => { 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([]); }); @@ -30,35 +43,119 @@ describe("findStalePins", () => { expect(await findStalePins(cwd, "0.11.2")).toEqual([]); }); - it.each([ - ["0.10.2", true], - ["=0.10.2", true], - ["v0.11.1", 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], - ["^0.11.0", false], - ["~0.10.9", true], - ["~0.11.0", false], - // A nightly carries the same layout as its release. - ["0.11.2-20260901000000xabcdef0", false], - ["0.11.1-20260901000000xabcdef0", true], - // 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], - ])("a devDependency of %s is stale: %s", async (spec, stale) => { - await writePackage({ devDependencies: { "@taskless/cli": spec } }); - const pins = await findStalePins(cwd, "0.11.2"); - expect(pins).toEqual( - stale - ? [{ location: "devDependencies", name: "@taskless/cli", spec }] - : [] + 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], + ])( + "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 () => { @@ -68,11 +165,17 @@ describe("findStalePins", () => { peerDependencies: { "@taskless/cli": "0.9.0" }, }); expect(await findStalePins(cwd, "0.11.2")).toEqual([ - { location: "dependencies", name: "@taskless/cli", spec: "0.9.0" }, + { + location: "dependencies", + name: "@taskless/cli", + spec: "0.9.0", + installed: null, + }, { location: "optionalDependencies", name: "@taskless/cli-nightly", spec: "0.10.0-2026x0", + installed: null, }, ]); }); @@ -88,39 +191,108 @@ describe("findStalePins", () => { }, }); expect(await findStalePins(cwd, "0.11.2")).toEqual([ - { location: "scripts.lint", name: "@taskless/cli", spec: "0.10.2" }, + { + 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 and offers the bump rather than claiming it", () => { + it("names every pin with its target, and offers the bump rather than claiming it", () => { const notice = getPinnedCliNotice( [ - { location: "devDependencies", name: "@taskless/cli", spec: "^0.10.2" }, - { location: "scripts.lint", name: "@taskless/cli", spec: "0.10.2" }, + { ...releasePin, spec: "^0.11.0", installed: "0.11.0" }, + { ...releasePin, location: "scripts.lint" }, ], "0.11.2" ); - expect(notice).toContain("devDependencies: @taskless/cli ^0.10.2"); - expect(notice).toContain("scripts.lint: @taskless/cli 0.10.2"); - expect(notice).toContain("Offer to update them to 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("hedges without a migration: the layout the pin reads did not move", () => { + 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( - [{ location: "devDependencies", name: "@taskless/cli", spec: "0.10.2" }], + [{ ...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"); }); @@ -128,15 +300,23 @@ describe("getPinnedCliNotice", () => { 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( - [{ location: "devDependencies", name: "@taskless/cli", spec: "0.10.2" }], - "0.11.2", - { migratedTo: 9 } - ); - expect(notice).toContain("schema version 9"); + 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(); + } + }); +}); From 1d41534228e8b850a26ca017f3d7d5b4864f0606 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 15:55:40 -0700 Subject: [PATCH 3/3] fix(cli): compare prerelease identifiers by semver precedence compareSemver compared the prerelease tail as one string, which orders rc.9 after rc.10. Compare dot-separated identifiers instead: numeric ones by value and below alphanumeric ones, a shorter prefix list first. Nightly stamps were unaffected, since their timestamp is fixed-width. --- packages/cli/src/util/version-compare.ts | 38 +++++++++++++++++++++--- packages/cli/test/pinned-cli.test.ts | 7 +++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/util/version-compare.ts b/packages/cli/src/util/version-compare.ts index b0343e4c..c5066b2f 100644 --- a/packages/cli/src/util/version-compare.ts +++ b/packages/cli/src/util/version-compare.ts @@ -32,9 +32,9 @@ export function compareVersions(a: string, b: string): number { * 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 as strings, - * which orders nightlies by build time because the timestamp leads the stamp - * and is fixed-width. Build metadata (`+…`) carries no precedence. + * 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); @@ -44,7 +44,37 @@ export function compareSemver(a: string, b: string): number { if (left === right) return 0; if (left === undefined) return 1; if (right === undefined) return -1; - return left < right ? -1 : 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 { diff --git a/packages/cli/test/pinned-cli.test.ts b/packages/cli/test/pinned-cli.test.ts index e97b6732..3388cba5 100644 --- a/packages/cli/test/pinned-cli.test.ts +++ b/packages/cli/test/pinned-cli.test.ts @@ -92,6 +92,13 @@ describe("findStalePins", () => { ["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) => {