diff --git a/.changeset/30123-scaffold-security-datasets.md b/.changeset/30123-scaffold-security-datasets.md new file mode 100644 index 00000000000..4b19774ff6b --- /dev/null +++ b/.changeset/30123-scaffold-security-datasets.md @@ -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. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 3ab16296211..2828e6b4a63 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -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 @@ -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 | Every scaffold is written as `NAME.TYPE.ts`, and the infix is read from the @@ -2412,12 +2416,12 @@ the rules a JSON Schema can express and names the rest under **Options:** - `-d, --dir ` — Override target directory - `--dry-run` — Preview without writing files -- `--object ` — The object a `flow`, `action` or `app` binds; default: the stack's only object +- `--object ` — The object a `flow`, `action`, `app`, `permission` or `dataset` binds; default: the stack's only object - `--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` diff --git a/packages/cli/README.md b/packages/cli/README.md index 0be368fe949..a67075cbb70 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -60,12 +60,17 @@ os compile | `os generate ` | Generate metadata files (alias: `os g`) | | `os create [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/.picklist.ts`, with `definePicklist`). A select field names it with `Field.select({ picklist: '' })` in place of its own `options`, and the server serves that field with the list's options. +`permission` writes a permission set (`src/security/.permission.ts`, with +`definePermissionSet`) granting the object `--object` names, or the stack's only object. +`dataset` writes an analytics dataset (`src/datasets/.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 diff --git a/packages/cli/src/commands/generate-permission-dataset.pin.test.ts b/packages/cli/src/commands/generate-permission-dataset.pin.test.ts new file mode 100644 index 00000000000..4ef1e8a5e6f --- /dev/null +++ b/packages/cli/src/commands/generate-permission-dataset.pin.test.ts @@ -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> { + 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; +} + +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>)[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); + }); +}); diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 9944d6dfb03..ebf1acafe15 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -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; +`; + }, + }, }; /** diff --git a/packages/create-objectstack/README.md b/packages/create-objectstack/README.md index 6e116da4e57..1a15bf7380f 100644 --- a/packages/create-objectstack/README.md +++ b/packages/create-objectstack/README.md @@ -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: diff --git a/packages/create-objectstack/src/index.ts b/packages/create-objectstack/src/index.ts index c402bf41f8e..d6d69c7c563 100644 --- a/packages/create-objectstack/src/index.ts +++ b/packages/create-objectstack/src/index.ts @@ -155,6 +155,7 @@ function rewriteProjectIdentity( targetDir: string, projectName: string, namespace: string, + pm: string, ) { const title = toTitleCase(projectName); @@ -268,16 +269,20 @@ function rewriteProjectIdentity( fs.writeFileSync(readmePath, md); } - writeAgentGuides(targetDir, title, projectName); + writeAgentGuides(targetDir, title, projectName, pm); } // Emit the cross-agent guidance file (AGENTS.md) and the GitHub Copilot variant // (.github/copilot-instructions.md) from the shared template. This is what tells -// the coding agent to run `npm run validate` after editing metadata — the gate +// the coding agent to run ` run validate` after editing metadata — the gate // that catches bare-field predicates and dangling bindings that otherwise fail // silently at runtime. Skip either file if the template already shipped its own, // so a curated template can override the default. -function writeAgentGuides(targetDir: string, title: string, projectName: string) { +// +// `{{PM}}` is the package manager this run detected — the same one the install +// line and "Next steps" name — so a project created with pnpm is not told to +// type `npm run` in the file its coding agent reads first. +function writeAgentGuides(targetDir: string, title: string, projectName: string, pm: string) { const templatePath = path.join(BUNDLED_TEMPLATES_DIR, 'AGENTS.md'); let template: string; try { @@ -289,7 +294,8 @@ function writeAgentGuides(targetDir: string, title: string, projectName: string) const rendered = template .replace(/\{\{PROJECT_TITLE\}\}/g, title) - .replace(/\{\{PROJECT_NAME\}\}/g, projectName); + .replace(/\{\{PROJECT_NAME\}\}/g, projectName) + .replace(/\{\{PM\}\}/g, pm); // Atomic exclusive-create (the `wx` flag) instead of existsSync()+writeFileSync(): // it fails with EEXIST if the file already exists, so a curated template that @@ -593,7 +599,7 @@ const program = new Command() const createdFiles = loadBundled(template.source.dir, targetDir); - rewriteProjectIdentity(targetDir, projectName, namespace); + rewriteProjectIdentity(targetDir, projectName, namespace, pm); // Progress, not an inventory. The authoritative list is printed at the // end, once ` install` and the skills installer have also written — diff --git a/packages/create-objectstack/src/scaffold-next-steps-pm.test.ts b/packages/create-objectstack/src/scaffold-next-steps-pm.test.ts index 3b1769af014..efbe597c43b 100644 --- a/packages/create-objectstack/src/scaffold-next-steps-pm.test.ts +++ b/packages/create-objectstack/src/scaffold-next-steps-pm.test.ts @@ -362,3 +362,56 @@ describe('a failed install still lists the install step in "Next steps" (#23865) }); }, 20_000); }); + +// ─── The generated AGENTS.md names the same package manager ───────────────── +// +// The guide a coding agent reads first said `npm run validate` in every +// project, whichever package manager created it: a pnpm project was told one +// thing by "Next steps" and another by its own AGENTS.md. The template now +// carries the detected manager, and both written copies (AGENTS.md and the +// Copilot variant) are read back from the scaffolded project. + +/** Scaffold under `bin`'s PATH and return the two agent guides it wrote. */ +function scaffoldAgentGuides(bin: string): { agents: string; copilot: string } { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-agents-pm-')); + try { + execFileSync( + TSX, + [INDEX_TS, 'my-app', '--template', 'blank', '--skip-install', '--skip-skills'], + { cwd: tmp, env: { ...process.env, PATH: childPath(bin) }, encoding: 'utf8' }, + ); + const project = path.join(tmp, 'my-app'); + return { + agents: fs.readFileSync(path.join(project, 'AGENTS.md'), 'utf8'), + copilot: fs.readFileSync(path.join(project, '.github', 'copilot-instructions.md'), 'utf8'), + }; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +describe('the generated AGENTS.md names the package manager the run detected', () => { + it('with pnpm on PATH: every run command in both guides is `pnpm run`, none bare `npm run`', () => { + withBin(() => makeStubPnpmBin(0), (bin) => { + expectPnpmResolvesToStub(bin); + + for (const guide of Object.values(scaffoldAgentGuides(bin))) { + expect(guide).toMatch(/\bpnpm run validate\b/); + expect(guide).not.toMatch(/\bnpm run\b/); + expect(guide).not.toContain('{{'); + } + }); + }, 20_000); + + it('CONTROL: with pnpm unreachable, the guides say `npm run` and never pnpm', () => { + withBin(makePnpmlessBin, (bin) => { + expectPnpmUnreachable(bin); + + for (const guide of Object.values(scaffoldAgentGuides(bin))) { + expect(guide).toMatch(/\bnpm run validate\b/); + expect(guide).not.toMatch(/\bpnpm\b/); + expect(guide).not.toContain('{{'); + } + }); + }, 20_000); +}); diff --git a/packages/create-objectstack/src/templates/AGENTS.md b/packages/create-objectstack/src/templates/AGENTS.md index 8f0801f4fac..c7d05b059f7 100644 --- a/packages/create-objectstack/src/templates/AGENTS.md +++ b/packages/create-objectstack/src/templates/AGENTS.md @@ -20,18 +20,18 @@ and silently hides an action on every record; a dangling widget binding renders an empty chart. Catch them before you ship: ```bash -npm run validate # schema + CEL predicates + widget bindings (no artifact) -npm run typecheck # TypeScript types against @objectstack/spec +{{PM}} run validate # schema + CEL predicates + widget bindings (no artifact) +{{PM}} run typecheck # TypeScript types against @objectstack/spec ``` -`npm run validate` runs the **same gates as `npm run build`** (Zod protocol +`{{PM}} run validate` runs the **same gates as `{{PM}} run build`** (Zod protocol schema, CEL/predicate validation with `record.` existence checks, and dashboard widget-binding integrity) but emits no `dist/` — so it is the fast inner-loop check after editing an `*.object.ts` / `*.view.ts` / `*.action.ts` / `*.flow.ts`. Both exit non-zero with a located, corrective message on failure. -Run `npm run build` when you need the compiled `dist/objectstack.json` artifact. +Run `{{PM}} run build` when you need the compiled `dist/objectstack.json` artifact. -**Never report a metadata change as done until `npm run validate` passes.** +**Never report a metadata change as done until `{{PM}} run validate` passes.** ## Naming Conventions @@ -54,7 +54,7 @@ Run `npm run build` when you need the compiled `dist/objectstack.json` artifact. 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.`, - never bare ``. A bare reference is a silent runtime bug (`npm run validate` + never bare ``. A bare reference is a silent runtime bug (`{{PM}} run validate` now rejects it). ## Project Structure @@ -70,11 +70,13 @@ Run `npm run build` when you need the compiled `dist/objectstack.json` artifact. │ ├── dashboards/ # BI dashboards │ ├── apps/ # App navigation & page structure │ ├── skills/ # AI skills -│ └── picklists/ # Shared option sets that select fields reuse +│ ├── picklists/ # Shared option sets that select fields reuse +│ ├── security/ # Permission sets (what signed-in users may do) +│ └── datasets/ # Analytics datasets that dashboard widgets bind to ``` These are exactly the directories `objectstack.config.ts` wires. A new directory -under `src/` is never loaded, and `npm run validate` neither counts nor checks +under `src/` is never loaded, and `{{PM}} run validate` neither counts nor checks what it holds, until its `index.ts` barrel is imported in `objectstack.config.ts` and handed to its stack key there. diff --git a/packages/create-objectstack/src/templates/blank/README.md b/packages/create-objectstack/src/templates/blank/README.md index 8e38bad2a70..b080b43b8c9 100644 --- a/packages/create-objectstack/src/templates/blank/README.md +++ b/packages/create-objectstack/src/templates/blank/README.md @@ -69,7 +69,8 @@ for OAuth, API keys, and which objects/actions become tools. 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/`, `src/picklists/` — empty to start. Each directory's `index.ts` + `src/skills/`, `src/picklists/`, `src/security/` (permission sets), + `src/datasets/` (analytics datasets) — 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 diff --git a/packages/create-objectstack/src/templates/blank/objectstack.config.ts b/packages/create-objectstack/src/templates/blank/objectstack.config.ts index b5dcfda7cdd..76e0e29e1fd 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.config.ts +++ b/packages/create-objectstack/src/templates/blank/objectstack.config.ts @@ -10,6 +10,8 @@ import * as dashboards from './src/dashboards'; import * as apps from './src/apps'; import * as skills from './src/skills'; import * as picklists from './src/picklists'; +import * as permissions from './src/security'; +import * as datasets from './src/datasets'; // 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. @@ -79,4 +81,6 @@ export default defineStack({ apps: exportsOf(apps), skills: exportsOf(skills), picklists: exportsOf(picklists), + permissions: exportsOf(permissions), + datasets: exportsOf(datasets), }); diff --git a/packages/create-objectstack/src/templates/blank/src/datasets/index.ts b/packages/create-objectstack/src/templates/blank/src/datasets/index.ts new file mode 100644 index 00000000000..c0533d323c6 --- /dev/null +++ b/packages/create-objectstack/src/templates/blank/src/datasets/index.ts @@ -0,0 +1,5 @@ +// The datasets in this directory. `objectstack generate dataset NAME` writes one +// here and adds its export line below. objectstack.config.ts hands every +// export of this file to the stack, so a dataset exported here is part of it, +// and a dataset file this index does not export is never loaded. +export {}; diff --git a/packages/create-objectstack/src/templates/blank/src/security/index.ts b/packages/create-objectstack/src/templates/blank/src/security/index.ts new file mode 100644 index 00000000000..cc3f4e8d7dc --- /dev/null +++ b/packages/create-objectstack/src/templates/blank/src/security/index.ts @@ -0,0 +1,5 @@ +// The permissions in this directory. `objectstack generate permission NAME` writes one +// here and adds its export line below. objectstack.config.ts hands every +// export of this file to the stack, so a permission exported here is part of it, +// and a permission file this index does not export is never loaded. +export {};