Skip to content

Commit 9786d39

Browse files
os-trumpclaude
andauthored
fix(cli): stamp the manifest identity block in the os create example scaffold (#14821)
* fix(cli): stamp the manifest identity block in the `os create` example scaffold `os create example <name>` emitted an `objectstack.config.ts` whose manifest declared only `name`, `version` and `description`. `ManifestSchema` requires `id` and `type`, and `namespace` decides every object's table name and REST path — so `defineStack` threw and the freshly scaffolded project refused on its first run. The template now stamps `id`, `namespace` (via `init`'s own `sanitizeNamespace`), `type: 'app'` and `engines.protocol` from `PROTOCOL_MAJOR`, matching what the three `os init` templates stamp. A new pin renders every `init` and `create` template that emits an `objectstack.config.ts`, loads it back, and parses its `manifest` through the real `ManifestSchema`. The population is derived from the two template maps, so a later template is swept without editing the test. `os create` is not removed, deprecated, or redirected at `os init`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * test(cli): emit each scaffold's own src files before loading its config The manifest sweep wrote only `objectstack.config.ts`, so the two `os init` templates whose config imports `./src/objects` failed to resolve and were reported as refusing to load. Each scaffold now emits through its own emitter — `writeTemplateSrcFiles` for `init`, the `.ts` entries of the `files` map for `create`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 09cc6be commit 9786d39

3 files changed

Lines changed: 279 additions & 3 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os create example` now scaffolds a manifest the protocol schema accepts
6+
7+
The `objectstack.config.ts` that `os create example <name>` wrote declared
8+
three manifest keys — `name`, `version`, `description` — and nothing else.
9+
`ManifestSchema` requires `id` (the reverse-domain package id) and `type`
10+
(`app` | `plugin` | …), and `namespace` is the mandatory prefix of every object
11+
name, which decides each object's table name and REST path. Parsed against the
12+
schema, the emitted block answered `success: false` with
13+
`invalid_type@id · invalid_value@type`.
14+
15+
`defineStack` throws on exactly that, so the project a documented command had
16+
just created refused to load on its first run — before the author had written a
17+
line. The three `os init` templates all stamped the identity block; this was
18+
the one scaffold that had drifted, and nothing noticed because no test looked
19+
at these templates as data.
20+
21+
The template now stamps what `os init` stamps: `id`, `namespace` (derived from
22+
the project name with `init`'s own `sanitizeNamespace`, so both scaffolders
23+
answer the same way for the same input), `type: 'app'` and
24+
`engines.protocol`, alongside the `version`, `name` and `description` it
25+
already carried. `engines.protocol` is stamped from `PROTOCOL_MAJOR` — the same
26+
constant `init` stamps — and ships with the same self-contained comment
27+
explaining what the range is and when to move it.
28+
29+
A pin sweeps both scaffolders: every `init` and `create` template that emits an
30+
`objectstack.config.ts` is rendered through its own emitter, loaded back, and
31+
its `manifest` parsed through the real `ManifestSchema`. The population is
32+
derived from the two template maps rather than listed, so a template added
33+
later is swept the day it is added.
34+
35+
`os create` itself is untouched — it is not removed, deprecated, or redirected
36+
at `os init`. Whether the two scaffolders should stay separate is a CLI-surface
37+
decision, not this fix.

packages/cli/src/commands/create.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import chalk from 'chalk';
55
import fs from 'fs';
66
import path from 'path';
77
import { execSync } from 'child_process';
8+
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
9+
import { sanitizeNamespace } from './init.js';
810

911
export const templates = {
1012
plugin: {
@@ -119,7 +121,9 @@ MIT
119121
vitest: '^4.0.0',
120122
},
121123
}),
122-
'objectstack.config.ts': (name: string) => `import { defineStack } from '@objectstack/spec';
124+
'objectstack.config.ts': (name: string) => {
125+
const namespace = sanitizeNamespace(name);
126+
return `import { defineStack } from '@objectstack/spec';
123127
124128
// Barrel imports — add more as you create new type folders
125129
// import * as objects from './src/objects';
@@ -128,9 +132,20 @@ MIT
128132
129133
export default defineStack({
130134
manifest: {
131-
name: '${name}',
135+
id: 'com.example.${namespace}',
136+
namespace: '${namespace}',
132137
version: '0.1.0',
138+
type: 'app',
139+
name: '${name}',
133140
description: '${name} example application',
141+
// Protocol compatibility range: the metadata-protocol major this app is
142+
// authored against. The runtime checks it before it loads anything, so a
143+
// runtime outside the range refuses this app at the boundary with the
144+
// exact migration command instead of crashing later. Scaffolding stamped
145+
// it to match the ObjectStack version you installed — change it when you
146+
// deliberately move to a new protocol major, not to silence a mismatch.
147+
// Guide: https://objectstack.ai/docs/upgrading
148+
engines: { protocol: '^${PROTOCOL_MAJOR}' },
134149
},
135150
136151
objects: [
@@ -141,7 +156,8 @@ export default defineStack({
141156
// Object.values(apps), // Uncomment after creating src/apps/index.ts
142157
],
143158
});
144-
`,
159+
`;
160+
},
145161
'README.md': (name: string) => `# ${name} Example
146162
147163
ObjectStack example application: ${name}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Every scaffold this package ships must emit a `manifest:` block that
5+
* `ManifestSchema` accepts — the schema the user's very first command parses
6+
* it with.
7+
*
8+
* ## The defect this pins
9+
*
10+
* `os create example <name>` wrote an `objectstack.config.ts` whose manifest
11+
* was three keys — `name`, `version`, `description` — and nothing else.
12+
* `ManifestSchema` requires `id` (the reverse-domain package id) and `type`
13+
* (`app` | `plugin` | …), and `namespace` decides every object name, table
14+
* name and REST path (there was none). Parsed against the schema the block
15+
* answered:
16+
*
17+
* success : false
18+
* issues : invalid_type@id · invalid_value@type
19+
*
20+
* `defineStack` throws on exactly that, so a project scaffolded by a
21+
* documented command refused on its first run, before the author had written
22+
* a line. The three `os init` templates all stamped the identity block; the
23+
* `create` template was the one scaffold that had drifted, and nothing
24+
* noticed because no test ever looked at these templates as DATA.
25+
*
26+
* ## Why the sweep spans both scaffolders, and why it is derived
27+
*
28+
* The defect class is "a shipped scaffold whose manifest the shipped schema
29+
* refuses", and this package has two independent scaffold sources —
30+
* `init.ts`'s `TEMPLATES` and `create.ts`'s `templates`. Pinning only the
31+
* reported one would leave the other free to drift the same way, which is how
32+
* this one arrived. So the population is DERIVED from both maps (every entry
33+
* that emits an `objectstack.config.ts`), never written down: a template added
34+
* later is swept the day it is added, with nobody remembering to extend this
35+
* file.
36+
*
37+
* `create`'s `plugin` template contributes nothing here on purpose — it emits
38+
* no `objectstack.config.ts` at all. Its scaffolded `src/index.ts` declares a
39+
* `Plugin` object, a different contract from this package manifest, and a
40+
* sweep that pretended otherwise would report on a surface `ManifestSchema`
41+
* does not govern.
42+
*
43+
* ## Why the manifest is read back off a LOADED config, not off the source text
44+
*
45+
* Both scaffolders render their config as a template literal, so the only
46+
* honest reading of "what the scaffold declares" is the object the rendered
47+
* file actually evaluates to. The rendered file is written to disk and loaded
48+
* through `bundle-require` — the same loader `scaffold-validate.ts` uses for
49+
* `init`'s self-test — so what is parsed here is the real emitted artifact and
50+
* not a literal copied into a test, which is free to agree with a template
51+
* that has since changed.
52+
*
53+
* Temp projects go under this package's git-ignored `tmp/` (not
54+
* `os.tmpdir()`) because the rendered config imports `@objectstack/spec`,
55+
* which only resolves where Node can walk up into this package's
56+
* `node_modules` — the same constraint, for the same reason, as
57+
* `init-scaffold-authoring-rules.test.ts`. Keeping generated `.ts` out of
58+
* `test/` also keeps it away from any glob that collects sources.
59+
*
60+
* ## Why `ManifestSchema` is parsed explicitly, when `defineStack` already ran
61+
*
62+
* `defineStack` validates through `ObjectStackDefinitionSchema`, where
63+
* `manifest` is `ManifestSchema.optional()`. Two live gaps follow from that
64+
* `.optional()`, and both are the failure this file exists to catch:
65+
*
66+
* - a template that drops the `manifest:` block ENTIRELY loads green — the
67+
* stack door has nothing to check — and ships a project with no id, no
68+
* namespace and no type;
69+
* - a template that calls `defineStack(config, { strict: false })` skips the
70+
* parse altogether.
71+
*
72+
* Neither would redden a pin that only asserted "the config loads". So the
73+
* load is the first assertion, and the standalone parse is the one that does
74+
* not depend on the door staying the way it is today.
75+
*/
76+
77+
import { describe, it, expect, afterAll } from 'vitest';
78+
import fs from 'node:fs';
79+
import path from 'node:path';
80+
import { fileURLToPath } from 'node:url';
81+
import { ManifestSchema } from '@objectstack/spec/kernel';
82+
import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js';
83+
import { templates as createTemplates } from '../src/commands/create.js';
84+
85+
const HERE = path.dirname(fileURLToPath(import.meta.url));
86+
const TMP_ROOT = path.resolve(HERE, '../tmp');
87+
const PROJECT_NAME = 'my-app';
88+
const CONFIG_FILE = 'objectstack.config.ts';
89+
90+
const roots: string[] = [];
91+
afterAll(() => {
92+
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true });
93+
});
94+
95+
interface Scaffold {
96+
/** `<command>:<template key>` — the command a user would have typed. */
97+
id: string;
98+
/**
99+
* Write this scaffold's TypeScript into `root`, through its own emitter.
100+
*
101+
* TypeScript only, and deliberately: a config that imports `./src/objects`
102+
* needs that module on disk to load at all, while the `package.json` and
103+
* `tsconfig.json` the scaffolders also write are monorepo-relative
104+
* (`workspace:*` deps, `extends: '../../tsconfig.json'`) and resolve to
105+
* nothing from a throwaway directory. Neither one can change what the
106+
* manifest declares, so emitting them would buy a resolution failure and no
107+
* coverage. A future template whose config imports a NON-TypeScript file it
108+
* emits would fail loudly here, on the resolve, rather than quietly.
109+
*/
110+
emit: (root: string) => void;
111+
}
112+
113+
/**
114+
* `os init -t <key>`: every template renders a config, so the whole map
115+
* contributes. Both halves go through `init`'s own emitter — the same
116+
* `configContent` / `writeTemplateSrcFiles` pair the command calls, and the
117+
* pair `init-scaffold-authoring-rules.test.ts` drives, so neither test can
118+
* drift from what `init` really writes.
119+
*/
120+
const initScaffolds: Scaffold[] = Object.keys(TEMPLATES).map((key) => ({
121+
id: `init:${key}`,
122+
emit: (root: string) => {
123+
const namespace = sanitizeNamespace(PROJECT_NAME);
124+
fs.writeFileSync(
125+
path.join(root, CONFIG_FILE),
126+
TEMPLATES[key].configContent(PROJECT_NAME, namespace),
127+
);
128+
writeTemplateSrcFiles(TEMPLATES[key].srcFiles, root, PROJECT_NAME, namespace);
129+
},
130+
}));
131+
132+
/**
133+
* `os create <key> <name>`: only the templates whose file map carries an
134+
* `objectstack.config.ts` contribute — derived from the map, so a template
135+
* that grows one later is swept without an edit here. `create` has no
136+
* `srcFiles` split; every file it writes lives in one `files` map, keyed by
137+
* the path it lands at, and is rendered by calling that entry — which is
138+
* exactly what `Create.run()` does.
139+
*/
140+
const createScaffolds: Scaffold[] = Object.entries(createTemplates)
141+
.filter(([, template]) => CONFIG_FILE in template.files)
142+
.map(([key, template]) => ({
143+
id: `create:${key}`,
144+
emit: (root: string) => {
145+
const files = template.files as Record<string, (name: string) => unknown>;
146+
for (const [filePath, render] of Object.entries(files)) {
147+
if (!filePath.endsWith('.ts')) continue;
148+
const abs = path.join(root, filePath);
149+
fs.mkdirSync(path.dirname(abs), { recursive: true });
150+
fs.writeFileSync(abs, String(render(PROJECT_NAME)));
151+
}
152+
},
153+
}));
154+
155+
const SCAFFOLDS: Scaffold[] = [...initScaffolds, ...createScaffolds];
156+
157+
/** Emit one scaffold into a throwaway directory and load its config back. */
158+
async function loadStack(scaffold: Scaffold): Promise<Record<string, unknown>> {
159+
fs.mkdirSync(TMP_ROOT, { recursive: true });
160+
const root = fs.mkdtempSync(path.join(TMP_ROOT, `manifest-${scaffold.id.replace(':', '-')}-`));
161+
roots.push(root);
162+
scaffold.emit(root);
163+
164+
const { bundleRequire } = await import('bundle-require');
165+
const { mod } = await bundleRequire({ filepath: path.join(root, CONFIG_FILE), cwd: root });
166+
return (mod.default ?? mod) as Record<string, unknown>;
167+
}
168+
169+
describe('every shipped scaffold emits a manifest `ManifestSchema` accepts', () => {
170+
// A sweep that swept nothing reports exactly what a clean tree reports. The
171+
// counts are DERIVED from the two maps rather than frozen, so this stays a
172+
// check that the filters still match something — not a copy of today's
173+
// template list that the next added template makes stale.
174+
it('sweeps both scaffolders, and every template that emits a config', () => {
175+
expect(initScaffolds.length).toBe(Object.keys(TEMPLATES).length);
176+
expect(initScaffolds.length).toBeGreaterThan(0);
177+
expect(createScaffolds.length).toBeGreaterThan(0);
178+
expect(SCAFFOLDS.length).toBe(initScaffolds.length + createScaffolds.length);
179+
});
180+
181+
// The reported instance, named so a future edit that drops the identity
182+
// block again fails with the incident's own vocabulary rather than a bare
183+
// count.
184+
it('includes `os create example` — the scaffold that drifted', () => {
185+
expect(SCAFFOLDS.map((s) => s.id)).toContain('create:example');
186+
});
187+
188+
it.each(SCAFFOLDS.map((s) => s.id))(
189+
'scaffold "%s" declares a manifest the protocol schema accepts',
190+
async (id) => {
191+
const scaffold = SCAFFOLDS.find((s) => s.id === id)!;
192+
193+
let stack: Record<string, unknown>;
194+
try {
195+
stack = await loadStack(scaffold);
196+
} catch (error) {
197+
// `defineStack` refuses an invalid manifest by throwing, so this IS
198+
// the first-run failure the user sees. Re-raise it with the scaffold
199+
// named, because the thrown text alone does not say which one.
200+
throw new Error(
201+
`scaffold "${id}" produces a project that refuses to load — the user's very first `
202+
+ `command fails on this:\n${error instanceof Error ? error.message : String(error)}`,
203+
);
204+
}
205+
206+
// `ObjectStackDefinitionSchema.manifest` is `.optional()`, so a missing
207+
// block is silent at the door above. It is not silent here.
208+
expect(stack.manifest, `scaffold "${id}" declares no \`manifest:\` block`).toBeDefined();
209+
210+
const result = ManifestSchema.safeParse(stack.manifest);
211+
const issues = result.success
212+
? ''
213+
: result.error.issues
214+
.map((i) => `${i.code}@${i.path.join('.') || '<root>'}: ${i.message}`)
215+
.join('\n ');
216+
expect(
217+
result.success,
218+
`scaffold "${id}" emits a manifest \`ManifestSchema\` refuses:\n ${issues}`,
219+
).toBe(true);
220+
},
221+
120_000,
222+
);
223+
});

0 commit comments

Comments
 (0)