diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6bfd75..c2b2e2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -187,6 +187,17 @@ jobs: echo ">> publishing $name@$ver" npm publish "$dir" --access public --provenance } + # A name being tried for the first time can be refused by npm's + # typosquatting check, which runs only on a real publish — no local + # check predicts it. That refusal must not take the release with it, so + # an optional launcher is attempted last and allowed to fail. + publish_optional() { + if publish "$1"; then + return 0 + fi + echo "::warning::optional launcher $1 was not published; the release stands on the required ones" + } + # The platform packages must land before any launcher that optionally # depends on them: a launcher on the registry ahead of its binaries is a # broken install for whoever hits that window. @@ -196,6 +207,10 @@ jobs: for d in npm/dist/launchers/*/; do publish "${d%/}" done + for d in npm/dist/launchers-optional/*/; do + [ -d "$d" ] || continue + publish_optional "${d%/}" + done # Tag the released commit and create the GitHub Release — only after npm # publishing succeeds, so a failed publish never leaves a dangling tag/release. diff --git a/CLAUDE.md b/CLAUDE.md index 94def68..02bbdd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,7 +133,9 @@ Tests live beside the code and lean on a few package-local helpers rather than a - **A version is immutable.** Re-dispatching an already-released version from a *different* commit is refused, because publishing is idempotent and the run would otherwise go green having shipped nothing. - **Publishing is idempotent.** Already-published packages are skipped, so a run that died after `npm-publish` can be resumed by re-dispatching the same commit. - **Adding a platform touches three places** that must agree: `TARGETS` in `npm/scripts/build-packages.mjs`, `PLATFORMS` in the `Makefile`, and the `build` matrix in `release.yml`. -- **The launcher is published under two names**, listed in `LAUNCHERS` in the same script: `scc-cli` is the documented install, and `@protonspy/scc` stays published so earlier installs keep receiving versions. Both ship the same shim and put the same `scc` command on PATH — npm resolves the package name and installs the `bin` name, and those never had to match. Launchers are emitted under `npm/dist/launchers/` rather than beside the platform packages so that publish order stays structural: `dist/scc-*/` first, `dist/launchers/*/` second. A launcher that reached the registry ahead of the binaries in its `optionalDependencies` is a broken install for anyone in that window. +- **The launcher is published under every name in `LAUNCHERS`**, from one source, in two tiers. A `required` name is already ours — `@protonspy/scc` is the documented install, and a failure there fails the release. A non-required name is one being tried for the first time: attempted last, allowed to fail with a warning. Both tiers are directories (`npm/dist/launchers/`, `npm/dist/launchers-optional/`) so publish order *and* failure policy are visible in the layout, after `dist/scc-*/` — a launcher reaching the registry ahead of the binaries in its `optionalDependencies` is a broken install for anyone in that window. + + The optional tier exists because **npm's typosquatting similarity check runs only on a real publish**: `npm view` returning 404 means unregistered, not publishable, and `npm publish --dry-run` never reaches the check. v0.9.0 died on `403 — Package name too similar to existing package cp-cli` for a name both of those had called free, and because that name published first, `set -e` took the working launcher with it. Corollary, and it is the load-bearing half: **only a required name may appear in documentation**, the embedded `entry.md` included. Promote a name into the docs in the release *after* the one that proved it publishes. - Actions are pinned by commit SHA. Keep them pinned. ## Commits diff --git a/Makefile b/Makefile index 62d5f1e..cdf8266 100644 --- a/Makefile +++ b/Makefile @@ -87,22 +87,29 @@ npm-build: require-version ## Assemble npm/dist/ from the artifacts (VERSION=vX. npm-dry-run: ## Dry-run publish every assembled package @set -euo pipefail; \ if [ ! -f npm/dist/launchers/$(BIN)/package.json ]; then echo "npm/dist not assembled — run: make npm-build VERSION=vX.Y.Z first" >&2; exit 1; fi; \ - for d in npm/dist/$(BIN)-*/ npm/dist/launchers/*/; do \ + for d in npm/dist/$(BIN)-*/ npm/dist/launchers/*/ npm/dist/launchers-optional/*/; do \ [ -f "$$d/package.json" ] || continue; \ echo "== $$d"; npm publish "$$d" --access public --dry-run; done + @echo "note: --dry-run does not exercise npm's name-similarity check; only a real publish does" .PHONY: npm-publish npm-publish: ## Publish the assembled packages, skips already-published; OTP=123456 if 2FA @set -euo pipefail; \ if [ ! -f npm/dist/launchers/$(BIN)/package.json ]; then echo "npm/dist not assembled — run: make dist VERSION=vX.Y.Z && make npm-build VERSION=vX.Y.Z" >&2; exit 1; fi; \ otp=; if [ -n "$(OTP)" ]; then otp="--otp=$(OTP)"; fi; \ - for d in npm/dist/$(BIN)-*/ npm/dist/launchers/*/; do \ + for d in npm/dist/$(BIN)-*/ npm/dist/launchers/*/ npm/dist/launchers-optional/*/; do \ [ -f "$$d/package.json" ] || continue; \ name=$$(cd "$$d" && node -p "require('./package.json').name"); \ ver=$$(cd "$$d" && node -p "require('./package.json').version"); \ if npm view "$$name@$$ver" version >/dev/null 2>&1; then \ echo "skip $$name@$$ver (already published)"; continue; fi; \ - echo ">> publishing $$name@$$ver"; npm publish "$$d" --access public $$otp; \ + echo ">> publishing $$name@$$ver"; \ + case "$$d" in \ + npm/dist/launchers-optional/*) \ + npm publish "$$d" --access public $$otp \ + || echo "!! optional launcher $$name was not published; the release stands on the required ones" ;; \ + *) npm publish "$$d" --access public $$otp ;; \ + esac; \ done .PHONY: release diff --git a/README.md b/README.md index 04a109c..8e3cb91 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,16 @@ AI agents. No install — run it straight from npm inside the repo you want to govern: ```bash -npx scc-cli init # asks which harness, then scaffolds the rules, agents, and layout -npx scc-cli init --codex # or name it: --claude (default), --codex, --opencode -npx scc-cli spec new user-auth # specs/user-auth/: requirements.md, design.md, tasks.md -npx scc-cli plan new checkout-revamp # plans/checkout-revamp.md -npx scc-cli validate # every check; exit 2 means it found something -npx scc-cli update # show what a newer scc would change, then confirm +npx @protonspy/scc init # asks which harness, then scaffolds the rules, agents, and layout +npx @protonspy/scc init --codex # or name it: --claude (default), --codex, --opencode +npx @protonspy/scc spec new user-auth # specs/user-auth/: requirements.md, design.md, tasks.md +npx @protonspy/scc plan new checkout-revamp # plans/checkout-revamp.md +npx @protonspy/scc validate # every check; exit 2 means it found something +npx @protonspy/scc update # show what a newer scc would change, then confirm ``` -Installed globally (`npm i -g scc-cli`) the same commands are just `scc init`, -`scc spec new user-auth`, and so on — the package is `scc-cli`, the command is `scc`. +Installed globally (`npm i -g @protonspy/scc`) the same commands are just `scc init`, +`scc spec new user-auth`, and so on. | Command | What it does | |---|---| @@ -43,9 +43,9 @@ installs it with cargo when it is not on PATH, and puts its usage block into `CLAUDE.md`/`AGENTS.md` so the agent knows to prefix commands with it: ```bash -npx scc-cli init --rtk # scaffold, then wire RTK in -npx scc-cli rtk # wire it into a workspace that already exists -npx scc-cli rtk --check # CI: exit 2 when the block is missing +npx @protonspy/scc init --rtk # scaffold, then wire RTK in +npx @protonspy/scc rtk # wire it into a workspace that already exists +npx @protonspy/scc rtk --check # CI: exit 2 when the block is missing ``` The block sits between RTK's own `` markers, and scc inserts @@ -89,21 +89,19 @@ accountability, and a checker that was confidently incomplete would be worse tha ## Install -Published on npm as [`scc-cli`](https://www.npmjs.com/package/scc-cli) — the launcher -pulls the right prebuilt binary for your platform as an optional dependency, so there -is no toolchain to set up. +Published on npm as [`@protonspy/scc`](https://www.npmjs.com/package/@protonspy/scc) — +the launcher pulls the right prebuilt binary for your platform as an optional +dependency, so there is no toolchain to set up. ```bash -npx scc-cli help # no install; pins nothing, always the latest -npx scc-cli@0.0.1 help # pin a version (CI) -npm i -g scc-cli # then: scc help +npx @protonspy/scc help # no install; pins nothing, always the latest +npx @protonspy/scc@0.0.1 help # pin a version (CI) +npm i -g @protonspy/scc # then: scc help ``` -The package is `scc-cli`; the command it installs is `scc`. Without `-g` it lands in -`node_modules/.bin`, which npm scripts see and your shell does not — reach it there as -`npx scc`. The same package is also published as `@protonspy/scc` for installs that -predate the shorter name; use one or the other, not both, since they claim the same -command. +The package is `@protonspy/scc`; the command it installs is `scc`. Without `-g` it +lands in `node_modules/.bin`, which npm scripts see and your shell does not — reach it +there as `npx scc`. Or from source (Go 1.25+): diff --git a/internal/assets/assets.go b/internal/assets/assets.go index fdf59e1..1bcae63 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -67,7 +67,7 @@ import ( // 8: the entry file names *when* to read each rule instead of tabulating all nine // as equals — five read on their own trigger, four looked up by name — and the two // review agents are tightened in the same pass. -// 9: the npx fallback names the unscoped `scc-cli` package; the entry file gives +// 9: the entry file gives // each rule its own trigger line instead of running four of them together in a // sentence — project.md above all, since a build command nobody read is guessed — // and it stops telling a harness that preloads the rules to go and read them. diff --git a/internal/assets/templates/entry.md b/internal/assets/templates/entry.md index a316c0d..2a91a9a 100644 --- a/internal/assets/templates/entry.md +++ b/internal/assets/templates/entry.md @@ -49,7 +49,7 @@ docs/ knowledge base — wiki, adr, codewiki, glossary, stack ## Checking your work -`scc validate` — or `npx scc-cli validate` if not installed (`@` pins for CI). +`scc validate` — or `npx @protonspy/scc validate` if not installed (`@` pins for CI). `scc update` brings a newer scc's rules and agents in: it shows the plan, then asks. Exit `0` ok · `1` could not run · `2` ran and found something. A finding is an answer, not a crash. diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 28b000f..5aa09ef 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -132,14 +132,14 @@ func TestUnknownFlagIsAUsageError(t *testing.T) { } // SCC_PROG lets the npm launcher make help text echo the spelling the user typed -// (`npx scc-cli`) instead of a bare binary name they may not have. +// (`npx @protonspy/scc`) instead of a bare binary name they may not have. func TestProgHonorsEnvOverride(t *testing.T) { - t.Setenv("SCC_PROG", "npx scc-cli") - if got := prog(); got != "npx scc-cli" { + t.Setenv("SCC_PROG", "npx @protonspy/scc") + if got := prog(); got != "npx @protonspy/scc" { t.Errorf("prog() = %q, want the override", got) } _, stderr, _ := run(t, "help") - if !strings.Contains(stderr, "npx scc-cli") { + if !strings.Contains(stderr, "npx @protonspy/scc") { t.Errorf("usage did not use SCC_PROG: %q", stderr) } } diff --git a/npm/README.md b/npm/README.md index 0fcf3d5..d063bcf 100644 --- a/npm/README.md +++ b/npm/README.md @@ -9,7 +9,8 @@ How `scc` reaches npm. Nothing here is built from source at install time. | `scc/` | The launcher source: a Node shim, no binary. Its `package.json` is a template — `name`, `version`, and `optionalDependencies` are all generated. | | `scripts/build-packages.mjs` | Assembles `npm/dist/` from the release artifacts. | | `dist/scc-/` | Generated. One per target, carrying the native binary. | -| `dist/launchers//` | Generated. The same shim, once per published launcher name. | +| `dist/launchers//` | Generated. The same shim, under a name that is already ours. | +| `dist/launchers-optional//` | Generated. The same shim, under a name being tried — allowed to fail. | ## How the install works @@ -21,27 +22,43 @@ matching the host, and `bin/scc.js` resolves that package's binary and execs it. No postinstall script and no network access at install time — the binary is already there or the install failed. -## Two launcher names - -The launcher is published twice, from one source, listed in `LAUNCHERS` in -`build-packages.mjs`: - -| Name | Role | -|---|---| -| `scc-cli` | The documented install. Unscoped, so `npm i -g scc-cli` is the whole line. | -| `@protonspy/scc` | Kept published so earlier installs keep receiving versions. | - -Both put the same `scc` command on PATH: npm resolves the *package* name and -puts the *bin* name on PATH, and those never had to match. The bare `scc` on npm -has belonged to an unrelated project since 2013, which is why neither name is it. - -Installing both globally is the one thing to avoid — they compete for the same -command name, and npm resolves that by letting the last one win. - -The split is also why launchers live under `dist/launchers/` instead of beside -the platform packages: publishing walks `dist/scc-*/` first and `dist/launchers/*/` -second, and a launcher that reached the registry ahead of the binaries it depends -on would be a broken install for anyone who hit that window. +## Launcher names, required and optional + +The launcher is published from one source under every name in `LAUNCHERS` in +`build-packages.mjs`. Each name is `required` or not, and that flag is the whole +difference: + +| Tier | Meaning | On failure | +|---|---|---| +| `required: true` | The name is already ours. `@protonspy/scc` is the documented install. | The release fails. | +| `required: false` | A name being tried for the first time. | Warn and carry on. | + +All of them put the same `scc` command on PATH: npm resolves the *package* name +and installs the *bin* name, and those never had to match. The bare `scc` on npm +has belonged to an unrelated project since 2013, which is why no name here is it. + +**Why the optional tier exists.** npm applies a typosquatting similarity check +that runs *only on a real publish*. `npm view ` returning 404 says the name +is unregistered, not that it is publishable, and `npm publish --dry-run` never +reaches the check either. v0.9.0 died on `403 — Package name too similar to +existing package cp-cli` for a name both of those had called free; because that +name published first, `set -e` took the working launcher down with it and left +six orphaned platform packages on the registry. + +So an unproven name is attempted last and allowed to fail, and **only a required +name may appear in documentation** — including the `entry.md` embedded in six +binaries. Pointing an install line at a package that might be refused is the same +bug somewhere more expensive. Promote a name to `required`, and into the docs, in +the release *after* the one that proved it publishes. + +Installing two launchers globally is the one thing to avoid: they compete for the +same command name, and npm resolves that by letting the last one win. + +The tiers are also directories, so publish order and failure policy are both +visible in the layout: `dist/scc-*/`, then `dist/launchers/`, then +`dist/launchers-optional/`. A launcher that reached the registry ahead of the +binaries in its `optionalDependencies` would be a broken install for anyone who +hit that window. ## Releasing diff --git a/npm/scc/README.md b/npm/scc/README.md index 13b316d..9352517 100644 --- a/npm/scc/README.md +++ b/npm/scc/README.md @@ -5,20 +5,14 @@ workflow into a mechanically validated contract for humans and AI agents. Works with Claude Code, Codex, and opencode. ```bash -npm i -g scc-cli # then, anywhere: scc help -npx scc-cli help # no install +npm i -g @protonspy/scc # then, anywhere: scc help +npx @protonspy/scc help # no install ``` The command is `scc` either way. Installing without `-g` puts it in `node_modules/.bin`, which is on PATH for npm scripts but not for your shell — there, reach it as `npx scc`. -> Published under two names: **`scc-cli`**, which is the one to use, and -> `@protonspy/scc`, kept so earlier installs keep receiving versions. Same -> package, same `scc` command — install one, not both, since they compete for -> the same command name. The bare `scc` on npm belongs to an unrelated project -> from 2013. - This package is a thin launcher. The native binary ships in a per-platform optional dependency (`@protonspy/scc-linux-x64`, `…-darwin-arm64`, …); npm installs only the one matching your machine. There is no postinstall step and no diff --git a/npm/scc/bin/scc.js b/npm/scc/bin/scc.js index f4a1076..87395e3 100644 --- a/npm/scc/bin/scc.js +++ b/npm/scc/bin/scc.js @@ -39,16 +39,16 @@ try { process.exit(1); } -// When invoked via `npx scc-cli` (which is `npm exec` under the hood), echo that -// exact spelling in the binary's help/usage output. A global install runs this -// same launcher as the bare `scc` command — there npm is not in the picture -// (npm_command is unset), so the binary keeps its default name. An explicit -// SCC_PROG always wins. +// When invoked via `npx @protonspy/scc` (which is `npm exec` under the hood), +// echo that exact spelling in the binary's help/usage output. A global install +// runs this same launcher as the bare `scc` command — there npm is not in the +// picture (npm_command is unset), so the binary keeps its default name. An +// explicit SCC_PROG always wins. // // The name is read from this package's own package.json rather than written in, -// because one shim is published under two names (`scc-cli` and `@protonspy/scc`) -// and a hardcoded spelling would be wrong for whichever one the user did not -// type — telling them to re-run a command under a package they never installed. +// because one shim is published under more than one name and a hardcoded +// spelling would be wrong for whoever installed under the other — telling them +// to re-run a command under a package they never installed. const env = { ...process.env }; if (!env.SCC_PROG) { const argv1 = process.argv[1] || ""; @@ -57,7 +57,7 @@ if (!env.SCC_PROG) { argv1.includes("/_npx/") || argv1.includes("\\_npx\\"); if (viaNpx) { - let self = "scc-cli"; + let self = "@protonspy/scc"; try { self = require("../package.json").name || self; } catch { diff --git a/npm/scripts/build-packages.mjs b/npm/scripts/build-packages.mjs index 24cd577..1b0bdd4 100644 --- a/npm/scripts/build-packages.mjs +++ b/npm/scripts/build-packages.mjs @@ -10,11 +10,14 @@ // (default: "artifacts"). // // Output: npm/dist/ -// scc--/ one per target, carrying the native binary -// launchers// the shim, published under each launcher name +// scc--/ one per target, carrying the native binary +// launchers// the shim, under a name that is already ours +// launchers-optional// the same shim, under a name being tried // -// Publish order is the layout: every scc-*/ package must reach the registry -// before any launchers/ package that optionally depends on it. +// Publish order is the layout, and so is the failure policy: every scc-*/ +// package must reach the registry before any launcher that optionally depends +// on it, and only launchers-optional/ is allowed to fail without failing the +// release. See the LAUNCHERS comment below for why that tier exists. // // The Go binaries are reused as-is from the release artifacts (they already // carry the version baked in via -ldflags), so the npm binary is byte-identical @@ -129,31 +132,46 @@ for (const t of TARGETS) { } // --- launcher packages ----------------------------------------------------- -// Two names, one package. `scc-cli` is the documented install line; the scoped -// `@protonspy/scc` stays published so anybody who already installed it keeps -// receiving versions. Both carry the same shim, the same optionalDependencies, -// and the same `bin` — so both put the same `scc` command on PATH. The package -// name is what npm resolves and the bin name is what you type, and those were -// never required to match: the bare `scc` on npm has been taken since 2013. +// One package, published under more than one name. Both carry the same shim, the +// same optionalDependencies and the same `bin`, so both put the same `scc` +// command on PATH: npm resolves the *package* name and installs the *bin* name, +// and those were never required to match. The bare `scc` on npm has belonged to +// an unrelated project since 2013, which is why neither name is it. // -// They are emitted under launchers/ rather than beside the platform packages, -// and that is load-bearing rather than tidy: the publish order is expressed as -// "everything in dist/scc-*/ first, then everything in dist/launchers/", and a -// second launcher sitting at the top level would be swept into the platform -// glob and published ahead of the binaries it optionally depends on. Anyone -// installing during that window gets a launcher that cannot resolve a binary. +// The split into required/ and optional/ is what a failed release taught us. +// npm applies a typosquatting similarity check that runs only on a real publish +// — `npm view` returning 404 and `npm publish --dry-run` passing both say +// nothing about it, and v0.9.0 died on a 403 for a name both had called free. +// The name that was rejected happened to publish first, so `set -e` took the +// working launcher down with it and shipped six orphaned platform packages. +// +// So: a required launcher is one whose name is already ours, and a failure there +// is a real failure. An optional launcher is a name being tried for the first +// time; the publish step is allowed to skip it and carry on, because a registry +// refusing a name is not a reason to abandon a release whose binaries are built +// and whose primary launcher works. +// +// Only a required name may be documented. Pointing an install line — or the +// `entry.md` embedded in six binaries — at a package that might be refused is +// the same bug in a costlier place. Promote a name to required, and into the +// docs, in the release *after* the one that proved it publishes. const LAUNCHERS = [ - { dir: "scc-cli", name: "scc-cli" }, - { dir: BIN, name: `${SCOPE}/${BIN}` }, + { dir: "scc", name: `${SCOPE}/${BIN}`, required: true }, + { dir: "spec-claude-code-cli", name: "spec-claude-code-cli", required: false }, ]; +// Where each tier lands. Publishing walks dist/scc-*/ first, then required, then +// optional — the order is the layout, so a launcher can never reach the registry +// ahead of the binaries its optionalDependencies name. +const tier = (l) => (l.required ? "launchers" : "launchers-optional"); + const launcherSrc = join(npmDir, BIN); // The checked-in package.json is a template: its name and optionalDependencies // are both placeholders, generated here so that neither a new platform nor a new // launcher name can ever be half-wired. const launcherPkg = JSON.parse(readFileSync(join(launcherSrc, "package.json"), "utf8")); for (const launcher of LAUNCHERS) { - const out = join(outDir, "launchers", launcher.dir); + const out = join(outDir, tier(launcher), launcher.dir); mkdirSync(join(out, "bin"), { recursive: true }); cpSync(join(launcherSrc, "bin", `${BIN}.js`), join(out, "bin", `${BIN}.js`)); cpSync(join(launcherSrc, "README.md"), join(out, "README.md")); @@ -166,5 +184,5 @@ for (const launcher of LAUNCHERS) { 2 ) + "\n" ); - console.log(`built ${launcher.name}@${version}`); + console.log(`built ${launcher.name}@${version}${launcher.required ? "" : " (optional)"}`); }