From e2f53b36e40841d46ee77eb4b0564555369858c6 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 31 Jul 2026 23:53:57 -0400 Subject: [PATCH] feat: fail fast when init has no terminal to prompt on Run on a pipe, init used to render a @clack prompt nobody could answer and wait forever, so a CI step failed only when its job timed out. Every prompt in the init flow is now fronted by requireInteractive, which stops on the first unanswered question and names the flag that answers it. A run whose answers all come from flags is unaffected and behaves 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 printed one character per line) now warns rather than just looking broken. Scoped to init. The other commands that prompt are still unguarded, tracked in the issue. Closes #153 --- .changeset/init-requires-tty.md | 18 +++++++ AGENTS.md | 5 ++ README.md | 12 +++++ src/commands/init.test.ts | 66 +++++++++++++++++++++++++ src/commands/init.ts | 33 ++++++++++++- src/core/tty.test.ts | 80 ++++++++++++++++++++++++++++++ src/core/tty.ts | 32 ++++++++++++ src/prompts/appSelect.test.ts | 15 +++++- src/prompts/appSelect.ts | 6 +++ src/prompts/projectSetup.test.ts | 84 ++++++++++++++++++++++++++++++++ src/prompts/projectSetup.ts | 30 ++++++++++++ 11 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 .changeset/init-requires-tty.md create mode 100644 src/core/tty.test.ts create mode 100644 src/core/tty.ts 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)",