diff --git a/.changeset/os-create-example-retired.md b/.changeset/os-create-example-retired.md new file mode 100644 index 0000000000..d059d3200e --- /dev/null +++ b/.changeset/os-create-example-retired.md @@ -0,0 +1,62 @@ +--- +"@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. (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 +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 # config only, no src/objects +``` + +`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 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 +`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`. 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 7b81536d88..f472589a7c 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 # config only, no src/objects +``` + + 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..f1281891b1 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -1,7 +1,37 @@ // 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 (#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 + * 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 +72,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 +115,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 +261,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 +283,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 +407,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 tsconfig.json and an', + 'equivalent objectstack.config.ts, and adds src/objects, a .gitignore and', + 'the dependency install this template never had:', + '', + ' os init -> a full application project', + ' 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.', + '', + '`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 +485,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 +496,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 fb2602d9db..d2f2acb468 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -347,10 +347,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..ce9a0935e1 --- /dev/null +++ b/packages/cli/test/create-example-retired.e2e.test.ts @@ -0,0 +1,209 @@ +// 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 (#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 + * 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 647975974a..52a17a3adc 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` @@ -78,7 +80,6 @@ import { renderScaffoldTsconfig, SCAFFOLD_PNPM_RANGE, SCAFFOLD_TSCONFIG_INCLUDE_WITH_ROOT_CONFIG, - SCAFFOLD_TSX_RANGE, SCAFFOLD_TYPES_NODE_RANGE, SCAFFOLD_TYPESCRIPT_RANGE, SCAFFOLD_VITEST_RANGE, @@ -123,9 +124,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 }> = []; @@ -162,13 +167,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', @@ -198,11 +203,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 f883d62ab5..46a695366a 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