diff --git a/.changeset/20333-create-objectstack-wire-barrels.md b/.changeset/20333-create-objectstack-wire-barrels.md new file mode 100644 index 00000000000..63c9cfb3782 --- /dev/null +++ b/.changeset/20333-create-objectstack-wire-barrels.md @@ -0,0 +1,15 @@ +--- +'create-objectstack': patch +--- + +fix(create-objectstack): the blank starter wires every directory `os generate` writes into + +`npm create objectstack` scaffolded an `objectstack.config.ts` that imported `./src/objects` alone. `os g view`, `action`, `flow`, `dashboard`, `app` and `skill` each wrote a file and a barrel `index.ts` that nothing imported, and `os validate` then exited 0 printing `Logic: 0 Flows`: the generated metadata was never loaded. + +**What a new blank project now ships** is the wiring `os init` writes: + +- `objectstack.config.ts` imports every directory `os generate` writes into (`src/objects`, `src/views`, `src/actions`, `src/flows`, `src/dashboards`, `src/apps`, `src/skills`) and hands each barrel's exports to `defineStack` under its key (`objects`, `views`, …). A file `os g` writes there is part of the stack with no edit to the config. The keys read the barrels through a small `exportsOf` helper declared in the config, because `Object.values` on an empty barrel does not type-check against `defineStack`'s collection types. +- An `index.ts` containing only `export {};` in each of those directories except `src/objects`, which keeps the sample object. +- `requires: ['automation', 'triggers']`. `automation` was already there for the three connector plugins. `triggers` fires a flow that starts on a record change, the kind `os g flow` writes, and without it the config stops loading as soon as it holds one. A project with no flow boots as before. + +**Projects scaffolded by an earlier release** keep their config. `os g` says when a file it wrote is not wired, and prints the lines to add. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 247bfeadca4..7b518e78565 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -33,10 +33,15 @@ This scaffolds a working project with `objectstack.config.ts`, a sample object, ```bash os generate object customer # Add a Customer object -os generate action approve # Add an action -os generate flow onboarding # Add an automation flow +os generate flow customer # Add an automation flow on it +os generate action customer # Add an action on it that runs the flow ``` +Each command writes a file and its export line, and the starter's config already +wires every directory they write into, so all three are part of the stack. The +flow and the action bind to the object named like them, which is why the object +comes first: an action bound to an object the stack does not declare is refused. + ### Launch the dev server ```bash @@ -739,8 +744,8 @@ the view and the action listed under renamed out of that page's `support_desk_` namespace into `my_app_`. That page's `support` app is **not** added, and a zero count is never printed, which is why the `UI:` row reads `1 Views 1 Actions` with no `Apps`. The walkthrough's `os generate` commands are **not** -part of the fixture — run those as well and the summary gains `my_app_customer` and a -`Logic:` row. Timings are machine identity, and the rule count and artifact size track the +part of the fixture — run those as well and the summary gains `my_app_customer`, a second +action and a `Logic:` row. Timings are machine identity, and the rule count and artifact size track the CLI version; everything else is fixture identity and reproduces. @@ -1410,7 +1415,8 @@ else a scaffold names (an action's, flow's or app's own `name`) is prefixed. **Every scaffold reaches the stack, or the command says it does not.** The `objectstack.config.ts` that `os init` writes for the `app` and `plugin` -templates wires every directory in the table below: it imports each +templates, and the one the `npm create objectstack` starter ships, wires every +directory in the table below: it imports each `src//index.ts` barrel and hands its exports to `defineStack` under the key in the **Collected as** column, so a file `os g` writes there is part of the stack with no edit to the config. It also declares @@ -1421,8 +1427,8 @@ After writing, `os g` loads the config again and says which of these holds: checks it. - **Not wired**: the config loads and its stack does not carry the item, or there is no config. This is what happens with a config that imports - `./src/objects` alone, as `os init` projects from earlier releases and the - `npm create objectstack` starter do. The file is written, the config is left + `./src/objects` alone, as projects that `os init` and `npm create objectstack` + scaffolded in earlier releases do. The file is written, the config is left as it was, and the command prints the import and the `defineStack` key that wire the directory. - **Cannot run**: the stack carries a flow, and its `requires` lacks diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 9b1da864799..1640287c80b 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -223,12 +223,16 @@ export const SupportApp = defineApp({ }); ``` -The agent also **wires the new files into `objectstack.config.ts`** — the object -through the `src/objects/index.ts` barrel, and the action, view and app via the -`actions:` / `views:` / `apps:` arrays in `defineStack()`. There is no -filename-suffix magic: metadata exists in the app only if the config imports it, -so if a freshly-authored action doesn't show up, the wiring is the first thing to -check. +The agent also **exports each new file from its directory's barrel** — the object +from `src/objects/index.ts`, and the action, view and app from +`src/actions/index.ts`, `src/views/index.ts` and `src/apps/index.ts`. The +starter's `objectstack.config.ts` already hands every barrel to `defineStack()` +through `exportsOf()`, so that export line is the whole wiring and the config is +not edited; adding an `actions:` / `views:` / `apps:` key for the file instead +would duplicate the key and replace that barrel's wiring. There is no +filename-suffix magic: metadata exists in the app only if its barrel exports it, +so if a freshly-authored action doesn't show up, its export line is the first +thing to check. What it does **not** touch is the starter `note` object the `blank` template scaffolded in step 1 (`src/objects/note.object.ts`, two fields). Nothing asked diff --git a/content/docs/getting-started/your-first-project.mdx b/content/docs/getting-started/your-first-project.mdx index 8d228472f36..b3f62dbbf13 100644 --- a/content/docs/getting-started/your-first-project.mdx +++ b/content/docs/getting-started/your-first-project.mdx @@ -91,22 +91,41 @@ my-app/ ├── tsconfig.json ├── AGENTS.md # conventions for coding agents └── src/ - └── objects/ - ├── index.ts # barrel — re-exports every object - └── note.object.ts # a sample object + ├── objects/ + │ ├── index.ts # barrel — re-exports every object + │ └── note.object.ts # a sample object + ├── views/index.ts # an empty barrel for each directory + ├── actions/index.ts # `os generate` writes into, already + ├── flows/index.ts # wired into objectstack.config.ts + ├── dashboards/index.ts + ├── apps/index.ts + └── skills/index.ts ``` Two files matter most: **`objectstack.config.ts`** wires everything together. There is no -filename-suffix magic — metadata exists in the app only if it is imported here: +filename-suffix magic — metadata exists in the app only if it is imported here. +Every directory `os generate` writes into is already imported, so a generated +view, action or flow adds a file and one export line to its directory's +`index.ts` and is part of the stack with no edit to this file: ```typescript title="objectstack.config.ts" import { defineStack } from '@objectstack/spec'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; -import * as objects from './src/objects/index.js'; +import * as objects from './src/objects'; +import * as views from './src/views'; +import * as actions from './src/actions'; +import * as flows from './src/flows'; +import * as dashboards from './src/dashboards'; +import * as apps from './src/apps'; +import * as skills from './src/skills'; + +// Object.values, typed by what the barrel exports, so an empty barrel still +// type-checks. +const exportsOf = (barrel: M): M[keyof M][] => Object.values(barrel); export default defineStack({ manifest: { @@ -120,14 +139,22 @@ export default defineStack({ }, // `automation` runs flows and, per ADR-0097, materializes declarative // `connectors:` entries at boot. The three generic executors below register - // their `rest` / `openapi` / `mcp` provider factories with it. - requires: ['automation'], + // their `rest` / `openapi` / `mcp` provider factories with it. `triggers` + // fires a flow that starts on a record change, the kind `os generate flow` + // writes. + requires: ['automation', 'triggers'], plugins: [ new ConnectorRestPlugin(), new ConnectorOpenApiPlugin(), new ConnectorMcpPlugin(), ], - objects: Object.values(objects), + objects: exportsOf(objects), + views: exportsOf(views), + actions: exportsOf(actions), + flows: exportsOf(flows), + dashboards: exportsOf(dashboards), + apps: exportsOf(apps), + skills: exportsOf(skills), }); ``` diff --git a/packages/cli/package.json b/packages/cli/package.json index 176024d636b..a57d5b003a4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -132,6 +132,9 @@ "better-sqlite3": "^13.0.3" }, "devDependencies": { + "@objectstack/connector-mcp": "workspace:*", + "@objectstack/connector-openapi": "workspace:*", + "@objectstack/connector-rest": "workspace:*", "@objectstack/driver-turso": "workspace:*", "@objectstack/plugin-dev": "workspace:*", "@oclif/plugin-help": "^6.2.58", diff --git a/packages/cli/test/create-objectstack-stack-reach.test.ts b/packages/cli/test/create-objectstack-stack-reach.test.ts new file mode 100644 index 00000000000..abac07a894a --- /dev/null +++ b/packages/cli/test/create-objectstack-stack-reach.test.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#20333) — `npm create objectstack` → `os g flow` → `os validate` + * counts the flow, with `os g object` as the control. + * + * ## What was measured before the fix + * + * On `origin/main` `c74de10a9`, a project scaffolded by the on-ramp's real + * `bin/` entry, then `os g object order_line` and `os g flow order_line`: + * + * os g object exit 0, "Reaches the stack" (objects was always wired) + * os g flow exit 0, "Not wired: … is not part of the stack" + * os validate exit 0, `Data: 2 Objects` and `Logic: 0 Flows` + * + * The blank starter's config imported `./src/objects` alone, so the flow was + * written and never loaded. The control is what makes the red readable: the + * same chain counted the generated object, so a 0 for the flow is the wiring + * and not a harness that counts nothing. + * + * ## The chain, through the real commands + * + * node create-objectstack/bin/create-objectstack.js my-app --skip-install --skip-skills + * os g object order_line → exit 0, reaches the stack + * os g flow order_line → exit 0, reaches the stack, no wiring lines to add + * os validate → exit 0, `Data: 2 Objects`, `Logic: 1 Flows` + * + * Asserted: exit statuses, the named subjects, the absence of the wiring + * lines `os g` prints for a scaffold that did not arrive (code an author + * pastes), and the counts `os validate` prints. Prose is not pinned. The item + * names are read off the generator roster, not written down. + * + * ## Why here, why a child process, and why this file is NOT named `.e2e` + * + * `os g` and `os validate` are this package's commands, and it already + * depends on `create-objectstack`, so `@objectstack/cli#test`'s `^build` + * builds the on-ramp's `dist/` — the tree its `bin/` copies from. The bin and + * the blank template are declared cross-package inputs of this package + * (scripts/cross-package-test-inputs.mjs, mirrored into turbo.json). + * + * The project lives under this package's `node_modules`, so the scaffolded + * config's imports resolve to workspace copies without an install. Besides + * `@objectstack/spec`, the blank config imports three connector packages; + * they are this package's devDependencies for that reason alone, which is + * also what puts them in this suite's build closure. + * + * An exit status is the contract, and `process.exit` inside a vitest worker + * is not one, so the commands are spawned (the `integration` project). The + * name keeps it in the per-PR run. The structural half — the blank config + * wires exactly what `os init` wires — is `create-objectstack-wiring-parity.test.ts`. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js'; +import { childEnv } from './helpers/serve-process.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +// One `resolve(HERE, …)` call per line: `check:cross-package-test-inputs` +// reconstructs this read by SOURCE SCAN. +const ON_RAMP_BIN = resolve(HERE, '../../..', 'packages/create-objectstack/bin/create-objectstack.js'); + +/** One plain-node scaffold, then three oclif + tsx cold starts, sequential. */ +const RUN_TIMEOUT_MS = 240_000; + +const PROJECT = 'my-app'; +const NS = 'my_app'; +const STEM = 'order_line'; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function run(file: string, args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + file, + args, + { cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + // `err.code` is the real exit status; a signalled child has none and + // is reported as 1, never as 0. + code: err + ? typeof (err as { code?: unknown }).code === 'number' + ? (err as unknown as { code: number }).code + : 1 + : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +const os = (args: string[], cwd: string) => run(TSX, [CLI, ...args], cwd); +const out = (r: Run) => r.stdout + r.stderr; + +const target = (type: string) => { + const t = GENERATOR_SCAFFOLD_TARGETS.find((g) => g.type === type); + if (!t) throw new Error(`no '${type}' generator in the roster`); + return t; +}; +const OBJECT = target('object'); +const FLOW = target('flow'); + +let root: string; +let project: string; +let scaffold: Run; +let genObject: Run; +let genFlow: Run; +let validate: Run; + +beforeAll(async () => { + root = mkdtempSync(join(HERE, '..', 'node_modules', '.create-objectstack-reach-')); + project = join(root, PROJECT); + scaffold = await run(process.execPath, [ON_RAMP_BIN, PROJECT, '--skip-install', '--skip-skills'], root); + // Sequential on purpose: the object first, so the flow binds to something + // declared, and cold starts in a container several agents share. + genObject = await os(['g', 'object', STEM], project); + genFlow = await os(['g', 'flow', STEM], project); + validate = await os(['validate'], project); +}, RUN_TIMEOUT_MS); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +describe('[#20333] `npm create objectstack` → `os g flow` → `os validate`', () => { + it('the on-ramp scaffolded the project, under the namespace this file assumes', () => { + expect(scaffold.code, out(scaffold)).toBe(0); + expect(readFileSync(join(project, 'objectstack.config.ts'), 'utf-8')).toContain(`namespace: '${NS}'`); + }); + + it('CONTROL: `os g object` reaches the stack', () => { + expect(genObject.code, out(genObject)).toBe(0); + expect(genObject.stdout).toContain(`'${OBJECT.itemName(STEM, NS)}'`); + expect(genObject.stdout).not.toContain(`import * as ${OBJECT.stackKey}`); + }); + + it('`os g flow` reaches the stack, with no wiring lines to add', () => { + expect(genFlow.code, out(genFlow)).toBe(0); + expect(genFlow.stdout).toContain(`'${FLOW.itemName(STEM, NS)}'`); + expect(genFlow.stdout).not.toContain(`import * as ${FLOW.stackKey}`); + // Nor a `requires` line: the starter already declares what a flow runs on. + expect(genFlow.stdout).not.toContain('requires: ['); + }); + + it('`os validate` exits 0 and counts the generated flow beside the control', () => { + expect(validate.code, out(validate)).toBe(0); + // The starter's own object plus the generated one. + expect(validate.stdout).toMatch(/\bData: 2 Objects\b/); + expect(validate.stdout).toMatch(/\bLogic: 1 Flows\b/); + }); +}); diff --git a/packages/cli/test/create-objectstack-wiring-parity.test.ts b/packages/cli/test/create-objectstack-wiring-parity.test.ts new file mode 100644 index 00000000000..f228853c33d --- /dev/null +++ b/packages/cli/test/create-objectstack-wiring-parity.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#20333) — `npm create objectstack`'s blank starter wires exactly the + * barrels `os init` wires, in the lines `os init` renders. + * + * ## The defect + * + * The blank starter's `objectstack.config.ts` imported `./src/objects` alone. + * `os g view|action|flow|dashboard|app|skill` wrote a scaffold and a barrel + * that nothing imported, and `os validate` exited 0 with `Logic: 0 Flows`. + * `os init` had the same config and was fixed by wiring every generator + * barrel (#20215): `SCAFFOLD_WIRED_BARRELS`, derived from the generator + * roster, rendered through `exportsOf` over `export {};` barrels, with + * `SCAFFOLD_WIRED_REQUIRES` in `requires`. + * + * ## Why a parity pin, and why it lives in this package + * + * `create-objectstack` cannot import that roster. The dependency edge runs + * the other way (this package depends on it for `created-summary`), and the + * npx entry point must not pull the CLI's closure — the same boundary + * `scripts/sync-scaffold-emission-policy.mjs` documents. Its template is a + * static file tree copied byte for byte, so its wiring is a COPY of what + * `os init` renders. A copy with nothing binding it to the source is a second + * wiring rule, and it decays: the next generator added to the roster would be + * wired by `os init` and silently not by the on-ramp. + * + * This file is the binding. Every expected value below is READ off the CLI — + * `TEMPLATES.app`'s rendered config and barrels, and the two exported + * rosters — and none is written down here, so a roster change, a renderer + * change or a hand edit of the template reddens this file until the two + * scaffolders agree again. Only this package can call the renderer; the + * template side is a static file, so reading it IS reading its producer. + * + * What is compared is code, verbatim: the barrel imports, the `exportsOf` + * helper, the stack-key lines, the `requires` tokens, and each empty barrel's + * bytes. The prose comments around them are not compared — the starter keeps + * its own explanation of `automation` (its connectors need it too). + * + * The behaviour this wiring buys is pinned through the real commands in + * `create-objectstack-stack-reach.test.ts` (`npm create objectstack` → + * `os g flow` → `os validate`). + */ + +import { describe, expect, it } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SCAFFOLD_WIRED_BARRELS, SCAFFOLD_WIRED_REQUIRES, TEMPLATES } from '../src/commands/init.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); + +// One `resolve(HERE, …)` call per line: `check:cross-package-test-inputs` +// reconstructs these reads by SOURCE SCAN. The blank template is declared as a +// cross-package input of `@objectstack/cli` (scripts/cross-package-test-inputs.mjs, +// mirrored into turbo.json), so a template-only diff re-runs this file. +const BLANK = resolve(HERE, '../../create-objectstack/src/templates/blank'); + +const blankConfig = readFileSync(join(BLANK, 'objectstack.config.ts'), 'utf8'); +const initConfig = TEMPLATES.app.configContent('my-app', 'my_app'); + +/** Every line of `text` matching `re`, sorted — order is not the contract. */ +const linesMatching = (text: string, re: RegExp): string[] => + text.split('\n').filter((line) => re.test(line)).sort(); + +/** `import * as from './src/';` — one per wired barrel. */ +const BARREL_IMPORT = /^import \* as \w+ from '\.\/src\/[\w-]+';$/; +/** ` : exportsOf(),` — one per wired barrel, inside `defineStack`. */ +const STACK_KEY = /^\s+\w+: exportsOf\(\w+\),$/; +/** The helper both configs read their barrels through. */ +const HELPER = /^const exportsOf = /; + +/** A barrel whose only code is `export {};` — comments aside. */ +const isEmptyBarrel = (source: string): boolean => + source + .split('\n') + .filter((line) => line.trim() !== '' && !line.trimStart().startsWith('//')) + .join('\n') === 'export {};'; + +describe('[#20333] the roster this file reads is the real one', () => { + it('wires more than objects, and includes flows', () => { + // Vacuity guard: an empty or objects-only roster would make every + // comparison below agree with the defect. + expect(SCAFFOLD_WIRED_BARRELS.length).toBeGreaterThan(1); + expect(SCAFFOLD_WIRED_BARRELS.map((b) => b.stackKey)).toContain('flows'); + expect(SCAFFOLD_WIRED_REQUIRES.length).toBeGreaterThan(0); + }); + + it('the patterns below find one line per wired barrel in the CLI render', () => { + expect(linesMatching(initConfig, BARREL_IMPORT)).toHaveLength(SCAFFOLD_WIRED_BARRELS.length); + expect(linesMatching(initConfig, STACK_KEY)).toHaveLength(SCAFFOLD_WIRED_BARRELS.length); + expect(linesMatching(initConfig, HELPER)).toHaveLength(1); + }); +}); + +describe('[#20333] the blank config wires what `os init` wires, in its lines', () => { + it('imports every wired barrel, and nothing else under ./src', () => { + expect(linesMatching(blankConfig, BARREL_IMPORT)).toEqual(linesMatching(initConfig, BARREL_IMPORT)); + }); + + it('reads the barrels through the same `exportsOf` helper', () => { + expect(linesMatching(blankConfig, HELPER)).toEqual(linesMatching(initConfig, HELPER)); + }); + + it('hands every wired barrel to its stack key', () => { + expect(linesMatching(blankConfig, STACK_KEY)).toEqual(linesMatching(initConfig, STACK_KEY)); + }); + + it('declares every capability the scaffolds need to run', () => { + const declared = [...blankConfig.matchAll(/^\s+requires: \[([^\]]*)\],$/gm)]; + expect(declared, 'exactly one top-level `requires` line').toHaveLength(1); + const tokens = declared[0][1].split(',').map((t) => t.trim().replace(/^'|'$/g, '')); + // A superset, not an equality: the starter's connectors need `automation` + // whether or not a scaffold does. + for (const token of SCAFFOLD_WIRED_REQUIRES) expect(tokens, token).toContain(token); + }); +}); + +describe('[#20333] the blank template ships every wired barrel', () => { + const barrels = SCAFFOLD_WIRED_BARRELS.map((b) => { + const file = `${b.dir}/index.ts`; + const render = TEMPLATES.app.srcFiles[file]; + return { ...b, file, init: render ? render('my-app', 'my_app') : undefined }; + }); + + it.each(barrels.map((b) => [b.file, b] as const))('%s exists', (_file, b) => { + expect(existsSync(join(BLANK, b.file))).toBe(true); + }); + + const empty = barrels.filter((b) => b.init !== undefined && isEmptyBarrel(b.init)); + + it('`os init` writes an empty barrel for every wired directory but objects', () => { + // Vacuity guard for the byte comparison below. + expect(empty.map((b) => b.stackKey).sort()) + .toEqual(SCAFFOLD_WIRED_BARRELS.map((b) => b.stackKey).filter((k) => k !== 'objects').sort()); + }); + + it.each(empty.map((b) => [b.file, b] as const))('%s is byte-identical to the one `os init` writes', (_file, b) => { + expect(readFileSync(join(BLANK, b.file), 'utf8')).toBe(b.init); + }); +}); diff --git a/packages/create-objectstack/README.md b/packages/create-objectstack/README.md index 30683ea1c11..2e9c0f976f5 100644 --- a/packages/create-objectstack/README.md +++ b/packages/create-objectstack/README.md @@ -77,9 +77,15 @@ my-app/ ├── README.md ├── AGENTS.md # conventions for coding agents └── src/ - └── objects/ - ├── index.ts - └── note.object.ts + ├── objects/ + │ ├── index.ts + │ └── note.object.ts + ├── views/index.ts # an empty barrel for each directory + ├── actions/index.ts # `objectstack generate` writes into, + ├── flows/index.ts # already wired into objectstack.config.ts + ├── dashboards/index.ts + ├── apps/index.ts + └── skills/index.ts ``` Next steps inside the project: diff --git a/packages/create-objectstack/src/starter-comments-self-contained.test.ts b/packages/create-objectstack/src/starter-comments-self-contained.test.ts index 1427f6702f9..436bf308eb5 100644 --- a/packages/create-objectstack/src/starter-comments-self-contained.test.ts +++ b/packages/create-objectstack/src/starter-comments-self-contained.test.ts @@ -142,7 +142,7 @@ const MONOREPO_ONLY = [ // "the shallowest point a path reaches"). In THIS population the two give // the same answer, for a measured reason: every relative reference the // shipped tree carries is `./`-anchored and points DOWNWARD from the file - // that writes it (`./note.object.js`, `./src/objects/index.js`), so the + // that writes it (`./note.object.js`, `./src/objects`), so the // count of `../` here — escaping or merely climbing — is zero. The bare // anchor therefore has no correct text to redden. The leading lookbehind is // what keeps it that way: it refuses a `..` that is itself part of a longer diff --git a/packages/create-objectstack/src/templates/AGENTS.md b/packages/create-objectstack/src/templates/AGENTS.md index 55324f2ecdc..944cb0e4720 100644 --- a/packages/create-objectstack/src/templates/AGENTS.md +++ b/packages/create-objectstack/src/templates/AGENTS.md @@ -46,7 +46,11 @@ Run `npm run build` when you need the compiled `dist/objectstack.json` artifact. 1. **Zod First** — All schema definitions start with Zod. Types are derived via `z.infer<>`. 2. `defineStack()` is the single configuration entry point in `objectstack.config.ts`. -3. Use `Object.values()` barrel pattern for metadata arrays. +3. Metadata reaches the stack through barrels: each `src/*/index.ts` exports what its + directory holds, and `objectstack.config.ts` already hands every barrel to + `defineStack()` through `exportsOf()` — a typed `Object.values()` that still + type-checks while a barrel is empty, so keep it. Export a new file from its barrel + rather than adding a key for it. 4. Import from `@objectstack/spec` — never use relative paths into the spec package. 5. **Predicates are CEL** — `visible`, `disabled`, `requiredWhen`, validation rules, flow conditions and sharing rules reference record fields as `record.`, diff --git a/packages/create-objectstack/src/templates/blank/README.md b/packages/create-objectstack/src/templates/blank/README.md index f1dc70ba54c..5830ac33994 100644 --- a/packages/create-objectstack/src/templates/blank/README.md +++ b/packages/create-objectstack/src/templates/blank/README.md @@ -38,8 +38,10 @@ the whole time — the `curl` above returns it, and an MCP client can read and write it. What it has no route into is the Console's navigation. **An object appears in Console navigation only when an app lists it.** Add an -`*.app.ts` under `src/apps/` (plus the views it points at), and the Console -renders it after the next `pnpm dev` rebuild. The `objectstack-ui` skill covers +`*.app.ts` under `src/apps/` (plus the views it points at), export each one from +its directory's `index.ts`, and the Console renders it after the next `pnpm dev` +rebuild. `objectstack generate app NAME` and `objectstack generate view NAME` +write the file and its export line together. The `objectstack-ui` skill covers the shape; describing the app you want to your coding agent is the intended path. @@ -63,8 +65,15 @@ for OAuth, API keys, and which objects/actions become tools. ## Layout -- `objectstack.config.ts` — environment manifest (objects, API, plugins) +- `objectstack.config.ts` — environment manifest (objects, API, plugins), and + the wiring for every directory below - `src/objects/` — object definitions (one file per object) +- `src/views/`, `src/actions/`, `src/flows/`, `src/dashboards/`, `src/apps/`, + `src/skills/` — empty to start. Each directory's `index.ts` exports what it + holds and `objectstack.config.ts` hands those exports to the stack, so + `objectstack generate flow NAME` (or `view`, `action`, …) adds a file and one + export line, and the item is loaded, and counted by `pnpm validate`, with no + edit to the config ## Connectors (default providers) diff --git a/packages/create-objectstack/src/templates/blank/objectstack.config.ts b/packages/create-objectstack/src/templates/blank/objectstack.config.ts index 84a65033903..17e29830585 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.config.ts +++ b/packages/create-objectstack/src/templates/blank/objectstack.config.ts @@ -2,8 +2,24 @@ import { defineStack } from '@objectstack/spec'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; -import * as objects from './src/objects/index.js'; +import * as objects from './src/objects'; +import * as views from './src/views'; +import * as actions from './src/actions'; +import * as flows from './src/flows'; +import * as dashboards from './src/dashboards'; +import * as apps from './src/apps'; +import * as skills from './src/skills'; +// Every value a barrel exports, as the list a stack key takes: typed by what +// the barrel exports, and an empty list while it exports nothing yet. +const exportsOf = (barrel: M): M[keyof M][] => Object.values(barrel); + +// This file is a MODULE, and the whole module is the stack: the default +// export below is the base, and every NAMED export is merged onto it as a +// top-level stack key under its own name. A helper exported from here is +// therefore read as a stack key and the build refuses it — keep helpers in a +// sibling module and import them. Only names the stack schema declares +// (onEnable, functions, the collections) belong here as named exports. export default defineStack({ manifest: { id: 'com.example.blank', @@ -27,7 +43,12 @@ export default defineStack({ // connector executors below register their provider factories with it — // without `automation` loaded they have nowhere to register and boot fails, // so keep this capability whenever `plugins:` lists a connector. - requires: ['automation'], + // + // `triggers` fires a flow that starts on a record change, the kind + // `objectstack generate flow NAME` writes: without it this config stops + // loading once it holds such a flow. It can go if this project will never + // hold one. + requires: ['automation', 'triggers'], // Generic connector executors, default-present so you can add a `connectors:` // entry naming `provider: 'rest' | 'openapi' | 'mcp'` and have it materialize @@ -43,5 +64,17 @@ export default defineStack({ new ConnectorMcpPlugin(), ], - objects: Object.values(objects), + // Every directory `objectstack generate` writes into is wired here: its + // index.ts exports what the directory holds, and each list below hands + // those exports to the stack. `objectstack generate view NAME` adds a file + // and one export line, and the view is part of this stack with no edit to + // this file. A directory that is not wired here is never loaded, and + // `objectstack validate` neither counts nor checks what it holds. + objects: exportsOf(objects), + views: exportsOf(views), + actions: exportsOf(actions), + flows: exportsOf(flows), + dashboards: exportsOf(dashboards), + apps: exportsOf(apps), + skills: exportsOf(skills), }); diff --git a/packages/create-objectstack/src/templates/blank/src/actions/index.ts b/packages/create-objectstack/src/templates/blank/src/actions/index.ts new file mode 100644 index 00000000000..9ad0888f28b --- /dev/null +++ b/packages/create-objectstack/src/templates/blank/src/actions/index.ts @@ -0,0 +1,5 @@ +// The actions in this directory. `objectstack generate action NAME` writes one +// here and adds its export line below. objectstack.config.ts hands every +// export of this file to the stack, so a action exported here is part of it, +// and a action file this index does not export is never loaded. +export {}; diff --git a/packages/create-objectstack/src/templates/blank/src/apps/index.ts b/packages/create-objectstack/src/templates/blank/src/apps/index.ts new file mode 100644 index 00000000000..535c2b56f6b --- /dev/null +++ b/packages/create-objectstack/src/templates/blank/src/apps/index.ts @@ -0,0 +1,5 @@ +// The apps in this directory. `objectstack generate app NAME` writes one +// here and adds its export line below. objectstack.config.ts hands every +// export of this file to the stack, so a app exported here is part of it, +// and a app file this index does not export is never loaded. +export {}; diff --git a/packages/create-objectstack/src/templates/blank/src/dashboards/index.ts b/packages/create-objectstack/src/templates/blank/src/dashboards/index.ts new file mode 100644 index 00000000000..c053abecb0b --- /dev/null +++ b/packages/create-objectstack/src/templates/blank/src/dashboards/index.ts @@ -0,0 +1,5 @@ +// The dashboards in this directory. `objectstack generate dashboard NAME` writes one +// here and adds its export line below. objectstack.config.ts hands every +// export of this file to the stack, so a dashboard exported here is part of it, +// and a dashboard file this index does not export is never loaded. +export {}; diff --git a/packages/create-objectstack/src/templates/blank/src/flows/index.ts b/packages/create-objectstack/src/templates/blank/src/flows/index.ts new file mode 100644 index 00000000000..51262047af5 --- /dev/null +++ b/packages/create-objectstack/src/templates/blank/src/flows/index.ts @@ -0,0 +1,5 @@ +// The flows in this directory. `objectstack generate flow NAME` writes one +// here and adds its export line below. objectstack.config.ts hands every +// export of this file to the stack, so a flow exported here is part of it, +// and a flow file this index does not export is never loaded. +export {}; diff --git a/packages/create-objectstack/src/templates/blank/src/skills/index.ts b/packages/create-objectstack/src/templates/blank/src/skills/index.ts new file mode 100644 index 00000000000..013da9c6bf6 --- /dev/null +++ b/packages/create-objectstack/src/templates/blank/src/skills/index.ts @@ -0,0 +1,5 @@ +// The skills in this directory. `objectstack generate skill NAME` writes one +// here and adds its export line below. objectstack.config.ts hands every +// export of this file to the stack, so a skill exported here is part of it, +// and a skill file this index does not export is never loaded. +export {}; diff --git a/packages/create-objectstack/src/templates/blank/src/views/index.ts b/packages/create-objectstack/src/templates/blank/src/views/index.ts new file mode 100644 index 00000000000..51c27851ae0 --- /dev/null +++ b/packages/create-objectstack/src/templates/blank/src/views/index.ts @@ -0,0 +1,5 @@ +// The views in this directory. `objectstack generate view NAME` writes one +// here and adds its export line below. objectstack.config.ts hands every +// export of this file to the stack, so a view exported here is part of it, +// and a view file this index does not export is never loaded. +export {}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 980f23e80db..bf69c2837b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -616,6 +616,15 @@ importers: specifier: ^4.6.1 version: 4.6.1 devDependencies: + '@objectstack/connector-mcp': + specifier: workspace:* + version: link:../connectors/connector-mcp + '@objectstack/connector-openapi': + specifier: workspace:* + version: link:../connectors/connector-openapi + '@objectstack/connector-rest': + specifier: workspace:* + version: link:../connectors/connector-rest '@objectstack/driver-turso': specifier: workspace:* version: link:../drivers/driver-turso diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 830ab47d042..431ed76ab95 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -624,7 +624,31 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // that file DEFINES the `FieldType` enum the pin asserts totality over -- // a member added there is exactly the change that must re-run this suite. 'packages/spec/src/data/field.zod.ts', + // The blank starter's config and its `src/` tree, the third pair of the + // two-scaffolder shape above (#20333). The starter cannot import the + // wiring `os init` renders (the dependency edge runs the other way), so + // its config and barrels are a static COPY of that render, and + // test/create-objectstack-wiring-parity.test.ts holds the copy to it: + // every expected line is read off `TEMPLATES.app` and the two exported + // rosters. test/create-objectstack-stack-reach.test.ts drives the + // on-ramp's `bin/` into a project and counts what `os validate` loads, + // the starter's own object included. A template-only diff therefore + // changes what both measure, and without these globs + // `@objectstack/cli#test` would hash the same and replay a cached green + // over the drift the parity pin exists to catch. `src/**` rather than + // the barrels by name: the pin reaches them through a join over the + // roster, which is why the glob carries a witness below. + 'packages/create-objectstack/src/templates/blank/objectstack.config.ts', + 'packages/create-objectstack/src/templates/blank/src/**', ], + heldBy: { + // The parity pin joins each barrel path from `SCAFFOLD_WIRED_BARRELS`, a + // roster the scan cannot fold, so no rostered path lands in this glob; + // the witness keeps it attributed to the read that needs it. + 'packages/create-objectstack/src/templates/blank/src/**': [ + 'packages/cli/test/create-objectstack-wiring-parity.test.ts', + ], + }, }, '@objectstack/client': { // The first entry this gate DERIVED from import specifiers rather than from diff --git a/turbo.json b/turbo.json index 9b149402f9b..42a3e1c2316 100644 --- a/turbo.json +++ b/turbo.json @@ -144,7 +144,9 @@ "$TURBO_ROOT$/scripts/sync-scaffold-emission-policy.mjs", "$TURBO_ROOT$/packages/drivers/driver-sql/src/sql-driver.ts", "$TURBO_ROOT$/packages/drivers/driver-sql/src/schema-drift.ts", - "$TURBO_ROOT$/packages/spec/src/data/field.zod.ts" + "$TURBO_ROOT$/packages/spec/src/data/field.zod.ts", + "$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/objectstack.config.ts", + "$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/src/**" ] }, "@objectstack/client#test": {