Draft
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
37 changes: 37 additions & 0 deletions .changeset/create-scaffold-manifest-identity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/cli": patch
---

fix(cli): `os create example` now scaffolds a manifest the protocol schema accepts

The `objectstack.config.ts` that `os create example <name>` wrote declared
three manifest keys — `name`, `version`, `description` — and nothing else.
`ManifestSchema` requires `id` (the reverse-domain package id) and `type`
(`app` | `plugin` | …), and `namespace` is the mandatory prefix of every object
name, which decides each object's table name and REST path. Parsed against the
schema, the emitted block answered `success: false` with
`invalid_type@id · invalid_value@type`.

`defineStack` throws on exactly that, so the project a documented command had
just created refused to load on its first run — before the author had written a
line. The three `os init` templates all stamped the identity block; this was
the one scaffold that had drifted, and nothing noticed because no test looked
at these templates as data.

The template now stamps what `os init` stamps: `id`, `namespace` (derived from
the project name with `init`'s own `sanitizeNamespace`, so both scaffolders
answer the same way for the same input), `type: 'app'` and
`engines.protocol`, alongside the `version`, `name` and `description` it
already carried. `engines.protocol` is stamped from `PROTOCOL_MAJOR` — the same
constant `init` stamps — and ships with the same self-contained comment
explaining what the range is and when to move it.

A pin sweeps both scaffolders: every `init` and `create` template that emits an
`objectstack.config.ts` is rendered through its own emitter, loaded back, and
its `manifest` parsed through the real `ManifestSchema`. The population is
derived from the two template maps rather than listed, so a template added
later is swept the day it is added.

`os create` itself is untouched — it is not removed, deprecated, or redirected
at `os init`. Whether the two scaffolders should stay separate is a CLI-surface
decision, not this fix.
22 changes: 19 additions & 3 deletions packages/cli/src/commands/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
import { sanitizeNamespace } from './init.js';

export const templates = {
plugin: {
Expand DownExpand Up@@ -119,7 +121,9 @@ MIT
vitest: '^4.0.0',
},
}),
'objectstack.config.ts': (name: string) => `import { defineStack } from '@objectstack/spec';
'objectstack.config.ts': (name: string) => {
const namespace = sanitizeNamespace(name);
return `import { defineStack } from '@objectstack/spec';

// Barrel imports — add more as you create new type folders
// import * as objects from './src/objects';
Expand All@@ -128,9 +132,20 @@ MIT

export default defineStack({
manifest: {
name: '${name}',
id: 'com.example.${namespace}',
namespace: '${namespace}',
version: '0.1.0',
type: 'app',
name: '${name}',
description: '${name} example application',
// Protocol compatibility range: the metadata-protocol major this app is
// authored against. The runtime checks it before it loads anything, so a
// runtime outside the range refuses this app at the boundary with the
// exact migration command instead of crashing later. Scaffolding stamped
// it to match the ObjectStack version you installed — change it when you
// deliberately move to a new protocol major, not to silence a mismatch.
// Guide: https://objectstack.ai/docs/upgrading
engines: { protocol: '^${PROTOCOL_MAJOR}' },
},

objects: [
Expand All@@ -141,7 +156,8 @@ export default defineStack({
// Object.values(apps), // Uncomment after creating src/apps/index.ts
],
});
`,
`;
},
'README.md': (name: string) => `# ${name} Example

ObjectStack example application: ${name}
Expand Down
223 changes: 223 additions & 0 deletions packages/cli/test/scaffold-manifest-schema.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Every scaffold this package ships must emit a `manifest:` block that
* `ManifestSchema` accepts — the schema the user's very first command parses
* it with.
*
* ## The defect this pins
*
* `os create example <name>` wrote an `objectstack.config.ts` whose manifest
* was three keys — `name`, `version`, `description` — and nothing else.
* `ManifestSchema` requires `id` (the reverse-domain package id) and `type`
* (`app` | `plugin` | …), and `namespace` decides every object name, table
* name and REST path (there was none). Parsed against the schema the block
* answered:
*
* success : false
* issues : invalid_type@id · invalid_value@type
*
* `defineStack` throws on exactly that, so a project scaffolded by a
* documented command refused on its first run, before the author had written
* a line. The three `os init` templates all stamped the identity block; the
* `create` template was the one scaffold that had drifted, and nothing
* noticed because no test ever looked at these templates as DATA.
*
* ## Why the sweep spans both scaffolders, and why it is derived
*
* The defect class is "a shipped scaffold whose manifest the shipped schema
* refuses", and this package has two independent scaffold sources —
* `init.ts`'s `TEMPLATES` and `create.ts`'s `templates`. Pinning only the
* reported one would leave the other free to drift the same way, which is how
* this one arrived. So the population is DERIVED from both maps (every entry
* that emits an `objectstack.config.ts`), never written down: a template added
* later is swept the day it is added, with nobody remembering to extend this
* file.
*
* `create`'s `plugin` template contributes nothing here on purpose — it emits
* no `objectstack.config.ts` at all. Its scaffolded `src/index.ts` declares a
* `Plugin` object, a different contract from this package manifest, and a
* sweep that pretended otherwise would report on a surface `ManifestSchema`
* does not govern.
*
* ## Why the manifest is read back off a LOADED config, not off the source text
*
* Both scaffolders render their config as a template literal, so the only
* honest reading of "what the scaffold declares" is the object the rendered
* file actually evaluates to. The rendered file is written to disk and loaded
* through `bundle-require` — the same loader `scaffold-validate.ts` uses for
* `init`'s self-test — so what is parsed here is the real emitted artifact and
* not a literal copied into a test, which is free to agree with a template
* that has since changed.
*
* Temp projects go under this package's git-ignored `tmp/` (not
* `os.tmpdir()`) because the rendered config imports `@objectstack/spec`,
* which only resolves where Node can walk up into this package's
* `node_modules` — the same constraint, for the same reason, as
* `init-scaffold-authoring-rules.test.ts`. Keeping generated `.ts` out of
* `test/` also keeps it away from any glob that collects sources.
*
* ## Why `ManifestSchema` is parsed explicitly, when `defineStack` already ran
*
* `defineStack` validates through `ObjectStackDefinitionSchema`, where
* `manifest` is `ManifestSchema.optional()`. Two live gaps follow from that
* `.optional()`, and both are the failure this file exists to catch:
*
* - a template that drops the `manifest:` block ENTIRELY loads green — the
* stack door has nothing to check — and ships a project with no id, no
* namespace and no type;
* - a template that calls `defineStack(config, { strict: false })` skips the
* parse altogether.
*
* Neither would redden a pin that only asserted "the config loads". So the
* load is the first assertion, and the standalone parse is the one that does
* not depend on the door staying the way it is today.
*/

import { describe, it, expect, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ManifestSchema } from '@objectstack/spec/kernel';
import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js';
import { templates as createTemplates } from '../src/commands/create.js';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const TMP_ROOT = path.resolve(HERE, '../tmp');
const PROJECT_NAME = 'my-app';
const CONFIG_FILE = 'objectstack.config.ts';

const roots: string[] = [];
afterAll(() => {
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true });
});

interface Scaffold {
/** `<command>:<template key>` — the command a user would have typed. */
id: string;
/**
* Write this scaffold's TypeScript into `root`, through its own emitter.
*
* TypeScript only, and deliberately: a config that imports `./src/objects`
* needs that module on disk to load at all, while the `package.json` and
* `tsconfig.json` the scaffolders also write are monorepo-relative
* (`workspace:*` deps, `extends: '../../tsconfig.json'`) and resolve to
* nothing from a throwaway directory. Neither one can change what the
* manifest declares, so emitting them would buy a resolution failure and no
* coverage. A future template whose config imports a NON-TypeScript file it
* emits would fail loudly here, on the resolve, rather than quietly.
*/
emit: (root: string) => void;
}

/**
* `os init -t <key>`: every template renders a config, so the whole map
* contributes. Both halves go through `init`'s own emitter — the same
* `configContent` / `writeTemplateSrcFiles` pair the command calls, and the
* pair `init-scaffold-authoring-rules.test.ts` drives, so neither test can
* drift from what `init` really writes.
*/
const initScaffolds: Scaffold[] = Object.keys(TEMPLATES).map((key) => ({
id: `init:${key}`,
emit: (root: string) => {
const namespace = sanitizeNamespace(PROJECT_NAME);
fs.writeFileSync(
path.join(root, CONFIG_FILE),
TEMPLATES[key].configContent(PROJECT_NAME, namespace),
);
writeTemplateSrcFiles(TEMPLATES[key].srcFiles, root, PROJECT_NAME, namespace);
},
}));

/**
* `os create <key> <name>`: only the templates whose file map carries an
* `objectstack.config.ts` contribute — derived from the map, so a template
* that grows one later is swept without an edit here. `create` has no
* `srcFiles` split; every file it writes lives in one `files` map, keyed by
* the path it lands at, and is rendered by calling that entry — which is
* exactly what `Create.run()` does.
*/
const createScaffolds: Scaffold[] = Object.entries(createTemplates)
.filter(([, template]) => CONFIG_FILE in template.files)
.map(([key, template]) => ({
id: `create:${key}`,
emit: (root: string) => {
const files = template.files as Record<string, (name: string) => unknown>;
for (const [filePath, render] of Object.entries(files)) {
if (!filePath.endsWith('.ts')) continue;
const abs = path.join(root, filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, String(render(PROJECT_NAME)));
}
},
}));

const SCAFFOLDS: Scaffold[] = [...initScaffolds, ...createScaffolds];

/** Emit one scaffold into a throwaway directory and load its config back. */
async function loadStack(scaffold: Scaffold): Promise<Record<string, unknown>> {
fs.mkdirSync(TMP_ROOT, { recursive: true });
const root = fs.mkdtempSync(path.join(TMP_ROOT, `manifest-${scaffold.id.replace(':', '-')}-`));
roots.push(root);
scaffold.emit(root);

const { bundleRequire } = await import('bundle-require');
const { mod } = await bundleRequire({ filepath: path.join(root, CONFIG_FILE), cwd: root });
return (mod.default ?? mod) as Record<string, unknown>;
}

describe('every shipped scaffold emits a manifest `ManifestSchema` accepts', () => {
// A sweep that swept nothing reports exactly what a clean tree reports. The
// counts are DERIVED from the two maps rather than frozen, so this stays a
// check that the filters still match something — not a copy of today's
// template list that the next added template makes stale.
it('sweeps both scaffolders, and every template that emits a config', () => {
expect(initScaffolds.length).toBe(Object.keys(TEMPLATES).length);
expect(initScaffolds.length).toBeGreaterThan(0);
expect(createScaffolds.length).toBeGreaterThan(0);
expect(SCAFFOLDS.length).toBe(initScaffolds.length + createScaffolds.length);
});

// The reported instance, named so a future edit that drops the identity
// block again fails with the incident's own vocabulary rather than a bare
// count.
it('includes `os create example` — the scaffold that drifted', () => {
expect(SCAFFOLDS.map((s) => s.id)).toContain('create:example');
});

it.each(SCAFFOLDS.map((s) => s.id))(
'scaffold "%s" declares a manifest the protocol schema accepts',
async (id) => {
const scaffold = SCAFFOLDS.find((s) => s.id === id)!;

let stack: Record<string, unknown>;
try {
stack = await loadStack(scaffold);
} catch (error) {
// `defineStack` refuses an invalid manifest by throwing, so this IS
// the first-run failure the user sees. Re-raise it with the scaffold
// named, because the thrown text alone does not say which one.
throw new Error(
`scaffold "${id}" produces a project that refuses to load — the user's very first `
+ `command fails on this:\n${error instanceof Error ? error.message : String(error)}`,
);
}

// `ObjectStackDefinitionSchema.manifest` is `.optional()`, so a missing
// block is silent at the door above. It is not silent here.
expect(stack.manifest, `scaffold "${id}" declares no \`manifest:\` block`).toBeDefined();

const result = ManifestSchema.safeParse(stack.manifest);
const issues = result.success
? ''
: result.error.issues
.map((i) => `${i.code}@${i.path.join('.') || '<root>'}: ${i.message}`)
.join('\n ');
expect(
result.success,
`scaffold "${id}" emits a manifest \`ManifestSchema\` refuses:\n ${issues}`,
).toBe(true);
},
120_000,
);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Draft
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
37 changes: 37 additions & 0 deletions .changeset/create-scaffold-manifest-identity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/cli": patch
---

fix(cli): `os create example` now scaffolds a manifest the protocol schema accepts

The `objectstack.config.ts` that `os create example <name>` wrote declared
three manifest keys — `name`, `version`, `description` — and nothing else.
`ManifestSchema` requires `id` (the reverse-domain package id) and `type`
(`app` | `plugin` | …), and `namespace` is the mandatory prefix of every object
name, which decides each object's table name and REST path. Parsed against the
schema, the emitted block answered `success: false` with
`invalid_type@id · invalid_value@type`.

`defineStack` throws on exactly that, so the project a documented command had
just created refused to load on its first run — before the author had written a
line. The three `os init` templates all stamped the identity block; this was
the one scaffold that had drifted, and nothing noticed because no test looked
at these templates as data.

The template now stamps what `os init` stamps: `id`, `namespace` (derived from
the project name with `init`'s own `sanitizeNamespace`, so both scaffolders
answer the same way for the same input), `type: 'app'` and
`engines.protocol`, alongside the `version`, `name` and `description` it
already carried. `engines.protocol` is stamped from `PROTOCOL_MAJOR` — the same
constant `init` stamps — and ships with the same self-contained comment
explaining what the range is and when to move it.

A pin sweeps both scaffolders: every `init` and `create` template that emits an
`objectstack.config.ts` is rendered through its own emitter, loaded back, and
its `manifest` parsed through the real `ManifestSchema`. The population is
derived from the two template maps rather than listed, so a template added
later is swept the day it is added.

`os create` itself is untouched — it is not removed, deprecated, or redirected
at `os init`. Whether the two scaffolders should stay separate is a CLI-surface
decision, not this fix.
22 changes: 19 additions & 3 deletions packages/cli/src/commands/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
import { sanitizeNamespace } from './init.js';

export const templates = {
plugin: {
Expand DownExpand Up@@ -119,7 +121,9 @@ MIT
vitest: '^4.0.0',
},
}),
'objectstack.config.ts': (name: string) => `import { defineStack } from '@objectstack/spec';
'objectstack.config.ts': (name: string) => {
const namespace = sanitizeNamespace(name);
return `import { defineStack } from '@objectstack/spec';

// Barrel imports — add more as you create new type folders
// import * as objects from './src/objects';
Expand All@@ -128,9 +132,20 @@ MIT

export default defineStack({
manifest: {
name: '${name}',
id: 'com.example.${namespace}',
namespace: '${namespace}',
version: '0.1.0',
type: 'app',
name: '${name}',
description: '${name} example application',
// Protocol compatibility range: the metadata-protocol major this app is
// authored against. The runtime checks it before it loads anything, so a
// runtime outside the range refuses this app at the boundary with the
// exact migration command instead of crashing later. Scaffolding stamped
// it to match the ObjectStack version you installed — change it when you
// deliberately move to a new protocol major, not to silence a mismatch.
// Guide: https://objectstack.ai/docs/upgrading
engines: { protocol: '^${PROTOCOL_MAJOR}' },
},

objects: [
Expand All@@ -141,7 +156,8 @@ export default defineStack({
// Object.values(apps), // Uncomment after creating src/apps/index.ts
],
});
`,
`;
},
'README.md': (name: string) => `# ${name} Example

ObjectStack example application: ${name}
Expand Down
223 changes: 223 additions & 0 deletions packages/cli/test/scaffold-manifest-schema.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Every scaffold this package ships must emit a `manifest:` block that
* `ManifestSchema` accepts — the schema the user's very first command parses
* it with.
*
* ## The defect this pins
*
* `os create example <name>` wrote an `objectstack.config.ts` whose manifest
* was three keys — `name`, `version`, `description` — and nothing else.
* `ManifestSchema` requires `id` (the reverse-domain package id) and `type`
* (`app` | `plugin` | …), and `namespace` decides every object name, table
* name and REST path (there was none). Parsed against the schema the block
* answered:
*
* success : false
* issues : invalid_type@id · invalid_value@type
*
* `defineStack` throws on exactly that, so a project scaffolded by a
* documented command refused on its first run, before the author had written
* a line. The three `os init` templates all stamped the identity block; the
* `create` template was the one scaffold that had drifted, and nothing
* noticed because no test ever looked at these templates as DATA.
*
* ## Why the sweep spans both scaffolders, and why it is derived
*
* The defect class is "a shipped scaffold whose manifest the shipped schema
* refuses", and this package has two independent scaffold sources —
* `init.ts`'s `TEMPLATES` and `create.ts`'s `templates`. Pinning only the
* reported one would leave the other free to drift the same way, which is how
* this one arrived. So the population is DERIVED from both maps (every entry
* that emits an `objectstack.config.ts`), never written down: a template added
* later is swept the day it is added, with nobody remembering to extend this
* file.
*
* `create`'s `plugin` template contributes nothing here on purpose — it emits
* no `objectstack.config.ts` at all. Its scaffolded `src/index.ts` declares a
* `Plugin` object, a different contract from this package manifest, and a
* sweep that pretended otherwise would report on a surface `ManifestSchema`
* does not govern.
*
* ## Why the manifest is read back off a LOADED config, not off the source text
*
* Both scaffolders render their config as a template literal, so the only
* honest reading of "what the scaffold declares" is the object the rendered
* file actually evaluates to. The rendered file is written to disk and loaded
* through `bundle-require` — the same loader `scaffold-validate.ts` uses for
* `init`'s self-test — so what is parsed here is the real emitted artifact and
* not a literal copied into a test, which is free to agree with a template
* that has since changed.
*
* Temp projects go under this package's git-ignored `tmp/` (not
* `os.tmpdir()`) because the rendered config imports `@objectstack/spec`,
* which only resolves where Node can walk up into this package's
* `node_modules` — the same constraint, for the same reason, as
* `init-scaffold-authoring-rules.test.ts`. Keeping generated `.ts` out of
* `test/` also keeps it away from any glob that collects sources.
*
* ## Why `ManifestSchema` is parsed explicitly, when `defineStack` already ran
*
* `defineStack` validates through `ObjectStackDefinitionSchema`, where
* `manifest` is `ManifestSchema.optional()`. Two live gaps follow from that
* `.optional()`, and both are the failure this file exists to catch:
*
* - a template that drops the `manifest:` block ENTIRELY loads green — the
* stack door has nothing to check — and ships a project with no id, no
* namespace and no type;
* - a template that calls `defineStack(config, { strict: false })` skips the
* parse altogether.
*
* Neither would redden a pin that only asserted "the config loads". So the
* load is the first assertion, and the standalone parse is the one that does
* not depend on the door staying the way it is today.
*/

import { describe, it, expect, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ManifestSchema } from '@objectstack/spec/kernel';
import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js';
import { templates as createTemplates } from '../src/commands/create.js';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const TMP_ROOT = path.resolve(HERE, '../tmp');
const PROJECT_NAME = 'my-app';
const CONFIG_FILE = 'objectstack.config.ts';

const roots: string[] = [];
afterAll(() => {
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true });
});

interface Scaffold {
/** `<command>:<template key>` — the command a user would have typed. */
id: string;
/**
* Write this scaffold's TypeScript into `root`, through its own emitter.
*
* TypeScript only, and deliberately: a config that imports `./src/objects`
* needs that module on disk to load at all, while the `package.json` and
* `tsconfig.json` the scaffolders also write are monorepo-relative
* (`workspace:*` deps, `extends: '../../tsconfig.json'`) and resolve to
* nothing from a throwaway directory. Neither one can change what the
* manifest declares, so emitting them would buy a resolution failure and no
* coverage. A future template whose config imports a NON-TypeScript file it
* emits would fail loudly here, on the resolve, rather than quietly.
*/
emit: (root: string) => void;
}

/**
* `os init -t <key>`: every template renders a config, so the whole map
* contributes. Both halves go through `init`'s own emitter — the same
* `configContent` / `writeTemplateSrcFiles` pair the command calls, and the
* pair `init-scaffold-authoring-rules.test.ts` drives, so neither test can
* drift from what `init` really writes.
*/
const initScaffolds: Scaffold[] = Object.keys(TEMPLATES).map((key) => ({
id: `init:${key}`,
emit: (root: string) => {
const namespace = sanitizeNamespace(PROJECT_NAME);
fs.writeFileSync(
path.join(root, CONFIG_FILE),
TEMPLATES[key].configContent(PROJECT_NAME, namespace),
);
writeTemplateSrcFiles(TEMPLATES[key].srcFiles, root, PROJECT_NAME, namespace);
},
}));

/**
* `os create <key> <name>`: only the templates whose file map carries an
* `objectstack.config.ts` contribute — derived from the map, so a template
* that grows one later is swept without an edit here. `create` has no
* `srcFiles` split; every file it writes lives in one `files` map, keyed by
* the path it lands at, and is rendered by calling that entry — which is
* exactly what `Create.run()` does.
*/
const createScaffolds: Scaffold[] = Object.entries(createTemplates)
.filter(([, template]) => CONFIG_FILE in template.files)
.map(([key, template]) => ({
id: `create:${key}`,
emit: (root: string) => {
const files = template.files as Record<string, (name: string) => unknown>;
for (const [filePath, render] of Object.entries(files)) {
if (!filePath.endsWith('.ts')) continue;
const abs = path.join(root, filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, String(render(PROJECT_NAME)));
}
},
}));

const SCAFFOLDS: Scaffold[] = [...initScaffolds, ...createScaffolds];

/** Emit one scaffold into a throwaway directory and load its config back. */
async function loadStack(scaffold: Scaffold): Promise<Record<string, unknown>> {
fs.mkdirSync(TMP_ROOT, { recursive: true });
const root = fs.mkdtempSync(path.join(TMP_ROOT, `manifest-${scaffold.id.replace(':', '-')}-`));
roots.push(root);
scaffold.emit(root);

const { bundleRequire } = await import('bundle-require');
const { mod } = await bundleRequire({ filepath: path.join(root, CONFIG_FILE), cwd: root });
return (mod.default ?? mod) as Record<string, unknown>;
}

describe('every shipped scaffold emits a manifest `ManifestSchema` accepts', () => {
// A sweep that swept nothing reports exactly what a clean tree reports. The
// counts are DERIVED from the two maps rather than frozen, so this stays a
// check that the filters still match something — not a copy of today's
// template list that the next added template makes stale.
it('sweeps both scaffolders, and every template that emits a config', () => {
expect(initScaffolds.length).toBe(Object.keys(TEMPLATES).length);
expect(initScaffolds.length).toBeGreaterThan(0);
expect(createScaffolds.length).toBeGreaterThan(0);
expect(SCAFFOLDS.length).toBe(initScaffolds.length + createScaffolds.length);
});

// The reported instance, named so a future edit that drops the identity
// block again fails with the incident's own vocabulary rather than a bare
// count.
it('includes `os create example` — the scaffold that drifted', () => {
expect(SCAFFOLDS.map((s) => s.id)).toContain('create:example');
});

it.each(SCAFFOLDS.map((s) => s.id))(
'scaffold "%s" declares a manifest the protocol schema accepts',
async (id) => {
const scaffold = SCAFFOLDS.find((s) => s.id === id)!;

let stack: Record<string, unknown>;
try {
stack = await loadStack(scaffold);
} catch (error) {
// `defineStack` refuses an invalid manifest by throwing, so this IS
// the first-run failure the user sees. Re-raise it with the scaffold
// named, because the thrown text alone does not say which one.
throw new Error(
`scaffold "${id}" produces a project that refuses to load — the user's very first `
+ `command fails on this:\n${error instanceof Error ? error.message : String(error)}`,
);
}

// `ObjectStackDefinitionSchema.manifest` is `.optional()`, so a missing
// block is silent at the door above. It is not silent here.
expect(stack.manifest, `scaffold "${id}" declares no \`manifest:\` block`).toBeDefined();

const result = ManifestSchema.safeParse(stack.manifest);
const issues = result.success
? ''
: result.error.issues
.map((i) => `${i.code}@${i.path.join('.') || '<root>'}: ${i.message}`)
.join('\n ');
expect(
result.success,
`scaffold "${id}" emits a manifest \`ManifestSchema\` refuses:\n ${issues}`,
).toBe(true);
},
120_000,
);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
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
37 changes: 37 additions & 0 deletions .changeset/create-scaffold-manifest-identity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/cli": patch
---

fix(cli): `os create example` now scaffolds a manifest the protocol schema accepts

The `objectstack.config.ts` that `os create example <name>` wrote declared
three manifest keys — `name`, `version`, `description` — and nothing else.
`ManifestSchema` requires `id` (the reverse-domain package id) and `type`
(`app` | `plugin` | …), and `namespace` is the mandatory prefix of every object
name, which decides each object's table name and REST path. Parsed against the
schema, the emitted block answered `success: false` with
`invalid_type@id · invalid_value@type`.

`defineStack` throws on exactly that, so the project a documented command had
just created refused to load on its first run — before the author had written a
line. The three `os init` templates all stamped the identity block; this was
the one scaffold that had drifted, and nothing noticed because no test looked
at these templates as data.

The template now stamps what `os init` stamps: `id`, `namespace` (derived from
the project name with `init`'s own `sanitizeNamespace`, so both scaffolders
answer the same way for the same input), `type: 'app'` and
`engines.protocol`, alongside the `version`, `name` and `description` it
already carried. `engines.protocol` is stamped from `PROTOCOL_MAJOR` — the same
constant `init` stamps — and ships with the same self-contained comment
explaining what the range is and when to move it.

A pin sweeps both scaffolders: every `init` and `create` template that emits an
`objectstack.config.ts` is rendered through its own emitter, loaded back, and
its `manifest` parsed through the real `ManifestSchema`. The population is
derived from the two template maps rather than listed, so a template added
later is swept the day it is added.

`os create` itself is untouched — it is not removed, deprecated, or redirected
at `os init`. Whether the two scaffolders should stay separate is a CLI-surface
decision, not this fix.
22 changes: 19 additions & 3 deletions packages/cli/src/commands/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
import { sanitizeNamespace } from './init.js';

export const templates = {
plugin: {
Expand DownExpand Up@@ -119,7 +121,9 @@ MIT
vitest: '^4.0.0',
},
}),
'objectstack.config.ts': (name: string) => `import { defineStack } from '@objectstack/spec';
'objectstack.config.ts': (name: string) => {
const namespace = sanitizeNamespace(name);
return `import { defineStack } from '@objectstack/spec';

// Barrel imports — add more as you create new type folders
// import * as objects from './src/objects';
Expand All@@ -128,9 +132,20 @@ MIT

export default defineStack({
manifest: {
name: '${name}',
id: 'com.example.${namespace}',
namespace: '${namespace}',
version: '0.1.0',
type: 'app',
name: '${name}',
description: '${name} example application',
// Protocol compatibility range: the metadata-protocol major this app is
// authored against. The runtime checks it before it loads anything, so a
// runtime outside the range refuses this app at the boundary with the
// exact migration command instead of crashing later. Scaffolding stamped
// it to match the ObjectStack version you installed — change it when you
// deliberately move to a new protocol major, not to silence a mismatch.
// Guide: https://objectstack.ai/docs/upgrading
engines: { protocol: '^${PROTOCOL_MAJOR}' },
},

objects: [
Expand All@@ -141,7 +156,8 @@ export default defineStack({
// Object.values(apps), // Uncomment after creating src/apps/index.ts
],
});
`,
`;
},
'README.md': (name: string) => `# ${name} Example

ObjectStack example application: ${name}
Expand Down
223 changes: 223 additions & 0 deletions packages/cli/test/scaffold-manifest-schema.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Every scaffold this package ships must emit a `manifest:` block that
* `ManifestSchema` accepts — the schema the user's very first command parses
* it with.
*
* ## The defect this pins
*
* `os create example <name>` wrote an `objectstack.config.ts` whose manifest
* was three keys — `name`, `version`, `description` — and nothing else.
* `ManifestSchema` requires `id` (the reverse-domain package id) and `type`
* (`app` | `plugin` | …), and `namespace` decides every object name, table
* name and REST path (there was none). Parsed against the schema the block
* answered:
*
* success : false
* issues : invalid_type@id · invalid_value@type
*
* `defineStack` throws on exactly that, so a project scaffolded by a
* documented command refused on its first run, before the author had written
* a line. The three `os init` templates all stamped the identity block; the
* `create` template was the one scaffold that had drifted, and nothing
* noticed because no test ever looked at these templates as DATA.
*
* ## Why the sweep spans both scaffolders, and why it is derived
*
* The defect class is "a shipped scaffold whose manifest the shipped schema
* refuses", and this package has two independent scaffold sources —
* `init.ts`'s `TEMPLATES` and `create.ts`'s `templates`. Pinning only the
* reported one would leave the other free to drift the same way, which is how
* this one arrived. So the population is DERIVED from both maps (every entry
* that emits an `objectstack.config.ts`), never written down: a template added
* later is swept the day it is added, with nobody remembering to extend this
* file.
*
* `create`'s `plugin` template contributes nothing here on purpose — it emits
* no `objectstack.config.ts` at all. Its scaffolded `src/index.ts` declares a
* `Plugin` object, a different contract from this package manifest, and a
* sweep that pretended otherwise would report on a surface `ManifestSchema`
* does not govern.
*
* ## Why the manifest is read back off a LOADED config, not off the source text
*
* Both scaffolders render their config as a template literal, so the only
* honest reading of "what the scaffold declares" is the object the rendered
* file actually evaluates to. The rendered file is written to disk and loaded
* through `bundle-require` — the same loader `scaffold-validate.ts` uses for
* `init`'s self-test — so what is parsed here is the real emitted artifact and
* not a literal copied into a test, which is free to agree with a template
* that has since changed.
*
* Temp projects go under this package's git-ignored `tmp/` (not
* `os.tmpdir()`) because the rendered config imports `@objectstack/spec`,
* which only resolves where Node can walk up into this package's
* `node_modules` — the same constraint, for the same reason, as
* `init-scaffold-authoring-rules.test.ts`. Keeping generated `.ts` out of
* `test/` also keeps it away from any glob that collects sources.
*
* ## Why `ManifestSchema` is parsed explicitly, when `defineStack` already ran
*
* `defineStack` validates through `ObjectStackDefinitionSchema`, where
* `manifest` is `ManifestSchema.optional()`. Two live gaps follow from that
* `.optional()`, and both are the failure this file exists to catch:
*
* - a template that drops the `manifest:` block ENTIRELY loads green — the
* stack door has nothing to check — and ships a project with no id, no
* namespace and no type;
* - a template that calls `defineStack(config, { strict: false })` skips the
* parse altogether.
*
* Neither would redden a pin that only asserted "the config loads". So the
* load is the first assertion, and the standalone parse is the one that does
* not depend on the door staying the way it is today.
*/

import { describe, it, expect, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ManifestSchema } from '@objectstack/spec/kernel';
import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js';
import { templates as createTemplates } from '../src/commands/create.js';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const TMP_ROOT = path.resolve(HERE, '../tmp');
const PROJECT_NAME = 'my-app';
const CONFIG_FILE = 'objectstack.config.ts';

const roots: string[] = [];
afterAll(() => {
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true });
});

interface Scaffold {
/** `<command>:<template key>` — the command a user would have typed. */
id: string;
/**
* Write this scaffold's TypeScript into `root`, through its own emitter.
*
* TypeScript only, and deliberately: a config that imports `./src/objects`
* needs that module on disk to load at all, while the `package.json` and
* `tsconfig.json` the scaffolders also write are monorepo-relative
* (`workspace:*` deps, `extends: '../../tsconfig.json'`) and resolve to
* nothing from a throwaway directory. Neither one can change what the
* manifest declares, so emitting them would buy a resolution failure and no
* coverage. A future template whose config imports a NON-TypeScript file it
* emits would fail loudly here, on the resolve, rather than quietly.
*/
emit: (root: string) => void;
}

/**
* `os init -t <key>`: every template renders a config, so the whole map
* contributes. Both halves go through `init`'s own emitter — the same
* `configContent` / `writeTemplateSrcFiles` pair the command calls, and the
* pair `init-scaffold-authoring-rules.test.ts` drives, so neither test can
* drift from what `init` really writes.
*/
const initScaffolds: Scaffold[] = Object.keys(TEMPLATES).map((key) => ({
id: `init:${key}`,
emit: (root: string) => {
const namespace = sanitizeNamespace(PROJECT_NAME);
fs.writeFileSync(
path.join(root, CONFIG_FILE),
TEMPLATES[key].configContent(PROJECT_NAME, namespace),
);
writeTemplateSrcFiles(TEMPLATES[key].srcFiles, root, PROJECT_NAME, namespace);
},
}));

/**
* `os create <key> <name>`: only the templates whose file map carries an
* `objectstack.config.ts` contribute — derived from the map, so a template
* that grows one later is swept without an edit here. `create` has no
* `srcFiles` split; every file it writes lives in one `files` map, keyed by
* the path it lands at, and is rendered by calling that entry — which is
* exactly what `Create.run()` does.
*/
const createScaffolds: Scaffold[] = Object.entries(createTemplates)
.filter(([, template]) => CONFIG_FILE in template.files)
.map(([key, template]) => ({
id: `create:${key}`,
emit: (root: string) => {
const files = template.files as Record<string, (name: string) => unknown>;
for (const [filePath, render] of Object.entries(files)) {
if (!filePath.endsWith('.ts')) continue;
const abs = path.join(root, filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, String(render(PROJECT_NAME)));
}
},
}));

const SCAFFOLDS: Scaffold[] = [...initScaffolds, ...createScaffolds];

/** Emit one scaffold into a throwaway directory and load its config back. */
async function loadStack(scaffold: Scaffold): Promise<Record<string, unknown>> {
fs.mkdirSync(TMP_ROOT, { recursive: true });
const root = fs.mkdtempSync(path.join(TMP_ROOT, `manifest-${scaffold.id.replace(':', '-')}-`));
roots.push(root);
scaffold.emit(root);

const { bundleRequire } = await import('bundle-require');
const { mod } = await bundleRequire({ filepath: path.join(root, CONFIG_FILE), cwd: root });
return (mod.default ?? mod) as Record<string, unknown>;
}

describe('every shipped scaffold emits a manifest `ManifestSchema` accepts', () => {
// A sweep that swept nothing reports exactly what a clean tree reports. The
// counts are DERIVED from the two maps rather than frozen, so this stays a
// check that the filters still match something — not a copy of today's
// template list that the next added template makes stale.
it('sweeps both scaffolders, and every template that emits a config', () => {
expect(initScaffolds.length).toBe(Object.keys(TEMPLATES).length);
expect(initScaffolds.length).toBeGreaterThan(0);
expect(createScaffolds.length).toBeGreaterThan(0);
expect(SCAFFOLDS.length).toBe(initScaffolds.length + createScaffolds.length);
});

// The reported instance, named so a future edit that drops the identity
// block again fails with the incident's own vocabulary rather than a bare
// count.
it('includes `os create example` — the scaffold that drifted', () => {
expect(SCAFFOLDS.map((s) => s.id)).toContain('create:example');
});

it.each(SCAFFOLDS.map((s) => s.id))(
'scaffold "%s" declares a manifest the protocol schema accepts',
async (id) => {
const scaffold = SCAFFOLDS.find((s) => s.id === id)!;

let stack: Record<string, unknown>;
try {
stack = await loadStack(scaffold);
} catch (error) {
// `defineStack` refuses an invalid manifest by throwing, so this IS
// the first-run failure the user sees. Re-raise it with the scaffold
// named, because the thrown text alone does not say which one.
throw new Error(
`scaffold "${id}" produces a project that refuses to load — the user's very first `
+ `command fails on this:\n${error instanceof Error ? error.message : String(error)}`,
);
}

// `ObjectStackDefinitionSchema.manifest` is `.optional()`, so a missing
// block is silent at the door above. It is not silent here.
expect(stack.manifest, `scaffold "${id}" declares no \`manifest:\` block`).toBeDefined();

const result = ManifestSchema.safeParse(stack.manifest);
const issues = result.success
? ''
: result.error.issues
.map((i) => `${i.code}@${i.path.join('.') || '<root>'}: ${i.message}`)
.join('\n ');
expect(
result.success,
`scaffold "${id}" emits a manifest \`ManifestSchema\` refuses:\n ${issues}`,
).toBe(true);
},
120_000,
);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
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
37 changes: 37 additions & 0 deletions .changeset/create-scaffold-manifest-identity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/cli": patch
---

fix(cli): `os create example` now scaffolds a manifest the protocol schema accepts

The `objectstack.config.ts` that `os create example <name>` wrote declared
three manifest keys — `name`, `version`, `description` — and nothing else.
`ManifestSchema` requires `id` (the reverse-domain package id) and `type`
(`app` | `plugin` | …), and `namespace` is the mandatory prefix of every object
name, which decides each object's table name and REST path. Parsed against the
schema, the emitted block answered `success: false` with
`invalid_type@id · invalid_value@type`.

`defineStack` throws on exactly that, so the project a documented command had
just created refused to load on its first run — before the author had written a
line. The three `os init` templates all stamped the identity block; this was
the one scaffold that had drifted, and nothing noticed because no test looked
at these templates as data.

The template now stamps what `os init` stamps: `id`, `namespace` (derived from
the project name with `init`'s own `sanitizeNamespace`, so both scaffolders
answer the same way for the same input), `type: 'app'` and
`engines.protocol`, alongside the `version`, `name` and `description` it
already carried. `engines.protocol` is stamped from `PROTOCOL_MAJOR` — the same
constant `init` stamps — and ships with the same self-contained comment
explaining what the range is and when to move it.

A pin sweeps both scaffolders: every `init` and `create` template that emits an
`objectstack.config.ts` is rendered through its own emitter, loaded back, and
its `manifest` parsed through the real `ManifestSchema`. The population is
derived from the two template maps rather than listed, so a template added
later is swept the day it is added.

`os create` itself is untouched — it is not removed, deprecated, or redirected
at `os init`. Whether the two scaffolders should stay separate is a CLI-surface
decision, not this fix.
22 changes: 19 additions & 3 deletions packages/cli/src/commands/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
import { sanitizeNamespace } from './init.js';

export const templates = {
plugin: {
Expand DownExpand Up@@ -119,7 +121,9 @@ MIT
vitest: '^4.0.0',
},
}),
'objectstack.config.ts': (name: string) => `import { defineStack } from '@objectstack/spec';
'objectstack.config.ts': (name: string) => {
const namespace = sanitizeNamespace(name);
return `import { defineStack } from '@objectstack/spec';

// Barrel imports — add more as you create new type folders
// import * as objects from './src/objects';
Expand All@@ -128,9 +132,20 @@ MIT

export default defineStack({
manifest: {
name: '${name}',
id: 'com.example.${namespace}',
namespace: '${namespace}',
version: '0.1.0',
type: 'app',
name: '${name}',
description: '${name} example application',
// Protocol compatibility range: the metadata-protocol major this app is
// authored against. The runtime checks it before it loads anything, so a
// runtime outside the range refuses this app at the boundary with the
// exact migration command instead of crashing later. Scaffolding stamped
// it to match the ObjectStack version you installed — change it when you
// deliberately move to a new protocol major, not to silence a mismatch.
// Guide: https://objectstack.ai/docs/upgrading
engines: { protocol: '^${PROTOCOL_MAJOR}' },
},

objects: [
Expand All@@ -141,7 +156,8 @@ export default defineStack({
// Object.values(apps), // Uncomment after creating src/apps/index.ts
],
});
`,
`;
},
'README.md': (name: string) => `# ${name} Example

ObjectStack example application: ${name}
Expand Down
223 changes: 223 additions & 0 deletions packages/cli/test/scaffold-manifest-schema.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Every scaffold this package ships must emit a `manifest:` block that
* `ManifestSchema` accepts — the schema the user's very first command parses
* it with.
*
* ## The defect this pins
*
* `os create example <name>` wrote an `objectstack.config.ts` whose manifest
* was three keys — `name`, `version`, `description` — and nothing else.
* `ManifestSchema` requires `id` (the reverse-domain package id) and `type`
* (`app` | `plugin` | …), and `namespace` decides every object name, table
* name and REST path (there was none). Parsed against the schema the block
* answered:
*
* success : false
* issues : invalid_type@id · invalid_value@type
*
* `defineStack` throws on exactly that, so a project scaffolded by a
* documented command refused on its first run, before the author had written
* a line. The three `os init` templates all stamped the identity block; the
* `create` template was the one scaffold that had drifted, and nothing
* noticed because no test ever looked at these templates as DATA.
*
* ## Why the sweep spans both scaffolders, and why it is derived
*
* The defect class is "a shipped scaffold whose manifest the shipped schema
* refuses", and this package has two independent scaffold sources —
* `init.ts`'s `TEMPLATES` and `create.ts`'s `templates`. Pinning only the
* reported one would leave the other free to drift the same way, which is how
* this one arrived. So the population is DERIVED from both maps (every entry
* that emits an `objectstack.config.ts`), never written down: a template added
* later is swept the day it is added, with nobody remembering to extend this
* file.
*
* `create`'s `plugin` template contributes nothing here on purpose — it emits
* no `objectstack.config.ts` at all. Its scaffolded `src/index.ts` declares a
* `Plugin` object, a different contract from this package manifest, and a
* sweep that pretended otherwise would report on a surface `ManifestSchema`
* does not govern.
*
* ## Why the manifest is read back off a LOADED config, not off the source text
*
* Both scaffolders render their config as a template literal, so the only
* honest reading of "what the scaffold declares" is the object the rendered
* file actually evaluates to. The rendered file is written to disk and loaded
* through `bundle-require` — the same loader `scaffold-validate.ts` uses for
* `init`'s self-test — so what is parsed here is the real emitted artifact and
* not a literal copied into a test, which is free to agree with a template
* that has since changed.
*
* Temp projects go under this package's git-ignored `tmp/` (not
* `os.tmpdir()`) because the rendered config imports `@objectstack/spec`,
* which only resolves where Node can walk up into this package's
* `node_modules` — the same constraint, for the same reason, as
* `init-scaffold-authoring-rules.test.ts`. Keeping generated `.ts` out of
* `test/` also keeps it away from any glob that collects sources.
*
* ## Why `ManifestSchema` is parsed explicitly, when `defineStack` already ran
*
* `defineStack` validates through `ObjectStackDefinitionSchema`, where
* `manifest` is `ManifestSchema.optional()`. Two live gaps follow from that
* `.optional()`, and both are the failure this file exists to catch:
*
* - a template that drops the `manifest:` block ENTIRELY loads green — the
* stack door has nothing to check — and ships a project with no id, no
* namespace and no type;
* - a template that calls `defineStack(config, { strict: false })` skips the
* parse altogether.
*
* Neither would redden a pin that only asserted "the config loads". So the
* load is the first assertion, and the standalone parse is the one that does
* not depend on the door staying the way it is today.
*/

import { describe, it, expect, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ManifestSchema } from '@objectstack/spec/kernel';
import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js';
import { templates as createTemplates } from '../src/commands/create.js';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const TMP_ROOT = path.resolve(HERE, '../tmp');
const PROJECT_NAME = 'my-app';
const CONFIG_FILE = 'objectstack.config.ts';

const roots: string[] = [];
afterAll(() => {
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true });
});

interface Scaffold {
/** `<command>:<template key>` — the command a user would have typed. */
id: string;
/**
* Write this scaffold's TypeScript into `root`, through its own emitter.
*
* TypeScript only, and deliberately: a config that imports `./src/objects`
* needs that module on disk to load at all, while the `package.json` and
* `tsconfig.json` the scaffolders also write are monorepo-relative
* (`workspace:*` deps, `extends: '../../tsconfig.json'`) and resolve to
* nothing from a throwaway directory. Neither one can change what the
* manifest declares, so emitting them would buy a resolution failure and no
* coverage. A future template whose config imports a NON-TypeScript file it
* emits would fail loudly here, on the resolve, rather than quietly.
*/
emit: (root: string) => void;
}

/**
* `os init -t <key>`: every template renders a config, so the whole map
* contributes. Both halves go through `init`'s own emitter — the same
* `configContent` / `writeTemplateSrcFiles` pair the command calls, and the
* pair `init-scaffold-authoring-rules.test.ts` drives, so neither test can
* drift from what `init` really writes.
*/
const initScaffolds: Scaffold[] = Object.keys(TEMPLATES).map((key) => ({
id: `init:${key}`,
emit: (root: string) => {
const namespace = sanitizeNamespace(PROJECT_NAME);
fs.writeFileSync(
path.join(root, CONFIG_FILE),
TEMPLATES[key].configContent(PROJECT_NAME, namespace),
);
writeTemplateSrcFiles(TEMPLATES[key].srcFiles, root, PROJECT_NAME, namespace);
},
}));

/**
* `os create <key> <name>`: only the templates whose file map carries an
* `objectstack.config.ts` contribute — derived from the map, so a template
* that grows one later is swept without an edit here. `create` has no
* `srcFiles` split; every file it writes lives in one `files` map, keyed by
* the path it lands at, and is rendered by calling that entry — which is
* exactly what `Create.run()` does.
*/
const createScaffolds: Scaffold[] = Object.entries(createTemplates)
.filter(([, template]) => CONFIG_FILE in template.files)
.map(([key, template]) => ({
id: `create:${key}`,
emit: (root: string) => {
const files = template.files as Record<string, (name: string) => unknown>;
for (const [filePath, render] of Object.entries(files)) {
if (!filePath.endsWith('.ts')) continue;
const abs = path.join(root, filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, String(render(PROJECT_NAME)));
}
},
}));

const SCAFFOLDS: Scaffold[] = [...initScaffolds, ...createScaffolds];

/** Emit one scaffold into a throwaway directory and load its config back. */
async function loadStack(scaffold: Scaffold): Promise<Record<string, unknown>> {
fs.mkdirSync(TMP_ROOT, { recursive: true });
const root = fs.mkdtempSync(path.join(TMP_ROOT, `manifest-${scaffold.id.replace(':', '-')}-`));
roots.push(root);
scaffold.emit(root);

const { bundleRequire } = await import('bundle-require');
const { mod } = await bundleRequire({ filepath: path.join(root, CONFIG_FILE), cwd: root });
return (mod.default ?? mod) as Record<string, unknown>;
}

describe('every shipped scaffold emits a manifest `ManifestSchema` accepts', () => {
// A sweep that swept nothing reports exactly what a clean tree reports. The
// counts are DERIVED from the two maps rather than frozen, so this stays a
// check that the filters still match something — not a copy of today's
// template list that the next added template makes stale.
it('sweeps both scaffolders, and every template that emits a config', () => {
expect(initScaffolds.length).toBe(Object.keys(TEMPLATES).length);
expect(initScaffolds.length).toBeGreaterThan(0);
expect(createScaffolds.length).toBeGreaterThan(0);
expect(SCAFFOLDS.length).toBe(initScaffolds.length + createScaffolds.length);
});

// The reported instance, named so a future edit that drops the identity
// block again fails with the incident's own vocabulary rather than a bare
// count.
it('includes `os create example` — the scaffold that drifted', () => {
expect(SCAFFOLDS.map((s) => s.id)).toContain('create:example');
});

it.each(SCAFFOLDS.map((s) => s.id))(
'scaffold "%s" declares a manifest the protocol schema accepts',
async (id) => {
const scaffold = SCAFFOLDS.find((s) => s.id === id)!;

let stack: Record<string, unknown>;
try {
stack = await loadStack(scaffold);
} catch (error) {
// `defineStack` refuses an invalid manifest by throwing, so this IS
// the first-run failure the user sees. Re-raise it with the scaffold
// named, because the thrown text alone does not say which one.
throw new Error(
`scaffold "${id}" produces a project that refuses to load — the user's very first `
+ `command fails on this:\n${error instanceof Error ? error.message : String(error)}`,
);
}

// `ObjectStackDefinitionSchema.manifest` is `.optional()`, so a missing
// block is silent at the door above. It is not silent here.
expect(stack.manifest, `scaffold "${id}" declares no \`manifest:\` block`).toBeDefined();

const result = ManifestSchema.safeParse(stack.manifest);
const issues = result.success
? ''
: result.error.issues
.map((i) => `${i.code}@${i.path.join('.') || '<root>'}: ${i.message}`)
.join('\n ');
expect(
result.success,
`scaffold "${id}" emits a manifest \`ManifestSchema\` refuses:\n ${issues}`,
).toBe(true);
},
120_000,
);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Draft
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
37 changes: 37 additions & 0 deletions .changeset/create-scaffold-manifest-identity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/cli": patch
---

fix(cli): `os create example` now scaffolds a manifest the protocol schema accepts

The `objectstack.config.ts` that `os create example <name>` wrote declared
three manifest keys — `name`, `version`, `description` — and nothing else.
`ManifestSchema` requires `id` (the reverse-domain package id) and `type`
(`app` | `plugin` | …), and `namespace` is the mandatory prefix of every object
name, which decides each object's table name and REST path. Parsed against the
schema, the emitted block answered `success: false` with
`invalid_type@id · invalid_value@type`.

`defineStack` throws on exactly that, so the project a documented command had
just created refused to load on its first run — before the author had written a
line. The three `os init` templates all stamped the identity block; this was
the one scaffold that had drifted, and nothing noticed because no test looked
at these templates as data.

The template now stamps what `os init` stamps: `id`, `namespace` (derived from
the project name with `init`'s own `sanitizeNamespace`, so both scaffolders
answer the same way for the same input), `type: 'app'` and
`engines.protocol`, alongside the `version`, `name` and `description` it
already carried. `engines.protocol` is stamped from `PROTOCOL_MAJOR` — the same
constant `init` stamps — and ships with the same self-contained comment
explaining what the range is and when to move it.

A pin sweeps both scaffolders: every `init` and `create` template that emits an
`objectstack.config.ts` is rendered through its own emitter, loaded back, and
its `manifest` parsed through the real `ManifestSchema`. The population is
derived from the two template maps rather than listed, so a template added
later is swept the day it is added.

`os create` itself is untouched — it is not removed, deprecated, or redirected
at `os init`. Whether the two scaffolders should stay separate is a CLI-surface
decision, not this fix.
22 changes: 19 additions & 3 deletions packages/cli/src/commands/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
import { sanitizeNamespace } from './init.js';

export const templates = {
plugin: {
Expand DownExpand Up@@ -119,7 +121,9 @@ MIT
vitest: '^4.0.0',
},
}),
'objectstack.config.ts': (name: string) => `import { defineStack } from '@objectstack/spec';
'objectstack.config.ts': (name: string) => {
const namespace = sanitizeNamespace(name);
return `import { defineStack } from '@objectstack/spec';

// Barrel imports — add more as you create new type folders
// import * as objects from './src/objects';
Expand All@@ -128,9 +132,20 @@ MIT

export default defineStack({
manifest: {
name: '${name}',
id: 'com.example.${namespace}',
namespace: '${namespace}',
version: '0.1.0',
type: 'app',
name: '${name}',
description: '${name} example application',
// Protocol compatibility range: the metadata-protocol major this app is
// authored against. The runtime checks it before it loads anything, so a
// runtime outside the range refuses this app at the boundary with the
// exact migration command instead of crashing later. Scaffolding stamped
// it to match the ObjectStack version you installed — change it when you
// deliberately move to a new protocol major, not to silence a mismatch.
// Guide: https://objectstack.ai/docs/upgrading
engines: { protocol: '^${PROTOCOL_MAJOR}' },
},

objects: [
Expand All@@ -141,7 +156,8 @@ export default defineStack({
// Object.values(apps), // Uncomment after creating src/apps/index.ts
],
});
`,
`;
},
'README.md': (name: string) => `# ${name} Example

ObjectStack example application: ${name}
Expand Down
223 changes: 223 additions & 0 deletions packages/cli/test/scaffold-manifest-schema.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Every scaffold this package ships must emit a `manifest:` block that
* `ManifestSchema` accepts — the schema the user's very first command parses
* it with.
*
* ## The defect this pins
*
* `os create example <name>` wrote an `objectstack.config.ts` whose manifest
* was three keys — `name`, `version`, `description` — and nothing else.
* `ManifestSchema` requires `id` (the reverse-domain package id) and `type`
* (`app` | `plugin` | …), and `namespace` decides every object name, table
* name and REST path (there was none). Parsed against the schema the block
* answered:
*
* success : false
* issues : invalid_type@id · invalid_value@type
*
* `defineStack` throws on exactly that, so a project scaffolded by a
* documented command refused on its first run, before the author had written
* a line. The three `os init` templates all stamped the identity block; the
* `create` template was the one scaffold that had drifted, and nothing
* noticed because no test ever looked at these templates as DATA.
*
* ## Why the sweep spans both scaffolders, and why it is derived
*
* The defect class is "a shipped scaffold whose manifest the shipped schema
* refuses", and this package has two independent scaffold sources —
* `init.ts`'s `TEMPLATES` and `create.ts`'s `templates`. Pinning only the
* reported one would leave the other free to drift the same way, which is how
* this one arrived. So the population is DERIVED from both maps (every entry
* that emits an `objectstack.config.ts`), never written down: a template added
* later is swept the day it is added, with nobody remembering to extend this
* file.
*
* `create`'s `plugin` template contributes nothing here on purpose — it emits
* no `objectstack.config.ts` at all. Its scaffolded `src/index.ts` declares a
* `Plugin` object, a different contract from this package manifest, and a
* sweep that pretended otherwise would report on a surface `ManifestSchema`
* does not govern.
*
* ## Why the manifest is read back off a LOADED config, not off the source text
*
* Both scaffolders render their config as a template literal, so the only
* honest reading of "what the scaffold declares" is the object the rendered
* file actually evaluates to. The rendered file is written to disk and loaded
* through `bundle-require` — the same loader `scaffold-validate.ts` uses for
* `init`'s self-test — so what is parsed here is the real emitted artifact and
* not a literal copied into a test, which is free to agree with a template
* that has since changed.
*
* Temp projects go under this package's git-ignored `tmp/` (not
* `os.tmpdir()`) because the rendered config imports `@objectstack/spec`,
* which only resolves where Node can walk up into this package's
* `node_modules` — the same constraint, for the same reason, as
* `init-scaffold-authoring-rules.test.ts`. Keeping generated `.ts` out of
* `test/` also keeps it away from any glob that collects sources.
*
* ## Why `ManifestSchema` is parsed explicitly, when `defineStack` already ran
*
* `defineStack` validates through `ObjectStackDefinitionSchema`, where
* `manifest` is `ManifestSchema.optional()`. Two live gaps follow from that
* `.optional()`, and both are the failure this file exists to catch:
*
* - a template that drops the `manifest:` block ENTIRELY loads green — the
* stack door has nothing to check — and ships a project with no id, no
* namespace and no type;
* - a template that calls `defineStack(config, { strict: false })` skips the
* parse altogether.
*
* Neither would redden a pin that only asserted "the config loads". So the
* load is the first assertion, and the standalone parse is the one that does
* not depend on the door staying the way it is today.
*/

import { describe, it, expect, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ManifestSchema } from '@objectstack/spec/kernel';
import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js';
import { templates as createTemplates } from '../src/commands/create.js';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const TMP_ROOT = path.resolve(HERE, '../tmp');
const PROJECT_NAME = 'my-app';
const CONFIG_FILE = 'objectstack.config.ts';

const roots: string[] = [];
afterAll(() => {
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true });
});

interface Scaffold {
/** `<command>:<template key>` — the command a user would have typed. */
id: string;
/**
* Write this scaffold's TypeScript into `root`, through its own emitter.
*
* TypeScript only, and deliberately: a config that imports `./src/objects`
* needs that module on disk to load at all, while the `package.json` and
* `tsconfig.json` the scaffolders also write are monorepo-relative
* (`workspace:*` deps, `extends: '../../tsconfig.json'`) and resolve to
* nothing from a throwaway directory. Neither one can change what the
* manifest declares, so emitting them would buy a resolution failure and no
* coverage. A future template whose config imports a NON-TypeScript file it
* emits would fail loudly here, on the resolve, rather than quietly.
*/
emit: (root: string) => void;
}

/**
* `os init -t <key>`: every template renders a config, so the whole map
* contributes. Both halves go through `init`'s own emitter — the same
* `configContent` / `writeTemplateSrcFiles` pair the command calls, and the
* pair `init-scaffold-authoring-rules.test.ts` drives, so neither test can
* drift from what `init` really writes.
*/
const initScaffolds: Scaffold[] = Object.keys(TEMPLATES).map((key) => ({
id: `init:${key}`,
emit: (root: string) => {
const namespace = sanitizeNamespace(PROJECT_NAME);
fs.writeFileSync(
path.join(root, CONFIG_FILE),
TEMPLATES[key].configContent(PROJECT_NAME, namespace),
);
writeTemplateSrcFiles(TEMPLATES[key].srcFiles, root, PROJECT_NAME, namespace);
},
}));

/**
* `os create <key> <name>`: only the templates whose file map carries an
* `objectstack.config.ts` contribute — derived from the map, so a template
* that grows one later is swept without an edit here. `create` has no
* `srcFiles` split; every file it writes lives in one `files` map, keyed by
* the path it lands at, and is rendered by calling that entry — which is
* exactly what `Create.run()` does.
*/
const createScaffolds: Scaffold[] = Object.entries(createTemplates)
.filter(([, template]) => CONFIG_FILE in template.files)
.map(([key, template]) => ({
id: `create:${key}`,
emit: (root: string) => {
const files = template.files as Record<string, (name: string) => unknown>;
for (const [filePath, render] of Object.entries(files)) {
if (!filePath.endsWith('.ts')) continue;
const abs = path.join(root, filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, String(render(PROJECT_NAME)));
}
},
}));

const SCAFFOLDS: Scaffold[] = [...initScaffolds, ...createScaffolds];

/** Emit one scaffold into a throwaway directory and load its config back. */
async function loadStack(scaffold: Scaffold): Promise<Record<string, unknown>> {
fs.mkdirSync(TMP_ROOT, { recursive: true });
const root = fs.mkdtempSync(path.join(TMP_ROOT, `manifest-${scaffold.id.replace(':', '-')}-`));
roots.push(root);
scaffold.emit(root);

const { bundleRequire } = await import('bundle-require');
const { mod } = await bundleRequire({ filepath: path.join(root, CONFIG_FILE), cwd: root });
return (mod.default ?? mod) as Record<string, unknown>;
}

describe('every shipped scaffold emits a manifest `ManifestSchema` accepts', () => {
// A sweep that swept nothing reports exactly what a clean tree reports. The
// counts are DERIVED from the two maps rather than frozen, so this stays a
// check that the filters still match something — not a copy of today's
// template list that the next added template makes stale.
it('sweeps both scaffolders, and every template that emits a config', () => {
expect(initScaffolds.length).toBe(Object.keys(TEMPLATES).length);
expect(initScaffolds.length).toBeGreaterThan(0);
expect(createScaffolds.length).toBeGreaterThan(0);
expect(SCAFFOLDS.length).toBe(initScaffolds.length + createScaffolds.length);
});

// The reported instance, named so a future edit that drops the identity
// block again fails with the incident's own vocabulary rather than a bare
// count.
it('includes `os create example` — the scaffold that drifted', () => {
expect(SCAFFOLDS.map((s) => s.id)).toContain('create:example');
});

it.each(SCAFFOLDS.map((s) => s.id))(
'scaffold "%s" declares a manifest the protocol schema accepts',
async (id) => {
const scaffold = SCAFFOLDS.find((s) => s.id === id)!;

let stack: Record<string, unknown>;
try {
stack = await loadStack(scaffold);
} catch (error) {
// `defineStack` refuses an invalid manifest by throwing, so this IS
// the first-run failure the user sees. Re-raise it with the scaffold
// named, because the thrown text alone does not say which one.
throw new Error(
`scaffold "${id}" produces a project that refuses to load — the user's very first `
+ `command fails on this:\n${error instanceof Error ? error.message : String(error)}`,
);
}

// `ObjectStackDefinitionSchema.manifest` is `.optional()`, so a missing
// block is silent at the door above. It is not silent here.
expect(stack.manifest, `scaffold "${id}" declares no \`manifest:\` block`).toBeDefined();

const result = ManifestSchema.safeParse(stack.manifest);
const issues = result.success
? ''
: result.error.issues
.map((i) => `${i.code}@${i.path.join('.') || '<root>'}: ${i.message}`)
.join('\n ');
expect(
result.success,
`scaffold "${id}" emits a manifest \`ManifestSchema\` refuses:\n ${issues}`,
).toBe(true);
},
120_000,
);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
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
37 changes: 37 additions & 0 deletions .changeset/create-scaffold-manifest-identity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/cli": patch
---

fix(cli): `os create example` now scaffolds a manifest the protocol schema accepts

The `objectstack.config.ts` that `os create example <name>` wrote declared
three manifest keys — `name`, `version`, `description` — and nothing else.
`ManifestSchema` requires `id` (the reverse-domain package id) and `type`
(`app` | `plugin` | …), and `namespace` is the mandatory prefix of every object
name, which decides each object's table name and REST path. Parsed against the
schema, the emitted block answered `success: false` with
`invalid_type@id · invalid_value@type`.

`defineStack` throws on exactly that, so the project a documented command had
just created refused to load on its first run — before the author had written a
line. The three `os init` templates all stamped the identity block; this was
the one scaffold that had drifted, and nothing noticed because no test looked
at these templates as data.

The template now stamps what `os init` stamps: `id`, `namespace` (derived from
the project name with `init`'s own `sanitizeNamespace`, so both scaffolders
answer the same way for the same input), `type: 'app'` and
`engines.protocol`, alongside the `version`, `name` and `description` it
already carried. `engines.protocol` is stamped from `PROTOCOL_MAJOR` — the same
constant `init` stamps — and ships with the same self-contained comment
explaining what the range is and when to move it.

A pin sweeps both scaffolders: every `init` and `create` template that emits an
`objectstack.config.ts` is rendered through its own emitter, loaded back, and
its `manifest` parsed through the real `ManifestSchema`. The population is
derived from the two template maps rather than listed, so a template added
later is swept the day it is added.

`os create` itself is untouched — it is not removed, deprecated, or redirected
at `os init`. Whether the two scaffolders should stay separate is a CLI-surface
decision, not this fix.
22 changes: 19 additions & 3 deletions packages/cli/src/commands/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
import { sanitizeNamespace } from './init.js';

export const templates = {
plugin: {
Expand DownExpand Up@@ -119,7 +121,9 @@ MIT
vitest: '^4.0.0',
},
}),
'objectstack.config.ts': (name: string) => `import { defineStack } from '@objectstack/spec';
'objectstack.config.ts': (name: string) => {
const namespace = sanitizeNamespace(name);
return `import { defineStack } from '@objectstack/spec';

// Barrel imports — add more as you create new type folders
// import * as objects from './src/objects';
Expand All@@ -128,9 +132,20 @@ MIT

export default defineStack({
manifest: {
name: '${name}',
id: 'com.example.${namespace}',
namespace: '${namespace}',
version: '0.1.0',
type: 'app',
name: '${name}',
description: '${name} example application',
// Protocol compatibility range: the metadata-protocol major this app is
// authored against. The runtime checks it before it loads anything, so a
// runtime outside the range refuses this app at the boundary with the
// exact migration command instead of crashing later. Scaffolding stamped
// it to match the ObjectStack version you installed — change it when you
// deliberately move to a new protocol major, not to silence a mismatch.
// Guide: https://objectstack.ai/docs/upgrading
engines: { protocol: '^${PROTOCOL_MAJOR}' },
},

objects: [
Expand All@@ -141,7 +156,8 @@ export default defineStack({
// Object.values(apps), // Uncomment after creating src/apps/index.ts
],
});
`,
`;
},
'README.md': (name: string) => `# ${name} Example

ObjectStack example application: ${name}
Expand Down
223 changes: 223 additions & 0 deletions packages/cli/test/scaffold-manifest-schema.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Every scaffold this package ships must emit a `manifest:` block that
* `ManifestSchema` accepts — the schema the user's very first command parses
* it with.
*
* ## The defect this pins
*
* `os create example <name>` wrote an `objectstack.config.ts` whose manifest
* was three keys — `name`, `version`, `description` — and nothing else.
* `ManifestSchema` requires `id` (the reverse-domain package id) and `type`
* (`app` | `plugin` | …), and `namespace` decides every object name, table
* name and REST path (there was none). Parsed against the schema the block
* answered:
*
* success : false
* issues : invalid_type@id · invalid_value@type
*
* `defineStack` throws on exactly that, so a project scaffolded by a
* documented command refused on its first run, before the author had written
* a line. The three `os init` templates all stamped the identity block; the
* `create` template was the one scaffold that had drifted, and nothing
* noticed because no test ever looked at these templates as DATA.
*
* ## Why the sweep spans both scaffolders, and why it is derived
*
* The defect class is "a shipped scaffold whose manifest the shipped schema
* refuses", and this package has two independent scaffold sources —
* `init.ts`'s `TEMPLATES` and `create.ts`'s `templates`. Pinning only the
* reported one would leave the other free to drift the same way, which is how
* this one arrived. So the population is DERIVED from both maps (every entry
* that emits an `objectstack.config.ts`), never written down: a template added
* later is swept the day it is added, with nobody remembering to extend this
* file.
*
* `create`'s `plugin` template contributes nothing here on purpose — it emits
* no `objectstack.config.ts` at all. Its scaffolded `src/index.ts` declares a
* `Plugin` object, a different contract from this package manifest, and a
* sweep that pretended otherwise would report on a surface `ManifestSchema`
* does not govern.
*
* ## Why the manifest is read back off a LOADED config, not off the source text
*
* Both scaffolders render their config as a template literal, so the only
* honest reading of "what the scaffold declares" is the object the rendered
* file actually evaluates to. The rendered file is written to disk and loaded
* through `bundle-require` — the same loader `scaffold-validate.ts` uses for
* `init`'s self-test — so what is parsed here is the real emitted artifact and
* not a literal copied into a test, which is free to agree with a template
* that has since changed.
*
* Temp projects go under this package's git-ignored `tmp/` (not
* `os.tmpdir()`) because the rendered config imports `@objectstack/spec`,
* which only resolves where Node can walk up into this package's
* `node_modules` — the same constraint, for the same reason, as
* `init-scaffold-authoring-rules.test.ts`. Keeping generated `.ts` out of
* `test/` also keeps it away from any glob that collects sources.
*
* ## Why `ManifestSchema` is parsed explicitly, when `defineStack` already ran
*
* `defineStack` validates through `ObjectStackDefinitionSchema`, where
* `manifest` is `ManifestSchema.optional()`. Two live gaps follow from that
* `.optional()`, and both are the failure this file exists to catch:
*
* - a template that drops the `manifest:` block ENTIRELY loads green — the
* stack door has nothing to check — and ships a project with no id, no
* namespace and no type;
* - a template that calls `defineStack(config, { strict: false })` skips the
* parse altogether.
*
* Neither would redden a pin that only asserted "the config loads". So the
* load is the first assertion, and the standalone parse is the one that does
* not depend on the door staying the way it is today.
*/

import { describe, it, expect, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ManifestSchema } from '@objectstack/spec/kernel';
import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js';
import { templates as createTemplates } from '../src/commands/create.js';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const TMP_ROOT = path.resolve(HERE, '../tmp');
const PROJECT_NAME = 'my-app';
const CONFIG_FILE = 'objectstack.config.ts';

const roots: string[] = [];
afterAll(() => {
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true });
});

interface Scaffold {
/** `<command>:<template key>` — the command a user would have typed. */
id: string;
/**
* Write this scaffold's TypeScript into `root`, through its own emitter.
*
* TypeScript only, and deliberately: a config that imports `./src/objects`
* needs that module on disk to load at all, while the `package.json` and
* `tsconfig.json` the scaffolders also write are monorepo-relative
* (`workspace:*` deps, `extends: '../../tsconfig.json'`) and resolve to
* nothing from a throwaway directory. Neither one can change what the
* manifest declares, so emitting them would buy a resolution failure and no
* coverage. A future template whose config imports a NON-TypeScript file it
* emits would fail loudly here, on the resolve, rather than quietly.
*/
emit: (root: string) => void;
}

/**
* `os init -t <key>`: every template renders a config, so the whole map
* contributes. Both halves go through `init`'s own emitter — the same
* `configContent` / `writeTemplateSrcFiles` pair the command calls, and the
* pair `init-scaffold-authoring-rules.test.ts` drives, so neither test can
* drift from what `init` really writes.
*/
const initScaffolds: Scaffold[] = Object.keys(TEMPLATES).map((key) => ({
id: `init:${key}`,
emit: (root: string) => {
const namespace = sanitizeNamespace(PROJECT_NAME);
fs.writeFileSync(
path.join(root, CONFIG_FILE),
TEMPLATES[key].configContent(PROJECT_NAME, namespace),
);
writeTemplateSrcFiles(TEMPLATES[key].srcFiles, root, PROJECT_NAME, namespace);
},
}));

/**
* `os create <key> <name>`: only the templates whose file map carries an
* `objectstack.config.ts` contribute — derived from the map, so a template
* that grows one later is swept without an edit here. `create` has no
* `srcFiles` split; every file it writes lives in one `files` map, keyed by
* the path it lands at, and is rendered by calling that entry — which is
* exactly what `Create.run()` does.
*/
const createScaffolds: Scaffold[] = Object.entries(createTemplates)
.filter(([, template]) => CONFIG_FILE in template.files)
.map(([key, template]) => ({
id: `create:${key}`,
emit: (root: string) => {
const files = template.files as Record<string, (name: string) => unknown>;
for (const [filePath, render] of Object.entries(files)) {
if (!filePath.endsWith('.ts')) continue;
const abs = path.join(root, filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, String(render(PROJECT_NAME)));
}
},
}));

const SCAFFOLDS: Scaffold[] = [...initScaffolds, ...createScaffolds];

/** Emit one scaffold into a throwaway directory and load its config back. */
async function loadStack(scaffold: Scaffold): Promise<Record<string, unknown>> {
fs.mkdirSync(TMP_ROOT, { recursive: true });
const root = fs.mkdtempSync(path.join(TMP_ROOT, `manifest-${scaffold.id.replace(':', '-')}-`));
roots.push(root);
scaffold.emit(root);

const { bundleRequire } = await import('bundle-require');
const { mod } = await bundleRequire({ filepath: path.join(root, CONFIG_FILE), cwd: root });
return (mod.default ?? mod) as Record<string, unknown>;
}

describe('every shipped scaffold emits a manifest `ManifestSchema` accepts', () => {
// A sweep that swept nothing reports exactly what a clean tree reports. The
// counts are DERIVED from the two maps rather than frozen, so this stays a
// check that the filters still match something — not a copy of today's
// template list that the next added template makes stale.
it('sweeps both scaffolders, and every template that emits a config', () => {
expect(initScaffolds.length).toBe(Object.keys(TEMPLATES).length);
expect(initScaffolds.length).toBeGreaterThan(0);
expect(createScaffolds.length).toBeGreaterThan(0);
expect(SCAFFOLDS.length).toBe(initScaffolds.length + createScaffolds.length);
});

// The reported instance, named so a future edit that drops the identity
// block again fails with the incident's own vocabulary rather than a bare
// count.
it('includes `os create example` — the scaffold that drifted', () => {
expect(SCAFFOLDS.map((s) => s.id)).toContain('create:example');
});

it.each(SCAFFOLDS.map((s) => s.id))(
'scaffold "%s" declares a manifest the protocol schema accepts',
async (id) => {
const scaffold = SCAFFOLDS.find((s) => s.id === id)!;

let stack: Record<string, unknown>;
try {
stack = await loadStack(scaffold);
} catch (error) {
// `defineStack` refuses an invalid manifest by throwing, so this IS
// the first-run failure the user sees. Re-raise it with the scaffold
// named, because the thrown text alone does not say which one.
throw new Error(
`scaffold "${id}" produces a project that refuses to load — the user's very first `
+ `command fails on this:\n${error instanceof Error ? error.message : String(error)}`,
);
}

// `ObjectStackDefinitionSchema.manifest` is `.optional()`, so a missing
// block is silent at the door above. It is not silent here.
expect(stack.manifest, `scaffold "${id}" declares no \`manifest:\` block`).toBeDefined();

const result = ManifestSchema.safeParse(stack.manifest);
const issues = result.success
? ''
: result.error.issues
.map((i) => `${i.code}@${i.path.join('.') || '<root>'}: ${i.message}`)
.join('\n ');
expect(
result.success,
`scaffold "${id}" emits a manifest \`ManifestSchema\` refuses:\n ${issues}`,
).toBe(true);
},
120_000,
);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Draft
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
37 changes: 37 additions & 0 deletions .changeset/create-scaffold-manifest-identity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/cli": patch
---

fix(cli): `os create example` now scaffolds a manifest the protocol schema accepts

The `objectstack.config.ts` that `os create example <name>` wrote declared
three manifest keys — `name`, `version`, `description` — and nothing else.
`ManifestSchema` requires `id` (the reverse-domain package id) and `type`
(`app` | `plugin` | …), and `namespace` is the mandatory prefix of every object
name, which decides each object's table name and REST path. Parsed against the
schema, the emitted block answered `success: false` with
`invalid_type@id · invalid_value@type`.

`defineStack` throws on exactly that, so the project a documented command had
just created refused to load on its first run — before the author had written a
line. The three `os init` templates all stamped the identity block; this was
the one scaffold that had drifted, and nothing noticed because no test looked
at these templates as data.

The template now stamps what `os init` stamps: `id`, `namespace` (derived from
the project name with `init`'s own `sanitizeNamespace`, so both scaffolders
answer the same way for the same input), `type: 'app'` and
`engines.protocol`, alongside the `version`, `name` and `description` it
already carried. `engines.protocol` is stamped from `PROTOCOL_MAJOR` — the same
constant `init` stamps — and ships with the same self-contained comment
explaining what the range is and when to move it.

A pin sweeps both scaffolders: every `init` and `create` template that emits an
`objectstack.config.ts` is rendered through its own emitter, loaded back, and
its `manifest` parsed through the real `ManifestSchema`. The population is
derived from the two template maps rather than listed, so a template added
later is swept the day it is added.

`os create` itself is untouched — it is not removed, deprecated, or redirected
at `os init`. Whether the two scaffolders should stay separate is a CLI-surface
decision, not this fix.
22 changes: 19 additions & 3 deletions packages/cli/src/commands/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
import { sanitizeNamespace } from './init.js';

export const templates = {
plugin: {
Expand DownExpand Up@@ -119,7 +121,9 @@ MIT
vitest: '^4.0.0',
},
}),
'objectstack.config.ts': (name: string) => `import { defineStack } from '@objectstack/spec';
'objectstack.config.ts': (name: string) => {
const namespace = sanitizeNamespace(name);
return `import { defineStack } from '@objectstack/spec';

// Barrel imports — add more as you create new type folders
// import * as objects from './src/objects';
Expand All@@ -128,9 +132,20 @@ MIT

export default defineStack({
manifest: {
name: '${name}',
id: 'com.example.${namespace}',
namespace: '${namespace}',
version: '0.1.0',
type: 'app',
name: '${name}',
description: '${name} example application',
// Protocol compatibility range: the metadata-protocol major this app is
// authored against. The runtime checks it before it loads anything, so a
// runtime outside the range refuses this app at the boundary with the
// exact migration command instead of crashing later. Scaffolding stamped
// it to match the ObjectStack version you installed — change it when you
// deliberately move to a new protocol major, not to silence a mismatch.
// Guide: https://objectstack.ai/docs/upgrading
engines: { protocol: '^${PROTOCOL_MAJOR}' },
},

objects: [
Expand All@@ -141,7 +156,8 @@ export default defineStack({
// Object.values(apps), // Uncomment after creating src/apps/index.ts
],
});
`,
`;
},
'README.md': (name: string) => `# ${name} Example

ObjectStack example application: ${name}
Expand Down
223 changes: 223 additions & 0 deletions packages/cli/test/scaffold-manifest-schema.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Every scaffold this package ships must emit a `manifest:` block that
* `ManifestSchema` accepts — the schema the user's very first command parses
* it with.
*
* ## The defect this pins
*
* `os create example <name>` wrote an `objectstack.config.ts` whose manifest
* was three keys — `name`, `version`, `description` — and nothing else.
* `ManifestSchema` requires `id` (the reverse-domain package id) and `type`
* (`app` | `plugin` | …), and `namespace` decides every object name, table
* name and REST path (there was none). Parsed against the schema the block
* answered:
*
* success : false
* issues : invalid_type@id · invalid_value@type
*
* `defineStack` throws on exactly that, so a project scaffolded by a
* documented command refused on its first run, before the author had written
* a line. The three `os init` templates all stamped the identity block; the
* `create` template was the one scaffold that had drifted, and nothing
* noticed because no test ever looked at these templates as DATA.
*
* ## Why the sweep spans both scaffolders, and why it is derived
*
* The defect class is "a shipped scaffold whose manifest the shipped schema
* refuses", and this package has two independent scaffold sources —
* `init.ts`'s `TEMPLATES` and `create.ts`'s `templates`. Pinning only the
* reported one would leave the other free to drift the same way, which is how
* this one arrived. So the population is DERIVED from both maps (every entry
* that emits an `objectstack.config.ts`), never written down: a template added
* later is swept the day it is added, with nobody remembering to extend this
* file.
*
* `create`'s `plugin` template contributes nothing here on purpose — it emits
* no `objectstack.config.ts` at all. Its scaffolded `src/index.ts` declares a
* `Plugin` object, a different contract from this package manifest, and a
* sweep that pretended otherwise would report on a surface `ManifestSchema`
* does not govern.
*
* ## Why the manifest is read back off a LOADED config, not off the source text
*
* Both scaffolders render their config as a template literal, so the only
* honest reading of "what the scaffold declares" is the object the rendered
* file actually evaluates to. The rendered file is written to disk and loaded
* through `bundle-require` — the same loader `scaffold-validate.ts` uses for
* `init`'s self-test — so what is parsed here is the real emitted artifact and
* not a literal copied into a test, which is free to agree with a template
* that has since changed.
*
* Temp projects go under this package's git-ignored `tmp/` (not
* `os.tmpdir()`) because the rendered config imports `@objectstack/spec`,
* which only resolves where Node can walk up into this package's
* `node_modules` — the same constraint, for the same reason, as
* `init-scaffold-authoring-rules.test.ts`. Keeping generated `.ts` out of
* `test/` also keeps it away from any glob that collects sources.
*
* ## Why `ManifestSchema` is parsed explicitly, when `defineStack` already ran
*
* `defineStack` validates through `ObjectStackDefinitionSchema`, where
* `manifest` is `ManifestSchema.optional()`. Two live gaps follow from that
* `.optional()`, and both are the failure this file exists to catch:
*
* - a template that drops the `manifest:` block ENTIRELY loads green — the
* stack door has nothing to check — and ships a project with no id, no
* namespace and no type;
* - a template that calls `defineStack(config, { strict: false })` skips the
* parse altogether.
*
* Neither would redden a pin that only asserted "the config loads". So the
* load is the first assertion, and the standalone parse is the one that does
* not depend on the door staying the way it is today.
*/

import { describe, it, expect, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ManifestSchema } from '@objectstack/spec/kernel';
import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js';
import { templates as createTemplates } from '../src/commands/create.js';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const TMP_ROOT = path.resolve(HERE, '../tmp');
const PROJECT_NAME = 'my-app';
const CONFIG_FILE = 'objectstack.config.ts';

const roots: string[] = [];
afterAll(() => {
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true });
});

interface Scaffold {
/** `<command>:<template key>` — the command a user would have typed. */
id: string;
/**
* Write this scaffold's TypeScript into `root`, through its own emitter.
*
* TypeScript only, and deliberately: a config that imports `./src/objects`
* needs that module on disk to load at all, while the `package.json` and
* `tsconfig.json` the scaffolders also write are monorepo-relative
* (`workspace:*` deps, `extends: '../../tsconfig.json'`) and resolve to
* nothing from a throwaway directory. Neither one can change what the
* manifest declares, so emitting them would buy a resolution failure and no
* coverage. A future template whose config imports a NON-TypeScript file it
* emits would fail loudly here, on the resolve, rather than quietly.
*/
emit: (root: string) => void;
}

/**
* `os init -t <key>`: every template renders a config, so the whole map
* contributes. Both halves go through `init`'s own emitter — the same
* `configContent` / `writeTemplateSrcFiles` pair the command calls, and the
* pair `init-scaffold-authoring-rules.test.ts` drives, so neither test can
* drift from what `init` really writes.
*/
const initScaffolds: Scaffold[] = Object.keys(TEMPLATES).map((key) => ({
id: `init:${key}`,
emit: (root: string) => {
const namespace = sanitizeNamespace(PROJECT_NAME);
fs.writeFileSync(
path.join(root, CONFIG_FILE),
TEMPLATES[key].configContent(PROJECT_NAME, namespace),
);
writeTemplateSrcFiles(TEMPLATES[key].srcFiles, root, PROJECT_NAME, namespace);
},
}));

/**
* `os create <key> <name>`: only the templates whose file map carries an
* `objectstack.config.ts` contribute — derived from the map, so a template
* that grows one later is swept without an edit here. `create` has no
* `srcFiles` split; every file it writes lives in one `files` map, keyed by
* the path it lands at, and is rendered by calling that entry — which is
* exactly what `Create.run()` does.
*/
const createScaffolds: Scaffold[] = Object.entries(createTemplates)
.filter(([, template]) => CONFIG_FILE in template.files)
.map(([key, template]) => ({
id: `create:${key}`,
emit: (root: string) => {
const files = template.files as Record<string, (name: string) => unknown>;
for (const [filePath, render] of Object.entries(files)) {
if (!filePath.endsWith('.ts')) continue;
const abs = path.join(root, filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, String(render(PROJECT_NAME)));
}
},
}));

const SCAFFOLDS: Scaffold[] = [...initScaffolds, ...createScaffolds];

/** Emit one scaffold into a throwaway directory and load its config back. */
async function loadStack(scaffold: Scaffold): Promise<Record<string, unknown>> {
fs.mkdirSync(TMP_ROOT, { recursive: true });
const root = fs.mkdtempSync(path.join(TMP_ROOT, `manifest-${scaffold.id.replace(':', '-')}-`));
roots.push(root);
scaffold.emit(root);

const { bundleRequire } = await import('bundle-require');
const { mod } = await bundleRequire({ filepath: path.join(root, CONFIG_FILE), cwd: root });
return (mod.default ?? mod) as Record<string, unknown>;
}

describe('every shipped scaffold emits a manifest `ManifestSchema` accepts', () => {
// A sweep that swept nothing reports exactly what a clean tree reports. The
// counts are DERIVED from the two maps rather than frozen, so this stays a
// check that the filters still match something — not a copy of today's
// template list that the next added template makes stale.
it('sweeps both scaffolders, and every template that emits a config', () => {
expect(initScaffolds.length).toBe(Object.keys(TEMPLATES).length);
expect(initScaffolds.length).toBeGreaterThan(0);
expect(createScaffolds.length).toBeGreaterThan(0);
expect(SCAFFOLDS.length).toBe(initScaffolds.length + createScaffolds.length);
});

// The reported instance, named so a future edit that drops the identity
// block again fails with the incident's own vocabulary rather than a bare
// count.
it('includes `os create example` — the scaffold that drifted', () => {
expect(SCAFFOLDS.map((s) => s.id)).toContain('create:example');
});

it.each(SCAFFOLDS.map((s) => s.id))(
'scaffold "%s" declares a manifest the protocol schema accepts',
async (id) => {
const scaffold = SCAFFOLDS.find((s) => s.id === id)!;

let stack: Record<string, unknown>;
try {
stack = await loadStack(scaffold);
} catch (error) {
// `defineStack` refuses an invalid manifest by throwing, so this IS
// the first-run failure the user sees. Re-raise it with the scaffold
// named, because the thrown text alone does not say which one.
throw new Error(
`scaffold "${id}" produces a project that refuses to load — the user's very first `
+ `command fails on this:\n${error instanceof Error ? error.message : String(error)}`,
);
}

// `ObjectStackDefinitionSchema.manifest` is `.optional()`, so a missing
// block is silent at the door above. It is not silent here.
expect(stack.manifest, `scaffold "${id}" declares no \`manifest:\` block`).toBeDefined();

const result = ManifestSchema.safeParse(stack.manifest);
const issues = result.success
? ''
: result.error.issues
.map((i) => `${i.code}@${i.path.join('.') || '<root>'}: ${i.message}`)
.join('\n ');
expect(
result.success,
`scaffold "${id}" emits a manifest \`ManifestSchema\` refuses:\n ${issues}`,
).toBe(true);
},
120_000,
);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Draft
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
37 changes: 37 additions & 0 deletions .changeset/create-scaffold-manifest-identity.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
"@objectstack/cli": patch
---

fix(cli): `os create example` now scaffolds a manifest the protocol schema accepts

The `objectstack.config.ts` that `os create example <name>` wrote declared
three manifest keys — `name`, `version`, `description` — and nothing else.
`ManifestSchema` requires `id` (the reverse-domain package id) and `type`
(`app` | `plugin` | …), and `namespace` is the mandatory prefix of every object
name, which decides each object's table name and REST path. Parsed against the
schema, the emitted block answered `success: false` with
`invalid_type@id · invalid_value@type`.

`defineStack` throws on exactly that, so the project a documented command had
just created refused to load on its first run — before the author had written a
line. The three `os init` templates all stamped the identity block; this was
the one scaffold that had drifted, and nothing noticed because no test looked
at these templates as data.

The template now stamps what `os init` stamps: `id`, `namespace` (derived from
the project name with `init`'s own `sanitizeNamespace`, so both scaffolders
answer the same way for the same input), `type: 'app'` and
`engines.protocol`, alongside the `version`, `name` and `description` it
already carried. `engines.protocol` is stamped from `PROTOCOL_MAJOR` — the same
constant `init` stamps — and ships with the same self-contained comment
explaining what the range is and when to move it.

A pin sweeps both scaffolders: every `init` and `create` template that emits an
`objectstack.config.ts` is rendered through its own emitter, loaded back, and
its `manifest` parsed through the real `ManifestSchema`. The population is
derived from the two template maps rather than listed, so a template added
later is swept the day it is added.

`os create` itself is untouched — it is not removed, deprecated, or redirected
at `os init`. Whether the two scaffolders should stay separate is a CLI-surface
decision, not this fix.
22 changes: 19 additions & 3 deletions packages/cli/src/commands/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,8 @@ import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
import { sanitizeNamespace } from './init.js';

export const templates = {
plugin: {
Expand DownExpand Up@@ -119,7 +121,9 @@ MIT
vitest: '^4.0.0',
},
}),
'objectstack.config.ts': (name: string) => `import { defineStack } from '@objectstack/spec';
'objectstack.config.ts': (name: string) => {
const namespace = sanitizeNamespace(name);
return `import { defineStack } from '@objectstack/spec';

// Barrel imports — add more as you create new type folders
// import * as objects from './src/objects';
Expand All@@ -128,9 +132,20 @@ MIT

export default defineStack({
manifest: {
name: '${name}',
id: 'com.example.${namespace}',
namespace: '${namespace}',
version: '0.1.0',
type: 'app',
name: '${name}',
description: '${name} example application',
// Protocol compatibility range: the metadata-protocol major this app is
// authored against. The runtime checks it before it loads anything, so a
// runtime outside the range refuses this app at the boundary with the
// exact migration command instead of crashing later. Scaffolding stamped
// it to match the ObjectStack version you installed — change it when you
// deliberately move to a new protocol major, not to silence a mismatch.
// Guide: https://objectstack.ai/docs/upgrading
engines: { protocol: '^${PROTOCOL_MAJOR}' },
},

objects: [
Expand All@@ -141,7 +156,8 @@ export default defineStack({
// Object.values(apps), // Uncomment after creating src/apps/index.ts
],
});
`,
`;
},
'README.md': (name: string) => `# ${name} Example

ObjectStack example application: ${name}
Expand Down
223 changes: 223 additions & 0 deletions packages/cli/test/scaffold-manifest-schema.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Every scaffold this package ships must emit a `manifest:` block that
* `ManifestSchema` accepts — the schema the user's very first command parses
* it with.
*
* ## The defect this pins
*
* `os create example <name>` wrote an `objectstack.config.ts` whose manifest
* was three keys — `name`, `version`, `description` — and nothing else.
* `ManifestSchema` requires `id` (the reverse-domain package id) and `type`
* (`app` | `plugin` | …), and `namespace` decides every object name, table
* name and REST path (there was none). Parsed against the schema the block
* answered:
*
* success : false
* issues : invalid_type@id · invalid_value@type
*
* `defineStack` throws on exactly that, so a project scaffolded by a
* documented command refused on its first run, before the author had written
* a line. The three `os init` templates all stamped the identity block; the
* `create` template was the one scaffold that had drifted, and nothing
* noticed because no test ever looked at these templates as DATA.
*
* ## Why the sweep spans both scaffolders, and why it is derived
*
* The defect class is "a shipped scaffold whose manifest the shipped schema
* refuses", and this package has two independent scaffold sources —
* `init.ts`'s `TEMPLATES` and `create.ts`'s `templates`. Pinning only the
* reported one would leave the other free to drift the same way, which is how
* this one arrived. So the population is DERIVED from both maps (every entry
* that emits an `objectstack.config.ts`), never written down: a template added
* later is swept the day it is added, with nobody remembering to extend this
* file.
*
* `create`'s `plugin` template contributes nothing here on purpose — it emits
* no `objectstack.config.ts` at all. Its scaffolded `src/index.ts` declares a
* `Plugin` object, a different contract from this package manifest, and a
* sweep that pretended otherwise would report on a surface `ManifestSchema`
* does not govern.
*
* ## Why the manifest is read back off a LOADED config, not off the source text
*
* Both scaffolders render their config as a template literal, so the only
* honest reading of "what the scaffold declares" is the object the rendered
* file actually evaluates to. The rendered file is written to disk and loaded
* through `bundle-require` — the same loader `scaffold-validate.ts` uses for
* `init`'s self-test — so what is parsed here is the real emitted artifact and
* not a literal copied into a test, which is free to agree with a template
* that has since changed.
*
* Temp projects go under this package's git-ignored `tmp/` (not
* `os.tmpdir()`) because the rendered config imports `@objectstack/spec`,
* which only resolves where Node can walk up into this package's
* `node_modules` — the same constraint, for the same reason, as
* `init-scaffold-authoring-rules.test.ts`. Keeping generated `.ts` out of
* `test/` also keeps it away from any glob that collects sources.
*
* ## Why `ManifestSchema` is parsed explicitly, when `defineStack` already ran
*
* `defineStack` validates through `ObjectStackDefinitionSchema`, where
* `manifest` is `ManifestSchema.optional()`. Two live gaps follow from that
* `.optional()`, and both are the failure this file exists to catch:
*
* - a template that drops the `manifest:` block ENTIRELY loads green — the
* stack door has nothing to check — and ships a project with no id, no
* namespace and no type;
* - a template that calls `defineStack(config, { strict: false })` skips the
* parse altogether.
*
* Neither would redden a pin that only asserted "the config loads". So the
* load is the first assertion, and the standalone parse is the one that does
* not depend on the door staying the way it is today.
*/

import { describe, it, expect, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ManifestSchema } from '@objectstack/spec/kernel';
import { TEMPLATES, sanitizeNamespace, writeTemplateSrcFiles } from '../src/commands/init.js';
import { templates as createTemplates } from '../src/commands/create.js';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const TMP_ROOT = path.resolve(HERE, '../tmp');
const PROJECT_NAME = 'my-app';
const CONFIG_FILE = 'objectstack.config.ts';

const roots: string[] = [];
afterAll(() => {
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true });
});

interface Scaffold {
/** `<command>:<template key>` — the command a user would have typed. */
id: string;
/**
* Write this scaffold's TypeScript into `root`, through its own emitter.
*
* TypeScript only, and deliberately: a config that imports `./src/objects`
* needs that module on disk to load at all, while the `package.json` and
* `tsconfig.json` the scaffolders also write are monorepo-relative
* (`workspace:*` deps, `extends: '../../tsconfig.json'`) and resolve to
* nothing from a throwaway directory. Neither one can change what the
* manifest declares, so emitting them would buy a resolution failure and no
* coverage. A future template whose config imports a NON-TypeScript file it
* emits would fail loudly here, on the resolve, rather than quietly.
*/
emit: (root: string) => void;
}

/**
* `os init -t <key>`: every template renders a config, so the whole map
* contributes. Both halves go through `init`'s own emitter — the same
* `configContent` / `writeTemplateSrcFiles` pair the command calls, and the
* pair `init-scaffold-authoring-rules.test.ts` drives, so neither test can
* drift from what `init` really writes.
*/
const initScaffolds: Scaffold[] = Object.keys(TEMPLATES).map((key) => ({
id: `init:${key}`,
emit: (root: string) => {
const namespace = sanitizeNamespace(PROJECT_NAME);
fs.writeFileSync(
path.join(root, CONFIG_FILE),
TEMPLATES[key].configContent(PROJECT_NAME, namespace),
);
writeTemplateSrcFiles(TEMPLATES[key].srcFiles, root, PROJECT_NAME, namespace);
},
}));

/**
* `os create <key> <name>`: only the templates whose file map carries an
* `objectstack.config.ts` contribute — derived from the map, so a template
* that grows one later is swept without an edit here. `create` has no
* `srcFiles` split; every file it writes lives in one `files` map, keyed by
* the path it lands at, and is rendered by calling that entry — which is
* exactly what `Create.run()` does.
*/
const createScaffolds: Scaffold[] = Object.entries(createTemplates)
.filter(([, template]) => CONFIG_FILE in template.files)
.map(([key, template]) => ({
id: `create:${key}`,
emit: (root: string) => {
const files = template.files as Record<string, (name: string) => unknown>;
for (const [filePath, render] of Object.entries(files)) {
if (!filePath.endsWith('.ts')) continue;
const abs = path.join(root, filePath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, String(render(PROJECT_NAME)));
}
},
}));

const SCAFFOLDS: Scaffold[] = [...initScaffolds, ...createScaffolds];

/** Emit one scaffold into a throwaway directory and load its config back. */
async function loadStack(scaffold: Scaffold): Promise<Record<string, unknown>> {
fs.mkdirSync(TMP_ROOT, { recursive: true });
const root = fs.mkdtempSync(path.join(TMP_ROOT, `manifest-${scaffold.id.replace(':', '-')}-`));
roots.push(root);
scaffold.emit(root);

const { bundleRequire } = await import('bundle-require');
const { mod } = await bundleRequire({ filepath: path.join(root, CONFIG_FILE), cwd: root });
return (mod.default ?? mod) as Record<string, unknown>;
}

describe('every shipped scaffold emits a manifest `ManifestSchema` accepts', () => {
// A sweep that swept nothing reports exactly what a clean tree reports. The
// counts are DERIVED from the two maps rather than frozen, so this stays a
// check that the filters still match something — not a copy of today's
// template list that the next added template makes stale.
it('sweeps both scaffolders, and every template that emits a config', () => {
expect(initScaffolds.length).toBe(Object.keys(TEMPLATES).length);
expect(initScaffolds.length).toBeGreaterThan(0);
expect(createScaffolds.length).toBeGreaterThan(0);
expect(SCAFFOLDS.length).toBe(initScaffolds.length + createScaffolds.length);
});

// The reported instance, named so a future edit that drops the identity
// block again fails with the incident's own vocabulary rather than a bare
// count.
it('includes `os create example` — the scaffold that drifted', () => {
expect(SCAFFOLDS.map((s) => s.id)).toContain('create:example');
});

it.each(SCAFFOLDS.map((s) => s.id))(
'scaffold "%s" declares a manifest the protocol schema accepts',
async (id) => {
const scaffold = SCAFFOLDS.find((s) => s.id === id)!;

let stack: Record<string, unknown>;
try {
stack = await loadStack(scaffold);
} catch (error) {
// `defineStack` refuses an invalid manifest by throwing, so this IS
// the first-run failure the user sees. Re-raise it with the scaffold
// named, because the thrown text alone does not say which one.
throw new Error(
`scaffold "${id}" produces a project that refuses to load — the user's very first `
+ `command fails on this:\n${error instanceof Error ? error.message : String(error)}`,
);
}

// `ObjectStackDefinitionSchema.manifest` is `.optional()`, so a missing
// block is silent at the door above. It is not silent here.
expect(stack.manifest, `scaffold "${id}" declares no \`manifest:\` block`).toBeDefined();

const result = ManifestSchema.safeParse(stack.manifest);
const issues = result.success
? ''
: result.error.issues
.map((i) => `${i.code}@${i.path.join('.') || '<root>'}: ${i.message}`)
.join('\n ');
expect(
result.success,
`scaffold "${id}" emits a manifest \`ManifestSchema\` refuses:\n ${issues}`,
).toBe(true);
},
120_000,
);
});
Loading