Skip to content

fix(cli): name the standalone os create plugin scaffold plugin-NAME and mark it private - #17096

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-15530-standalone-plugin-scaffold-name
Sep 9, 2026
Merged

fix(cli): name the standalone os create plugin scaffold plugin-NAME and mark it private#17096
os-project-manager merged 4 commits into
mainfrom
claude/issue-15530-standalone-plugin-scaffold-name

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #15530

Clause-②: no

os create plugin named the standalone scaffold @objectstack/plugin-NAME — a scope the
developer 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 and
scripts/create-scaffold-smoke.sh were all green on the defect. The cost landed later, at
npm publish, in someone else's terminal — and the emitted README walked them into it by
instructing 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:

Ruled. (1) The standalone default output of os create plugin names the package
plugin-NAME — unscoped, matching the directory the scaffolder prints — and writes
"private": true, the line that actually prevents an accidental publish. (2) The emitted
README's install instruction becomes a local / workspace reference, not
pnpm add @objectstack/plugin-NAME. (3) --in-repo keeps @objectstack/plugin-NAME, where
every sibling really carries that scope. B (placeholder scope) and C (status quo) refused.

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.json would leave it pointing
at 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 chosen
against a constraint rather than by taste. The first attempt was pnpm add link:../plugin-NAME,
and test/init-template-comments-self-contained.test.ts refused it — correctly: the scaffolder
knows 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 --global names no location at
all, and both halves run where the reader already is:

# here — your app loads dist/index.js, so build it first
pnpm install && pnpm build
pnpm link --global

# in your ObjectStack app
pnpm link --global plugin-my-app

The pin, and the proof that it cannot pass on a scaffolder that stopped emitting

test/create.test.ts gains one describe with two its. Each renders both placements in the
same 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 and
git diff HEAD, never by the restore's own exit code:

leg mutation exit failing tests
control none 0 0 of 16
M1 both arms scoped (the pre-fix shape reinstated) 1 2
M2 both arms unscoped (in-repo dragged down) 1 2
M3 private dropped (both arms lack it) 1 1

M1 and M2 are the "identical in either direction" pair. M3 is there because the private flag is
the half that actually prevents the defect — a pin checking only the name would have passed on
M3, and M3 is a manifest an npm publish accepts.

No build/dist leg is owed and that is measured, not assumed: test/create.test.ts imports
../src/commands/create.js, a relative same-package specifier vitest resolves to
src/commands/create.ts. Nothing crosses a package exports boundary, so no dist copy could
keep serving pre-mutation bytes.

--in-repo is unchanged — byte-identity, measured

Not argued from construction. The pre-change create.ts blob was restored into the worktree
(git restore --source=BASE, worktree only), packages/cli rebuilt, and --in-repo scaffolded
through the real CLI; then the tree was restored and the restore proved by hash. Both emissions
compared:

2bdc909876d4baade53cfc076c519bb39b7a04e883ec5ba60d6c9b0753d160a3  ./README.md
217a53a9cd207acda74982d36c4b8fe6d3de5607d685d8f1df5c5cdee956a969  ./package.json
a98397b05aeae0a862bd5c7d9a9fb06b7f1a55c5067f4580f1a9aea832cb98c3  ./src/index.ts
cdf5348bcc53af2ff63554a224a88d648dafdcf841e210e65e1989b7d1c13667  ./tsconfig.json

Identical on both sides; diff -r exit 0 over the whole emitted tree.

Verification

At 3106bdf7aa, working tree clean. Authored by Claude Code, session session_015QE8qk46e5CHJxyQEUjbf8.

  • pnpm --filter @objectstack/cli exec vitest run --project unit189 files / 2626 tests, 0 failed
  • pnpm --filter @objectstack/cli typecheck — green (tsc --noEmit + check:test-typecheck)
  • nightly tier, the layer the two .e2e files 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 merging main)
  • eslint . --no-inline-config — the whole repo-wide union, not a narrowing: 6408 files, 0
    errors, 0 warnings
    , run at 3106bdf7aa on a clean tree
  • gates, each read by redirect-then-$?, never through a pipe — all exit 0:
    check:scaffold-emission-policy (its roster lives under packages/cli/src/commands, so its
    silence 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-comment
  • gate family re-derived from the delivered diff with scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack after merging origin/main (a pre-merge run printed
    STALE TREE; the post-merge run does not, and the derived family list is byte-identical between
    the two). Change set: exactly the 5 paths of this PR.

origin/main was merged through scripts/pm/os-regen-merge.sh; it left no regeneration debt (no
os-regen-pending), which is expected — this diff touches no generated artifact and no
packages/spec path.

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 no at claim time. Re-derived here from the diff rather than inherited, and it holds —
but with one measurement worth a reviewer's eye:

  • Declaration limb — clean. git diff origin/main -- packages/cli/src | grep -c '^+export'
    is 0. An earlier revision of this branch did add one (pluginPackageName); it was made
    module-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.json is file content the CLI writes to a user's disk, not
    a payload on a published surface, and check:published-files is green.
  • Path limb — clean. packages/cli/** plus one .changeset/*.md. Nothing in
    packages/spec/src/**, no error-code ledger, no *.zod.ts.
  • Flagged for the reviewer, not hidden: the composed-name length ceiling moves. os create
    refuses a project name whose composed package name exceeds npm's 214 characters, and the
    standalone prefix shrinks from @objectstack/plugin- (20 chars) to plugin- (7). This is a
    strict 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-repo is unchanged. I read this as a derived
    consequence 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/docs tree object on this branch is cef003a0db446bb07dc7cb4425a906b537411984,
byte-identical to origin/main, so this PR changes no documentation at all):

  1. content/docs/plugins/index.mdx — the "Which artifact this page means" callout says a kernel
    code plugin is "published as @objectstack/plugin-NAME. That is what os create plugin
    scaffolds." True only for --in-repo now.
  2. content/docs/deployment/cli.mdx, the scaffolder routing table — the "Publishable?" cell for
    os create plugin NAME reads "Yes — a publishable @objectstack/plugin-NAME package".
    False in both halves now; the honest answer mirrors the os init -t plugin row directly above
    it, which already reads "No — the emitted package.json is private: true".
  3. content/docs/deployment/cli.mdx, the os create section — "Scaffolds a standalone
    kernel code plugin project (the Plugin contract, built by tsc, publishable; …)". The
    word "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 the
emitted 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):

  • The emitted README's Usage prose calls the argument "the package name" while it is really the
    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-repo README, which the ruling asks to leave unchanged. Carrier if anyone
    revisits: whoever next edits this README template.
  • scripts/create-scaffold-smoke.sh asserts the emitted manifest's dependency specs and
    tsconfig.json, but nothing about its name or private. That is by design — the unit pin
    reads 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

…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>
… 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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 4 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via os create (command, read off packages/cli/src/commands/create.ts))
  • content/docs/getting-started/your-first-project.mdx (via os create (command, read off packages/cli/src/commands/create.ts))
  • content/docs/kernel/contracts/metadata-service.mdx (via packageName (symbol, a top-level const object))
  • content/docs/plugins/index.mdx (via os create (command, read off packages/cli/src/commands/create.ts))
  • content/docs/protocol/kernel/index.mdx (via os create (command, read off packages/cli/src/commands/create.ts))
  • content/docs/protocol/kernel/lifecycle.mdx (via packageName (symbol, a top-level const object))
  • content/docs/protocol/kernel/plugin-spec.mdx (via os create (command, read off packages/cli/src/commands/create.ts))
What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c43bac704819ecb4ae7ecc92b7b3fa2681befe6bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 24bbb72bf4936e17133c6ec8e0e74eba4edc67e2 — the merge of head 3106bdf7aa20129d002e8994e0d214be0394a048 into base c43bac704819ecb4ae7ecc92b7b3fa2681befe6b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c43bac704819ecb4ae7ecc92b7b3fa2681befe6b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os create plugin names the scaffolded package @objectstack/plugin-NAME — a scope the developer it is scaffolded for cannot publish to

2 participants