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
36 changes: 36 additions & 0 deletions .changeset/init-scaffold-owd-and-author-time-rules.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
"@objectstack/cli": patch
---

fix(cli): `objectstack init` scaffolds now compile — templates author an OWD, and the scaffold self-test runs the author-time rules (#9666)

`objectstack init my-app -t app --install` reported `✓ Scaffold validated`, and
the next command in the documented on-ramp, `npm run dev`, failed to compile:

```
✗ Author-time rules failed (1 issue)
• object "my_app_item": custom object "my_app_item" declares no sharingModel (OWD)…
rule: security-owd-unset at objects[0].sharingModel
```

The CLI's own shipped template was refused by the CLI's own shipped rule set, so
the dev server never started on a freshly generated project.

Two halves:

- **Templates author an OWD.** The `app` and `plugin` templates now declare
`sharingModel: 'private'` on the object they emit — the rule's own recommended
default and the ADR-0090 D1 baseline (absence is not a decision). A sweep of
every built-in template found `plugin` in the same state as the reported `app`;
`empty` emits no objects and was already clean.
- **`init`'s self-test got teeth.** It used to check only that the rendered config
loaded and carried a `manifest.namespace`, which is why a template that could
not compile shipped. It now runs the author-time rule registry over the
generated project and refuses to report success when any rule rejects it. The
rule set is the `build` one — the same set `os dev` reaches by spawning
`os compile` — so this is a shift-left, not a stricter bar: nothing that
compiles today stops compiling, and a broken template now fails at generation
time instead of at a user's first `dev`.

`✓ Scaffold validated` still prints, and now names how many author-time rules
passed.
127 changes: 97 additions & 30 deletions packages/cli/src/commands/init.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,8 @@ import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { printHeader, printSuccess, printError, printStep, printKV, printInfo } from '../utils/format.js';
import { printHeader, printSuccess, printError, printStep, printKV, printInfo, formatZodErrors } from '../utils/format.js';
import { validateScaffold } from '../utils/scaffold-validate.js';

// ─── Version resolution ──────────────────────────────────────────────
//
Expand DownExpand Up@@ -180,6 +181,12 @@ const ${toCamelCase(namespace)}Item: Data.Object = {
defaultValue: 'draft',
},
},
// Org-wide default (OWD): who can see records they do NOT own. ADR-0090 D1
// requires this to be an authored decision rather than an accident — the
// \`security-owd-unset\` author-time rule refuses an object without it, so a
// scaffold that omitted it could not compile. 'private' is the rule's own
// recommended default: owner + explicit shares.
sharingModel: 'private',
};

export default ${toCamelCase(namespace)}Item;
Expand DownExpand Up@@ -240,6 +247,12 @@ const ${toCamelCase(namespace)}Item: Data.Object = {
required: true,
},
},
// Org-wide default (OWD): who can see records they do NOT own. ADR-0090 D1
// requires this to be an authored decision rather than an accident — the
// \`security-owd-unset\` author-time rule refuses an object without it, so a
// scaffold that omitted it could not compile. 'private' is the rule's own
// recommended default: owner + explicit shares.
sharingModel: 'private',
};

export default ${toCamelCase(namespace)}Item;
Expand DownExpand Up@@ -296,6 +309,41 @@ function printWarning(msg: string) {
console.log(chalk.yellow(` ⚠ ${msg}`));
}

/**
* Write a template's `srcFiles` into `targetDir` and return the relative paths
* written, in creation order.
*
* File paths use `__name__` as a placeholder for the NAMESPACE (not the npm
* name) so generated identifiers stay snake_case even when the project name
* contains hyphens (`my-app` → namespace `my_app` → `src/objects/my_app_item.ts`).
*
* Exported so the scaffold pin test generates projects through the real
* emitter instead of a copy of it. A test that re-implemented this loop could
* drift from it silently, and the drift would land in exactly the class the
* pin exists to catch: a shipped template the CLI's own rules refuse.
*/
export function writeTemplateSrcFiles(
srcFiles: Record<string, (name: string, namespace: string) => string>,
targetDir: string,
projectName: string,
namespace: string,
): string[] {
const written: string[] = [];
for (const [filePath, contentFn] of Object.entries(srcFiles)) {
const resolvedPath = filePath.replace(/__name__/g, namespace);
const fullPath = path.join(targetDir, resolvedPath);
const dir = path.dirname(fullPath);

if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}

fs.writeFileSync(fullPath, contentFn(projectName, namespace));
written.push(resolvedPath);
}
return written;
}

/**
* Detect the package manager that invoked this CLI by inspecting
* `npm_config_user_agent` (set by every modern PM). Falls back to `npm`,
Expand DownExpand Up@@ -488,22 +536,9 @@ export default class Init extends Command {
createdFiles.push('tsconfig.json');
}

// 4. Create src files. File paths use `__name__` as a placeholder for
// the namespace (NOT the npm name) so generated identifiers stay snake
// _case even when the project name contains hyphens (e.g. `my-app` →
// namespace `my_app` → `src/objects/my_app_item.ts`).
for (const [filePath, contentFn] of Object.entries(template.srcFiles)) {
const resolvedPath = filePath.replace(/__name__/g, namespace);
const fullPath = path.join(targetDir, resolvedPath);
const dir = path.dirname(fullPath);

if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}

fs.writeFileSync(fullPath, contentFn(projectName, namespace));
createdFiles.push(resolvedPath);
}
// 4. Create src files (see `writeTemplateSrcFiles` for the `__name__`
// placeholder rule and why the loop is exported).
createdFiles.push(...writeTemplateSrcFiles(template.srcFiles, targetDir, projectName, namespace));

// 5. Create .gitignore if missing
const gitignorePath = path.join(targetDir, '.gitignore');
Expand DownExpand Up@@ -533,25 +568,57 @@ export default class Init extends Command {
}
}

// Self-test the scaffold so we catch template regressions (e.g. an
// invalid namespace or object name) before the user discovers them by
// running `objectstack dev`. Only runs when deps are present —
// `defineStack()` validation lives in `@objectstack/spec`.
// Self-test the scaffold so we catch template regressions before the
// user discovers them by running `objectstack dev`. Only runs when deps
// are present — `defineStack()` validation lives in `@objectstack/spec`.
//
// This used to check only that the rendered config LOADED and carried a
// `manifest.namespace`, which is how the CLI shipped a `-t app` template
// its own author-time rules refused: `init` printed `✓ Scaffold
// validated`, and the documented next command — `npm run dev` — died on
// `security-owd-unset` before the dev server ever started. The self-test
// now runs the same rule set `dev` reaches through `os compile`
// (`SCAFFOLD_RULE_COMMAND`), so a template that cannot compile fails
// HERE, at generation time, in CI, instead of at a user's first `dev`.
// It is a shift-left, not a new bar: same registry, same command tier.
if (installSucceeded) {
printStep('Validating scaffold...');
let scaffoldRejected = false;
try {
const { bundleRequire } = await import('bundle-require');
const { mod } = await bundleRequire({
filepath: path.join(targetDir, 'objectstack.config.ts'),
cwd: targetDir,
});
const stack = mod.default ?? mod;
if (!stack?.manifest?.namespace) {
throw new Error('Rendered config has no manifest.namespace');
const report = await validateScaffold(targetDir);

for (const f of report.advisories.slice(0, 50)) {
printWarning(`${f.where}: ${f.message}`);
if (f.hint) console.log(chalk.dim(` ${f.hint}`));
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
}

if (report.schemaError) {
printError('Scaffold validation failed: rendered config does not satisfy the protocol schema');
formatZodErrors(report.schemaError);
scaffoldRejected = true;
} else if (report.errors.length > 0) {
// Report every failing rule at once, like `os validate` / `os build`.
printError(
`Scaffold validation failed: author-time rules rejected the generated project (${report.errors.length} issue${report.errors.length > 1 ? 's' : ''})`,
);
for (const f of report.errors.slice(0, 50)) {
console.log(` • ${f.where}: ${f.message}`);
if (f.hint) console.log(chalk.dim(` ${f.hint}`));
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
}
scaffoldRejected = true;
} else {
printSuccess(
`Scaffold validated (namespace: ${report.namespace}; ${report.ruleCount} author-time rules passed)`,
);
}
printSuccess(`Scaffold validated (namespace: ${stack.manifest.namespace})`);
} catch (err: any) {
printError(`Scaffold validation failed: ${err.message || err}`);
scaffoldRejected = true;
}

if (scaffoldRejected) {
console.log(chalk.dim(' This is a CLI bug — please report it at https://github.com/objectstack-ai/objectstack/issues'));
this.error('Scaffold validation failed');
}
Expand Down
132 changes: 132 additions & 0 deletions packages/cli/src/utils/scaffold-validate.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Hold a freshly generated scaffold to the SAME author-time bar the user's
* very next command holds it to.
*
* ## The defect this exists to close
*
* `objectstack init my-app -t app --install` printed `✓ Scaffold validated`
* and the next command, `npm run dev`, failed to compile — the CLI's own
* shipped template was refused by the CLI's own shipped rule set
* (`security-owd-unset`: the template's object declared no `sharingModel`).
* `init`'s self-test only checked that the rendered config *loaded* and
* carried a `manifest.namespace`, so every author-time rule was unread at the
* one moment the CLI is generating the metadata itself. The documented
* on-ramp was dead and nothing in the CLI noticed.
*
* ## Why `'build'` and not `'validate'`
*
* `os dev` auto-compiles by spawning `os compile`, and `compile` runs
* `authoringRulesFor('build')`. Running the *same* command's rule set here is
* what keeps this a shift-left rather than a new, stricter gate: everything
* that survives `init` is exactly what survives the `dev` the user runs
* moments later. Picking a different command from the registry would let
* `init` refuse a scaffold `dev` accepts (or the reverse) — a second bar, the
* drift class `authoring-rules.ts` exists to prevent.
*
* The pipeline below mirrors `compile.ts` step-for-step for the same reason:
* normalize → lower callables → Zod parse → registry. A rule reading a
* differently-prepared stack is the same drift wearing a different hat.
*/

import { join } from 'node:path';
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
import type { ZodError } from 'zod';
import {
runAuthoringRules,
splitBySeverity,
authoringRulesFor,
type AuthoringCommand,
type AuthoringFinding,
} from '@objectstack/lint';
import { lowerCallables } from './lower-callables.js';
import { resolveSduiManifest } from './sdui-manifest.js';

/**
* The registry command whose rule set a generated scaffold is held to.
*
* Pinned to what `os dev` reaches through `os compile`. Exported so the pin
* test asserts the coupling instead of restating the string.
*/
export const SCAFFOLD_RULE_COMMAND: AuthoringCommand = 'build';

export interface ScaffoldRuleReport {
/** How many registry rules ran (for the progress line). */
ruleCount: number;
/** Protocol-schema failure, if the stack did not parse at all. */
schemaError: ZodError | null;
/** Gating findings — a non-empty list means `dev` would refuse this scaffold. */
errors: AuthoringFinding[];
/** `warning` / `info` findings — reported, never gating. */
advisories: AuthoringFinding[];
}

/**
* Run the author-time rule set over an already-loaded stack config.
*
* Takes the config *object* rather than a path so the caller owns module
* loading (`init` bundle-requires the rendered config from the target dir,
* which is not `process.cwd()`), and so the pin test can drive real template
* output through the real rules without spawning a CLI.
*/
export function runScaffoldAuthoringRules(config: unknown): ScaffoldRuleReport {
const normalized = normalizeStackInput(config as Record<string, unknown>);
const lowering = lowerCallables(normalized as Record<string, unknown>);
const result = ObjectStackDefinitionSchema.safeParse(lowering.lowered);

if (!result.success) {
return {
ruleCount: authoringRulesFor(SCAFFOLD_RULE_COMMAND).length,
schemaError: result.error as unknown as ZodError,
errors: [],
advisories: [],
};
}

const findings = runAuthoringRules(SCAFFOLD_RULE_COMMAND, {
normalized: normalized as Record<string, unknown>,
parsed: result.data as Record<string, unknown>,
sduiManifest: resolveSduiManifest(),
});
const { errors, advisories } = splitBySeverity(findings);

return {
ruleCount: authoringRulesFor(SCAFFOLD_RULE_COMMAND).length,
schemaError: null,
errors,
advisories,
};
}

/**
* Load a generated scaffold's `objectstack.config.ts` and run the author-time
* rule set over it.
*
* Module loading lives here — rather than in `init.ts` — so the pin test that
* sweeps every built-in template drives the SAME loader the command does. A
* test that re-implemented the load would be free to drift from it, and the
* drift would land precisely in the "the CLI's own template does not compile"
* class this whole file exists to close.
*
* The load is deliberately unchanged from what `init`'s self-test always did
* (no `external` list): only the checking after it is stronger.
*
* Note on `resolveSduiManifest()`: it reads `process.cwd()`, which for `init`
* is the directory the user invoked from, not `targetDir`. A freshly generated
* scaffold has no `sdui.manifest.json` either way, so both resolve to the copy
* shipped in `@objectstack/console` — the same input `os compile` gets when the
* user runs `dev` inside the new project.
*/
export async function validateScaffold(targetDir: string): Promise<ScaffoldRuleReport & { namespace: string }> {
const { bundleRequire } = await import('bundle-require');
const { mod } = await bundleRequire({
filepath: join(targetDir, 'objectstack.config.ts'),
cwd: targetDir,
});
const stack = mod.default ?? mod;
if (!stack?.manifest?.namespace) {
throw new Error('Rendered config has no manifest.namespace');
}
return { namespace: String(stack.manifest.namespace), ...runScaffoldAuthoringRules(stack) };
}
Loading
Loading