Skip to content

Commit 5865b02

Browse files
claude[bot]claude
andauthored
fix(cli): name the standalone os create plugin scaffold plugin-NAME and mark it private (#17096)
* fix(cli): name the standalone `os create plugin` scaffold `plugin-<name>` 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> * 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-<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> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aa5152d commit 5865b02

5 files changed

Lines changed: 318 additions & 37 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os create plugin` names the standalone scaffold `plugin-<name>` and marks it `private`
6+
7+
The default (standalone) emission wrote `"name": "@objectstack/plugin-<name>"` into a
8+
project scaffolded for a developer outside this monorepo — a scope they cannot publish
9+
to — and did not mark the manifest `private`. Nothing failed at scaffold time: the name is
10+
never resolved from a registry inside the project, so `pnpm install`, the type-check and
11+
the scaffold smoke were all green on it, and the cost landed later at `npm publish`. The
12+
emitted README compounded it by instructing `pnpm add @objectstack/plugin-<name>`.
13+
14+
The standalone default now emits:
15+
16+
- `"name": "plugin-<name>"` — unscoped, and the same string as the directory the
17+
scaffolder prints and creates;
18+
- `"private": true` — the line that actually stops an accidental publish, whatever the
19+
name says;
20+
- a README whose install instruction is a local reference (`pnpm add link:../plugin-<name>`)
21+
and whose import specifier matches the emitted package name.
22+
23+
`os create plugin --in-repo` is unchanged: it still emits a publishable
24+
`@objectstack/plugin-<name>` with no `private` flag, because that placement lands under
25+
`packages/plugins/` where every sibling genuinely carries that scope.
26+
27+
No action is needed for a project already scaffolded. If you generated one with the old
28+
name and have not published it, rename `package.json`'s `name` to `plugin-<name>` (or a
29+
scope you own) and update the README's install line; the exported symbol and the plugin's
30+
runtime `name` are unaffected.

packages/cli/src/commands/create.ts

Lines changed: 150 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,27 @@
6868
* semver range pinned to the running CLI's own
6969
* version, the `tsconfig.json` is self-contained,
7070
* a `pnpm-workspace.yaml` carries the build
71-
* approvals a fresh `pnpm install` needs, and the
72-
* project lands in the developer's own directory.
71+
* approvals a fresh `pnpm install` needs, the
72+
* package is named `plugin-<name>` and marked
73+
* `private`, and the project lands in the
74+
* developer's own directory.
7375
* `in-repo` (--in-repo) the platform-work shape: `workspace:*` deps, a
7476
* `tsconfig.json` that extends this repo's root
75-
* config, landing under `packages/plugins/`.
76-
* Explicit and documented, never the default — its
77-
* output installs nowhere else.
77+
* config, a publishable `@objectstack/plugin-<name>`
78+
* landing under `packages/plugins/`. Explicit and
79+
* documented, never the default — its output
80+
* installs nowhere else.
81+
*
82+
* ## The emitted package NAME follows the placement too (#15530)
83+
*
84+
* The audience decides the name, and #14824 moved the audience without moving
85+
* the name: the standalone default kept stamping `@objectstack/plugin-<name>`
86+
* — a scope the developer it now scaffolds for cannot publish to — onto every
87+
* project, with the emitted README telling them to install it from there. The
88+
* rule and the reason live on {@link pluginPackageName}; the README is the
89+
* SECOND site that repeats the name and is fixed in the same place, because a
90+
* rename that reaches only the manifest leaves the README pointing at a package
91+
* that exists under no name at all.
7892
*
7993
* ## The version the standalone shape pins
8094
*
@@ -192,14 +206,20 @@ function defineTemplate(t: Omit<CreateTemplate, 'files'>): CreateTemplate {
192206
}
193207

194208
/**
195-
* The scoped package name a scaffold is about to write, READ BACK off the
196-
* rendered manifest rather than recomposed here.
209+
* The package name a scaffold is about to write, READ BACK off the rendered
210+
* manifest rather than recomposed here.
211+
*
212+
* Recomposing it would be a second copy of the composition that nothing keeps
213+
* in step with the renderer — the same restatement that let this command's
214+
* emitted name drift away from what `os init` enforces. Reading the rendered
215+
* object measures the string that actually lands on disk, and a template added
216+
* later is covered without being told to declare anything.
197217
*
198-
* Recomposing it would be a second copy of `@objectstack/plugin-${name}` that
199-
* nothing keeps in step with the renderer — the same restatement that let this
200-
* command's emitted name drift away from what `os init` enforces. Reading the
201-
* rendered object measures the string that actually lands on disk, and a
202-
* template added later is covered without being told to declare anything.
218+
* ⭐ Load-bearing since #15530, not merely tidy: the composition is no longer
219+
* ONE string. The standalone placement emits an unscoped `plugin-<name>` and
220+
* `--in-repo` a scoped `@objectstack/plugin-<name>`, so a recomposition here
221+
* would have to know the placement rule too — and would be judging the wrong
222+
* length for one of the two placements the moment the rule moved.
203223
*
204224
* `null` when the template emits no `package.json`, or emits one without a
205225
* string `name`: there is then no package name to judge, which is not the same
@@ -220,12 +240,17 @@ export function emittedPackageName(
220240
* The one rule `os create` needs and `os init` cannot.
221241
*
222242
* `init`'s argument IS the package name, so measuring the argument is the same
223-
* measurement. `create` composes its argument into a SCOPED name, and npm's
224-
* 214-character ceiling counts the scope: `@objectstack/plugin-` spends 20 of
225-
* them before the user's first character. A 200-character name is therefore
226-
* legal for `init` (measured: accepted) and illegal for `create` (measured:
227-
* emits a 220-character name npm refuses) — which is why the shared validator
228-
* is shared and this check is not.
243+
* measurement. `create` COMPOSES its argument into a longer name, and npm's
244+
* 214-character ceiling counts every character of the composition — the
245+
* `plugin-` prefix the standalone placement writes (7), or the whole
246+
* `@objectstack/plugin-` the in-repo placement writes (20), before the user's
247+
* first character. A 214-character name is therefore legal for `init`
248+
* (measured: accepted) and illegal for `create` in EITHER placement — which is
249+
* why the shared validator is shared and this check is not.
250+
*
251+
* ⛔ Never re-derive the prefix length here: the caller hands in the string
252+
* `emittedPackageName` read back off the rendered manifest, so this measures
253+
* the bytes that would land whichever placement produced them.
229254
*/
230255
export function validateEmittedPackageName(packageName: string): string | null {
231256
const over = packageName.length - NPM_PACKAGE_NAME_MAX_LENGTH;
@@ -284,17 +309,76 @@ export function sanitizeIdentifier(name: string): string {
284309

285310
const PLUGIN_IN_REPO_DIR = 'packages/plugins';
286311

312+
/** The project directory the `plugin` template lands in, in either placement. */
313+
function pluginDirName(name: string): string {
314+
return `plugin-${name}`;
315+
}
316+
317+
/**
318+
* The package name the `plugin` template writes — DERIVED from the placement,
319+
* exactly as its dependency specs and its `tsconfig.json` already are.
320+
*
321+
* ## Why the standalone name is unscoped
322+
*
323+
* `@objectstack` is a scope the developer this command scaffolds FOR cannot
324+
* publish to. Until #14824 that was arguably fine, because the default output
325+
* landed inside this monorepo, where every sibling really does carry the scope.
326+
* That ruling pointed the default at the developer's own directory and the name
327+
* did not move with the audience — so the standalone emission stamped a scope
328+
* its owner does not own onto every project generated from it. ⚠️ Nothing in
329+
* this repository can see that: the name is never resolved from a registry
330+
* inside the project, so `pnpm install`, the type-check and the scaffold smoke
331+
* are all green on it. The cost is paid once, later, at `npm publish`, in
332+
* someone else's terminal.
333+
*
334+
* The #15530 ruling is that the standalone default emits `plugin-<name>` —
335+
* unscoped, and the same string as {@link pluginDirName}, which is what the
336+
* scaffolder prints and what the developer already sees on disk. ⛔ Those are
337+
* not two spellings of one convention: the package name is COMPOSED from the
338+
* directory name here, so a template that renames its directory cannot leave a
339+
* stale package name behind it.
340+
*
341+
* ⭐ The name is the readable half. `"private": true` — emitted beside it, for
342+
* the standalone placement only — is the STRUCTURAL half, and the one that
343+
* actually prevents the defect: `npm publish` refuses a private manifest
344+
* loudly, whatever the name says. A later change that keeps this name and drops
345+
* that flag reinstates the defect with better prose.
346+
*
347+
* `--in-repo` keeps `@objectstack/plugin-<name>` and stays publishable: that
348+
* placement lands under `packages/plugins/`, where every sibling genuinely
349+
* carries that scope and whoever runs it genuinely can publish there.
350+
*
351+
* ⛔ Module-private on purpose, unlike its five exported neighbours. Each of
352+
* those is exported because a test in this package IMPORTS it; nothing imports
353+
* this one, and nothing should — `test/create.test.ts` pins the two composed
354+
* names as LITERALS precisely so the pin cannot move with the function it is
355+
* pinning. An `export` here would widen this module's surface for no reader.
356+
*/
357+
function pluginPackageName(placement: ScaffoldPlacement, name: string): string {
358+
return placement === 'in-repo'
359+
? `@objectstack/${pluginDirName(name)}`
360+
: pluginDirName(name);
361+
}
362+
287363
export const templates: Record<string, CreateTemplate> = {
288364
plugin: defineTemplate({
289365
description: 'Create a new kernel code plugin (TypeScript implementing the kernel Plugin contract)',
290366
inRepoDir: PLUGIN_IN_REPO_DIR,
291-
dirName: (name: string) => `plugin-${name}`,
367+
dirName: pluginDirName,
292368
filesFor: (placement: ScaffoldPlacement) => {
293369
const standalone = placement === 'standalone';
294370
const files: Record<string, FileRenderer> = {
295371
'package.json': (name: string) => ({
296-
name: `@objectstack/plugin-${name}`,
372+
name: pluginPackageName(placement, name),
297373
version: '0.1.0',
374+
// ⛔ Standalone only, and ⛔ never dropped as "just a default the
375+
// developer will change": this is the line that makes an accidental
376+
// `npm publish` fail loudly instead of landing a package in a
377+
// namespace its author does not own. The unscoped name above is the
378+
// readable half; this is the enforcing one. The in-repo placement
379+
// omits it because `packages/plugins/*` really is published from here
380+
// — see {@link pluginPackageName}.
381+
...(standalone ? { private: true } : {}),
298382
description: `ObjectStack Plugin: ${name}`,
299383
// `tsc` emits ES modules under the compiler options below, so the
300384
// manifest has to declare the project as ESM or Node refuses the
@@ -332,7 +416,7 @@ export const templates: Record<string, CreateTemplate> = {
332416
include: SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY,
333417
})
334418
: {
335-
extends: rootTsconfigExtends(PLUGIN_IN_REPO_DIR, `plugin-${name}`),
419+
extends: rootTsconfigExtends(PLUGIN_IN_REPO_DIR, pluginDirName(name)),
336420
compilerOptions: {
337421
outDir: 'dist',
338422
rootDir: 'src',
@@ -361,15 +445,53 @@ export const ${sanitizeIdentifier(name)}Plugin: Plugin = {
361445
362446
export default ${sanitizeIdentifier(name)}Plugin;
363447
`,
364-
'README.md': (name: string) => `# @objectstack/plugin-${name}
448+
'README.md': (name: string) => {
449+
const packageName = pluginPackageName(placement, name);
450+
// ⛔ The README is not downstream of the manifest rename — it REPEATS
451+
// the name, at the title, at the install line and at the import
452+
// specifier. Renaming the manifest alone would leave this file
453+
// telling a developer to `pnpm add` a package that now exists under
454+
// no name at all, which is the same defect one layer out. All three
455+
// sites read `packageName` for that reason.
456+
//
457+
// The install instruction itself is placement-dependent (#15530):
458+
// the standalone project is `private` and unpublished, so a registry
459+
// install is not something its reader can run — the only instruction
460+
// that WORKS from a freshly scaffolded directory is a local link.
461+
// The in-repo project is a real workspace sibling under a scope this
462+
// repo publishes, so it keeps the install it always had.
463+
//
464+
// ⛔ Never spell the local reference as a PATH (`pnpm add ../<dir>`,
465+
// `link:../<dir>`): the scaffolder knows where this project landed
466+
// and knows nothing about where the reader's app is, so any relative
467+
// path is a guess about a directory layout it never created — a
468+
// reference the newcomer cannot follow, and one
469+
// `test/init-template-comments-self-contained.test.ts` refuses on
470+
// exactly that ground. `pnpm link --global` names no location at all
471+
// and both halves run where the reader already is.
472+
const install = standalone
473+
? `This project is \`private\` and carries no npm scope, so there is nothing to
474+
install from a registry — and \`npm publish\` refuses it until you give it a name
475+
you own and drop that flag. Link it into your app locally in the meantime:
476+
477+
\`\`\`bash
478+
# here — your app loads dist/index.js, so build it first
479+
pnpm install && pnpm build
480+
pnpm link --global
481+
482+
# in your ObjectStack app
483+
pnpm link --global ${packageName}
484+
\`\`\``
485+
: `\`\`\`bash
486+
pnpm add ${packageName}
487+
\`\`\``;
488+
return `# ${packageName}
365489
366490
ObjectStack Plugin: ${name}
367491
368492
## Installation
369493
370-
\`\`\`bash
371-
pnpm add @objectstack/plugin-${name}
372-
\`\`\`
494+
${install}
373495
374496
## Usage
375497
@@ -380,7 +502,7 @@ hyphen, an underscore, a leading digit) are folded away, so the exported symbol
380502
can differ from the name.
381503
382504
\`\`\`typescript
383-
import { ${sanitizeIdentifier(name)}Plugin } from '@objectstack/plugin-${name}';
505+
import { ${sanitizeIdentifier(name)}Plugin } from '${packageName}';
384506
385507
// Use the plugin in your ObjectStack configuration
386508
export default {
@@ -393,7 +515,8 @@ export default {
393515
## License
394516
395517
MIT
396-
`,
518+
`;
519+
},
397520
};
398521

399522
// pnpm does not run dependency build scripts unless they are approved in

packages/cli/test/create-plugin-identifier-parses.test.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,15 @@
4141
*
4242
* ## What this pin deliberately does not touch
4343
*
44-
* The emitted package name, its scope and the emitted directory name are the
45-
* user's string byte-for-byte (#15530 / #15816) — asserted below, so a future
46-
* edit that "fixes" the name instead of the identifier reddens here.
44+
* The USER'S STRING survives byte-for-byte into the emitted package name and
45+
* the emitted directory name (#15816) — asserted below, so a future edit that
46+
* "fixes" the name instead of the identifier reddens here.
47+
*
48+
* ⚠️ What the package name is COMPOSED of is a different question, and it moved
49+
* under #15530: the standalone default is now an unscoped `plugin-<name>` and
50+
* only `--in-repo` keeps `@objectstack/plugin-<name>`. That rule is pinned in
51+
* `create.test.ts`; this file asserts only that whatever the composition is, it
52+
* carries the typed name through unaltered.
4753
*/
4854

4955
import { describe, expect, it } from 'vitest';
@@ -151,7 +157,9 @@ describe('`os create plugin <name>` emits a parseable identifier', () => {
151157
diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' ')),
152158
name,
153159
).toEqual([]);
154-
expect(readme).toContain(`import { ${identifier}Plugin } from '@objectstack/plugin-${name}';`);
160+
// The DEFAULT placement is standalone, whose package — and therefore whose
161+
// import specifier — is unscoped since #15530.
162+
expect(readme).toContain(`import { ${identifier}Plugin } from 'plugin-${name}';`);
155163
});
156164

157165
it.each(CASES)('names the derived identifier in the README prose for $name', ({ name, identifier }) => {
@@ -182,7 +190,9 @@ describe('`os create plugin <name>` emits a parseable identifier', () => {
182190

183191
it.each(CASES)('leaves the emitted package name and directory as typed for $name', ({ name }) => {
184192
const manifest = JSON.parse(emit('package.json', name, DEFAULT_PLACEMENT)) as { name: string };
185-
expect(manifest.name).toBe(`@objectstack/plugin-${name}`);
193+
// Unscoped under the DEFAULT placement (#15530) — but still the user's
194+
// string, unaltered, which is the property this file is about.
195+
expect(manifest.name).toBe(`plugin-${name}`);
186196
expect(templates.plugin.dirName(name)).toBe(`plugin-${name}`);
187197
});
188198

packages/cli/test/create-refuses-invalid-project-name.e2e.test.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,14 @@
3131
*
3232
* ## Why the two commands do NOT refuse identically
3333
*
34-
* `os init`'s argument IS the package name. `os create`'s argument is composed
35-
* into a SCOPED one (`@objectstack/plugin-<name>`), and npm's 214-character
36-
* ceiling counts the scope — so a name that is legal for `init` can compose to
37-
* one npm refuses. `refuses a name only the composed length catches` pins that
34+
* `os init`'s argument IS the package name. `os create`'s argument is COMPOSED
35+
* into a longer one — `plugin-<name>` standalone, `@objectstack/plugin-<name>`
36+
* for `--in-repo` since #15530 — and npm's 214-character ceiling counts every
37+
* character of the composition, so a name that is legal for `init` can compose
38+
* to one npm refuses in either placement. The population below is derived from
39+
* the placement list for exactly that reason, and the composed-length case is
40+
* built from the constant rather than from an assumed prefix width.
41+
* `refuses a name only the composed length catches` pins that
3842
* asymmetry from both ends: the shared validator passes the name (asserted
3943
* directly), `create` refuses it, and `init` still accepts it. ⛔ Moving that
4044
* 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', ()
213217

214218
it('reads the name off the DEFAULT placement the same way the command does', () => {
215219
const emitted = emittedPackageName(templates.plugin, DEFAULT_PLACEMENT, VALID_NAME);
216-
expect(emitted).toBe(`@objectstack/plugin-${VALID_NAME}`);
220+
// The default is standalone, which emits unscoped since #15530. What the
221+
// composition IS is pinned in `create.test.ts`; what this asserts is that
222+
// the length check reads the same string the command would write.
223+
expect(emitted).toBe(`plugin-${VALID_NAME}`);
217224
});
218225
});

0 commit comments

Comments
 (0)