From 0dac786d4797a9cc213e8d09400c78a704bb20db Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 16:41:29 -0700 Subject: [PATCH 1/2] feat(cli): read workspace packages for stale CLI pins, and report them on info --- .changeset/init-stale-cli-pins.md | 2 +- .../proposal.md | 63 ++++++ .../specs/cli-init/spec.md | 106 +++++++++ .../specs/cli/spec.md | 24 ++ .../tasks.md | 31 +++ openspec/specs/cli-init/spec.md | 36 ++- openspec/specs/cli/spec.md | 23 ++ packages/cli/src/agent/info.md | 18 +- packages/cli/src/agent/init.md | 22 +- packages/cli/src/agent/update.md | 29 +-- packages/cli/src/commands/info.ts | 45 ++-- packages/cli/src/install/pinned-cli.ts | 210 +++++++++++++++--- packages/cli/src/schemas/info.ts | 29 +++ packages/cli/test/info.test.ts | 33 +++ packages/cli/test/init-no-interactive.test.ts | 1 + packages/cli/test/pinned-cli.test.ts | 142 +++++++++++- 16 files changed, 729 insertions(+), 85 deletions(-) create mode 100644 openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/proposal.md create mode 100644 openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/specs/cli-init/spec.md create mode 100644 openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/specs/cli/spec.md create mode 100644 openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/tasks.md diff --git a/.changeset/init-stale-cli-pins.md b/.changeset/init-stale-cli-pins.md index 7a2f65a3..e38fd70c 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` 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. +`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`. The workspace packages a project declares (`pnpm-workspace.yaml`, or the `workspaces` field) are read as well as the root, and a dependency is judged by the version that package actually runs: its own `node_modules` link, else the hoisted one. `init --json` and `info --json` both carry the pins as `pinnedCli`, each naming its `manifest`, and plain `info` lists them. The `init` (topic v4), `info` (topic v2) and `update` (topic v14) recipes tell an agent to offer the bump, and `update` now reads the pins from `info --json` rather than re-running `init`. diff --git a/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/proposal.md b/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/proposal.md new file mode 100644 index 00000000..af5e2032 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/proposal.md @@ -0,0 +1,63 @@ +## Why + +#443 made `taskless init` name a `package.json` pin of the CLI that would run +an older build than the one that just upgraded the project. It left two gaps, +recorded in #445. + +It reads only the root `package.json`. In a monorepo the pin usually lives in a +workspace package, and that package's CI job is what runs it, so after an +upgrade that migrated `.taskless/` the job fails with +`SCAFFOLD_VERSION_MISMATCH` and nothing warned. That is the silent break #443 +exists to prevent. + +And the pins are only on `init`. The `update` recipe is reached after a version +move, possibly in a later session that never saw `init`'s output, so its step 4 +tells the agent to re-run `init --json`. That is safe, but `init` is not a read +command, and `update` already runs `info --json` in step 1. + +## What Changes + +- The detector reads the workspace packages the working directory declares: + `pnpm-workspace.yaml` `packages`, and the `workspaces` field as an array or + `{ packages }`. Patterns expand with Node's built-in `fs.glob`, already used + by the CLI, so no dependency is added. `!` negates, absolute and `..` + patterns are skipped, `node_modules` and `.git` are never descended into, + and expansion stops at 500 manifests. +- A dependency's installed version is resolved as Node resolves it: the + package's own `node_modules` link first (pnpm), then each parent's up to the + working directory (npm/yarn hoisting). +- Each pin carries `manifest`, the `/`-separated path of its `package.json`, + separate from `location` rather than folded into it, so `location` keeps + meaning "the field" and a consumer need not parse it. The notice names it on + every line, the root included. +- `info --json` carries `pinnedCli` in the same shape; plain `info` lists the + pins. +- Recipes: `update` topic v14 reads the pins from `info --json` in step 4. + `info` topic v2 documents the field and a step to offer the bump. `init` + topic v4 documents `manifest`. +- The existing `init-stale-cli-pins` changeset is extended. #443 has not been + released (`latest` is 0.11.2), so this is one release note. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-init`: "Init names a package.json pin older than the running CLI" is + MODIFIED, restated in full under the same title, with every standing + scenario kept and three added. +- `cli`: one ADDED requirement, "Info reports stale CLI pins". The standing + `info` requirement is not restated. + +## Impact + +Additive. `pinnedCli` and `manifest` have never been released, so no consumer +sees a shape change. `patch`: the package is pre-1.0. + +## Delivery shape + +**Single PR.** Detector, `info` wiring, recipes, spec and tests are one +reviewable diff. It is the tip, so the change is archived here. diff --git a/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/specs/cli-init/spec.md b/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/specs/cli-init/spec.md new file mode 100644 index 00000000..8cc6296d --- /dev/null +++ b/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/specs/cli-init/spec.md @@ -0,0 +1,106 @@ +## MODIFIED 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 the `package.json` of every workspace package the working directory declares, and report every pin of `@taskless/cli` or `@taskless/cli-nightly` that would run a CLI older than the running one. + +Workspace packages are declared by the `packages` list of `pnpm-workspace.yaml` and by the `workspaces` field of the root `package.json`, as an array or as `{ packages }`; both sources SHALL be read. A pattern SHALL name each directory it matches that holds a `package.json`. A pattern prefixed `!` SHALL remove the directories it matches. A pattern that is absolute or contains a `..` segment SHALL be skipped. Expansion SHALL NOT descend into `node_modules` or `.git`, and SHALL stop after a fixed number of manifests. The root SHALL be read once even when a pattern names it. An absent or malformed workspace declaration SHALL declare nothing, and a malformed root `package.json` SHALL NOT stop `pnpm-workspace.yaml` from being read. + +A pin is: + +- an entry in `dependencies`, `devDependencies`, or `optionalDependencies`; or +- a script in `scripts` that spells out `@taskless/cli@` or `@taskless/cli-nightly@`, where the name is not the tail of a longer name and the spec ends at whitespace, a quote, or shell punctuation (`;&|()<>,:`). A pin repeated within one script SHALL be reported once. + +A dependency pin SHALL be reported when either holds: + +- the version installed for that package is older than the running version, because that, and the lockfile it came from, is what runs. It SHALL be read from `node_modules//package.json` in the directory holding the pin, then in each parent up to the working directory, the first found winning, as Node resolves it: a workspace package's own link under pnpm, the hoisted root copy under npm or yarn; or +- its spec is bounded and cannot reach the running version. + +A script pin SHALL be reported when its spec is bounded and cannot reach the running version. + +A bounded spec is an exact version (optionally prefixed `=` or `v`, with optional whitespace after the operator, prerelease, and build metadata), or a `^` or `~` range. An exact version, and an installed version, SHALL be compared with semver precedence, so a prerelease sorts before its release and two prereleases of one base compare by their prerelease text, which orders nightlies by build time. A range SHALL be compared by its exclusive ceiling on the numeric core, with caret ranges holding the left-most non-zero part as npm does. A spec the CLI cannot bound (`latest`, `*`, a comparator range, `workspace:`, a URL or git spec) SHALL NOT be reported on its own. An absent or unparseable `package.json`, or an unreadable installed manifest, SHALL produce no report from that source and SHALL NOT fail the install. + +Pins SHALL be reported root first, then by workspace manifest path, each manifest's in file order: dependency fields, then scripts. + +The install SHALL NOT modify any `package.json`. The report SHALL offer the bump rather than claim it. + +On the human path (batch and wizard), when at least one pin is reported, the CLI SHALL print a notice naming, for each pin, its manifest path relative to the working directory, its location in that manifest (the dependency field, or `scripts.`), package, spec, installed version when known, and the package and version to move it to. The target SHALL be the running version on the package that publishes it: `@taskless/cli-nightly` when the running version carries a prerelease, `@taskless/cli` otherwise, with the switch named when the pin is on the other package. The notice SHALL print whether or not the run changed anything. On the batch path it SHALL print after the upgrade trailer, and the onboarding trailer SHALL remain the final line. + +When the same run migrated an existing `.taskless/` (the migration's `from` is above `0`), the notice SHALL NOT hedge. It SHALL name the schema versions the run moved between, state that a CLI predating the new schema refuses the project with `SCAFFOLD_VERSION_MISMATCH` so CI running the pins will break on the push carrying the migrated files, and say the bump belongs in the same commit as `.taskless/`. A migration from `0` is how a fresh install creates `.taskless/`; it SHALL NOT be described as an upgrade, and like a run with no migration the notice SHALL describe the failure as likely, not certain. + +Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ manifest, location, name, spec, installed }`, where `manifest` is the `/`-separated path of the `package.json` holding the pin relative to the working directory, `installed` is the installed version or `null`, present on every successful run and empty when nothing is stale. + +#### Scenario: A stale dependency and script pin are named and left alone + +- **WHEN** `taskless init` runs at version `V` in a project whose `package.json` has `devDependencies["@taskless/cli"]` set to a version below `V`, and a script running `npx @taskless/cli@` +- **THEN** stdout SHALL name both pins with their location and spec, and the target `@taskless/cli@V` +- **AND** `package.json` SHALL be byte-identical afterwards +- **AND** the notice SHALL appear after the upgrade trailer, with the onboarding trailer still the final line + +#### Scenario: An installed build older than the running CLI is stale even when its range admits the running version + +- **WHEN** `package.json` pins `@taskless/cli` at `^0.11.0`, `node_modules/@taskless/cli` is `0.11.0`, and the running CLI is `0.11.2` +- **THEN** the pin SHALL be reported with `installed` set to `0.11.0` + +#### Scenario: A pre-1.0 caret range that cannot reach the running version is stale + +- **WHEN** `package.json` pins `@taskless/cli` at `^0.10.2`, nothing is installed, and the running CLI is `0.11.2` +- **THEN** the pin SHALL be reported + +#### Scenario: An older nightly of the same base is stale + +- **WHEN** `package.json` pins `@taskless/cli-nightly` at `0.12.0-20260901000000xaaaaaaa` and the running CLI is `0.12.0-20261005000000xbbbbbbb` or `0.12.0` +- **THEN** the pin SHALL be reported + +#### Scenario: A nightly pin moves to the release package when a release is running + +- **WHEN** a stale pin names `@taskless/cli-nightly` and the running CLI is a release `V` +- **THEN** the notice SHALL give `@taskless/cli@V` as the target and name the package switch + +#### Scenario: A floating or current pin is not reported + +- **WHEN** nothing older than the running version is installed, and `package.json` pins `@taskless/cli` at `latest`, `*`, `>=0.10.0`, `workspace:*`, or a range that admits the running version, or a script runs `@taskless/cli@latest` +- **THEN** no pin SHALL be reported + +#### Scenario: A migration of an existing scaffold makes the breakage definite + +- **WHEN** `taskless init` migrates an existing `.taskless/` from schema version `M` above `0` to `N` in a project with a stale pin +- **THEN** the notice SHALL name schema versions `M` and `N` and `SCAFFOLD_VERSION_MISMATCH` +- **AND** SHALL state that CI running the pins will break, and that the bump belongs in the same commit as `.taskless/` +- **AND** SHALL NOT describe the failure as merely likely + +#### Scenario: A fresh install is not called an upgrade + +- **WHEN** `taskless init` creates `.taskless/` in a project with a stale pin +- **THEN** the notice SHALL describe the failure as likely +- **AND** SHALL NOT mention `SCAFFOLD_VERSION_MISMATCH` or describe the run as an upgrade + +#### Scenario: A stale pin is named on a re-install that changed nothing + +- **WHEN** `taskless init` runs against a project that is already current and whose `package.json` holds a stale pin +- **THEN** stdout SHALL NOT contain the upgrade trailer +- **AND** stdout SHALL name the stale pin + +#### Scenario: The JSON envelope carries the pins + +- **WHEN** `taskless init --json` runs +- **THEN** the envelope SHALL contain `pinnedCli`, an array with one `{ manifest, location, name, spec, installed }` entry per stale pin +- **AND** `pinnedCli` SHALL be an empty array when there is no `package.json` or nothing in it is stale + +#### Scenario: A pin in a declared workspace package is named with its manifest + +- **WHEN** `pnpm-workspace.yaml` declares `packages/*`, or the root `package.json` declares `workspaces` as `["packages/*"]` or `{ "packages": ["packages/*"] }`, and `packages/app/package.json` pins `@taskless/cli` at a version below the running one +- **THEN** the pin SHALL be reported with `manifest` set to `packages/app/package.json` +- **AND** the notice line SHALL name `packages/app/package.json` and the field + +#### Scenario: A workspace package is judged by the version it runs + +- **WHEN** `packages/linked/package.json` and `packages/hoisted/package.json` both pin `@taskless/cli` at `^0.11.0`, `packages/linked/node_modules/@taskless/cli` is `0.11.0`, only the root `node_modules/@taskless/cli` exists for `packages/hoisted` at `0.11.1`, and the running CLI is `0.11.2` +- **THEN** `packages/linked/package.json` SHALL be reported with `installed` `0.11.0` +- **AND** `packages/hoisted/package.json` SHALL be reported with `installed` `0.11.1` + +#### Scenario: Workspace expansion stays inside the project + +- **WHEN** a workspace pattern is negated, is absolute, climbs out with `..`, or is `**` over a tree that contains `node_modules` +- **THEN** a directory the negation matches SHALL NOT be reported +- **AND** nothing outside the working directory, and no `package.json` under `node_modules`, SHALL be read as a workspace package diff --git a/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/specs/cli/spec.md b/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/specs/cli/spec.md new file mode 100644 index 00000000..89417c7b --- /dev/null +++ b/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/specs/cli/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Info reports stale CLI pins + +`taskless info` SHALL report the same pins `taskless init` reports under "Init names a package.json pin older than the running CLI", judged against the running version, read from the working directory and its declared workspace packages by the same rules. Under `--json` the payload SHALL carry `pinnedCli` in the shape `init --json` uses, `{ manifest, location, name, spec, installed }`, present on every successful run and empty when nothing is stale. Without `--json`, when at least one pin is reported, the output SHALL list each pin with its manifest, location, spec, installed version when known, and the package and version to move it to. + +`info` is read-only: it SHALL NOT modify any `package.json`, and an unreadable manifest SHALL NOT make it fail. `--anonymous` SHALL NOT suppress the pins, since they are local state. + +The `update` recipe SHALL read the pins from `info --json`, the read-only command it already runs, rather than directing an agent to re-run `init`. + +#### Scenario: info --json carries a workspace package's stale pin + +- **WHEN** `taskless info --json` runs in a project whose root `package.json` declares `workspaces: ["packages/*"]` and `packages/app/package.json` pins `@taskless/cli` at a version below the running one +- **THEN** `pinnedCli` SHALL contain `{ manifest: "packages/app/package.json", location: "devDependencies", name: "@taskless/cli", spec, installed: null }` + +#### Scenario: info --json reports no pins as an empty list + +- **WHEN** `taskless info --json` runs where there is no `package.json`, or nothing in one is stale +- **THEN** `pinnedCli` SHALL be an empty array + +#### Scenario: Plain info lists the pins + +- **WHEN** `taskless info` runs without `--json` and a pin is stale +- **THEN** stdout SHALL name the pin's manifest, location, and spec, and the version to move it to diff --git a/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/tasks.md b/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/tasks.md new file mode 100644 index 00000000..ce0f9c87 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/tasks.md @@ -0,0 +1,31 @@ +## 1. Spec + +- [x] 1.1 MODIFIED `cli-init` requirement restated in full under its existing + title; ADDED `cli` requirement for `info`. +- [x] 1.2 Dry-run `openspec archive` and confirm every prior scenario survives. + +## 2. Detector + +- [x] 2.1 Read workspace patterns from `pnpm-workspace.yaml` and `workspaces`. +- [x] 2.2 Expand them with `fs.glob`, honouring negation, skipping absolute and + `..` patterns and `node_modules`, bounded at 500 manifests. +- [x] 2.3 Resolve the installed version from the package directory up to the + working directory. +- [x] 2.4 Add `manifest` to each pin and to the notice line. + +## 3. info + +- [x] 3.1 `pinnedCli` on the `info` schema and payload; plain output lists pins. + +## 4. Recipes and changeset + +- [x] 4.1 `update` v14 reads pins from `info --json`; `info` v2; `init` v4. +- [x] 4.2 Extend the `init-stale-cli-pins` changeset. + +## 5. Tests + +- [x] 5.1 Detector: both workspace sources, root-first order, negation, `**` + past `node_modules`, escaping patterns, own-link versus hoisted + installs, malformed root with `pnpm-workspace.yaml`. +- [x] 5.2 `info --json` and plain `info` integration; `init --json` carries + `manifest`. diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 9f887be3..3e174de7 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -805,27 +805,33 @@ 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 would run a CLI older than the running one. A pin is: +After a successful install, `taskless init` SHALL read `package.json` in the working directory, and the `package.json` of every workspace package the working directory declares, and report every pin of `@taskless/cli` or `@taskless/cli-nightly` that would run a CLI older than the running one. + +Workspace packages are declared by the `packages` list of `pnpm-workspace.yaml` and by the `workspaces` field of the root `package.json`, as an array or as `{ packages }`; both sources SHALL be read. A pattern SHALL name each directory it matches that holds a `package.json`. A pattern prefixed `!` SHALL remove the directories it matches. A pattern that is absolute or contains a `..` segment SHALL be skipped. Expansion SHALL NOT descend into `node_modules` or `.git`, and SHALL stop after a fixed number of manifests. The root SHALL be read once even when a pattern names it. An absent or malformed workspace declaration SHALL declare nothing, and a malformed root `package.json` SHALL NOT stop `pnpm-workspace.yaml` from being read. + +A pin is: - an entry in `dependencies`, `devDependencies`, or `optionalDependencies`; or - a script in `scripts` that spells out `@taskless/cli@` or `@taskless/cli-nightly@`, where the name is not the tail of a longer name and the spec ends at whitespace, a quote, or shell punctuation (`;&|()<>,:`). A pin repeated within one script SHALL be reported once. A dependency pin SHALL be reported when either holds: -- the version installed at `node_modules//package.json` is older than the running version, because that, and the lockfile it came from, is what runs; or +- the version installed for that package is older than the running version, because that, and the lockfile it came from, is what runs. It SHALL be read from `node_modules//package.json` in the directory holding the pin, then in each parent up to the working directory, the first found winning, as Node resolves it: a workspace package's own link under pnpm, the hoisted root copy under npm or yarn; or - its spec is bounded and cannot reach the running version. A script pin SHALL be reported when its spec is bounded and cannot reach the running version. A bounded spec is an exact version (optionally prefixed `=` or `v`, with optional whitespace after the operator, prerelease, and build metadata), or a `^` or `~` range. An exact version, and an installed version, SHALL be compared with semver precedence, so a prerelease sorts before its release and two prereleases of one base compare by their prerelease text, which orders nightlies by build time. A range SHALL be compared by its exclusive ceiling on the numeric core, with caret ranges holding the left-most non-zero part as npm does. A spec the CLI cannot bound (`latest`, `*`, a comparator range, `workspace:`, a URL or git spec) SHALL NOT be reported on its own. An absent or unparseable `package.json`, or an unreadable installed manifest, SHALL produce no report from that source and SHALL NOT fail the install. -The install SHALL NOT modify `package.json`. The report SHALL offer the bump rather than claim it. +Pins SHALL be reported root first, then by workspace manifest path, each manifest's in file order: dependency fields, then scripts. + +The install SHALL NOT modify any `package.json`. The report SHALL offer the bump rather than claim it. -On the human path (batch and wizard), when at least one pin is reported, the CLI SHALL print a notice naming, for each pin, its 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. +On the human path (batch and wizard), when at least one pin is reported, the CLI SHALL print a notice naming, for each pin, its manifest path relative to the working directory, its location in that manifest (the dependency field, or `scripts.`), package, spec, installed version when known, and the package and version to move it to. The target SHALL be the running version on the package that publishes it: `@taskless/cli-nightly` when the running version carries a prerelease, `@taskless/cli` otherwise, with the switch named when the pin is on the other package. The notice SHALL print whether or not the run changed anything. On the batch path it SHALL print after the upgrade trailer, and the onboarding trailer SHALL remain the final line. When the same run migrated an existing `.taskless/` (the migration's `from` is above `0`), the notice SHALL NOT hedge. It SHALL name the schema versions the run moved between, state that a CLI predating the new schema refuses the project with `SCAFFOLD_VERSION_MISMATCH` so CI running the pins will break on the push carrying the migrated files, and say the bump belongs in the same commit as `.taskless/`. A migration from `0` is how a fresh install creates `.taskless/`; it SHALL NOT be described as an upgrade, and like a run with no migration the notice SHALL describe the failure as likely, not certain. -Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ location, name, spec, installed }`, where `installed` is the installed version or `null`, present on every successful run and empty when nothing is stale. +Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ manifest, location, name, spec, installed }`, where `manifest` is the `/`-separated path of the `package.json` holding the pin relative to the working directory, `installed` is the installed version or `null`, present on every successful run and empty when nothing is stale. #### Scenario: A stale dependency and script pin are named and left alone @@ -881,5 +887,23 @@ 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, installed }` entry per stale pin +- **THEN** the envelope SHALL contain `pinnedCli`, an array with one `{ manifest, location, name, spec, installed }` entry per stale pin - **AND** `pinnedCli` SHALL be an empty array when there is no `package.json` or nothing in it is stale + +#### Scenario: A pin in a declared workspace package is named with its manifest + +- **WHEN** `pnpm-workspace.yaml` declares `packages/*`, or the root `package.json` declares `workspaces` as `["packages/*"]` or `{ "packages": ["packages/*"] }`, and `packages/app/package.json` pins `@taskless/cli` at a version below the running one +- **THEN** the pin SHALL be reported with `manifest` set to `packages/app/package.json` +- **AND** the notice line SHALL name `packages/app/package.json` and the field + +#### Scenario: A workspace package is judged by the version it runs + +- **WHEN** `packages/linked/package.json` and `packages/hoisted/package.json` both pin `@taskless/cli` at `^0.11.0`, `packages/linked/node_modules/@taskless/cli` is `0.11.0`, only the root `node_modules/@taskless/cli` exists for `packages/hoisted` at `0.11.1`, and the running CLI is `0.11.2` +- **THEN** `packages/linked/package.json` SHALL be reported with `installed` `0.11.0` +- **AND** `packages/hoisted/package.json` SHALL be reported with `installed` `0.11.1` + +#### Scenario: Workspace expansion stays inside the project + +- **WHEN** a workspace pattern is negated, is absolute, climbs out with `..`, or is `**` over a tree that contains `node_modules` +- **THEN** a directory the negation matches SHALL NOT be reported +- **AND** nothing outside the working directory, and no `package.json` under `node_modules`, SHALL be read as a workspace package diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index 78d5a5f4..9d2c0dac 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -544,3 +544,26 @@ Where detection reports unknown, the printed command SHALL name `npx` with the c - **WHEN** the CLI runs from a `package.json` script under pnpm and prints an authentication remedy - **THEN** the printed command SHALL NOT suggest `pnpm dlx`, because that is not how the reader reached the CLI + +### Requirement: Info reports stale CLI pins + +`taskless info` SHALL report the same pins `taskless init` reports under "Init names a package.json pin older than the running CLI", judged against the running version, read from the working directory and its declared workspace packages by the same rules. Under `--json` the payload SHALL carry `pinnedCli` in the shape `init --json` uses, `{ manifest, location, name, spec, installed }`, present on every successful run and empty when nothing is stale. Without `--json`, when at least one pin is reported, the output SHALL list each pin with its manifest, location, spec, installed version when known, and the package and version to move it to. + +`info` is read-only: it SHALL NOT modify any `package.json`, and an unreadable manifest SHALL NOT make it fail. `--anonymous` SHALL NOT suppress the pins, since they are local state. + +The `update` recipe SHALL read the pins from `info --json`, the read-only command it already runs, rather than directing an agent to re-run `init`. + +#### Scenario: info --json carries a workspace package's stale pin + +- **WHEN** `taskless info --json` runs in a project whose root `package.json` declares `workspaces: ["packages/*"]` and `packages/app/package.json` pins `@taskless/cli` at a version below the running one +- **THEN** `pinnedCli` SHALL contain `{ manifest: "packages/app/package.json", location: "devDependencies", name: "@taskless/cli", spec, installed: null }` + +#### Scenario: info --json reports no pins as an empty list + +- **WHEN** `taskless info --json` runs where there is no `package.json`, or nothing in one is stale +- **THEN** `pinnedCli` SHALL be an empty array + +#### Scenario: Plain info lists the pins + +- **WHEN** `taskless info` runs without `--json` and a pin is stale +- **THEN** stdout SHALL name the pin's manifest, location, and spec, and the version to move it to diff --git a/packages/cli/src/agent/info.md b/packages/cli/src/agent/info.md index dc8cc89e..243244b4 100644 --- a/packages/cli/src/agent/info.md +++ b/packages/cli/src/agent/info.md @@ -1,4 +1,4 @@ -# Topic: info (CLI v%(CLI_VERSION)s / topic v1) +# Topic: info (CLI v%(CLI_VERSION)s / topic v2) ## Goal Report local Taskless state: CLI version, installed skill versions @@ -43,7 +43,12 @@ which version of the CLI/skills the agent is talking to. { "name": "jq", "present": false, "applicable": true } ], "loggedIn": true, - "auth": { "user": "...", "email": "...", "orgs": ["..."] } + "auth": { "user": "...", "email": "...", "orgs": ["..."] }, + "pinnedCli": [ + { "manifest": "packages/app/package.json", + "location": "devDependencies", "name": "@taskless/cli", + "spec": "^0.6.0", "installed": "0.6.3" } + ] } ``` @@ -64,6 +69,15 @@ which version of the CLI/skills the agent is talking to. suggest `%(TASKLESS_CLI)s` to reinstall and pull the latest bundle. +5. **Name stale CLI pins.** `pinnedCli` lists every pin of + `@taskless/cli` or `@taskless/cli-nightly` that runs a CLI older + than `version`, in the root `package.json` or a workspace package's + (`manifest`), with the field it is in (`location`). `installed` is + the version that pin actually runs, or `null` when nothing is + installed or the pin is a script. Scripts, CI, and git hooks run + these pins, so report them and offer the bump to `version`. Do not + edit a `package.json` on your own; a pin can be deliberate. + ## Errors When `--json` is set, failures emit `{ ok: false, code, message }`: diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index 7c43706d..3eec127e 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 v3) +# Topic: init (CLI v%(CLI_VERSION)s / topic v4) ## Goal Install or update the Taskless skill in this project, and migrate the @@ -43,7 +43,8 @@ first step, not the whole job. "migrated": { "from": 3, "to": 4, "applied": [4], "files": { "added": [], "modified": [], "removed": [] } }, "pinnedCli": [ - { "location": "devDependencies", "name": "@taskless/cli", + { "manifest": "packages/app/package.json", + "location": "devDependencies", "name": "@taskless/cli", "spec": "^0.10.0", "installed": "0.10.2" } ] } @@ -60,12 +61,13 @@ first step, not the whole job. 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. + - `pinnedCli` is always present. Each entry is a pin that runs a + Taskless CLI older than `installed`: a dependency whose installed + build (`installed`, `null` when nothing is installed) or range is + behind, or a script spelling out an older version. `manifest` is the + `package.json` it lives in, the root's or a workspace package's, and + `location` the field in it. 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 @@ -87,8 +89,8 @@ first step, not the whole job. 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. + the migrated files. Do not edit a `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 diff --git a/packages/cli/src/agent/update.md b/packages/cli/src/agent/update.md index 8b22deae..2aafc9b0 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 v13) +# Topic: update (CLI v%(CLI_VERSION)s / topic v14) ## You are here This is `update`. It tells you what an upgrade changed for the rules @@ -54,21 +54,24 @@ 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. **Offer to bump a pinned CLI.** If `package.json` pins - `@taskless/cli` or `@taskless/cli-nightly` (a dependency entry, or a - script spelling out `@taskless/cli@`) behind the installed - CLI, `init` names each pin and the version to move it to, and - `init --json` carries them as `pinnedCli`. Re-running `init --json` - is safe if you did not see that output yourself: on a current - project it changes nothing and still reports the pins. Those pins are - what scripts, CI, and git hooks run. +4. **Offer to bump a pinned CLI.** Read `pinnedCli` from the same + `info --json` payload as step 1. Each entry is a pin of + `@taskless/cli` or `@taskless/cli-nightly` behind the installed CLI: + a dependency entry, or a script spelling out + `@taskless/cli@`, in the root `package.json` or a workspace + package's (`manifest` names which, `location` the field). An empty + list means there is nothing to bump. Those pins are what scripts, CI, + and git hooks run. Move each to the installed version on the package + that publishes it: a nightly version exists only on + `@taskless/cli-nightly`, a release only on `@taskless/cli`. 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 + `0`; if you did not see that output, assume it did), 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. diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index 246ac38b..ab7817dd 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -4,6 +4,7 @@ import { defineCommand } from "citty"; import { detectHostTools } from "../detect/host-tools"; import { checkStaleness } from "../install/install"; +import { describePin, findStalePins } from "../install/pinned-cli"; import { getToken } from "../auth/token"; import { fetchWhoami } from "../auth/whoami"; import { outputSchema as infoOutputSchema } from "../schemas/info"; @@ -41,22 +42,27 @@ export const infoCommand = defineCommand({ // The repository context resolves regardless of `--anonymous`: it comes // from the local git remote, not from the API, so suppressing it would // hide capability state that has nothing to do with the auth probe. - const [harnesses, tools, token, repository, manifest] = await Promise.all([ - checkStaleness(cwd), - // Presence on `PATH`, nothing executed. Resolves its own repository - // context, which is the same never-throwing call as `repository` below - // and cheap enough not to be worth threading through. - detectHostTools(cwd), - args.anonymous ? Promise.resolve() : getToken(cwd), - resolveRepositoryContext(cwd), - // Never fails: an absent or unreadable manifest is an ordinary state for - // a project that has not been initialised, and `info` still has plenty - // to report about one. - readManifest(join(cwd, TASKLESS_DIRECTORY)).then( - (read) => read.manifest, - () => null - ), - ]); + const [harnesses, tools, token, repository, manifest, pinnedCli] = + await Promise.all([ + checkStaleness(cwd), + // Presence on `PATH`, nothing executed. Resolves its own repository + // context, which is the same never-throwing call as `repository` below + // and cheap enough not to be worth threading through. + detectHostTools(cwd), + args.anonymous ? Promise.resolve() : getToken(cwd), + resolveRepositoryContext(cwd), + // Never fails: an absent or unreadable manifest is an ordinary state for + // a project that has not been initialised, and `info` still has plenty + // to report about one. + readManifest(join(cwd, TASKLESS_DIRECTORY)).then( + (read) => read.manifest, + () => null + ), + // Read here as well as on `init`: the `update` recipe reaches this + // after a version move, possibly in a later session that never saw + // `init`'s output, and `info` is the read-only command it runs. + findStalePins(cwd, __VERSION__), + ]); let auth: { user: string; email?: string; orgs: string[] } | undefined; if (!args.anonymous && token) { @@ -107,6 +113,7 @@ export const infoCommand = defineCommand({ // the ledger reports a walk rather than "nothing to do". walk: reconciliationStart(manifest?.rules?.reconciledTo) ?? null, }, + pinnedCli, }; if (args.json) { @@ -172,6 +179,12 @@ export const infoCommand = defineCommand({ console.log(` ${tool.name}: ${where}`); } + if (pinnedCli.length > 0) { + console.log(""); + console.log(`Pinned CLI older than v${__VERSION__}:`); + for (const pin of pinnedCli) console.log(describePin(pin, __VERSION__)); + } + console.log(""); if (auth) { const orgs = auth.orgs.length > 0 ? ` (${auth.orgs.join(", ")})` : ""; diff --git a/packages/cli/src/install/pinned-cli.ts b/packages/cli/src/install/pinned-cli.ts index 9d6f7530..91815bef 100644 --- a/packages/cli/src/install/pinned-cli.ts +++ b/packages/cli/src/install/pinned-cli.ts @@ -1,5 +1,7 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { glob, readFile } from "node:fs/promises"; +import { basename, dirname, join, posix, sep } from "node:path"; + +import { parse as parseYaml } from "yaml"; import { isRecord } from "../util/is-record"; import { compareSemver, compareVersions } from "../util/version-compare"; @@ -25,6 +27,11 @@ import { compareSemver, compareVersions } from "../util/version-compare"; * checkout with nothing installed, and is stale only when it cannot reach the * running version at all. * + * In a monorepo the pin usually lives in a workspace package rather than at + * the root, and that package's CI job is the one that breaks, so the + * workspace packages the root declares are read too. See + * `listWorkspaceManifests()` for what counts as declared. + * * Otherwise detection is deliberately narrow. A spec this module cannot bound * (`latest`, `*`, `>=`, a `workspace:` or URL spec) is not reported, because * "this might be old" is a guess, and a notice that guesses is one an agent @@ -68,7 +75,13 @@ const BOUNDED_SPEC = /** One pin that would run a CLI older than the one that just ran. */ export interface PinnedCli { - /** Where the pin lives: `devDependencies`, or `scripts.`. */ + /** + * The `package.json` holding the pin, relative to the directory searched + * and `/`-separated: `package.json` at the root, `packages/app/package.json` + * in a workspace package. + */ + manifest: string; + /** Where the pin lives in it: `devDependencies`, or `scripts.`. */ location: string; /** The package the pin names. */ name: string; @@ -120,46 +133,144 @@ function isStale(spec: string, cliVersion: string): boolean { } /** - * 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. + * How many workspace manifests are read, at most. + * + * A pattern like `**` in a large tree can match far more than a workspace + * means to declare. This is advice on a successful install, so it stops + * reading rather than make `init` or `info` slow; a tree past the cap is one + * this check has nothing useful to say about anyway. */ -async function readInstalledVersion( +const MAX_WORKSPACE_MANIFESTS = 500; + +/** Directories a workspace glob never descends into. */ +const SKIPPED_DIRECTORIES = new Set(["node_modules", ".git"]); + +/** The parsed JSON at `path`, or `undefined` when it is absent or malformed. */ +async function readJson(path: string): Promise { + try { + return JSON.parse(await readFile(path, "utf8")) as unknown; + } catch { + return undefined; + } +} + +/** Only the strings of a list that should have held nothing else. */ +function strings(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +/** + * The workspace patterns `cwd` declares: the `workspaces` field of its + * `package.json` (npm and yarn, as an array or as `{ packages }`), and the + * `packages` list of `pnpm-workspace.yaml`. Both are read, since a project + * migrating between managers can carry either, and a pattern in one that the + * other lacks still names a package something runs. + */ +async function readWorkspacePatterns( cwd: string, - name: string -): Promise { + root: unknown +): Promise { + const patterns: string[] = []; + if (isRecord(root)) { + const { workspaces } = root; + patterns.push( + ...strings(isRecord(workspaces) ? workspaces.packages : workspaces) + ); + } try { - const parsed: unknown = JSON.parse( - await readFile(join(cwd, "node_modules", name, "package.json"), "utf8") + const parsed: unknown = parseYaml( + await readFile(join(cwd, "pnpm-workspace.yaml"), "utf8") ); - return isRecord(parsed) && typeof parsed.version === "string" - ? parsed.version - : undefined; + if (isRecord(parsed)) patterns.push(...strings(parsed.packages)); } catch { - return undefined; + // Absent or malformed; pnpm reports the latter on its own terms. } + return patterns; } /** - * Every stale pin in `/package.json`, in file order: dependency fields - * first, then scripts. An absent or unreadable `package.json` has no pins. + * Every workspace package's `package.json` under `cwd`, as sorted + * `/`-separated paths relative to it, not including the root's own. * - * 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. + * Each pattern is expanded by `fs.glob` against `/package.json`, so + * a pattern matches a package exactly when it matches the package's + * directory, as it does for pnpm, npm and yarn. A `!`-prefixed pattern + * removes what it matches. A pattern that is absolute or climbs out with + * `..` is skipped: what it names is not inside this project, and reading it + * would walk a tree nobody asked about. `node_modules` is never descended + * into, which is also what keeps `**` affordable. */ -export async function findStalePins( +async function listWorkspaceManifests( + cwd: string, + root: unknown +): Promise { + const included = new Set(); + const excluded = new Set(); + let read = 0; + for (const raw of await readWorkspacePatterns(cwd, root)) { + const negated = raw.startsWith("!"); + const pattern = posix + .normalize((negated ? raw.slice(1) : raw).trim()) + .replace(/\/+$/, ""); + if ( + pattern === "" || + posix.isAbsolute(pattern) || + pattern.split("/").includes("..") + ) { + continue; + } + for await (const entry of glob(`${pattern}/package.json`, { + cwd, + exclude: (path) => SKIPPED_DIRECTORIES.has(basename(path)), + })) { + (negated ? excluded : included).add(entry.split(sep).join("/")); + read += 1; + if (read >= MAX_WORKSPACE_MANIFESTS) break; + } + if (read >= MAX_WORKSPACE_MANIFESTS) break; + } + // A `.` pattern names the root, which is read on its own. + included.delete("package.json"); + return [...included].filter((path) => !excluded.has(path)).toSorted(); +} + +/** + * The version of `name` that `directory` runs, or `undefined`. + * + * Resolved as Node resolves it: `/node_modules/`, then each + * parent's, stopping at `cwd`. With pnpm a workspace package has its own link, + * and that is what it runs even when the root links another version; with npm + * or yarn hoisting it may exist only at the root. pnpm links the directory + * into its store, and reading through the link is what resolves the version + * actually run. + */ +async function readInstalledVersion( + cwd: string, + directory: string, + name: string +): Promise { + for (let current = directory; ; current = dirname(current)) { + const parsed = await readJson( + join(current, "node_modules", name, "package.json") + ); + if (isRecord(parsed) && typeof parsed.version === "string") { + return parsed.version; + } + if (current === cwd || dirname(current) === current) return undefined; + } +} + +/** Every stale pin in one parsed manifest: dependency fields, then scripts. */ +async function findPinsIn( cwd: string, + manifest: string, + parsed: unknown, cliVersion: string ): Promise { - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")); - } catch { - return []; - } if (!isRecord(parsed)) return []; + const directory = join(cwd, dirname(manifest)); const pins: PinnedCli[] = []; for (const field of DEPENDENCY_FIELDS) { @@ -168,7 +279,7 @@ export async function findStalePins( for (const name of PACKAGE_NAMES) { const spec = dependencies[name]; if (typeof spec !== "string") continue; - const installed = await readInstalledVersion(cwd, name); + const installed = await readInstalledVersion(cwd, directory, name); // Either one is a real failure: an installed build older than this one // is what runs today, and a range that cannot reach this version is // what a fresh install will resolve. @@ -177,6 +288,7 @@ export async function findStalePins( isStale(spec, cliVersion); if (stale) { pins.push({ + manifest, location: field, name, spec, @@ -200,6 +312,7 @@ export async function findStalePins( seen.add(`${name}@${spec}`); if (isStale(spec, cliVersion)) { pins.push({ + manifest, location: `scripts.${script}`, name, spec, @@ -213,6 +326,35 @@ export async function findStalePins( return pins; } +/** + * Every stale pin in `/package.json` and in each workspace package it + * declares, root first and then workspace packages by path, each in file + * order. An absent or unreadable `package.json` has no pins. + * + * Unreadable is not an error here. This is advice attached to a successful + * command, and a malformed manifest is something the package manager will + * report on its own terms; failing an install over it would be the wrong + * tool refusing. + */ +export async function findStalePins( + cwd: string, + cliVersion: string +): Promise { + const root = await readJson(join(cwd, "package.json")); + const pins = await findPinsIn(cwd, "package.json", root, cliVersion); + for (const manifest of await listWorkspaceManifests(cwd, root)) { + pins.push( + ...(await findPinsIn( + cwd, + manifest, + await readJson(join(cwd, manifest)), + cliVersion + )) + ); + } + 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 @@ -228,8 +370,12 @@ function bumpTarget(cliVersion: string): { name: string; version: string } { }; } -/** One notice line: where the pin is, what it says, and what to change it to. */ -function describePin(pin: PinnedCli, cliVersion: string): string { +/** + * One notice line: which manifest and field the pin is in, what it says, and + * what to change it to. The manifest is named even at the root, so a list + * mixing the root with workspace packages reads the same way throughout. + */ +export function describePin(pin: PinnedCli, cliVersion: string): string { const target = bumpTarget(cliVersion); const installed = pin.installed === null ? "" : ` (installed ${pin.installed})`; @@ -237,7 +383,7 @@ function describePin(pin: PinnedCli, cliVersion: string): string { pin.name === target.name ? `${target.name}@${target.version}` : `${target.name}@${target.version}, replacing ${pin.name}`; - return ` - ${pin.location}: ${pin.name} ${pin.spec}${installed} -> ${move}`; + return ` - ${pin.manifest} ${pin.location}: ${pin.name} ${pin.spec}${installed} -> ${move}`; } /** diff --git a/packages/cli/src/schemas/info.ts b/packages/cli/src/schemas/info.ts index 3dc823f0..8b7e53b3 100644 --- a/packages/cli/src/schemas/info.ts +++ b/packages/cli/src/schemas/info.ts @@ -34,6 +34,30 @@ const hostToolSchema = z.object({ reason: z.string().optional(), }); +/** + * A `package.json` pin that would run a CLI older than this one. The same + * shape `init --json` carries as `pinnedCli`, so an agent reading either one + * reads the same thing. + */ +const pinnedCliSchema = z.object({ + manifest: z + .string() + .describe( + "The package.json holding the pin, relative to the working directory" + ), + location: z + .string() + .describe("The field in it: `devDependencies`, or `scripts.`"), + name: z.string().describe("The package the pin names"), + spec: z.string().describe("The pin as written"), + installed: z + .string() + .nullable() + .describe( + "The installed version the pin runs, or null when nothing is installed or the pin is a script" + ), +}); + const authSchema = z.object({ user: z.string(), email: z.string(), @@ -97,6 +121,11 @@ export const outputSchema = z.object({ .describe( "What the rules are valid against. Distinct from `install`: these advance only on a completed reconciliation, never on an upgrade" ), + pinnedCli: z + .array(pinnedCliSchema) + .describe( + "Pins of the Taskless CLI, release or nightly, in package.json and its workspace packages, that would run a CLI older than this one. Empty when nothing is stale" + ), }); export const errorSchema = z.object({ diff --git a/packages/cli/test/info.test.ts b/packages/cli/test/info.test.ts index 76e15fc3..9e78db2b 100644 --- a/packages/cli/test/info.test.ts +++ b/packages/cli/test/info.test.ts @@ -161,4 +161,37 @@ describe("taskless info --json", () => { expect(result.install.onboarded).toBe(true); }); + + it("reports pinnedCli, as an empty list when nothing is stale", async () => { + const bare = await info(cwd); + expect(bare.pinnedCli).toEqual([]); + + // The `update` recipe reads the pins here, after a version move that may + // have happened in another session, so a workspace package's pin has to + // be on the read-only command and not only on `init`. + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ workspaces: ["packages/*"] }) + ); + await mkdir(join(cwd, "packages", "app"), { recursive: true }); + await writeFile( + join(cwd, "packages", "app", "package.json"), + JSON.stringify({ devDependencies: { "@taskless/cli": "0.0.1" } }) + ); + const pinned = await info(cwd); + expect(pinned.pinnedCli).toEqual([ + { + manifest: "packages/app/package.json", + location: "devDependencies", + name: "@taskless/cli", + spec: "0.0.1", + installed: null, + }, + ]); + + const { stdout } = await runCli(["info", "--anonymous", "-d", cwd], cwd); + expect(stdout).toContain( + "packages/app/package.json devDependencies: @taskless/cli 0.0.1 ->" + ); + }); }); diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index 644adc2a..1c79eaad 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -412,6 +412,7 @@ describe("taskless init (the batch install)", () => { (JSON.parse(pinned.stdout) as { pinnedCli: unknown }).pinnedCli ).toEqual([ { + manifest: "package.json", location: "dependencies", name: "@taskless/cli", spec: "0.0.1", diff --git a/packages/cli/test/pinned-cli.test.ts b/packages/cli/test/pinned-cli.test.ts index 3388cba5..50886c5f 100644 --- a/packages/cli/test/pinned-cli.test.ts +++ b/packages/cli/test/pinned-cli.test.ts @@ -24,8 +24,23 @@ 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("/")); + async function writeAt(path: string, contents: unknown): Promise { + await mkdir(join(cwd, path), { recursive: true }); + await writeFile(join(cwd, path, "package.json"), JSON.stringify(contents)); + } + + /** The manifest of every stale pin, in report order. */ + async function manifestsOf(cliVersion: string): Promise { + const pins = await findStalePins(cwd, cliVersion); + return pins.map((pin) => pin.manifest); + } + + async function install( + name: string, + version: string, + path = "." + ): Promise { + const directory = join(cwd, path, "node_modules", ...name.split("/")); await mkdir(directory, { recursive: true }); await writeFile( join(directory, "package.json"), @@ -72,6 +87,7 @@ describe("findStalePins", () => { stale ? [ { + manifest: "package.json", location: "devDependencies", name: "@taskless/cli", spec, @@ -125,6 +141,7 @@ describe("findStalePins", () => { await install("@taskless/cli", "0.11.0"); expect(await findStalePins(cwd, "0.11.2")).toEqual([ { + manifest: "package.json", location: "devDependencies", name: "@taskless/cli", spec: "^0.11.0", @@ -146,6 +163,7 @@ describe("findStalePins", () => { await install("@taskless/cli", "0.11.2"); expect(await findStalePins(cwd, "0.11.2")).toEqual([ { + manifest: "package.json", location: "devDependencies", name: "@taskless/cli", spec: "^0.10.0", @@ -173,12 +191,14 @@ describe("findStalePins", () => { }); expect(await findStalePins(cwd, "0.11.2")).toEqual([ { + manifest: "package.json", location: "dependencies", name: "@taskless/cli", spec: "0.9.0", installed: null, }, { + manifest: "package.json", location: "optionalDependencies", name: "@taskless/cli-nightly", spec: "0.10.0-2026x0", @@ -199,12 +219,14 @@ describe("findStalePins", () => { }); expect(await findStalePins(cwd, "0.11.2")).toEqual([ { + manifest: "package.json", location: "scripts.lint", name: "@taskless/cli", spec: "0.10.2", installed: null, }, { + manifest: "package.json", location: "scripts.nightly", name: "@taskless/cli-nightly", spec: "0.10.0-2026x0", @@ -224,6 +246,7 @@ describe("findStalePins", () => { await writePackage({ scripts: { lint: command } }); expect(await findStalePins(cwd, "0.11.2")).toEqual([ { + manifest: "package.json", location: "scripts.lint", name: "@taskless/cli", spec, @@ -246,10 +269,115 @@ describe("findStalePins", () => { }); expect(await findStalePins(cwd, "0.11.2")).toHaveLength(1); }); + + describe("workspace packages", () => { + const stalePin = { devDependencies: { "@taskless/cli": "^0.10.2" } }; + + it("reads the packages pnpm-workspace.yaml declares, naming the manifest", async () => { + await writePackage({ name: "root" }); + await writeFile( + join(cwd, "pnpm-workspace.yaml"), + "packages:\n - 'packages/*'\n" + ); + await writeAt("packages/app", stalePin); + await writeAt("packages/lib", { name: "lib" }); + expect(await findStalePins(cwd, "0.11.2")).toEqual([ + { + manifest: "packages/app/package.json", + location: "devDependencies", + name: "@taskless/cli", + spec: "^0.10.2", + installed: null, + }, + ]); + }); + + it.each([ + ["an array", ["apps/*"]], + ["{ packages }", { packages: ["apps/*"] }], + ])("reads the workspaces field as %s", async (_, workspaces) => { + await writePackage({ workspaces }); + await writeAt("apps/web", stalePin); + expect(await manifestsOf("0.11.2")).toEqual(["apps/web/package.json"]); + }); + + it("lists the root first, then workspace packages by path", async () => { + await writePackage({ ...stalePin, workspaces: [".", "packages/*"] }); + await writeAt("packages/b", stalePin); + await writeAt("packages/a", stalePin); + expect(await manifestsOf("0.11.2")).toEqual([ + "package.json", + "packages/a/package.json", + "packages/b/package.json", + ]); + }); + + it("drops what a negated pattern matches", async () => { + await writePackage({ + workspaces: ["packages/*", "!packages/fixture"], + }); + await writeAt("packages/app", stalePin); + await writeAt("packages/fixture", stalePin); + expect(await manifestsOf("0.11.2")).toEqual([ + "packages/app/package.json", + ]); + }); + + it("follows ** without descending into node_modules", async () => { + await writePackage({ workspaces: ["packages/**"] }); + await writeAt("packages/group/deep", stalePin); + // An installed dependency is not a workspace package, whatever it pins. + await writeAt("packages/group/deep/node_modules/dep", stalePin); + expect(await manifestsOf("0.11.2")).toEqual([ + "packages/group/deep/package.json", + ]); + }); + + it("skips a pattern that leaves the project", async () => { + const inner = join(cwd, "inner"); + await writeAt("inner", { workspaces: ["../outside", "/abs/*"] }); + await writeAt("outside", stalePin); + expect(await findStalePins(inner, "0.11.2")).toEqual([]); + }); + + it("reads the version the workspace package runs: its own link first, then the root's", async () => { + await writePackage({ workspaces: ["packages/*"] }); + const caret = { devDependencies: { "@taskless/cli": "^0.11.0" } }; + await writeAt("packages/linked", caret); + await writeAt("packages/hoisted", caret); + // pnpm: the package's own link is what it runs, whatever the root has. + await install("@taskless/cli", "0.11.0", "packages/linked"); + // npm/yarn hoisting: only the root has it. + await install("@taskless/cli", "0.11.1"); + const pins = await findStalePins(cwd, "0.11.2"); + expect(pins.map((pin) => [pin.manifest, pin.installed])).toEqual([ + ["packages/hoisted/package.json", "0.11.1"], + ["packages/linked/package.json", "0.11.0"], + ]); + }); + + it("is quiet when a workspace package's own link is current", async () => { + await writePackage({ workspaces: ["packages/*"] }); + await writeAt("packages/app", { + devDependencies: { "@taskless/cli": "^0.11.0" }, + }); + await install("@taskless/cli", "0.11.2", "packages/app"); + await install("@taskless/cli", "0.11.0"); + expect(await findStalePins(cwd, "0.11.2")).toEqual([]); + }); + + it("still reads pnpm-workspace.yaml when the root package.json is malformed", async () => { + await writeFile(join(cwd, "package.json"), "{ not json"); + await writeFile(join(cwd, "pnpm-workspace.yaml"), "packages: [app]\n"); + await writeAt("app", stalePin); + expect(await findStalePins(cwd, "0.11.2")).toHaveLength(1); + }); + }); }); describe("getPinnedCliNotice", () => { const releasePin: PinnedCli = { + manifest: "package.json", location: "devDependencies", name: "@taskless/cli", spec: "0.10.2", @@ -264,15 +392,19 @@ describe("getPinnedCliNotice", () => { const notice = getPinnedCliNotice( [ { ...releasePin, spec: "^0.11.0", installed: "0.11.0" }, - { ...releasePin, location: "scripts.lint" }, + { + ...releasePin, + manifest: "packages/app/package.json", + location: "scripts.lint", + }, ], "0.11.2" ); expect(notice).toContain( - "devDependencies: @taskless/cli ^0.11.0 (installed 0.11.0) -> @taskless/cli@0.11.2" + " - package.json devDependencies: @taskless/cli ^0.11.0 (installed 0.11.0) -> @taskless/cli@0.11.2" ); expect(notice).toContain( - "scripts.lint: @taskless/cli 0.10.2 -> @taskless/cli@0.11.2" + " - packages/app/package.json scripts.lint: @taskless/cli 0.10.2 -> @taskless/cli@0.11.2" ); expect(notice).toContain("Offer to update them as shown"); }); From 91edd99791051dc154454feb6ba38cc7fdb326a3 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 5 Oct 2026 19:22:52 -0700 Subject: [PATCH 2/2] fix(cli): cap only included workspace manifests, and don't let update guess a migration --- .changeset/init-stale-cli-pins.md | 2 +- .../proposal.md | 2 +- .../tasks.md | 2 +- packages/cli/src/agent/update.md | 19 +++++++++++-------- packages/cli/src/install/pinned-cli.ts | 9 +++++---- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.changeset/init-stale-cli-pins.md b/.changeset/init-stale-cli-pins.md index e38fd70c..2ae29fab 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` 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`. The workspace packages a project declares (`pnpm-workspace.yaml`, or the `workspaces` field) are read as well as the root, and a dependency is judged by the version that package actually runs: its own `node_modules` link, else the hoisted one. `init --json` and `info --json` both carry the pins as `pinnedCli`, each naming its `manifest`, and plain `info` lists them. The `init` (topic v4), `info` (topic v2) and `update` (topic v14) recipes tell an agent to offer the bump, and `update` now reads the pins from `info --json` rather than re-running `init`. +`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`. The workspace packages a project declares (`pnpm-workspace.yaml`, or the `workspaces` field) are read as well as the root, and a dependency is judged by the version that package actually runs: its own `node_modules` link, else the hoisted one. `init --json` and `info --json` both carry the pins as `pinnedCli`, each naming its `manifest`, and plain `info` lists them. The `init` (topic v4), `info` (topic v2) and `update` (topic v15) recipes tell an agent to offer the bump, and `update` now reads the pins from `info --json` rather than re-running `init`. diff --git a/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/proposal.md b/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/proposal.md index af5e2032..5be2d68a 100644 --- a/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/proposal.md +++ b/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/proposal.md @@ -32,7 +32,7 @@ command, and `update` already runs `info --json` in step 1. every line, the root included. - `info --json` carries `pinnedCli` in the same shape; plain `info` lists the pins. -- Recipes: `update` topic v14 reads the pins from `info --json` in step 4. +- Recipes: `update` topic v15 reads the pins from `info --json` in step 4. `info` topic v2 documents the field and a step to offer the bump. `init` topic v4 documents `manifest`. - The existing `init-stale-cli-pins` changeset is extended. #443 has not been diff --git a/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/tasks.md b/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/tasks.md index ce0f9c87..068d3352 100644 --- a/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/tasks.md +++ b/openspec/changes/archive/2026-10-05-stale-cli-pins-workspaces/tasks.md @@ -19,7 +19,7 @@ ## 4. Recipes and changeset -- [x] 4.1 `update` v14 reads pins from `info --json`; `info` v2; `init` v4. +- [x] 4.1 `update` v15 reads pins from `info --json`; `info` v2; `init` v4. - [x] 4.2 Extend the `init-stale-cli-pins` changeset. ## 5. Tests diff --git a/packages/cli/src/agent/update.md b/packages/cli/src/agent/update.md index 2aafc9b0..6baf947c 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 v14) +# Topic: update (CLI v%(CLI_VERSION)s / topic v15) ## You are here This is `update`. It tells you what an upgrade changed for the rules @@ -67,13 +67,16 @@ that you finished. If the upgrade also migrated an existing `.taskless/` (`init` printed a migration, or `init --json` carried `migrated` with `from` above - `0`; if you did not see that output, assume it did), 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. + `0`), this is not optional advice: a CLI that predates the new + schema refuses the project with `SCAFFOLD_VERSION_MISMATCH`, so CI + breaks on the push that carries the migrated files. The bump belongs + in that same commit. Without a migration the pin still reads the + layout, but checks rules against engines this walk has moved past. + + `info --json` does not say whether a migration ran. If you did not + see `init`'s output yourself, treat the bump as required, but say + that you cannot tell whether `.taskless/` was migrated; do not name + schema versions you have not read. Offer the user the bump to the installed version, along with reinstalling dependencies. Do not make it silently: a pin can be diff --git a/packages/cli/src/install/pinned-cli.ts b/packages/cli/src/install/pinned-cli.ts index 91815bef..57da5ffd 100644 --- a/packages/cli/src/install/pinned-cli.ts +++ b/packages/cli/src/install/pinned-cli.ts @@ -208,7 +208,9 @@ async function listWorkspaceManifests( ): Promise { const included = new Set(); const excluded = new Set(); - let read = 0; + // Only inclusions count toward the cap: they are what gets read later. A + // broad `!**/fixtures` costing the same budget would leave real packages + // listed after it unread, and the silence would look like "nothing stale". for (const raw of await readWorkspacePatterns(cwd, root)) { const negated = raw.startsWith("!"); const pattern = posix @@ -226,10 +228,9 @@ async function listWorkspaceManifests( exclude: (path) => SKIPPED_DIRECTORIES.has(basename(path)), })) { (negated ? excluded : included).add(entry.split(sep).join("/")); - read += 1; - if (read >= MAX_WORKSPACE_MANIFESTS) break; + if (included.size >= MAX_WORKSPACE_MANIFESTS) break; } - if (read >= MAX_WORKSPACE_MANIFESTS) break; + if (included.size >= MAX_WORKSPACE_MANIFESTS) break; } // A `.` pattern names the root, which is read on its own. included.delete("package.json");