diff --git a/.changeset/create-scaffold-manifest-identity.md b/.changeset/create-scaffold-manifest-identity.md new file mode 100644 index 0000000000..872530bd8c --- /dev/null +++ b/.changeset/create-scaffold-manifest-identity.md @@ -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 ` 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. diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index f706bb2199..779e4fecfd 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -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: { @@ -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'; @@ -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: [ @@ -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} diff --git a/packages/cli/test/scaffold-manifest-schema.test.ts b/packages/cli/test/scaffold-manifest-schema.test.ts new file mode 100644 index 0000000000..cdd9612f27 --- /dev/null +++ b/packages/cli/test/scaffold-manifest-schema.test.ts @@ -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 ` 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 { + /** `: