From 64e3bc6eff8c8828b40d0aed9cf486449a9d5efe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 06:35:30 +0000 Subject: [PATCH 1/3] fix(cli): name the standalone `os create plugin` scaffold `plugin-` and mark it private The default (standalone) emission wrote `"name": "@objectstack/plugin-"` 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-` -- 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-` -- 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-`: 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 --- ...dalone-plugin-scaffold-unscoped-private.md | 30 ++++ packages/cli/src/commands/create.ts | 161 +++++++++++++++--- .../create-plugin-identifier-parses.test.ts | 20 ++- ...e-refuses-invalid-project-name.e2e.test.ts | 17 +- packages/cli/test/create.test.ts | 109 ++++++++++++ 5 files changed, 300 insertions(+), 37 deletions(-) create mode 100644 .changeset/standalone-plugin-scaffold-unscoped-private.md diff --git a/.changeset/standalone-plugin-scaffold-unscoped-private.md b/.changeset/standalone-plugin-scaffold-unscoped-private.md new file mode 100644 index 0000000000..5792287e88 --- /dev/null +++ b/.changeset/standalone-plugin-scaffold-unscoped-private.md @@ -0,0 +1,30 @@ +--- +'@objectstack/cli': patch +--- + +`os create plugin` names the standalone scaffold `plugin-` and marks it `private` + +The default (standalone) emission wrote `"name": "@objectstack/plugin-"` into a +project scaffolded for a developer outside this monorepo — a scope they cannot publish +to — and did not mark the manifest `private`. Nothing failed at scaffold time: the name is +never resolved from a registry inside the project, so `pnpm install`, the type-check and +the scaffold smoke were all green on it, and the cost landed later at `npm publish`. The +emitted README compounded it by instructing `pnpm add @objectstack/plugin-`. + +The standalone default now emits: + +- `"name": "plugin-"` — unscoped, and the same string as the directory the + scaffolder prints and creates; +- `"private": true` — the line that actually stops an accidental publish, whatever the + name says; +- a README whose install instruction is a local reference (`pnpm add link:../plugin-`) + and whose import specifier matches the emitted package name. + +`os create plugin --in-repo` is unchanged: it still emits a publishable +`@objectstack/plugin-` with no `private` flag, because that placement lands under +`packages/plugins/` where every sibling genuinely carries that scope. + +No action is needed for a project already scaffolded. If you generated one with the old +name and have not published it, rename `package.json`'s `name` to `plugin-` (or a +scope you own) and update the README's install line; the exported symbol and the plugin's +runtime `name` are unaffected. diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 3b323dad84..92bde08dd8 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -68,13 +68,27 @@ * semver range pinned to the running CLI's own * version, the `tsconfig.json` is self-contained, * a `pnpm-workspace.yaml` carries the build - * approvals a fresh `pnpm install` needs, and the - * project lands in the developer's own directory. + * approvals a fresh `pnpm install` needs, the + * package is named `plugin-` and marked + * `private`, and the project lands in the + * developer's own directory. * `in-repo` (--in-repo) the platform-work shape: `workspace:*` deps, a * `tsconfig.json` that extends this repo's root - * config, landing under `packages/plugins/`. - * Explicit and documented, never the default — its - * output installs nowhere else. + * config, a publishable `@objectstack/plugin-` + * landing under `packages/plugins/`. Explicit and + * documented, never the default — its output + * installs nowhere else. + * + * ## The emitted package NAME follows the placement too (#15530) + * + * The audience decides the name, and #14824 moved the audience without moving + * the name: the standalone default kept stamping `@objectstack/plugin-` + * — a scope the developer it now scaffolds for cannot publish to — onto every + * project, with the emitted README telling them to install it from there. The + * rule and the reason live on {@link pluginPackageName}; the README is the + * SECOND site that repeats the name and is fixed in the same place, because a + * rename that reaches only the manifest leaves the README pointing at a package + * that exists under no name at all. * * ## The version the standalone shape pins * @@ -192,14 +206,20 @@ function defineTemplate(t: Omit): CreateTemplate { } /** - * The scoped package name a scaffold is about to write, READ BACK off the - * rendered manifest rather than recomposed here. + * The package name a scaffold is about to write, READ BACK off the rendered + * manifest rather than recomposed here. + * + * Recomposing it would be a second copy of the composition that nothing keeps + * in step with the renderer — the same restatement that let this command's + * emitted name drift away from what `os init` enforces. Reading the rendered + * object measures the string that actually lands on disk, and a template added + * later is covered without being told to declare anything. * - * Recomposing it would be a second copy of `@objectstack/plugin-${name}` that - * nothing keeps in step with the renderer — the same restatement that let this - * command's emitted name drift away from what `os init` enforces. Reading the - * rendered object measures the string that actually lands on disk, and a - * template added later is covered without being told to declare anything. + * ⭐ Load-bearing since #15530, not merely tidy: the composition is no longer + * ONE string. The standalone placement emits an unscoped `plugin-` and + * `--in-repo` a scoped `@objectstack/plugin-`, so a recomposition here + * would have to know the placement rule too — and would be judging the wrong + * length for one of the two placements the moment the rule moved. * * `null` when the template emits no `package.json`, or emits one without a * string `name`: there is then no package name to judge, which is not the same @@ -220,12 +240,17 @@ export function emittedPackageName( * The one rule `os create` needs and `os init` cannot. * * `init`'s argument IS the package name, so measuring the argument is the same - * measurement. `create` composes its argument into a SCOPED name, and npm's - * 214-character ceiling counts the scope: `@objectstack/plugin-` spends 20 of - * them before the user's first character. A 200-character name is therefore - * legal for `init` (measured: accepted) and illegal for `create` (measured: - * emits a 220-character name npm refuses) — which is why the shared validator - * is shared and this check is not. + * measurement. `create` COMPOSES its argument into a longer name, and npm's + * 214-character ceiling counts every character of the composition — the + * `plugin-` prefix the standalone placement writes (7), or the whole + * `@objectstack/plugin-` the in-repo placement writes (20), before the user's + * first character. A 214-character name is therefore legal for `init` + * (measured: accepted) and illegal for `create` in EITHER placement — which is + * why the shared validator is shared and this check is not. + * + * ⛔ Never re-derive the prefix length here: the caller hands in the string + * `emittedPackageName` read back off the rendered manifest, so this measures + * the bytes that would land whichever placement produced them. */ export function validateEmittedPackageName(packageName: string): string | null { const over = packageName.length - NPM_PACKAGE_NAME_MAX_LENGTH; @@ -284,17 +309,70 @@ export function sanitizeIdentifier(name: string): string { const PLUGIN_IN_REPO_DIR = 'packages/plugins'; +/** The project directory the `plugin` template lands in, in either placement. */ +function pluginDirName(name: string): string { + return `plugin-${name}`; +} + +/** + * The package name the `plugin` template writes — DERIVED from the placement, + * exactly as its dependency specs and its `tsconfig.json` already are. + * + * ## Why the standalone name is unscoped + * + * `@objectstack` is a scope the developer this command scaffolds FOR cannot + * publish to. Until #14824 that was arguably fine, because the default output + * landed inside this monorepo, where every sibling really does carry the scope. + * That ruling pointed the default at the developer's own directory and the name + * did not move with the audience — so the standalone emission stamped a scope + * its owner does not own onto every project generated from it. ⚠️ Nothing in + * this repository can see that: the name is never resolved from a registry + * inside the project, so `pnpm install`, the type-check and the scaffold smoke + * are all green on it. The cost is paid once, later, at `npm publish`, in + * someone else's terminal. + * + * The #15530 ruling is that the standalone default emits `plugin-` — + * unscoped, and the same string as {@link pluginDirName}, which is what the + * scaffolder prints and what the developer already sees on disk. ⛔ Those are + * not two spellings of one convention: the package name is COMPOSED from the + * directory name here, so a template that renames its directory cannot leave a + * stale package name behind it. + * + * ⭐ The name is the readable half. `"private": true` — emitted beside it, for + * the standalone placement only — is the STRUCTURAL half, and the one that + * actually prevents the defect: `npm publish` refuses a private manifest + * loudly, whatever the name says. A later change that keeps this name and drops + * that flag reinstates the defect with better prose. + * + * `--in-repo` keeps `@objectstack/plugin-` and stays publishable: that + * placement lands under `packages/plugins/`, where every sibling genuinely + * carries that scope and whoever runs it genuinely can publish there. + */ +export function pluginPackageName(placement: ScaffoldPlacement, name: string): string { + return placement === 'in-repo' + ? `@objectstack/${pluginDirName(name)}` + : pluginDirName(name); +} + export const templates: Record = { plugin: defineTemplate({ description: 'Create a new kernel code plugin (TypeScript implementing the kernel Plugin contract)', inRepoDir: PLUGIN_IN_REPO_DIR, - dirName: (name: string) => `plugin-${name}`, + dirName: pluginDirName, filesFor: (placement: ScaffoldPlacement) => { const standalone = placement === 'standalone'; const files: Record = { 'package.json': (name: string) => ({ - name: `@objectstack/plugin-${name}`, + name: pluginPackageName(placement, name), version: '0.1.0', + // ⛔ Standalone only, and ⛔ never dropped as "just a default the + // developer will change": this is the line that makes an accidental + // `npm publish` fail loudly instead of landing a package in a + // namespace its author does not own. The unscoped name above is the + // readable half; this is the enforcing one. The in-repo placement + // omits it because `packages/plugins/*` really is published from here + // — see {@link pluginPackageName}. + ...(standalone ? { private: true } : {}), description: `ObjectStack Plugin: ${name}`, // `tsc` emits ES modules under the compiler options below, so the // manifest has to declare the project as ESM or Node refuses the @@ -332,7 +410,7 @@ export const templates: Record = { include: SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY, }) : { - extends: rootTsconfigExtends(PLUGIN_IN_REPO_DIR, `plugin-${name}`), + extends: rootTsconfigExtends(PLUGIN_IN_REPO_DIR, pluginDirName(name)), compilerOptions: { outDir: 'dist', rootDir: 'src', @@ -361,15 +439,43 @@ export const ${sanitizeIdentifier(name)}Plugin: Plugin = { export default ${sanitizeIdentifier(name)}Plugin; `, - 'README.md': (name: string) => `# @objectstack/plugin-${name} + 'README.md': (name: string) => { + const packageName = pluginPackageName(placement, name); + // ⛔ The README is not downstream of the manifest rename — it REPEATS + // the name, at the title, at the install line and at the import + // specifier. Renaming the manifest alone would leave this file + // telling a developer to `pnpm add` a package that now exists under + // no name at all, which is the same defect one layer out. All three + // sites read `packageName` for that reason. + // + // The install instruction itself is placement-dependent (#15530): + // the standalone project is `private` and unpublished, so a registry + // install is not something its reader can run — the only instruction + // that WORKS from a freshly scaffolded directory is a local + // reference. The in-repo project is a real workspace sibling under a + // scope this repo publishes, so it keeps the install it always had. + const install = standalone + ? `This project is \`private\` and carries no npm scope, so there is nothing to +install from a registry — and \`npm publish\` refuses it until you give it a name +you own and drop that flag. Reference it from your app by path in the meantime: + +\`\`\`bash +# here — your app loads dist/index.js, so build it first +pnpm install && pnpm build + +# in your app, when it sits next to this directory +pnpm add link:../${pluginDirName(name)} +\`\`\`` + : `\`\`\`bash +pnpm add ${packageName} +\`\`\``; + return `# ${packageName} ObjectStack Plugin: ${name} ## Installation -\`\`\`bash -pnpm add @objectstack/plugin-${name} -\`\`\` +${install} ## Usage @@ -380,7 +486,7 @@ hyphen, an underscore, a leading digit) are folded away, so the exported symbol can differ from the name. \`\`\`typescript -import { ${sanitizeIdentifier(name)}Plugin } from '@objectstack/plugin-${name}'; +import { ${sanitizeIdentifier(name)}Plugin } from '${packageName}'; // Use the plugin in your ObjectStack configuration export default { @@ -393,7 +499,8 @@ export default { ## License MIT -`, +`; + }, }; // pnpm does not run dependency build scripts unless they are approved in diff --git a/packages/cli/test/create-plugin-identifier-parses.test.ts b/packages/cli/test/create-plugin-identifier-parses.test.ts index d7a652f64e..7e35da4a1f 100644 --- a/packages/cli/test/create-plugin-identifier-parses.test.ts +++ b/packages/cli/test/create-plugin-identifier-parses.test.ts @@ -41,9 +41,15 @@ * * ## What this pin deliberately does not touch * - * The emitted package name, its scope and the emitted directory name are the - * user's string byte-for-byte (#15530 / #15816) — asserted below, so a future - * edit that "fixes" the name instead of the identifier reddens here. + * The USER'S STRING survives byte-for-byte into the emitted package name and + * the emitted directory name (#15816) — asserted below, so a future edit that + * "fixes" the name instead of the identifier reddens here. + * + * ⚠️ What the package name is COMPOSED of is a different question, and it moved + * under #15530: the standalone default is now an unscoped `plugin-` and + * only `--in-repo` keeps `@objectstack/plugin-`. That rule is pinned in + * `create.test.ts`; this file asserts only that whatever the composition is, it + * carries the typed name through unaltered. */ import { describe, expect, it } from 'vitest'; @@ -151,7 +157,9 @@ describe('`os create plugin ` emits a parseable identifier', () => { diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' ')), name, ).toEqual([]); - expect(readme).toContain(`import { ${identifier}Plugin } from '@objectstack/plugin-${name}';`); + // The DEFAULT placement is standalone, whose package — and therefore whose + // import specifier — is unscoped since #15530. + expect(readme).toContain(`import { ${identifier}Plugin } from 'plugin-${name}';`); }); it.each(CASES)('names the derived identifier in the README prose for $name', ({ name, identifier }) => { @@ -182,7 +190,9 @@ describe('`os create plugin ` emits a parseable identifier', () => { it.each(CASES)('leaves the emitted package name and directory as typed for $name', ({ name }) => { const manifest = JSON.parse(emit('package.json', name, DEFAULT_PLACEMENT)) as { name: string }; - expect(manifest.name).toBe(`@objectstack/plugin-${name}`); + // Unscoped under the DEFAULT placement (#15530) — but still the user's + // string, unaltered, which is the property this file is about. + expect(manifest.name).toBe(`plugin-${name}`); expect(templates.plugin.dirName(name)).toBe(`plugin-${name}`); }); diff --git a/packages/cli/test/create-refuses-invalid-project-name.e2e.test.ts b/packages/cli/test/create-refuses-invalid-project-name.e2e.test.ts index dba7d96ca8..2ea4749bdd 100644 --- a/packages/cli/test/create-refuses-invalid-project-name.e2e.test.ts +++ b/packages/cli/test/create-refuses-invalid-project-name.e2e.test.ts @@ -31,10 +31,14 @@ * * ## Why the two commands do NOT refuse identically * - * `os init`'s argument IS the package name. `os create`'s argument is composed - * into a SCOPED one (`@objectstack/plugin-`), and npm's 214-character - * ceiling counts the scope — so a name that is legal for `init` can compose to - * one npm refuses. `refuses a name only the composed length catches` pins that + * `os init`'s argument IS the package name. `os create`'s argument is COMPOSED + * into a longer one — `plugin-` standalone, `@objectstack/plugin-` + * for `--in-repo` since #15530 — and npm's 214-character ceiling counts every + * character of the composition, so a name that is legal for `init` can compose + * to one npm refuses in either placement. The population below is derived from + * the placement list for exactly that reason, and the composed-length case is + * built from the constant rather than from an assumed prefix width. + * `refuses a name only the composed length catches` pins that * asymmetry from both ends: the shared validator passes the name (asserted * directly), `create` refuses it, and `init` still accepts it. ⛔ Moving that * length rule into the shared validator would break `init` for a name npm @@ -213,6 +217,9 @@ describe('os create: the composed package name is judged for every template', () it('reads the name off the DEFAULT placement the same way the command does', () => { const emitted = emittedPackageName(templates.plugin, DEFAULT_PLACEMENT, VALID_NAME); - expect(emitted).toBe(`@objectstack/plugin-${VALID_NAME}`); + // The default is standalone, which emits unscoped since #15530. What the + // composition IS is pinned in `create.test.ts`; what this asserts is that + // the length check reads the same string the command would write. + expect(emitted).toBe(`plugin-${VALID_NAME}`); }); }); diff --git a/packages/cli/test/create.test.ts b/packages/cli/test/create.test.ts index 5da1d6a321..3c8082d692 100644 --- a/packages/cli/test/create.test.ts +++ b/packages/cli/test/create.test.ts @@ -44,6 +44,7 @@ import { describe, it, expect } from 'vitest'; import { RETIRED_TEMPLATES, templates, + emittedPackageName, objectstackDependencySpec, rootTsconfigExtends, DEFAULT_PLACEMENT, @@ -207,6 +208,114 @@ describe.each(TEMPLATE_KEYS)('os create %s --in-repo — the platform-work emiss }); }); +/** + * PIN (#15530) — the emitted package NAME follows the placement, and the + * standalone half carries the flag that enforces it. + * + * ## The defect + * + * #14824 pointed the default emission at a developer outside this monorepo and + * the name did not move with the audience: `os create plugin my-thing` kept + * writing `"name": "@objectstack/plugin-my-thing"` — a scope its new owner + * cannot publish to — and no `private` flag. ⚠️ Nothing here could see it. The + * name is never resolved from a registry inside the emitted project, so the + * unit pins above, the type-check and `scripts/create-scaffold-smoke.sh` are + * all green on the defect; the cost lands at `npm publish`, in the terminal of + * whoever ran the scaffolder. The ruling (#15530, decision batch #106 item 1) + * is option A: unscoped `plugin-` plus `"private": true` for standalone, + * `@objectstack/plugin-` unchanged for `--in-repo`. + * + * ## Why both arms are read in ONE test, and why `not.toBe` is here as well + * + * This is a DISCRIMINATION, not two independent facts, and the failure mode a + * one-armed pin has is that it passes on a scaffolder that has stopped + * discriminating — or stopped emitting. So each `it` renders both placements + * in the same run and closes with the inequality: a mutation that makes the + * two arms identical **in either direction** (scoping the standalone name + * back, unscoping the in-repo one, marking both `private`, marking neither) + * reddens here even if every equality above it were somehow satisfied. + * + * The expected strings are LITERALS, deliberately — ⛔ never `pluginPackageName`, + * which is the function under test: reading it would move both sides of every + * comparison together and pin nothing at all. The one derived expectation + * (`dirName`) is derived from the OTHER surface the ruling names — "matching + * the directory the scaffolder prints". + */ +describe('os create plugin: the emitted package name follows the placement (#15530)', () => { + /** One rendered file, or a loud failure — ⛔ never `undefined` read as a pass. */ + function emit(file: string, placement: ScaffoldPlacement): unknown { + const render = templates.plugin.filesFor(placement)[file]; + if (!render) throw new Error(`the plugin template emits no ${file} for ${placement}`); + return render(PROJECT); + } + + it('emits an unscoped, private standalone manifest and a scoped, publishable --in-repo one', () => { + const standalone = emit('package.json', 'standalone') as Record; + const inRepo = emit('package.json', 'in-repo') as Record; + + // Read the same way the COMMAND reads it before it writes anything, so a + // manifest that stopped carrying a string `name` fails here rather than + // being judged as absent-and-therefore-fine. + expect(emittedPackageName(templates.plugin, 'standalone', PROJECT)) + .toBe(`plugin-${PROJECT}`); + expect(emittedPackageName(templates.plugin, 'in-repo', PROJECT)) + .toBe(`@objectstack/plugin-${PROJECT}`); + + // (1) standalone — unscoped, and the same string as the directory the + // scaffolder prints, which is the ruling's own wording for it. + expect(standalone.name).toBe(`plugin-${PROJECT}`); + expect(standalone.name).toBe(templates.plugin.dirName(PROJECT)); + expect(String(standalone.name).startsWith('@')).toBe(false); + + // ⭐ The load-bearing half. The name is readability; THIS is what makes an + // accidental `npm publish` fail loudly whichever name won. + expect(standalone.private).toBe(true); + + // (3) --in-repo — scoped and unchanged: it lands under packages/plugins/, + // where every sibling really carries that scope and really is published. + expect(inRepo.name).toBe(`@objectstack/plugin-${PROJECT}`); + expect(inRepo.private).toBeUndefined(); + + // NON-DEGENERACY: the two arms differ, in this run, in both fields. + expect(standalone.name).not.toBe(inRepo.name); + expect(standalone.private).not.toBe(inRepo.private); + }); + + it('carries the name into the README, install instruction included', () => { + const standalone = String(emit('README.md', 'standalone')); + const inRepo = String(emit('README.md', 'in-repo')); + + // The README is the SECOND copy of the name — title, install line, import + // specifier. A rename that reaches only the manifest leaves this file + // telling a developer to install a package that exists under no name. + expect(standalone).not.toContain('@objectstack/plugin-'); + expect(standalone).toContain(`# plugin-${PROJECT}`); + expect(standalone).toContain(`from 'plugin-${PROJECT}'`); + + // (2) A local reference, because that is what a reader standing in a + // freshly scaffolded directory can actually run: the package is `private` + // and unscoped, so there is nothing on a registry to `pnpm add`. Every + // install line is checked, and the population is asserted non-empty — a + // for-loop over nothing is the shape that passes on a deleted section. + const installLines = standalone.split('\n').filter((l) => l.includes('pnpm add')); + expect(installLines.length).toBeGreaterThan(0); + for (const line of installLines) { + expect(line, 'standalone install lines must be local references').toMatch( + new RegExp(`pnpm add link:\\.\\./plugin-${PROJECT}$`), + ); + } + + // --in-repo unchanged: a real workspace sibling under a scope this repo + // publishes, so its registry install line is still the correct one. + expect(inRepo).toContain(`pnpm add @objectstack/plugin-${PROJECT}`); + expect(inRepo).toContain(`from '@objectstack/plugin-${PROJECT}'`); + + // NON-DEGENERACY, same shape as above: one README for both placements is + // the regression this test exists to catch. + expect(standalone).not.toBe(inRepo); + }); +}); + describe('rootTsconfigExtends derives the ascent from where a template lands', () => { it('counts the project directory itself', () => { expect(rootTsconfigExtends('packages/plugins', 'plugin-x')).toBe('../../../tsconfig.json'); From de7017d358b77294f1913d0a137d43a37e4b6607 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 07:07:21 +0000 Subject: [PATCH 2/3] fix(cli): spell the scaffold's local install as `pnpm link --global`, 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-` 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 --- packages/cli/src/commands/create.ts | 22 ++++++++++++++++------ packages/cli/test/create.test.ts | 22 ++++++++++++---------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 92bde08dd8..315ff3305c 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -451,20 +451,30 @@ export default ${sanitizeIdentifier(name)}Plugin; // The install instruction itself is placement-dependent (#15530): // the standalone project is `private` and unpublished, so a registry // install is not something its reader can run — the only instruction - // that WORKS from a freshly scaffolded directory is a local - // reference. The in-repo project is a real workspace sibling under a - // scope this repo publishes, so it keeps the install it always had. + // that WORKS from a freshly scaffolded directory is a local link. + // The in-repo project is a real workspace sibling under a scope this + // repo publishes, so it keeps the install it always had. + // + // ⛔ Never spell the local reference as a PATH (`pnpm add ../`, + // `link:../`): 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 — a + // reference the newcomer cannot follow, and one + // `test/init-template-comments-self-contained.test.ts` refuses on + // exactly that ground. `pnpm link --global` names no location at all + // and both halves run where the reader already is. const install = standalone ? `This project is \`private\` and carries no npm scope, so there is nothing to install from a registry — and \`npm publish\` refuses it until you give it a name -you own and drop that flag. Reference it from your app by path in the meantime: +you own and drop that flag. Link it into your app locally in the meantime: \`\`\`bash # here — your app loads dist/index.js, so build it first pnpm install && pnpm build +pnpm link --global -# in your app, when it sits next to this directory -pnpm add link:../${pluginDirName(name)} +# in your ObjectStack app +pnpm link --global ${packageName} \`\`\`` : `\`\`\`bash pnpm add ${packageName} diff --git a/packages/cli/test/create.test.ts b/packages/cli/test/create.test.ts index 3c8082d692..a6b9a57ee0 100644 --- a/packages/cli/test/create.test.ts +++ b/packages/cli/test/create.test.ts @@ -294,16 +294,18 @@ describe('os create plugin: the emitted package name follows the placement (#155 // (2) A local reference, because that is what a reader standing in a // freshly scaffolded directory can actually run: the package is `private` - // and unscoped, so there is nothing on a registry to `pnpm add`. Every - // install line is checked, and the population is asserted non-empty — a - // for-loop over nothing is the shape that passes on a deleted section. - const installLines = standalone.split('\n').filter((l) => l.includes('pnpm add')); - expect(installLines.length).toBeGreaterThan(0); - for (const line of installLines) { - expect(line, 'standalone install lines must be local references').toMatch( - new RegExp(`pnpm add link:\\.\\./plugin-${PROJECT}$`), - ); - } + // and unscoped, so nothing on a registry answers to that name. Asserted + // from BOTH sides — the registry verb is gone, and the local one is + // present and names this package — so neither a deleted section nor a + // reinstated `pnpm add` can satisfy it. + expect(standalone).not.toContain('pnpm add'); + expect(standalone).toContain(`pnpm link --global plugin-${PROJECT}`); + + // ⛔ And not spelled as a path. The scaffolder knows nothing about where + // the reader's app is, so `link:../` would be a guess about a layout + // it never created — refused, on that ground, by + // `init-template-comments-self-contained.test.ts`. + expect(standalone).not.toMatch(/\.\.\//); // --in-repo unchanged: a real workspace sibling under a scope this repo // publishes, so its registry install line is still the correct one. From 3106bdf7aa20129d002e8994e0d214be0394a048 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 07:36:07 +0000 Subject: [PATCH 3/3] refactor(cli): keep `pluginPackageName` module-private -- nothing imports 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 --- packages/cli/src/commands/create.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 315ff3305c..d3b831ca55 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -347,8 +347,14 @@ function pluginDirName(name: string): string { * `--in-repo` keeps `@objectstack/plugin-` and stays publishable: that * placement lands under `packages/plugins/`, where every sibling genuinely * carries that scope and whoever runs it genuinely can publish there. + * + * ⛔ Module-private on purpose, unlike its five exported neighbours. Each of + * those is exported because a test in this package IMPORTS it; nothing imports + * this one, and nothing should — `test/create.test.ts` pins the two composed + * names as LITERALS precisely so the pin cannot move with the function it is + * pinning. An `export` here would widen this module's surface for no reader. */ -export function pluginPackageName(placement: ScaffoldPlacement, name: string): string { +function pluginPackageName(placement: ScaffoldPlacement, name: string): string { return placement === 'in-repo' ? `@objectstack/${pluginDirName(name)}` : pluginDirName(name);