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 63f48e4..ddc041a 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); }