From d2620be245140414c784c853bd568b5ed9f772f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:10:44 +0000 Subject: [PATCH 1/3] fix(create-objectstack): refuse a project name npm would refuse, before writing anything The resolved project name (argument or current directory basename) is checked with validate-npm-package-name's validForNewPackages before the first write. A refused name exits 1 with npm's reasons, the rule, and a suggested slug; the name is never rewritten silently. Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B Co-authored-by: Claude --- packages/create-objectstack/package.json | 4 +- packages/create-objectstack/src/index.ts | 22 +++ .../create-objectstack/src/package-name.ts | 127 ++++++++++++++++++ pnpm-lock.yaml | 6 + 4 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 packages/create-objectstack/src/package-name.ts diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 26356c75523..582e7cb8f23 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -33,11 +33,13 @@ "license": "Apache-2.0", "dependencies": { "chalk": "^6.0.1", - "commander": "^15.0.0" + "commander": "^15.0.0", + "validate-npm-package-name": "^7.0.2" }, "devDependencies": { "@objectstack/spec": "workspace:*", "@types/node": "^26.6.3", + "@types/validate-npm-package-name": "^4.0.2", "tsup": "^8.5.1", "typescript": "^6.0.3", "vitest": "^4.1.11", diff --git a/packages/create-objectstack/src/index.ts b/packages/create-objectstack/src/index.ts index 85a862be374..625a2dc9c1c 100644 --- a/packages/create-objectstack/src/index.ts +++ b/packages/create-objectstack/src/index.ts @@ -17,6 +17,11 @@ * `template-registry.ts` still names them, so `-t todo` refuses with an * explanation instead of a bare "unknown template". * + * Before anything is written, the resolved project name (the `[name]` + * argument, or the current directory's basename) is checked against npm's + * package-name rules, and a name npm would refuse is refused here — with the + * rule and a suggested slug, never a silent rewrite (package-name.ts). + * * After the files land in `targetDir`, four files are rewritten with the * user-supplied project name: * - package.json .name @@ -90,6 +95,7 @@ import { skillsSyncedFromPackage, } from './skills-install.js'; import { renderVersionBanner } from './banner.js'; +import { checkProjectName, describeInvalidProjectName } from './package-name.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -500,6 +506,22 @@ const program = new Command() const cwd = process.cwd(); const projectName = name || path.basename(cwd); + + // Refused BEFORE anything touches the disk: the name is written verbatim + // into package.json and objectstack.manifest.json, and npm is the first to + // refuse a name like `My App!` — at publish, far from here. Never + // normalised silently: the user re-runs with the slug, or a name of their + // own (package-name.ts carries the why). + const nameVerdict = checkProjectName(projectName); + if (!nameVerdict.valid) { + const [headline, ...detail] = describeInvalidProjectName(projectName, nameVerdict, { + fromDirectory: !name, + }); + printError(headline); + for (const line of detail) console.log(chalk.dim(` ${line}`)); + process.exit(1); + } + const namespace = sanitizeNamespace(projectName); const targetDir = name ? path.resolve(cwd, name) : cwd; const isCurrentDir = targetDir === cwd; diff --git a/packages/create-objectstack/src/package-name.ts b/packages/create-objectstack/src/package-name.ts new file mode 100644 index 00000000000..48e789b83f1 --- /dev/null +++ b/packages/create-objectstack/src/package-name.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// The project name is checked against npm's package-name rules BEFORE the +// scaffolder writes anything. Kept out of index.ts because that module calls +// `program.parse()` on import — anything a test needs must be importable +// without running the CLI (same reason as pkg-utils.ts). +// +// WHY A REFUSAL, AND NEVER A REWRITE +// ---------------------------------- +// The resolved project name — the `[name]` argument, or the current +// directory's basename when there is none — is written verbatim into +// `package.json` `name` and `objectstack.manifest.json` `name`. A display-style +// name (`My App!`, `MyApp`) used to land there unchecked: `validate` and +// `build` pass on such a project, and the first refusal came from npm itself, +// at `npm publish`, far from its cause. +// +// The name is the user's choice, so the scaffolder does not normalise it +// behind their back: it refuses, states the rule, and names the slug it would +// suggest instead. The user re-runs with the name they accept, and that is the +// value written. +// +// WHY npm's OWN PREDICATE +// ----------------------- +// The verdict is `validate-npm-package-name`'s `validForNewPackages` — the +// module npm itself consults — rather than a restated rule set. "Valid for NEW +// packages" is the bar a freshly scaffolded project has to meet: names npm +// still accepts only for packages that already exist (capital letters, a core +// module name such as `http`) are refused here too. + +import validateNpmPackageName from 'validate-npm-package-name'; + +/** npm's ceiling on a package name, the scope included. */ +const NPM_NAME_MAX_LENGTH = 214; + +/** The rule, stated once, in the refusal every invalid name gets. */ +export const NPM_NAME_RULE = + 'An npm package name is lowercase, at most 214 characters, made of URL-safe ' + + 'characters (letters, digits, "-", ".", "_"), does not start with ".", "_" or "-", ' + + 'and may carry a scope (@scope/name).'; + +export type ProjectNameVerdict = + | { valid: true } + | { + valid: false; + /** npm's own reasons, verbatim from `validate-npm-package-name`. */ + reasons: string[]; + /** A legal name derived from the input, or undefined when none derives cleanly. */ + suggestion: string | undefined; + }; + +/** Whether `name` may be written to a new project's `package.json` as-is. */ +export function checkProjectName(name: string): ProjectNameVerdict { + const result = validateNpmPackageName(name); + if (result.validForNewPackages) return { valid: true }; + return { + valid: false, + reasons: [...(result.errors ?? []), ...(result.warnings ?? [])], + suggestion: suggestPackageName(name), + }; +} + +/** One name segment → lowercase, hyphen-separated, npm-safe characters only. */ +function slugSegment(segment: string): string { + return segment + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') // MyApp -> My-App + .toLowerCase() + .replace(/[^a-z0-9._]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^[-._]+|[-._]+$/g, ''); +} + +/** + * The slug a refusal suggests for `name`: `My App!` → `my-app`, `MyApp` → + * `my-app`, `@Acme/My App` → `@acme/my-app`. + * + * Only a SUGGESTION — it is printed, never written. Returns undefined when the + * slug is empty or is itself refused by npm (`HTTP` slugs to the core module + * name `http`), so the refusal never recommends a name it would refuse next. + */ +export function suggestPackageName(name: string): string | undefined { + const scoped = /^@([^/]*)\/(.*)$/.exec(name.trim()); + const scope = scoped ? slugSegment(scoped[1]) : ''; + const prefix = scope ? `@${scope}/` : ''; + const budget = NPM_NAME_MAX_LENGTH - prefix.length; + const base = slugSegment(scoped ? scoped[2] : name) + .slice(0, budget) + .replace(/[-._]+$/, ''); + if (!base) return undefined; + const slug = `${prefix}${base}`; + return validateNpmPackageName(slug).validForNewPackages ? slug : undefined; +} + +/** + * The lines of the refusal, without colour, the first one being the headline. + * + * `fromDirectory` is the no-argument path: the name was not typed, it is the + * current directory's basename, so the remedy is to rename the directory or + * to scaffold into a new one under a name passed explicitly. + */ +export function describeInvalidProjectName( + name: string, + verdict: Extract, + { fromDirectory }: { fromDirectory: boolean }, +): string[] { + const subject = fromDirectory + ? `The current directory's name "${name}" is the project name, and it` + : `The project name "${name}"`; + const lines = [ + `${subject} is not a valid npm package name, so it cannot be written to package.json. Nothing was written.`, + `npm refuses it: ${verdict.reasons.join('; ')}.`, + NPM_NAME_RULE, + ]; + const example = verdict.suggestion ?? 'my-app'; + const rerun = `create-objectstack ${example}`; + if (fromDirectory) { + lines.push( + verdict.suggestion + ? `Rename this directory to "${verdict.suggestion}" and re-run, or scaffold into a new directory: ${rerun}` + : `Rename this directory to a valid name and re-run, or scaffold into a new directory, e.g.: ${rerun}`, + ); + } else { + lines.push( + verdict.suggestion ? `Suggested name: ${rerun}` : `Choose a valid name, e.g.: ${rerun}`, + ); + } + return lines; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dc4a47e195..8b64d515386 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -964,6 +964,9 @@ importers: commander: specifier: ^15.0.0 version: 15.0.0 + validate-npm-package-name: + specifier: ^7.0.2 + version: 7.0.2 devDependencies: '@objectstack/spec': specifier: workspace:* @@ -971,6 +974,9 @@ importers: '@types/node': specifier: ^26.6.3 version: 26.6.3 + '@types/validate-npm-package-name': + specifier: ^4.0.2 + version: 4.0.2 tsup: specifier: ^8.5.1 version: 8.5.1(patch_hash=d1c8d6bd9db5a776a639cd37b802f8b962f7846a6e3758af4478b0e0bde082e4)(@microsoft/api-extractor@7.58.2(@types/node@26.6.3))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.31)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(typescript@6.0.3)(yaml@2.9.1) From cefee3c0fa27359a4d17c7c7ce90b19d4f5466aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:17:12 +0000 Subject: [PATCH 2/3] test(create-objectstack): pin the npm-name refusal and the legal-name control Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B Co-authored-by: Claude --- .../src/package-name.test.ts | 168 ++++++++++++++++++ .../create-objectstack/src/package-name.ts | 2 + 2 files changed, 170 insertions(+) create mode 100644 packages/create-objectstack/src/package-name.test.ts diff --git a/packages/create-objectstack/src/package-name.test.ts b/packages/create-objectstack/src/package-name.test.ts new file mode 100644 index 00000000000..ca17eaa1629 --- /dev/null +++ b/packages/create-objectstack/src/package-name.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// The project name is refused BEFORE anything is written when npm would refuse +// it as a package name — with the rule stated and a suggested slug, and never +// a silent rewrite. Two layers: +// +// - the predicate and the slug (package-name.ts), imported directly; +// - the wiring, through the real CLI under `tsx` (index.ts calls +// `program.parse()` on import, so it cannot be imported — the same +// no-build subprocess pattern scaffold-description.test.ts uses). This is +// the layer that proves the refusal happens before the first write: the +// verdict is the exit code, the message, and an untouched directory. +// +// The legal-name legs are the control: a check that refused everything would +// pass every refusal leg, and only `my-app` / `@scope/my-app` scaffolding as +// before tells the two apart. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import validateNpmPackageName from 'validate-npm-package-name'; +import { + NPM_NAME_RULE, + checkProjectName, + describeInvalidProjectName, + suggestPackageName, +} from './package-name.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = path.resolve(HERE, '..'); +const REPO_ROOT = path.resolve(PKG_ROOT, '..', '..'); +const TSX = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx'); +const INDEX_TS = path.join(PKG_ROOT, 'src', 'index.ts'); + +describe('checkProjectName — npm\'s rule for a NEW package', () => { + it.each(['my-app', '@scope/my-app', 'my.app', 'a_b', 'app2', 'x'])('accepts %s', (name) => { + expect(checkProjectName(name)).toEqual({ valid: true }); + }); + + it.each([ + ['My App!', /URL-friendly/], + ['MyApp', /capital letters/], + ['HTTP', /core module name/], + ['http', /core module name/], + ['.hidden', /start with a period/], + ['_private', /start with an underscore/], + [' my-app', /leading or trailing spaces/], + ['node_modules', /not a valid package name/], + ['apps/my-app', /URL-friendly/], + ['a'.repeat(215), /214 characters/], + ])('refuses %j, naming npm\'s reason', (name, reason) => { + const verdict = checkProjectName(name); + expect(verdict.valid).toBe(false); + if (verdict.valid) return; + expect(verdict.reasons.join('; ')).toMatch(reason); + }); +}); + +describe('suggestPackageName — the slug a refusal offers', () => { + it.each([ + ['My App!', 'my-app'], + ['MyApp', 'my-app'], + ['my app', 'my-app'], + ['@Acme/My App', '@acme/my-app'], + ['./my-app', 'my-app'], + [' Support Desk ', 'support-desk'], + ['Ünïcode App', 'unicode-app'], + ])('%j -> %j', (name, slug) => { + expect(suggestPackageName(name)).toBe(slug); + }); + + it('suggests nothing rather than a name npm would refuse next', () => { + // `HTTP` slugs to `http`, a core module name. + expect(suggestPackageName('HTTP')).toBeUndefined(); + expect(suggestPackageName('!!!')).toBeUndefined(); + }); + + it('every suggestion it does make is a valid new package name', () => { + for (const name of ['My App!', 'MyApp', '@Acme/My App', 'A'.repeat(300), '@X/' + 'B'.repeat(300), 'Ünïcode App']) { + const slug = suggestPackageName(name); + expect(slug, name).toBeDefined(); + expect(validateNpmPackageName(slug!).validForNewPackages, `${name} -> ${slug}`).toBe(true); + } + }); +}); + +describe('describeInvalidProjectName', () => { + it('names the rule and the slug for a typed name', () => { + const verdict = checkProjectName('My App!'); + if (verdict.valid) throw new Error('precondition: My App! is invalid'); + const lines = describeInvalidProjectName('My App!', verdict, { fromDirectory: false }); + expect(lines[0]).toContain('"My App!" is not a valid npm package name'); + expect(lines).toContain(NPM_NAME_RULE); + expect(lines.join('\n')).toContain('create-objectstack my-app'); + }); + + it('for the current-directory name, says the name came from the directory', () => { + const verdict = checkProjectName('My App'); + if (verdict.valid) throw new Error('precondition: My App is invalid'); + const lines = describeInvalidProjectName('My App', verdict, { fromDirectory: true }); + expect(lines[0]).toContain('current directory'); + expect(lines.join('\n')).toContain('Rename this directory to "my-app"'); + }); +}); + +// ─── The real CLI ────────────────────────────────────────────────── + +let tmp: string; +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-npm-name-')); +}); +afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + +/** Run the CLI in `cwd`; never throws — the exit status is part of the verdict. */ +function run(cwd: string, args: string[]): { status: number; stdout: string } { + try { + const stdout = execFileSync(TSX, [INDEX_TS, ...args, '--skip-install', '--skip-skills'], { + cwd, + encoding: 'utf8', + stdio: 'pipe', + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + }); + return { status: 0, stdout }; + } catch (error) { + const e = error as { status?: number; stdout?: string }; + return { status: e.status ?? -1, stdout: e.stdout ?? '' }; + } +} + +describe('the CLI refuses an illegal project name before writing anything', () => { + it.each(['My App!', 'MyApp'])('%j: exit 1, the rule, the slug, and no directory', (name) => { + const { status, stdout } = run(tmp, [name]); + expect(status).toBe(1); + expect(stdout).toContain(`"${name}" is not a valid npm package name`); + expect(stdout).toContain(NPM_NAME_RULE); + expect(stdout).toContain('Suggested name: create-objectstack my-app'); + expect(stdout).not.toContain('Template files written'); + expect(fs.readdirSync(tmp)).toEqual([]); + }, 30_000); + + it('the current directory\'s name is checked too, and the directory stays empty', () => { + const here = path.join(tmp, 'My App'); + fs.mkdirSync(here); + const { status, stdout } = run(here, []); + expect(status).toBe(1); + expect(stdout).toContain('The current directory\'s name "My App" is the project name'); + expect(stdout).toContain('Rename this directory to "my-app"'); + expect(fs.readdirSync(here)).toEqual([]); + }, 30_000); + + it.each([ + ['my-app', 'my-app'], + ['@scope/my-app', path.join('@scope', 'my-app')], + ])('control: %j scaffolds as before, written verbatim', (name, dir) => { + const { status, stdout } = run(tmp, [name]); + expect(status, stdout).toBe(0); + const projectDir = path.join(tmp, dir); + const pkg = JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8')); + const manifest = JSON.parse( + fs.readFileSync(path.join(projectDir, 'objectstack.manifest.json'), 'utf8'), + ); + expect(pkg.name).toBe(name); + expect(manifest.name).toBe(name); + }, 30_000); +}); diff --git a/packages/create-objectstack/src/package-name.ts b/packages/create-objectstack/src/package-name.ts index 48e789b83f1..3c6e4f25bb8 100644 --- a/packages/create-objectstack/src/package-name.ts +++ b/packages/create-objectstack/src/package-name.ts @@ -62,6 +62,8 @@ export function checkProjectName(name: string): ProjectNameVerdict { /** One name segment → lowercase, hyphen-separated, npm-safe characters only. */ function slugSegment(segment: string): string { return segment + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') // Ünïcode -> Unicode: drop combining marks .replace(/([a-z0-9])([A-Z])/g, '$1-$2') // MyApp -> My-App .toLowerCase() .replace(/[^a-z0-9._]+/g, '-') From 0108d0c2038c41fc3644f1006cfc53e87f28dd6d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:17:45 +0000 Subject: [PATCH 3/3] chore(changeset): create-objectstack npm-name check (patch) Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B Co-authored-by: Claude --- .changeset/26642-npm-name-check.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/26642-npm-name-check.md diff --git a/.changeset/26642-npm-name-check.md b/.changeset/26642-npm-name-check.md new file mode 100644 index 00000000000..b3410960ad9 --- /dev/null +++ b/.changeset/26642-npm-name-check.md @@ -0,0 +1,14 @@ +--- +'create-objectstack': patch +--- + +fix(create-objectstack): a project name npm would refuse is refused before anything is written, with the rule and a suggested name + +Clause-②: no +Metadata-protocol: none + +`create-objectstack "My App!"` (or `MyApp`, or no argument inside a directory called `My App`) used to write that name verbatim into `package.json` and `objectstack.manifest.json`. `validate` and `build` passed, and the first refusal came from npm at `npm publish`. + +The resolved project name (the argument, or the current directory's name when there is none) is now checked against npm's rules for a new package name, using `validate-npm-package-name` (the module npm itself uses), before the first file is written. A refused name exits 1 and prints npm's reasons, the rule, and a suggested name (`My App!` and `MyApp` both suggest `my-app`). The name is never rewritten for you: re-run with the suggested name or one of your own. Names npm accepts, such as `my-app` and `@scope/my-app`, scaffold as before. + +`create-objectstack` gains one runtime dependency, `validate-npm-package-name`.