From 9d089c79acd0b0692926a8e4f6915bd12dcaea85 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 30 Jul 2026 12:29:48 -0400 Subject: [PATCH 01/11] chore: move the scaffold onto the v0.6.0 ecosystem Bump the pinned auth API to v0.6.0, the admin dashboard to v0.4.0, and the templates ref to v0.6.0, which carries @seamless-auth/react ^0.7.0 in both React starters and @seamless-auth/express ^0.11.0 in the Express starter. The API drops the admin bootstrap invite flow in favor of the OWNER_EMAIL grant the CLI already writes, adds admin:read and admin:write to AVAILABLE_ROLES, and gains DB_SSL configuration. Those reach generated projects without code changes: the CLI fetches .env.example at the pinned tag and mutates it. Move the conformance adapter to @seamless-auth/express ^0.11.0. Its breaking change splits error into errorCode and errorBody on the handler result types, which only affects code importing handlers from @seamless-auth/core directly, so the adapter needed no source change. --- .changeset/ecosystem-bump-july.md | 25 +++++++++++++++++++++++++ src/core/images.ts | 6 +++--- verify/adapter-app/package.json | 2 +- 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 .changeset/ecosystem-bump-july.md diff --git a/.changeset/ecosystem-bump-july.md b/.changeset/ecosystem-bump-july.md new file mode 100644 index 0000000..0da9563 --- /dev/null +++ b/.changeset/ecosystem-bump-july.md @@ -0,0 +1,25 @@ +--- +"seamless-cli": minor +--- + +Move the scaffold onto the current Seamless ecosystem: auth API `v0.6.0`, admin dashboard `v0.4.0`, +and seamless-templates `v0.6.0` (which carries `@seamless-auth/react` `^0.7.0` in both React +starters and `@seamless-auth/express` `^0.11.0` in the Express starter). + +Both React starters gain a protected `/session` route that shows the issued claims, roles, +organization context, step-up freshness, and registered passkeys, so the first authenticated screen +reads as an app rather than a `JSON.stringify` dump. Missing configuration now stops a scaffolded +project with a message naming the variable instead of surfacing later as a 500, and the Express +starter reports every configuration problem at once. + +The auth API drops the admin bootstrap invite flow in favor of the `OWNER_EMAIL` grant the CLI +already writes, so the generated `.env` no longer carries `SEAMLESS_BOOTSTRAP_ENABLED`, +`SEAMLESS_BOOTSTRAP_SECRET`, or `SEAMLESS_AUTH_DEBUG_SECRETS`. `AVAILABLE_ROLES` now offers +`admin:read` and `admin:write` alongside bare `admin`, and assigning a role the instance does not +list is rejected rather than silently doing nothing. Postgres TLS is configurable through `DB_SSL`, +`DB_SSL_CA`, and `DB_SSL_REJECT_UNAUTHORIZED`, and `DB_URI` is accepted as a `DATABASE_URL` alias. + +The conformance harness adapter moves to `@seamless-auth/express` `^0.11.0`. Its breaking change +splits `error` into `errorCode` and `errorBody` on the handler result types, which only affects code +importing handlers from `@seamless-auth/core` directly; the adapter uses `createSeamlessAuthServer`, +so it needed no source change. diff --git a/src/core/images.ts b/src/core/images.ts index c1abf6b..8c59148 100644 --- a/src/core/images.ts +++ b/src/core/images.ts @@ -1,10 +1,10 @@ export const POSTGRES_IMAGE = "postgres:17"; -export const SEAMLESS_AUTH_API_VERSION = "v0.5.0"; +export const SEAMLESS_AUTH_API_VERSION = "v0.6.0"; export const SEAMLESS_AUTH_API_IMAGE = `ghcr.io/fells-code/seamless-auth-api:${SEAMLESS_AUTH_API_VERSION}`; -export const SEAMLESS_AUTH_ADMIN_DASHBOARD_VERSION = "v0.3.0"; +export const SEAMLESS_AUTH_ADMIN_DASHBOARD_VERSION = "v0.4.0"; export const SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE = `ghcr.io/fells-code/seamless-auth-admin-dashboard:${SEAMLESS_AUTH_ADMIN_DASHBOARD_VERSION}`; @@ -13,4 +13,4 @@ export const SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE = `ghcr.io/fells-code/seamless- // SEAMLESS_TEMPLATES_REF, or point at a local checkout with SEAMLESS_TEMPLATES_DIR. export const SEAMLESS_TEMPLATES_REPO = "fells-code/seamless-templates"; -export const SEAMLESS_TEMPLATES_REF = "v0.5.0"; +export const SEAMLESS_TEMPLATES_REF = "v0.6.0"; diff --git a/verify/adapter-app/package.json b/verify/adapter-app/package.json index 425909d..31528d5 100644 --- a/verify/adapter-app/package.json +++ b/verify/adapter-app/package.json @@ -5,7 +5,7 @@ "type": "module", "description": "Minimal adopter backend for the conformance harness — real @seamless-auth/express with a capture transport.", "dependencies": { - "@seamless-auth/express": "^0.9.0", + "@seamless-auth/express": "^0.11.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "express": "^5.1.0" From e4f5bd4970fc3e3df49b592f95ede5835202e715 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 30 Jul 2026 15:52:51 -0400 Subject: [PATCH 02/11] chore: offer the Fastify starter from templates v0.7.0 Bump the templates ref to v0.7.0, which adds a Fastify API starter beside Express. The backend prompt renders it as "Fastify (beta)" from the registry status, so no CLI code changes are needed. The starter serves the same surface as Express on the same env contract, including the admin console at /console behind SERVE_ADMIN_CONSOLE, which the Fastify adapter gained in @seamless-auth/fastify 0.2.0. Both API starters now ship .env.example secrets long enough to clear the adapter's 32 character minimum. --- .changeset/ecosystem-bump-july.md | 10 +++++++++- README.md | 2 +- src/core/images.ts | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.changeset/ecosystem-bump-july.md b/.changeset/ecosystem-bump-july.md index 0da9563..3d041dc 100644 --- a/.changeset/ecosystem-bump-july.md +++ b/.changeset/ecosystem-bump-july.md @@ -3,9 +3,17 @@ --- Move the scaffold onto the current Seamless ecosystem: auth API `v0.6.0`, admin dashboard `v0.4.0`, -and seamless-templates `v0.6.0` (which carries `@seamless-auth/react` `^0.7.0` in both React +and seamless-templates `v0.7.0` (which carries `@seamless-auth/react` `^0.7.0` in both React starters and `@seamless-auth/express` `^0.11.0` in the Express starter). +`seamless init` now offers Fastify as a backend, listed as "Fastify (beta)" beside Express. It +serves the same surface as the Express starter on the same environment contract, including the +admin console at `/console` behind `SERVE_ADMIN_CONSOLE`, which the Fastify adapter gained in +`@seamless-auth/fastify` 0.2.0. Both Express and Fastify starters now ship `.env.example` secret +placeholders long enough to clear the adapter's 32 character minimum, so the documented +`cp .env.example .env && npm run dev` path boots. A project from `seamless init` was already +unaffected, because the CLI fills `COOKIE_SIGNING_KEY` itself. + Both React starters gain a protected `/session` route that shows the issued claims, roles, organization context, step-up freshness, and registered passkeys, so the first authenticated screen reads as an app rather than a `JSON.stringify` dump. Missing configuration now stops a scaffolded diff --git a/README.md b/README.md index 63f48e4..f3aa575 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Depending on your selections, the CLI generates a project like this: my-app/ ├─ auth/ # Seamless Auth server (optional) ├─ web/ # React web application (optional) -├─ api/ # Express API server (optional) +├─ api/ # Express or Fastify API server (optional) ├─ docker-compose.yml (optional) └─ README.md ``` diff --git a/src/core/images.ts b/src/core/images.ts index 8c59148..7bb49ce 100644 --- a/src/core/images.ts +++ b/src/core/images.ts @@ -13,4 +13,4 @@ export const SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE = `ghcr.io/fells-code/seamless- // SEAMLESS_TEMPLATES_REF, or point at a local checkout with SEAMLESS_TEMPLATES_DIR. export const SEAMLESS_TEMPLATES_REPO = "fells-code/seamless-templates"; -export const SEAMLESS_TEMPLATES_REF = "v0.6.0"; +export const SEAMLESS_TEMPLATES_REF = "v0.7.0"; From 50b949345725eff815c52ee68543c3d1fccec59f Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 30 Jul 2026 23:35:52 -0400 Subject: [PATCH 03/11] chore: bump to auth API v0.7.0 and templates v0.8.0 The auth API adds an unauthenticated GET /system-config/public returning the configured login methods. The adapters proxy it, and the React starters read it to offer a skip on the passkey registration screen when the instance has another login method enabled. The API, adapters, and templates have to move together for that path to work, so bump them as a set. Templates v0.8.0 carries @seamless-auth/react ^0.8.0, @seamless-auth/express ^0.12.0, and @seamless-auth/fastify ^0.3.0, plus two Fastify boot fixes: an empty PORT= now falls back to 3000 instead of binding a random free port, and pino-pretty moves to a runtime dependency. Move the conformance adapter to @seamless-auth/express ^0.12.0, the release that proxies the new route. --- .changeset/ecosystem-bump-july.md | 36 ++++++++++++++++++++----------- src/core/images.ts | 4 ++-- verify/adapter-app/package.json | 2 +- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/.changeset/ecosystem-bump-july.md b/.changeset/ecosystem-bump-july.md index 3d041dc..837c932 100644 --- a/.changeset/ecosystem-bump-july.md +++ b/.changeset/ecosystem-bump-july.md @@ -2,17 +2,28 @@ "seamless-cli": minor --- -Move the scaffold onto the current Seamless ecosystem: auth API `v0.6.0`, admin dashboard `v0.4.0`, -and seamless-templates `v0.7.0` (which carries `@seamless-auth/react` `^0.7.0` in both React -starters and `@seamless-auth/express` `^0.11.0` in the Express starter). +Move the scaffold onto the current Seamless ecosystem: auth API `v0.7.0`, admin dashboard `v0.4.0`, +and seamless-templates `v0.8.0` (which carries `@seamless-auth/react` `^0.8.0` in both React +starters, `@seamless-auth/express` `^0.12.0` in the Express starter, and `@seamless-auth/fastify` +`^0.3.0` in the Fastify starter). + +A scaffolded project can now finish registration without a passkey. Registration used to end on a +screen with a single control, leaving anyone who did not want a passkey, or whose device could not +make one, with no way forward. The starters offer a skip when the instance has another login method +enabled, and say so plainly when it does not. That reads from `GET /system-config/public`, a new +unauthenticated route on the auth server that returns the configured login methods, so the sign-in +screens can offer what an instance actually has enabled instead of a hardcoded guess. The API, the +adapters, and the web templates all had to move together for it to work, which is why this bumps +them as a set. `seamless init` now offers Fastify as a backend, listed as "Fastify (beta)" beside Express. It serves the same surface as the Express starter on the same environment contract, including the -admin console at `/console` behind `SERVE_ADMIN_CONSOLE`, which the Fastify adapter gained in -`@seamless-auth/fastify` 0.2.0. Both Express and Fastify starters now ship `.env.example` secret -placeholders long enough to clear the adapter's 32 character minimum, so the documented -`cp .env.example .env && npm run dev` path boots. A project from `seamless init` was already -unaffected, because the CLI fills `COOKIE_SIGNING_KEY` itself. +admin console at `/console` behind `SERVE_ADMIN_CONSOLE`. Two boot-time fixes land with it: an empty +`PORT=` in `.env` now falls back to 3000 rather than binding a random free port, and `pino-pretty` +moves to a runtime dependency so an install without dev dependencies boots. Both Express and Fastify +starters ship `.env.example` secret placeholders long enough to clear the adapter's 32 character +minimum, so the documented `cp .env.example .env && npm run dev` path boots. A project from +`seamless init` was already unaffected, because the CLI fills `COOKIE_SIGNING_KEY` itself. Both React starters gain a protected `/session` route that shows the issued claims, roles, organization context, step-up freshness, and registered passkeys, so the first authenticated screen @@ -27,7 +38,8 @@ already writes, so the generated `.env` no longer carries `SEAMLESS_BOOTSTRAP_EN list is rejected rather than silently doing nothing. Postgres TLS is configurable through `DB_SSL`, `DB_SSL_CA`, and `DB_SSL_REJECT_UNAUTHORIZED`, and `DB_URI` is accepted as a `DATABASE_URL` alias. -The conformance harness adapter moves to `@seamless-auth/express` `^0.11.0`. Its breaking change -splits `error` into `errorCode` and `errorBody` on the handler result types, which only affects code -importing handlers from `@seamless-auth/core` directly; the adapter uses `createSeamlessAuthServer`, -so it needed no source change. +The conformance harness adapter moves to `@seamless-auth/express` `^0.12.0`, which is also what +proxies the new public system-config route. The breaking change in `0.11.0` splits `error` into +`errorCode` and `errorBody` on the handler result types, which only affects code importing handlers +from `@seamless-auth/core` directly; the adapter uses `createSeamlessAuthServer`, so it needed no +source change. diff --git a/src/core/images.ts b/src/core/images.ts index 7bb49ce..a0596a2 100644 --- a/src/core/images.ts +++ b/src/core/images.ts @@ -1,6 +1,6 @@ export const POSTGRES_IMAGE = "postgres:17"; -export const SEAMLESS_AUTH_API_VERSION = "v0.6.0"; +export const SEAMLESS_AUTH_API_VERSION = "v0.7.0"; export const SEAMLESS_AUTH_API_IMAGE = `ghcr.io/fells-code/seamless-auth-api:${SEAMLESS_AUTH_API_VERSION}`; @@ -13,4 +13,4 @@ export const SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE = `ghcr.io/fells-code/seamless- // SEAMLESS_TEMPLATES_REF, or point at a local checkout with SEAMLESS_TEMPLATES_DIR. export const SEAMLESS_TEMPLATES_REPO = "fells-code/seamless-templates"; -export const SEAMLESS_TEMPLATES_REF = "v0.7.0"; +export const SEAMLESS_TEMPLATES_REF = "v0.8.0"; diff --git a/verify/adapter-app/package.json b/verify/adapter-app/package.json index 31528d5..5a33ded 100644 --- a/verify/adapter-app/package.json +++ b/verify/adapter-app/package.json @@ -5,7 +5,7 @@ "type": "module", "description": "Minimal adopter backend for the conformance harness — real @seamless-auth/express with a capture transport.", "dependencies": { - "@seamless-auth/express": "^0.11.0", + "@seamless-auth/express": "^0.12.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "express": "^5.1.0" From f13477c4fa256cfb3ae7abb5395c3b4a1d36584a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 31 Jul 2026 16:42:35 -0400 Subject: [PATCH 04/11] chore: bump to auth API v0.7.1 and templates v0.8.1 Registering against a scaffolded Fastify API returned a 500 with TypeError: option maxAge is invalid: 300. The auth server sent the registration response's ttl as the string "300", and the Fastify adapter handed it to a cookie library that requires an integer. Express never showed this because its adapter multiplies the value into milliseconds, coercing the string on the way past. It is fixed from both ends. @seamless-auth/core 0.12.1 parses the lifetime before it reaches an adapter and rejects anything that is not a positive whole number of seconds, and auth API v0.7.1 sends a number. Templates v0.8.1 carries the fix through the Fastify starter's committed lockfile, which is what pinned the broken version. The conformance adapter needs no pin change: it has no lockfile, so ^0.12.0 already resolves to the fixed 0.12.1. --- .changeset/ecosystem-bump-july.md | 14 +++++++++++--- src/core/images.ts | 4 ++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.changeset/ecosystem-bump-july.md b/.changeset/ecosystem-bump-july.md index 837c932..da04b8e 100644 --- a/.changeset/ecosystem-bump-july.md +++ b/.changeset/ecosystem-bump-july.md @@ -2,10 +2,10 @@ "seamless-cli": minor --- -Move the scaffold onto the current Seamless ecosystem: auth API `v0.7.0`, admin dashboard `v0.4.0`, -and seamless-templates `v0.8.0` (which carries `@seamless-auth/react` `^0.8.0` in both React +Move the scaffold onto the current Seamless ecosystem: auth API `v0.7.1`, admin dashboard `v0.4.0`, +and seamless-templates `v0.8.1` (which carries `@seamless-auth/react` `^0.8.0` in both React starters, `@seamless-auth/express` `^0.12.0` in the Express starter, and `@seamless-auth/fastify` -`^0.3.0` in the Fastify starter). +`^0.3.1` in the Fastify starter). A scaffolded project can now finish registration without a passkey. Registration used to end on a screen with a single control, leaving anyone who did not want a passkey, or whose device could not @@ -16,6 +16,14 @@ screens can offer what an instance actually has enabled instead of a hardcoded g adapters, and the web templates all had to move together for it to work, which is why this bumps them as a set. +Registration against a scaffolded Fastify API used to fail with a 500 and +`TypeError: option maxAge is invalid: 300`. The auth server sent the registration response's `ttl` +as the string `"300"`, and the Fastify adapter handed it to a cookie library that requires an +integer. The Express starter never showed this, because its adapter multiplies the value into +milliseconds and so coerced the string on the way past. It is fixed from both ends: +`@seamless-auth/core` `0.12.1` parses the lifetime before it reaches an adapter and rejects anything +that is not a positive whole number of seconds, and auth API `v0.7.1` sends the value as a number. + `seamless init` now offers Fastify as a backend, listed as "Fastify (beta)" beside Express. It serves the same surface as the Express starter on the same environment contract, including the admin console at `/console` behind `SERVE_ADMIN_CONSOLE`. Two boot-time fixes land with it: an empty diff --git a/src/core/images.ts b/src/core/images.ts index a0596a2..eac49e3 100644 --- a/src/core/images.ts +++ b/src/core/images.ts @@ -1,6 +1,6 @@ export const POSTGRES_IMAGE = "postgres:17"; -export const SEAMLESS_AUTH_API_VERSION = "v0.7.0"; +export const SEAMLESS_AUTH_API_VERSION = "v0.7.1"; export const SEAMLESS_AUTH_API_IMAGE = `ghcr.io/fells-code/seamless-auth-api:${SEAMLESS_AUTH_API_VERSION}`; @@ -13,4 +13,4 @@ export const SEAMLESS_AUTH_ADMIN_DASHBOARD_IMAGE = `ghcr.io/fells-code/seamless- // SEAMLESS_TEMPLATES_REF, or point at a local checkout with SEAMLESS_TEMPLATES_DIR. export const SEAMLESS_TEMPLATES_REPO = "fells-code/seamless-templates"; -export const SEAMLESS_TEMPLATES_REF = "v0.8.0"; +export const SEAMLESS_TEMPLATES_REF = "v0.8.1"; From d3a97dd7df2ce752ec6a784bd4e8e40582feceb1 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 31 Jul 2026 23:18:19 -0400 Subject: [PATCH 05/11] feat: add per-command help Every command now answers -h / --help with usage, flags, subcommands, and examples scoped to that command, and seamless help prints the same thing. The text lives in one registry (src/commands/helpTopics.ts) that both the full seamless --help output and the per-command output render from, so a flag is documented once. The dispatcher's known-command list comes from there too. The help check runs before a command parses its own arguments, so seamless init -h prints help instead of scaffolding ./-h. A -- separator ends the check, leaving room for a literal -h operand (seamless config set key -- -h). --- .changeset/per-command-help.md | 13 ++ AGENTS.md | 5 + README.md | 15 ++ src/commands/help.test.ts | 65 ++++++- src/commands/help.ts | 255 +++++++++------------------ src/commands/helpTopics.ts | 307 +++++++++++++++++++++++++++++++++ src/core/args.test.ts | 17 +- src/core/args.ts | 10 ++ src/index.test.ts | 63 ++++++- src/index.ts | 58 ++++--- 10 files changed, 610 insertions(+), 198 deletions(-) create mode 100644 .changeset/per-command-help.md create mode 100644 src/commands/helpTopics.ts diff --git a/.changeset/per-command-help.md b/.changeset/per-command-help.md new file mode 100644 index 0000000..b91fedf --- /dev/null +++ b/.changeset/per-command-help.md @@ -0,0 +1,13 @@ +--- +"seamless-cli": minor +--- + +Add per-command help. Every command now answers `-h` / `--help` with usage, flags, subcommands, and +examples scoped to that command (`seamless init -h`, `seamless verify --help`), and +`seamless help ` prints the same thing. The help text lives in one registry +(`src/commands/helpTopics.ts`) that both the full `seamless --help` output and the per-command +output render from, so a flag is documented once and appears in both. + +The help check runs before a command parses its own arguments, so `seamless init -h` prints help +instead of treating `-h` as a project name. A `--` separator ends the check, so a command can still +take a literal `-h` value (`seamless config set key -- -h`). diff --git a/AGENTS.md b/AGENTS.md index 4a52dcb..2178d26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,11 @@ The entry point is [src/index.ts](src/index.ts), which dispatches to a command m `logout`/`whoami`, `sessions`, `config` (system config + OAuth providers), `users`, and `org` all talk to a running instance and are authenticated by the stored session. +- **help** — `seamless --help`, `seamless -h/--help`, and `seamless help ` all + render from the single registry in [src/commands/helpTopics.ts](src/commands/helpTopics.ts) + ([src/commands/help.ts](src/commands/help.ts) does the formatting, and `COMMANDS` there is also + the dispatcher's known-command list). Document a new command or flag in that registry, not in the + help template. `src/index.ts` answers the help flag before a command parses its own args. - **portal** — `login` signs in to the Seamless portal, a separate account from any instance profile. Its session lives beside the profile map in `config.json` and is the only one `init` uses to connect a managed diff --git a/README.md b/README.md index f3aa575..b6b6262 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,21 @@ You’ll be guided through a short setup process where you can choose: --- +## Getting help + +`seamless --help` lists every command, and every command documents itself: + +```bash +seamless init --help +``` + +`-h` is the short form, and `seamless help ` is the spelled-out one, so +`seamless verify -h`, `seamless verify --help`, and `seamless help verify` all print the flags, +subcommands, and examples for `verify` only. If a command takes a value that is literally `-h`, +put it after `--` (`seamless config set key -- -h`). + +--- + ## Connecting to a managed instance If you are signed in to the Seamless portal (`seamless login`) and your account has at least one diff --git a/src/commands/help.test.ts b/src/commands/help.test.ts index a7ce572..2fa13ee 100644 --- a/src/commands/help.test.ts +++ b/src/commands/help.test.ts @@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Loading index.ts first here avoids a circular-import TDZ error that occurs // when help.ts is the first module to pull index.ts in. import "../index.js"; -import { printHelp } from "./help.js"; +import { printCommandHelp, printHelp } from "./help.js"; +import { COMMAND_HELP } from "./helpTopics.js"; describe("printHelp", () => { let logSpy: ReturnType; @@ -26,4 +27,66 @@ describe("printHelp", () => { expect(output).toContain("seamless login"); expect(output).toContain("https://docs.seamlessauth.com"); }); + + it("documents every command", () => { + printHelp(); + + const [output] = logSpy.mock.calls[0] as [string]; + for (const command of COMMAND_HELP) { + for (const usage of command.usage) { + expect(output).toContain(usage); + } + } + }); +}); + +describe("printCommandHelp", () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it.each(COMMAND_HELP.map((c) => c.name))( + "prints usage scoped to %s", + (name) => { + expect(printCommandHelp(name)).toBe(true); + + const [output] = logSpy.mock.calls[0] as [string]; + expect(output).toContain(`seamless ${name} — seamless v`); + expect(output).toContain("USAGE"); + expect(output).toContain("DESCRIPTION"); + expect(output).toContain("https://docs.seamlessauth.com"); + }, + ); + + it("keeps each command's help to that command", () => { + printCommandHelp("check"); + + const [output] = logSpy.mock.calls[0] as [string]; + expect(output).toContain("seamless check"); + expect(output).not.toContain("seamless verify"); + }); + + it("prints the section headings only when a command has several", () => { + printCommandHelp("sessions"); + const [sessions] = logSpy.mock.calls[0] as [string]; + expect(sessions).toContain("sessions revoke "); + + logSpy.mockClear(); + printCommandHelp("whoami"); + const [whoami] = logSpy.mock.calls[0] as [string]; + expect(whoami.split("DESCRIPTION")[1].trimStart()).toMatch( + /^Show the identity/, + ); + }); + + it("reports an unknown topic instead of printing an empty one", () => { + expect(printCommandHelp("frobnicate")).toBe(false); + expect(logSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/help.ts b/src/commands/help.ts index 8363f51..48a9a42 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,185 +1,60 @@ import { VERSION } from "../index.js"; +import { + COMMAND_HELP, + findCommandHelp, + type CommandHelp, +} from "./helpTopics.js"; + +const DIVIDER = "────────────────────────────────────────────"; + +const DOCS_URL = "https://docs.seamlessauth.com"; + +function indent(text: string, spaces: number): string { + const pad = " ".repeat(spaces); + return text + .split("\n") + .map((line) => (line.length > 0 ? pad + line : line)) + .join("\n"); +} + +function renderSections(command: CommandHelp, headingIndent: number): string { + return command.sections + .map( + (section) => + `${indent(section.heading, headingIndent)}\n${indent( + section.body, + headingIndent + 2, + )}`, + ) + .join("\n\n"); +} export function printHelp() { + const usage = COMMAND_HELP.flatMap((c) => c.usage) + .concat(["seamless --help", "seamless --help", "seamless --version"]) + .map((line) => ` ${line}`) + .join("\n"); + + const commands = COMMAND_HELP.map((c) => renderSections(c, 2)).join("\n\n"); + console.log(` seamless v${VERSION} Seamless CLI — scaffold and manage full-stack authentication systems. -──────────────────────────────────────────── +${DIVIDER} USAGE - seamless init [project-name] [--] - seamless check - seamless verify [--api-only] [--filter=] [--keep-up] - seamless profile - seamless login [identifier] [--identifier ] [--local] - seamless apps - seamless whoami [--profile ] - seamless logout [--all] [--profile ] - seamless sessions [list] - seamless sessions revoke - seamless config - seamless users - seamless org - seamless org members - seamless --help - seamless --version - -──────────────────────────────────────────── - -COMMANDS - - init [project-name] - Scaffold a new Seamless Auth project +${usage} - Without a name: - • Creates project in current directory +${DIVIDER} - With a name: - • Creates new directory +COMMANDS - With an example flag (e.g. --oauth): - • Scaffolds that use-case starter and skips the web prompt - • --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 +${commands} - profile - Manage the Seamless Auth instances the CLI targets, stored as named - profiles in ~/.config/seamless/config.json (respects XDG_CONFIG_HOME). - A profile is an instance you administer, which is a different account from - your portal login: it lives in that instance's own user pool. - - profile list - • Show configured profiles; the active one is marked with * - - profile add --instance-url [--identifier-type email|phone] - • Create or update a profile (prompts interactively if flags are omitted) - - profile use - • Switch the active profile for subsequent commands - - profile remove - • Delete a profile - - profile login [name] [identifier] [--identifier ] [--local] - • Log in to that instance so users, config, org, and sessions can run - • Defaults to the active profile, and does not change which one is active - - The active profile can also be chosen per command with --profile or - the SEAMLESS_PROFILE environment variable. - - login [identifier] - Sign in to the Seamless portal, the managed control plane. This is the - account that authorizes connecting a project to a managed application, and - it needs no profile. Prompts for the identifier (or pass it positionally or - with --identifier) and the emailed code, then stores the session in the OS - keychain. Use seamless profile login to sign in to an auth instance. - - --local - • For a local portal only. Asks the instance to return the OTP in the - response instead of emailing it, and verifies with it automatically. - • Requires the auth API to run outside production with - ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=true. - • Point SEAMLESS_PORTAL_AUTH_URL at a local instance to develop against it. - - apps - Show the managed applications your portal account owns. Requires a portal - session (seamless login), not an instance profile. - - apps list [--json] - • Table of reference, name, plan, status, and instance URL - • The reference is the infra id, or the id before one is assigned - • Applications still provisioning are listed with (provisioning) - - apps get [--json] - • Detail for one application, including the console URL, owners, and - whether a service token has been issued (masked, never the live value) - - whoami - Show the identity behind your portal session (sub, email, roles), alongside - the instance URL. Pass --profile to report an instance session - instead. Fails cleanly if not logged in. - - logout [--all] - End your portal session and clear the local keychain tokens. Pass - --profile to log out of an instance instead. - --all revokes every session for the user before clearing local tokens. - - sessions [list] - List the active sessions for the logged-in user, with the current session - marked. Shows the session id, device or user agent, IP, and last-used time. - - sessions revoke - Revoke one session by id, or every session with --all. Revoking the current - session (or --all) prompts for confirmation and then clears local tokens. - - config - Read and write the instance system configuration (requires an admin role). - - config get [key] [--json] - • Print the whole config or a single key - - config set - • Update one key; the value is parsed as JSON, falling back to a string - (for example: config set access_token_ttl 15m, - config set login_methods '["email_otp","passkey"]') - - config roles [--json] - • List the instance's available roles - - config diff - • Show how a local JSON config file differs from the instance - - config apply [--dry-run] - • Apply a local JSON config file after a confirmation prompt - - config oauth-providers - • Manage OAuth providers one at a time. Client secrets stay server-side, - referenced by clientSecretEnv; the secret value is never sent. - (for example: config oauth-providers add --file google.json, - config oauth-providers update google '{"enabled":false}', - config oauth-providers remove google) - - users - Admin user management (requires an admin role). - - users list [--limit ] [--offset ] [--json] - • List users - users delete - • Delete a user (asks for confirmation) - users credentials [--json] - • Show a user's registered credentials - users prepare-device-replacement [--keep-sessions] [--keep-passkeys] [--keep-totp] - • Admin-assisted account recovery (needs an elevated session) - - org , org members - Admin organization management (requires an admin role). - - org list [--json] - org create [--slug ] - org get [--json] - org update [--name ] [--slug ] - org members list [--json] - org members add (--user | --email ) [--roles a,b] [--scopes a,b] - org members update [--roles a,b] [--scopes a,b] - org members remove - - check - Validate project setup, Docker, and running services - - verify [--local] [--api-only] [--filter=] [--keep-up] - Stand up the auth stack and run the conformance suite across the API and - the cookie (adapter) paths. Requires Docker. Builds the auth server from - a sibling seamless-auth-api checkout (override with SEAMLESS_API_DIR). - - --local builds and links the local @seamless-auth/* SDK source (sibling - seamless-auth-server, override with SEAMLESS_SERVER_DIR) instead of the - published npm packages — so you can catch SDK regressions before publishing. - -──────────────────────────────────────────── +${DIVIDER} GETTING STARTED @@ -189,7 +64,7 @@ GETTING STARTED → That address is the owner, so it becomes an admin -──────────────────────────────────────────── +${DIVIDER} WHAT YOU GET @@ -199,7 +74,7 @@ WHAT YOU GET • Admin dashboard (Docker or source) • Docker Compose setup -──────────────────────────────────────────── +${DIVIDER} EXAMPLES @@ -215,11 +90,49 @@ EXAMPLES seamless check → Validate your project -──────────────────────────────────────────── +${DIVIDER} DOCS - https://docs.seamlessauth.com + ${DOCS_URL} + +`); +} + +// Returns false when the command has no help entry, so the caller can fall +// back to the unknown-command path instead of printing an empty topic. +export function printCommandHelp(name: string): boolean { + const command = findCommandHelp(name); + if (!command) return false; + + const usage = command.usage.map((line) => ` ${line}`).join("\n"); + + // The heading only earns its place when a command has more than one section + // (sessions list vs sessions revoke); otherwise it just repeats the usage. + const description = + command.sections.length === 1 + ? indent(command.sections[0].body, 2) + : renderSections(command, 2); + + const examples = command.examples?.length + ? `\nEXAMPLES\n\n${command.examples + .map((example) => indent(example, 2)) + .join("\n\n")}\n` + : ""; + + console.log(` +seamless ${command.name} — seamless v${VERSION} + +USAGE +${usage} + +DESCRIPTION + +${description} +${examples} +Docs: ${DOCS_URL} `); + + return true; } diff --git a/src/commands/helpTopics.ts b/src/commands/helpTopics.ts new file mode 100644 index 0000000..05aca29 --- /dev/null +++ b/src/commands/helpTopics.ts @@ -0,0 +1,307 @@ +export interface HelpSection { + heading: string; + body: string; +} + +export interface CommandHelp { + name: string; + usage: string[]; + sections: HelpSection[]; + examples?: string[]; +} + +// One entry per dispatched command. Both the full `seamless --help` output and +// the per-command `seamless --help` output are rendered from this, so +// a flag documented once shows up in both places. +export const COMMAND_HELP: CommandHelp[] = [ + { + name: "init", + usage: ["seamless init [project-name] [--]"], + sections: [ + { + heading: "init [project-name]", + body: `Scaffold a new Seamless Auth project + +Without a name: + • Creates project in current directory + +With a name: + • Creates new directory + +With an example flag (e.g. --oauth): + • Scaffolds that use-case starter and skips the web prompt + • --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 + +--profile + • Use that profile instead of the active one + +--app + • Connect the project to that managed application (needs a portal + session from seamless login) + +--local + • Point the generated project at a locally running auth stack`, + }, + ], + examples: [ + `seamless init + → Interactive setup in current directory`, + `seamless init my-app + → Create new project in ./my-app`, + `seamless init --oauth my-app + → Create ./my-app from the OAuth example starter`, + ], + }, + { + name: "check", + usage: ["seamless check"], + sections: [ + { + heading: "check", + body: `Validate project setup, Docker, and running services`, + }, + ], + examples: [ + `seamless check + → Validate your project`, + ], + }, + { + name: "verify", + usage: [ + "seamless verify [--local] [--api-only] [--no-react] [--filter=] [--keep-up]", + ], + sections: [ + { + heading: "verify [--local] [--api-only] [--filter=] [--keep-up]", + body: `Stand up the auth stack and run the conformance suite across the API and +the cookie (adapter) paths. Requires Docker. Builds the auth server from +a sibling seamless-auth-api checkout (override with SEAMLESS_API_DIR). + +--local + • Builds and links the local @seamless-auth/* SDK source (sibling + seamless-auth-server, override with SEAMLESS_SERVER_DIR) instead of the + published npm packages, so you can catch SDK regressions before + publishing + +--api-only + • Run the API layer only, skipping the adapter and browser layers + +--no-react + • Skip the browser layer but keep the adapter layer + +--filter= + • Run only the flows matching (the = form; a space-separated + --filter is not parsed) + +--keep-up + • Leave the Docker stack running after the suite finishes`, + }, + ], + examples: [ + `seamless verify --api-only + → Fast pass against the API layer only`, + `seamless verify --local --filter=passkey + → Run the passkey flows against locally built SDK source`, + ], + }, + { + name: "profile", + usage: ["seamless profile "], + sections: [ + { + heading: "profile ", + body: `Manage the Seamless Auth instances the CLI targets, stored as named +profiles in ~/.config/seamless/config.json (respects XDG_CONFIG_HOME). +A profile is an instance you administer, which is a different account from +your portal login: it lives in that instance's own user pool. + +profile list + • Show configured profiles; the active one is marked with * + +profile add --instance-url [--identifier-type email|phone] + • Create or update a profile (prompts interactively if flags are omitted) + +profile use + • Switch the active profile for subsequent commands + +profile remove + • Delete a profile + +profile login [name] [identifier] [--identifier ] [--local] + • Log in to that instance so users, config, org, and sessions can run + • Defaults to the active profile, and does not change which one is active + +The active profile can also be chosen per command with --profile or +the SEAMLESS_PROFILE environment variable.`, + }, + ], + }, + { + name: "login", + usage: ["seamless login [identifier] [--identifier ] [--local]"], + sections: [ + { + heading: "login [identifier]", + body: `Sign in to the Seamless portal, the managed control plane. This is the +account that authorizes connecting a project to a managed application, and +it needs no profile. Prompts for the identifier (or pass it positionally or +with --identifier) and the emailed code, then stores the session in the OS +keychain. Use seamless profile login to sign in to an auth instance. + +--local + • For a local portal only. Asks the instance to return the OTP in the + response instead of emailing it, and verifies with it automatically. + • Requires the auth API to run outside production with + ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=true. + • Point SEAMLESS_PORTAL_AUTH_URL at a local instance to develop against it.`, + }, + ], + }, + { + name: "apps", + usage: ["seamless apps "], + sections: [ + { + heading: "apps ", + body: `Show the managed applications your portal account owns. Requires a portal +session (seamless login), not an instance profile. + +apps list [--json] + • Table of reference, name, plan, status, and instance URL + • The reference is the infra id, or the id before one is assigned + • Applications still provisioning are listed with (provisioning) + +apps get [--json] + • Detail for one application, including the console URL, owners, and + whether a service token has been issued (masked, never the live value)`, + }, + ], + }, + { + name: "whoami", + usage: ["seamless whoami [--profile ]"], + sections: [ + { + heading: "whoami", + body: `Show the identity behind your portal session (sub, email, roles), alongside +the instance URL. Pass --profile to report an instance session +instead. Fails cleanly if not logged in.`, + }, + ], + }, + { + name: "logout", + usage: ["seamless logout [--all] [--profile ]"], + sections: [ + { + heading: "logout [--all]", + body: `End your portal session and clear the local keychain tokens. Pass +--profile to log out of an instance instead. +--all revokes every session for the user before clearing local tokens.`, + }, + ], + }, + { + name: "sessions", + usage: ["seamless sessions [list]", "seamless sessions revoke "], + sections: [ + { + heading: "sessions [list]", + body: `List the active sessions for the logged-in user, with the current session +marked. Shows the session id, device or user agent, IP, and last-used time.`, + }, + { + heading: "sessions revoke ", + body: `Revoke one session by id, or every session with --all. Revoking the current +session (or --all) prompts for confirmation and then clears local tokens.`, + }, + ], + }, + { + name: "config", + usage: ["seamless config "], + sections: [ + { + heading: "config ", + body: `Read and write the instance system configuration (requires an admin role). + +config get [key] [--json] + • Print the whole config or a single key + +config set + • Update one key; the value is parsed as JSON, falling back to a string + (for example: config set access_token_ttl 15m, + config set login_methods '["email_otp","passkey"]') + +config roles [--json] + • List the instance's available roles + +config diff + • Show how a local JSON config file differs from the instance + +config apply [--dry-run] + • Apply a local JSON config file after a confirmation prompt + +config oauth-providers + • Manage OAuth providers one at a time. Client secrets stay server-side, + referenced by clientSecretEnv; the secret value is never sent. + (for example: config oauth-providers add --file google.json, + config oauth-providers update google '{"enabled":false}', + config oauth-providers remove google)`, + }, + ], + }, + { + name: "users", + usage: [ + "seamless users ", + ], + sections: [ + { + heading: "users ", + body: `Admin user management (requires an admin role). + +users list [--limit ] [--offset ] [--json] + • List users +users delete + • Delete a user (asks for confirmation) +users credentials [--json] + • Show a user's registered credentials +users prepare-device-replacement [--keep-sessions] [--keep-passkeys] [--keep-totp] + • Admin-assisted account recovery (needs an elevated session)`, + }, + ], + }, + { + name: "org", + usage: [ + "seamless org ", + "seamless org members ", + ], + sections: [ + { + heading: + "org , org members ", + body: `Admin organization management (requires an admin role). + +org list [--json] +org create [--slug ] +org get [--json] +org update [--name ] [--slug ] +org members list [--json] +org members add (--user | --email ) [--roles a,b] [--scopes a,b] +org members update [--roles a,b] [--scopes a,b] +org members remove `, + }, + ], + }, +]; + +export const COMMANDS = COMMAND_HELP.map((c) => c.name); + +export function findCommandHelp(name: string): CommandHelp | undefined { + return COMMAND_HELP.find((c) => c.name === name); +} diff --git a/src/core/args.test.ts b/src/core/args.test.ts index 3d14dcc..04451e0 100644 --- a/src/core/args.test.ts +++ b/src/core/args.test.ts @@ -1,5 +1,20 @@ import { describe, expect, it } from "vitest"; -import { extractFlag } from "./args.js"; +import { extractFlag, hasHelpFlag } from "./args.js"; + +describe("hasHelpFlag", () => { + it.each(["-h", "--help"])("detects %s", (flag) => { + expect(hasHelpFlag(["sub", flag])).toBe(true); + }); + + it("returns false when no help flag is present", () => { + expect(hasHelpFlag(["set", "key", "value"])).toBe(false); + expect(hasHelpFlag([])).toBe(false); + }); + + it("treats a help flag after -- as an operand, not a request for help", () => { + expect(hasHelpFlag(["set", "key", "--", "-h"])).toBe(false); + }); +}); describe("extractFlag", () => { it("extracts a --flag value pair and removes both from rest", () => { diff --git a/src/core/args.ts b/src/core/args.ts index cdef9b7..70a76c2 100644 --- a/src/core/args.ts +++ b/src/core/args.ts @@ -1,3 +1,13 @@ +// A literal `--` ends flag parsing, so a later -h belongs to the command's +// operands (a config value, say) rather than being a request for help. +export function hasHelpFlag(args: string[]): boolean { + for (const arg of args) { + if (arg === "--") return false; + if (arg === "-h" || arg === "--help") return true; + } + return false; +} + export interface ExtractedFlag { value?: string; rest: string[]; diff --git a/src/index.test.ts b/src/index.test.ts index 360d053..d7a017e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -6,7 +6,10 @@ import pkg from "../package.json" with { type: "json" }; // never touches real command logic. args.js stays real (extractFlag is pure). vi.mock("./commands/init.js", () => ({ runCLI: vi.fn() })); vi.mock("./commands/check.js", () => ({ runCheck: vi.fn() })); -vi.mock("./commands/help.js", () => ({ printHelp: vi.fn() })); +vi.mock("./commands/help.js", () => ({ + printHelp: vi.fn(), + printCommandHelp: vi.fn((name: string) => name !== "frobnicate"), +})); vi.mock("./commands/verify.js", () => ({ runVerify: vi.fn() })); vi.mock("./commands/profile.js", () => ({ runProfile: vi.fn() })); vi.mock("./commands/login.js", () => ({ runLogin: vi.fn() })); @@ -67,6 +70,64 @@ describe("index dispatcher", () => { expect(logSpy).toHaveBeenCalledWith(pkg.version); }); + it.each(["-h", "--help"])( + "prints command help for %s and skips the command", + async (flag) => { + await dispatch(["verify", flag]); + + const { printCommandHelp, printHelp } = await import("./commands/help.js"); + expect(printCommandHelp).toHaveBeenCalledWith("verify"); + expect(printHelp).not.toHaveBeenCalled(); + + const { runVerify } = await import("./commands/verify.js"); + expect(runVerify).not.toHaveBeenCalled(); + }, + ); + + // `-h` is not a project name: init parses unrecognized args positionally, so + // the help check has to win before that parsing runs. + it("prints command help for init rather than scaffolding ./-h", async () => { + await dispatch(["init", "-h"]); + + const { printCommandHelp } = await import("./commands/help.js"); + expect(printCommandHelp).toHaveBeenCalledWith("init"); + + const { runCLI } = await import("./commands/init.js"); + expect(runCLI).not.toHaveBeenCalled(); + }); + + it("passes a help flag through when it follows --", async () => { + await dispatch(["config", "set", "key", "--", "-h"]); + + const { printCommandHelp } = await import("./commands/help.js"); + expect(printCommandHelp).not.toHaveBeenCalled(); + + const { runConfig } = await import("./commands/config.js"); + expect(runConfig).toHaveBeenCalledWith(["set", "key", "--", "-h"]); + }); + + it("dispatches help to that command's help", async () => { + await dispatch(["help", "org"]); + + const { printCommandHelp } = await import("./commands/help.js"); + expect(printCommandHelp).toHaveBeenCalledWith("org"); + }); + + it("prints the full help for a bare help command", async () => { + await dispatch(["help"]); + + const { printHelp } = await import("./commands/help.js"); + expect(printHelp).toHaveBeenCalledTimes(1); + }); + + it("rejects an unknown help topic", async () => { + await dispatch(["help", "frobnicate"]); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errors = errSpy.mock.calls.map((c) => c[0] as string).join("\n"); + expect(errors).toContain('Unknown command "frobnicate"'); + }); + it("dispatches init with parsed project name, aliases, profile and app flags", async () => { await dispatch(["init", "my-app", "--local", "--oauth", "--profile", "prod", "--app", "app1"]); const { runCLI } = await import("./commands/init.js"); diff --git a/src/index.ts b/src/index.ts index aa385ba..993db2e 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ #!/usr/bin/env node import { runCLI } from "./commands/init.js"; -import { extractFlag } from "./core/args.js"; +import { extractFlag, hasHelpFlag } from "./core/args.js"; import { runCheck } from "./commands/check.js"; -import { printHelp } from "./commands/help.js"; +import { printCommandHelp, printHelp } from "./commands/help.js"; +import { COMMANDS } from "./commands/helpTopics.js"; import pkg from "../package.json" with { type: "json" }; import { runVerify } from "./commands/verify.js"; import { runProfile } from "./commands/profile.js"; @@ -20,21 +21,6 @@ import kleur from "kleur"; export const VERSION = pkg.version; -const COMMANDS = [ - "init", - "check", - "verify", - "profile", - "login", - "apps", - "whoami", - "logout", - "sessions", - "config", - "users", - "org", -]; - const args = process.argv.slice(2); const command = args[0]; @@ -55,6 +41,24 @@ async function main() { return; } + // `seamless help [command]` is the spelled-out form of `--help`. + if (command === "help") { + const topic = args[1]; + if (!topic) { + printHelp(); + return; + } + if (printCommandHelp(topic)) return; + unknownCommand(topic); + return; + } + + // Every command answers -h / --help itself, ahead of its own arg parsing. + if (COMMANDS.includes(command) && hasHelpFlag(args.slice(1))) { + printCommandHelp(command); + return; + } + if (command === "init") { const profileFlag = extractFlag(args.slice(1), "profile"); const appFlag = extractFlag(profileFlag.rest, "app"); @@ -129,18 +133,24 @@ async function main() { return; } - // An unrecognized command used to be treated as a project name and scaffolded, - // which made every typo create a directory with no indication the command was - // not understood. Scaffolding is `init` and nothing else. - console.error(kleur.red(`Unknown command "${command}".`)); - if (!command.startsWith("-")) { + unknownCommand(command); +} + +// An unrecognized command used to be treated as a project name and scaffolded, +// which made every typo create a directory with no indication the command was +// not understood. Scaffolding is `init` and nothing else. +function unknownCommand(name: string) { + console.error(kleur.red(`Unknown command "${name}".`)); + if (!name.startsWith("-")) { console.error( kleur.dim("To scaffold a project, run: ") + - kleur.cyan(`seamless init ${command}`), + kleur.cyan(`seamless init ${name}`), ); } console.error(kleur.dim(`Commands: ${COMMANDS.join(", ")}`)); - console.error(kleur.dim("Run seamless --help for details.")); + console.error( + kleur.dim("Run seamless --help, or seamless --help, for details."), + ); process.exit(1); } From 1395d2eed5da73d780ac71ca324a38e3d765d918 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 31 Jul 2026 23:42:42 -0400 Subject: [PATCH 06/11] 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 b6b6262..dcdac7b 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; From e35bea1fa1e62b1a058fca9ede73d906ac0c9251 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 31 Jul 2026 23:50:12 -0400 Subject: [PATCH 07/11] feat: add a non-interactive seamless init `--yes` (-y) answers every question with the option the prompt marks as recommended, so a scaffold runs from CI, a Dockerfile, or a script with no terminal attached. Each question also gets its own flag, honored with or without --yes: --web and --api choose the starters (by id or alias), --email sets the owner who becomes the admin, --auth picks how the auth server runs, and --admin picks where the console is hosted. Values are validated against the registry and the known modes in runCLI, before a directory is created. --yes stops rather than guessing in three places. Managed or local needs --app or --local, because that decides where the project's auth lives for good. Scaffolding into a non-empty directory needs --force, because starter files overwrite anything with the same name. Rotating an application's existing service token needs --force, because it breaks whatever is deployed on the old one. init's argument parsing moves into parseInitArgs so the new switches are not mistaken for template flags or for the project name. Closes #152 --- .changeset/init-non-interactive.md | 23 +++ AGENTS.md | 7 + README.md | 33 ++++ src/commands/helpTopics.ts | 37 +++- src/commands/init.test.ts | 216 ++++++++++++++++++++- src/commands/init.ts | 219 ++++++++++++++++++--- src/index.test.ts | 55 +++++- src/index.ts | 62 ++++-- src/prompts/projectSetup.test.ts | 106 +++++++++++ src/prompts/projectSetup.ts | 295 +++++++++++++++++++---------- 10 files changed, 904 insertions(+), 149 deletions(-) create mode 100644 .changeset/init-non-interactive.md diff --git a/.changeset/init-non-interactive.md b/.changeset/init-non-interactive.md new file mode 100644 index 0000000..38db869 --- /dev/null +++ b/.changeset/init-non-interactive.md @@ -0,0 +1,23 @@ +--- +"seamless-cli": minor +--- + +Add a non-interactive `seamless init`. `--yes` (`-y`) answers every question with the option the +prompt marks as recommended, so a scaffold runs from CI, a Dockerfile, or a script with no terminal +attached: + +```bash +seamless init my-app --local --yes --email=you@example.com +``` + +Each question also gets its own flag, honored with or without `--yes`: `--web=` and +`--api=` choose the starters, `--email=
` sets the owner who becomes the admin, +`--auth=` picks how the auth server runs, and `--admin=` picks +where the admin console is hosted. Unspecified values fall back to the recommended option, except +the owner email, which has no safe default and is taken from `--email` or the portal session. + +`--yes` deliberately stops rather than guessing in three places. Choosing between a managed +application and a local stack needs `--app ` or `--local`. Scaffolding into a directory that is +not empty needs `--force`, since starter files overwrite anything with the same name. Rotating a +managed application's existing service token needs `--force` too, because it breaks whatever is +already deployed on the old one. diff --git a/AGENTS.md b/AGENTS.md index 0a042bc..027f0d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,13 @@ The entry point is [src/index.ts](src/index.ts), which dispatches to a command m 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. + - `--yes` runs the whole thing without prompting: every question has a flag (`--web`, `--api`, + `--email`, `--auth`, `--admin`) and anything unspecified falls back to the option the prompt + marks "(recommended)". `--yes` is never enough for a destructive step: overwriting a non-empty + directory and rotating an existing service token both require `--force`, and choosing between a + managed application and a local stack requires `--app` or `--local`. Flag parsing lives in + `parseInitArgs` ([src/index.ts](src/index.ts)); everything it produces is validated in `runCLI` + before a directory is created. - **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. diff --git a/README.md b/README.md index dcdac7b..4549a60 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,39 @@ registry `init` does, so `SEAMLESS_TEMPLATES_DIR` and `SEAMLESS_TEMPLATES_REF` a --- +## Scripting init + +`--yes` answers every question with the recommended option instead of prompting, so `init` runs from +CI, a Dockerfile, or a script with no terminal attached: + +```bash +seamless init my-app --local --yes --email=you@example.com +``` + +Each question also has its own flag, honored with or without `--yes`: + +| Flag | Question | Default under `--yes` | +| --- | --- | --- | +| `--web=` | Web example | first selectable web template | +| `--api=` | Backend framework | first selectable api template | +| `--email=
` | Owner email (becomes the admin) | required | +| `--auth=` | How the auth server runs | `docker` | +| `--admin=` | Where the admin console is hosted | `api` | + +Two things `--yes` deliberately will not decide for you: + +- **Managed or local.** With a portal session and neither `--local` nor `--app `, `init` stops + rather than guessing where the project's auth lives. +- **Anything destructive.** Scaffolding into a directory that is not empty (starter files overwrite + anything with the same name) and rotating a managed application's existing service token (which + breaks whatever is deployed on the old one) both take `--force`, not `--yes`. + +`--email` has no safe default, so under `--yes` it is required unless `seamless login` has left a +portal session to take it from. Templates that would prompt for OAuth provider credentials are +scaffolded with none configured; add them afterwards with `seamless config oauth-providers add`. + +--- + ## 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 158c83b..617dba9 100644 --- a/src/commands/helpTopics.ts +++ b/src/commands/helpTopics.ts @@ -16,7 +16,10 @@ export interface CommandHelp { export const COMMAND_HELP: CommandHelp[] = [ { name: "init", - usage: ["seamless init [project-name] [--]"], + usage: [ + "seamless init [project-name] [--