Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/26642-npm-name-check.md
Original file line number Diff line number Diff line change
@@ -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`.
4 changes: 3 additions & 1 deletion packages/create-objectstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions packages/create-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
168 changes: 168 additions & 0 deletions packages/create-objectstack/src/package-name.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
129 changes: 129 additions & 0 deletions packages/create-objectstack/src/package-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// 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
.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, '-')
.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<ProjectNameVerdict, { valid: false }>,
{ 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;
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading