diff --git a/.changeset/pinned-cli-permissions.md b/.changeset/pinned-cli-permissions.md new file mode 100644 index 00000000..e47c9aa0 --- /dev/null +++ b/.changeset/pinned-cli-permissions.md @@ -0,0 +1,7 @@ +--- +"@taskless/cli": patch +--- + +Released builds now pin their own version. The installed skill and the `agent` recipes name `npx @taskless/cli@` instead of the unpinned `npx @taskless/cli` or `@latest`, as nightlies already did, so an agent runs exactly the version the project installed. Commands the CLI prints for you, such as an authentication remedy, are pinned the same way. Upgrading is unchanged: re-run `init`. Two places stay unpinned: a reference stub's reinstall line, so stubs do not change on every release, and `.taskless/README.md`, which is written once and read across upgrades. + +`init` now also reports stale Taskless pins in CI and git-hook files (GitHub Actions, GitLab CI, CircleCI, husky, lefthook, pre-commit, and the rest of the files `detect` recognizes) by file and line, through the same notice and `pinnedCli` JSON field as `package.json` pins. It recommends the version to move to and never edits the file. diff --git a/openspec/changes/pinned-cli-permissions/design.md b/openspec/changes/pinned-cli-permissions/design.md new file mode 100644 index 00000000..db7b3bf7 --- /dev/null +++ b/openspec/changes/pinned-cli-permissions/design.md @@ -0,0 +1,72 @@ +## Context + +Every Taskless action runs `npx @ …`, a command that downloads and executes a package. Claude Code's auto mode, and the equivalent gates in other harnesses, block that by default. #506 and #507 taught the skill to stop, name the command, and offer remedies. A real session showed the remedies work but cost a round trip, and that the agent cannot write the permission itself: auto mode refused its edit to `.claude/settings.local.json` as self-modification. The agent handed the user a one-line command instead, and the narrow rule it wrote (`Bash(npx @taskless/cli-nightly@:*)`) let the preflight through under auto mode. + +The user's position: installing Taskless at a version is an intentional choice, and that choice is what a permission rule records. The installer, which the user ran, writes it. + +## Goals / Non-Goals + +**Goals:** + +- After `init`, an agent in any supported harness can run exactly the installed Taskless CLI without a prompt or a block. +- The rule allows nothing beyond that invocation: no other version, no other package. +- An upgrade leaves exactly one rule per harness, for the new version. +- The installer never removes or overrides anything it did not write. + +**Non-Goals:** + +- Allowing a different runner (`pnpm dlx`, `bunx`, `yarn dlx`). The skill names `npx`, and the rule matches the skill. +- Getting past an organization- or team-level policy. Those outrank project files by design in every harness that has them. +- Harnesses with no file-based allowlist. Cline decides per command by model judgement, with no allowlist ("Cline does not use a fixed allowlist", docs.cline.bot/features/auto-approve), and the generic `.agents/` target names no harness. Crush can allow only whole tools: its `crushrc` takes `permissions allow `, and its schema's `allowed_tools` holds bare names (`"bash"`, `"view"`) with no command scoping. Allowing `bash` approves every shell command, which this change never does. All three rely on the skill's remedies; approving in chat fits Cline's and Crush's per-command prompts. +- Trusting the workspace on the user's behalf. Claude Code and Codex ignore project allow rules in an untrusted workspace; that gate stays the user's. + +## Decisions + +**Releases pin themselves.** The `prod` define becomes `npx @taskless/cli@`. Without it, a release has no version to scope a rule to, and a rule on the bare package name matches any version npm resolves, which is exactly the widening the skill forbids. Nightlies already pin, and nothing about them changes. + +The cost is in the code that asks "is this the released build". `isProductionInvocation()` compares the define to the bare `npx @taskless/cli` today; it moves to a build-target define (`__TASKLESS_BUILD_TARGET__` or equivalent) rather than a string comparison. `pinnedSpecifier()` stops appending `@latest`, because the specifier now carries its version. Where the CLI prints a command for a human, the specifier is still the build's own, now pinned. + +Alternative considered: keep releases unpinned and write `Bash(npx @taskless/cli *)`. The rule itself is narrow, since the space before `*` keeps it from matching `npx @taskless/cli@0.1.0`. But the command it allows is not: a bare `npx @taskless/cli` runs whatever version npx resolves, a cached copy or the latest release, so the rule approves a version nobody chose. Rejected. + +**Two places stay unpinned on purpose.** A reference stub's recovery line keeps `npx @taskless/cli init`, because the standing `cli-init` requirement forbids stub content that varies per release and names the version-free released command as the form every build converges on; the canonical skill the stub points to carries the pin. `.taskless/README.md` names `@taskless/cli@latest` for a release, because a migration writes it once and upgrades do not rewrite it, so a pinned version there would be stale by the next release. A nightly's README keeps its pin, as it does today. + +**An upgrade names stale pins in CI and hook files.** Pinning is what creates them: the `ci` and `hooks` recipes render the installed version into what they write. `init` already reports stale `package.json` pins; the same scan now reads the CI and hook files `detect` recognizes (`detect/automation.ts` owns the list), as text with the script-pin grammar, and reports them through the same notice and `pinnedCli` entry, by file and line. It never edits them, for the same reason it never edits `package.json`: a pin can be deliberate. + +**`init` writes the rule, in the wizard and in `--no-interactive`.** Both are the user installing Taskless. The summary names each file and the exact rule written, on both paths. + +**One writer per harness format, each using the format's word-bounded form.** From each harness's documentation: + +| Harness | Install dir | File | Rule | Matching | +| ------------- | ------------ | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| Claude Code | `.claude/` | `.claude/settings.json` | `permissions.allow`: `Bash( *)` | Space before `*` is a word boundary; also matches the bare command. | +| Cursor editor | `.cursor/` | `.cursor/permissions.json` | `terminalAllowlist`: `` | Prefix, word-bounded. | +| Cursor CLI | `.cursor/` | `.cursor/cli.json` | `permissions.allow`: `Shell(npx:@)` and `Shell(npx:@ *)` | First token is the command base; args are globbed. | +| OpenCode | `.opencode/` | existing `opencode.json[c]`, root or `.opencode/`; else `.opencode/opencode.json` | object: `permission.bash[" *"] = "allow"`; array: `{action:"shell", resource:" *", effect:"allow"}` | Trailing ` *` becomes `( .*)?`; anchored. | +| Codex | `.agents/` | `.codex/rules/taskless.rules` | `prefix_rule(pattern=["npx","@"], decision="allow")` | Whole argv tokens. | + +**Only harnesses Taskless installs into get a rule, and the list is closed.** The table is the install targets that have a documented, file-based allowlist able to scope to one command with a word boundary. A harness joins by becoming an install target and getting a writer and its tests. The generic `.agents/` target is read by many harnesses (Devin, Gemini CLI, Grok, Crush among them), and none of them gets a rule through it: `.agents/` names no harness, so there is no file to write. Codex is the exception because it is a named row for `.agents/` and has its own rules directory; its rule is written when `.agents/` is selected and Codex is detected. Every other harness, present or future, uses piecemeal approval through the skill's remedies, with the user approving each Taskless command as it runs. Trying to cover every harness is a list that never finishes, and a wrong guess at an undocumented format writes a rule that is either inert or too wide. + +**Rule placement follows each format's evaluation order.** OpenCode's object form applies the last matching rule ("last matching rule winning", opencode.ai/docs/permissions), so the key is appended after every existing key. The array form is evaluated top to bottom (confirmed by hand testing), so the entry goes first. Claude Code, Cursor, and Devin rank deny above allow regardless of order, so placement does not matter there. + +**A deny that covers the invocation stops the write.** If a harness file already denies the invocation (Claude Code `permissions.deny`, Cursor deny rules, a Codex `forbidden` or `prompt` rule, an OpenCode rule that would win over ours), the installer writes nothing to that file and the summary says so. In most harnesses the deny would win anyway; in OpenCode's last-match form ours could silently override it, which is the case this rule exists for. + +**The manifest records what was written; an upgrade replaces only that.** A new `install.permissions` map records, per file, the exact rule strings Taskless added. On the next `init` the installer removes those exact strings and adds the new ones. Anything else in the file, including a rule the user wrote for Taskless themselves, is untouched. A file Taskless created and that holds nothing but its own rule is deleted when the rule is removed; a file that existed before is never deleted. + +**Edits preserve the file.** JSON files are parsed and re-serialized with their existing indentation. OpenCode's `.jsonc` keeps its comments, which needs a JSONC editor rather than `JSON.parse` (`jsonc-parser`'s `modify`/`applyEdits`). A file that does not parse is left alone and reported, never overwritten. + +**The skill's remedies stay.** The preflight still runs, and its blocked-command guidance still applies. It now covers the cases the install cannot: an untrusted workspace, a harness with no permission file, a rule the user deleted, and a harness that drops the rule. + +## Risks / Trade-offs + +- [Auto mode drops "package-manager run commands" from allow rules on entry] → The docs give no `npx` example, so this was hand-tested: a pinned rule in place before launch survives. A rule added mid-session was not part of that test. +- [Workspace trust] → Claude Code and Codex ignore project allow rules until the workspace is trusted, and Claude Code always ignores them under `-p` and the SDK. The skill's remedies cover it. +- [Codex `allow` runs the command outside the sandbox] → Broader than "no prompt", and accepted: the rule is one pinned `npx` invocation the user installed. +- [Cursor: a non-empty `terminalAllowlist` reportedly disables "Auto-review (with Sandbox)"] → Forum report, not documented. Writing it may change a user's run-mode options. +- [Shared files touch the whole team] → `.claude/settings.json` and its peers are committed, so every teammate gets the rule. That matches `.taskless/` itself being committed: the team chose the version together. +- [The rule matches only `npx`] → An agent that swaps in `pnpm dlx` or prefixes an environment variable is not matched, and falls back to the skill's remedies. + +## Resolved + +- **Shared files.** Rules go in each harness's committed project file (`.claude/settings.json`, not `settings.local.json`, and the committed equivalents elsewhere), so the whole team gets the rule along with the committed `.taskless/`. The installer edits those files directly, which is why every edit merges, preserves comments, and touches only what it recorded. +- **Claude Code auto mode keeps the rule.** Hand-tested: a correctly scoped `Bash(npx @taskless/cli-nightly@ *)` in place before Claude Code launches survives auto mode, and `agent ok` runs without a block. +- **Codex matches the pinned version only.** Checked with `codex execpolicy check` (codex-cli 0.156.1) against `prefix_rule(pattern = ["npx", "@taskless/cli@0.12.0"], decision = "allow")`: `npx @taskless/cli@0.12.0` with or without arguments is allowed; `@0.12.01`, `@latest`, the unpinned `@taskless/cli`, and `@taskless/cli-nightly@0.12.0` match nothing. diff --git a/openspec/changes/pinned-cli-permissions/proposal.md b/openspec/changes/pinned-cli-permissions/proposal.md new file mode 100644 index 00000000..318700bb --- /dev/null +++ b/openspec/changes/pinned-cli-permissions/proposal.md @@ -0,0 +1,50 @@ +## Why + +Harnesses now gate commands that download and run a package. Every Taskless action is one of those, so on a fresh install the first thing an agent hits is a block. The skill's remedies (#506, #507) recover from it, but they cost the user a round trip, and the most direct remedy fails: Claude Code's auto mode refuses an agent editing its own permissions, so the agent has to hand the user a command to run. + +The block is answering a question the user already answered. Installing Taskless is the user choosing to run Taskless, and choosing a version. The installer can record that choice in each harness's own project permission file, scoped to exactly that version. The CLI does the writing, not the agent, which is what the user wants and what a self-modification guard is there to distinguish. + +A narrow rule works in practice: in a real auto-mode session, `Bash(npx @taskless/cli-nightly@:*)` in `.claude/settings.local.json` let the preflight through. + +## What Changes + +- **Released builds pin their own version.** The `prod` target bakes `npx @taskless/cli@` into installed skills and rendered recipes, as `nightly` already does with `npx @taskless/cli-nightly@`. A release currently names the unpinned `@taskless/cli` (and `@latest` when handed to a launcher), which no rule can scope to one version. Upgrading stays what it is today: re-running `init`. +- **An upgrade names stale pins in CI and git hooks.** Once releases pin, the `ci` and `hooks` recipes write a pinned invocation, and the next upgrade leaves it behind. `init` already reports stale pins in `package.json`; it now reads the CI and hook files `detect` recognizes as well, and recommends the version to move to, without editing them. +- **`init` writes a version-pinned allow rule** for each harness it installs the skill into, in that harness's project permission file: + - Claude Code (`.claude/`): `.claude/settings.json`, `permissions.allow`: `Bash( *)`. + - Cursor (`.cursor/`): `.cursor/permissions.json` (`terminalAllowlist`) for the editor agent, and `.cursor/cli.json` (`permissions.allow`: `Shell(...)`) for the CLI agent. + - OpenCode (`.opencode/`): the existing `opencode.json` or `opencode.jsonc`, in whichever form it already uses; the object form takes the rule last, the array form first. + - Codex (`.agents/`, when Codex is detected): `.codex/rules/taskless.rules`, `prefix_rule(..., decision="allow")`. `allow` also runs outside Codex's sandbox, which is acceptable for one pinned `npx` invocation. +- **Harnesses Taskless does not install into get no rule.** That includes every harness that reads the generic `.agents/skills` (Devin, Gemini CLI, Grok, Crush, and more), Cline, which has no allowlist, and anything new. They use piecemeal approval through the skill's remedies: the user approves each Taskless command as it runs. The same fallback covers an untrusted workspace and a rule the user removed. +- **Every edit is announced.** `init` names each settings file it creates or edits, with a green `ALLOW: ` line per rule added (and red `REMOVE:` on upgrade). The wizard shows these lines before writing. +- **The rule is never wider than the invocation.** It always uses each harness's word-bounded form, so `@0.13.0` cannot match `@0.13.01`, and never a wildcard over the package name or version. +- **The manifest records every rule Taskless wrote.** An upgrade replaces the old rule with the new one, touching only the exact entry it recorded and merging into everything else in the file. A harness file that carries an explicit deny covering the invocation is left alone and reported, since overriding a user's deny is not the installer's call. + +## Delivery shape + +**Stacked, merging forward.** Two PRs, each safe in production alone: + +1. Release pinning, plus naming stale pins in CI and hook files. Pinning is what creates those stale pins, so the two ship together. Correct on its own: an agent runs the version that was installed, and an upgrade says what it left behind. +2. Permission writing. It depends on (1) for releases to have a version to scope to; nightlies already do. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `infrastructure`: the `prod` build target bakes a version-pinned invocation. +- `cli`: user-facing commands name the pinned release rather than `@latest`. +- `cli-init`: `init` writes, records, replaces, and reports harness permission rules. +- `skill-taskless`: the preflight treats the install's rule as the normal case, and offers only per-command approval where a harness cannot scope a rule. + +## Impact + +- `packages/cli/vite.config.ts`, `scripts/build-target.ts`, `src/util/invocation.ts`, `src/util/package-manager.ts`: the prod define and every check of "is this the released build", which can no longer compare the invocation to the bare `npx @taskless/cli`. +- `src/install/`: a permission writer per harness format, manifest records, upgrade replacement. +- `skills/taskless/SKILL.md`: preflight wording. +- A JSONC-preserving editor for OpenCode, likely a new dependency (`jsonc-parser`). +- Tests for each format, merge, placement, replace, deny, and the pinned invocation. +- Changesets: `patch` for each. Pre-1.0, and neither asks anything of a consumer: re-running `init` is already how an install upgrades. diff --git a/openspec/changes/pinned-cli-permissions/specs/cli-init/spec.md b/openspec/changes/pinned-cli-permissions/specs/cli-init/spec.md new file mode 100644 index 00000000..44656a4e --- /dev/null +++ b/openspec/changes/pinned-cli-permissions/specs/cli-init/spec.md @@ -0,0 +1,155 @@ +## ADDED Requirements + +### Requirement: Init names a CI or hook pin older than the running CLI + +Alongside the `package.json` pins it already reports, `taskless init` SHALL read the project's CI and git-hook configuration files and report every pin of `@taskless/cli` or `@taskless/cli-nightly` in them that would run a CLI older than the running one. Every build pins its own version, so the invocation the `ci` and `hooks` recipes write names that version, and an upgrade leaves it behind. + +The files read SHALL be the ones `detect` recognizes as CI and commit-time configuration at the project root: `.github/workflows/*.yml` and `*.yaml`, `.gitlab-ci.yml`, `.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml` and `.yaml`, `bitbucket-pipelines.yml`, `.drone.yml`, `.travis.yml`, the files directly in `.buildkite/` and `.husky/`, the lefthook, pre-commit, simple-git-hooks, and lint-staged config files. They SHALL be read as text with the script-pin grammar, a pin repeated in one file SHALL be reported once at its first line, and only a bounded spec SHALL be judged, as for a script. + +Each pin SHALL be reported through the same notice and the same `pinnedCli` envelope entry as a `package.json` pin, with `manifest` the file's root-relative `/`-separated path, `location` `line `, and `installed` `null`, after every `package.json` pin, ordered by path. The notice SHALL introduce its list without naming `package.json` alone. The install SHALL NOT modify any of these files. + +#### Scenario: A stale workflow pin is named by file and line + +- **WHEN** `.github/workflows/taskless.yml` runs `npx @taskless/cli@0.11.0 check` on line 4 and the running CLI is `0.12.0` +- **THEN** `pinnedCli` SHALL contain `{ "manifest": ".github/workflows/taskless.yml", "location": "line 4", "name": "@taskless/cli", "spec": "0.11.0", "installed": null }` +- **AND** the notice SHALL name `.github/workflows/taskless.yml line 4` and the target `@taskless/cli@0.12.0` +- **AND** the workflow file SHALL be byte-identical afterwards + +#### Scenario: Hook files are read + +- **WHEN** `.husky/pre-commit` or `lefthook.yml` spells out an older `@taskless/cli` or `@taskless/cli-nightly` version +- **THEN** each SHALL be reported with its path + +#### Scenario: A current or floating CI pin is not reported + +- **WHEN** a CI file runs `@taskless/cli` at the running version or at `latest` +- **THEN** no pin SHALL be reported for it + +### Requirement: Init allows the installed CLI in each harness's permission file + +`taskless init`, in the wizard and in `--no-interactive`, SHALL write a rule allowing exactly the installed CLI invocation to the project permission file of each harness it installs the skill for, from this table: + +| Harness | When | File | Rule | +| --------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Claude Code | `.claude/` selected | `.claude/settings.json` | `permissions.allow`: `Bash( *)` | +| Cursor (editor) | `.cursor/` selected | `.cursor/permissions.json` | `terminalAllowlist`: `` | +| Cursor (CLI) | `.cursor/` selected | `.cursor/cli.json` | `permissions.allow`: `Shell(npx:)` and `Shell(npx: *)` | +| OpenCode | `.opencode/` selected | the existing `opencode.json` or `opencode.jsonc`, at the root or under `.opencode/`, else `.opencode/opencode.json` | object form: `permission.bash[" *"]: "allow"`; array form: `{ "action": "shell", "resource": " *", "effect": "allow" }` | +| Codex | `.agents/` selected and Codex detected | `.codex/rules/taskless.rules` | `prefix_rule(pattern=["npx", ""], decision="allow")` | + +Each rule SHALL use that harness's word-bounded form, so it matches the invocation alone or followed by arguments and SHALL NOT match a longer version string. No rule SHALL wildcard the package name or version. The generic `.agents/` target has no permission file and SHALL receive none. The table is exhaustive: `init` SHALL NOT write a permission rule for any harness not listed in it. Harnesses that read the skill from `.agents/` (other than Codex), harnesses with no file-based allowlist such as Cline, and harnesses whose allowlist can only allow a whole tool such as Crush SHALL receive none. + +In OpenCode's object form the entry SHALL be placed after every existing key, since the last matching rule wins; in the array form it SHALL be placed first. + +How each edit is reported is defined by the requirement that init reports every permission edit. + +#### Scenario: An unlisted harness gets no rule + +- **WHEN** `init` runs in a project with a harness configuration directory that is not in the table +- **THEN** `init` SHALL NOT create or edit any file in it + +#### Scenario: Claude Code gets a pinned rule + +- **WHEN** `init` installs for `.claude/` with invocation `npx @taskless/cli@0.13.0` +- **THEN** `.claude/settings.json` SHALL contain `Bash(npx @taskless/cli@0.13.0 *)` in `permissions.allow` +- **AND** every other key and entry in the file SHALL be unchanged + +#### Scenario: The rule does not match another version + +- **WHEN** the rule for `npx @taskless/cli@0.13.0` is written for any harness +- **THEN** under that harness's documented matching it SHALL NOT match `npx @taskless/cli@0.13.01` + +#### Scenario: OpenCode object form puts the rule last + +- **WHEN** `opencode.json` has `permission.bash` with `"*": "ask"` +- **THEN** the Taskless key SHALL follow `"*"` in the written object + +#### Scenario: OpenCode array form puts the rule first + +- **WHEN** the OpenCode file uses the array form under `permissions` +- **THEN** the Taskless entry SHALL be the first element + +#### Scenario: Codex gets a rules file only when detected + +- **WHEN** `.agents/` is selected and no Codex detection signal is present +- **THEN** `init` SHALL NOT create `.codex/` + +#### Scenario: OpenCode comments survive + +- **WHEN** the OpenCode file is `opencode.jsonc` with comments +- **THEN** the written file SHALL keep every comment + +### Requirement: Init reports every permission edit + +Every time `init` edits or creates a harness permission file, its output SHALL name the file and each rule it changed, one per line, labelled so the reader cannot miss that a settings file was touched: + +``` +Edited .claude/settings.json + ALLOW: Bash(npx @taskless/cli@0.13.1 *) + REMOVE: Bash(npx @taskless/cli@0.13.0 *) +``` + +- `ALLOW: ` for a rule added, rendered in green. +- `REMOVE: ` for a recorded rule taken out on upgrade, rendered in red. +- `SKIPPED: ` with the reason, for a file left alone because it denies the invocation or does not parse, rendered in yellow. + +The first line SHALL say `Created` for a file Taskless created and `Edited` for one that existed. The labels SHALL carry the meaning on their own, so the output reads the same with color off (`NO_COLOR`, no TTY). Color SHALL come from the CLI's shared color setup, so it follows the real terminal. + +The wizard SHALL show the same lines in its summary step, before anything is written, so the user sees each permission edit before confirming. `--no-interactive` SHALL print them after writing. Under `--json` they SHALL go to stderr with the rest of the human summary, and the envelope SHALL carry the same information as data: per file, the path, whether it was created, the rules allowed and removed, and any skip reason. + +#### Scenario: A first install names the edit + +- **WHEN** `init --no-interactive` adds a rule to an existing `.claude/settings.json` +- **THEN** the output SHALL include `Edited .claude/settings.json` followed by `ALLOW: Bash( *)` +- **AND** with color on, the `ALLOW` line SHALL be green + +#### Scenario: An upgrade shows both sides + +- **WHEN** `init` replaces a recorded `0.13.0` rule with the `0.13.1` rule +- **THEN** the output SHALL list `ALLOW:` for the `0.13.1` rule and `REMOVE:` for the `0.13.0` rule under the file's name + +#### Scenario: The wizard previews before writing + +- **WHEN** the wizard reaches its summary step for an install that will edit `.cursor/cli.json` +- **THEN** the summary SHALL show the file and its `ALLOW:` lines before the user confirms +- **AND** nothing SHALL be written if the user cancels + +#### Scenario: Color off still reads + +- **WHEN** `NO_COLOR` is set +- **THEN** the lines SHALL carry the same `ALLOW:`, `REMOVE:`, and `SKIPPED:` labels with no escape sequences + +#### Scenario: JSON carries the edits as data + +- **WHEN** `init --json` edits a permission file +- **THEN** the envelope SHALL report that file's path, whether it was created, and the exact rules allowed and removed + +### Requirement: Init never overrides a deny or a file it cannot read + +`init` SHALL NOT write a rule to a harness file that denies the invocation, or that holds a rule which would take precedence over the Taskless rule and refuse it. It SHALL NOT write to a file that does not parse. In each case it SHALL leave the file unchanged and name it in the summary with the reason. + +#### Scenario: A user deny is respected + +- **WHEN** `.claude/settings.json` denies `Bash(npx *)` +- **THEN** `init` SHALL NOT add the allow rule +- **AND** the summary SHALL say the file denies the invocation + +#### Scenario: A malformed file is not overwritten + +- **WHEN** `.cursor/cli.json` does not parse +- **THEN** `init` SHALL leave it byte-identical and report it + +### Requirement: The manifest records permission rules, and an upgrade replaces only those + +The manifest SHALL record, under `install.permissions`, each file Taskless wrote a rule to, the exact rule entries it added, and whether Taskless created the file. On a later `init` the installer SHALL remove exactly the recorded entries and write the current ones. It SHALL NOT remove or edit any entry it did not record, including a rule for Taskless the user wrote. A file Taskless created SHALL be deleted when removing its entries leaves it holding nothing else; a file that existed before SHALL never be deleted. + +#### Scenario: An upgrade swaps the version + +- **WHEN** the manifest records `Bash(npx @taskless/cli@0.13.0 *)` in `.claude/settings.json` and `init` runs at `0.13.1` +- **THEN** the file SHALL contain `Bash(npx @taskless/cli@0.13.1 *)` and not the `0.13.0` rule +- **AND** the manifest SHALL record only the `0.13.1` rule + +#### Scenario: A user's own rule survives an upgrade + +- **WHEN** `.claude/settings.json` also holds `Bash(npx @taskless/cli@0.12.0 *)`, which the manifest does not record +- **THEN** an upgrade SHALL leave that entry in place diff --git a/openspec/changes/pinned-cli-permissions/specs/cli/spec.md b/openspec/changes/pinned-cli-permissions/specs/cli/spec.md new file mode 100644 index 00000000..2b36852a --- /dev/null +++ b/openspec/changes/pinned-cli-permissions/specs/cli/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: User-facing CLI invocations name the package the reader is running + +Wherever the CLI prints a command for the user to run — an authentication prompt, a re-authentication hint, an error remedy — it SHALL compose that command from the detected launcher and from the package specifier of the build in hand, not from a hardcoded string. + +The package specifier SHALL come from the build-target invocation, so a nightly build names `@taskless/cli-nightly` at its published version and a prod build names `@taskless/cli` at its released version. Neither SHALL be handed to a launcher as `@latest` or unpinned: the printed command runs the version the project installed, which is also the version its permission rules allow. A build whose invocation is a filesystem path (`dev`, `self`) SHALL be printed verbatim, since no launcher applies to it. + +Where detection reports unknown, the printed command SHALL name `npx` with the correct package specifier. That is a display default for a human reader who needs something runnable, and it is distinct from the recipe renderer's marker, which is read by an agent that can be asked to supply the right answer. + +#### Scenario: A nightly names itself in an error message + +- **WHEN** a nightly build prints an authentication remedy +- **THEN** the printed command SHALL name `@taskless/cli-nightly` at the nightly's own version, not `@taskless/cli` + +#### Scenario: A release names its own version + +- **WHEN** a prod build prints an authentication remedy +- **THEN** the printed command SHALL name `@taskless/cli@` at the build's own version +- **AND** SHALL NOT name `@taskless/cli@latest` + +#### Scenario: A pnpm script no longer suggests pnpm dlx + +- **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 diff --git a/openspec/changes/pinned-cli-permissions/specs/infrastructure/spec.md b/openspec/changes/pinned-cli-permissions/specs/infrastructure/spec.md new file mode 100644 index 00000000..5a7d064b --- /dev/null +++ b/openspec/changes/pinned-cli-permissions/specs/infrastructure/spec.md @@ -0,0 +1,32 @@ +## MODIFIED Requirements + +### Requirement: The CLI has three build targets + +The CLI SHALL support exactly three build targets, selected by `TASKLESS_BUILD_TARGET`: `prod` (the default, emitting `dist/` with the published invocation pinned to the build's own version, `npx @taskless/cli@`), `self` (emitting `dist-self/` with a repo-root-relative path, for dogfooding inside this repository), and `nightly` (emitting `dist/` with `npx @taskless/cli-nightly@`). + +There SHALL NOT be a build target that emits an invocation runnable from outside this repository's checkout. Exercising unreleased behavior elsewhere is served by a published nightly, which resolves on any machine and is pinned to the build whose instructions it carries, rather than by an absolute filesystem path baked into shipped content. + +An unset or unrecognized `TASKLESS_BUILD_TARGET` SHALL resolve to `prod`. The retired value `dev` SHALL be an exception: it SHALL fail the build with an error naming the target as removed, because a caller who sets it is looking for an output directory that will not be written, and a silent fall back to `prod` would surface only as a missing file at a path nothing else mentions. + +#### Scenario: The prod target pins its version + +- **WHEN** a build runs with `TASKLESS_BUILD_TARGET` unset +- **THEN** the baked invocation SHALL be `npx @taskless/cli@`, where `` is the version the build reports +- **AND** whether a build is the released one SHALL be read from its build target, not by comparing its invocation to an unpinned string + +#### Scenario: The self target stays repo-relative + +- **WHEN** a build runs with `TASKLESS_BUILD_TARGET=self` +- **THEN** the output SHALL be written to `dist-self/` +- **AND** the baked invocation SHALL be a path relative to the repo root + +#### Scenario: A retired target fails loudly + +- **WHEN** a build runs with `TASKLESS_BUILD_TARGET=dev` +- **THEN** the build SHALL fail with an error stating the target has been removed +- **AND** SHALL NOT fall back to the `prod` target + +#### Scenario: An unknown target still defaults to prod + +- **WHEN** a build runs with `TASKLESS_BUILD_TARGET` unset or set to an unrecognized value other than `dev` +- **THEN** the build SHALL resolve to the `prod` target diff --git a/openspec/changes/pinned-cli-permissions/specs/skill-taskless/spec.md b/openspec/changes/pinned-cli-permissions/specs/skill-taskless/spec.md new file mode 100644 index 00000000..30bd91c2 --- /dev/null +++ b/openspec/changes/pinned-cli-permissions/specs/skill-taskless/spec.md @@ -0,0 +1,29 @@ +## MODIFIED Requirements + +### Requirement: Skill preflights the CLI before the first Taskless command + +The skill body SHALL instruct the agent to run `%(TASKLESS_CLI)s agent ok` once per session, before any other Taskless command, and to proceed only once it prints its confirmation line. Once it does, the agent SHALL go straight to the user's request, without surveying the install or offering a menu of topics. On an install whose harness permission rule is in place the preflight passes without a prompt; the guidance below is the fallback for when it does not. + +When the preflight is blocked by the harness or the user, the body SHALL instruct the agent to: + +1. Stop before doing any Taskless work, and not attempt to work around the block. +2. Send one message that names the exact command, and says it downloads and runs a package from npm. +3. Offer the user three ways forward: the agent adds an allow rule for exactly the invocation the skill names to the agent's permission settings, and if the harness blocks that edit too, gives the user the change to make themselves; the user approves it through their agent's permissions command (in Claude Code, `/permissions` with the rule `Bash(:*)`); or the user approves every Taskless command manually, in chat, as the agent runs it. +4. Never propose an allow rule that widens the invocation, such as a wildcard over the package name or its version. Where the harness's allowlist cannot scope to the one invocation, for example one that only allows a whole tool, offer per-command approval in chat instead of a rule. +5. Not ask in that message what the user wants Taskless to do. Their request stands, and the agent carries it out once the command is unblocked. + +#### Scenario: Skill body runs the preflight first + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent to run `%(TASKLESS_CLI)s agent ok` before any other Taskless command in the session + +#### Scenario: Skill body handles a blocked preflight + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent, on a blocked preflight, to stop and name the exact command to the user in one message +- **AND** SHALL offer an allow rule for the exact invocation added by the agent, with the user making the change when the harness blocks the agent's edit +- **AND** SHALL offer the agent's permissions command, and per-command approval in chat +- **AND** SHALL NOT offer running the command with `!`, which unblocks only the preflight +- **AND** SHALL forbid an allow rule that wildcards the package name or version +- **AND** SHALL offer only per-command approval where the harness cannot allow the one invocation without allowing a whole tool +- **AND** SHALL forbid asking what the user wants Taskless to do in that message diff --git a/openspec/changes/pinned-cli-permissions/tasks.md b/openspec/changes/pinned-cli-permissions/tasks.md new file mode 100644 index 00000000..e69a6de3 --- /dev/null +++ b/openspec/changes/pinned-cli-permissions/tasks.md @@ -0,0 +1,20 @@ +## 1. Releases pin their version (PR 1) + +- [x] 1.1 Bake `npx @taskless/cli@` as the prod define; add a build-target define and move `isProductionInvocation()` and its callers onto it +- [x] 1.2 Stop appending `@latest` in `pinnedSpecifier()`; update user-facing command composition +- [x] 1.3 Update the recovery-line and stub-staleness logic that treats the unpinned invocation as the released form +- [x] 1.4 Report stale pins in CI and hook files (the files `detect` recognizes) through the existing notice and `pinnedCli`; update the `init` recipe +- [x] 1.5 Tests: prod skill, recipes, and printed remedies name the pinned version; nightly and self unchanged; CI and hook pins +- [x] 1.6 Changeset; run `pnpm typecheck` and `pnpm lint` + +## 2. Permission rules (PR 2) + +- [x] 2.1 Hand test: under Claude Code auto mode, confirm `Bash(npx @taskless/cli-nightly@ *)` in `.claude/settings.json` survives entry and lets `agent ok` run, in a trusted workspace +- [x] 2.2 Hand test Codex: `codex execpolicy check --rules .codex/rules/taskless.rules -- check` allows, and `1 check` does not +- [ ] 2.3 A writer per format: Claude Code, Cursor editor and CLI, OpenCode object (last) and array (first), JSONC-preserving, and Codex +- [ ] 2.4 Deny detection and unparseable-file refusal per format +- [ ] 2.5 `install.permissions` in the manifest; upgrade replacement; delete a Taskless-created file left empty +- [ ] 2.6 Report every edit: `Created`/`Edited ` with green `ALLOW:`, red `REMOVE:`, yellow `SKIPPED:` lines via `util/color.ts`; preview in the wizard summary step before writing; print after writing in `--no-interactive`; stderr plus envelope data under `--json` +- [ ] 2.7 Preflight wording: per-command approval only where a harness cannot scope a rule to one invocation +- [ ] 2.8 Tests per format: merge, placement, no longer-version match, deny, malformed, upgrade, user-owned rule untouched +- [ ] 2.9 Update the `init` recipe; changeset; run `pnpm typecheck` and `pnpm lint` diff --git a/packages/cli/scripts/build-target.ts b/packages/cli/scripts/build-target.ts index 133ff35b..db979c47 100644 --- a/packages/cli/scripts/build-target.ts +++ b/packages/cli/scripts/build-target.ts @@ -8,6 +8,12 @@ * instead of silently emitting the released invocation. */ +/** The invocation a `self` build bakes: a path relative to the repo root. */ +const SELF_INVOCATION = "node packages/cli/dist-self/index.js"; + +/** The npm package a release is published under. */ +const RELEASE_PACKAGE = "@taskless/cli"; + /** The npm package a nightly is published under (see `nightly-pack.cjs`). */ const NIGHTLY_PACKAGE = "@taskless/cli-nightly"; @@ -194,7 +200,7 @@ export function resolveSelfVersion( } /** - * Refuse to emit a nightly whose two version-bearing defines disagree. + * Refuse to emit a build whose two version-bearing defines disagree. * * A post-mortem guard, not a hypothetical one. #148 shipped because * `__VERSION__` and `__TASKLESS_CLI__` are *derived* from the same stamp but @@ -221,15 +227,16 @@ export function assertVersionConsistency( version: string, invocation: string ): void { - if (resolveBuildTarget(environment) !== "nightly") return; + // `self` names a path, which carries no version to disagree with. + if (resolveBuildTarget(environment) === "self") return; if (invocation.endsWith(`@${version}`)) return; throw new Error( - `nightly build is inconsistent with itself: it reports version ` + + `build is inconsistent with itself: it reports version ` + `${JSON.stringify(version)} but its invocation is ` + `${JSON.stringify(invocation)}. These must name the same version — a ` + `build that announces one version and sends agents to another is ` + - `taskless/cli#148. Both derive from ${NIGHTLY_VERSION_ENV}; if they no ` + - `longer do, that is the bug.` + `taskless/cli#148. Both derive from the version this build resolves; if ` + + `they no longer do, that is the bug.` ); } @@ -237,28 +244,37 @@ export function assertVersionConsistency( * The CLI invocation baked into emitted skill/command/recipe content, chosen by * the TASKLESS_BUILD_TARGET env var (see package.json build:self/ * build:nightly): - * - prod (default): the published `npx @taskless/cli` + * - prod (default): `npx @taskless/cli@`, the released package + * pinned to the version being published * - self: a repo-root-relative path, for dogfooding inside this repo - * - nightly: `npx @taskless/cli-nightly@`, PINNED to the exact - * version being published — a floating `@taskless/cli-nightly` would send - * an agent to whatever nightly is newest, which is not the build whose - * skills it is reading. + * - nightly: `npx @taskless/cli-nightly@`, pinned the same way + * + * Both published forms are PINNED. A floating specifier sends an agent to + * whatever version npx resolves (a cached copy, or the newest publish), which + * is not the build whose skills it is reading, and a project permission rule + * can only allow a version it can name. + * + * `version` is the version this build reports, from {@link resolveCliVersion}, + * so the invocation and `__VERSION__` cannot disagree. * * `self` is the only target whose invocation is a path, and it is relative to * the repo root, so it only resolves for a CLI run from inside this checkout. * That is the whole local story: nothing here emits an invocation usable from * another repository, and a published nightly is what serves that case. */ -export function resolveCliInvocation(environment: BuildEnvironment): string { +export function resolveCliInvocation( + environment: BuildEnvironment, + version: string +): string { switch (resolveBuildTarget(environment)) { case "self": { - return `node packages/cli/${OUT_DIRS.self}/index.js`; + return SELF_INVOCATION; } case "nightly": { - return `npx ${NIGHTLY_PACKAGE}@${resolveNightlyVersion(environment)}`; + return `npx ${NIGHTLY_PACKAGE}@${version}`; } default: { - return "npx @taskless/cli"; + return `npx ${RELEASE_PACKAGE}@${version}`; } } } @@ -281,7 +297,7 @@ export function resolveCliNotice(environment: BuildEnvironment): string { if (resolveBuildTarget(environment) !== "self") return ""; return ( `> **Local Taskless build.** The commands below call a locally built CLI ` + - `(\`${resolveCliInvocation(environment)}\`). If that ` + + `(\`${SELF_INVOCATION}\`). If that ` + `path does not exist yet, run \`pnpm build:self\` from the repo root first.` ); } diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index f76c1a6f..8922e46d 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 v6) +# Topic: init (CLI v%(CLI_VERSION)s / topic v7) ## Goal Install or update the Taskless skill in this project, and migrate the @@ -63,10 +63,12 @@ first step, not the whole job. - `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. + behind, or a script, CI file, or git hook spelling out an older + version. `manifest` is the file it lives in: the root's or a + workspace package's `package.json`, with `location` the field in it, + or a CI or hook file such as `.github/workflows/ci.yml`, with + `location` the line. It is reported even when `changed` is `false`, + because the pin and the project still disagree. Without `--json`, the same facts print as prose: a per-target summary, then a trailer naming the directories that changed and, after a @@ -81,15 +83,18 @@ first step, not the whole job. or commit on your own; the git operations are theirs. 3. **Offer to bump every stale pin.** For each `pinnedCli` entry, offer - the user the update to the installed CLI, along with reinstalling - dependencies. When the installed CLI is a nightly and the pin names + the user the update to the installed CLI. A pin in a `package.json` + dependency field also needs dependencies reinstalled; a script, CI, or + hook pin is a launcher line, and editing it is the whole fix. When the + installed CLI is a nightly and the pin names `@taskless/cli`, or the reverse, the move switches package, since a nightly version only exists on the nightly package. When `migrated` is present with `from` above `0`, say plainly that CI breaks without it: a CLI that predates the new schema refuses the project with `SCAFFOLD_VERSION_MISMATCH`, so the bump belongs in the same commit as - the migrated files. Do not edit a `package.json` on your own; a pin - can be deliberate, and the bump changes the lockfile. + the migrated files. Do not edit a `package.json`, CI file, or hook on + your own; a pin can be deliberate, and a dependency bump changes the + lockfile. 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/detect/automation.ts b/packages/cli/src/detect/automation.ts index 8891d384..7f010e9f 100644 --- a/packages/cli/src/detect/automation.ts +++ b/packages/cli/src/detect/automation.ts @@ -1,4 +1,4 @@ -import { existsSync, globSync } from "node:fs"; +import { existsSync, globSync, readdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; @@ -169,6 +169,36 @@ function matchSignals( return detected; } +/** + * Every CI and commit-time config file at `root`, as sorted root-relative + * paths: the files the signals above match, with a directory signal expanded + * to the files directly inside it (`.husky/pre-commit`, `.buildkite/pipeline.yml`). + * + * This is what runs commands on the repository's behalf, so it is where a + * pinned Taskless invocation is left behind by an upgrade. + */ +export function automationFiles(root: string): string[] { + const files = new Set(); + for (const signal of [...CI_SIGNALS, ...HOOK_SIGNALS]) { + for (const pattern of signal.paths ?? []) { + if (!pattern.endsWith("/")) { + for (const path of globSync(pattern, { cwd: root })) files.add(path); + continue; + } + let entries; + try { + entries = readdirSync(resolve(root, pattern), { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isFile()) files.add(`${pattern}${entry.name}`); + } + } + } + return [...files].toSorted(); +} + /** * The CI systems and commit-time tools configured at `root`. Pure filesystem * reads; an unreadable or malformed `package.json` contributes nothing rather diff --git a/packages/cli/src/filesystem/migrations/0001-init.ts b/packages/cli/src/filesystem/migrations/0001-init.ts index 0ee5558b..8afa373a 100644 --- a/packages/cli/src/filesystem/migrations/0001-init.ts +++ b/packages/cli/src/filesystem/migrations/0001-init.ts @@ -11,7 +11,7 @@ import { import { addToGitignore } from "../gitignore"; import type { Migration } from "../types"; import { buildInvocation } from "../../util/invocation"; -import { pinnedSpecifier } from "../../util/package-manager"; +import { readmeSpecifier } from "../../util/package-manager"; const MANIFEST_FILE = "taskless.json"; @@ -123,7 +123,7 @@ const migration: Migration = async (directory) => { // Always write README.md (overwrite stale content from older versions) await writeFile( join(directory, "README.md"), - buildReadmeContent(pinnedSpecifier()), + buildReadmeContent(readmeSpecifier()), "utf8" ); diff --git a/packages/cli/src/filesystem/migrations/0006-refresh-readme.ts b/packages/cli/src/filesystem/migrations/0006-refresh-readme.ts index 5d07d2a4..9adf0208 100644 --- a/packages/cli/src/filesystem/migrations/0006-refresh-readme.ts +++ b/packages/cli/src/filesystem/migrations/0006-refresh-readme.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { writeFile } from "node:fs/promises"; -import { pinnedSpecifier } from "../../util/package-manager"; +import { readmeSpecifier } from "../../util/package-manager"; import { buildReadmeContent } from "./0001-init"; import type { Migration } from "../types"; @@ -30,7 +30,7 @@ import type { Migration } from "../types"; const migration: Migration = async (directory) => { await writeFile( join(directory, "README.md"), - buildReadmeContent(pinnedSpecifier()), + buildReadmeContent(readmeSpecifier()), "utf8" ); }; diff --git a/packages/cli/src/globals.d.ts b/packages/cli/src/globals.d.ts index e331166a..96017b5e 100644 --- a/packages/cli/src/globals.d.ts +++ b/packages/cli/src/globals.d.ts @@ -3,3 +3,4 @@ declare const __VERSION__: string; declare const __TASKLESS_CLI__: string; declare const __TASKLESS_CLI_NOTICE__: string; +declare const __TASKLESS_BUILD_TARGET__: "prod" | "self" | "nightly"; diff --git a/packages/cli/src/install/canonical.ts b/packages/cli/src/install/canonical.ts index d92bcccc..4b7c46b8 100644 --- a/packages/cli/src/install/canonical.ts +++ b/packages/cli/src/install/canonical.ts @@ -5,6 +5,7 @@ import { stringify } from "yaml"; import { applyCliInvocation, + isProductionInvocation, PROD_INVOCATION, renderInvocationPlaceholder, withCliBuildNotice, @@ -121,11 +122,15 @@ const PROD_RESTORE_COMMAND = `${PROD_INVOCATION} init`; * installs in both, falling back to a non-interactive install when there is * no TTY. * - * Nothing here varies per release for a prod build, where - * {@link applyCliInvocation} is a no-op, so the stub footprint outside - * `.taskless` stays byte-stable (see {@link shimMetadata}). + * A prod build keeps the unpinned {@link PROD_RESTORE_COMMAND} here even + * though it pins everywhere else. A pinned version in the stub body would + * rewrite every reference stub outside `.taskless` on every release, and those + * stubs are committed in each tool directory (see {@link shimMetadata}). The + * canonical skill the stub points to carries the pinned invocation, so the + * only command left unpinned is the reinstall for when that file is missing. */ function restoreCommand(): string { + if (isProductionInvocation()) return PROD_RESTORE_COMMAND; return applyCliInvocation(PROD_RESTORE_COMMAND); } diff --git a/packages/cli/src/install/pinned-cli.ts b/packages/cli/src/install/pinned-cli.ts index 8536d092..b442bb73 100644 --- a/packages/cli/src/install/pinned-cli.ts +++ b/packages/cli/src/install/pinned-cli.ts @@ -3,6 +3,7 @@ import { basename, dirname, join, posix, sep } from "node:path"; import { parse as parseYaml } from "yaml"; +import { automationFiles } from "../detect/automation"; import { isRecord } from "../util/is-record"; import { compareSemver, compareVersions } from "../util/version-compare"; import { renderNoticeBox } from "./notice-box"; @@ -77,12 +78,16 @@ const BOUNDED_SPEC = /** One pin that would run a CLI older than the one that just ran. */ export interface PinnedCli { /** - * 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. + * The file holding the pin, relative to the directory searched and + * `/`-separated: `package.json` at the root, `packages/app/package.json` in a + * workspace package, or a CI or hook file such as + * `.github/workflows/ci.yml`. */ manifest: string; - /** Where the pin lives in it: `devDependencies`, or `scripts.`. */ + /** + * Where the pin lives in it: `devDependencies` or `scripts.` in a + * `package.json`, `line ` in a CI or hook file. + */ location: string; /** The package the pin names. */ name: string; @@ -329,9 +334,53 @@ async function findPinsIn( } /** - * 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. + * Every stale pin spelled out in one CI or hook file, by line. + * + * Read as text with the same grammar as a `package.json` script, because that + * is what these files are: commands, wrapped in YAML or a shell script. Once + * every build pins its own version, the invocation the `ci` and `hooks` + * recipes write names that version, and an upgrade leaves it behind. Only a + * bounded spec is judged, as for scripts, and a pin repeated in one file is + * reported once, at its first line. + */ +async function findPinsInAutomationFile( + cwd: string, + file: string, + cliVersion: string +): Promise { + let text: string; + try { + text = await readFile(join(cwd, file), "utf8"); + } catch { + return []; + } + const pins: PinnedCli[] = []; + const seen = new Set(); + for (const [index, line] of text.split("\n").entries()) { + for (const match of line.matchAll(SCRIPT_PIN)) { + const [, suffix, spec] = match; + const name = `@taskless/${suffix ?? "cli"}`; + if (spec === undefined || seen.has(`${name}@${spec}`)) continue; + seen.add(`${name}@${spec}`); + if (isStale(spec, cliVersion)) { + pins.push({ + manifest: file, + location: `line ${String(index + 1)}`, + name, + spec, + installed: null, + }); + } + } + } + return pins; +} + +/** + * Every stale pin in `/package.json`, in each workspace package it + * declares, and in the project's CI and hook files: root first, then + * workspace packages by path, then CI and hook files by path, each in file + * order. An absent or unreadable file has no pins. * * Unreadable is not an error here. This is advice attached to a successful * command, and a malformed manifest is something the package manager will @@ -354,6 +403,9 @@ export async function findStalePins( )) ); } + for (const file of automationFiles(cwd)) { + pins.push(...(await findPinsInAutomationFile(cwd, file, cliVersion))); + } return pins; } @@ -393,7 +445,7 @@ export function describePin(pin: PinnedCli, cliVersion: string): string { * 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 + * `package.json`, a CI file, or a hook: a pin is often deliberate (a CI image, a reproducible * build), and bumping it changes a lockfile the person has not seen. What the * notice owes them is that the pin and the project now disagree, and which * version would agree. @@ -420,15 +472,24 @@ export function getPinnedCliNotice( options.migrated !== undefined && options.migrated.from > 0 ? options.migrated : undefined; + // Only a dependency pin has anything to reinstall. A script, CI, or hook pin + // is a launcher line, and editing it is the whole fix. + const reinstall = pins.some((pin) => + (DEPENDENCY_FIELDS as readonly string[]).includes(pin.location) + ); const consequence = upgraded === undefined ? `Anything that runs these pins (a script, CI, a git hook) runs a CLI older than this project expects and will likely fail. ` + - `Offer to update them as shown as part of this change, then reinstall dependencies.` + (reinstall + ? `Offer to update them as shown as part of this change, then reinstall dependencies.` + : `Offer to update them as shown as part of this change.`) : `This upgrade migrated .taskless/ from schema version ${String(upgraded.from)} to ${String(upgraded.to)}, and a CLI that predates that schema refuses the project (SCAFFOLD_VERSION_MISMATCH). ` + `CI, scripts, and git hooks that run these pins will break on the push that carries the migrated files. ` + - `Offer to update them as shown and reinstall dependencies, in the same commit as .taskless/.`; + (reinstall + ? `Offer to update them as shown and reinstall dependencies, in the same commit as .taskless/.` + : `Offer to update them as shown, in the same commit as .taskless/.`); return renderNoticeBox("UPDATE PINNED TASKLESS VERSIONS", [ - `package.json pins a Taskless CLI older than ${cliVersion}, the version that just ran here:`, + `This project pins a Taskless CLI older than ${cliVersion}, the version that just ran here:`, { items: pins.map((pin) => describePin(pin, cliVersion)) }, consequence, ]); diff --git a/packages/cli/src/schemas/info.ts b/packages/cli/src/schemas/info.ts index 8b7e53b3..3cc97455 100644 --- a/packages/cli/src/schemas/info.ts +++ b/packages/cli/src/schemas/info.ts @@ -43,18 +43,20 @@ const pinnedCliSchema = z.object({ manifest: z .string() .describe( - "The package.json holding the pin, relative to the working directory" + "The file holding the pin, relative to the working directory: a package.json, or a CI or git-hook file" ), location: z .string() - .describe("The field in it: `devDependencies`, or `scripts.`"), + .describe( + "Where in it: `devDependencies` or `scripts.` in a package.json, `line ` in a CI or hook file" + ), 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" + "The installed version the pin runs, or null when nothing is installed or the pin is in a script, CI file, or hook" ), }); diff --git a/packages/cli/src/util/invocation.ts b/packages/cli/src/util/invocation.ts index 78da1c68..f63bcba2 100644 --- a/packages/cli/src/util/invocation.ts +++ b/packages/cli/src/util/invocation.ts @@ -1,26 +1,29 @@ /** - * The published CLI invocation baked into skill, command, and recipe source. - * Build targets other than prod rewrite it: to a repo-root-relative path so a - * locally built CLI can be dogfooded in this repo (`build:self`), and to - * `npx @taskless/cli-nightly@` for a nightly, whose shipped - * instructions must name the package the reader actually installed rather than - * the released one. There is no target that emits a path usable from outside - * this checkout; a nightly covers that. See `scripts/build-target.ts`, - * `vite.config.ts`, and the root `package.json` scripts. + * The unpinned published invocation: the literal some content was written + * with before invocations were pinned, and the rewrite anchor + * {@link applyCliInvocation} replaces with this build's own invocation. + * + * No build bakes this string any more. Prod bakes `npx @taskless/cli@`, + * a nightly `npx @taskless/cli-nightly@`, and `self` a repo-root + * path. See `scripts/build-target.ts`, `vite.config.ts`, and the root + * `package.json` scripts. */ export const PROD_INVOCATION = "npx @taskless/cli"; /** - * Whether this build's invocation is the released one. + * Whether this is the released build. + * + * Read from the build target rather than by comparing the invocation to + * {@link PROD_INVOCATION}: a release now bakes its own version, so the + * invocation no longer equals the unpinned string, and a comparison would + * quietly report every build as unreleased. * - * `false` means the build knows exactly what it is — a `nightly` pinned to its - * published version, or a `self` path — and its instructions must say so - * rather than naming `@taskless/cli`. `true` means the build is the released - * package and has no idea how it was launched, which is a different situation - * from knowing it was launched as `npx @taskless/cli`. + * `false` means a `nightly` or a `self` path. `true` means the released + * package, which knows its version but not how it was launched (`npx`, + * `pnpm dlx`, a `package.json` script). */ export function isProductionInvocation(): boolean { - return __TASKLESS_CLI__ === PROD_INVOCATION; + return __TASKLESS_BUILD_TARGET__ === "prod"; } /** This build's invocation, whatever the target. */ @@ -51,18 +54,15 @@ export function renderInvocationPlaceholder(content: string): string { } /** - * Rewrite the canonical `npx @taskless/cli` invocation to the build-target + * Rewrite the unpinned `npx @taskless/cli` invocation to the build-target * invocation (`__TASKLESS_CLI__`). * - * A no-op for prod builds, where the define equals {@link PROD_INVOCATION}, so - * emitted content stays byte-identical to source. For `dev`/`self`/`nightly` - * builds it swaps both the bare form and the `@latest`-pinned form (the - * version-pinned form first, so the bare replacement can't leave a dangling - * `@latest` — which for a nightly would read - * `npx @taskless/cli-nightly@@latest`). + * Every target rewrites, prod included, since every target now bakes a + * pinned invocation. It swaps both the bare form and the `@latest` form (the + * `@latest` form first, so the bare replacement can't leave a dangling + * `@latest`, which would read `npx @taskless/cli@@latest`). */ export function applyCliInvocation(content: string): string { - if (isProductionInvocation()) return content; return content .replaceAll(`${PROD_INVOCATION}@latest`, __TASKLESS_CLI__) .replaceAll(PROD_INVOCATION, __TASKLESS_CLI__); diff --git a/packages/cli/src/util/package-manager.ts b/packages/cli/src/util/package-manager.ts index 73279424..ec61a007 100644 --- a/packages/cli/src/util/package-manager.ts +++ b/packages/cli/src/util/package-manager.ts @@ -1,13 +1,15 @@ import process from "node:process"; -import { buildInvocation } from "./invocation"; +import { buildInvocation, isProductionInvocation } from "./invocation"; /** * How the CLI answers "what would the reader type to run me again?" * * The answer has two halves that come from different places. The **package - * specifier** (`@taskless/cli@latest`, `@taskless/cli-nightly@0.11.0-…`) is a - * build-time fact, baked in by `scripts/build-target.ts`. The **launcher** + * specifier** (`@taskless/cli@0.11.2`, `@taskless/cli-nightly@0.11.0-…`) is a + * build-time fact, baked in by `scripts/build-target.ts`: every published + * build pins its own version, and `@latest` survives only in + * `readmeSpecifier()`. The **launcher** * (`npx`, `pnpm dlx`) is a runtime fact, and the only honest source for it is * the path this process was launched from. * @@ -146,18 +148,31 @@ const NPX_PREFIX = "npx "; * or `undefined` when the build's invocation is a filesystem path. * * A `self` build names `node `; no launcher applies to it and there is - * nothing to pin. A nightly's specifier already carries its exact - * version — deliberately, since a floating `@taskless/cli-nightly` resolves to - * whatever nightly is newest rather than the one whose instructions are being - * read. Only the released `@taskless/cli` needs `@latest` appended, because - * `npx` and `pnpm dlx` otherwise prefer whatever is already in the cache. + * nothing to pin. Both published builds carry their exact version in the + * invocation: a release as `@taskless/cli@`, a nightly as + * `@taskless/cli-nightly@`. A floating specifier resolves to whatever + * npx has cached or whatever is newest, which is not the build whose + * instructions are being read, and no permission rule can allow it narrowly. */ export function pinnedSpecifier(): string | undefined { const invocation = buildInvocation(); if (!invocation.startsWith(NPX_PREFIX)) return undefined; - const specifier = invocation.slice(NPX_PREFIX.length); - // Scoped names open with `@`, so a version pin is an `@` anywhere after it. - return specifier.includes("@", 1) ? specifier : `${specifier}@latest`; + return invocation.slice(NPX_PREFIX.length); +} + +/** + * The specifier `.taskless/README.md` names. + * + * The README is written by a migration and read long after, through any number + * of upgrades that do not rewrite it, so a release's version pinned there would + * be stale by the next release. A release's README therefore names + * `@taskless/cli@latest`. A nightly keeps its pin, since a reader of a nightly's + * README is exercising that nightly, and `@latest` would install the release + * over it. + */ +export function readmeSpecifier(): string | undefined { + if (isProductionInvocation()) return "@taskless/cli@latest"; + return pinnedSpecifier(); } /** diff --git a/packages/cli/test/agent-extensions.test.ts b/packages/cli/test/agent-extensions.test.ts index e098dcf5..6a619745 100644 --- a/packages/cli/test/agent-extensions.test.ts +++ b/packages/cli/test/agent-extensions.test.ts @@ -250,7 +250,7 @@ describe("taskless agent ", () => { const result = await runCli(["agent", "totally-unknown", "-d", cwd]); // Through the launcher, not a bare `taskless` that may not be on PATH. expect(result.stderr).toMatch( - /Run `(?:npx|pnpm dlx) @taskless\/cli@latest agent` for available/ + /Run `(?:npx|pnpm dlx) @taskless\/cli@\d[^\s`]* agent` for available/ ); }); diff --git a/packages/cli/test/anonymous-flag.test.ts b/packages/cli/test/anonymous-flag.test.ts index e93d2234..c53714cd 100644 --- a/packages/cli/test/anonymous-flag.test.ts +++ b/packages/cli/test/anonymous-flag.test.ts @@ -96,7 +96,7 @@ describe("--anonymous flag (per-command behavior matrix)", () => { const result = await runCli(["rule", "create", "--anonymous", "-d", cwd]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toMatch( - /`(?:npx|pnpm dlx) @taskless\/cli@latest agent create-sg-rule`/ + /`(?:npx|pnpm dlx) @taskless\/cli@\d[^\s`]* agent create-sg-rule`/ ); }); @@ -127,7 +127,7 @@ describe("--anonymous flag (per-command behavior matrix)", () => { ]); expect(result.exitCode).not.toBe(0); expect(result.stderr).toMatch( - /`(?:npx|pnpm dlx) @taskless\/cli@latest agent improve-rule --anonymous`/ + /`(?:npx|pnpm dlx) @taskless\/cli@\d[^\s`]* agent improve-rule --anonymous`/ ); }); }); diff --git a/packages/cli/test/build-target.test.ts b/packages/cli/test/build-target.test.ts index 8a1c3919..a8f3ed01 100644 --- a/packages/cli/test/build-target.test.ts +++ b/packages/cli/test/build-target.test.ts @@ -25,13 +25,13 @@ describe("build target resolution", () => { expect(resolveBuildTarget({ TASKLESS_BUILD_TARGET: "nightlies" })).toBe( "prod" ); - expect(resolveCliInvocation({})).toBe("npx @taskless/cli"); + expect(resolveCliInvocation({}, "0.12.0")).toBe("npx @taskless/cli@0.12.0"); expect(resolveCliNotice({})).toBe(""); }); it("keeps the self target pointed at its own output directory", () => { const self = { TASKLESS_BUILD_TARGET: "self" }; - expect(resolveCliInvocation(self)).toBe( + expect(resolveCliInvocation(self, "0.12.0")).toBe( `node packages/cli/${OUT_DIRS.self}/index.js` ); expect(resolveCliNotice(self)).toContain("pnpm build:self"); @@ -47,7 +47,9 @@ describe("build target resolution", () => { const removed = { TASKLESS_BUILD_TARGET: "dev" }; expect(() => resolveBuildTarget(removed)).toThrow(/has been removed/); expect(() => resolveOutputDirectory(removed)).toThrow(/has been removed/); - expect(() => resolveCliInvocation(removed)).toThrow(/has been removed/); + expect(() => resolveCliInvocation(removed, "0.12.0")).toThrow( + /has been removed/ + ); expect(() => resolveCliNotice(removed)).toThrow(/has been removed/); }); @@ -139,14 +141,14 @@ describe("a build must agree with itself about its version", () => { // The exact shape of the shipped bug, named in the message so whoever hits // this next has the history rather than just the assertion. - it("names the issue and the env var both values derive from", () => { + it("names the issue", () => { expect(() => assertVersionConsistency( nightlyEnvironment, "0.10.2", `npx @taskless/cli-nightly@${NIGHTLY_VERSION}` ) - ).toThrow(new RegExp(String.raw`148[\s\S]*` + NIGHTLY_VERSION_ENV)); + ).toThrow(/148/); }); it("accepts a nightly whose two values agree", () => { @@ -159,18 +161,30 @@ describe("a build must agree with itself about its version", () => { ).not.toThrow(); }); - // A version pin is meaningless for the other targets: prod names a floating - // package deliberately, and self names a filesystem path. Asserting there - // would fail every ordinary build. + // A release pins itself now, so it can disagree with itself the same way a + // nightly could, and an unpinned invocation is that disagreement. it.each([ - ["prod", "npx @taskless/cli"], - ["self", "node packages/cli/dist-self/index.js"], - ])("does not constrain the %s target", (target, invocation) => { + ["an older version", "npx @taskless/cli@0.10.1"], + ["no version at all", "npx @taskless/cli"], + ])("refuses a release whose invocation names %s", (_label, invocation) => { + expect(() => assertVersionConsistency({}, "0.10.2", invocation)).toThrow( + /inconsistent with itself/ + ); + }); + + it("accepts a release whose two values agree", () => { + expect(() => + assertVersionConsistency({}, "0.10.2", "npx @taskless/cli@0.10.2") + ).not.toThrow(); + }); + + // self names a filesystem path, which carries no version to agree with. + it("does not constrain the self target", () => { expect(() => assertVersionConsistency( - { TASKLESS_BUILD_TARGET: target }, + { TASKLESS_BUILD_TARGET: "self" }, "0.10.2", - invocation + "node packages/cli/dist-self/index.js" ) ).not.toThrow(); }); @@ -182,7 +196,7 @@ describe("the nightly target", () => { // version, `npx @taskless/cli-nightly` would float to whatever nightly is // newest — a different build from the one whose instructions are being read. it("names the nightly package pinned to the exact published version", () => { - expect(resolveCliInvocation(nightlyEnvironment)).toBe( + expect(resolveCliInvocation(nightlyEnvironment, NIGHTLY_VERSION)).toBe( `npx @taskless/cli-nightly@${NIGHTLY_VERSION}` ); }); @@ -224,6 +238,8 @@ describe("the nightly target", () => { // The failure that matters. Falling back to `npx @taskless/cli` here would // reintroduce exactly the bug this target fixes, and would do it silently: // the build would succeed and ship instructions for the released package. + // The version is resolved before the invocation is built from it, so this + // is where a missing stamp stops the build. it("fails the build when the version is missing or malformed", () => { for (const environment of [ { TASKLESS_BUILD_TARGET: "nightly" }, @@ -236,7 +252,7 @@ describe("the nightly target", () => { }, ]) { expect( - () => resolveCliInvocation(environment), + () => resolveCliVersion(environment, "0.10.2"), `${JSON.stringify(environment[NIGHTLY_VERSION_ENV])} must fail the build, not fall back` ).toThrowError(new RegExp(NIGHTLY_VERSION_ENV)); } diff --git a/packages/cli/test/canonical-store.test.ts b/packages/cli/test/canonical-store.test.ts index 6184b41a..b921c0d5 100644 --- a/packages/cli/test/canonical-store.test.ts +++ b/packages/cli/test/canonical-store.test.ts @@ -14,8 +14,8 @@ import { } from "../src/install/canonical"; import { parseFrontmatter } from "../src/install/frontmatter"; import { - buildInvocation, isProductionInvocation, + PROD_INVOCATION, } from "../src/util/invocation"; const SENTINEL = "INLINED-CANONICAL-BODY-MARKER"; @@ -95,9 +95,10 @@ describe("buildSkillStub", () => { const stub = buildSkillStub({ name: "taskless", description: "d" }); const { content } = parseFrontmatter(stub); expect(content).toContain("does not exist"); - // The invocation is the build's own, so a dev/self build points at its own - // binary rather than sending the reader to the published package. - expect(content).toContain(`\`${buildInvocation()} init\``); + // A release keeps the unpinned form here, so the stub does not change on + // every release; a self or nightly build names its own invocation (see + // test/nightly/stub-recovery-invocation.test.ts). + expect(content).toContain(`\`${PROD_INVOCATION} init\``); }); it("embeds nothing that varies per release", () => { @@ -230,9 +231,9 @@ function withRecoveryInvocation(stub: string, invocation: string): string { describe("stubRecoveryInvocation / stubRecoveryInvocationStale", () => { const meta = { name: "taskless", description: "Use for any Taskless task." }; // This project runs under a released define, so a stub written here carries - // the released invocation. The nightly side of the same behaviour lives in - // test/nightly/stub-recovery-invocation.test.ts. - const productionRestore = `${buildInvocation()} init`; + // the released restore command, which stays unpinned. The nightly side of + // the same behaviour lives in test/nightly/stub-recovery-invocation.test.ts. + const productionRestore = `${PROD_INVOCATION} init`; it("reads the invocation back out of a stub it wrote", () => { expect(stubRecoveryInvocation(buildSkillStub(meta))).toBe( diff --git a/packages/cli/test/check.test.ts b/packages/cli/test/check.test.ts index 73cf6014..08ab4507 100644 --- a/packages/cli/test/check.test.ts +++ b/packages/cli/test/check.test.ts @@ -67,7 +67,7 @@ describe("check", () => { // The remedy must run as printed: through the launcher, with the // `--from` that `rule create` refuses to start without. expect(stdout).toMatch( - /`(?:npx|pnpm dlx) @taskless\/cli@latest rule create --from `/ + /`(?:npx|pnpm dlx) @taskless\/cli@\d[^\s`]* rule create --from `/ ); }); diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index 0cd72d38..e27ab398 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -317,7 +317,9 @@ describe("taskless init (the batch install)", () => { // 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")); + const pinAt = lines.findIndex((line) => + line.includes("pins a Taskless CLI older") + ); // 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. diff --git a/packages/cli/test/installed-documentation.test.ts b/packages/cli/test/installed-documentation.test.ts index 239dfc06..9f0ba33a 100644 --- a/packages/cli/test/installed-documentation.test.ts +++ b/packages/cli/test/installed-documentation.test.ts @@ -5,7 +5,7 @@ import { join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { buildReadmeContent } from "../src/filesystem/migrations/0001-init"; -import { pinnedSpecifier } from "../src/util/package-manager"; +import { readmeSpecifier } from "../src/util/package-manager"; import { ensureTasklessDirectory } from "../src/filesystem/directory"; import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; import { @@ -104,7 +104,7 @@ describe("this repository's own installed Taskless docs", () => { // Run `pnpm build && pnpm cli init` and commit the result; // do not edit `.taskless/README.md` by hand, since the next migration // overwrites it. - expect(onDisk).toBe(buildReadmeContent(pinnedSpecifier())); + expect(onDisk).toBe(buildReadmeContent(readmeSpecifier())); }); it("describes no directory the layout migrations removed", async () => { diff --git a/packages/cli/test/package-manager.test.ts b/packages/cli/test/package-manager.test.ts index c6535ba7..6bab3878 100644 --- a/packages/cli/test/package-manager.test.ts +++ b/packages/cli/test/package-manager.test.ts @@ -8,6 +8,7 @@ import { type Launcher, type LauncherContext, } from "../src/util/package-manager"; +import { CLI_VERSION } from "../src/version"; /** * These tests replace a suite that asserted `npm_config_user_agent` alone @@ -167,20 +168,20 @@ describe("detectLauncher", () => { describe("detectCliInvocation", () => { // The test suite runs against a prod build, so the specifier is the released - // package pinned to @latest. + // package pinned to its own version. it("composes the launcher with the pinned package specifier", () => { expect( detectCliInvocation( context("/Users/dev/.npm/_npx/a1b2c3/node_modules/.bin/taskless") ) - ).toBe("npx @taskless/cli@latest"); + ).toBe(`npx @taskless/cli@${CLI_VERSION}`); expect( detectCliInvocation( context("/Users/dev/Library/Caches/pnpm/dlx/9f8/node_modules/.bin/x", { npm_config_user_agent: PNPM_AGENT, }) ) - ).toBe("pnpm dlx @taskless/cli@latest"); + ).toBe(`pnpm dlx @taskless/cli@${CLI_VERSION}`); }); it("is undefined when the launcher is unknown", () => { @@ -193,7 +194,9 @@ describe("detectCliInvocation", () => { describe("getCliPrefix", () => { it("prints npx when detection is unknown, because a human needs something runnable", () => { // Whatever launcher the test runner used, the printed command must resolve. - expect(getCliPrefix()).toMatch(/^(?:npx|pnpm dlx) @taskless\/cli@latest$/); + expect(getCliPrefix()).toMatch( + new RegExp(`^(?:npx|pnpm dlx) @taskless/cli@${CLI_VERSION}$`) + ); }); it("never suggests pnpm dlx from a pnpm script", () => { diff --git a/packages/cli/test/pinned-cli.test.ts b/packages/cli/test/pinned-cli.test.ts index b40180d5..861288b0 100644 --- a/packages/cli/test/pinned-cli.test.ts +++ b/packages/cli/test/pinned-cli.test.ts @@ -30,6 +30,11 @@ describe("findStalePins", () => { await writeFile(join(cwd, path, "package.json"), JSON.stringify(contents)); } + async function writeFileAt(path: string, contents: string): Promise { + await mkdir(join(cwd, path, ".."), { recursive: true }); + await writeFile(join(cwd, path), contents); + } + /** The manifest of every stale pin, in report order. */ async function manifestsOf(cliVersion: string): Promise { const pins = await findStalePins(cwd, cliVersion); @@ -271,6 +276,67 @@ describe("findStalePins", () => { expect(await findStalePins(cwd, "0.11.2")).toHaveLength(1); }); + describe("CI and hook files", () => { + // Every build pins its own version now, so the `ci` and `hooks` recipes + // write `npx @taskless/cli@`, and the next upgrade leaves it behind. + it("names a stale pin in a workflow by file and line", async () => { + await writeFileAt( + ".github/workflows/taskless.yml", + "jobs:\n check:\n steps:\n - run: npx @taskless/cli@0.11.0 check\n" + ); + expect(await findStalePins(cwd, "0.12.0")).toEqual([ + { + manifest: ".github/workflows/taskless.yml", + location: "line 4", + name: "@taskless/cli", + spec: "0.11.0", + installed: null, + }, + ]); + }); + + it("reads hook files, including a directory of hook scripts", async () => { + await writeFileAt( + ".husky/pre-commit", + "npx @taskless/cli@0.11.0 check\n" + ); + await writeFileAt( + "lefthook.yml", + "pre-commit:\n commands:\n taskless:\n run: npx @taskless/cli-nightly@0.11.0-20260901000000xaaaaaaa check {staged_files}\n" + ); + expect(await manifestsOf("0.12.0")).toEqual([ + ".husky/pre-commit", + "lefthook.yml", + ]); + }); + + it("leaves a current or floating pin alone", async () => { + await writeFileAt( + ".gitlab-ci.yml", + "check:\n script:\n - npx @taskless/cli@0.12.0 check\n - npx @taskless/cli@latest check\n" + ); + expect(await findStalePins(cwd, "0.12.0")).toEqual([]); + }); + + it("reports a pin repeated in one file once, at its first line", async () => { + await writeFileAt( + ".github/workflows/ci.yml", + "- run: npx @taskless/cli@0.11.0 check\n- run: npx @taskless/cli@0.11.0 report\n" + ); + const pins = await findStalePins(cwd, "0.12.0"); + expect(pins.map((pin) => pin.location)).toEqual(["line 1"]); + }); + + it("comes after package.json pins", async () => { + await writePackage({ devDependencies: { "@taskless/cli": "0.11.0" } }); + await writeFileAt(".gitlab-ci.yml", "- npx @taskless/cli@0.11.0 check\n"); + expect(await manifestsOf("0.12.0")).toEqual([ + "package.json", + ".gitlab-ci.yml", + ]); + }); + }); + describe("workspace packages", () => { const stalePin = { devDependencies: { "@taskless/cli": "^0.10.2" } }; @@ -447,6 +513,23 @@ describe("getPinnedCliNotice", () => { } }); + it("names a CI pin by file and line", () => { + const pin: PinnedCli = { + manifest: ".github/workflows/ci.yml", + location: "line 12", + name: "@taskless/cli", + spec: "0.11.0", + installed: null, + }; + const text = pinnedNotice([pin], "0.12.0"); + expect(text).toContain( + ".github/workflows/ci.yml line 12: @taskless/cli 0.11.0 -> @taskless/cli@0.12.0" + ); + expect(text).toContain( + "This project pins a Taskless CLI older than 0.12.0" + ); + }); + it("names every pin with its target, and offers the bump rather than claiming it", () => { const notice = pinnedNotice( [ @@ -505,6 +588,24 @@ describe("getPinnedCliNotice", () => { expect(notice).not.toContain("likely"); }); + it("asks for a reinstall only when a dependency pin moves", () => { + expect(pinnedNotice([releasePin], "0.11.2")).toContain( + "reinstall dependencies" + ); + // A CI or hook pin is a launcher line: there is nothing to reinstall. + const ciPin: PinnedCli = { + manifest: ".github/workflows/ci.yml", + location: "line 12", + name: "@taskless/cli", + spec: "0.10.2", + installed: null, + }; + expect(pinnedNotice([ciPin], "0.11.2")).not.toContain("reinstall"); + expect( + pinnedNotice([ciPin], "0.11.2", { migrated: { from: 5, to: 9 } }) + ).not.toContain("reinstall"); + }); + it("does not call a fresh install an upgrade", () => { // A fresh `init` creates `.taskless/` by migrating from schema 0. const notice = pinnedNotice([releasePin], "0.11.2", { diff --git a/packages/cli/test/scaffold-readme.test.ts b/packages/cli/test/scaffold-readme.test.ts index c4bdbf23..4dbc8c57 100644 --- a/packages/cli/test/scaffold-readme.test.ts +++ b/packages/cli/test/scaffold-readme.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildReadmeContent } from "../src/filesystem/migrations/0001-init"; import { buildInvocation } from "../src/util/invocation"; -import { pinnedSpecifier } from "../src/util/package-manager"; +import { readmeSpecifier } from "../src/util/package-manager"; /** * `.taskless/README.md` is persisted into the user's repository and rewritten @@ -17,7 +17,7 @@ import { pinnedSpecifier } from "../src/util/package-manager"; const NIGHTLY_SPECIFIER = "@taskless/cli-nightly@0.11.0-20260824213902xf26a7b0"; const RELEASED_SPECIFIER = "@taskless/cli@latest"; -/** What `pinnedSpecifier()` returns for a path-form (`self`) build. */ +/** What `readmeSpecifier()` returns for a path-form (`self`) build. */ const NO_SPECIFIER: string | undefined = undefined; describe("buildReadmeContent", () => { @@ -55,9 +55,11 @@ describe("buildReadmeContent", () => { // Byte stability matters because many projects commit this file and the // migration overwrites it on every run. The specifier is the only input, // and it is a build-time constant, so nothing here can vary per launch. - expect(buildReadmeContent(pinnedSpecifier())).toBe( - buildReadmeContent(pinnedSpecifier()) + expect(buildReadmeContent(readmeSpecifier())).toBe( + buildReadmeContent(readmeSpecifier()) ); - expect(pinnedSpecifier()).toBe(RELEASED_SPECIFIER); + // A release pins its own version everywhere else; the README, written + // once and read across upgrades, names @latest instead. + expect(readmeSpecifier()).toBe(RELEASED_SPECIFIER); }); }); diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index 2f12d757..b1858ba9 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -415,9 +415,9 @@ describe("the wizard's stale-pin notice", () => { 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 - ); + expect( + printed.some((line) => line.includes("pins a Taskless CLI older")) + ).toBe(false); } finally { logSpy.mockRestore(); } diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index 4ca0d0e8..2d36dbf7 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -31,7 +31,8 @@ const pkg = JSON.parse( // binds those pure functions to this process. const buildTarget = resolveBuildTarget(process.env); const outDir = resolveOutputDirectory(process.env); -const cliInvocation = resolveCliInvocation(process.env); +const cliVersion = resolveCliVersion(process.env, pkg.version); +const cliInvocation = resolveCliInvocation(process.env, cliVersion); const cliNotice = resolveCliNotice(process.env); // A nightly emits to `dist` — the same directory as prod, because that is what @@ -455,11 +456,12 @@ const NIGHTLY_TEST_VERSION = resolveCliVersion( NIGHTLY_TEST_ENVIRONMENT, pkg.version ); -const NIGHTLY_TEST_INVOCATION = resolveCliInvocation(NIGHTLY_TEST_ENVIRONMENT); +const NIGHTLY_TEST_INVOCATION = resolveCliInvocation( + NIGHTLY_TEST_ENVIRONMENT, + NIGHTLY_TEST_VERSION +); const NIGHTLY_TEST_NOTICE = resolveCliNotice(NIGHTLY_TEST_ENVIRONMENT); -const cliVersion = resolveCliVersion(process.env, pkg.version); - // Fails the build rather than emitting an artifact whose version and // invocation disagree — the shape of taskless/cli#148. assertVersionConsistency(process.env, cliVersion, cliInvocation); @@ -469,6 +471,7 @@ export default defineConfig({ __VERSION__: JSON.stringify(cliVersion), __TASKLESS_CLI__: JSON.stringify(cliInvocation), __TASKLESS_CLI_NOTICE__: JSON.stringify(cliNotice), + __TASKLESS_BUILD_TARGET__: JSON.stringify(buildTarget), }, plugins: [ tsconfigPaths(), @@ -544,6 +547,7 @@ export default defineConfig({ __VERSION__: JSON.stringify(NIGHTLY_TEST_VERSION), __TASKLESS_CLI__: JSON.stringify(NIGHTLY_TEST_INVOCATION), __TASKLESS_CLI_NOTICE__: JSON.stringify(NIGHTLY_TEST_NOTICE), + __TASKLESS_BUILD_TARGET__: JSON.stringify("nightly"), }, test: { name: "nightly", include: ["test/nightly/**/*.test.ts"] }, },