Skip to content

Commit 82e8a08

Browse files
os-zhuangclaude
andcommitted
fix(scaffold): make blank/init outputs pass objectstack validate
Both @objectstack/cli init and create-objectstack's bundled `blank` template produced projects that failed validation: missing manifest namespace and object names violating the `${namespace}_${shortName}` rule. - packages/cli: sanitizeNamespace + __name__ path substitution, threaded (name, namespace) through template fns, post-scaffold bundleRequire self-test; round-trip render tests - packages/create-objectstack: template files declare namespace + use `blank_note`; rewriteProjectIdentity now also reads the template's original namespace and swaps `name: '${templateNs}_*'` → user namespace across src/**/*.ts; version pulled from package.json Bumps @objectstack/cli and create-objectstack to 6.5.2. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e0f8971 commit 82e8a08

8 files changed

Lines changed: 269 additions & 36 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@objectstack/cli",
3-
"version": "6.5.1",
3+
"version": "6.5.2",
44
"description": "Command Line Interface for ObjectStack Protocol",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

packages/cli/src/commands/init.ts

Lines changed: 84 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,37 @@ function pkgVersion(): string {
3737
return `^${getCliVersion()}`;
3838
}
3939

40+
/**
41+
* Convert an npm package name into a valid ObjectStack namespace identifier.
42+
*
43+
* Namespace rules (from `ManifestSchema` in `@objectstack/spec`):
44+
* - 2-20 chars, `^[a-z][a-z0-9_]{1,19}$`
45+
* - Reserved: `base`, `system`, `sys`
46+
*
47+
* npm names allow hyphens/dots/scopes (e.g. `@acme/my-app`); identifiers don't.
48+
* We strip the scope, replace separators with `_`, lowercase, prefix a leading
49+
* digit, truncate to 20, and pad short names so the result always satisfies
50+
* the regex. Reserved names get a `_app` suffix.
51+
*/
52+
export function sanitizeNamespace(name: string): string {
53+
let s = name.replace(/^@[^/]+\//, ''); // drop npm scope
54+
s = s.toLowerCase().replace(/[^a-z0-9]+/g, '_'); // separators → _
55+
s = s.replace(/^_+|_+$/g, ''); // trim underscores
56+
if (!s) s = 'app';
57+
if (/^[0-9]/.test(s)) s = 'a' + s; // must start with a letter
58+
if (s.length < 2) s = (s + '_app').slice(0, 20);
59+
if (s.length > 20) s = s.slice(0, 20).replace(/_+$/, '');
60+
if (['base', 'system', 'sys'].includes(s)) s = (s + '_app').slice(0, 20);
61+
return s;
62+
}
63+
4064
export const TEMPLATES: Record<string, {
4165
description: string;
4266
dependencies: Record<string, string>;
4367
devDependencies: Record<string, string>;
4468
scripts: Record<string, string>;
45-
configContent: (name: string) => string;
46-
srcFiles: Record<string, (name: string) => string>;
69+
configContent: (name: string, namespace: string) => string;
70+
srcFiles: Record<string, (name: string, namespace: string) => string>;
4771
}> = {
4872
app: {
4973
description: 'Full application with objects, views, and actions',
@@ -69,13 +93,13 @@ export const TEMPLATES: Record<string, {
6993
validate: 'objectstack validate',
7094
typecheck: 'tsc --noEmit',
7195
},
72-
configContent: (name: string) => `import { defineStack } from '@objectstack/spec';
96+
configContent: (name: string, namespace: string) => `import { defineStack } from '@objectstack/spec';
7397
import * as objects from './src/objects';
7498
7599
export default defineStack({
76100
manifest: {
77-
id: 'com.example.${name}',
78-
namespace: '${name}',
101+
id: 'com.example.${namespace}',
102+
namespace: '${namespace}',
79103
version: '0.1.0',
80104
type: 'app',
81105
name: '${toTitleCase(name)}',
@@ -86,13 +110,13 @@ export default defineStack({
86110
});
87111
`,
88112
srcFiles: {
89-
'src/objects/index.ts': (name) => `export { default as ${toCamelCase(name)} } from './${name}';
113+
'src/objects/index.ts': (_name, namespace) => `export { default as ${toCamelCase(namespace)}Item } from './${namespace}_item';
90114
`,
91-
'src/objects/__name__.ts': (name) => `import * as Data from '@objectstack/spec/data';
115+
'src/objects/__name___item.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data';
92116
93-
const ${toCamelCase(name)}: Data.Object = {
94-
name: '${name}',
95-
label: '${toTitleCase(name)}',
117+
const ${toCamelCase(namespace)}Item: Data.Object = {
118+
name: '${namespace}_item',
119+
label: '${toTitleCase(namespace)} Item',
96120
ownership: 'own',
97121
fields: {
98122
name: {
@@ -117,7 +141,7 @@ const ${toCamelCase(name)}: Data.Object = {
117141
},
118142
};
119143
120-
export default ${toCamelCase(name)};
144+
export default ${toCamelCase(namespace)}Item;
121145
`,
122146
},
123147
},
@@ -142,13 +166,13 @@ export default ${toCamelCase(name)};
142166
test: 'vitest run',
143167
typecheck: 'tsc --noEmit',
144168
},
145-
configContent: (name: string) => `import { defineStack } from '@objectstack/spec';
169+
configContent: (name: string, namespace: string) => `import { defineStack } from '@objectstack/spec';
146170
import * as objects from './src/objects';
147171
148172
export default defineStack({
149173
manifest: {
150174
id: 'com.objectstack.plugin-${name}',
151-
namespace: 'plugin_${name}',
175+
namespace: '${namespace}',
152176
version: '0.1.0',
153177
type: 'plugin',
154178
name: '${toTitleCase(name)} Plugin',
@@ -159,13 +183,13 @@ export default defineStack({
159183
});
160184
`,
161185
srcFiles: {
162-
'src/objects/index.ts': (name) => `export { default as ${toCamelCase(name)} } from './${name}';
186+
'src/objects/index.ts': (_name, namespace) => `export { default as ${toCamelCase(namespace)}Item } from './${namespace}_item';
163187
`,
164-
'src/objects/__name__.ts': (name) => `import * as Data from '@objectstack/spec/data';
188+
'src/objects/__name___item.ts': (_name, namespace) => `import * as Data from '@objectstack/spec/data';
165189
166-
const ${toCamelCase(name)}: Data.Object = {
167-
name: '${name}',
168-
label: '${toTitleCase(name)}',
190+
const ${toCamelCase(namespace)}Item: Data.Object = {
191+
name: '${namespace}_item',
192+
label: '${toTitleCase(namespace)} Item',
169193
ownership: 'own',
170194
fields: {
171195
name: {
@@ -176,7 +200,7 @@ const ${toCamelCase(name)}: Data.Object = {
176200
},
177201
};
178202
179-
export default ${toCamelCase(name)};
203+
export default ${toCamelCase(namespace)}Item;
180204
`,
181205
},
182206
},
@@ -199,12 +223,12 @@ export default ${toCamelCase(name)};
199223
validate: 'objectstack validate',
200224
typecheck: 'tsc --noEmit',
201225
},
202-
configContent: (name: string) => `import { defineStack } from '@objectstack/spec';
226+
configContent: (name: string, namespace: string) => `import { defineStack } from '@objectstack/spec';
203227
204228
export default defineStack({
205229
manifest: {
206-
id: 'com.example.${name}',
207-
namespace: '${name}',
230+
id: 'com.example.${namespace}',
231+
namespace: '${namespace}',
208232
version: '0.1.0',
209233
type: 'app',
210234
name: '${toTitleCase(name)}',
@@ -343,7 +367,14 @@ export default class Init extends Command {
343367
this.error('objectstack.config.ts already exists');
344368
}
345369

370+
// Convert the npm-name (which allows hyphens, dots, scopes) into a
371+
// valid ObjectStack namespace identifier. Threaded into every template
372+
// function so object names use `${namespace}_${shortName}` form and
373+
// satisfy `defineStack()` validation.
374+
const namespace = sanitizeNamespace(projectName);
375+
346376
printKV('Project', projectName);
377+
printKV('Namespace', namespace);
347378
printKV('Template', `${flags.template}${template.description}`);
348379
printKV('Directory', targetDir);
349380
console.log('');
@@ -374,7 +405,7 @@ export default class Init extends Command {
374405
}
375406

376407
// 2. Create objectstack.config.ts
377-
const configContent = template.configContent(projectName);
408+
const configContent = template.configContent(projectName, namespace);
378409
fs.writeFileSync(path.join(targetDir, 'objectstack.config.ts'), configContent);
379410
createdFiles.push('objectstack.config.ts');
380411

@@ -400,17 +431,20 @@ export default class Init extends Command {
400431
createdFiles.push('tsconfig.json');
401432
}
402433

403-
// 4. Create src files
434+
// 4. Create src files. File paths use `__name__` as a placeholder for
435+
// the namespace (NOT the npm name) so generated identifiers stay snake
436+
// _case even when the project name contains hyphens (e.g. `my-app` →
437+
// namespace `my_app` → `src/objects/my_app_item.ts`).
404438
for (const [filePath, contentFn] of Object.entries(template.srcFiles)) {
405-
const resolvedPath = filePath.replace('__name__', projectName);
439+
const resolvedPath = filePath.replace(/__name__/g, namespace);
406440
const fullPath = path.join(targetDir, resolvedPath);
407441
const dir = path.dirname(fullPath);
408442

409443
if (!fs.existsSync(dir)) {
410444
fs.mkdirSync(dir, { recursive: true });
411445
}
412446

413-
fs.writeFileSync(fullPath, contentFn(projectName));
447+
fs.writeFileSync(fullPath, contentFn(projectName, namespace));
414448
createdFiles.push(resolvedPath);
415449
}
416450

@@ -442,6 +476,30 @@ export default class Init extends Command {
442476
}
443477
}
444478

479+
// Self-test the scaffold so we catch template regressions (e.g. an
480+
// invalid namespace or object name) before the user discovers them by
481+
// running `objectstack dev`. Only runs when deps are present —
482+
// `defineStack()` validation lives in `@objectstack/spec`.
483+
if (installSucceeded) {
484+
printStep('Validating scaffold...');
485+
try {
486+
const { bundleRequire } = await import('bundle-require');
487+
const { mod } = await bundleRequire({
488+
filepath: path.join(targetDir, 'objectstack.config.ts'),
489+
cwd: targetDir,
490+
});
491+
const stack = mod.default ?? mod;
492+
if (!stack?.manifest?.namespace) {
493+
throw new Error('Rendered config has no manifest.namespace');
494+
}
495+
printSuccess(`Scaffold validated (namespace: ${stack.manifest.namespace})`);
496+
} catch (err: any) {
497+
printError(`Scaffold validation failed: ${err.message || err}`);
498+
console.log(chalk.dim(' This is a CLI bug — please report it at https://github.com/objectstack-ai/framework/issues'));
499+
this.error('Scaffold validation failed');
500+
}
501+
}
502+
445503
if (!installAttempted || installSucceeded) {
446504
printSuccess('Project initialized!');
447505
console.log('');

packages/cli/test/init.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import { describe, it, expect } from 'vitest';
44
import fs from 'fs';
5+
import os from 'os';
56
import path from 'path';
67
import { fileURLToPath } from 'url';
7-
import { TEMPLATES, getCliVersion, detectPackageManager } from '../src/commands/init';
8+
import { TEMPLATES, getCliVersion, detectPackageManager, sanitizeNamespace } from '../src/commands/init';
89

910
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1011
const pkg = JSON.parse(
@@ -50,6 +51,95 @@ describe('init command — published scaffold', () => {
5051
});
5152
});
5253

54+
describe('sanitizeNamespace', () => {
55+
const NS_RE = /^[a-z][a-z0-9_]{1,19}$/;
56+
57+
it.each([
58+
['my-app', 'my_app'],
59+
['@acme/my-app', 'my_app'],
60+
['MyApp', 'myapp'],
61+
['hello.world', 'hello_world'],
62+
['a', 'a_app'],
63+
])('sanitizes %s → %s', (input, expected) => {
64+
expect(sanitizeNamespace(input)).toBe(expected);
65+
});
66+
67+
it('prefixes a leading digit so identifier starts with a letter', () => {
68+
const out = sanitizeNamespace('123app');
69+
expect(out).toMatch(NS_RE);
70+
expect(out.startsWith('a')).toBe(true);
71+
});
72+
73+
it('avoids reserved namespaces', () => {
74+
expect(sanitizeNamespace('sys')).toBe('sys_app');
75+
expect(sanitizeNamespace('base')).toBe('base_app');
76+
expect(sanitizeNamespace('system')).toBe('system_app');
77+
});
78+
79+
it('always produces a value matching the manifest namespace regex', () => {
80+
for (const input of ['my-app', '@acme/my-app', '123app', 'sys', 'a', 'A__B', 'really-long-name-truncated-here']) {
81+
expect(sanitizeNamespace(input)).toMatch(NS_RE);
82+
}
83+
});
84+
});
85+
86+
describe('scaffold rendering — round-trip', () => {
87+
// Re-implement the file-resolution logic from init.ts so we can verify
88+
// rendered output without spawning a child CLI process.
89+
function renderTemplate(templateKey: keyof typeof TEMPLATES, projectName: string) {
90+
const t = TEMPLATES[templateKey];
91+
const namespace = sanitizeNamespace(projectName);
92+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'os-init-'));
93+
fs.writeFileSync(
94+
path.join(tmpRoot, 'objectstack.config.ts'),
95+
t.configContent(projectName, namespace),
96+
);
97+
const written: string[] = ['objectstack.config.ts'];
98+
for (const [filePath, contentFn] of Object.entries(t.srcFiles)) {
99+
const resolvedPath = filePath.replace(/__name__/g, namespace);
100+
const fullPath = path.join(tmpRoot, resolvedPath);
101+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
102+
fs.writeFileSync(fullPath, contentFn(projectName, namespace));
103+
written.push(resolvedPath);
104+
}
105+
return { tmpRoot, namespace, written };
106+
}
107+
108+
it('renders kebab project name into snake_case file paths and identifiers (app template)', () => {
109+
const { tmpRoot, namespace, written } = renderTemplate('app', 'my-app');
110+
expect(namespace).toBe('my_app');
111+
// No file should contain a hyphen in its path segments.
112+
for (const rel of written) {
113+
expect(rel).not.toMatch(/-/);
114+
}
115+
// Object file is namespace-prefixed.
116+
const objFile = path.join(tmpRoot, 'src', 'objects', 'my_app_item.ts');
117+
expect(fs.existsSync(objFile)).toBe(true);
118+
const objSrc = fs.readFileSync(objFile, 'utf8');
119+
// Rendered object name must satisfy `${namespace}_${shortName}`.
120+
expect(objSrc).toMatch(/name: 'my_app_item'/);
121+
// Index re-exports the canonical identifier.
122+
const indexSrc = fs.readFileSync(path.join(tmpRoot, 'src', 'objects', 'index.ts'), 'utf8');
123+
expect(indexSrc).toMatch(/from '\.\/my_app_item'/);
124+
expect(indexSrc).toMatch(/myAppItem/);
125+
// Rendered config embeds the sanitized namespace.
126+
const cfg = fs.readFileSync(path.join(tmpRoot, 'objectstack.config.ts'), 'utf8');
127+
expect(cfg).toMatch(/namespace: 'my_app'/);
128+
expect(namespace).toMatch(/^[a-z][a-z0-9_]{1,19}$/);
129+
fs.rmSync(tmpRoot, { recursive: true, force: true });
130+
});
131+
132+
it('renders namespace identifiers identically for plugin and empty templates', () => {
133+
for (const key of ['plugin', 'empty'] as const) {
134+
const { tmpRoot, namespace } = renderTemplate(key, 'my-app');
135+
expect(namespace).toBe('my_app');
136+
const cfg = fs.readFileSync(path.join(tmpRoot, 'objectstack.config.ts'), 'utf8');
137+
expect(cfg).toMatch(/namespace: 'my_app'/);
138+
fs.rmSync(tmpRoot, { recursive: true, force: true });
139+
}
140+
});
141+
});
142+
53143
describe('detectPackageManager', () => {
54144
it('detects pnpm from npm_config_user_agent', () => {
55145
expect(detectPackageManager({ npm_config_user_agent: 'pnpm/10.31.0 npm/? node/v22.0.0 linux x64' })).toBe('pnpm');

packages/create-objectstack/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "create-objectstack",
3-
"version": "6.5.1",
3+
"version": "6.5.2",
44
"description": "Create a new ObjectStack project — npx create-objectstack",
55
"bin": {
66
"create-objectstack": "./bin/create-objectstack.js"

0 commit comments

Comments
 (0)