diff --git a/.changeset/init-requires-tty.md b/.changeset/init-requires-tty.md new file mode 100644 index 0000000..907a72a --- /dev/null +++ b/.changeset/init-requires-tty.md @@ -0,0 +1,18 @@ +--- +"seamless-cli": patch +--- + +`seamless init` no longer hangs when it has no terminal to prompt on. Run on a pipe, it used to +render a prompt nobody could answer and wait forever, so a CI step failed only when its job timed +out. It now stops on the first unanswered question and names the flag that answers it: + +```text +$ seamless init --local < /dev/null +Error: "Web example" needs an interactive terminal, and this run does not have one. +Pass --web= to choose one (see `seamless templates list`), or --yes to take the +recommended template. +``` + +A run whose answers all come from flags is unaffected and works the same on a pipe as on a +terminal. A terminal too narrow to render a prompt (a pty allocated without a size reports one +column, which used to print one character per line) now warns instead of just looking broken. diff --git a/AGENTS.md b/AGENTS.md index 027f0d3..8c899e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,11 @@ The entry point is [src/index.ts](src/index.ts), which dispatches to a command m 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. + - Every prompt in the init flow is fronted by `requireInteractive` + ([src/core/tty.ts](src/core/tty.ts)), so a run without a TTY on stdin fails naming the flag that + answers the question instead of rendering a prompt nobody can answer. When adding a prompt to + this flow, guard it the same way. The other commands that prompt (`login`, `profile`, + `config apply`, `users delete`, `sessions revoke`) are not guarded yet, see #153. - **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 842b4b9..02531e4 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,18 @@ Two things `--yes` deliberately will not decide for you: 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`. +Without a terminal on stdin, `init` will not render a prompt nobody can answer. It stops on the +first unanswered question and names the flag that answers it: + +```text +$ seamless init --local < /dev/null +Error: "Web example" needs an interactive terminal, and this run does not have one. +Pass --web= to choose one (see `seamless templates list`), or --yes to take the +recommended template. +``` + +A fully flagged run works the same on a pipe as it does on a terminal. + --- ## What gets created diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index 771ccd4..dcf934d 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -195,10 +195,16 @@ function app(over: Record = {}) { }; } +// Captured before any test flips it, so a suite run leaves the process as it +// found it. +const ORIGINAL_TTY = process.stdin.isTTY; + let logs: string[]; beforeEach(() => { vi.clearAllMocks(); + // The prompt paths refuse to run without a terminal, and vitest has none. + process.stdin.isTTY = true; logs = []; vi.spyOn(console, "log").mockImplementation((msg?: unknown) => { logs.push(String(msg ?? "")); @@ -216,6 +222,7 @@ beforeEach(() => { }); afterEach(() => { + process.stdin.isTTY = ORIGINAL_TTY; vi.restoreAllMocks(); }); @@ -1464,3 +1471,62 @@ describe("non-interactive init (--yes)", () => { expect(confirm).not.toHaveBeenCalled(); }); }); + +describe("init without a terminal", () => { + beforeEach(() => { + process.stdin.isTTY = false; + vi.mocked(createPortalClient).mockRejectedValue( + new ReauthRequiredError("no session"), + ); + vi.mocked(openTemplateSource).mockResolvedValue(makeSource() as never); + vi.mocked(generateDockerCompose).mockResolvedValue({} as never); + }); + + // The prompts used to render to a pipe nobody could answer and wait forever, + // so a CI step hung until its job timed out. + it("fails fast rather than asking a question nobody can answer", async () => { + vi.mocked(fs.readdirSync).mockReturnValue(["src"] as never); + + await expect(runCLI(undefined, [], { local: true })).rejects.toThrow( + /This directory is not empty.*needs an interactive terminal/s, + ); + expect(chooseExistingDirectoryAction).not.toHaveBeenCalled(); + }); + + it("refuses the managed-or-local question too", async () => { + vi.mocked(createPortalClient).mockResolvedValue({} as never); + vi.mocked(listApplications).mockResolvedValue([app()] as never); + + await expect(runCLI(undefined, [])).rejects.toThrow( + /How should this project get its auth\?.*needs an interactive terminal/s, + ); + expect(chooseScaffoldTarget).not.toHaveBeenCalled(); + }); + + it("points at the flag that answers the question it stopped on", async () => { + vi.mocked(fs.readdirSync).mockReturnValue(["src"] as never); + + await expect(runCLI(undefined, [], { local: true })).rejects.toThrow( + /--yes --force/, + ); + }); + + it("runs to completion when every question is answered by a flag", async () => { + vi.mocked(runProjectSetupPrompts).mockResolvedValue({ + webTemplateId: "web-basic", + apiTemplateId: "api-express", + authMode: "docker", + adminMode: "api", + useDocker: true, + ownerEmail: "dev@example.com", + } as never); + + await expect( + runCLI(undefined, [], { + local: true, + yes: true, + email: "dev@example.com", + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/commands/init.ts b/src/commands/init.ts index 8e8a05d..09000db 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -38,6 +38,7 @@ import { type ScaffoldTarget, } from "../prompts/initMode.js"; import { CancelledError, orCancel } from "../core/cancel.js"; +import { requireInteractive, warnOnUnusableWidth } from "../core/tty.js"; import type { CollectedOAuthProvider } from "../core/oauthProviders.js"; import { createPortalClient, @@ -100,6 +101,10 @@ export async function runCLI( ); } + if (!opts.yes) { + warnOnUnusableWidth((message) => console.log(kleur.yellow(message))); + } + const openSource = lazyTemplateSource(); // Every flag is validated against the registry and the known values before a @@ -245,6 +250,10 @@ async function scaffold( "Could not reach the Seamless control plane, and --yes will not silently scaffold a local stack instead. Re-run with --local to scaffold self-hosted.", ); } + requireInteractive( + "Could not reach the Seamless control plane. Scaffold a local stack instead?", + "Pass --local to scaffold a self-hosted stack.", + ); await confirmLocalFallback(); } else if (fallbackReason === "no-session") { console.log( @@ -265,7 +274,13 @@ async function resolveExistingDirectoryAction( canConnect: boolean, opts: InitOptions, ): Promise { - if (!opts.yes) return chooseExistingDirectoryAction(canConnect); + if (!opts.yes) { + requireInteractive( + "This directory is not empty. What would you like to do?", + "Pass --yes --force to scaffold here anyway, or --app to connect the existing project to a managed application.", + ); + return chooseExistingDirectoryAction(canConnect); + } if (!opts.force) { throw new Error( @@ -285,7 +300,13 @@ async function resolveScaffoldTarget( appCount: number, opts: InitOptions, ): Promise { - if (!opts.yes) return chooseScaffoldTarget(appCount); + if (!opts.yes) { + requireInteractive( + "How should this project get its auth?", + "Pass --app to connect a managed application, or --local to scaffold a self-hosted stack.", + ); + return chooseScaffoldTarget(appCount); + } throw new Error( "You are logged in, so --yes will not guess between a managed application and a local stack. Pass --app to connect one of your managed applications, or --local to scaffold a self-hosted stack.", @@ -476,6 +497,10 @@ async function scaffoldLocal( ), ); } else { + requireInteractive( + "Which OAuth providers would you like to configure?", + "Pass --yes to scaffold with none configured, then add them with `seamless config oauth-providers add`.", + ); oauthProviders = await runOAuthSetupPrompts(); } } @@ -668,6 +693,10 @@ async function issueServiceToken( ), ); } else { + requireInteractive( + `"${app.name}" already has a service token. Issue a new one?`, + "Pass --force to rotate it, which invalidates the existing token.", + ); const proceed = orCancel( await confirm({ message: `"${app.name}" already has a service token. Issuing a new one invalidates the existing token. Continue?`, diff --git a/src/core/tty.test.ts b/src/core/tty.test.ts new file mode 100644 index 0000000..ef1f653 --- /dev/null +++ b/src/core/tty.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { isInteractive, requireInteractive, warnOnUnusableWidth } from "./tty.js"; + +const ORIGINAL_TTY = process.stdin.isTTY; +const ORIGINAL_COLUMNS = process.stdout.columns; + +beforeEach(() => { + process.stdin.isTTY = true; + process.stdout.columns = 120; +}); + +afterEach(() => { + process.stdin.isTTY = ORIGINAL_TTY; + process.stdout.columns = ORIGINAL_COLUMNS; + vi.restoreAllMocks(); +}); + +describe("isInteractive", () => { + it("is true only for a TTY stdin", () => { + expect(isInteractive()).toBe(true); + process.stdin.isTTY = false; + expect(isInteractive()).toBe(false); + }); +}); + +describe("requireInteractive", () => { + it("allows a question when a terminal is attached", () => { + expect(() => requireInteractive("Pick one?", "Pass --yes.")).not.toThrow(); + }); + + it("names the question and the way around it when there is no terminal", () => { + process.stdin.isTTY = false; + + expect(() => requireInteractive("Pick one?", "Pass --yes.")).toThrow( + /"Pick one\?" needs an interactive terminal.*Pass --yes\./s, + ); + }); +}); + +describe("warnOnUnusableWidth", () => { + it("says nothing at a normal width", () => { + const warn = vi.fn(); + warnOnUnusableWidth(warn); + expect(warn).not.toHaveBeenCalled(); + }); + + // A pty allocated without a size reports one column and renders the prompts + // one character per line, which reads as a broken CLI rather than a bad size. + it("warns when the terminal is too narrow to render a prompt", () => { + process.stdout.columns = 1; + const warn = vi.fn(); + + warnOnUnusableWidth(warn); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("1 columns")); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("--yes")); + }); + + it("says nothing when there is no terminal at all", () => { + process.stdin.isTTY = false; + process.stdout.columns = 1; + const warn = vi.fn(); + + warnOnUnusableWidth(warn); + + // requireInteractive has the actionable error for that case; a width + // warning on top of it would only be noise. + expect(warn).not.toHaveBeenCalled(); + }); + + it("says nothing when the width is unknown", () => { + process.stdout.columns = undefined as never; + const warn = vi.fn(); + + warnOnUnusableWidth(warn); + + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/tty.ts b/src/core/tty.ts new file mode 100644 index 0000000..72e352b --- /dev/null +++ b/src/core/tty.ts @@ -0,0 +1,32 @@ +// A terminal narrower than this cannot render a @clack/prompts list legibly. It +// shows up when a pty is allocated without a size (an expect script, some CI +// runners), which used to produce one character per line with no explanation. +const MIN_USABLE_COLUMNS = 20; + +export function isInteractive(): boolean { + return process.stdin.isTTY === true; +} + +// Refuses to ask a question nobody can answer. Without a TTY a prompt renders +// and then waits forever, so a run on a pipe used to hang until its job timed +// out rather than failing. +export function requireInteractive(question: string, remedy: string): void { + if (isInteractive()) return; + + throw new Error( + `"${question}" needs an interactive terminal, and this run does not have one. ${remedy}`, + ); +} + +// Warns rather than failing: a narrow terminal still accepts input, so the +// prompts work even when they look wrong. +export function warnOnUnusableWidth(warn: (message: string) => void): void { + const columns = process.stdout.columns; + if (!isInteractive() || columns === undefined || columns >= MIN_USABLE_COLUMNS) { + return; + } + + warn( + `This terminal reports ${columns} columns, so the prompts below will render badly. Resize it, or re-run with --yes to skip them.`, + ); +} diff --git a/src/prompts/appSelect.test.ts b/src/prompts/appSelect.test.ts index 82b2de6..55ff2f2 100644 --- a/src/prompts/appSelect.test.ts +++ b/src/prompts/appSelect.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { select, isCancel, cancel } from "@clack/prompts"; import { CancelledError } from "../core/cancel.js"; @@ -21,6 +21,19 @@ function app(over: Partial = {}): PortalApp { }; } +// Captured before any test flips it, so a suite run leaves the process as it +// found it. +const ORIGINAL_TTY = process.stdin.isTTY; + +beforeEach(() => { + // The prompt path refuses to run without a terminal, and vitest has none. + process.stdin.isTTY = true; +}); + +afterEach(() => { + process.stdin.isTTY = ORIGINAL_TTY; +}); + describe("selectApplication", () => { it("throws when there are no applications", async () => { await expect(selectApplication([])).rejects.toBeInstanceOf( diff --git a/src/prompts/appSelect.ts b/src/prompts/appSelect.ts index bc145f9..47d7828 100644 --- a/src/prompts/appSelect.ts +++ b/src/prompts/appSelect.ts @@ -1,6 +1,7 @@ import { select } from "@clack/prompts"; import { orCancel } from "../core/cancel.js"; +import { requireInteractive } from "../core/tty.js"; import { resolveAppInstanceUrl, type PortalApp } from "../core/portal.js"; export class NoApplicationsError extends Error { @@ -45,6 +46,11 @@ export async function selectApplication( return apps[0]; } + requireInteractive( + "Which managed application should this project connect to?", + "Pass --app to name one (see `seamless apps list`).", + ); + const choice = orCancel( await select({ message: "Which managed application should this project connect to?", diff --git a/src/prompts/projectSetup.test.ts b/src/prompts/projectSetup.test.ts index 49a2ec2..bbce863 100644 --- a/src/prompts/projectSetup.test.ts +++ b/src/prompts/projectSetup.test.ts @@ -69,9 +69,15 @@ function fullRegistry(): RegistryEntry[] { ]; } +// Captured before any test flips it, so a suite run leaves the process as it +// found it. +const ORIGINAL_TTY = process.stdin.isTTY; + let logs: string[]; beforeEach(() => { + // The prompt paths refuse to run without a terminal, and vitest has none. + process.stdin.isTTY = true; // Every full run answers the owner-email prompt; tests that care override it. vi.mocked(text).mockResolvedValue("dev@example.com" as never); logs = []; @@ -81,6 +87,7 @@ beforeEach(() => { }); afterEach(() => { + process.stdin.isTTY = ORIGINAL_TTY; vi.restoreAllMocks(); }); @@ -385,3 +392,80 @@ describe("runProjectSetupPrompts with --yes", () => { expect(select).not.toHaveBeenCalled(); }); }); + +describe("runProjectSetupPrompts without a terminal", () => { + beforeEach(() => { + process.stdin.isTTY = false; + }); + + // Each question names the flag that answers it, so the error tells you how to + // run the same command unattended rather than just that it cannot prompt. + it.each([ + [{}, /Web example.*--web=/s], + [{ webTemplateId: "web-a" }, /Backend framework.*--api=/s], + [ + { webTemplateId: "web-a", apiTemplateId: "api-a" }, + /becomes the admin.*--email
/s, + ], + [ + { + webTemplateId: "web-a", + apiTemplateId: "api-a", + ownerEmail: "dev@example.com", + }, + /run SeamlessAuth\?.*--auth=/s, + ], + [ + { + webTemplateId: "web-a", + apiTemplateId: "api-a", + ownerEmail: "dev@example.com", + authMode: "docker" as const, + }, + /host the admin console\?.*--admin=/s, + ], + ])("stops on the first unanswered question (%#)", async (preselect, expected) => { + await expect( + runProjectSetupPrompts(fullRegistry(), preselect), + ).rejects.toThrow(expected); + expect(select).not.toHaveBeenCalled(); + expect(text).not.toHaveBeenCalled(); + }); + + it("runs to completion when every question is answered", async () => { + const result = await runProjectSetupPrompts(fullRegistry(), { + webTemplateId: "web-a", + apiTemplateId: "api-a", + ownerEmail: "dev@example.com", + authMode: "docker", + adminMode: "api", + }); + + expect(result.webTemplateId).toBe("web-a"); + expect(select).not.toHaveBeenCalled(); + }); + + it("stops on the Docker confirmation for a local auth mode", async () => { + await expect( + runProjectSetupPrompts(fullRegistry(), { + webTemplateId: "web-a", + apiTemplateId: "api-a", + ownerEmail: "dev@example.com", + authMode: "local", + adminMode: "api", + }), + ).rejects.toThrow(/Enable Docker\?.*needs an interactive terminal/s); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("does not stop when --yes has answered everything", async () => { + const result = await runProjectSetupPrompts( + fullRegistry(), + { ownerEmail: "dev@example.com" }, + undefined, + true, + ); + + expect(result.webTemplateId).toBe("web-a"); + }); +}); diff --git a/src/prompts/projectSetup.ts b/src/prompts/projectSetup.ts index 2d616cd..6a1bbc3 100644 --- a/src/prompts/projectSetup.ts +++ b/src/prompts/projectSetup.ts @@ -1,6 +1,7 @@ import { confirm, select, text } from "@clack/prompts"; import { orCancel } from "../core/cancel.js"; +import { requireInteractive } from "../core/tty.js"; import type { RegistryEntry, TemplateKind } from "../core/templates.js"; @@ -90,6 +91,11 @@ async function resolveTemplateId( return chosen; } + requireInteractive( + message, + `Pass --${kind}= to choose one (see \`seamless templates list\`), or --yes to take the recommended template.`, + ); + return orCancel( await select({ message, options: toOptions(templates, kind) }), ) as string; @@ -161,6 +167,9 @@ export async function runProjectSetupPrompts( preselect.authMode, assumeYes ? DEFAULT_AUTH_MODE : undefined, "Auth server", + "How would you like to run SeamlessAuth?", + "--auth", + AUTH_MODES, async () => orCancel( await select({ @@ -183,6 +192,9 @@ export async function runProjectSetupPrompts( preselect.adminMode, assumeYes ? DEFAULT_ADMIN_MODE : undefined, "Admin console", + "How would you like to host the admin console?", + "--admin", + ADMIN_MODES, async () => orCancel( await select({ @@ -211,6 +223,10 @@ export async function runProjectSetupPrompts( ); if (authMode === "local" && !assumeYes) { + requireInteractive( + "Auth server still requires Docker for full stack. Enable Docker?", + "Pass --yes; Docker is enabled either way.", + ); const confirmDocker = orCancel( await confirm({ message: @@ -248,6 +264,9 @@ async function resolveChoice( supplied: T | undefined, fallback: T | undefined, echoLabel: string, + question: string, + flag: string, + allowed: readonly T[], ask: () => Promise, ): Promise { const chosen = supplied ?? fallback; @@ -255,6 +274,12 @@ async function resolveChoice( console.log(`${echoLabel}: ${chosen}`); return chosen; } + + requireInteractive( + question, + `Pass ${flag}=<${allowed.join("|")}>, or --yes to take the recommended option.`, + ); + return ask(); } @@ -274,6 +299,11 @@ async function resolveOwnerEmail( ); } + requireInteractive( + "Your email (becomes the admin when you register)", + "Pass --email
, or run `seamless login` so it can be taken from your portal session.", + ); + return orCancel( await text({ message: "Your email (becomes the admin when you register)",