Skip to content

Commit f97d147

Browse files
committed
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.
1 parent 143ff37 commit f97d147

13 files changed

Lines changed: 723 additions & 30 deletions

File tree

‎.changeset/init-stale-cli-pins.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@taskless/cli": patch
3+
---
4+
5+
`taskless init` names any `package.json` pin of `@taskless/cli` (a dependency entry, or a script spelling out `@taskless/cli@<version>`) 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 v11) tells an agent to offer the bump.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
## Why
2+
3+
An upgrade is usually run through a launcher, `npx @taskless/cli@latest init`,
4+
and the launcher leaves the project's own pins alone. A `devDependencies` entry
5+
of `^0.10.2`, or a script spelling out `npx @taskless/cli@0.10.2 check`, is
6+
what CI, a git hook and `pnpm lint` actually run. After the upgrade those run a
7+
CLI older than the `.taskless/` it now finds, and an older CLI refuses a layout
8+
newer than it understands ("Upgrade the CLI to continue"). Nothing about the
9+
upgrade itself fails, so the first sign is a red CI run on the next push, and it
10+
does not read as an upgrade problem when it arrives.
11+
12+
The upgrade trailer already tells the caller what to commit and that `update`
13+
exists. It says nothing about the one other place the upgrade is incomplete.
14+
15+
## What Changes
16+
17+
- `taskless init`, both the batch path and the wizard, reads `package.json` in
18+
the working directory and names every pin of `@taskless/cli` or
19+
`@taskless/cli-nightly` whose ceiling sits below the running CLI: an exact
20+
version, or a `^`/`~` range that cannot reach it, in `dependencies`,
21+
`devDependencies`, `optionalDependencies`, or spelled out in a script. It
22+
offers the bump; it does not make it.
23+
- When the same run migrated `.taskless/`, the notice states the breakage as
24+
certain rather than likely: a CLI that predates the new schema refuses the
25+
project with `SCAFFOLD_VERSION_MISMATCH`, so CI breaks on the push carrying
26+
the migrated files, and the bump belongs in that same commit.
27+
- `init --json` carries the pins as `pinnedCli`, always present, empty when
28+
nothing is stale.
29+
- The `update` recipe goes to topic v11 with a step telling the agent to offer
30+
the bump as part of the upgrade, without making it silently.
31+
- `compareVersions` moves from `reconcile-marker.ts` to
32+
`util/version-compare.ts`, unchanged, so both callers share it.
33+
34+
## Capabilities
35+
36+
### New Capabilities
37+
38+
None.
39+
40+
### Modified Capabilities
41+
42+
- `cli-init`: one ADDED requirement. No standing requirement is restated,
43+
renamed or removed. The notice is separate from the upgrade trailer, so the
44+
standing "a no-op re-install prints no upgrade trailer" scenario still holds.
45+
46+
## Impact
47+
48+
Additive output on `init`, plus one envelope field. `patch`: the package is
49+
pre-1.0. A spec this change cannot bound (`latest`, `*`, `>=`, `workspace:`, a
50+
URL) is not reported, so a project that floats its pin sees nothing new.
51+
52+
## Delivery shape
53+
54+
**Single PR.** The spec, detector, wiring, recipe step and tests are one small
55+
reviewable diff. It is the tip, so the change is archived here.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Init names a package.json pin older than the running CLI
4+
5+
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:
6+
7+
- an entry in `dependencies`, `devDependencies`, or `optionalDependencies`; or
8+
- a script in `scripts` that spells out `@taskless/cli@<spec>` or `@taskless/cli-nightly@<spec>`.
9+
10+
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.
11+
12+
The install SHALL NOT modify `package.json`. The report SHALL offer the bump rather than claim it.
13+
14+
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.<name>`), 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.
15+
16+
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.
17+
18+
Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ location, name, spec }`, present on every successful run and empty when nothing is stale.
19+
20+
#### Scenario: A stale dependency and script pin are named and left alone
21+
22+
- **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@<older>`
23+
- **THEN** stdout SHALL name both pins with their location and spec, and offer updating them to `V`
24+
- **AND** `package.json` SHALL be byte-identical afterwards
25+
- **AND** the notice SHALL appear after the upgrade trailer, with the onboarding trailer still the final line
26+
27+
#### Scenario: A pre-1.0 caret range that cannot reach the running version is stale
28+
29+
- **WHEN** `package.json` pins `@taskless/cli` at `^0.10.2` and the running CLI is `0.11.2`
30+
- **THEN** the pin SHALL be reported
31+
32+
#### Scenario: A floating or current pin is not reported
33+
34+
- **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`
35+
- **THEN** no pin SHALL be reported
36+
37+
#### Scenario: A migration makes the breakage definite
38+
39+
- **WHEN** `taskless init` migrates `.taskless/` to schema version `N` in a project with a stale pin
40+
- **THEN** the notice SHALL name schema version `N` and `SCAFFOLD_VERSION_MISMATCH`
41+
- **AND** SHALL state that CI running the pins will break, and that the bump belongs in the same commit as `.taskless/`
42+
- **AND** SHALL NOT describe the failure as merely likely
43+
44+
#### Scenario: A stale pin is named on a re-install that changed nothing
45+
46+
- **WHEN** `taskless init` runs against a project that is already current and whose `package.json` holds a stale pin
47+
- **THEN** stdout SHALL NOT contain the upgrade trailer
48+
- **AND** stdout SHALL name the stale pin
49+
50+
#### Scenario: The JSON envelope carries the pins
51+
52+
- **WHEN** `taskless init --json` runs
53+
- **THEN** the envelope SHALL contain `pinnedCli`, an array with one `{ location, name, spec }` entry per stale pin
54+
- **AND** `pinnedCli` SHALL be an empty array when there is no `package.json` or nothing in it is stale
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
## 1. Spec
2+
3+
- [x] 1.1 Add the requirement to `cli-init` as an ADDED block, separate from
4+
the upgrade trailer, so no standing requirement is restated and the
5+
no-op scenario keeps holding.
6+
- [x] 1.2 Dry-run `openspec archive` and compare the scenario count in
7+
`cli-init` before and after.
8+
9+
## 2. Detector
10+
11+
- [x] 2.1 Move `compareVersions` to `util/version-compare.ts`, unchanged.
12+
- [x] 2.2 `install/pinned-cli.ts`: read `package.json`, report bounded pins
13+
whose ceiling is at or below the running version, in the three
14+
dependency fields and in scripts.
15+
- [x] 2.3 Treat an absent or unparseable `package.json` as no pins.
16+
17+
## 3. Wiring
18+
19+
- [x] 3.1 Batch `init`: print the notice after the upgrade trailer, not gated
20+
on the run having changed anything; add `pinnedCli` to the envelope.
21+
- [x] 3.2 Wizard: print the notice after the outro.
22+
- [x] 3.3 When the run migrated, state the breakage as certain: name the
23+
schema version and `SCAFFOLD_VERSION_MISMATCH`, and put the bump in the
24+
same commit as `.taskless/`.
25+
26+
## 4. Recipe
27+
28+
- [x] 4.1 `update` topic v11: a step offering the bump, without making it
29+
silently, before recording the walk.
30+
31+
## 5. Tests
32+
33+
- [x] 5.1 Detector: the spec table in both directions, every dependency
34+
field, the nightly name, scripts, an unreadable `package.json`.
35+
- [x] 5.2 Integration: notice order and content, `package.json` untouched,
36+
the no-op re-install, and the `--json` field.

‎openspec/specs/cli-init/spec.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,3 +825,56 @@ The `migrated` field is unchanged: present with the migration report when a migr
825825
- **WHEN** `taskless init --json` runs
826826
- **THEN** the envelope SHALL contain `cliVersion.previous` (a string or `null`), `cliVersion.installed`, a `targets` array with one entry per install target, and a boolean `changed`
827827
- **AND** `changed` SHALL be `true` exactly when `migrated` is present, any target's written or removed list is non-empty, or `cliVersion.previous` is non-null and differs from `cliVersion.installed`
828+
829+
### Requirement: Init names a package.json pin older than the running CLI
830+
831+
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:
832+
833+
- an entry in `dependencies`, `devDependencies`, or `optionalDependencies`; or
834+
- a script in `scripts` that spells out `@taskless/cli@<spec>` or `@taskless/cli-nightly@<spec>`.
835+
836+
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.
837+
838+
The install SHALL NOT modify `package.json`. The report SHALL offer the bump rather than claim it.
839+
840+
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.<name>`), 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.
841+
842+
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.
843+
844+
Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ location, name, spec }`, present on every successful run and empty when nothing is stale.
845+
846+
#### Scenario: A stale dependency and script pin are named and left alone
847+
848+
- **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@<older>`
849+
- **THEN** stdout SHALL name both pins with their location and spec, and offer updating them to `V`
850+
- **AND** `package.json` SHALL be byte-identical afterwards
851+
- **AND** the notice SHALL appear after the upgrade trailer, with the onboarding trailer still the final line
852+
853+
#### Scenario: A pre-1.0 caret range that cannot reach the running version is stale
854+
855+
- **WHEN** `package.json` pins `@taskless/cli` at `^0.10.2` and the running CLI is `0.11.2`
856+
- **THEN** the pin SHALL be reported
857+
858+
#### Scenario: A floating or current pin is not reported
859+
860+
- **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`
861+
- **THEN** no pin SHALL be reported
862+
863+
#### Scenario: A migration makes the breakage definite
864+
865+
- **WHEN** `taskless init` migrates `.taskless/` to schema version `N` in a project with a stale pin
866+
- **THEN** the notice SHALL name schema version `N` and `SCAFFOLD_VERSION_MISMATCH`
867+
- **AND** SHALL state that CI running the pins will break, and that the bump belongs in the same commit as `.taskless/`
868+
- **AND** SHALL NOT describe the failure as merely likely
869+
870+
#### Scenario: A stale pin is named on a re-install that changed nothing
871+
872+
- **WHEN** `taskless init` runs against a project that is already current and whose `package.json` holds a stale pin
873+
- **THEN** stdout SHALL NOT contain the upgrade trailer
874+
- **AND** stdout SHALL name the stale pin
875+
876+
#### Scenario: The JSON envelope carries the pins
877+
878+
- **WHEN** `taskless init --json` runs
879+
- **THEN** the envelope SHALL contain `pinnedCli`, an array with one `{ location, name, spec }` entry per stale pin
880+
- **AND** `pinnedCli` SHALL be an empty array when there is no `package.json` or nothing in it is stale

‎packages/cli/src/agent/update.md‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Topic: update (CLI v%(CLI_VERSION)s / topic v10)
1+
# Topic: update (CLI v%(CLI_VERSION)s / topic v11)
22

33
## You are here
44
This is `update`. It tells you what an upgrade changed for the rules
@@ -54,7 +54,27 @@ that you finished.
5454
it means for existing rules. A section that says there is nothing to
5555
do means exactly that; it is a claim, not an oversight.
5656

57-
4. **Record that you finished.** Run:
57+
4. **Offer to bump a pinned CLI.** If `package.json` pins
58+
`@taskless/cli` (a dependency entry, or a script spelling out
59+
`@taskless/cli@<version>`) below the installed CLI, `init` named each
60+
pin when it upgraded the project, and `init --json` carries them as
61+
`pinnedCli`. Those pins are what scripts, CI, and git hooks run.
62+
63+
If the upgrade also migrated `.taskless/` (`init` printed a migration,
64+
or `init --json` carried `migrated`), this is not optional advice: a
65+
CLI that predates the new schema refuses the project with
66+
`SCAFFOLD_VERSION_MISMATCH`, so CI breaks on the push that carries the
67+
migrated files. The bump belongs in that same commit. Without a
68+
migration the pin still reads the layout, but checks rules against
69+
engines this walk has moved past.
70+
71+
Offer the user the bump to the installed version, along with
72+
reinstalling dependencies. Do not make it silently: a pin can be
73+
deliberate, and the bump changes the lockfile. The walk does not
74+
depend on the answer, so record it either way, but if they decline
75+
after a migration, tell them plainly that CI will fail.
76+
77+
5. **Record that you finished.** Run:
5878
```
5979
%(TASKLESS_CLI)s update --rules
6080
```

‎packages/cli/src/commands/init.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import { getMandatorySkillNames } from "../install/catalog";
1616
import type { InstallMode } from "../install/state";
1717
import { getReloadNotice, versionMoved } from "../install/reload-notice";
1818
import { getUpgradeTrailer } from "../install/upgrade-trailer";
19+
import {
20+
findStalePins,
21+
getPinnedCliNotice,
22+
type PinnedCli,
23+
} from "../install/pinned-cli";
1924
import { readInstallState } from "../install/state";
2025
import { getTelemetry } from "../telemetry";
2126
import { getCliVersion } from "../wizard/intro";
@@ -120,6 +125,9 @@ export const initCommand = defineCommand({
120125
// is the one value an agent gates its commit step on, and folding
121126
// four lists and a presence check is how a consumer gets it wrong.
122127
changed: result.changed,
128+
// Always present, empty when nothing is stale, for the same reason
129+
// `cliVersion.previous` is `null` rather than absent.
130+
pinnedCli: result.pinnedCli,
123131
// Absent when nothing ran, so a caller distinguishes "the tree was
124132
// rewritten" from "nothing happened" by presence, never by reading
125133
// empty arrays out of it.
@@ -147,6 +155,17 @@ export const initCommand = defineCommand({
147155
if (upgradeTrailer !== undefined) {
148156
console.log(upgradeTrailer);
149157
}
158+
// Not gated on the run having changed anything. A re-install that wrote
159+
// nothing still leaves the pin and the project disagreeing, and this is
160+
// the one place that looks.
161+
const pinnedNotice = getPinnedCliNotice(
162+
result.pinnedCli,
163+
result.cliVersion,
164+
{ migratedTo: result.migrated?.to }
165+
);
166+
if (pinnedNotice !== undefined) {
167+
console.log(pinnedNotice);
168+
}
150169
if (result.reloadNotice !== undefined) {
151170
console.log(result.reloadNotice);
152171
}
@@ -315,6 +334,8 @@ async function runNonInteractive(
315334
targets: TargetOutcome[];
316335
/** Whether a migration ran or any target wrote or removed anything. */
317336
changed: boolean;
337+
/** `package.json` pins that cannot resolve to the CLI that ran this. */
338+
pinnedCli: PinnedCli[];
318339
}> {
319340
// Under `--json`, stdout carries only the envelope printed by the caller.
320341
// This per-target summary is not on that envelope (it is finer-grained than
@@ -466,6 +487,7 @@ async function runNonInteractive(
466487
migrated !== undefined ||
467488
targets.some((target) => targetChanged(target)) ||
468489
versionMoved({ previousCliVersion, cliVersion }),
490+
pinnedCli: await findStalePins(cwd, cliVersion),
469491
};
470492
}
471493

0 commit comments

Comments
 (0)