Skip to content

Commit 8d41998

Browse files
os-zhuangclaude
andauthored
fix(create-objectstack): 远程模板的对象名改写被静默跳过 —— 5/6 已发布模板对新用户不可用 (#4926) (#4927)
* fix(create-objectstack): stop silently skipping the object-name rewrite for every remote template (#4926) `npx create-objectstack@latest my-app -t <template>` produced a project that could not build for 5 of the 6 offered templates. Only the bundled `blank` worked. The nightly registry canary — the workflow whose entire purpose is to be the new-user canary — had been red on all five for at least a week (20/20 scheduled runs since 2026-07-27), unseen because it runs only on `schedule`. `rewriteProjectIdentity` read the template's original namespace from `objectstack.manifest.json` alone, and that filename means two different documents. The bundled template's is app-shaped and carries `namespace`; a remote template's is the template-REGISTRY document (`$schema: .../template-manifest.json`) and carries none — its namespace lives only in `objectstack.config.ts`. So `templateNamespace` came back undefined for every remote template, the `&& templateNamespace` guard fell through, and the object-name rewrite was skipped entirely — while the config's `namespace:` was rewritten unconditionally. That leaves `namespace: 'my_app'` beside `name: 'todo_task'`, which the ${namespace}_${shortName} rule rejects. Measured against the real templates at objectstack-ai/templates@960f24d, the old code resolved `undefined` for all five and rewrote 0 of the 74 object names that needed it: todo 7, compliance 17, content 22, contracts 12, procurement 16. Two changes, in a new module because index.ts calls program.parse() on import and nothing there is testable (the reason pkg-utils.ts already exists): - `objectstack.config.ts` is now the AUTHORITY for the template namespace, with the manifest as fallback. The config holds the very literal the scaffolder overwrites, so the two cannot disagree. - The rewrite VERIFIES ITSELF. A prefix rewrite that quietly does nothing is indistinguishable from one that was not needed, and that ambiguity is what let this ship. Any surviving stale prefix now throws, naming the files and lines, at the scaffold — not in the user's first `objectstack build`. Verified: 10 new unit tests covering both manifest shapes, the fallback, the absent case and an unparseable manifest; all five real templates now resolve their namespace and rewrite every literal with zero stale left; the bundled blank template still scaffolds to `namespace: 'regr_app'` / `name: 'regr_app_note'`; package suite 28/28, tsc --noEmit and eslint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbNVKv6KgPzuQ5p76nMgnf * chore: changeset for #4926 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbNVKv6KgPzuQ5p76nMgnf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2b63a00 commit 8d41998

4 files changed

Lines changed: 347 additions & 45 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'create-objectstack': patch
3+
---
4+
5+
fix(create-objectstack): scaffolding a remote template no longer produces a project that cannot build (#4926)
6+
7+
`npx create-objectstack@latest my-app -t todo` (and `compliance`, `content`,
8+
`contracts`, `procurement`) generated a project that failed `objectstack build`
9+
immediately — 5 of the 6 offered templates. Only the bundled `blank` worked.
10+
11+
The scaffolder read the template's original namespace from
12+
`objectstack.manifest.json`, and that filename names two different documents.
13+
The bundled template's is app-shaped and carries `namespace`; a remote
14+
template's is the template-registry document
15+
(`$schema: …/template-manifest.json`) and carries none — its namespace lives
16+
only in `objectstack.config.ts`. So the value came back `undefined` for every
17+
remote template and the object-name rewrite was skipped, while the config's
18+
`namespace:` was rewritten anyway. The result was `namespace: 'my_app'` sitting
19+
next to `name: 'todo_task'`, which the `${namespace}_${shortName}` rule rejects.
20+
Across the five templates, 74 object names were left unrewritten.
21+
22+
`objectstack.config.ts` is now the authority for the template namespace (it
23+
holds the very literal the scaffolder overwrites, so the two cannot disagree),
24+
with the manifest as fallback. The rewrite also verifies itself: any surviving
25+
stale prefix throws at the scaffold, naming the files and lines, instead of
26+
surfacing as a build failure on the user's first command.

packages/create-objectstack/src/index.ts

Lines changed: 42 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ import * as tar from 'tar';
4747

4848
import { syncObjectStackDeps } from './pkg-utils.js';
4949
import { copyDir } from './template-copy.js';
50+
import {
51+
readTemplateNamespace,
52+
rewriteObjectNamePrefix,
53+
findStaleNamespacePrefixes,
54+
} from './rewrite-identity.js';
5055

5156
const __filename = fileURLToPath(import.meta.url);
5257
const __dirname = path.dirname(__filename);
@@ -227,26 +232,9 @@ async function loadRemote(pkgName: string, targetDir: string): Promise<string[]>
227232
}
228233

229234
// ─── Field-aware rewrites ───────────────────────────────────────────
230-
231-
/**
232-
* Walk every `*.ts` file under `dir` and apply `fn` to its contents.
233-
* Used to swap the bundled template's literal `blank_` object-name prefix
234-
* for the user-supplied namespace so the rendered objects satisfy the
235-
* `${namespace}_${shortName}` rule enforced by `objectstack validate`.
236-
*/
237-
function walkAndRewriteTs(dir: string, fn: (src: string) => string) {
238-
if (!fs.existsSync(dir)) return;
239-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
240-
const full = path.join(dir, entry.name);
241-
if (entry.isDirectory()) {
242-
walkAndRewriteTs(full, fn);
243-
} else if (entry.isFile() && entry.name.endsWith('.ts')) {
244-
const before = fs.readFileSync(full, 'utf8');
245-
const after = fn(before);
246-
if (after !== before) fs.writeFileSync(full, after);
247-
}
248-
}
249-
}
235+
//
236+
// The object-name prefix walk moved to rewrite-identity.ts so it can be tested
237+
// without importing this module (which calls program.parse() on import).
250238

251239
function rewriteProjectIdentity(
252240
targetDir: string,
@@ -255,18 +243,14 @@ function rewriteProjectIdentity(
255243
) {
256244
const title = toTitleCase(projectName);
257245

258-
// Read the template's *original* namespace from the manifest before we
259-
// overwrite it — we use this as the prefix to swap in src/**/*.ts files.
260-
let templateNamespace: string | undefined;
261-
const manifestPathPre = path.join(targetDir, 'objectstack.manifest.json');
262-
if (fs.existsSync(manifestPathPre)) {
263-
try {
264-
const m = JSON.parse(fs.readFileSync(manifestPathPre, 'utf8'));
265-
if (typeof m.namespace === 'string') templateNamespace = m.namespace;
266-
} catch {
267-
// ignore
268-
}
269-
}
246+
// The template's *original* namespace, read before we overwrite it — this is
247+
// the prefix we swap in src/**/*.ts. It comes from objectstack.config.ts
248+
// first: a REMOTE template's objectstack.manifest.json is the template-
249+
// REGISTRY document and carries no `namespace` at all, so reading only the
250+
// manifest silently yielded undefined and skipped the whole rewrite below —
251+
// shipping every remote template with a rewritten manifest namespace next to
252+
// untouched object names (#4902). See rewrite-identity.ts for the account.
253+
const templateNamespace = readTemplateNamespace(targetDir);
270254

271255
// package.json — set .name and pin @objectstack/* deps to this scaffolder's
272256
// own release line. All @objectstack packages (including create-objectstack)
@@ -312,19 +296,32 @@ function rewriteProjectIdentity(
312296
fs.writeFileSync(configPath, cfg);
313297
}
314298

315-
// src/**/*.ts — swap the bundled template's `${templateNamespace}_` object-name
316-
// prefix for the user's sanitized namespace so rendered objects satisfy
317-
// the `${namespace}_${shortName}` rule. No-op if namespace already matches.
318-
if (namespace !== templateNamespace && templateNamespace) {
319-
const prefixRe = new RegExp(
320-
`(\\bname:\\s*)(['"\`])${templateNamespace}_([a-z0-9_]+)\\2`,
321-
'g',
322-
);
323-
walkAndRewriteTs(path.join(targetDir, 'src'), (src) =>
324-
src.replace(prefixRe, (_m, prefix: string, q: string, rest: string) =>
325-
`${prefix}${q}${namespace}_${rest}${q}`,
326-
),
327-
);
299+
// src/**/*.ts — swap the template's `${templateNamespace}_` object-name prefix
300+
// for the user's sanitized namespace so rendered objects satisfy the
301+
// `${namespace}_${shortName}` rule. No-op if the namespace already matches.
302+
//
303+
// Then VERIFY. A prefix rewrite that quietly does nothing looks exactly like
304+
// one that was not needed, and that ambiguity is what let five broken
305+
// templates ship (#4902). If any stale literal survives, the scaffold has
306+
// produced a project that cannot build — fail here, where the cause is still
307+
// legible, rather than in the user's first `objectstack build`.
308+
if (templateNamespace && namespace !== templateNamespace) {
309+
const srcDir = path.join(targetDir, 'src');
310+
rewriteObjectNamePrefix(srcDir, templateNamespace, namespace);
311+
const stale = findStaleNamespacePrefixes(srcDir, templateNamespace);
312+
if (stale.length > 0) {
313+
const shown = stale
314+
.slice(0, 5)
315+
.map((s) => ` src/${s.file}:${s.line} ${s.text}`)
316+
.join('\n');
317+
const more = stale.length > 5 ? `\n …and ${stale.length - 5} more` : '';
318+
throw new Error(
319+
`Scaffolding rewrote the namespace to '${namespace}' but ${stale.length} object ` +
320+
`name(s) still carry the template's '${templateNamespace}_' prefix:\n${shown}${more}\n` +
321+
`The generated project would fail 'objectstack build' on the ` +
322+
`\${namespace}_\${shortName} rule. This is a bug in the scaffolder, not in your input.`,
323+
);
324+
}
328325
}
329326

330327
// README.md — rewrite first H1
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// Regression cover for #4902: every published remote template scaffolded into a
4+
// project that could not build, because the object-name prefix rewrite was
5+
// guarded on a field only the BUNDLED template's manifest has.
6+
7+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8+
import fs from 'node:fs';
9+
import os from 'node:os';
10+
import path from 'node:path';
11+
import {
12+
readTemplateNamespace,
13+
rewriteObjectNamePrefix,
14+
findStaleNamespacePrefixes,
15+
} from './rewrite-identity.js';
16+
17+
let dir: string;
18+
19+
beforeEach(() => {
20+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-rewrite-'));
21+
fs.mkdirSync(path.join(dir, 'src', 'objects'), { recursive: true });
22+
});
23+
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
24+
25+
const writeConfig = (ns: string) =>
26+
fs.writeFileSync(
27+
path.join(dir, 'objectstack.config.ts'),
28+
`export default defineStack({\n manifest: {\n id: 'x',\n namespace: '${ns}',\n },\n});\n`,
29+
);
30+
31+
const writeObject = (file: string, name: string) =>
32+
fs.writeFileSync(
33+
path.join(dir, 'src', 'objects', file),
34+
`export const o = {\n name: '${name}',\n label: 'X',\n};\n`,
35+
);
36+
37+
describe('readTemplateNamespace', () => {
38+
it('reads a REMOTE template shape: registry manifest with no namespace, config has it', () => {
39+
// The exact shape every template in objectstack-ai/templates ships:
40+
// $schema template-manifest.json, no `namespace` key anywhere in it.
41+
fs.writeFileSync(
42+
path.join(dir, 'objectstack.manifest.json'),
43+
JSON.stringify({
44+
$schema: 'https://schemas.objectstack.dev/template-manifest.json',
45+
name: 'todo',
46+
displayName: 'Todo',
47+
category: 'productivity',
48+
skills: ['objectstack-platform'],
49+
}),
50+
);
51+
writeConfig('todo');
52+
// Reading the manifest alone yields undefined — that was the bug.
53+
expect(readTemplateNamespace(dir)).toBe('todo');
54+
});
55+
56+
it('reads a BUNDLED template shape: app manifest carrying namespace', () => {
57+
fs.writeFileSync(
58+
path.join(dir, 'objectstack.manifest.json'),
59+
JSON.stringify({ name: 'blank', namespace: 'blank' }),
60+
);
61+
writeConfig('blank');
62+
expect(readTemplateNamespace(dir)).toBe('blank');
63+
});
64+
65+
it('falls back to the manifest when the config declares no namespace', () => {
66+
fs.writeFileSync(
67+
path.join(dir, 'objectstack.manifest.json'),
68+
JSON.stringify({ namespace: 'fallback' }),
69+
);
70+
fs.writeFileSync(
71+
path.join(dir, 'objectstack.config.ts'),
72+
'export default defineStack({ manifest: { id: "x" } });\n',
73+
);
74+
expect(readTemplateNamespace(dir)).toBe('fallback');
75+
});
76+
77+
it('is undefined when neither source declares one', () => {
78+
expect(readTemplateNamespace(dir)).toBeUndefined();
79+
});
80+
81+
it('survives an unparseable manifest', () => {
82+
fs.writeFileSync(path.join(dir, 'objectstack.manifest.json'), '{ not json');
83+
writeConfig('todo');
84+
expect(readTemplateNamespace(dir)).toBe('todo');
85+
});
86+
});
87+
88+
describe('rewriteObjectNamePrefix', () => {
89+
it('moves every object name onto the new namespace', () => {
90+
writeObject('todo_task.object.ts', 'todo_task');
91+
writeObject('todo_label.object.ts', 'todo_label');
92+
const n = rewriteObjectNamePrefix(path.join(dir, 'src'), 'todo', 'my_app');
93+
expect(n).toBe(2);
94+
const read = (f: string) =>
95+
fs.readFileSync(path.join(dir, 'src', 'objects', f), 'utf8');
96+
expect(read('todo_task.object.ts')).toContain("name: 'my_app_task'");
97+
expect(read('todo_label.object.ts')).toContain("name: 'my_app_label'");
98+
});
99+
100+
it('leaves names that do not carry the template prefix alone', () => {
101+
writeObject('other.object.ts', 'sys_user');
102+
expect(rewriteObjectNamePrefix(path.join(dir, 'src'), 'todo', 'my_app')).toBe(0);
103+
expect(
104+
fs.readFileSync(path.join(dir, 'src', 'objects', 'other.object.ts'), 'utf8'),
105+
).toContain("name: 'sys_user'");
106+
});
107+
108+
it('is a no-op on a missing directory rather than throwing', () => {
109+
expect(rewriteObjectNamePrefix(path.join(dir, 'nope'), 'todo', 'my_app')).toBe(0);
110+
});
111+
});
112+
113+
describe('findStaleNamespacePrefixes', () => {
114+
it('reports nothing once the rewrite has run', () => {
115+
writeObject('todo_task.object.ts', 'todo_task');
116+
rewriteObjectNamePrefix(path.join(dir, 'src'), 'todo', 'my_app');
117+
expect(findStaleNamespacePrefixes(path.join(dir, 'src'), 'todo')).toEqual([]);
118+
});
119+
120+
it('reports what a skipped rewrite leaves behind — the #4902 failure state', () => {
121+
writeObject('todo_task.object.ts', 'todo_task');
122+
writeObject('todo_label.object.ts', 'todo_label');
123+
// No rewrite at all: exactly what the old manifest-only guard produced.
124+
const stale = findStaleNamespacePrefixes(path.join(dir, 'src'), 'todo');
125+
expect(stale).toHaveLength(2);
126+
expect(stale.map((s) => s.file).sort()).toEqual([
127+
path.join('objects', 'todo_label.object.ts'),
128+
path.join('objects', 'todo_task.object.ts'),
129+
]);
130+
expect(stale[0].line).toBeGreaterThan(0);
131+
});
132+
});

0 commit comments

Comments
 (0)