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
11 changes: 11 additions & 0 deletions .changeset/21018-cli-generate-picklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/cli': minor
---

feat(cli): `objectstack generate picklist NAME` scaffolds a shared option list, and the metadata summary counts picklists

Clause-②: yes (widening)

- **`objectstack generate picklist NAME`** (alias `os g picklist`) writes `src/picklists/NAME.picklist.ts`, a list declared with `definePicklist({ name, label, options })`, and adds its export line to `src/picklists/index.ts`. The list is collected under the `picklists` stack key. A select field takes its options from the list by naming it, `Field.select({ picklist: 'NAME' })`, in place of options of its own. The server serves that field with the list's options resolved onto it, together with any options other packages add through `picklistExtensions`, and judges writes against them. `objectstack validate` and `objectstack build` refuse a field whose `picklist` names no list the stack declares, and so does the boot.
- **`objectstack init`** wires the new `src/picklists` barrel in the `app` and `plugin` templates, the same way it wires every other directory `objectstack generate` writes into: an empty `src/picklists/index.ts` and a `picklists: exportsOf(picklists)` key in `objectstack.config.ts`. A project scaffolded by an earlier release keeps its config. `objectstack generate picklist` then reports the list as not wired and prints the import line and the `defineStack` key to add.
- **The metadata summary** that `objectstack validate`, `objectstack build` and `objectstack info` print counts the picklists a stack declares, in the `Data:` row: `Data: 1 Objects 3 Fields 1 Picklists`. A stack that declares none prints the row it printed before. The `stats` object in the `--json` output of the same three commands gains a `picklists` count. A `picklistExtensions` entry is not counted as a list.
11 changes: 11 additions & 0 deletions .changeset/21018-create-objectstack-picklists-barrel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'create-objectstack': minor
---

feat(create-objectstack): the blank starter wires a `src/picklists` barrel for `objectstack generate picklist`

Clause-②: yes (widening)

A new blank project ships an empty `src/picklists/index.ts`, and its `objectstack.config.ts` imports it and hands its exports to `defineStack` under `picklists`, as it already does for every other directory `objectstack generate` writes into. `objectstack generate picklist NAME` then writes `src/picklists/NAME.picklist.ts` and its export line, and the list is part of the stack with no edit to the config. A select field takes its options from the list with `Field.select({ picklist: 'NAME' })`, and the server serves that field with the list's options.

A project scaffolded by an earlier release keeps its config. There, `objectstack generate picklist NAME` writes the list, reports that it does not reach the stack, and prints the import line and the `defineStack` key that wire `src/picklists`.
4 changes: 3 additions & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1483,6 +1483,7 @@ os g flow customer # Generate a flow that runs when a Customer changes
os g dashboard sales # Generate a dashboard
os g app customer # Generate an app whose navigation opens Customer
os g skill lead_qual # Generate an AI skill
os g picklist industry # Generate a shared option list select fields name

os g object task -d lib/ # Override target directory
os g object task --dry-run # Preview without writing
Expand All @@ -1499,6 +1500,7 @@ os g object task --dry-run # Preview without writing
| `dashboard` | `src/dashboards/` | `NAME.dashboard.ts` | `dashboards` | Analytics dashboard |
| `app` | `src/apps/` | `NAME.app.ts` | `apps` | Application navigation |
| `skill` | `src/skills/` | `NAME.skill.ts` | `skills` | AI skill — the ADR-0063 extension primitive |
| `picklist` | `src/picklists/` | `NAME.picklist.ts` | `picklists` | Shared option list that select fields reference by name |

<Callout type="info" title="Why generated files carry a type infix">
Every scaffold is written as `NAME.TYPE.ts`, and the infix is read from the
Expand Down Expand Up @@ -1555,7 +1557,7 @@ the rules a JSON Schema can express and names the rest under

**What it does:**
1. For a type that names an object (`object`, `view`, `action`, `flow`, `app`), reads `manifest.namespace` from the project config (`objectstack.config.ts`, `.js` or `.mjs`) and prefixes the object name with it (see above)
2. Creates the TypeScript file — an `object` declared with `ObjectSchema.create({ … })`, the same shape the `os init` templates write; a `skill` declared with `defineSkill({ … })`; the other types as typed literals (`UI.View`, `UI.Action`, `Automation.Flow`, `UI.Dashboard`, `UI.App`)
2. Creates the TypeScript file — an `object` declared with `ObjectSchema.create({ … })`, the same shape the `os init` templates write; a `skill` declared with `defineSkill({ … })`; a `picklist` declared with `definePicklist({ … })`, which a select field names with `Field.select({ picklist: 'NAME' })` in place of its own `options`, and which the server resolves into that field's `options`; the other types as typed literals (`UI.View`, `UI.Action`, `Automation.Flow`, `UI.Dashboard`, `UI.App`)
3. Creates or updates the barrel `index.ts` in the target directory
4. Shows a hint to run `objectstack validate`

Expand Down
6 changes: 5 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ os compile
| `os generate <type> <name>` | Generate metadata files (alias: `os g`) |
| `os create <type> [name]` | Scaffold a standalone **kernel code** plugin project (the `Plugin` contract, built by `tsc`) from a built-in template |

Available generate types: `object`, `view`, `action`, `flow`, `dashboard`, `app`, `skill`
Available generate types: `object`, `view`, `action`, `flow`, `dashboard`, `app`, `skill`, `picklist`

`picklist` writes a shared option list (`src/picklists/<name>.picklist.ts`, with
`definePicklist`). A select field names it with `Field.select({ picklist: '<name>' })`
in place of its own `options`, and the server serves that field with the list's options.

`agent` is **retired** (ADR-0063 §2): agents are platform-internal, so a scaffolded
`src/agents/*.ts` validated, published and was then filtered out of the runtime
Expand Down
127 changes: 127 additions & 0 deletions packages/cli/src/commands/generate-picklist.pin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* PIN: `os generate picklist NAME` writes a shared option list that the
* runtime SERVES, not one it ignores.
*
* ## What was measured before the row existed
*
* On `origin/main` 58a77dbde2, `os generate picklist industry` printed
* `Unknown type: picklist` and exited 1, and the blank starter wired no
* `src/picklists` barrel. A list could be authored only by hand, and the
* command an author reaches for first did not know the kind.
*
* ## Why the row waited, and what this file holds against
*
* A scaffold of a kind with no runtime reader validates, builds, and then
* serves nothing: the list registers and no field ever carries its options.
* The row was held until the engine resolved `picklist` into `options`
* (`@objectstack/objectql`, `picklist-resolution.ts`). So the obligation here
* is end to end, not a template string: the file the generator writes,
* loaded the way `os validate` loads authored TypeScript and collected under
* the stack key `os init` and the blank starter wire its barrel into, is what
* the engine resolves onto a field that names it.
*
* Every fact about the row is READ off the roster (`GENERATOR_SCAFFOLD_TARGETS`)
* and the registry (`metadataFileName`), never restated, except the three the
* card names: the type `picklist`, the directory `src/picklists`, and the
* stack key `picklists` the engine reads.
*
* The control: the same object with no list registered serves no options, so
* a green here cannot come from a field that carried options already.
*/

import { afterAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { bundleRequire } from 'bundle-require';
import { Field, ObjectSchema, PicklistSchema, PicklistServedFieldSchema } from '@objectstack/spec/data';
import { ObjectQL } from '@objectstack/objectql';
import { GENERATOR_SCAFFOLD_TARGETS } from './generate.js';
import { metadataFileName } from '../utils/metadata-file-name.js';
import { BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';

const STEM = 'industry';

const PICKLIST = GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === 'picklist');

/**
* Inside this package's own `node_modules`, for the reason
* `test/generate-scaffold-validates.test.ts` gives: git-ignored, and the
* scaffold's `@objectstack/spec/data` import resolves from here exactly as it
* would in an author's project.
*/
const TMP_ROOT = fs.mkdtempSync(
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'node_modules', '.generate-picklist-pin-'),
);

afterAll(() => {
fs.rmSync(TMP_ROOT, { recursive: true, force: true });
});

/** Materialize the scaffold through the loader `os validate` uses. */
async function loadScaffold(): Promise<Record<string, unknown>> {
if (!PICKLIST) throw new Error('no `picklist` generator on the roster');
const file = path.join(TMP_ROOT, metadataFileName('picklist', STEM) ?? 'unnamed.ts');
fs.writeFileSync(file, PICKLIST.generate(STEM), 'utf8');
const { mod } = await bundleRequire({ filepath: file, external: BUNDLE_REQUIRE_EXTERNALS });
return ((mod as { default?: unknown }).default ?? mod) as Record<string, unknown>;
}

/** An object with one select field that names the list, as an author writes it. */
const account = (listName: string) => ObjectSchema.create({
name: 'pin_account',
label: 'Account',
sharingModel: 'private',
fields: {
industry: Field.select({ picklist: listName, label: 'Industry' }),
},
});

/** Register a package the way the boot does, through the stack keys it declares. */
function serve(collections: Record<string, unknown[]>) {
const engine = new ObjectQL();
engine.registerApp({ id: 'com.example.picklist_pin', name: 'picklist_pin', ...collections } as never);
return engine.registry.getObject('pin_account')?.fields.industry as Record<string, unknown> | undefined;
}

describe('`os generate picklist` is on the roster, where the starter wires it', () => {
it('scaffolds into src/picklists, collected under the `picklists` stack key', () => {
expect(PICKLIST, 'the `picklist` generator').toBeDefined();
expect(PICKLIST!.defaultDir).toBe('src/picklists');
expect(PICKLIST!.stackKey).toBe('picklists');
// A list names no object, so a namespaced project writes the name as typed,
// and it needs no capability token to load.
expect(PICKLIST!.namesObject).toBe(false);
expect(PICKLIST!.requires).toEqual([]);
});

it('writes NAME.picklist.ts, the registry\'s own pattern for the kind', () => {
expect(metadataFileName('picklist', STEM)).toBe(`${STEM}.picklist.ts`);
});
});

describe('the scaffold the generator writes is a list the engine serves', () => {
it('loads as a picklist named what `os g` reports it reached', async () => {
const list = await loadScaffold();
expect(PicklistSchema.safeParse(list).success).toBe(true);
expect(list.name).toBe(PICKLIST!.itemName(STEM));
});

it('resolves onto a select field that names it: the served field carries the list\'s options', async () => {
const list = await loadScaffold();
const field = serve({ [PICKLIST!.stackKey]: [list], objects: [account(PICKLIST!.itemName(STEM))] });

expect(field?.picklist).toBe(STEM);
expect(field?.options).toEqual(list.options);
// The served contract the object read exits owe a client.
expect(PicklistServedFieldSchema.safeParse(field).success).toBe(true);
});

it('control: the same field with no list registered serves no options', () => {
const field = serve({ objects: [account(STEM)] });
expect(field?.picklist).toBe(STEM);
expect(field?.options).toBeUndefined();
});
});
61 changes: 58 additions & 3 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ const FLOW_SCAFFOLD_REQUIRES = ['automation', 'triggers'] as const;
* flag against the templates.
*
* Only object names are prefixed. The scaffold's own `name` on an action, a
* flow, a dashboard, an app or a skill is not judged against the namespace by
* any gate `os validate` runs, so it stays the name the author typed. A view
* flow, a dashboard, an app, a skill or a picklist is not judged against the
* namespace by any gate `os validate` runs, so it stays the name the author
* typed. A view
* container's own `name` IS an object name — the container is registered under
* the object it binds to — so it is prefixed with it (#20215).
*
Expand Down Expand Up @@ -539,6 +540,59 @@ const ${toCamelCase(name)}Skill = defineSkill({
});

export default ${toCamelCase(name)}Skill;
`,
},

picklist: {
description: 'Shared option list that select fields reference by name',
defaultDir: 'src/picklists',
/**
* A shared option list (`data/picklist.zod.ts`): one `options` array that
* select fields on any object take their options from by NAMING the list,
* `Field.select({ picklist: 'NAME' })`, instead of each carrying a copy.
*
* Written as `NAME.picklist.ts`, the registry's own pattern for the kind,
* through {@link metadataFileName} like every other type here, with no
* override. Declared through `definePicklist`, so the list is parsed the
* moment this module loads: an unknown key or an empty `options` is a
* startup error naming it, not a list that goes missing later.
*
* Every door a referencing field passes resolves the name, so the list
* this writes is one the runtime serves, not a declaration it ignores:
*
* - `os validate` and `os build` refuse a field whose `picklist` names no
* list the stack declares (`utils/picklist-references.ts`);
* - the boot refuses the same unresolved name, and serves every field that
* names a list with the list's options resolved onto it, together with
* the options other packages add through `picklistExtensions`
* (`@objectstack/objectql`, `picklist-resolution.ts`);
* - a write to such a field is judged against that resolved set.
*
* The emitted header states the one rule an author meets next: a field
* that names the list declares no `options` of its own, because
* `FieldSchema` refuses the two together.
*/
namesObject: false,
itemName: (name: string) => toSnakeCase(name),
generate: (name: string) => `import { definePicklist } from '@objectstack/spec/data';

/**
* ${toTitleCase(name)} Picklist
*
* A shared option list. A select field offers these options by naming the
* list — Field.select({ picklist: '${toSnakeCase(name)}' }) — and declares no
* \`options\` of its own: a field declaring both is refused.
*/
const ${toCamelCase(name)}Picklist = definePicklist({
name: '${toSnakeCase(name)}',
label: '${toTitleCase(name)}',
options: [
{ label: 'Option A', value: 'option_a' },
{ label: 'Option B', value: 'option_b' },
],
});

export default ${toCamelCase(name)}Picklist;
`,
},
};
Expand Down Expand Up @@ -1267,7 +1321,8 @@ async function runMetadataGeneration(type: string, name: string, flags: { dir?:
// them: the scaffold file, and the barrel re-export line. They are the two
// files one name reaches (#16541), and rendering them at the single point
// where the name has finished being derived is what lets one refusal cover
// all 14 emission sites across all 7 generators instead of 14 patches.
// every emission site of every generator (14 across 7 when it landed)
// instead of one patch per site.
const content = generator.generate(name, namespace);
const exportLine = `export { default as ${toCamelCase(name)} } from '${moduleSpecifier}';`;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const ZEROES: MetadataStats = {
objects: 0,
objectExtensions: 0,
fields: 0,
picklists: 0,
views: 0,
pages: 0,
apps: 0,
Expand Down
102 changes: 102 additions & 0 deletions packages/cli/src/utils/format.metadata-stats-picklists.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* PIN: the metadata summary `os validate`, `os build` and `os info` print
* counts the picklists a stack declares.
*
* ## What was measured before the row existed
*
* On `origin/main` 58a77dbde2, a blank starter with one picklist wired under
* `picklists` and one select field naming it printed, through `os validate`:
*
* Data: 1 Objects 3 Fields
*
* The list was declared, loaded and resolved, and the summary that answers
* "what does this stack declare" said nothing about it. With `os generate
* picklist` on the roster, an author who runs it and then `os validate` reads
* that summary to see the scaffold arrive.
*
* ## What is pinned
*
* - the READER (`collectMetadataStats`) counts `picklists`, in both ADR-0130
* D4 shapes, through the one fold every other member goes through;
* - the PRINTER renders the count in the `Data:` row, the domain the kind
* belongs to (`kernel/metadata-plugin.zod.ts` files it under `data`);
* - a stack with no picklists prints the `Data:` row it printed before,
* because a zero item is filtered like every other one;
* - a `picklistExtensions` entry is not a list: it adds options to a list
* another package owns, so it is not counted as one.
*/

import { describe, expect, it } from 'vitest';
import { collectMetadataStats, printMetadataStats, type MetadataStats } from './format.js';

/** Drop SGR sequences so an assertion reads the words, not chalk's opinion. */
const stripAnsi = (s: string) => s.replace(/\u001B\[[0-9;]*m/g, '');

function dataRow(stats: MetadataStats): string | undefined {
const captured: string[] = [];
const original = console.log;
console.log = (...args: unknown[]) => {
captured.push(args.map(String).join(' '));
};
try {
printMetadataStats(stats);
} finally {
console.log = original;
}
return stripAnsi(captured.join('\n')).split('\n').find((l) => l.trim().startsWith('Data:'))?.trim();
}

const manifest = {
id: 'com.example.pk',
name: 'pk',
version: '1.0.0',
type: 'app',
namespace: 'pk',
};

const INDUSTRY = { name: 'industry', label: 'Industry', options: [{ label: 'Tech', value: 'tech' }] };
const REGION = { name: 'region', label: 'Region', options: [{ label: 'EMEA', value: 'emea' }] };

const ACCOUNT = {
name: 'pk_account',
label: 'Account',
sharingModel: 'private',
fields: {
name: { type: 'text', label: 'Name' },
industry: { type: 'select', label: 'Industry', picklist: 'industry' },
},
};

describe('collectMetadataStats counts the picklists a stack declares', () => {
it('a top-level stack', () => {
expect(collectMetadataStats({ manifest, picklists: [INDUSTRY, REGION], objects: [ACCOUNT] }).picklists).toBe(2);
});

it('an option-B stack, every definition inside `packages[]`', () => {
const optionB = { manifest, packages: [{ manifest: { ...manifest, picklists: [INDUSTRY], objects: [ACCOUNT] } }] };
expect(collectMetadataStats(optionB).picklists).toBe(1);
});

it('a `picklistExtensions` entry is not a list', () => {
const extending = { manifest, picklistExtensions: [{ extend: 'industry', options: [{ label: 'Bio', value: 'bio' }] }] };
expect(collectMetadataStats(extending).picklists).toBe(0);
});

it('a stack that declares none counts zero', () => {
expect(collectMetadataStats({ manifest, objects: [ACCOUNT] }).picklists).toBe(0);
});
});

describe('printMetadataStats renders the count in the Data: row', () => {
it('beside the objects and fields that name the lists', () => {
const stats = collectMetadataStats({ manifest, picklists: [INDUSTRY], objects: [ACCOUNT] });
expect(dataRow(stats)).toBe('Data: 1 Objects 2 Fields 1 Picklists');
});

it('control: a stack with no picklists prints the row it printed before', () => {
const stats = collectMetadataStats({ manifest, objects: [ACCOUNT] });
expect(dataRow(stats)).toBe('Data: 1 Objects 2 Fields');
});
});
Loading
Loading