Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/init-requires-tty.md
Original file line number Diff line number Diff line change
@@ -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=<id> 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.
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id> 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
Expand Down
66 changes: 66 additions & 0 deletions src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,16 @@ function app(over: Record<string, any> = {}) {
};
}

// 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 ?? ""));
Expand All @@ -216,6 +222,7 @@ beforeEach(() => {
});

afterEach(() => {
process.stdin.isTTY = ORIGINAL_TTY;
vi.restoreAllMocks();
});

Expand Down Expand Up @@ -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();
});
});
33 changes: 31 additions & 2 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -265,7 +274,13 @@ async function resolveExistingDirectoryAction(
canConnect: boolean,
opts: InitOptions,
): Promise<ExistingDirectoryAction> {
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 <id> to connect the existing project to a managed application.",
);
return chooseExistingDirectoryAction(canConnect);
}

if (!opts.force) {
throw new Error(
Expand All @@ -285,7 +300,13 @@ async function resolveScaffoldTarget(
appCount: number,
opts: InitOptions,
): Promise<ScaffoldTarget> {
if (!opts.yes) return chooseScaffoldTarget(appCount);
if (!opts.yes) {
requireInteractive(
"How should this project get its auth?",
"Pass --app <id> 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 <id> to connect one of your managed applications, or --local to scaffold a self-hosted stack.",
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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?`,
Expand Down
80 changes: 80 additions & 0 deletions src/core/tty.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
32 changes: 32 additions & 0 deletions src/core/tty.ts
Original file line number Diff line number Diff line change
@@ -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.`,
);
}
15 changes: 14 additions & 1 deletion src/prompts/appSelect.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,6 +21,19 @@ function app(over: Partial<PortalApp> = {}): 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(
Expand Down
6 changes: 6 additions & 0 deletions src/prompts/appSelect.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -45,6 +46,11 @@ export async function selectApplication<T extends PortalApp>(
return apps[0];
}

requireInteractive(
"Which managed application should this project connect to?",
"Pass --app <id> to name one (see `seamless apps list`).",
);

const choice = orCancel(
await select({
message: "Which managed application should this project connect to?",
Expand Down
Loading
Loading