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
23 changes: 23 additions & 0 deletions .changeset/require-tty-everywhere.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"seamless-cli": minor
---

No command renders a prompt when stdin is not a terminal. `seamless init` got this in 0.11.0; it now
covers every command that prompts (`login`, `profile add`, `users delete`,
`users prepare-device-replacement`, `sessions revoke`, `org members remove`, `config apply`, and
`config oauth-providers remove`). Each one stops naming the flag that answers the question, so a CI
step or a scripted run fails immediately instead of hanging until its job times out.

The confirmations that guard a destructive action now take `--force`, matching what `init` already
means by it:

```bash
seamless users delete <id> --force
seamless sessions revoke --all --force
seamless org members remove <orgId> <userId> --force
seamless config apply config.json --force
```

`--yes` and `-y` are accepted aliases everywhere, so `seamless config oauth-providers remove --yes`
keeps working. `--force` does not override `--dry-run`: `config apply --dry-run --force` still
changes nothing, and cancelling a confirmation still reads as declining rather than as an error.
16 changes: 11 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,10 @@ 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.
- Every prompt 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. This holds across every command, not just `init`. When adding a
prompt anywhere, guard it the same way.
- **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 All @@ -97,6 +96,13 @@ The entry point is [src/index.ts](src/index.ts), which dispatches to a command m
[src/core/oauthProviders.ts](src/core/oauthProviders.ts)). The chosen providers are wired into
the auth server env (`OAUTH_PROVIDERS`, per-provider `*_CLIENT_SECRET`, the `oauth` login
method) by `buildAuthEnv` in [src/generators/docker/docker.ts](src/generators/docker/docker.ts).
- **destructive confirmations** go through `confirmDestructive`
([src/core/confirmAction.ts](src/core/confirmAction.ts)), which answers itself when `--force` is
set and otherwise asks. `--force` is the standing spelling for "do it without asking";
`hasForceFlag` also accepts `--yes` and `-y`, because `config oauth-providers remove --yes`
shipped before the convention existed. `--yes` means something narrower on `init` (answer the
ordinary questions, never the destructive ones), so do not add `--yes` alone to a destructive
step. Cancelling a confirmation reads as declining, not as an error.
- **check** health-checks a running stack (local or managed).
- **verify** ([src/commands/verify.ts](src/commands/verify.ts)) runs the conformance harness (below).
- **instance management** — `profile` (targets, plus `profile login`),
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,28 @@ seamless whoami
Because `/refresh` rotates the refresh token on every call, this path is best for a single
invocation; the rotated token is held only in memory for that process.

No command will render a prompt when stdin is not a terminal. Each one stops instead, naming the
flag that answers the question, so a CI step fails immediately rather than hanging until its job
times out.

The commands that ask before destroying something take `--force` (with `--yes` and `-y` accepted as
aliases), which is both how you skip the confirmation and how you run them unattended:

```bash
seamless users delete <id> --force
seamless sessions revoke --all --force
seamless org members remove <orgId> <userId> --force
seamless config apply config.json --force
seamless config oauth-providers remove google --force
```

`--force` never overrides `--dry-run`: `config apply --dry-run --force` still changes nothing.

The prompts that gather input rather than confirm an action are answered by the flags those
commands already take, for example `seamless login --identifier you@example.com` and
`seamless profile add prod --instance-url https://auth.example.com`. The one-time code at login is
the exception: it only exists after the code is sent, so that step genuinely needs a terminal.

---

## What is configured for you
Expand Down
54 changes: 54 additions & 0 deletions src/commands/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,3 +674,57 @@ describe("runConfig oauth-providers", () => {
expect(errOutput()).toContain("Could not read file: missing.json");
});
});

describe("config --force", () => {
beforeEach(() => {
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({ app_name: "New" }),
);
});

it.each(["--force", "--yes", "-y"])(
"applies without confirming when given %s",
async (flag) => {
const { client } = fakeClient(() =>
response(200, { app_name: "Old", updatedKeys: ["app_name"] }),
);
vi.mocked(createAuthClient).mockResolvedValue(client);

await runConfig(["apply", "local.json", flag]);

expect(vi.mocked(confirm)).not.toHaveBeenCalled();
expect(output()).toContain("Applied.");
},
);

// --dry-run means "show me, change nothing", which --force does not override.
it("still applies nothing with --dry-run --force", async () => {
const { client } = fakeClient(() => response(200, { app_name: "Old" }));
vi.mocked(createAuthClient).mockResolvedValue(client);

await runConfig(["apply", "local.json", "--dry-run", "--force"]);

expect(output()).toContain("Dry run: no changes applied.");
expect(vi.mocked(confirm)).not.toHaveBeenCalled();
});

it("refuses to ask without a terminal, naming --force", async () => {
process.stdin.isTTY = false;
const { client } = fakeClient(() => response(200, { app_name: "Old" }));
vi.mocked(createAuthClient).mockResolvedValue(client);

await expect(runConfig(["apply", "local.json"])).rejects.toThrow(
/needs an interactive terminal[\s\S]*--force/,
);
});

it("removes an OAuth provider without confirming when forced", async () => {
const { client } = fakeClient(() => response(200, { message: "ok" }));
vi.mocked(createAuthClient).mockResolvedValue(client);

await runConfig(["oauth-providers", "remove", "google", "--force"]);

expect(vi.mocked(confirm)).not.toHaveBeenCalled();
expect(output()).toContain("Removed OAuth provider: google");
});
});
34 changes: 19 additions & 15 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import fs from "fs";
import { confirm, isCancel } from "@clack/prompts";
import kleur from "kleur";
import { extractFlag } from "../core/args.js";
import { createAuthClient, ReauthRequiredError, type AuthClient } from "../core/authClient.js";
Expand All @@ -20,6 +19,10 @@ import {
type OAuthProvider,
type SystemConfig,
} from "../core/systemConfig.js";
import {
confirmDestructive,
hasForceFlag,
} from "../core/confirmAction.js";

export async function runConfig(args: string[]): Promise<void> {
const sub = args[0];
Expand Down Expand Up @@ -180,13 +183,14 @@ async function configApply(client: AuthClient, rest: string[]): Promise<void> {
return;
}

const proceed = await confirm({
const proceed = await confirmDestructive({
message: `Apply ${changes.length} change${
changes.length === 1 ? "" : "s"
} to ${client.profile.instanceUrl}?`,
initialValue: false,
force: hasForceFlag(rest),
remedy: "Pass --force to apply without confirming.",
});
if (isCancel(proceed) || !proceed) {
if (!proceed) {
console.log("Cancelled.");
return;
}
Expand Down Expand Up @@ -304,24 +308,24 @@ async function oauthProvidersRemove(
client: AuthClient,
rest: string[],
): Promise<void> {
const skipConfirm = rest.includes("--yes") || rest.includes("-y");
const id = rest.find((arg) => !arg.startsWith("-"));
if (!id) {
console.error(
kleur.red("Usage: seamless config oauth-providers remove <id> [--yes]"),
kleur.red("Usage: seamless config oauth-providers remove <id> [--force]"),
);
process.exit(1);
}

if (!skipConfirm) {
const proceed = await confirm({
message: `Remove OAuth provider "${id}" from ${client.profile.instanceUrl}?`,
initialValue: false,
});
if (isCancel(proceed) || !proceed) {
console.log("Cancelled.");
return;
}
const proceed = await confirmDestructive({
message: `Remove OAuth provider "${id}" from ${client.profile.instanceUrl}?`,
// This subcommand shipped with --yes before --force was the convention, so
// hasForceFlag keeps accepting it.
force: hasForceFlag(rest),
remedy: "Pass --force to remove it without confirming.",
});
if (!proceed) {
console.log("Cancelled.");
return;
}

await deleteOAuthProvider(client, id);
Expand Down
40 changes: 31 additions & 9 deletions src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,17 +267,24 @@ instead. Fails cleanly if not logged in.`,
},
{
name: "sessions",
usage: ["seamless sessions [list]", "seamless sessions revoke <id | --all>"],
usage: [
"seamless sessions [list]",
"seamless sessions revoke <id | --all> [--force]",
],
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 <id | --all>",
heading: "sessions revoke <id | --all> [--force]",
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.`,
session (or --all) prompts for confirmation and then clears local tokens.

--force
• Skip that confirmation (--yes and -y are accepted aliases), which is also
what lets this run without a terminal attached`,
},
],
},
Expand All @@ -303,15 +310,21 @@ config roles [--json]
config diff <file>
• Show how a local JSON config file differs from the instance

config apply <file> [--dry-run]
config apply <file> [--dry-run] [--force]
• Apply a local JSON config file after a confirmation prompt
• --force skips the confirmation; --dry-run still changes nothing

config oauth-providers <list|add|update|remove>
• 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)`,
config oauth-providers remove google --force)

--force
• Skips the confirmation on apply and oauth-providers remove (--yes and -y
are accepted aliases), which is also what lets them run without a
terminal attached`,
},
],
},
Expand All @@ -327,12 +340,17 @@ config oauth-providers <list|add|update|remove>

users list [--limit <n>] [--offset <n>] [--json]
• List users
users delete <id>
users delete <id> [--force]
• Delete a user (asks for confirmation)
users credentials <id> [--json]
• Show a user's registered credentials
users prepare-device-replacement <id> [--keep-sessions] [--keep-passkeys] [--keep-totp]
• Admin-assisted account recovery (needs an elevated session)`,
users prepare-device-replacement <id> [--force] [--keep-sessions] [--keep-passkeys] [--keep-totp]
• Admin-assisted account recovery (needs an elevated session)

--force
• Skips the confirmation on delete and prepare-device-replacement (--yes and
-y are accepted aliases), which is also what lets them run without a
terminal attached`,
},
],
},
Expand All @@ -355,7 +373,11 @@ org update <id> [--name <name>] [--slug <slug>]
org members list <orgId> [--json]
org members add <orgId> (--user <id> | --email <email>) [--roles a,b] [--scopes a,b]
org members update <orgId> <userId> [--roles a,b] [--scopes a,b]
org members remove <orgId> <userId>`,
org members remove <orgId> <userId> [--force]

--force
• Skips the confirmation on members remove (--yes and -y are accepted
aliases), which is also what lets it run without a terminal attached`,
},
],
},
Expand Down
7 changes: 0 additions & 7 deletions src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,16 +195,10 @@ 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 @@ -222,7 +216,6 @@ beforeEach(() => {
});

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

Expand Down
10 changes: 10 additions & 0 deletions src/commands/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,3 +538,13 @@ describe("runLogin: failure handling", () => {
expect(exitSpy).not.toHaveBeenCalled();
});
});

describe("runLogin without a terminal", () => {
it("refuses the identifier prompt, naming --identifier", async () => {
process.stdin.isTTY = false;

await expect(runLogin([])).rejects.toThrow(
/"Email or phone" needs an interactive terminal[\s\S]*--identifier/,
);
});
});
23 changes: 23 additions & 0 deletions src/commands/org.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,26 @@ describe("runOrg members remove", () => {
expect(logs()).toContain("Removed user u1 from o1.");
});
});

describe("org members remove --force", () => {
it.each(["--force", "--yes", "-y"])(
"removes without confirming when given %s",
async (flag) => {
vi.mocked(removeMember).mockResolvedValue(undefined as never);

await runOrg(["members", "remove", "o1", "u1", flag]);

expect(vi.mocked(confirm)).not.toHaveBeenCalled();
expect(logs()).toContain("Removed user u1 from o1.");
},
);

it("refuses to ask without a terminal, naming --force", async () => {
process.stdin.isTTY = false;

await expect(runOrg(["members", "remove", "o1", "u1"])).rejects.toThrow(
/needs an interactive terminal[\s\S]*--force/,
);
expect(vi.mocked(removeMember)).not.toHaveBeenCalled();
});
});
12 changes: 8 additions & 4 deletions src/commands/org.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { confirm, isCancel } from "@clack/prompts";
import kleur from "kleur";
import { extractFlag } from "../core/args.js";
import { createAuthClient, type AuthClient } from "../core/authClient.js";
Expand All @@ -14,6 +13,10 @@ import {
type Json,
} from "../core/admin.js";
import { parseList, reportAdminError } from "./adminShared.js";
import {
confirmDestructive,
hasForceFlag,
} from "../core/confirmAction.js";

export async function runOrg(args: string[]): Promise<void> {
if (args[0] === "members") {
Expand Down Expand Up @@ -246,11 +249,12 @@ async function membersRemove(client: AuthClient, rest: string[]): Promise<void>
process.exit(1);
}

const proceed = await confirm({
const proceed = await confirmDestructive({
message: `Remove user ${userId} from organization ${orgId}?`,
initialValue: false,
force: hasForceFlag(rest),
remedy: "Pass --force to remove the member without confirming.",
});
if (isCancel(proceed) || !proceed) {
if (!proceed) {
console.log("Cancelled.");
return;
}
Expand Down
Loading
Loading