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
13 changes: 13 additions & 0 deletions .changeset/30123-scaffold-security-datasets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/cli': patch
'create-objectstack': patch
---

feat(cli,create-objectstack): a new project has a wired place for permission sets and datasets, `os g permission` / `os g dataset` write them, and the generated AGENTS.md names the package manager the project was created with

Clause-②: yes (widening)
Metadata-protocol: none

- **`create-objectstack`: the blank starter ships `src/security/index.ts` and `src/datasets/index.ts`**, empty barrels already handed to the stack's `permissions` and `datasets` keys in `objectstack.config.ts`, like the eight directories it wired before. A permission set or a dataset exported from them is loaded and counted by `validate` with no edit to the config. The generated `AGENTS.md` lists both directories.
- **`@objectstack/cli`: `os g permission NAME [--object OBJECT]`** writes `src/security/NAME.permission.ts`, a `definePermissionSet` granting read, create and edit (no delete) on the object `--object` names, or the stack's only object, with `readScope: 'own'` declared. **`os g dataset NAME [--object OBJECT]`** writes `src/datasets/NAME.dataset.ts`, a `defineDataset` over that object with a `record_count` measure, grouped by the record's title field when it has one. `defineDataset` types the literal, so it needs no `as const`. Both pass `os validate`, `os build` and `os lint` with no finding on a fresh project. `os init` wires both directories too.
- **`create-objectstack`: the generated `AGENTS.md` (and `.github/copilot-instructions.md`) says `pnpm run validate` in a project created with pnpm**, and `npm run …` in one created with npm, the same package manager the install line and "Next steps" name. It used to say `npm run` in every project.
10 changes: 7 additions & 3 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2341,6 +2341,8 @@ os g dashboard sales # Generate a dashboard
os g app sales --object 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 permission sales_user --object customer # Generate a permission set that grants Customer
os g dataset customer_metrics --object customer # Generate a dataset dashboard widgets bind to

os g action escalate --object customer --flow customer_changed_flow # Name the flow when there are several
os g object task -d lib/ # Override target directory
Expand All @@ -2359,6 +2361,8 @@ os g object task --dry-run # Preview without writing
| `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 |
| `permission` | `src/security/` | `NAME.permission.ts` | `permissions` | Permission set: what a signed-in user may do with an object |
| `dataset` | `src/datasets/` | `NAME.dataset.ts` | `datasets` | Analytics dataset: the dimensions and measures dashboard widgets bind to |

<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 @@ -2412,12 +2416,12 @@ the rules a JSON Schema can express and names the rest under
**Options:**
- `-d, --dir <directory>` — Override target directory
- `--dry-run` — Preview without writing files
- `--object <object>` — The object a `flow`, `action` or `app` binds; default: the stack's only object
- `--object <object>` — The object a `flow`, `action`, `app`, `permission` or `dataset` binds; default: the stack's only object
- `--flow <flow>` — The flow an `action` runs; default: the stack's only flow

**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, and resolves every object or flow the scaffold binds against the stack that config loads (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({ … })`; 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`)
1. For a type that names an object (`object`, `view`, `action`, `flow`, `app`, `permission`, `dataset`), reads `manifest.namespace` from the project config (`objectstack.config.ts`, `.js` or `.mjs`) and prefixes the object name with it, and resolves every object or flow the scaffold binds against the stack that config loads (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({ … })`; 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`; a `permission` set declared with `definePermissionSet({ … })`, granting the bound object; a `dataset` declared with `defineDataset({ … })` over the bound object, with a record-count measure; 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
7 changes: 6 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,17 @@ 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`, `picklist`
Available generate types: `object`, `view`, `action`, `flow`, `dashboard`, `app`, `skill`, `picklist`, `permission`, `dataset`

`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.

`permission` writes a permission set (`src/security/<name>.permission.ts`, with
`definePermissionSet`) granting the object `--object` names, or the stack's only object.
`dataset` writes an analytics dataset (`src/datasets/<name>.dataset.ts`, with
`defineDataset`) over that object, which dashboard and report widgets bind to by name.

`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
catalog without a word. `os g agent` now says so and points at skills — the
Expand Down
144 changes: 144 additions & 0 deletions packages/cli/src/commands/generate-permission-dataset.pin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* PIN: `os generate permission NAME` and `os generate dataset NAME` write a
* permission set and an analytics dataset into the directories the blank
* starter wires, each bound to an object the stack declares.
*
* ## What was measured before the rows existed
*
* On `origin/main` 5fa9375e, `os generate permission` and
* `os generate dataset` each printed `Unknown type:` and exited 1, and the
* blank starter wired no `src/security` or `src/datasets` barrel. An app that
* real users sign in to needs a permission set, and a dashboard widget binds
* to a dataset; both could be written only by hand, in a directory the author
* also had to wire by hand.
*
* ## What this file holds, and what it leaves to the derived pins
*
* The roster facts the starter depends on — the type key, the directory, the
* stack key — and the scaffold's own shape: it parses under the schema the
* stack key carries, under the name `os g` reports it reached, and it names
* the object it was bound to, never one derived from its own name. That every
* scaffold passes `os validate`, `os build` and `os lint` with zero findings,
* type-checks, and reaches the stack is held for the whole roster by
* `test/generate-scaffold-validates.test.ts`,
* `test/scaffold-emission-typechecks.test.ts` and
* `test/generate-scaffolds-reach-stack.e2e.test.ts`; the blank starter's
* wiring by `test/create-objectstack-wiring-parity.test.ts`.
*
* Every other fact is READ off the roster (`GENERATOR_SCAFFOLD_TARGETS`) and
* the registry (`metadataFileName`).
*/

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 { PermissionSetSchema } from '@objectstack/spec/security';
import { DatasetSchema } from '@objectstack/spec/ui';
import { GENERATOR_SCAFFOLD_TARGETS, type ScaffoldObjectBinding } from './generate.js';
import { metadataFileName } from '../utils/metadata-file-name.js';
import { BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';

const STEM = 'sales_user';

/** Deliberately NOT named like the item: a binding derived from the name would miss it. */
const TARGET: ScaffoldObjectBinding = {
name: 'pin_account',
label: 'Account',
fields: ['name', 'industry'],
displayField: 'name',
};

const target = (type: string) => GENERATOR_SCAFFOLD_TARGETS.find((t) => t.type === type);

/**
* 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/*` 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-permission-dataset-pin-'),
);

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

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

describe('`os generate permission` and `os generate dataset` are on the roster, where the starter wires them', () => {
it.each([
['permission', 'src/security', 'permissions'],
['dataset', 'src/datasets', 'datasets'],
])('%s scaffolds into %s, collected under the `%s` stack key', (type, dir, stackKey) => {
const gen = target(type);
expect(gen, `the \`${type}\` generator`).toBeDefined();
expect(gen!.defaultDir).toBe(dir);
expect(gen!.stackKey).toBe(stackKey);
// Both bind an object the stack declares, from `--object` or the only one.
expect(gen!.binds).toEqual({ object: 'flag' });
expect(gen!.namesObject).toBe(true);
expect(gen!.requires).toEqual([]);
});

it.each(['permission', 'dataset'])('writes NAME.%s.ts, the registry\'s own pattern for the kind', (type) => {
expect(metadataFileName(type, STEM)).toBe(`${STEM}.${type}.ts`);
});
});

describe('the permission scaffold is a set that grants the bound object', () => {
it('parses as a permission set named what `os g` reports it reached', async () => {
const set = await loadScaffold('permission', TARGET);
expect(PermissionSetSchema.safeParse(set).success).toBe(true);
expect(set.name).toBe(target('permission')!.itemName(STEM));
});

it('grants the bound object, and only it, without delete', async () => {
const set = await loadScaffold('permission', TARGET);
expect(Object.keys(set.objects as object)).toEqual([TARGET.name]);
expect((set.objects as Record<string, Record<string, unknown>>)[TARGET.name]).toMatchObject({
allowRead: true,
allowCreate: true,
allowEdit: true,
allowDelete: false,
// The read depth is declared, not left to the object's sharing model.
readScope: 'own',
});
// Never bound to every signed-in user by the scaffold.
expect(set.isDefault).toBe(false);
});
});

describe('the dataset scaffold is a dataset over the bound object', () => {
it('parses as a dataset named what `os g` reports it reached, over the bound object', async () => {
const dataset = await loadScaffold('dataset', TARGET);
expect(DatasetSchema.safeParse(dataset).success).toBe(true);
expect(dataset.name).toBe(target('dataset')!.itemName(STEM));
expect(dataset.object).toBe(TARGET.name);
});

it('declares a record count, grouped by the record\'s title field', async () => {
const dataset = await loadScaffold('dataset', TARGET);
expect(dataset.measures).toEqual([{ name: 'record_count', label: 'Records', aggregate: 'count' }]);
expect(dataset.dimensions).toEqual([{ name: TARGET.displayField, field: TARGET.displayField }]);
});

it('an object with no title field gets no dimension, and the dataset still parses', async () => {
const dataset = await loadScaffold('dataset', { ...TARGET, displayField: undefined });
expect(dataset.dimensions).toEqual([]);
expect(DatasetSchema.safeParse(dataset).success).toBe(true);
});
});
134 changes: 134 additions & 0 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,140 @@ const ${toCamelCase(name)}Picklist = definePicklist({
export default ${toCamelCase(name)}Picklist;
`,
},

permission: {
description: 'Permission set (what a signed-in user may do with an object)',
/**
* `src/security`, not `src/permissions`: the directory a project keeps its
* access rules in, the name the example apps use for it. The type key is
* the registry's, `permission` (`*.permission.ts`), and its stack key is
* `singularToPlural('permission')`, `permissions` — the collection half of
* that key (ADR-0090), where a permission set is the only capability
* container.
*/
defaultDir: 'src/security',
/**
* A permission set whose `objects` map grants nothing gives a signed-in
* user nothing, so the scaffold grants one object: the object from
* `--object`, or the stack's only object, one the stack declares. The grant
* is read, create and edit without delete, the shape the example apps
* start a user's set from; the emitted comments name the bits to change.
*
* `readScope` is written, as `'own'`: on an object whose sharing model is
* private (the `object` scaffold's), a read grant with no depth is what
* `security-private-no-readscope` reports, because a holder then reads
* only their own records and the author may not know it. `'own'` states
* that same reach as a decision rather than widening it; the emitted
* comment names `'org'` for the author who means every record.
*
* Declared through `definePermissionSet`, so the set is parsed the moment
* this module loads: an unknown key (`roles`, `profiles`, `users` — none is
* a field of a set) is a startup error that names where that rule lives.
*
* `isDefault` is left at its default (`false`) and explained instead of
* written: an app-level set marked `isDefault` is bound to every signed-in
* user at boot, which is a decision the author takes, not one a scaffold
* takes for them.
*/
namesObject: true,
binds: { object: 'flag' },
itemName: (name: string) => toSnakeCase(name),
generate: (name: string, _namespace?: string, bindings?: ScaffoldBindings) => {
const object = requireBinding('permission', bindings, 'object');
return `import { definePermissionSet } from '@objectstack/spec/security';

/**
* ${toTitleCase(name)} Permission Set
*
* What a user holding this set may do. A user is given it by a position
* that lists it in \`permissionSets\`, or by an administrator in Setup; the
* set itself never names users. Sets a user holds are merged: a bit granted
* by any one of them is granted.
*/
const ${toCamelCase(name)}PermissionSet = definePermissionSet({
name: '${toSnakeCase(name)}',
label: '${toTitleCase(name)}',

// One entry per object, keyed by its machine name. readScope is which of
// the object's records a holder reads: 'own' is the ones they own (plus
// records shared with them), 'org' is every record. Also: viewAllRecords
// and modifyAllRecords, which reach past the object's sharing model.
objects: {
${tsString(object.name)}: {
allowRead: true,
allowCreate: true,
allowEdit: true,
allowDelete: false,
readScope: 'own',
},
},

// isDefault: true binds this set to every signed-in user when the app
// boots. Leave it off unless that is what you mean.
});

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

dataset: {
description: 'Analytics dataset (dimensions and measures dashboards bind to)',
defaultDir: 'src/datasets',
/**
* The analytics semantic layer (`ui/dataset.zod.ts`, ADR-0021): a
* dashboard or report widget names a dataset and picks its dimensions and
* measures by name, instead of carrying its own object, field and
* aggregate. Not seed data — that is the stack's `data` key.
*
* `object` is a BINDING (#21325): the object from `--object`, or the
* stack's only object, one the stack declares. The scaffold declares the
* one measure every object can answer, a record count, and groups it by
* the record's title field when the object has one, so a widget has a
* dimension and a measure to bind from the first build.
*
* Declared through `defineDataset`, which types the literal as a
* `Dataset`: a bare object literal handed to the stack's `datasets` key
* widens `aggregate` to `string` and fails the type check without
* `as const`.
*/
namesObject: true,
binds: { object: 'flag' },
itemName: (name: string) => toSnakeCase(name),
generate: (name: string, _namespace?: string, bindings?: ScaffoldBindings) => {
const object = requireBinding('dataset', bindings, 'object');
const dimension = object.displayField
? `\n { name: ${tsString(object.displayField)}, field: ${tsString(object.displayField)} },\n `
: '';
return `import { defineDataset } from '@objectstack/spec/ui';

/**
* ${toTitleCase(name)} Dataset
*
* The numbers a dashboard or report shows for ${tsString(object.name)}, defined once.
* A widget names this dataset and picks a dimension and a measure from it by
* name; it declares no object, field or aggregate of its own.
*/
const ${toCamelCase(name)}Dataset = defineDataset({
name: '${toSnakeCase(name)}',
label: '${toTitleCase(name)}',
object: ${tsString(object.name)},

// The axes a widget groups by: a field on the object, or a path through a
// lookup (\`account.region\`). Declare the fields people group by.
dimensions: [${dimension}],

// The values a widget shows. Also: sum, avg, min and max over a numeric
// field (\`{ name: 'total', aggregate: 'sum', field: 'amount' }\`).
measures: [
{ name: 'record_count', label: 'Records', aggregate: 'count' },
],
});

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

/**
Expand Down
4 changes: 3 additions & 1 deletion packages/create-objectstack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ my-app/
├── dashboards/index.ts
├── apps/index.ts
├── skills/index.ts
└── picklists/index.ts
├── picklists/index.ts
├── security/index.ts # permission sets
└── datasets/index.ts # analytics datasets
```

Next steps inside the project:
Expand Down
Loading
Loading