fix(cli): name the standalone os create plugin scaffold plugin-NAME and mark it private - #17096
Conversation
…me>` and mark it private The default (standalone) emission wrote `"name": "@objectstack/plugin-<name>"` into a project scaffolded for a developer outside this monorepo -- a scope they cannot publish to -- and did not mark the manifest `private`. Nothing here could see it: the name is never resolved from a registry inside the emitted project, so the unit pins, the type-check and `scripts/create-scaffold-smoke.sh` were all green on it, and the cost landed later at `npm publish`, in someone else's terminal. The emitted README compounded it by instructing `pnpm add @objectstack/plugin-<name>` -- a second copy of the same name, which a manifest rename alone would leave pointing at a package that exists under no name at all. Standalone now emits `plugin-<name>` -- unscoped, COMPOSED from the directory name the scaffolder prints, so a template that renames its directory cannot leave a stale package name behind -- plus `"private": true`, which is the half that actually prevents the defect: `npm publish` refuses a private manifest loudly whatever the name says. The README's install instruction becomes a local reference and its import specifier follows the emitted name. `--in-repo` is unchanged and stays publishable as `@objectstack/plugin-<name>`: that placement lands under `packages/plugins/`, where every sibling genuinely carries that scope. The pin renders BOTH placements in one run and closes with an inequality, so a scaffolder that stopped discriminating -- or stopped emitting -- cannot pass it. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
…andalone-plugin-scaffold-name
… not a relative path `test/init-template-comments-self-contained.test.ts` refuses a rendered scaffold file that cites a path climbing out of the project, and it was right to: the scaffolder knows where THIS project landed and knows nothing about where the reader's app is, so `pnpm add link:../plugin-<name>` was a guess about a directory layout it never created -- an unfollowable reference in the same class as the `../../content/docs` link that pin was written for. `pnpm link --global` names no location at all: both halves run where the reader already is. The pin asserts it from both sides -- the registry verb `pnpm add` is absent from the standalone README, the local one is present and names this package -- plus an explicit refusal of any `../`, so the path spelling cannot come back without reddening here first. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
…orts it Every other exported symbol in this file is exported because a test in this package imports it (`emittedPackageName`, `validateEmittedPackageName`, `objectstackDependencySpec`, `rootTsconfigExtends`, `sanitizeIdentifier`, `DEFAULT_PLACEMENT` -- measured, each has one). This one had no importer and must not get one: `test/create.test.ts` pins the two composed names as LITERALS precisely so the pin cannot move together with the function it is pinning. Behaviour is byte-identical; this narrows the module's surface to its readers. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 24bbb72bf4936e17133c6ec8e0e74eba4edc67e2 && git checkout 24bbb72bf4936e17133c6ec8e0e74eba4edc67e2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c43bac704819ecb4ae7ecc92b7b3fa2681befe6b 3106bdf7aa20129d002e8994e0d214be0394a048 && git checkout -B drift-repro c43bac704819ecb4ae7ecc92b7b3fa2681befe6b && git merge --no-ff 3106bdf7aa20129d002e8994e0d214be0394a048
node scripts/docs-audit/affected-docs.mjs --json c43bac704819ecb4ae7ecc92b7b3fa2681befe6b
|
Fixes #15530
Clause-②: no
os create pluginnamed the standalone scaffold@objectstack/plugin-NAME— a scope thedeveloper it is scaffolded for cannot publish to — and did not mark it
private. Under the#14824 ruling the default output is a standalone project in that developer's own directory, so
the name never moved with the audience. Nothing in this repository could see it: the name is
never resolved from a registry inside the emitted project, so
pnpm install, the type-check andscripts/create-scaffold-smoke.shwere all green on the defect. The cost landed later, atnpm publish, in someone else's terminal — and the emitted README walked them into it byinstructing
pnpm add @objectstack/plugin-NAME.What the ruling settled, and what this implements
Executing the director-seat ruling on the card (decision batch #106 item 1, option A), verbatim:
All three product questions were settled by that ruling and none is reopened here: no flag, no
prompt, no fourth option.
The emission, driven through the real CLI
os create plugin my-app(standalone, the default):{ "name": "plugin-my-app", "version": "0.1.0", "private": true, "type": "module", ... }os create plugin my-app --in-repo:{ "name": "@objectstack/plugin-my-app", "version": "0.1.0", ... }The standalone package name is composed from the directory name rather than written a second
time, so a template that renames its directory cannot leave a stale package name behind it —
which is what "matching the directory the scaffolder prints" has to mean if it is to stay true.
The README (ruling item 2)
The README is not downstream of the manifest: it repeats the name three times — title,
install line, import specifier — so a rename reaching only
package.jsonwould leave it pointingat a package that exists under no name at all. All three now read one value.
The install instruction is
pnpm link --global, in two halves, and the spelling was chosenagainst a constraint rather than by taste. The first attempt was
pnpm add link:../plugin-NAME,and
test/init-template-comments-self-contained.test.tsrefused it — correctly: the scaffolderknows where this project landed and knows nothing about where the reader's app is, so any
relative path is a guess about a directory layout it never created, i.e. exactly the
unfollowable-reference class that pin exists to catch.
pnpm link --globalnames no location atall, and both halves run where the reader already is:
The pin, and the proof that it cannot pass on a scaffolder that stopped emitting
test/create.test.tsgains one describe with twoits. Each renders both placements in thesame run, asserts each arm against literal expected strings (never
pluginPackageName,which is the function under test — reading it would move both sides of every comparison
together), and closes with the inequality, so a scaffolder that has stopped discriminating fails
even if every equality above it were relaxed. A missing renderer throws rather than reading as
undefined-and-therefore-fine.Three mutations, each proved on disk before the run (the exact deleted text and the exact
injected marker counted with
grep -c), each restored and the restore proved by blob hash andgit diff HEAD, never by the restore's own exit code:privatedropped (both arms lack it)M1 and M2 are the "identical in either direction" pair. M3 is there because the
privateflag isthe half that actually prevents the defect — a pin checking only the name would have passed on
M3, and M3 is a manifest an
npm publishaccepts.No build/dist leg is owed and that is measured, not assumed:
test/create.test.tsimports../src/commands/create.js, a relative same-package specifier vitest resolves tosrc/commands/create.ts. Nothing crosses a packageexportsboundary, so nodistcopy couldkeep serving pre-mutation bytes.
--in-repois unchanged — byte-identity, measuredNot argued from construction. The pre-change
create.tsblob was restored into the worktree(
git restore --source=BASE, worktree only),packages/clirebuilt, and--in-reposcaffoldedthrough the real CLI; then the tree was restored and the restore proved by hash. Both emissions
compared:
Identical on both sides;
diff -rexit 0 over the whole emitted tree.Verification
At
3106bdf7aa, working tree clean. Authored by Claude Code, sessionsession_015QE8qk46e5CHJxyQEUjbf8.pnpm --filter @objectstack/cli exec vitest run --project unit— 189 files / 2626 tests, 0 failedpnpm --filter @objectstack/cli typecheck— green (tsc --noEmit+check:test-typecheck).e2efiles I touched actually run in:OS_TEST_TIERS=nightly … create-refuses-invalid-project-name.e2e.test.ts scaffold-emission-policy.e2e.test.ts— 2 files / 16 tests passed (this one spawns the real CLI)
pnpm --filter '@objectstack/cli^...' build— green (dependency closure, after mergingmain)eslint . --no-inline-config— the whole repo-wide union, not a narrowing: 6408 files, 0errors, 0 warnings, run at
3106bdf7aaon a clean tree$?, never through a pipe — all exit 0:check:scaffold-emission-policy(its roster lives underpackages/cli/src/commands, so itssilence would not have been evidence in either direction),
check:nul-bytes,check:doc-authoring,check:cross-package-test-inputs,check:test-source-alias,check:cli-test-child-env,check:published-files,check-empty-changeset,check-changeset-no-major,check-adr-0087-registration,check-changeset-fixed,check-closing-keyword-parity,check:changeset-gate-self-tests,docs-audit/check-affected-docs,docs-audit/check-drift-commentscripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackafter mergingorigin/main(a pre-merge run printedSTALE TREE; the post-merge run does not, and the derived family list is byte-identical betweenthe two). Change set: exactly the 5 paths of this PR.
origin/mainwas merged throughscripts/pm/os-regen-merge.sh; it left no regeneration debt (noos-regen-pending), which is expected — this diff touches no generated artifact and nopackages/specpath.Clause-2, re-derived from the delivered diff
The carrier line is at the top of this body; this section is the reasoning behind it.
Declared
noat claim time. Re-derived here from the diff rather than inherited, and it holds —but with one measurement worth a reviewer's eye:
git diff origin/main -- packages/cli/src | grep -c '^+export'is 0. An earlier revision of this branch did add one (
pluginPackageName); it was mademodule-private in its own commit, because every other exported symbol in that file is exported
precisely because a test imports it (measured: all six have importers) and this one had none —
and must not get one, since the pin asserts literals on purpose. No new key on a published
payload either: the emitted
package.jsonis file content the CLI writes to a user's disk, nota payload on a published surface, and
check:published-filesis green.packages/cli/**plus one.changeset/*.md. Nothing inpackages/spec/src/**, no error-code ledger, no*.zod.ts.os createrefuses a project name whose composed package name exceeds npm's 214 characters, and the
standalone prefix shrinks from
@objectstack/plugin-(20 chars) toplugin-(7). This is astrict widening — the composed string only gets shorter, so no name that was accepted can
now be refused; names of roughly 195–207 characters that used to be refused for composed length
are now accepted in the standalone placement.
--in-repois unchanged. I read this as a derivedconsequence rather than a contract accept-set change, which is why the declaration stays
no;if the tier reads it the other way, the label belongs on this PR.
验收备注
Docs falsified by this diff — reported, deliberately NOT edited (docs are outside the declared
surface;
content/docstree object on this branch iscef003a0db446bb07dc7cb4425a906b537411984,byte-identical to
origin/main, so this PR changes no documentation at all):content/docs/plugins/index.mdx— the "Which artifact this page means" callout says a kernelcode plugin is "published as
@objectstack/plugin-NAME. That is whatos create pluginscaffolds." True only for
--in-reponow.content/docs/deployment/cli.mdx, the scaffolder routing table — the "Publishable?" cell foros create plugin NAMEreads "Yes — a publishable@objectstack/plugin-NAMEpackage".False in both halves now; the honest answer mirrors the
os init -t pluginrow directly aboveit, which already reads "No — the emitted
package.jsonisprivate: true".content/docs/deployment/cli.mdx, theos createsection — "Scaffolds a standalonekernel code plugin project (the
Plugincontract, built bytsc, publishable; …)". Theword "publishable" is now false for the default emission.
The docs-affected audit named 7 pages; the other four (
getting-started/your-first-project,kernel/contracts/metadata-service,protocol/kernel/index,protocol/kernel/lifecycle,protocol/kernel/plugin-spec) were read and carry no claim this diff falsifies — they show theemitted file tree and the directory name, both unchanged. A repo-wide sweep for the scope
literal outside those 7 found no further page.
Noted, not filed (neither is a defect class that earns a card):
project name — the identifier is derived from the argument, and the package name has always
carried a prefix on top of it. Pre-existing and equally loose before this change; touching it
would move the
--in-repoREADME, which the ruling asks to leave unchanged. Carrier if anyonerevisits: whoever next edits this README template.
scripts/create-scaffold-smoke.shasserts the emitted manifest's dependency specs andtsconfig.json, but nothing about itsnameorprivate. That is by design — the unit pinreads the renderer and the smoke proves the project installs and builds — and it stays green
here, but it means the smoke alone would not have caught this card's defect. Carrier: none.
Generated by Claude Code