diff --git a/.changeset/init-flag-ergonomics.md b/.changeset/init-flag-ergonomics.md new file mode 100644 index 0000000..495a363 --- /dev/null +++ b/.changeset/init-flag-ergonomics.md @@ -0,0 +1,19 @@ +--- +"seamless-cli": minor +--- + +Make `seamless init` template flags discoverable, predictable, and safe to get wrong. + +Add `seamless templates list [--json]`, which prints every starter `init` can scaffold with its id, +kind, framework, selecting flags, and status. It reads the same registry `init` does (so +`SEAMLESS_TEMPLATES_DIR` and `SEAMLESS_TEMPLATES_REF` apply) and needs no login, so the available +templates no longer have to be looked up in the source. + +Every template now answers to `--` as well as its shorter `--`, so `seamless init +--react-vite` works alongside `--basic`, and the api starters (`--express`, `--fastify`) have a flag +for the first time. The "unknown option" error lists both spellings and points at +`seamless templates list`. + +Template flags are also resolved before `init` creates a directory or asks whether to write into one +that is not empty. An unrecognized or conflicting flag now fails immediately instead of surfacing +only after the overwrite confirmation. diff --git a/AGENTS.md b/AGENTS.md index 2178d26..0a042bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ guidance may extend them but must not contradict them. - Install dependencies: `npm install` - Build (type-check and emit): `npm run build` (`tsc`, output in `dist/`) - Run from source: `npm run dev -- ` (`tsx`); or after building, `node dist/index.js ` -- Commands: `init [name]`, `check`, `verify [flags]`, `apps`, +- Commands: `init [name]`, `templates`, `check`, `verify [flags]`, `apps`, and the instance-management commands `profile`, `login`, `whoami`, `logout`, `sessions`, `config`, `users`, `org` (all dispatched from `src/index.ts`) @@ -72,8 +72,14 @@ The entry point is [src/index.ts](src/index.ts), which dispatches to a command m template's `template.json` env contract. The auth, docker, and config pieces are still generated locally in `src/generators/*`. Override the template source for development with `SEAMLESS_TEMPLATES_DIR` (a local checkout) or `SEAMLESS_TEMPLATES_REF` (a different ref). - - A `--` flag (e.g. `seamless init --oauth`) preselects the template whose registry - `alias` matches, skipping the web prompt. Aliases live in the registry, so no per-flag code. + - A `--` or `--` flag (e.g. `seamless init --react-oauth`, `seamless init --oauth`) + preselects the matching template and skips that layer's prompt. Both spellings live in the + registry, so no per-flag code. `resolveTemplateAliases` runs in `runCLI` before the project + directory is created and before the non-empty-directory confirmation, so an unknown flag can + never route through a destructive prompt on its way to an error. + - **templates** ([src/commands/templates.ts](src/commands/templates.ts)) lists the registry + (`seamless templates list [--json]`) so those ids and flags are discoverable without a + checkout. It reads the same source `init` does and needs no login. - A template can declare `setup.oauth` in its `template.json` to trigger the OAuth provider prompts ([src/prompts/oauthSetup.ts](src/prompts/oauthSetup.ts), catalog in [src/core/oauthProviders.ts](src/core/oauthProviders.ts)). The chosen providers are wired into diff --git a/README.md b/README.md index ddc041a..b2212c4 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,33 @@ Escape hatches: --- +## Choosing a starter + +`seamless templates list` shows every starter `init` can scaffold, with the flags that select it: + +```bash +seamless templates list +``` + +```text +ID KIND FRAMEWORK FLAGS STATUS +react-vite web react --basic, --react-vite stable +react-oauth web react --oauth, --react-oauth stable +express api express --express stable +``` + +Every template answers to `--`; some also declare a shorter `--`, and the two are +interchangeable. Passing a flag skips that layer's prompt: + +```bash +seamless init my-app --react-oauth --express +``` + +`--json` emits the registry entries for scripting. The command needs no login and reads the same +registry `init` does, so `SEAMLESS_TEMPLATES_DIR` and `SEAMLESS_TEMPLATES_REF` apply. + +--- + ## What gets created Depending on your selections, the CLI generates a project like this: diff --git a/src/commands/helpTopics.ts b/src/commands/helpTopics.ts index 05aca29..158c83b 100644 --- a/src/commands/helpTopics.ts +++ b/src/commands/helpTopics.ts @@ -28,11 +28,13 @@ Without a name: With a name: • Creates new directory -With an example flag (e.g. --oauth): - • Scaffolds that use-case starter and skips the web prompt +With a template flag (e.g. --oauth, --react-oauth, --fastify): + • Scaffolds that starter and skips that layer's prompt + • A template answers to both its id and its short alias, so --basic and + --react-vite select the same starter • --oauth also prompts for OIDC providers (Google, GitHub, Microsoft, GitLab) and wires the ones you configure into the auth server - • Run an unknown flag to see the available examples + • Run seamless templates list to see every id, alias, and flag --profile • Use that profile instead of the active one @@ -54,6 +56,32 @@ With an example flag (e.g. --oauth): → Create ./my-app from the OAuth example starter`, ], }, + { + name: "templates", + usage: ["seamless templates list [--json]"], + sections: [ + { + heading: "templates list [--json]", + body: `List the starters seamless init can scaffold, read from the same registry +init uses (so SEAMLESS_TEMPLATES_DIR and SEAMLESS_TEMPLATES_REF apply). +Needs no login. + + • Columns: id, kind (web or api), framework, the init flags that select + it, and status + • Every template answers to --; some also declare a shorter -- + • Templates marked coming-soon cannot be selected yet, so they list no flag + +--json + • Emit the registry entries as an array, for scripting`, + }, + ], + examples: [ + `seamless templates list + → Table of every available starter`, + `seamless templates list --json + → Machine-readable registry entries`, + ], + }, { name: "check", usage: ["seamless check"], diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index f4b4ca4..d598bdc 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -83,11 +83,16 @@ vi.mock("../core/output.js", () => ({ printManagedSuccessOutput: vi.fn(), printSuccessOutput: vi.fn(), })); -vi.mock("../core/templates.js", () => ({ +// The flag helpers are pure registry lookups, so they come from the real module; +// only the effectful exports are stubbed. templates.ts imports VERSION from +// ../index.js, which runs main() at import time, hence the mock below it. +vi.mock("../core/templates.js", async (importOriginal) => ({ + ...(await importOriginal()), openTemplateSource: vi.fn(), applyTemplateEnv: vi.fn(), assertCliSupports: vi.fn(), })); +vi.mock("../index.js", () => ({ VERSION: "0.0.0-test" })); vi.mock("../core/authClient.js", () => { class ReauthRequiredError extends Error {} return { createPortalClient: vi.fn(), ReauthRequiredError }; @@ -597,10 +602,33 @@ describe("template alias resolution", () => { ); }); - it("reports (none) available when no template exposes an alias", async () => { + it("preselects a template from its id when it has no alias", async () => { + await runCLI(undefined, ["web-basic"]); + expect(runProjectSetupPrompts).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ webTemplateId: "web-basic" }), + undefined, + ); + }); + + it("treats a template's id and alias as the same flag", async () => { + await runCLI(undefined, ["oauth", "web-oauth"]); + expect(runProjectSetupPrompts).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ webTemplateId: "web-oauth" }), + undefined, + ); + }); + + it("lists both spellings of every selectable template when a flag is unknown", async () => { + await expect(runCLI(undefined, ["nope"])).rejects.toThrow( + /--oauth, --web-oauth, --web-basic, --express, --api-express/, + ); + }); + + it("reports (none) available when nothing in the registry is selectable", async () => { const src = makeSource(); - // Strip every alias so the error's available-flags list is empty. - for (const t of src.registry.templates) delete (t as any).alias; + for (const t of src.registry.templates) (t as any).status = "coming-soon"; vi.mocked(openTemplateSource).mockResolvedValue(src as never); await expect(runCLI(undefined, ["nope"])).rejects.toThrow( @@ -608,6 +636,24 @@ describe("template alias resolution", () => { ); }); + // An unknown flag used to surface only after the scaffold had already asked + // whether to write over a directory that was not empty. + it("rejects an unknown flag before prompting about a non-empty directory", async () => { + vi.mocked(fs.readdirSync).mockReturnValue(["src"] as never); + + await expect(runCLI(undefined, ["nope"])).rejects.toThrow( + /Unknown option "--nope"/, + ); + expect(chooseExistingDirectoryAction).not.toHaveBeenCalled(); + }); + + it("rejects an unknown flag before creating the project directory", async () => { + await expect(runCLI("demo", ["nope"])).rejects.toThrow( + /Unknown option "--nope"/, + ); + expect(fs.mkdirSync).not.toHaveBeenCalled(); + }); + it("rejects conflicting web alias flags", async () => { // Add a second stable web alias so two web flags conflict. const src = makeSource(); diff --git a/src/commands/init.ts b/src/commands/init.ts index 619f1b2..84dfa28 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -18,7 +18,9 @@ import { generateSeamlessConfig } from "../generators/config/config.js"; import { applyTemplateEnv, assertCliSupports, + matchesTemplateFlag, openTemplateSource, + templateFlags, type RegistryEntry, type ScaffoldContext, type TemplateManifest, @@ -81,6 +83,19 @@ export async function runCLI( ); } + const openSource = lazyTemplateSource(); + + // Template flags are resolved against the registry before a directory is + // created and before the overwrite confirmation runs, so an unknown or + // conflicting flag can never reach a destructive prompt on its way to an + // error. Skipped entirely when there are no flags, which keeps the + // integrate-an-existing-project path from fetching a registry it never reads. + let preselect: TemplatePreselect = {}; + if (aliases.length > 0) { + const { registry } = await openSource(); + preselect = resolveTemplateAliases(aliases, registry.templates); + } + let root = cwd; // Only ever set to a directory mkdir just created, never one that already // existed, so discarding it can never take a developer's own files with it. @@ -108,7 +123,7 @@ export async function runCLI( if (created) process.on("SIGINT", onInterrupt); try { - await scaffold(root, projectName, aliases, opts); + await scaffold(root, projectName, preselect, openSource, opts); } catch (err) { // Anything short of a completed scaffold leaves nothing behind, so a retry // is not blocked by "Directory already exists" from a half-built attempt. @@ -119,6 +134,16 @@ export async function runCLI( } } +type OpenSource = () => Promise; + +// Opens the template source at most once per run. Validating flags up front and +// scaffolding both need the registry, and the remote source refetches it on +// every open. +function lazyTemplateSource(): OpenSource { + let pending: Promise | null = null; + return () => (pending ??= openTemplateSource()); +} + function discard(dir: string | null): void { if (!dir) return; try { @@ -132,7 +157,8 @@ function discard(dir: string | null): void { async function scaffold( root: string, projectName: string | undefined, - aliases: string[], + preselect: TemplatePreselect, + openSource: OpenSource, opts: InitOptions, ) { // --local forces the self-hosted stack without asking the control plane @@ -178,7 +204,15 @@ async function scaffold( ? "managed" : await chooseScaffoldTarget(apps.length); if (target === "managed") { - await scaffoldManaged(root, projectName, aliases, client!, apps, opts); + await scaffoldManaged( + root, + projectName, + preselect, + openSource, + client!, + apps, + opts, + ); return; } } else if (client) { @@ -193,7 +227,7 @@ async function scaffold( ); } - await scaffoldLocal(root, projectName, aliases); + await scaffoldLocal(root, projectName, preselect, openSource); } // The bundled database as a connection string with placeholder credentials, or @@ -249,13 +283,13 @@ async function resolveManagedClient(): Promise { async function scaffoldManaged( root: string, projectName: string | undefined, - aliases: string[], + preselect: TemplatePreselect, + openSource: OpenSource, client: AuthClient, apps: ConnectableApp[], opts: InitOptions, ) { - const source = await openTemplateSource(); - const preselect = resolveTemplateAliases(aliases, source.registry.templates); + const source = await openSource(); const answers = await runManagedTemplatePrompts( source.registry.templates, preselect, @@ -346,10 +380,10 @@ async function scaffoldManaged( async function scaffoldLocal( root: string, projectName: string | undefined, - aliases: string[], + preselect: TemplatePreselect, + openSource: OpenSource, ) { - const source = await openTemplateSource(); - const preselect = resolveTemplateAliases(aliases, source.registry.templates); + const source = await openSource(); const answers = await runProjectSetupPrompts( source.registry.templates, preselect, @@ -631,10 +665,11 @@ export interface TemplatePreselect { apiTemplateId?: string; } -// Resolves `--` flags (e.g. --oauth) to specific templates from the registry, -// so a matching layer's prompt can be skipped. Aliases live in the registry, so no -// per-flag code is needed here. Unknown or conflicting flags are hard errors. -function resolveTemplateAliases( +// Resolves `--` and `--` flags (e.g. --oauth, --react-oauth) to specific +// templates from the registry, so a matching layer's prompt can be skipped. Both +// spellings live in the registry, so no per-flag code is needed here. Unknown or +// conflicting flags are hard errors. +export function resolveTemplateAliases( aliases: string[], templates: RegistryEntry[], ): TemplatePreselect { @@ -642,15 +677,15 @@ function resolveTemplateAliases( for (const alias of aliases) { const entry = templates.find( - (t) => t.alias === alias && t.status !== "coming-soon", + (t) => matchesTemplateFlag(t, alias) && t.status !== "coming-soon", ); if (!entry) { const available = templates - .filter((t) => t.alias && t.status !== "coming-soon") - .map((t) => `--${t.alias}`) + .filter((t) => t.status !== "coming-soon") + .flatMap((t) => templateFlags(t)) .join(", "); throw new Error( - `Unknown option "--${alias}". Available template flags: ${available || "(none)"}.`, + `Unknown option "--${alias}". Available template flags: ${available || "(none)"}. Run \`seamless templates list\` for details.`, ); } diff --git a/src/commands/templates.test.ts b/src/commands/templates.test.ts new file mode 100644 index 0000000..1787166 --- /dev/null +++ b/src/commands/templates.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { openTemplateSource } from "../core/templates.js"; +import { runTemplates } from "./templates.js"; + +// templateFlags is a pure registry lookup, so it comes from the real module and +// only the fetching export is stubbed. templates.ts imports VERSION from +// ../index.js, which runs main() at import time, hence the mock below it. +vi.mock("../core/templates.js", async (importOriginal) => ({ + ...(await importOriginal()), + openTemplateSource: vi.fn(), +})); +vi.mock("../index.js", () => ({ VERSION: "0.0.0-test" })); + +function registry() { + return { + schemaVersion: 1, + templates: [ + { + id: "react-vite", + kind: "web", + framework: "react", + label: "React (Vite)", + alias: "basic", + status: "stable", + path: "templates/web/react-vite", + }, + { + id: "express", + kind: "api", + framework: "express", + label: "Express", + status: "stable", + path: "templates/api/express", + }, + { + id: "go-chi", + kind: "api", + framework: "go", + label: "Go", + alias: "go", + status: "coming-soon", + path: "templates/api/go-chi", + }, + ], + }; +} + +let logs: string[]; +let errors: string[]; + +beforeEach(() => { + vi.clearAllMocks(); + logs = []; + errors = []; + vi.spyOn(console, "log").mockImplementation((msg?: unknown) => { + logs.push(String(msg ?? "")); + }); + vi.spyOn(console, "error").mockImplementation((msg?: unknown) => { + errors.push(String(msg ?? "")); + }); + vi.mocked(openTemplateSource).mockResolvedValue({ + registry: registry(), + } as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const out = () => logs.join("\n"); + +describe("templates list", () => { + it("defaults to list when no subcommand is given", async () => { + await runTemplates([]); + expect(out()).toContain("react-vite"); + expect(out()).toContain("express"); + }); + + it("shows both the alias and the id as init flags", async () => { + await runTemplates(["list"]); + expect(out()).toContain("--basic, --react-vite"); + }); + + it("shows the id alone for a template with no alias", async () => { + await runTemplates(["list"]); + expect(out()).toContain("--express"); + expect(out()).not.toContain("--, --express"); + }); + + it("offers no flag for a coming-soon template", async () => { + await runTemplates(["list"]); + const row = logs.find((line) => line.startsWith("go-chi")); + expect(row).toBeDefined(); + expect(row).not.toContain("--go"); + expect(row).toContain("coming-soon"); + }); + + it("emits the registry entries with --json", async () => { + await runTemplates(["list", "--json"]); + const parsed = JSON.parse(out()); + expect(parsed).toHaveLength(3); + expect(parsed[0]).toMatchObject({ id: "react-vite", alias: "basic" }); + }); + + it("reports an empty registry rather than printing an empty table", async () => { + vi.mocked(openTemplateSource).mockResolvedValue({ + registry: { schemaVersion: 1, templates: [] }, + } as never); + + await runTemplates(["list"]); + expect(out()).toContain("registry is empty"); + }); + + it("rejects an unknown subcommand", async () => { + const exit = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + + await runTemplates(["nope"]); + + expect(errors.join("\n")).toContain("Unknown templates subcommand"); + expect(exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/commands/templates.ts b/src/commands/templates.ts new file mode 100644 index 0000000..f13116a --- /dev/null +++ b/src/commands/templates.ts @@ -0,0 +1,79 @@ +import kleur from "kleur"; + +import { + openTemplateSource, + templateFlags, + type RegistryEntry, +} from "../core/templates.js"; + +export async function runTemplates(args: string[]): Promise { + const sub = args[0]; + const rest = args.slice(1); + + switch (sub) { + case undefined: + case "list": + await templatesList(rest); + return; + default: + console.error(kleur.red(`Unknown templates subcommand: ${sub}`)); + console.log("Usage: seamless templates list [--json]"); + process.exit(1); + } +} + +async function templatesList(rest: string[]): Promise { + const json = rest.includes("--json"); + const { registry } = await openTemplateSource(); + const templates = registry.templates; + + if (json) { + console.log(JSON.stringify(templates, null, 2)); + return; + } + + if (templates.length === 0) { + console.log(kleur.dim("The template registry is empty.")); + return; + } + + const rows = templates.map((template) => [ + template.id, + template.kind, + template.framework, + flagsFor(template), + template.status, + ]); + + printTable(["ID", "KIND", "FRAMEWORK", "FLAGS", "STATUS"], rows); + console.log( + kleur.dim( + "\nPass a flag to seamless init to skip that layer's prompt, e.g. seamless init --oauth", + ), + ); +} + +// Both spellings a template answers to on the command line. The alias is the +// short form and the id always works, so listing both is what makes the pairing +// discoverable without opening registry.json. A coming-soon template cannot be +// selected at all, so offering it a flag would only produce an error later. +function flagsFor(template: RegistryEntry): string { + if (template.status === "coming-soon") return "-"; + return templateFlags(template).join(", "); +} + +function printTable(headers: string[], rows: string[][]): void { + const widths = headers.map((header, i) => + Math.max(header.length, ...rows.map((row) => row[i].length)), + ); + + const render = (cells: string[]) => + cells + .map((cell, i) => (i === cells.length - 1 ? cell : cell.padEnd(widths[i]))) + .join(" "); + + console.log(kleur.dim(render(headers))); + for (const row of rows) { + console.log(render(row)); + } +} diff --git a/src/core/templates.test.ts b/src/core/templates.test.ts index b70b76a..c1ea9e2 100644 --- a/src/core/templates.test.ts +++ b/src/core/templates.test.ts @@ -13,7 +13,9 @@ import { SEAMLESS_TEMPLATES_REF, SEAMLESS_TEMPLATES_REPO } from "./images.js"; import { applyTemplateEnv, assertCliSupports, + matchesTemplateFlag, openTemplateSource, + templateFlags, type RegistryEntry, type ScaffoldContext, type TemplateManifest, @@ -482,3 +484,42 @@ describe("applyTemplateEnv", () => { ).toThrow(/Unknown template placeholder \{\{bogus\}\}/); }); }); + +describe("template flags", () => { + const withAlias: RegistryEntry = { + id: "react-vite", + kind: "web", + framework: "react", + label: "React (Vite)", + alias: "basic", + status: "stable", + path: "templates/web/react-vite", + }; + const noAlias: RegistryEntry = { + id: "express", + kind: "api", + framework: "express", + label: "Express", + status: "stable", + path: "templates/api/express", + }; + + it("offers the alias before the id when a template declares one", () => { + expect(templateFlags(withAlias)).toEqual(["--basic", "--react-vite"]); + }); + + it("offers the id alone when a template declares no alias", () => { + expect(templateFlags(noAlias)).toEqual(["--express"]); + }); + + it("matches a template by either its alias or its id", () => { + expect(matchesTemplateFlag(withAlias, "basic")).toBe(true); + expect(matchesTemplateFlag(withAlias, "react-vite")).toBe(true); + expect(matchesTemplateFlag(withAlias, "vite")).toBe(false); + }); + + it("matches an alias-less template by its id", () => { + expect(matchesTemplateFlag(noAlias, "express")).toBe(true); + expect(matchesTemplateFlag(noAlias, "fastify")).toBe(false); + }); +}); diff --git a/src/core/templates.ts b/src/core/templates.ts index b998c46..dfc543e 100644 --- a/src/core/templates.ts +++ b/src/core/templates.ts @@ -27,6 +27,17 @@ export interface Registry { templates: RegistryEntry[]; } +// The command-line spellings for a template, longest-lived first. The id always +// works because it is what `seamless templates list` and the registry show; the +// alias is a shorter synonym some templates also declare. +export function templateFlags(entry: RegistryEntry): string[] { + return entry.alias ? [`--${entry.alias}`, `--${entry.id}`] : [`--${entry.id}`]; +} + +export function matchesTemplateFlag(entry: RegistryEntry, flag: string): boolean { + return entry.alias === flag || entry.id === flag; +} + export interface TemplateManifest { id: string; targetDir: string; diff --git a/src/index.test.ts b/src/index.test.ts index d7a017e..29a1222 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -20,6 +20,7 @@ vi.mock("./commands/config.js", () => ({ runConfig: vi.fn() })); vi.mock("./commands/users.js", () => ({ runUsers: vi.fn() })); vi.mock("./commands/org.js", () => ({ runOrg: vi.fn() })); vi.mock("./commands/apps.js", () => ({ runApps: vi.fn() })); +vi.mock("./commands/templates.js", () => ({ runTemplates: vi.fn() })); const flush = () => new Promise((r) => setImmediate(r)); @@ -160,6 +161,7 @@ describe("index dispatcher", () => { ["users", "./commands/users.js", "runUsers"], ["org", "./commands/org.js", "runOrg"], ["apps", "./commands/apps.js", "runApps"], + ["templates", "./commands/templates.js", "runTemplates"], ])("dispatches %s with the remaining args", async (cmd, modPath, fnName) => { await dispatch([cmd, "sub", "--flag"]); const mod = (await import(/* @vite-ignore */ modPath)) as Record>; diff --git a/src/index.ts b/src/index.ts index 993db2e..3341ecf 100755 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { runConfig } from "./commands/config.js"; import { runUsers } from "./commands/users.js"; import { runOrg } from "./commands/org.js"; import { runApps } from "./commands/apps.js"; +import { runTemplates } from "./commands/templates.js"; import { isCancelled } from "./core/cancel.js"; import kleur from "kleur"; @@ -78,6 +79,11 @@ async function main() { return; } + if (command === "templates") { + await runTemplates(args.slice(1)); + return; + } + if (command === "check") { await runCheck(); return;