From 250ccd2a2f2f9838a0680b2ef06dad4b600f9592 Mon Sep 17 00:00:00 2001 From: os-sales Date: Mon, 7 Sep 2026 15:58:25 +0000 Subject: [PATCH 1/2] feat(cli)!: retire `os create example`; the refusal names `os init` (#16483) The `example` template emitted a subset of what `os init` writes plus one README, the only template-level duplication #15531 found between the two scaffolder families. Removed under the #15531 ruling (batch #66, option B) with no alias and no deprecation window. The template is not merely deleted: `os create example` still answers, exits 1 and names `os init`, rather than falling through to `Unknown type:` and printing only the surviving roster. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- .changeset/os-create-example-retired.md | 51 ++++ content/docs/deployment/cli.mdx | 25 +- packages/cli/src/commands/create.ts | 235 ++++++++---------- packages/cli/src/commands/init.ts | 9 +- ...create-example-retired-docs-parity.test.ts | 114 +++++++++ .../test/create-example-retired.e2e.test.ts | 202 +++++++++++++++ packages/cli/test/create.test.ts | 32 ++- ...t-template-comments-self-contained.test.ts | 7 +- .../test/scaffold-emission-policy.e2e.test.ts | 25 +- .../cli/test/scaffold-manifest-schema.test.ts | 27 +- scripts/cross-package-test-inputs.mjs | 10 + 11 files changed, 573 insertions(+), 164 deletions(-) create mode 100644 .changeset/os-create-example-retired.md create mode 100644 packages/cli/test/create-example-retired-docs-parity.test.ts create mode 100644 packages/cli/test/create-example-retired.e2e.test.ts diff --git a/.changeset/os-create-example-retired.md b/.changeset/os-create-example-retired.md new file mode 100644 index 0000000000..5d52e57ca8 --- /dev/null +++ b/.changeset/os-create-example-retired.md @@ -0,0 +1,51 @@ +--- +"@objectstack/cli": minor +--- + +feat(cli)!: retire `os create example` — it was a weaker `os init` plus a README, and the refusal now names `os init` (#16483, #15531) + +**BREAKING** — a published CLI surface is removed. `os create example ` is a +command a user can run today and cannot run after this release. Graded `minor` +rather than `major` under this repo's lockstep launch-window convention (no +package here has ever shipped a `major` changeset); the break is stated here +instead of in the number. + + + +**No alias and no deprecation window.** `os create example` will not come back, +so change the command rather than pinning an older CLI. + +#15531 rendered the real emission of both scaffolder families and hashed it file +by file. The only template-level duplication left between them was this one +template: `os create example` wrote a **subset** of what `os init` writes, plus +one README. The two families' emission policy is already unified through four +shared exports, so the remaining duplicate was the template itself — and the +ruling (decision batch #66, option B) is that it goes, not that the two command +families merge. They emit two different artifacts: a kernel code `Plugin` is not +a declarative app, and collapsing them would make that collision structural. + +**What to run instead** + +```bash +os init # a full application project +os init -t empty # objectstack.config.ts only +``` + +`os init` writes the same `objectstack.config.ts` and `tsconfig.json` the retired +template did, and adds `src/objects`, a `.gitignore` and the dependency install +it never had. + +**`os create plugin` is unaffected.** It scaffolds the kernel code `Plugin` +contract — `src/index.ts` exporting a `Plugin` with `init` / `destroy`, built by +`tsc`, publishable as `@objectstack/plugin-` — which `os init` does not +emit. `os create`'s flags, its `--in-repo` placement and its standalone +emission policy are unchanged. + +**The removal is a signpost, not a deletion.** `os create example` still answers: +it exits **1** and names `os init`, rather than falling through to the generic +`Unknown type:` roster. A reader arriving from an older tutorial or a CI script +that still calls it learns what replaced it instead of learning only that their +spelling is off the list. Pinned end-to-end by driving the real CLI in +`packages/cli/test/create-example-retired.e2e.test.ts`, which asserts both halves +— the non-zero exit **and** the message naming `os init` — and holds the four +public doc pages to the same promise. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 7b81536d88..7d1f977e1c 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1241,7 +1241,7 @@ only. | Command | Alias | Description | |---------|-------|-------------| | `os generate ` | `os g` | Generate metadata files | -| `os create [name]` | | Scaffold a standalone plugin or example project | +| `os create [name]` | | Scaffold a standalone **kernel code** plugin project | #### `os generate` (alias: `os g`) @@ -1314,19 +1314,31 @@ third-party extension primitive, authored as `src/skills/.skill.ts` with #### `os create` -Scaffolds a **standalone** project — a **kernel code** plugin (the `Plugin` contract, +Scaffolds a **standalone** **kernel code** plugin project (the `Plugin` contract, built by `tsc`, publishable; *not* the metadata plugin `os init -t plugin` emits — see -[Which scaffolder?](#os-init)), or an example application — into the current directory: +[Which scaffolder?](#os-init)) into the current directory: ```bash os create plugin analytics # Create ./plugin-analytics -os create example my-app # Create ./my-app cd plugin-analytics pnpm install pnpm build ``` + +Use [`os init`](#os-init) to scaffold an application. `os create example` emitted a +subset of what `os init` writes plus one README, so it was withdrawn in +[#16483](https://github.com/objectstack-ai/objectstack/issues/16483) rather than kept as +a second, weaker way to do the same thing — with **no alias and no deprecation window**. +Running it now exits non-zero and names `os init`. + +```bash +os init my-app # a full application project +os init my-app -t empty # objectstack.config.ts only +``` + + The emitted `package.json` declares its `@objectstack/*` dependencies as published semver ranges pinned to the version of the CLI that generated it, and the emitted `tsconfig.json` is self-contained, so the project installs and @@ -1335,9 +1347,8 @@ builds anywhere — a workspace around it is neither needed nor assumed. **Options:** - `-d, --dir ` — Write the project here instead of `./` - `--in-repo` — Scaffold **inside an ObjectStack monorepo checkout** instead - (`packages/plugins/` for a plugin, `examples/` for an example), - with `workspace:*` dependencies and a `tsconfig.json` that extends the - repository root config. For ObjectStack platform work only: the project it + (`packages/plugins/plugin-`), with `workspace:*` dependencies and a + `tsconfig.json` that extends the repository root config. For ObjectStack platform work only: the project it writes installs nowhere else, and the command refuses the flag when the current directory is not a pnpm workspace root. diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index c7acf9a9a0..963d6d7994 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -1,7 +1,27 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * `os create [name]` — scaffold a plugin or an example application. + * `os create [name]` — scaffold a kernel code plugin. + * + * ## `os create example` is retired (#16483) + * + * The `example` template emitted a SUBSET of what `os init` writes, plus one + * README. #15531 rendered and hashed both command families' real emission: the + * only template-level duplication left between them was this one template, and + * the ruling (decision batch #66, option B) is that the template goes rather + * than that the two families merge — they emit two different artifacts, and a + * kernel code `Plugin` is not a declarative app. The maintainer ruled the same + * week that it goes with NO alias and NO deprecation window. + * + * ⛔ The template is not merely deleted. `os create example` still ANSWERS, and + * the answer names `os init` — see {@link RETIRED_TEMPLATES}. Letting it fall + * through to the `Unknown type:` branch would print the surviving roster and + * nothing else, so a reader arriving from an old doc page, a tutorial or a CI + * script would learn only that their spelling is not on the list, and the + * natural next move is to hunt for the right spelling of something that no + * longer exists. A removal that leaves a generic failure behind is the outcome + * the ruling exists to prevent, so the refusal is part of the contract and is + * pinned as one (`test/create-example-retired.e2e.test.ts`). * * ## What this command emits, and why it has two shapes * @@ -42,9 +62,9 @@ * project lands in the developer's own directory. * `in-repo` (--in-repo) the platform-work shape: `workspace:*` deps, a * `tsconfig.json` that extends this repo's root - * config, landing under `packages/plugins/` or - * `examples/`. Explicit and documented, never the - * default — its output installs nowhere else. + * config, landing under `packages/plugins/`. + * Explicit and documented, never the default — its + * output installs nowhere else. * * ## The version the standalone shape pins * @@ -85,17 +105,13 @@ import { Args, Command, Flags } from '@oclif/core'; import chalk from 'chalk'; import fs from 'fs'; import path from 'path'; -import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import { getCliVersion, NPM_PACKAGE_NAME_MAX_LENGTH, renderPnpmWorkspaceYaml, renderScaffoldTsconfig, - sanitizeNamespace, SCAFFOLD_PNPM_RANGE, SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY, - SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG, - SCAFFOLD_TSX_RANGE, SCAFFOLD_TYPES_NODE_RANGE, SCAFFOLD_TYPESCRIPT_RANGE, SCAFFOLD_VITEST_RANGE, @@ -235,7 +251,7 @@ export function validateEmittedPackageName(packageName: string): string | null { * The fold is GENERALISED, not narrowed: every run of characters illegal in a * JS identifier is the separator `-` already was — dropped, with the character * after it upper-cased — and a leading digit takes the `'a'` prefix that - * `sanitizeNamespace()` (imported one line away) has always used for exactly + * `init.ts`'s `sanitizeNamespace()` has always used for exactly * this rule. Ordinary names are unchanged: `my-app` still yields `myApp`. * * ⛔ This normalises the CODE identifier and nothing else. The package name, @@ -257,7 +273,6 @@ export function sanitizeIdentifier(name: string): string { } const PLUGIN_IN_REPO_DIR = 'packages/plugins'; -const EXAMPLE_IN_REPO_DIR = 'examples'; export const templates: Record = { plugin: defineTemplate({ @@ -382,136 +397,73 @@ MIT return files; }, }), +}; - example: defineTemplate({ - description: 'Create a new ObjectStack example application', - inRepoDir: EXAMPLE_IN_REPO_DIR, - dirName: (name: string) => name, - filesFor: (placement: ScaffoldPlacement) => { - const standalone = placement === 'standalone'; - const files: Record = { - 'package.json': (name: string) => ({ - name: `@example/${name}`, - version: '0.1.0', - private: true, - ...(standalone ? { type: 'module' } : {}), - description: `ObjectStack Example: ${name}`, - ...(standalone ? { engines: { pnpm: SCAFFOLD_PNPM_RANGE } } : {}), - scripts: { - build: 'objectstack compile', - dev: 'objectstack dev', - test: 'vitest', - typecheck: 'tsc --noEmit', - }, - dependencies: { - '@objectstack/spec': objectstackDependencySpec(placement), - '@objectstack/cli': objectstackDependencySpec(placement), - zod: SCAFFOLD_ZOD_RANGE, - }, - devDependencies: { - '@types/node': SCAFFOLD_TYPES_NODE_RANGE, - tsx: SCAFFOLD_TSX_RANGE, - typescript: SCAFFOLD_TYPESCRIPT_RANGE, - vitest: SCAFFOLD_VITEST_RANGE, - }, - }), - 'objectstack.config.ts': (name: string) => { - const namespace = sanitizeNamespace(name); - return `import { defineStack } from '@objectstack/spec'; - -// Barrel imports — add more as you create new type folders -// import * as objects from './src/objects'; -// import * as actions from './src/actions'; -// import * as apps from './src/apps'; - -export default defineStack({ - manifest: { - id: 'com.example.${namespace}', - namespace: '${namespace}', - version: '0.1.0', - type: 'app', - name: '${name}', - description: '${name} example application', - // Protocol compatibility range: the metadata-protocol major this app is - // authored against. The runtime checks it before it loads anything, so a - // runtime outside the range refuses this app at the boundary with the - // exact migration command instead of crashing later. Scaffolding stamped - // it to match the ObjectStack version you installed — change it when you - // deliberately move to a new protocol major, not to silence a mismatch. - // Guide: https://objectstack.ai/docs/upgrading - engines: { protocol: '^${PROTOCOL_MAJOR}' }, +/** + * Templates that were withdrawn, and what this command says when one is run. + * + * ⛔ A retired template is NOT an unknown template, and must never be allowed to + * fall through to the `Unknown type:` branch below. That branch prints the + * surviving roster and nothing else — so the reader of an old doc page, an + * older tutorial or a CI script that still names the retired template learns + * only that their spelling is off the list, and goes looking for the right + * spelling of something that no longer exists. The retirement replaces the + * command with a SIGNPOST; a generic failure is the outcome it exists to + * prevent. + * + * `example` (#16483, under the #15531 ruling): the template emitted a subset of + * what `os init` writes plus one README, measured by rendering and hashing both + * command families' whole emission. Every entry therefore owes both halves — + * why the template went, and the command to run instead, spelled so it can be + * copied straight out of the terminal. + */ +export const RETIRED_TEMPLATES: Record` was retired — …". */ + reason: string; + /** Body lines, printed in order; an empty string prints a blank line. */ + detail: string[]; +}> = { + example: { + // ⛔ No `#NNNN` in the printed strings below: a runtime message reaches + // authors and operators who cannot resolve a tracker id. The card is + // named in this comment and in the block above (`pnpm check:doc-authoring`). + reason: 'it was a weaker `os init`.', + detail: [ + 'It emitted a SUBSET of what `os init` writes, plus one README. The two', + 'command families were measured file by file and hashed: there was no', + 'shape this template produced that `os init` does not.', + '', + 'Use `os init` instead — it writes the same objectstack.config.ts and', + 'tsconfig.json, and adds src/objects, a .gitignore and the dependency', + 'install this template never had:', + '', + ' os init -> a full application project', + ' os init -t empty -> objectstack.config.ts only', + '', + 'There is no alias and no deprecation window: `os create example` will not', + 'come back, so change the command rather than pinning an older CLI.', + '', + '`os create plugin ` is unaffected. It scaffolds the kernel code', + '`Plugin` contract, which `os init` does not emit — see the scaffolder', + 'table on https://objectstack.ai/docs/deployment/cli', + ], }, - - objects: [ - // Object.values(objects), // Uncomment after creating src/objects/index.ts - ], - - apps: [ - // Object.values(apps), // Uncomment after creating src/apps/index.ts - ], -}); -`; - }, - 'README.md': (name: string) => `# ${name} Example - -ObjectStack example application: ${name} - -## Quick Start - -\`\`\`bash -# Install dependencies -pnpm install - -# Build the configuration -pnpm build - -# Run in development mode -pnpm dev -\`\`\` - -## Structure - -- \`objectstack.config.ts\` - Main configuration file -- \`dist/objectstack.json\` - Compiled artifact - -## Learn More - -${ - standalone - ? '- [ObjectStack Documentation](https://objectstack.ai/docs)\n' - + '- [CLI Reference](https://objectstack.ai/docs/deployment/cli)\n' - : '- [ObjectStack Documentation](../../content/docs)\n- [Examples](../)\n' -}`, - 'tsconfig.json': (name: string) => - standalone - ? renderScaffoldTsconfig({ - rootDir: '.', - include: SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG, - }) - : { - extends: rootTsconfigExtends(EXAMPLE_IN_REPO_DIR, name), - compilerOptions: { - outDir: 'dist', - rootDir: '.', - }, - include: ['*.ts', 'src/**/*'], - }, - }; - - if (standalone) { - files['pnpm-workspace.yaml'] = () => renderPnpmWorkspaceYaml(); - } - return files; - }, - }), }; +/** The roster an unknown type is shown, derived so a removal cannot outlive it. */ +function availableTypes(): string { + return Object.keys(templates).join(', '); +} + export default class Create extends Command { static override description = - 'Create a new standalone plugin or example project from a built-in template'; + 'Create a new standalone kernel code plugin from a built-in template'; static override args = { - type: Args.string({ description: 'Type of project to create (plugin, example)', required: true }), + type: Args.string({ + description: `Type of project to create (${Object.keys(templates).join(', ')})`, + required: true, + }), name: Args.string({ description: 'Name of the project', required: false }), }; @@ -523,7 +475,7 @@ export default class Create extends Command { 'in-repo': Flags.boolean({ default: false, description: - 'Scaffold INSIDE an ObjectStack monorepo checkout (packages/plugins/ or examples/) with ' + 'Scaffold INSIDE an ObjectStack monorepo checkout (packages/plugins/) with ' + 'workspace:* dependencies. For platform work only — the emitted project installs nowhere else.', }), }; @@ -534,9 +486,24 @@ export default class Create extends Command { console.log(chalk.bold(`\n📦 ObjectStack Project Creator`)); console.log(chalk.dim(`-------------------------------`)); + // A withdrawn template answers for itself, AHEAD of the roster lookup and + // ahead of the "name is required" check below — `os create example` with no + // name at all must still reach the signpost rather than be told to supply + // an argument to a command that no longer exists. See RETIRED_TEMPLATES. + const retired = RETIRED_TEMPLATES[args.type]; + if (retired) { + console.error(chalk.red(`\n❌ \`os create ${args.type}\` was retired — ${retired.reason}`)); + console.error(''); + for (const line of retired.detail) { + console.error(line ? chalk.dim(` ${line}`) : ''); + } + console.error(''); + process.exit(1); + } + if (!templates[args.type as keyof typeof templates]) { console.error(chalk.red(`\n❌ Unknown type: ${args.type}`)); - console.log(chalk.dim('Available types: plugin, example')); + console.log(chalk.dim(`Available types: ${availableTypes()}`)); process.exit(1); } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 52764264e0..1454d70f65 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -332,10 +332,11 @@ export const SCAFFOLD_TSCONFIG_COMPILER_OPTIONS = { * * `rootDir` and `include` are the only things the emitted shapes differ on: * `os create plugin` compiles `src/` alone, while `os init`'s three templates - * and `os create example` also compile the `objectstack.config.ts` at the - * project root. Measured before this renderer existed, four of the five - * emitted `tsconfig.json` files were already byte-identical and the fifth - * differed only in those two keys — so nothing here is a new decision. + * also compile the `objectstack.config.ts` at the project root. Measured before + * this renderer existed, four of the five emitted `tsconfig.json` files were + * already byte-identical and the fifth differed only in those two keys — so + * nothing here is a new decision. (Five because `os create example` was one of + * them; it was retired in #16483, and the measurement is left as it was taken.) */ export function renderScaffoldTsconfig( options: { rootDir: string; include: string[] }, diff --git a/packages/cli/test/create-example-retired-docs-parity.test.ts b/packages/cli/test/create-example-retired-docs-parity.test.ts new file mode 100644 index 0000000000..e8c1d8a2e5 --- /dev/null +++ b/packages/cli/test/create-example-retired-docs-parity.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * DOCS PARITY (#16483) — no public page still offers `os create example` to run. + * + * ## The half no process can check + * + * `create-example-retired.e2e.test.ts` drives the real CLI and proves the + * refusal: non-zero, and the message names `os init`. That says nothing about + * the four public pages which present `os create` as a user-facing command. A + * reader who follows a page rather than a terminal is exactly the reader the + * #15531 ruling is protecting, and a page that still prints + * `os create example my-app` in a copyable block hands them a command that now + * refuses. + * + * ## Why this is a SEPARATE file from the pin next door + * + * That one is `*.e2e.test.*`, a NAME-decided tier (`scripts/nightly-tiers.mjs`, + * #16455): those files are excluded from the per-PR and merge-queue population + * and run in the nightly job. That is the right lane for four cold `tsx` spawns + * and the wrong lane for four `readFileSync` calls — a docs-only PR that + * reintroduced the retired command would otherwise be caught the following + * night instead of on the PR. This file spawns nothing, so it stays queue-tier + * and reddens where the edit is made. + * + * ## The property, which is not "the string is gone" + * + * A page that still SAYS `os create example` is fine, and is often exactly what + * a reader arriving from a search engine or an older tutorial needs. What the + * ruling fixed is two narrower things, and those are what is asserted: + * + * a. no FENCED CODE BLOCK on any public page carries it, so there is nothing + * left to copy into a terminal; + * b. a page that mentions it in prose also names `os init`, so the mention is + * a signpost rather than a leftover. + * + * ⛔ Never satisfy a red here by deleting a page's `os create` section. The + * positive control below fails when a page stops showing `os create plugin` in + * a fence, which is what makes the two assertions capable of failing at all. + * + * All four pages are already declared as cross-package inputs of + * `@objectstack/cli` (`scripts/cross-package-test-inputs.mjs`, mirrored into + * `turbo.json`), so a docs-only edit reaches this suite instead of replaying a + * cached green. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { RETIRED_TEMPLATES, templates } from '../src/commands/create.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); + +// One `resolve(HERE, …)` call per line and nothing split across lines: +// `check:cross-package-test-inputs` reconstructs these reads by SOURCE SCAN, +// and a spelling it cannot parse leaves the glob declared and held by nothing. +const CLI_DOCS = resolve(HERE, '../../..', 'content/docs/deployment/cli.mdx'); +const PLUGINS_INDEX = resolve(HERE, '../../..', 'content/docs/plugins/index.mdx'); +const KERNEL_INDEX = resolve(HERE, '../../..', 'content/docs/protocol/kernel/index.mdx'); +const PLUGIN_SPEC = resolve(HERE, '../../..', 'content/docs/protocol/kernel/plugin-spec.mdx'); + +/** The four public pages that present `os create` as a user-facing command. */ +const DOC_SITES: Record = { + 'content/docs/deployment/cli.mdx': CLI_DOCS, + 'content/docs/plugins/index.mdx': PLUGINS_INDEX, + 'content/docs/protocol/kernel/index.mdx': KERNEL_INDEX, + 'content/docs/protocol/kernel/plugin-spec.mdx': PLUGIN_SPEC, +}; + +/** Every fenced code block on a page — what a reader copies into a terminal. */ +function fences(mdx: string): string[] { + return [...mdx.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)].map((m) => m[1]); +} + +/** The retired spelling, DERIVED from the registry rather than written twice. */ +const RETIRED = Object.keys(RETIRED_TEMPLATES).map((key) => `os create ${key}`); + +describe('[#16483] the docs and the retirement registry agree', () => { + it('has something retired to look for, and a survivor to control against', () => { + // Both halves, so neither loop below can pass over an empty population. + expect(RETIRED).toEqual(['os create example']); + expect(Object.keys(templates)).toEqual(['plugin']); + }); + + it.each(Object.keys(DOC_SITES))('%s still documents `os create` (control)', (site) => { + const text = readFileSync(DOC_SITES[site], 'utf8'); + expect(text).toContain('os create plugin'); + expect( + fences(text).some((f) => f.includes('os create plugin')), + `${site} shows no runnable \`os create plugin\` block — the control for the two ` + + 'assertions below is gone, so neither can fail any more', + ).toBe(true); + }); + + it.each(Object.keys(DOC_SITES))('%s offers nothing to COPY that now refuses', (site) => { + const copyable = fences(readFileSync(DOC_SITES[site], 'utf8')) + .flatMap((f) => f.split('\n')) + .filter((line) => RETIRED.some((cmd) => line.includes(cmd))); + expect( + copyable.map((line) => line.trim()), + `${site} still shows a retired command as runnable — it exits non-zero now`, + ).toEqual([]); + }); + + it.each(Object.keys(DOC_SITES))('%s that mentions it at all points at `os init`', (site) => { + const text = readFileSync(DOC_SITES[site], 'utf8'); + if (!RETIRED.some((cmd) => text.includes(cmd))) return; + expect( + text, + `${site} names a retired command without naming its replacement`, + ).toContain('os init'); + }); +}); diff --git a/packages/cli/test/create-example-retired.e2e.test.ts b/packages/cli/test/create-example-retired.e2e.test.ts new file mode 100644 index 0000000000..5f33ef62a9 --- /dev/null +++ b/packages/cli/test/create-example-retired.e2e.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#16483) — `os create example` is gone, and its refusal names `os init`. + * + * ## What the ruling actually replaced + * + * #15531 rendered and hashed the real emission of both scaffolder families and + * found one template-level duplicate: `os create example` wrote a SUBSET of + * what `os init` writes, plus one README. The director seat ruled (batch #66, + * option B) that the template goes rather than that the two command families + * merge, and the maintainer ruled the same week that it goes with no alias and + * no deprecation window. + * + * ⛔ So deleting the template is only half of it. A removal that let + * `os create example` fall through to `Unknown type:` would print the surviving + * roster and nothing else, and a reader arriving from an old doc page, an older + * tutorial or a CI script would learn only that their spelling is off the list + * — and would go hunting for the right spelling of something that no longer + * exists. The ruling replaces the command with a SIGNPOST. + * + * ## Why a non-zero exit is NOT enough to assert, and what carries the weight + * + * A pin that asserted only `code !== 0` stays green on exactly the outcome the + * ruling exists to prevent: the generic "unknown template" failure is also + * non-zero. So the load-bearing assertion here is the CONTENT — the refusal + * names `os init` — and `the generic branch is a different answer` below is its + * control: an unknown template must exit non-zero and must NOT name `os init`. + * With both, "assert non-zero" cannot be mistaken for "assert the signpost", + * because a message that stopped naming `os init` would leave the retired case + * indistinguishable from the unknown case and this file would go red. + * + * ## Why the CLI is driven rather than the module read + * + * `RETIRED_TEMPLATES` is asserted as DATA next door in `create.test.ts`. That + * is worth nothing on its own: a registry nothing reads prints nothing. What is + * measured here is a real child process — the exit status a CI script judges by + * `$?`, and the bytes that reach a terminal. Spawned through `bin/run-dev.js` + + * tsx so the suite does not depend on `packages/cli/dist` having been built + * (`@objectstack/cli#test` depends on `^build` only), the same way + * `create-refuses-invalid-project-name.e2e.test.ts` and + * `generate-agent-retired.e2e.test.ts` spawn. + * + * ## ⚠️ This file is NIGHTLY-tier, and the docs half is deliberately NOT here + * + * `*.e2e.test.*` is a NAME-decided tier (`scripts/nightly-tiers.mjs`, #16455): + * these files are excluded from the per-PR and merge-queue population and run + * in the nightly job. That is the right lane for four cold `tsx` spawns, and it + * is where both of this pin's closest siblings already live + * (`create-refuses-invalid-project-name.e2e.test.ts`, + * `generate-agent-retired.e2e.test.ts`). + * + * It is the wrong lane for a file read. So the docs half of this card's pin — + * that no public page still offers `os create example` to copy — lives next + * door in `create-example-retired-docs-parity.test.ts`, which spawns nothing, + * is queue-tier by name, and therefore reddens on the PR that reintroduces the + * command in a doc page rather than the following night. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { childEnv } from './helpers/serve-process.js'; +import { RETIRED_TEMPLATES, templates } from '../src/commands/create.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +/** oclif + tsx cold start with every command module loaded; ~2-10 s when healthy. */ +const RUN_TIMEOUT_MS = 180_000; + +const PROJECT = 'my-app'; +/** A spelling no template has ever carried — the generic branch's own input. */ +const UNKNOWN_TYPE = 'definitely-not-a-template'; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runCli(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, ...args], + { cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + // `err.code` is the real exit status; null/undefined means the child + // was signalled — a different failure, never reported 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), + }); + }, + ); + }); +} + +let dir: string; +let retired: Run; +let retiredNoName: Run; +let unknown: Run; +let survivor: Run; + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-create-example-retired-')); + + // Sequential on purpose: four cold tsx starts, each loading every command + // module, in a container several agents share. + retired = await runCli(['create', 'example', PROJECT], dir); + retiredNoName = await runCli(['create', 'example'], dir); + unknown = await runCli(['create', UNKNOWN_TYPE, PROJECT], dir); + survivor = await runCli(['create', 'plugin', PROJECT], dir); +}, RUN_TIMEOUT_MS); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('[#16483] `os create example` is retired', () => { + it('fails instead of scaffolding — a CI script that still calls it stops', () => { + expect(retired.code).toBe(1); + }); + + it('NAMES `os init` — the half a bare removal would drop', () => { + // The load-bearing assertion of this file. See the header: non-zero alone + // is satisfied by the generic failure this refusal exists to replace. + expect(retired.stderr).toContain('os init'); + }); + + it('says the template was RETIRED, not that the type is unrecognised', () => { + expect(retired.stderr).toContain('was retired'); + // The generic branch would have swallowed the whole explanation. + expect(retired.stderr).not.toContain('Unknown type:'); + }); + + it('states the terms the ruling fixed — no alias, no deprecation window', () => { + expect(retired.stderr).toContain('no alias'); + expect(retired.stderr).toContain('no deprecation window'); + }); + + it('says `os create plugin` survives, so the refusal is not read as the family going', () => { + expect(retired.stderr).toContain('os create plugin'); + }); + + it('writes NOTHING — the refusal lands before the first mkdir', () => { + expect(existsSync(join(dir, PROJECT))).toBe(false); + }); + + it('answers the same with no project name at all', () => { + // `os create example` on its own must reach the signpost rather than be + // told to supply an argument to a command that no longer exists. + expect(retiredNoName.code).toBe(1); + expect(retiredNoName.stderr).toContain('os init'); + expect(retiredNoName.stderr).not.toContain('Project name is required'); + }); +}); + +describe('[#16483] the generic branch is a DIFFERENT answer — the control', () => { + it('an unknown template still fails, and does NOT name `os init`', () => { + // Without this, "the refusal names `os init`" could be satisfied by any + // failure path at all, and the pin would not be able to tell a signpost + // from the generic "unknown template" outcome the ruling forbids. + expect(unknown.code).toBe(1); + expect(unknown.stdout + unknown.stderr).toContain('Unknown type:'); + expect(unknown.stdout + unknown.stderr).not.toContain('os init'); + }); + + it('the roster it prints no longer advertises `example`', () => { + expect(unknown.stdout).toContain('Available types:'); + expect(unknown.stdout).not.toContain('example'); + // Derived, so the roster and this assertion cannot drift apart. + for (const key of Object.keys(templates)) { + expect(unknown.stdout).toContain(key); + } + }); + + it('`example` is off the template map and on the retired one', () => { + expect(Object.keys(templates)).not.toContain('example'); + expect(Object.keys(RETIRED_TEMPLATES)).toContain('example'); + }); +}); + +describe('[#16483] the template that was NOT retired still scaffolds', () => { + it('`os create plugin ` still exits 0 and writes its project', () => { + // The positive control for every `code === 1` above: the same command in + // the same directory reaches the opposite verdict, so those assertions are + // capable of failing. + expect(survivor.code).toBe(0); + expect(existsSync(join(dir, templates.plugin.dirName(PROJECT), 'package.json'))).toBe(true); + }); +}); diff --git a/packages/cli/test/create.test.ts b/packages/cli/test/create.test.ts index dd14af4de8..5da1d6a321 100644 --- a/packages/cli/test/create.test.ts +++ b/packages/cli/test/create.test.ts @@ -27,12 +27,22 @@ * claimed the stronger property would be the same shape of comfort the * `workspace:*` assertion above was. * - * The sweep is DERIVED from the template map, never a list of `plugin` and - * `example`: a third template must arrive already covered. + * The sweep is DERIVED from the template map, never a written-down roster: a + * template added later must arrive already covered. + * + * ## `example` is not in that map any more + * + * `os create example` was retired in #16483 under the #15531 ruling — it + * emitted a subset of what `os init` writes plus one README. Its absence from + * the map is asserted below beside its presence in `RETIRED_TEMPLATES`, so + * deleting a template WITHOUT leaving the signpost behind reddens here. What + * the surviving refusal actually prints, driven through the real CLI, is pinned + * in `create-example-retired.e2e.test.ts`. */ import { describe, it, expect } from 'vitest'; import { + RETIRED_TEMPLATES, templates, objectstackDependencySpec, rootTsconfigExtends, @@ -77,8 +87,22 @@ function objectstackDeps(pkg: Record): Record { describe('os create: the sweep covers every shipped template', () => { it('derives its population from the template map', () => { expect(TEMPLATE_KEYS.length).toBeGreaterThan(0); - // The two reported in #14824, named so a rename is loud rather than silent. - expect(TEMPLATE_KEYS).toEqual(expect.arrayContaining(['plugin', 'example'])); + // Named so a rename is loud rather than silent. `plugin` is the survivor of + // the two #14824 reported; `example` was retired in #16483. + expect(TEMPLATE_KEYS).toEqual(expect.arrayContaining(['plugin'])); + }); + + it('has retired `example`, and left a signpost where the template was', () => { + // Both halves, because the ruling is not satisfied by either alone: the + // template is gone from the roster AND the command still answers for the + // word. A deletion that dropped the entry below would leave + // `os create example` failing generically, which is what #16483 forbids. + expect(TEMPLATE_KEYS).not.toContain('example'); + expect(Object.keys(RETIRED_TEMPLATES)).toContain('example'); + // The signpost names the replacement — the property, not the wording. The + // message a user actually sees is driven and asserted in + // `create-example-retired.e2e.test.ts`. + expect(RETIRED_TEMPLATES.example.detail.join('\n')).toContain('os init'); }); it('defaults to the standalone placement', () => { diff --git a/packages/cli/test/init-template-comments-self-contained.test.ts b/packages/cli/test/init-template-comments-self-contained.test.ts index 1afe7f6ba7..bd6f17f225 100644 --- a/packages/cli/test/init-template-comments-self-contained.test.ts +++ b/packages/cli/test/init-template-comments-self-contained.test.ts @@ -253,9 +253,14 @@ describe('rendered scaffold templates are followable by a stranger', () => { // The second scaffolder, named — so a future edit that drops it from the // population fails with this card's own vocabulary rather than a bare // count that a shrinking sweep satisfies just as well. + // + // `create:example` was in this list until #16483 retired the template. Its + // ABSENCE is asserted for the same reason its presence was: a shrinking sweep + // must fail loudly, and the retirement is the one reason this one may shrink. it('sweeps the `os create` scaffolder, not just `os init`', () => { const ids = [...new Set(rendered.map((r) => r.scaffoldId))]; - expect(ids).toContain('create:example'); + expect(ids).not.toContain('create:example'); + expect(Object.keys(createTemplates)).not.toContain('example'); expect(ids).toContain('create:plugin'); expect(ids.filter((id) => id.startsWith('init:')).length).toBe(Object.keys(TEMPLATES).length); expect(ids.filter((id) => id.startsWith('create:')).length).toBe(Object.keys(createTemplates).length); diff --git a/packages/cli/test/scaffold-emission-policy.e2e.test.ts b/packages/cli/test/scaffold-emission-policy.e2e.test.ts index d38b0b18b4..ce9c89064c 100644 --- a/packages/cli/test/scaffold-emission-policy.e2e.test.ts +++ b/packages/cli/test/scaffold-emission-policy.e2e.test.ts @@ -30,8 +30,10 @@ * transcribed `'^5.3.0'` would go green on a tree where one scaffolder had been * edited and the other had not, which is the exact state it exists to catch. * - * 1. Across all five emissions, each third-party dependency name resolves to - * exactly ONE range. This is the property; the value it settles on is not. + * 1. Across every emission the two commands still ship (four since #16483 + * retired `os create example`), each third-party dependency name resolves + * to exactly ONE range. This is the property; the value it settles on is + * not, and neither is the count — both are derived from the live maps. * 2. That one range IS the exported constant, so a template that grows a * literal instead of importing turns this red. * 3. The surviving TypeScript range is the floor the DOCS state. `^5.3.0` @@ -65,7 +67,6 @@ import { renderScaffoldPackageJson, renderScaffoldTsconfig, SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG, - SCAFFOLD_TSX_RANGE, SCAFFOLD_TYPES_NODE_RANGE, SCAFFOLD_TYPESCRIPT_RANGE, SCAFFOLD_VITEST_RANGE, @@ -100,9 +101,13 @@ function thirdPartyOnly(deps: Record | undefined): Array<[strin /** * Every `package.json` the two commands emit for the shape a reader of the docs - * actually gets — `os init`'s three templates and `os create`'s two, in its + * actually gets — `os init`'s three templates and `os create`'s one, in its * DEFAULT placement. `--in-repo` is excluded on purpose: it emits `workspace:*` * and is documented as platform-work-only. + * + * `os create` contributed two until #16483 retired `example`, which is why the + * harvest is four emissions now. Both halves are DERIVED from the live maps, so + * the count moves with the roster rather than being maintained here. */ function emittedManifests(): Array<{ id: string; manifest: Record }> { const out: Array<{ id: string; manifest: Record }> = []; @@ -139,13 +144,13 @@ function declaredRanges(): Map> { return byName; } -describe('scaffold emission policy — one definition, five emissions', () => { - it('harvests a non-empty policy from all five emissions (control)', () => { +describe('scaffold emission policy — one definition, four emissions', () => { + it('harvests a non-empty policy from all four emissions (control)', () => { // Without this, every assertion below passes over an empty harvest — the // vacuity that would make the whole file certify the defect it exists for. + // `os create example` was here until #16483 retired it. const manifests = emittedManifests(); expect(manifests.map((m) => m.id).sort()).toEqual([ - 'os create example', 'os create plugin', 'os init -t app', 'os init -t empty', @@ -175,11 +180,15 @@ describe('scaffold emission policy — one definition, five emissions', () => { it('emits the exported constant rather than a literal, for every policy range', () => { const ranges = declaredRanges(); + // ⚠️ `tsx` left this table with #16483: the retired `os create example` + // template was the only emission that declared it, so `SCAFFOLD_TSX_RANGE` + // now reaches no scaffold at all. Asserting it here anyway would compare an + // empty harvest against the constant and go red on a correct tree; a row is + // owed by a range some emission really declares, and by nothing else. const expected: Array<[string, string]> = [ ['typescript', SCAFFOLD_TYPESCRIPT_RANGE], ['vitest', SCAFFOLD_VITEST_RANGE], ['@types/node', SCAFFOLD_TYPES_NODE_RANGE], - ['tsx', SCAFFOLD_TSX_RANGE], ['zod', SCAFFOLD_ZOD_RANGE], ]; for (const [name, constant] of expected) { diff --git a/packages/cli/test/scaffold-manifest-schema.test.ts b/packages/cli/test/scaffold-manifest-schema.test.ts index cdd9612f27..610b6f5657 100644 --- a/packages/cli/test/scaffold-manifest-schema.test.ts +++ b/packages/cli/test/scaffold-manifest-schema.test.ts @@ -40,6 +40,14 @@ * sweep that pretended otherwise would report on a surface `ManifestSchema` * does not govern. * + * Since #16483 that leaves `os create` contributing NOTHING: `example`, the + * scaffold that drifted and the only `create` template that emitted a config, + * was retired in favour of `os init`. That is a measurement rather than a hole + * — the filter is still derived from the live map, so a `create` template that + * grows a config later is swept the day it arrives. It is asserted as the + * roster it selects from, not as a bare count, because an empty harvest and a + * broken filter produce the same count. + * * ## Why the manifest is read back off a LOADED config, not off the source text * * Both scaffolders render their config as a template literal, so the only @@ -174,15 +182,22 @@ describe('every shipped scaffold emits a manifest `ManifestSchema` accepts', () it('sweeps both scaffolders, and every template that emits a config', () => { expect(initScaffolds.length).toBe(Object.keys(TEMPLATES).length); expect(initScaffolds.length).toBeGreaterThan(0); - expect(createScaffolds.length).toBeGreaterThan(0); + // `os create`'s whole roster, spelled out: the filter above selects from + // THIS set, so a template added to it that emits a config joins the sweep, + // and a template added that does not emit one reddens this line until a + // reader has decided which it is. + expect(Object.keys(createTemplates)).toEqual(['plugin']); + expect(createScaffolds.map((s) => s.id)).toEqual([]); expect(SCAFFOLDS.length).toBe(initScaffolds.length + createScaffolds.length); }); - // The reported instance, named so a future edit that drops the identity - // block again fails with the incident's own vocabulary rather than a bare - // count. - it('includes `os create example` — the scaffold that drifted', () => { - expect(SCAFFOLDS.map((s) => s.id)).toContain('create:example'); + // The reported instance. `os create example` is retired (#16483) — the + // scaffold that drifted is gone rather than fixed, so what is named here is + // its ABSENCE, in the incident's own vocabulary. The refusal that replaced it + // is pinned in `create-example-retired.e2e.test.ts`. + it('no longer sweeps `os create example` — the scaffold that drifted is retired', () => { + expect(Object.keys(createTemplates)).not.toContain('example'); + expect(SCAFFOLDS.map((s) => s.id)).not.toContain('create:example'); }); it.each(SCAFFOLDS.map((s) => s.id))( diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 8dd4920315..3137866815 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -504,6 +504,16 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // declaration, and declaring one rarely-touched file is cheaper than // rewording prose to dodge a scanner. 'scripts/cross-package-test-inputs.mjs', + // `nightly-tiers.mjs` is the mention shape a FIFTH time, and the one entry + // that was already HALF declared: turbo.json has carried it (and its + // `.d.mts`) in this task's `inputs` all along, because `vitest-tiers.ts` + // imports it and it decides which files the `test` task collects at all. + // What was missing was the row here, and nothing forced it until a test + // named the path in prose --- test/create-example-retired-docs-parity.test.ts, + // explaining which tier its sibling pin runs in. Settled the way + // `check-nul-bytes.mjs` above is, and it costs nothing in practice: the file + // is already a real input to this package's verdict. + 'scripts/nightly-tiers.mjs', // `translation.zod.ts` is the second entry no test READS -- named in a // comment in test/i18n-section-coverage.test.ts, which describes it as the // DECLARATION face of the schema that test asserts against. It appears From fd31e93f51f70a82831a9d5cf5545ca959f3d874 Mon Sep 17 00:00:00 2001 From: os-sales Date: Mon, 7 Sep 2026 17:34:35 +0000 Subject: [PATCH 2/2] docs(cli): make the retirement's citations match what is verifiable (#16483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review on PR #16665, findings F1-F4. F1 — the "no alias, no deprecation window" ruling could not be located by the review: not in #15531's comments, not in ledger #12708 (whose earliest comment postdates the attributed date), not in the tree. The removal itself IS verified by the #15531 batch #66 entry, so create.ts and the e2e header now cite that and record the alias/window terms as recorded on card #16483, pending maintainer confirmation. The runtime message is deliberately unchanged: it describes what the code does, which is true whoever ruled it. F2 — `os init` does NOT write "the same objectstack.config.ts". The audit this card cites measures tsconfig.json byte-identical and the two manifests DIFFERENT (both ManifestSchema-valid). Runtime message and changeset now say "the same tsconfig.json and an equivalent objectstack.config.ts", and the changeset states the difference. F3 — `-t empty` writes five files and runs the install, so "objectstack.config.ts only" was wrong in the runtime message, the changeset and the doc callout this PR added. All three now read "config only, no src/objects". F4 — the changeset said the e2e file holds the doc pages (the docs pin is the separate queue-tier file) and that `--in-repo` is unchanged (its examples/ placement goes with the template). Both corrected. F5 is deliberately NOT acted on here: SCAFFOLD_TSX_RANGE stays, init.ts is held by #16654. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- .changeset/os-create-example-retired.md | 29 +++++++++++++------ content/docs/deployment/cli.mdx | 2 +- packages/cli/src/commands/create.ts | 26 ++++++++++++----- .../test/create-example-retired.e2e.test.ts | 15 +++++++--- 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/.changeset/os-create-example-retired.md b/.changeset/os-create-example-retired.md index 5d52e57ca8..d059d3200e 100644 --- a/.changeset/os-create-example-retired.md +++ b/.changeset/os-create-example-retired.md @@ -13,7 +13,10 @@ instead of in the number. **No alias and no deprecation window.** `os create example` will not come back, -so change the command rather than pinning an older CLI. +so change the command rather than pinning an older CLI. (Those terms are recorded +on card #16483 and are pending maintainer confirmation — the removal itself is +settled by the #15531 batch entry below. The behaviour ships either way, and it is +the same shape `os g agent` already shipped.) #15531 rendered the real emission of both scaffolder families and hashed it file by file. The only template-level duplication left between them was this one @@ -28,18 +31,24 @@ a declarative app, and collapsing them would make that collision structural. ```bash os init # a full application project -os init -t empty # objectstack.config.ts only +os init -t empty # config only, no src/objects ``` -`os init` writes the same `objectstack.config.ts` and `tsconfig.json` the retired -template did, and adds `src/objects`, a `.gitignore` and the dependency install -it never had. +`os init` writes the same `tsconfig.json` the retired template did (byte-identical, +measured) and an **equivalent** `objectstack.config.ts` — both manifests are +`ManifestSchema`-valid but they are not the same bytes: the retired template wrote +`name: ''`, `description: ' example application'` and commented-out +barrels, where `os init` writes a title-cased `name`, an empty `description` and no +barrels. On top of that `os init` adds `src/objects`, a `.gitignore` and the +dependency install the retired template never had. **`os create plugin` is unaffected.** It scaffolds the kernel code `Plugin` contract — `src/index.ts` exporting a `Plugin` with `init` / `destroy`, built by `tsc`, publishable as `@objectstack/plugin-` — which `os init` does not -emit. `os create`'s flags, its `--in-repo` placement and its standalone -emission policy are unchanged. +emit. `os create`'s flags and its standalone emission policy are unchanged for +`plugin`. ⚠️ `--in-repo` is narrowed rather than untouched: the flag survives, but +its `examples/` placement is removed with the template and gets no +replacement — `--in-repo` now only ever lands in `packages/plugins/plugin-`. **The removal is a signpost, not a deletion.** `os create example` still answers: it exits **1** and names `os init`, rather than falling through to the generic @@ -47,5 +56,7 @@ it exits **1** and names `os init`, rather than falling through to the generic that still calls it learns what replaced it instead of learning only that their spelling is off the list. Pinned end-to-end by driving the real CLI in `packages/cli/test/create-example-retired.e2e.test.ts`, which asserts both halves -— the non-zero exit **and** the message naming `os init` — and holds the four -public doc pages to the same promise. +— the non-zero exit **and** the message naming `os init`. The four public doc +pages are held to the same promise by a SEPARATE pin, +`packages/cli/test/create-example-retired-docs-parity.test.ts`: it spawns nothing, +so unlike the `.e2e` file it runs in the per-PR tier rather than the nightly one. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 7d1f977e1c..f472589a7c 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1335,7 +1335,7 @@ Running it now exits non-zero and names `os init`. ```bash os init my-app # a full application project -os init my-app -t empty # objectstack.config.ts only +os init my-app -t empty # config only, no src/objects ``` diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 963d6d7994..f1281891b1 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -8,10 +8,20 @@ * The `example` template emitted a SUBSET of what `os init` writes, plus one * README. #15531 rendered and hashed both command families' real emission: the * only template-level duplication left between them was this one template, and - * the ruling (decision batch #66, option B) is that the template goes rather - * than that the two families merge — they emit two different artifacts, and a - * kernel code `Plugin` is not a declarative app. The maintainer ruled the same - * week that it goes with NO alias and NO deprecation window. + * the ruling (#15531, decision batch #66, option B) is that the template goes + * rather than that the two families merge — they emit two different artifacts, + * and a kernel code `Plugin` is not a declarative app. + * + * ⚠️ That batch entry is the whole of what is verified here, and it settles the + * REMOVAL only. The terms this file implements on top of it — NO alias and NO + * deprecation window — are recorded on card #16483 and are PENDING MAINTAINER + * CONFIRMATION: a contract review could not locate the ruling they were + * attributed to, so the attribution is written as unverified with its source + * named rather than repeated. ⛔ Do not restate it as a settled ruling, and + * ⛔ do not go looking for a ruling to make it true — it is filed for the + * maintainer. The BEHAVIOUR is unaffected either way and is the shipped + * precedent (`os g agent`, `RETIRED_GENERATORS` in `generate.ts`): the command + * refuses rather than aliasing, which is what the message below says. * * ⛔ The template is not merely deleted. `os create example` still ANSWERS, and * the answer names `os init` — see {@link RETIRED_TEMPLATES}. Letting it fall @@ -433,12 +443,12 @@ export const RETIRED_TEMPLATES: Record -> a full application project', - ' os init -t empty -> objectstack.config.ts only', + ' os init -t empty -> config only, no src/objects', '', 'There is no alias and no deprecation window: `os create example` will not', 'come back, so change the command rather than pinning an older CLI.', diff --git a/packages/cli/test/create-example-retired.e2e.test.ts b/packages/cli/test/create-example-retired.e2e.test.ts index 5f33ef62a9..ce9a0935e1 100644 --- a/packages/cli/test/create-example-retired.e2e.test.ts +++ b/packages/cli/test/create-example-retired.e2e.test.ts @@ -7,10 +7,17 @@ * * #15531 rendered and hashed the real emission of both scaffolder families and * found one template-level duplicate: `os create example` wrote a SUBSET of - * what `os init` writes, plus one README. The director seat ruled (batch #66, - * option B) that the template goes rather than that the two command families - * merge, and the maintainer ruled the same week that it goes with no alias and - * no deprecation window. + * what `os init` writes, plus one README. The director seat ruled (#15531, + * batch #66, option B) that the template goes rather than that the two command + * families merge. + * + * ⚠️ That entry settles the REMOVAL, and it is the only half verified. The + * terms asserted below — no alias, no deprecation window — are recorded on + * card #16483 and are PENDING MAINTAINER CONFIRMATION: a contract review could + * not locate the ruling they were attributed to. They are asserted here as + * SHIPPED BEHAVIOUR, which is what a pin can hold: whatever settles the + * provenance, these assertions describe what the command does today, and they + * go red if that changes. ⛔ Do not restate the attribution as settled. * * ⛔ So deleting the template is only half of it. A removal that let * `os create example` fall through to `Unknown type:` would print the surviving