From 4917717e6572e12be100d0ff97187eb1094c1b76 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 31 Jul 2026 23:42:42 -0400 Subject: [PATCH] feat: make init template flags discoverable and validated up front 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 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 `init --react-vite` works alongside `--basic`, and the api starters get a flag for the first time. The unknown-option error lists both spellings and points at `templates list`. Template flags are resolved in runCLI before a directory is created and before the non-empty-directory confirmation, so an unrecognized or conflicting flag fails immediately instead of surfacing only after the overwrite prompt. The registry is opened at most once per run, and not at all on the integrate path. Closes #149, closes #150, closes #151 --- .changeset/init-flag-ergonomics.md | 19 +++++ AGENTS.md | 12 ++- README.md | 27 +++++++ src/commands/helpTopics.ts | 34 +++++++- src/commands/init.test.ts | 54 ++++++++++++- src/commands/init.ts | 71 +++++++++++----- src/commands/templates.test.ts | 125 +++++++++++++++++++++++++++++ src/commands/templates.ts | 79 ++++++++++++++++++ src/core/templates.test.ts | 41 ++++++++++ src/core/templates.ts | 11 +++ src/index.test.ts | 2 + src/index.ts | 6 ++ 12 files changed, 453 insertions(+), 28 deletions(-) create mode 100644 .changeset/init-flag-ergonomics.md create mode 100644 src/commands/templates.test.ts create mode 100644 src/commands/templates.ts 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;