Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .changeset/20333-create-objectstack-wire-barrels.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 13 additions & 7 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
</Callout>

Expand Down Expand Up @@ -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/<dir>/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
Expand All @@ -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
Expand Down
16 changes: 10 additions & 6 deletions content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 35 additions & 8 deletions content/docs/getting-started/your-first-project.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <M extends object>(barrel: M): M[keyof M][] => Object.values(barrel);

export default defineStack({
manifest: {
Expand All @@ -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),
});
```

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
164 changes: 164 additions & 0 deletions packages/cli/test/create-objectstack-stack-reach.test.ts
Original file line number Diff line number Diff line change
@@ -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<Run> {
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/);
});
});
Loading
Loading