From 62aa6d005c314f4c211b87fb6cde6b0b2518b545 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:29:35 -0600 Subject: [PATCH 01/35] refactor(prompts): add text/password/editor wrappers in lib/prompts.ts --- packages/cli-core/src/lib/prompts.test.ts | 205 +++++++++++++++++++++- packages/cli-core/src/lib/prompts.ts | 49 +++++- 2 files changed, 248 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/lib/prompts.test.ts b/packages/cli-core/src/lib/prompts.test.ts index b58eec1e..793b73e3 100644 --- a/packages/cli-core/src/lib/prompts.test.ts +++ b/packages/cli-core/src/lib/prompts.test.ts @@ -1,8 +1,14 @@ import { test, expect, mock, spyOn, beforeEach } from "bun:test"; -// Track calls to the underlying inquirer confirm +// Track calls to the underlying inquirer primitives let lastConfirmArgs: unknown[] = []; let confirmResult: boolean | Error = true; +let lastInputArgs: unknown[] = []; +let inputResult: string | Error = ""; +let lastPasswordArgs: unknown[] = []; +let passwordResult: string | Error = ""; +let lastEditorArgs: unknown[] = []; +let editorResult: string | Error = ""; mock.module("@inquirer/prompts", () => ({ confirm: async (...args: unknown[]) => { @@ -10,16 +16,28 @@ mock.module("@inquirer/prompts", () => ({ if (confirmResult instanceof Error) throw confirmResult; return confirmResult; }, + input: async (...args: unknown[]) => { + lastInputArgs = args; + if (inputResult instanceof Error) throw inputResult; + return inputResult; + }, + password: async (...args: unknown[]) => { + lastPasswordArgs = args; + if (passwordResult instanceof Error) throw passwordResult; + return passwordResult; + }, + editor: async (...args: unknown[]) => { + lastEditorArgs = args; + if (editorResult instanceof Error) throw editorResult; + return editorResult; + }, // Stub the other exports so this mock doesn't break other test files // that share this process and import @inquirer/prompts. select: async () => {}, search: async () => {}, - input: async () => "", - password: async () => "", - editor: async () => "", })); -const { confirm } = await import("./prompts.ts"); +const { confirm, text, password, editor } = await import("./prompts.ts"); const originalIsTTY = process.stdin.isTTY; const originalPlatform = process.platform; @@ -27,6 +45,12 @@ const originalPlatform = process.platform; beforeEach(() => { lastConfirmArgs = []; confirmResult = true; + lastInputArgs = []; + inputResult = ""; + lastPasswordArgs = []; + passwordResult = ""; + lastEditorArgs = []; + editorResult = ""; process.stdin.isTTY = originalIsTTY; Object.defineProperty(process, "platform", { value: originalPlatform, writable: true }); }); @@ -87,3 +111,174 @@ test("closes tty stream even when confirm throws", async () => { createReadStreamSpy.mockRestore(); }); + +test("text passes config through to inquirer input", async () => { + process.stdin.isTTY = true; + inputResult = "hello"; + const result = await text({ message: "Name?" }); + + expect(result).toBe("hello"); + expect(lastInputArgs[0]).toEqual({ message: "Name?" }); +}); + +test("text forwards default and validate options", async () => { + process.stdin.isTTY = true; + inputResult = "value"; + const validate = (v: string) => v.length > 0; + await text({ message: "Name?", default: "anon", validate }); + + expect(lastInputArgs[0]).toEqual({ message: "Name?", default: "anon", validate }); +}); + +test("text does not open tty when stdin is a TTY", async () => { + process.stdin.isTTY = true; + await text({ message: "Name?" }); + + expect(lastInputArgs[1]).toBeUndefined(); +}); + +test("text opens controlling terminal when stdin is not a TTY", async () => { + process.stdin.isTTY = false; + + const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; + const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( + mockStream as any, + ); + + await text({ message: "Name?" }); + + expect(lastInputArgs[1]).toEqual({ input: mockStream }); + expect(mockStream.close).toHaveBeenCalled(); + + createReadStreamSpy.mockRestore(); +}); + +test("text closes tty stream even when inquirer input throws", async () => { + process.stdin.isTTY = false; + inputResult = new Error("cancelled"); + + const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; + const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( + mockStream as any, + ); + + await expect(text({ message: "Name?" })).rejects.toThrow("cancelled"); + expect(mockStream.close).toHaveBeenCalled(); + + createReadStreamSpy.mockRestore(); +}); + +test("password passes config through to inquirer password", async () => { + process.stdin.isTTY = true; + passwordResult = "s3cret"; + const result = await password({ message: "Secret?" }); + + expect(result).toBe("s3cret"); + expect(lastPasswordArgs[0]).toEqual({ message: "Secret?" }); +}); + +test("password forwards validate option", async () => { + process.stdin.isTTY = true; + const validate = (v: string) => v.length >= 8; + await password({ message: "Secret?", validate }); + + expect(lastPasswordArgs[0]).toEqual({ message: "Secret?", validate }); +}); + +test("password does not open tty when stdin is a TTY", async () => { + process.stdin.isTTY = true; + await password({ message: "Secret?" }); + + expect(lastPasswordArgs[1]).toBeUndefined(); +}); + +test("password opens controlling terminal when stdin is not a TTY", async () => { + process.stdin.isTTY = false; + + const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; + const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( + mockStream as any, + ); + + await password({ message: "Secret?" }); + + expect(lastPasswordArgs[1]).toEqual({ input: mockStream }); + expect(mockStream.close).toHaveBeenCalled(); + + createReadStreamSpy.mockRestore(); +}); + +test("password closes tty stream even when inquirer password throws", async () => { + process.stdin.isTTY = false; + passwordResult = new Error("cancelled"); + + const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; + const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( + mockStream as any, + ); + + await expect(password({ message: "Secret?" })).rejects.toThrow("cancelled"); + expect(mockStream.close).toHaveBeenCalled(); + + createReadStreamSpy.mockRestore(); +}); + +test("editor passes config through to inquirer editor", async () => { + process.stdin.isTTY = true; + editorResult = "body content"; + const result = await editor({ message: "Notes?" }); + + expect(result).toBe("body content"); + expect(lastEditorArgs[0]).toEqual({ message: "Notes?" }); +}); + +test("editor forwards default, postfix, and validate options", async () => { + process.stdin.isTTY = true; + const validate = (v: string) => v.length > 0; + await editor({ message: "Notes?", default: "draft", postfix: ".md", validate }); + + expect(lastEditorArgs[0]).toEqual({ + message: "Notes?", + default: "draft", + postfix: ".md", + validate, + }); +}); + +test("editor does not open tty when stdin is a TTY", async () => { + process.stdin.isTTY = true; + await editor({ message: "Notes?" }); + + expect(lastEditorArgs[1]).toBeUndefined(); +}); + +test("editor opens controlling terminal when stdin is not a TTY", async () => { + process.stdin.isTTY = false; + + const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; + const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( + mockStream as any, + ); + + await editor({ message: "Notes?" }); + + expect(lastEditorArgs[1]).toEqual({ input: mockStream }); + expect(mockStream.close).toHaveBeenCalled(); + + createReadStreamSpy.mockRestore(); +}); + +test("editor closes tty stream even when inquirer editor throws", async () => { + process.stdin.isTTY = false; + editorResult = new Error("cancelled"); + + const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; + const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( + mockStream as any, + ); + + await expect(editor({ message: "Notes?" })).rejects.toThrow("cancelled"); + expect(mockStream.close).toHaveBeenCalled(); + + createReadStreamSpy.mockRestore(); +}); diff --git a/packages/cli-core/src/lib/prompts.ts b/packages/cli-core/src/lib/prompts.ts index 8ef126b0..deb1856a 100644 --- a/packages/cli-core/src/lib/prompts.ts +++ b/packages/cli-core/src/lib/prompts.ts @@ -9,7 +9,12 @@ * Uses the shared ttyContext from lib/listage.ts for consistent error handling. */ -import { confirm as inquirerConfirm } from "@inquirer/prompts"; +import { + confirm as inquirerConfirm, + input as inquirerInput, + password as inquirerPassword, + editor as inquirerEditor, +} from "@inquirer/prompts"; import { ttyContext } from "./listage.ts"; /** @@ -25,3 +30,45 @@ export async function confirm(config: { message: string; default?: boolean }): P tty?.close(); } } + +/** Single-line text input. Named `text` to match the post-clack API. */ +export async function text(config: { + message: string; + default?: string; + validate?: (value: string) => boolean | string | Promise; +}): Promise { + const tty = ttyContext(); + try { + return await inquirerInput(config, tty ? { input: tty.input } : undefined); + } finally { + tty?.close(); + } +} + +/** Masked password input. */ +export async function password(config: { + message: string; + validate?: (value: string) => boolean | string | Promise; +}): Promise { + const tty = ttyContext(); + try { + return await inquirerPassword(config, tty ? { input: tty.input } : undefined); + } finally { + tty?.close(); + } +} + +/** Multiline editor input ($EDITOR shellout). */ +export async function editor(config: { + message: string; + default?: string; + postfix?: string; + validate?: (value: string) => boolean | string | Promise; +}): Promise { + const tty = ttyContext(); + try { + return await inquirerEditor(config, tty ? { input: tty.input } : undefined); + } finally { + tty?.close(); + } +} From d1985f7537470bfca7ef92f19b638f4952e080b5 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:33:04 -0600 Subject: [PATCH 02/35] refactor(init): route input through lib/prompts.ts --- packages/cli-core/src/commands/init/bootstrap.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/init/bootstrap.ts b/packages/cli-core/src/commands/init/bootstrap.ts index b90ec502..1ad706b1 100644 --- a/packages/cli-core/src/commands/init/bootstrap.ts +++ b/packages/cli-core/src/commands/init/bootstrap.ts @@ -1,6 +1,5 @@ import { join } from "node:path"; -import { input } from "@inquirer/prompts"; -import { confirm } from "../../lib/prompts.ts"; +import { confirm, text } from "../../lib/prompts.ts"; import { search, filterChoices } from "../../lib/listage.ts"; import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js"; import { log } from "../../lib/log.js"; @@ -106,7 +105,7 @@ export async function findAvailableProjectName(cwd: string, base: string): Promi async function askProjectName(entry: BootstrapEntry, cwd: string): Promise { const defaultName = await findAvailableProjectName(cwd, entry.defaultProjectName); - const name = await input({ + const name = await text({ message: "Project name:", default: defaultName, validate: async (value) => { From a80c7c12be7b4ebf6dc3ff38db05a53828d63629 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:33:44 -0600 Subject: [PATCH 03/35] refactor(api): route input/editor through lib/prompts.ts --- packages/cli-core/src/commands/api/interactive.test.ts | 2 ++ packages/cli-core/src/commands/api/interactive.ts | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/api/interactive.test.ts b/packages/cli-core/src/commands/api/interactive.test.ts index 0ddad502..c6148027 100644 --- a/packages/cli-core/src/commands/api/interactive.test.ts +++ b/packages/cli-core/src/commands/api/interactive.test.ts @@ -65,6 +65,8 @@ mock.module("@inquirer/prompts", () => ({ mock.module("../../lib/prompts.ts", () => ({ confirm: async () => confirmResponses.shift(), + text: async () => inputResponses.shift(), + editor: async () => inputResponses.shift(), })); mock.module("../../lib/listage.ts", () => ({ diff --git a/packages/cli-core/src/commands/api/interactive.ts b/packages/cli-core/src/commands/api/interactive.ts index 1f543f7e..7c1653c6 100644 --- a/packages/cli-core/src/commands/api/interactive.ts +++ b/packages/cli-core/src/commands/api/interactive.ts @@ -2,9 +2,8 @@ * Interactive API request builder for `clerk api` (no args, human mode). */ -import { input, editor } from "@inquirer/prompts"; import { select } from "../../lib/listage.ts"; -import { confirm } from "../../lib/prompts.ts"; +import { confirm, editor, text } from "../../lib/prompts.ts"; import { isHuman } from "../../mode.ts"; import { loadCatalog, endpointsByTag, type EndpointInfo } from "./catalog.ts"; import type { ApiOptions } from "./index.ts"; @@ -51,7 +50,7 @@ export async function apiInteractive(options: ApiOptions): Promise { // 4. Fill path parameters let resolvedPath = endpoint.path; for (const param of endpoint.pathParams) { - const value = await input({ + const value = await text({ message: param.description ? `${param.name} (${param.description}):` : `${param.name}:`, validate: (v: string) => v.trim().length > 0 || `${param.name} is required`, }); From c449bf9288cc011964b72ba61fbfb23f11054424 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:34:35 -0600 Subject: [PATCH 04/35] refactor(deploy): route input/password through lib/prompts.ts --- packages/cli-core/src/commands/deploy/index.test.ts | 2 ++ packages/cli-core/src/commands/deploy/index.ts | 7 +++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 9805ccc2..02ef2eff 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -30,6 +30,8 @@ mock.module("@inquirer/prompts", () => ({ mock.module("../../lib/prompts.ts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), + text: (...args: unknown[]) => mockInput(...args), + password: (...args: unknown[]) => mockPassword(...args), })); mock.module("../../lib/listage.ts", () => ({ diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 4f7b8023..75f56f77 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,6 +1,5 @@ -import { input, password } from "@inquirer/prompts"; import { select } from "../../lib/listage.ts"; -import { confirm } from "../../lib/prompts.ts"; +import { confirm, password, text } from "../../lib/prompts.ts"; import { isAgent } from "../../mode.ts"; import { dim, bold, cyan, green, blue } from "../../lib/color.ts"; import { printNextSteps, NEXT_STEPS } from "../../lib/next-steps.ts"; @@ -142,7 +141,7 @@ export async function deploy(options: { debug?: boolean }) { let domain: string; if (domainChoice === "custom-domain") { - domain = await input({ + domain = await text({ message: "Enter your domain:", }); log.debug(`User provided custom domain: ${domain}`); @@ -228,7 +227,7 @@ export async function deploy(options: { debug?: boolean }) { log.data("Once you've created your credentials, enter them below:\n"); } - const clientId = await input({ + const clientId = await text({ message: `${displayName} OAuth Client ID:`, }); From da54512b2d2eaffaf73aeff34b8af7f61d2e9896 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:35:21 -0600 Subject: [PATCH 05/35] refactor(users): route input/password through lib/prompts.ts --- packages/cli-core/src/commands/users/create-wizard.test.ts | 2 ++ packages/cli-core/src/commands/users/create-wizard.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/users/create-wizard.test.ts b/packages/cli-core/src/commands/users/create-wizard.test.ts index ce82b583..7363c852 100644 --- a/packages/cli-core/src/commands/users/create-wizard.test.ts +++ b/packages/cli-core/src/commands/users/create-wizard.test.ts @@ -20,6 +20,8 @@ mock.module("../../lib/fapi.ts", () => ({ mock.module("@inquirer/prompts", () => ({ input: (...args: unknown[]) => mockInput(...args), password: (...args: unknown[]) => mockPassword(...args), + confirm: async () => true, + editor: async () => "", })); mock.module("../../lib/spinner.ts", () => ({ withSpinner: async (_msg: string, fn: () => Promise) => fn(), diff --git a/packages/cli-core/src/commands/users/create-wizard.ts b/packages/cli-core/src/commands/users/create-wizard.ts index 6e952a09..0bf53612 100644 --- a/packages/cli-core/src/commands/users/create-wizard.ts +++ b/packages/cli-core/src/commands/users/create-wizard.ts @@ -1,4 +1,4 @@ -import { input, password } from "@inquirer/prompts"; +import { password, text } from "../../lib/prompts.ts"; import { bootstrapDevBrowser, decodePublishableKey, @@ -106,6 +106,6 @@ async function promptField(field: FieldDef, required: boolean): Promise if (field.isPassword) { return password({ message, validate }); } - const value = await input({ message, validate }); + const value = await text({ message, validate }); return value.trim(); } From 0c466c8c3081912507b2879205af06eed98371cf Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:36:18 -0600 Subject: [PATCH 06/35] refactor(app-picker): route input through lib/prompts.ts --- packages/cli-core/src/lib/app-picker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/lib/app-picker.ts b/packages/cli-core/src/lib/app-picker.ts index 168d8ea0..2fb2562d 100644 --- a/packages/cli-core/src/lib/app-picker.ts +++ b/packages/cli-core/src/lib/app-picker.ts @@ -4,7 +4,7 @@ * project is linked and no --app was provided. */ -import { input } from "@inquirer/prompts"; +import { text } from "./prompts.ts"; import { cyan, dim } from "./color.ts"; import { CliError, ERROR_CODE, PlapiError, withApiContext } from "./errors.ts"; import { search } from "./listage.ts"; @@ -63,7 +63,7 @@ export async function pickOrCreateApp(opts: { }); if (selectedId === CREATE_NEW_APP) { - const name = await input({ + const name = await text({ message: "Application name:", validate: (v) => (v.trim() ? true : "Application name cannot be empty"), }); From 1bbc2064aaf309dbc8a13479852900c471356718 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:38:59 -0600 Subject: [PATCH 07/35] test: stub new lib/prompts.ts exports in existing mocks --- packages/cli-core/src/commands/auth/login.test.ts | 3 +++ packages/cli-core/src/commands/link/index.test.ts | 3 +++ packages/cli-core/src/commands/unlink/index.test.ts | 3 +++ packages/cli-core/src/test/integration/lib/harness.ts | 3 +++ 4 files changed, 12 insertions(+) diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index 836d95d8..28c1aa77 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -75,6 +75,9 @@ mock.module("../../mode.ts", () => ({ mock.module("../../lib/prompts.ts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), + text: async () => "", + password: async () => "", + editor: async () => "", })); mock.module("../../lib/open.ts", () => ({ diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index e7627af7..b985a9d3 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -89,6 +89,9 @@ mock.module("@inquirer/prompts", () => ({ mock.module("../../lib/prompts.ts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), + text: (...args: unknown[]) => mockInput(...args), + password: async () => "", + editor: async () => "", })); mock.module("../../lib/listage.ts", () => ({ diff --git a/packages/cli-core/src/commands/unlink/index.test.ts b/packages/cli-core/src/commands/unlink/index.test.ts index a523c8ea..d8fbdf16 100644 --- a/packages/cli-core/src/commands/unlink/index.test.ts +++ b/packages/cli-core/src/commands/unlink/index.test.ts @@ -37,6 +37,9 @@ mock.module("@inquirer/prompts", () => ({ mock.module("../../lib/prompts.ts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), + text: async () => "", + password: async () => "", + editor: async () => "", })); const { unlink } = await import("./index.ts"); diff --git a/packages/cli-core/src/test/integration/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index 23b9e80a..39af6408 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -207,6 +207,9 @@ mock.module("../../../lib/listage.ts", () => ({ mock.module("../../../lib/prompts.ts", () => ({ confirm: dequeuePrompt("confirm"), + text: dequeuePrompt("input"), + password: dequeuePrompt("password"), + editor: dequeuePrompt("editor"), })); mock.module( From a7c87a02d37269bc89480c670f59455c2f540065 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:40:18 -0600 Subject: [PATCH 08/35] build(deps): add @clack/prompts and external-editor --- bun.lock | 28 +++++++++++++++++++++++++--- packages/cli-core/package.json | 2 ++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 3bd75d22..5b6df9f6 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "packages/cli": { "name": "clerk", - "version": "1.1.1", + "version": "1.2.0", "bin": { "clerk": "./bin/clerk", }, @@ -29,6 +29,7 @@ "clerk": "./src/cli.ts", }, "dependencies": { + "@clack/prompts": "^1.3.0", "@clerk/cli-extras": "workspace:*", "@commander-js/extra-typings": "^14.0.0", "@inquirer/ansi": "^2.0.5", @@ -38,6 +39,7 @@ "@napi-rs/keyring": "^1.3.0", "commander": "^14.0.3", "env-paths": "^4.0.0", + "external-editor": "^3.1.0", "magicast": "^0.5.2", "semver": "^7.7.4", "yaml": "^2.8.4", @@ -104,6 +106,10 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], + "@clack/core": ["@clack/core@1.3.0", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA=="], + + "@clack/prompts": ["@clack/prompts@1.3.0", "", { "dependencies": { "@clack/core": "1.3.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw=="], + "@clerk/backend": ["@clerk/backend@3.2.4", "", { "dependencies": { "@clerk/shared": "^4.4.0", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-zEK0xE47dVZl1AAovMbiG95kxXjEt9A2RKNEmIJveuXoNdX5AK5yV6vdCEFTblhymlFAYvTr+NHIe6iJAtp50g=="], "@clerk/cli-core": ["@clerk/cli-core@workspace:packages/cli-core"], @@ -284,7 +290,7 @@ "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], - "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], "clerk": ["clerk@workspace:packages/cli"], @@ -310,6 +316,8 @@ "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], + "external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], @@ -340,7 +348,7 @@ "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -380,6 +388,8 @@ "nano-staged": ["nano-staged@1.0.2", "", { "bin": { "nano-staged": "lib/bin.js" } }, "sha512-Fytar3zHLY99nlMfqPPbraxZodqQAHPpdPRyYaplL+lB9DCR6pUrafxbG+Btz4+7fO5Rm/+DO4ZeDO/nLSUMhw=="], + "os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "oxfmt": ["oxfmt@0.47.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.47.0", "@oxfmt/binding-android-arm64": "0.47.0", "@oxfmt/binding-darwin-arm64": "0.47.0", "@oxfmt/binding-darwin-x64": "0.47.0", "@oxfmt/binding-freebsd-x64": "0.47.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.47.0", "@oxfmt/binding-linux-arm-musleabihf": "0.47.0", "@oxfmt/binding-linux-arm64-gnu": "0.47.0", "@oxfmt/binding-linux-arm64-musl": "0.47.0", "@oxfmt/binding-linux-ppc64-gnu": "0.47.0", "@oxfmt/binding-linux-riscv64-gnu": "0.47.0", "@oxfmt/binding-linux-riscv64-musl": "0.47.0", "@oxfmt/binding-linux-s390x-gnu": "0.47.0", "@oxfmt/binding-linux-x64-gnu": "0.47.0", "@oxfmt/binding-linux-x64-musl": "0.47.0", "@oxfmt/binding-openharmony-arm64": "0.47.0", "@oxfmt/binding-win32-arm64-msvc": "0.47.0", "@oxfmt/binding-win32-ia32-msvc": "0.47.0", "@oxfmt/binding-win32-x64-msvc": "0.47.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA=="], @@ -438,6 +448,8 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -458,6 +470,8 @@ "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -486,6 +500,10 @@ "@inquirer/expand/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], + "@inquirer/external-editor/chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + + "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "@inquirer/input/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], "@inquirer/number/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], @@ -508,6 +526,10 @@ "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "@inquirer/editor/@inquirer/external-editor/chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + + "@inquirer/editor/@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], } } diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index d35abfdb..3a34e44a 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -16,6 +16,7 @@ "format:check": "oxfmt --check src/" }, "dependencies": { + "@clack/prompts": "^1.3.0", "@clerk/cli-extras": "workspace:*", "@commander-js/extra-typings": "^14.0.0", "@inquirer/ansi": "^2.0.5", @@ -25,6 +26,7 @@ "@napi-rs/keyring": "^1.3.0", "commander": "^14.0.3", "env-paths": "^4.0.0", + "external-editor": "^3.1.0", "magicast": "^0.5.2", "semver": "^7.7.4", "yaml": "^2.8.4" From f3439fea30339f206ad84be09d2439c44431eba0 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:42:15 -0600 Subject: [PATCH 09/35] feat(prompts): swap confirm to @clack/prompts --- packages/cli-core/src/lib/prompts.test.ts | 312 ++++------------------ packages/cli-core/src/lib/prompts.ts | 83 ++---- 2 files changed, 74 insertions(+), 321 deletions(-) diff --git a/packages/cli-core/src/lib/prompts.test.ts b/packages/cli-core/src/lib/prompts.test.ts index 793b73e3..faa33197 100644 --- a/packages/cli-core/src/lib/prompts.test.ts +++ b/packages/cli-core/src/lib/prompts.test.ts @@ -1,284 +1,88 @@ -import { test, expect, mock, spyOn, beforeEach } from "bun:test"; - -// Track calls to the underlying inquirer primitives -let lastConfirmArgs: unknown[] = []; -let confirmResult: boolean | Error = true; -let lastInputArgs: unknown[] = []; -let inputResult: string | Error = ""; -let lastPasswordArgs: unknown[] = []; -let passwordResult: string | Error = ""; -let lastEditorArgs: unknown[] = []; -let editorResult: string | Error = ""; - -mock.module("@inquirer/prompts", () => ({ - confirm: async (...args: unknown[]) => { - lastConfirmArgs = args; - if (confirmResult instanceof Error) throw confirmResult; +import { test, expect, mock, beforeEach } from "bun:test"; + +// Sentinel for cancellation. Tests choose this symbol; the mocked +// @clack/core.isCancel below treats it as the clack cancel signal. +const cancelSymbol = Symbol.for("clack:cancel"); + +let lastConfirmConfig: Record | undefined; +let confirmResult: boolean | symbol = true; +let lastTextConfig: Record | undefined; +let textResult: string | symbol = ""; +let lastPasswordConfig: Record | undefined; +let passwordResult: string | symbol = ""; + +mock.module("@clack/prompts", () => ({ + confirm: async (config: Record) => { + lastConfirmConfig = config; return confirmResult; }, - input: async (...args: unknown[]) => { - lastInputArgs = args; - if (inputResult instanceof Error) throw inputResult; - return inputResult; + text: async (config: Record) => { + lastTextConfig = config; + return textResult; }, - password: async (...args: unknown[]) => { - lastPasswordArgs = args; - if (passwordResult instanceof Error) throw passwordResult; + password: async (config: Record) => { + lastPasswordConfig = config; return passwordResult; }, - editor: async (...args: unknown[]) => { - lastEditorArgs = args; - if (editorResult instanceof Error) throw editorResult; - return editorResult; - }, - // Stub the other exports so this mock doesn't break other test files - // that share this process and import @inquirer/prompts. - select: async () => {}, - search: async () => {}, + // Stubs for other exports so this mock doesn't break sibling test files + // that share this process and may import @clack/prompts. + intro: () => {}, + outro: () => {}, + cancel: () => {}, + log: { info: () => {}, warn: () => {}, error: () => {}, success: () => {} }, + spinner: () => ({ start: () => {}, stop: () => {}, message: () => {} }), +})); + +mock.module("@clack/core", () => ({ + isCancel: (value: unknown): value is symbol => value === cancelSymbol, })); -const { confirm, text, password, editor } = await import("./prompts.ts"); +mock.module("external-editor", () => ({ + editAsync: ( + _text: string, + _cb: (err: Error | null, value: string) => void, + _opts?: Record, + ) => { + // Real implementation overridden in editor tests via spyOn. + }, +})); -const originalIsTTY = process.stdin.isTTY; -const originalPlatform = process.platform; +const { confirm } = await import("./prompts.ts"); beforeEach(() => { - lastConfirmArgs = []; + lastConfirmConfig = undefined; confirmResult = true; - lastInputArgs = []; - inputResult = ""; - lastPasswordArgs = []; + lastTextConfig = undefined; + textResult = ""; + lastPasswordConfig = undefined; passwordResult = ""; - lastEditorArgs = []; - editorResult = ""; - process.stdin.isTTY = originalIsTTY; - Object.defineProperty(process, "platform", { value: originalPlatform, writable: true }); }); -test("passes config through to inquirer confirm", async () => { - process.stdin.isTTY = true; +test("confirm passes message to clack and returns true", async () => { + confirmResult = true; const result = await confirm({ message: "Continue?" }); expect(result).toBe(true); - expect(lastConfirmArgs[0]).toEqual({ message: "Continue?" }); + expect(lastConfirmConfig).toEqual({ message: "Continue?", initialValue: undefined }); }); -test("returns false when user declines", async () => { - process.stdin.isTTY = true; +test("confirm returns false when user declines", async () => { confirmResult = false; const result = await confirm({ message: "Continue?" }); expect(result).toBe(false); }); -test("does not open tty when stdin is a TTY", async () => { - process.stdin.isTTY = true; - await confirm({ message: "Continue?" }); - - // Second arg (context) should be undefined — no tty input needed - expect(lastConfirmArgs[1]).toBeUndefined(); -}); - -test("opens controlling terminal as input when stdin is not a TTY", async () => { - process.stdin.isTTY = false; - - const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; - const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( - mockStream as any, - ); - - await confirm({ message: "Continue?" }); - - // Should use the platform-appropriate TTY path - const expectedPath = process.platform === "win32" ? "CONIN$" : "/dev/tty"; - expect(createReadStreamSpy).toHaveBeenCalledWith(expectedPath); - expect(lastConfirmArgs[1]).toEqual({ input: mockStream }); - expect(mockStream.close).toHaveBeenCalled(); - - createReadStreamSpy.mockRestore(); -}); - -test("closes tty stream even when confirm throws", async () => { - process.stdin.isTTY = false; - confirmResult = new Error("user cancelled"); - - const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; - const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( - mockStream as any, - ); - - await expect(confirm({ message: "Continue?" })).rejects.toThrow("user cancelled"); - expect(mockStream.close).toHaveBeenCalled(); - - createReadStreamSpy.mockRestore(); -}); - -test("text passes config through to inquirer input", async () => { - process.stdin.isTTY = true; - inputResult = "hello"; - const result = await text({ message: "Name?" }); - - expect(result).toBe("hello"); - expect(lastInputArgs[0]).toEqual({ message: "Name?" }); -}); - -test("text forwards default and validate options", async () => { - process.stdin.isTTY = true; - inputResult = "value"; - const validate = (v: string) => v.length > 0; - await text({ message: "Name?", default: "anon", validate }); - - expect(lastInputArgs[0]).toEqual({ message: "Name?", default: "anon", validate }); -}); - -test("text does not open tty when stdin is a TTY", async () => { - process.stdin.isTTY = true; - await text({ message: "Name?" }); - - expect(lastInputArgs[1]).toBeUndefined(); -}); - -test("text opens controlling terminal when stdin is not a TTY", async () => { - process.stdin.isTTY = false; - - const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; - const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( - mockStream as any, - ); - - await text({ message: "Name?" }); - - expect(lastInputArgs[1]).toEqual({ input: mockStream }); - expect(mockStream.close).toHaveBeenCalled(); - - createReadStreamSpy.mockRestore(); -}); - -test("text closes tty stream even when inquirer input throws", async () => { - process.stdin.isTTY = false; - inputResult = new Error("cancelled"); - - const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; - const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( - mockStream as any, - ); - - await expect(text({ message: "Name?" })).rejects.toThrow("cancelled"); - expect(mockStream.close).toHaveBeenCalled(); - - createReadStreamSpy.mockRestore(); -}); - -test("password passes config through to inquirer password", async () => { - process.stdin.isTTY = true; - passwordResult = "s3cret"; - const result = await password({ message: "Secret?" }); - - expect(result).toBe("s3cret"); - expect(lastPasswordArgs[0]).toEqual({ message: "Secret?" }); -}); - -test("password forwards validate option", async () => { - process.stdin.isTTY = true; - const validate = (v: string) => v.length >= 8; - await password({ message: "Secret?", validate }); - - expect(lastPasswordArgs[0]).toEqual({ message: "Secret?", validate }); -}); - -test("password does not open tty when stdin is a TTY", async () => { - process.stdin.isTTY = true; - await password({ message: "Secret?" }); - - expect(lastPasswordArgs[1]).toBeUndefined(); -}); - -test("password opens controlling terminal when stdin is not a TTY", async () => { - process.stdin.isTTY = false; - - const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; - const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( - mockStream as any, - ); - - await password({ message: "Secret?" }); - - expect(lastPasswordArgs[1]).toEqual({ input: mockStream }); - expect(mockStream.close).toHaveBeenCalled(); - - createReadStreamSpy.mockRestore(); -}); - -test("password closes tty stream even when inquirer password throws", async () => { - process.stdin.isTTY = false; - passwordResult = new Error("cancelled"); - - const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; - const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( - mockStream as any, - ); - - await expect(password({ message: "Secret?" })).rejects.toThrow("cancelled"); - expect(mockStream.close).toHaveBeenCalled(); - - createReadStreamSpy.mockRestore(); -}); - -test("editor passes config through to inquirer editor", async () => { - process.stdin.isTTY = true; - editorResult = "body content"; - const result = await editor({ message: "Notes?" }); +test("confirm translates default to initialValue", async () => { + confirmResult = true; + await confirm({ message: "Continue?", default: false }); - expect(result).toBe("body content"); - expect(lastEditorArgs[0]).toEqual({ message: "Notes?" }); + expect(lastConfirmConfig).toEqual({ message: "Continue?", initialValue: false }); }); -test("editor forwards default, postfix, and validate options", async () => { - process.stdin.isTTY = true; - const validate = (v: string) => v.length > 0; - await editor({ message: "Notes?", default: "draft", postfix: ".md", validate }); +test("confirm throws UserAbortError when clack returns cancel symbol", async () => { + confirmResult = cancelSymbol; - expect(lastEditorArgs[0]).toEqual({ - message: "Notes?", - default: "draft", - postfix: ".md", - validate, + await expect(confirm({ message: "Continue?" })).rejects.toMatchObject({ + name: "UserAbortError", }); }); - -test("editor does not open tty when stdin is a TTY", async () => { - process.stdin.isTTY = true; - await editor({ message: "Notes?" }); - - expect(lastEditorArgs[1]).toBeUndefined(); -}); - -test("editor opens controlling terminal when stdin is not a TTY", async () => { - process.stdin.isTTY = false; - - const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; - const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( - mockStream as any, - ); - - await editor({ message: "Notes?" }); - - expect(lastEditorArgs[1]).toEqual({ input: mockStream }); - expect(mockStream.close).toHaveBeenCalled(); - - createReadStreamSpy.mockRestore(); -}); - -test("editor closes tty stream even when inquirer editor throws", async () => { - process.stdin.isTTY = false; - editorResult = new Error("cancelled"); - - const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; - const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( - mockStream as any, - ); - - await expect(editor({ message: "Notes?" })).rejects.toThrow("cancelled"); - expect(mockStream.close).toHaveBeenCalled(); - - createReadStreamSpy.mockRestore(); -}); diff --git a/packages/cli-core/src/lib/prompts.ts b/packages/cli-core/src/lib/prompts.ts index deb1856a..e7a06eea 100644 --- a/packages/cli-core/src/lib/prompts.ts +++ b/packages/cli-core/src/lib/prompts.ts @@ -1,74 +1,23 @@ /** - * Prompt helpers that handle edge cases like piped stdin. - * - * When stdin is piped (e.g. `clerk config pull | clerk config patch`), - * it gets consumed reading the input data. Interactive prompts then fail - * because stdin is at EOF. These helpers open the controlling terminal - * as a fallback input so prompts can still read from the user's terminal. - * - * Uses the shared ttyContext from lib/listage.ts for consistent error handling. + * Wrappers around @clack/prompts primitives. Every prompt translates + * clack's cancel symbol into a UserAbortError via throwUserAbort() so + * call sites never deal with the symbol directly. */ -import { - confirm as inquirerConfirm, - input as inquirerInput, - password as inquirerPassword, - editor as inquirerEditor, -} from "@inquirer/prompts"; -import { ttyContext } from "./listage.ts"; +import { confirm as clackConfirm } from "@clack/prompts"; +import { isCancel } from "@clack/core"; +import { throwUserAbort } from "./errors.ts"; -/** - * Like `confirm()` from @inquirer/prompts, but works even when stdin - * has been consumed by a pipe. Falls back to reading from the - * controlling terminal. - */ -export async function confirm(config: { message: string; default?: boolean }): Promise { - const tty = ttyContext(); - try { - return await inquirerConfirm(config, tty ? { input: tty.input } : undefined); - } finally { - tty?.close(); - } -} - -/** Single-line text input. Named `text` to match the post-clack API. */ -export async function text(config: { - message: string; - default?: string; - validate?: (value: string) => boolean | string | Promise; -}): Promise { - const tty = ttyContext(); - try { - return await inquirerInput(config, tty ? { input: tty.input } : undefined); - } finally { - tty?.close(); - } -} - -/** Masked password input. */ -export async function password(config: { - message: string; - validate?: (value: string) => boolean | string | Promise; -}): Promise { - const tty = ttyContext(); - try { - return await inquirerPassword(config, tty ? { input: tty.input } : undefined); - } finally { - tty?.close(); - } +function unwrap(value: T | symbol): T { + if (isCancel(value)) throwUserAbort(); + return value as T; } -/** Multiline editor input ($EDITOR shellout). */ -export async function editor(config: { - message: string; - default?: string; - postfix?: string; - validate?: (value: string) => boolean | string | Promise; -}): Promise { - const tty = ttyContext(); - try { - return await inquirerEditor(config, tty ? { input: tty.input } : undefined); - } finally { - tty?.close(); - } +/** Yes/no confirmation. */ +export async function confirm(config: { message: string; default?: boolean }): Promise { + const result = await clackConfirm({ + message: config.message, + initialValue: config.default, + }); + return unwrap(result); } From 645d1e7931af6f15e593c4ee9fac16788a307741 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:42:54 -0600 Subject: [PATCH 10/35] feat(prompts): swap text to @clack/prompts --- packages/cli-core/src/lib/prompts.test.ts | 36 ++++++++++++++++++++++- packages/cli-core/src/lib/prompts.ts | 18 +++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/lib/prompts.test.ts b/packages/cli-core/src/lib/prompts.test.ts index faa33197..e389ee7a 100644 --- a/packages/cli-core/src/lib/prompts.test.ts +++ b/packages/cli-core/src/lib/prompts.test.ts @@ -47,7 +47,7 @@ mock.module("external-editor", () => ({ }, })); -const { confirm } = await import("./prompts.ts"); +const { confirm, text } = await import("./prompts.ts"); beforeEach(() => { lastConfirmConfig = undefined; @@ -86,3 +86,37 @@ test("confirm throws UserAbortError when clack returns cancel symbol", async () name: "UserAbortError", }); }); + +test("text passes message to clack and returns the typed value", async () => { + textResult = "hello"; + const result = await text({ message: "Name?" }); + + expect(result).toBe("hello"); + expect(lastTextConfig).toEqual({ + message: "Name?", + initialValue: undefined, + placeholder: undefined, + validate: undefined, + }); +}); + +test("text forwards default, placeholder, and validate to clack", async () => { + textResult = "value"; + const validate = (v: string | undefined) => (v?.trim() ? undefined : "required"); + await text({ message: "Name?", default: "anon", placeholder: "type a name", validate }); + + expect(lastTextConfig).toEqual({ + message: "Name?", + initialValue: "anon", + placeholder: "type a name", + validate, + }); +}); + +test("text throws UserAbortError when clack returns cancel symbol", async () => { + textResult = cancelSymbol; + + await expect(text({ message: "Name?" })).rejects.toMatchObject({ + name: "UserAbortError", + }); +}); diff --git a/packages/cli-core/src/lib/prompts.ts b/packages/cli-core/src/lib/prompts.ts index e7a06eea..5edacc20 100644 --- a/packages/cli-core/src/lib/prompts.ts +++ b/packages/cli-core/src/lib/prompts.ts @@ -4,7 +4,7 @@ * call sites never deal with the symbol directly. */ -import { confirm as clackConfirm } from "@clack/prompts"; +import { confirm as clackConfirm, text as clackText } from "@clack/prompts"; import { isCancel } from "@clack/core"; import { throwUserAbort } from "./errors.ts"; @@ -21,3 +21,19 @@ export async function confirm(config: { message: string; default?: boolean }): P }); return unwrap(result); } + +/** Single-line text input. */ +export async function text(config: { + message: string; + default?: string; + placeholder?: string; + validate?: (value: string | undefined) => string | Error | undefined; +}): Promise { + const result = await clackText({ + message: config.message, + initialValue: config.default, + placeholder: config.placeholder, + validate: config.validate, + }); + return unwrap(result); +} From c4110d1f713b59a34b143b646e33880c9dae9241 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:43:24 -0600 Subject: [PATCH 11/35] feat(prompts): swap password to @clack/prompts --- packages/cli-core/src/lib/prompts.test.ts | 26 ++++++++++++++++++++++- packages/cli-core/src/lib/prompts.ts | 18 +++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/lib/prompts.test.ts b/packages/cli-core/src/lib/prompts.test.ts index e389ee7a..1978c972 100644 --- a/packages/cli-core/src/lib/prompts.test.ts +++ b/packages/cli-core/src/lib/prompts.test.ts @@ -47,7 +47,7 @@ mock.module("external-editor", () => ({ }, })); -const { confirm, text } = await import("./prompts.ts"); +const { confirm, text, password } = await import("./prompts.ts"); beforeEach(() => { lastConfirmConfig = undefined; @@ -120,3 +120,27 @@ test("text throws UserAbortError when clack returns cancel symbol", async () => name: "UserAbortError", }); }); + +test("password passes message to clack and returns the typed value", async () => { + passwordResult = "s3cret"; + const result = await password({ message: "Secret?" }); + + expect(result).toBe("s3cret"); + expect(lastPasswordConfig).toEqual({ message: "Secret?", validate: undefined }); +}); + +test("password forwards validate to clack", async () => { + passwordResult = "ok"; + const validate = (v: string | undefined) => ((v?.length ?? 0) >= 8 ? undefined : "too short"); + await password({ message: "Secret?", validate }); + + expect(lastPasswordConfig).toEqual({ message: "Secret?", validate }); +}); + +test("password throws UserAbortError when clack returns cancel symbol", async () => { + passwordResult = cancelSymbol; + + await expect(password({ message: "Secret?" })).rejects.toMatchObject({ + name: "UserAbortError", + }); +}); diff --git a/packages/cli-core/src/lib/prompts.ts b/packages/cli-core/src/lib/prompts.ts index 5edacc20..77b20652 100644 --- a/packages/cli-core/src/lib/prompts.ts +++ b/packages/cli-core/src/lib/prompts.ts @@ -4,7 +4,11 @@ * call sites never deal with the symbol directly. */ -import { confirm as clackConfirm, text as clackText } from "@clack/prompts"; +import { + confirm as clackConfirm, + text as clackText, + password as clackPassword, +} from "@clack/prompts"; import { isCancel } from "@clack/core"; import { throwUserAbort } from "./errors.ts"; @@ -37,3 +41,15 @@ export async function text(config: { }); return unwrap(result); } + +/** Masked password input. */ +export async function password(config: { + message: string; + validate?: (value: string | undefined) => string | Error | undefined; +}): Promise { + const result = await clackPassword({ + message: config.message, + validate: config.validate, + }); + return unwrap(result); +} From d0fdddd88edb68936d88a54ce7a3be9b11944f57 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:44:37 -0600 Subject: [PATCH 12/35] feat(prompts): implement editor via external-editor --- packages/cli-core/src/lib/prompts.test.ts | 79 +++++++++++++++++++++-- packages/cli-core/src/lib/prompts.ts | 27 ++++++++ 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/lib/prompts.test.ts b/packages/cli-core/src/lib/prompts.test.ts index 1978c972..17da9ed5 100644 --- a/packages/cli-core/src/lib/prompts.test.ts +++ b/packages/cli-core/src/lib/prompts.test.ts @@ -1,4 +1,5 @@ import { test, expect, mock, beforeEach } from "bun:test"; +import { captureLog } from "../test/lib/stubs.ts"; // Sentinel for cancellation. Tests choose this symbol; the mocked // @clack/core.isCancel below treats it as the clack cancel signal. @@ -11,6 +12,13 @@ let textResult: string | symbol = ""; let lastPasswordConfig: Record | undefined; let passwordResult: string | symbol = ""; +interface EditorCall { + text: string; + opts: Record | undefined; +} +let editorCalls: EditorCall[] = []; +let editorResults: string[] = []; + mock.module("@clack/prompts", () => ({ confirm: async (config: Record) => { lastConfirmConfig = config; @@ -39,15 +47,19 @@ mock.module("@clack/core", () => ({ mock.module("external-editor", () => ({ editAsync: ( - _text: string, - _cb: (err: Error | null, value: string) => void, - _opts?: Record, + text: string, + cb: (err: Error | null, value: string) => void, + opts?: Record, ) => { - // Real implementation overridden in editor tests via spyOn. + editorCalls.push({ text, opts }); + const next = editorResults.shift() ?? ""; + // Defer to next microtask so the wrapper's Promise resolves + // through the same path it would in production. + queueMicrotask(() => cb(null, next)); }, })); -const { confirm, text, password } = await import("./prompts.ts"); +const { confirm, text, password, editor } = await import("./prompts.ts"); beforeEach(() => { lastConfirmConfig = undefined; @@ -56,6 +68,8 @@ beforeEach(() => { textResult = ""; lastPasswordConfig = undefined; passwordResult = ""; + editorCalls = []; + editorResults = []; }); test("confirm passes message to clack and returns true", async () => { @@ -144,3 +158,58 @@ test("password throws UserAbortError when clack returns cancel symbol", async () name: "UserAbortError", }); }); + +test("editor invokes external-editor with the default body and postfix", async () => { + editorResults = ["my notes"]; + const captured = captureLog(); + + const result = await captured.run(() => + editor({ message: "Notes?", default: "draft", postfix: ".md" }), + ); + + expect(result).toBe("my notes"); + expect(editorCalls).toHaveLength(1); + expect(editorCalls[0]?.text).toBe("draft"); + expect(editorCalls[0]?.opts).toEqual({ postfix: ".md" }); + expect(captured.err).toContain("Notes?"); +}); + +test("editor strips a single trailing newline from the editor output", async () => { + editorResults = ["body\n"]; + const captured = captureLog(); + + const result = await captured.run(() => editor({ message: "Notes?" })); + + expect(result).toBe("body"); +}); + +test("editor re-prompts when validate returns an error message", async () => { + editorResults = ["", "good"]; + const captured = captureLog(); + + const result = await captured.run(() => + editor({ + message: "Notes?", + validate: (v) => (v?.trim() ? undefined : "required"), + }), + ); + + expect(result).toBe("good"); + expect(editorCalls).toHaveLength(2); + expect(captured.err).toContain("required"); +}); + +test("editor re-prompts when validate returns an Error", async () => { + editorResults = ["bad", "ok"]; + const captured = captureLog(); + + const result = await captured.run(() => + editor({ + message: "Notes?", + validate: (v) => (v === "ok" ? undefined : new Error("not ok")), + }), + ); + + expect(result).toBe("ok"); + expect(captured.err).toContain("not ok"); +}); diff --git a/packages/cli-core/src/lib/prompts.ts b/packages/cli-core/src/lib/prompts.ts index 77b20652..58754761 100644 --- a/packages/cli-core/src/lib/prompts.ts +++ b/packages/cli-core/src/lib/prompts.ts @@ -10,7 +10,9 @@ import { password as clackPassword, } from "@clack/prompts"; import { isCancel } from "@clack/core"; +import { editAsync } from "external-editor"; import { throwUserAbort } from "./errors.ts"; +import { log } from "./log.ts"; function unwrap(value: T | symbol): T { if (isCancel(value)) throwUserAbort(); @@ -53,3 +55,28 @@ export async function password(config: { }); return unwrap(result); } + +/** Multi-line editor input. Shells out to $EDITOR via external-editor. */ +export async function editor(config: { + message: string; + default?: string; + postfix?: string; + validate?: (value: string | undefined) => string | Error | undefined; +}): Promise { + log.info(config.message); + + for (;;) { + const raw = await new Promise((resolve, reject) => { + editAsync(config.default ?? "", (err, value) => (err ? reject(err) : resolve(value)), { + postfix: config.postfix, + }); + }); + + const trimmed = raw.replace(/\n$/, ""); + if (!config.validate) return trimmed; + + const verdict = config.validate(trimmed); + if (verdict === undefined) return trimmed; + log.warn(typeof verdict === "string" ? verdict : verdict.message); + } +} From 0a64cacd4a8f7c7b4d46f8cbfe20fbedd1e262d4 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:47:01 -0600 Subject: [PATCH 13/35] refactor(prompts): adopt clack validator contract at call sites --- .../cli-core/src/commands/api/interactive.ts | 8 ++++---- .../cli-core/src/commands/init/bootstrap.ts | 20 ++++++++++++++----- .../src/commands/users/create-wizard.ts | 2 +- packages/cli-core/src/lib/app-picker.ts | 2 +- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/cli-core/src/commands/api/interactive.ts b/packages/cli-core/src/commands/api/interactive.ts index 7c1653c6..0f42d7d4 100644 --- a/packages/cli-core/src/commands/api/interactive.ts +++ b/packages/cli-core/src/commands/api/interactive.ts @@ -52,7 +52,7 @@ export async function apiInteractive(options: ApiOptions): Promise { for (const param of endpoint.pathParams) { const value = await text({ message: param.description ? `${param.name} (${param.description}):` : `${param.name}:`, - validate: (v: string) => v.trim().length > 0 || `${param.name} is required`, + validate: (v) => (v?.trim() ? undefined : `${param.name} is required`), }); resolvedPath = resolvedPath.replace(`{${param.name}}`, value.trim()); } @@ -70,10 +70,10 @@ export async function apiInteractive(options: ApiOptions): Promise { message: "Enter request body (JSON):", default: "{}", postfix: ".json", - validate: (v: string) => { + validate: (v) => { try { - JSON.parse(v); - return true; + JSON.parse(v ?? ""); + return undefined; } catch { return "Invalid JSON"; } diff --git a/packages/cli-core/src/commands/init/bootstrap.ts b/packages/cli-core/src/commands/init/bootstrap.ts index 1ad706b1..79f183ef 100644 --- a/packages/cli-core/src/commands/init/bootstrap.ts +++ b/packages/cli-core/src/commands/init/bootstrap.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { statSync } from "node:fs"; import { confirm, text } from "../../lib/prompts.ts"; import { search, filterChoices } from "../../lib/listage.ts"; import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js"; @@ -81,6 +82,14 @@ export function resolvePackageManager(): PackageManager { return "npm"; } +function dirExistsSync(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + function validateProjectName(value: string): string | true { if (!value.trim()) return "Project name is required"; if (/[A-Z]/.test(value)) return "Project name must be lowercase"; @@ -108,13 +117,14 @@ async function askProjectName(entry: BootstrapEntry, cwd: string): Promise { - const valid = validateProjectName(value); + validate: (value) => { + const trimmed = value?.trim() ?? ""; + const valid = validateProjectName(trimmed); if (valid !== true) return valid; - if (await dirExists(join(cwd, value.trim()))) { - return `Directory '${value.trim()}' already exists. Pick a different name.`; + if (dirExistsSync(join(cwd, trimmed))) { + return `Directory '${trimmed}' already exists. Pick a different name.`; } - return true; + return undefined; }, }); return name.trim(); diff --git a/packages/cli-core/src/commands/users/create-wizard.ts b/packages/cli-core/src/commands/users/create-wizard.ts index 0bf53612..32b0b5e0 100644 --- a/packages/cli-core/src/commands/users/create-wizard.ts +++ b/packages/cli-core/src/commands/users/create-wizard.ts @@ -101,7 +101,7 @@ async function loadSettings( async function promptField(field: FieldDef, required: boolean): Promise { const message = required ? `${field.message} *` : `${field.message} (optional)`; const validate = required - ? (value: string) => value.trim().length > 0 || `${field.message} is required` + ? (value: string | undefined) => (value?.trim() ? undefined : `${field.message} is required`) : undefined; if (field.isPassword) { return password({ message, validate }); diff --git a/packages/cli-core/src/lib/app-picker.ts b/packages/cli-core/src/lib/app-picker.ts index 2fb2562d..03d4c912 100644 --- a/packages/cli-core/src/lib/app-picker.ts +++ b/packages/cli-core/src/lib/app-picker.ts @@ -65,7 +65,7 @@ export async function pickOrCreateApp(opts: { if (selectedId === CREATE_NEW_APP) { const name = await text({ message: "Application name:", - validate: (v) => (v.trim() ? true : "Application name cannot be empty"), + validate: (v) => (v?.trim() ? undefined : "Application name cannot be empty"), }); const created = await withApiContext( createApplication(name.trim()), From d0cce0627e718694ae20a24154f195710431c346 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:51:15 -0600 Subject: [PATCH 14/35] test: route prompt mocks through lib/prompts.ts --- packages/cli-core/src/commands/api/index.test.ts | 4 ++-- .../cli-core/src/commands/api/interactive.test.ts | 9 +-------- packages/cli-core/src/commands/billing/index.test.ts | 4 ++-- packages/cli-core/src/commands/config/push.test.ts | 4 ++-- packages/cli-core/src/commands/deploy/index.test.ts | 10 +--------- packages/cli-core/src/commands/link/index.test.ts | 10 +--------- packages/cli-core/src/commands/orgs/index.test.ts | 9 +-------- packages/cli-core/src/commands/unlink/index.test.ts | 7 +------ .../src/commands/users/create-wizard.test.ts | 4 ++-- packages/cli-core/src/commands/users/create.test.ts | 3 +-- packages/cli-core/src/test/lib/stubs.ts | 12 ++++++++++++ 11 files changed, 26 insertions(+), 50 deletions(-) diff --git a/packages/cli-core/src/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index d66b3613..8276c2ef 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -8,7 +8,7 @@ import { credentialStoreStubs, gitStubs, configStubs, - promptsStubs, + libPromptsStubs, stubFetch, } from "../../test/lib/stubs.ts"; @@ -171,7 +171,7 @@ mock.module("../../lib/config.ts", () => ({ }, })); -mock.module("@inquirer/prompts", () => promptsStubs); +mock.module("../../lib/prompts.ts", () => libPromptsStubs); const { _setConfigDir } = (await import("../../lib/config.ts")) as any; const { setMode } = (await import("../../mode.ts")) as any; diff --git a/packages/cli-core/src/commands/api/interactive.test.ts b/packages/cli-core/src/commands/api/interactive.test.ts index c6148027..1774561c 100644 --- a/packages/cli-core/src/commands/api/interactive.test.ts +++ b/packages/cli-core/src/commands/api/interactive.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { captureLog, promptsStubs, listageStubs, stubFetch } from "../../test/lib/stubs.ts"; +import { captureLog, listageStubs, stubFetch } from "../../test/lib/stubs.ts"; let _mode = "human"; mock.module("../../mode.ts", () => ({ @@ -56,13 +56,6 @@ let confirmResponses: boolean[] = []; // Track fetch calls made by the real api handler let fetchCalls: { url: string; method: string }[] = []; -mock.module("@inquirer/prompts", () => ({ - ...promptsStubs, - select: async () => selectResponses.shift(), - input: async () => inputResponses.shift(), - confirm: async () => confirmResponses.shift(), -})); - mock.module("../../lib/prompts.ts", () => ({ confirm: async () => confirmResponses.shift(), text: async () => inputResponses.shift(), diff --git a/packages/cli-core/src/commands/billing/index.test.ts b/packages/cli-core/src/commands/billing/index.test.ts index 6435aa8c..1b829fa3 100644 --- a/packages/cli-core/src/commands/billing/index.test.ts +++ b/packages/cli-core/src/commands/billing/index.test.ts @@ -7,13 +7,13 @@ import { captureLog, credentialStoreStubs, gitStubs, - promptsStubs, + libPromptsStubs, stubFetch, } from "../../test/lib/stubs.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); -mock.module("@inquirer/prompts", () => promptsStubs); +mock.module("../../lib/prompts.ts", () => libPromptsStubs); mock.module("../../lib/spinner.ts", () => ({ withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/config/push.test.ts b/packages/cli-core/src/commands/config/push.test.ts index 5b720dad..9317480b 100644 --- a/packages/cli-core/src/commands/config/push.test.ts +++ b/packages/cli-core/src/commands/config/push.test.ts @@ -7,14 +7,14 @@ import { captureLog, credentialStoreStubs, gitStubs, - promptsStubs, + libPromptsStubs, stubFetch, } from "../../test/lib/stubs.ts"; import { printDiff, hasConfigChanges } from "./push.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); -mock.module("@inquirer/prompts", () => promptsStubs); +mock.module("../../lib/prompts.ts", () => libPromptsStubs); mock.module("../../lib/spinner.ts", () => ({ withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 02ef2eff..247953aa 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { captureLog, promptsStubs, listageStubs } from "../../test/lib/stubs.ts"; +import { captureLog, listageStubs } from "../../test/lib/stubs.ts"; const mockIsAgent = mock(); let _modeOverride: string | undefined; @@ -20,14 +20,6 @@ const mockInput = mock(); const mockConfirm = mock(); const mockPassword = mock(); -mock.module("@inquirer/prompts", () => ({ - ...promptsStubs, - select: (...args: unknown[]) => mockSelect(...args), - input: (...args: unknown[]) => mockInput(...args), - confirm: (...args: unknown[]) => mockConfirm(...args), - password: (...args: unknown[]) => mockPassword(...args), -})); - mock.module("../../lib/prompts.ts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), text: (...args: unknown[]) => mockInput(...args), diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index b985a9d3..64831245 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -5,7 +5,6 @@ import { credentialStoreStubs, autolinkStubs, gitStubs, - promptsStubs, listageStubs, } from "../../test/lib/stubs.ts"; import { PlapiError } from "../../lib/errors.ts"; @@ -80,13 +79,6 @@ mock.module("../../lib/git.ts", () => ({ const mockSearch = mock(); const mockConfirm = mock(); const mockInput = mock(); -mock.module("@inquirer/prompts", () => ({ - ...promptsStubs, - search: (...args: unknown[]) => mockSearch(...args), - confirm: (...args: unknown[]) => mockConfirm(...args), - input: (...args: unknown[]) => mockInput(...args), -})); - mock.module("../../lib/prompts.ts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), text: (...args: unknown[]) => mockInput(...args), @@ -1242,7 +1234,7 @@ describe("link", () => { expect(capturedValidate).toBeDefined(); expect(capturedValidate!("")).toBe("Application name cannot be empty"); expect(capturedValidate!(" ")).toBe("Application name cannot be empty"); - expect(capturedValidate!("My App")).toBe(true); + expect(capturedValidate!("My App")).toBeUndefined(); }); test("propagates createApplication failure without linking", async () => { diff --git a/packages/cli-core/src/commands/orgs/index.test.ts b/packages/cli-core/src/commands/orgs/index.test.ts index 84a90250..45a68984 100644 --- a/packages/cli-core/src/commands/orgs/index.test.ts +++ b/packages/cli-core/src/commands/orgs/index.test.ts @@ -3,17 +3,10 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { _setConfigDir, setProfile } from "../../lib/config.ts"; -import { - captureLog, - credentialStoreStubs, - gitStubs, - promptsStubs, - stubFetch, -} from "../../test/lib/stubs.ts"; +import { captureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); -mock.module("@inquirer/prompts", () => promptsStubs); mock.module("../../lib/spinner.ts", () => ({ withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/unlink/index.test.ts b/packages/cli-core/src/commands/unlink/index.test.ts index d8fbdf16..3db63f09 100644 --- a/packages/cli-core/src/commands/unlink/index.test.ts +++ b/packages/cli-core/src/commands/unlink/index.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { captureLog, configStubs, gitStubs, promptsStubs } from "../../test/lib/stubs.ts"; +import { captureLog, configStubs, gitStubs } from "../../test/lib/stubs.ts"; const mockIsAgent = mock(); const mockIsHuman = mock(); @@ -30,11 +30,6 @@ mock.module("../../lib/git.ts", () => ({ })); const mockConfirm = mock(); -mock.module("@inquirer/prompts", () => ({ - ...promptsStubs, - confirm: (...args: unknown[]) => mockConfirm(...args), -})); - mock.module("../../lib/prompts.ts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), text: async () => "", diff --git a/packages/cli-core/src/commands/users/create-wizard.test.ts b/packages/cli-core/src/commands/users/create-wizard.test.ts index 7363c852..fcd25c15 100644 --- a/packages/cli-core/src/commands/users/create-wizard.test.ts +++ b/packages/cli-core/src/commands/users/create-wizard.test.ts @@ -17,8 +17,8 @@ mock.module("../../lib/fapi.ts", () => ({ instanceType: pk.startsWith("pk_test_") ? "development" : "production", }), })); -mock.module("@inquirer/prompts", () => ({ - input: (...args: unknown[]) => mockInput(...args), +mock.module("../../lib/prompts.ts", () => ({ + text: (...args: unknown[]) => mockInput(...args), password: (...args: unknown[]) => mockPassword(...args), confirm: async () => true, editor: async () => "", diff --git a/packages/cli-core/src/commands/users/create.test.ts b/packages/cli-core/src/commands/users/create.test.ts index f19a1c84..fdae3024 100644 --- a/packages/cli-core/src/commands/users/create.test.ts +++ b/packages/cli-core/src/commands/users/create.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { captureLog, promptsStubs } from "../../test/lib/stubs.ts"; +import { captureLog } from "../../test/lib/stubs.ts"; import { BapiError, CliError, ERROR_CODE, EXIT_CODE } from "../../lib/errors.ts"; const mockResolveBapiSecretKey = mock(); @@ -27,7 +27,6 @@ mock.module("./create-wizard.ts", () => ({ runCreateWizard: (...args: unknown[]) => mockRunCreateWizard(...args), })); -mock.module("@inquirer/prompts", () => promptsStubs); mock.module("../../lib/spinner.ts", () => ({ withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index 93faff37..6de0cac8 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -95,6 +95,18 @@ export const promptsStubs = { editor: async () => "{}", }; +/** + * Stubs for `lib/prompts.ts`. Use these when mocking the wrapper module + * directly rather than `@inquirer/prompts`. The wrapper exposes `text` in + * place of inquirer's `input`. + */ +export const libPromptsStubs = { + confirm: async () => true, + text: async () => "", + password: async () => "", + editor: async () => "{}", +}; + export { listageStubs } from "./listage-stubs.ts"; export const tokenExchangeStubs = { From ec9130978d203c994284afc571f76a668b9dab18 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:55:18 -0600 Subject: [PATCH 15/35] feat(listage): swap select/search internals to @clack/prompts --- packages/cli-core/src/lib/app-picker.ts | 5 +- packages/cli-core/src/lib/listage.ts | 642 ++++-------------------- 2 files changed, 106 insertions(+), 541 deletions(-) diff --git a/packages/cli-core/src/lib/app-picker.ts b/packages/cli-core/src/lib/app-picker.ts index 03d4c912..abd846bf 100644 --- a/packages/cli-core/src/lib/app-picker.ts +++ b/packages/cli-core/src/lib/app-picker.ts @@ -5,7 +5,7 @@ */ import { text } from "./prompts.ts"; -import { cyan, dim } from "./color.ts"; +import { cyan } from "./color.ts"; import { CliError, ERROR_CODE, PlapiError, withApiContext } from "./errors.ts"; import { search } from "./listage.ts"; import { log } from "./log.ts"; @@ -47,9 +47,8 @@ export async function pickOrCreateApp(opts: { }): Promise { const appChoices = opts.apps.map((a) => ({ name: appLabel(a), value: a.application_id })); const createChoice = { - name: "+ Create a new application", + name: cyan("+ Create a new application"), value: CREATE_NEW_APP, - style: (text: string, isActive: boolean) => (isActive ? cyan(text) : dim(text)), }; const selectedId = await search({ diff --git a/packages/cli-core/src/lib/listage.ts b/packages/cli-core/src/lib/listage.ts index b7c1d931..a8afab9d 100644 --- a/packages/cli-core/src/lib/listage.ts +++ b/packages/cli-core/src/lib/listage.ts @@ -1,154 +1,60 @@ /** - * Interactive list prompts with scroll indicators. - * - * Custom select/search prompts built on @inquirer/core that show - * "↑ N more above" / "↓ N more below" when the list overflows the - * visible page. Also includes the piped-stdin TTY fallback so prompts - * work even when stdin has been consumed by a pipe. + * List prompts powered by @clack/prompts. Provides select() and search() + * with the same exported shape the rest of the codebase expects. Cancel + * is translated to UserAbortError at the wrapper boundary. */ -import { createReadStream } from "node:fs"; import { - createPrompt, - useState, - useKeypress, - usePrefix, - usePagination, - useRef, - useMemo, - useEffect, - isBackspaceKey, - isEnterKey, - isUpKey, - isDownKey, - isNumberKey, - isTabKey, - Separator, - ValidationError, - makeTheme, -} from "@inquirer/core"; -import type { Theme } from "@inquirer/core"; -import { cursorHide, cursorShow } from "@inquirer/ansi"; -import { styleText } from "node:util"; -import figures from "@inquirer/figures"; -import type { PartialDeep } from "@inquirer/type"; + select as clackSelect, + autocomplete as clackAutocomplete, + type Option as ClackOption, +} from "@clack/prompts"; +import { isCancel } from "@clack/core"; +import { throwUserAbort } from "./errors.ts"; // --------------------------------------------------------------------------- -// Shared utilities +// Separator — kept as a tiny local class so call sites compile unchanged. +// Rendered as a disabled clack option with a dim label. // --------------------------------------------------------------------------- -const TTY_PATH = process.platform === "win32" ? "CONIN$" : "/dev/tty"; - -export function ttyContext(): { input: NodeJS.ReadableStream; close: () => void } | undefined { - if (process.stdin.isTTY) return undefined; - try { - const input = createReadStream(TTY_PATH); - // Swallow open errors (Docker without --tty, detached CI runners, Windows - // sessions without CONIN$) so the prompt falls back to the default stdin - // instead of crashing with an unhandled error event. - input.on("error", () => {}); - return { input, close: () => input.close() }; - } catch { - return undefined; - } -} - -/** Case-insensitive name filter — shared by search-based prompts. */ -export function filterChoices( - choices: T[], - term: string | undefined, -): T[] { - if (!term) return choices; - const lower = term.toLowerCase(); - return choices.filter((c) => c.name.toLowerCase().includes(lower)); -} - -// --------------------------------------------------------------------------- -// Scroll indicator helpers -// --------------------------------------------------------------------------- - -/** - * Calculate how many items sit above/below the visible page window. - * - * Approximates `usePagination`'s `usePointerPosition` logic for - * `loop: false`, assuming every item renders as a single line. - * - * Known imprecisions: - * - For odd `pageSize` values (e.g. 7, middle=3), the counts may be off - * by ±1 near the boundary where the window starts scrolling, because - * `usePagination` only slides once the active item would cross out of - * the visible range, whereas this function slides at `active > middle`. - * - Long labels that wrap in narrow terminals produce multi-line rendered - * items, causing the counts to drift from the actual rendered window. - * - * These are cosmetic — the indicator text ("3 more above") may be off by - * one in edge cases but the prompt remains fully functional. - */ -export function scrollBounds( - totalItems: number, - active: number, - pageSize: number, -): { above: number; below: number } { - if (totalItems <= pageSize) return { above: 0, below: 0 }; - - const middle = Math.floor(pageSize / 2); - const spaceBelow = totalItems - active; - - let firstVisible: number; - if (spaceBelow < pageSize - middle) { - // Near the bottom — window slides to show the last pageSize items. - firstVisible = totalItems - pageSize; - } else if (active <= middle) { - // Near the top — window starts at 0. - firstVisible = 0; - } else { - // Middle — active is roughly centered. - firstVisible = active - middle; +export class Separator { + static readonly TYPE = "separator" as const; + readonly type = Separator.TYPE; + constructor(public readonly separator: string = "──────────────") {} + static isSeparator(value: unknown): value is Separator { + return value instanceof Separator; } - - const lastVisible = Math.min(firstVisible + pageSize - 1, totalItems - 1); - return { - above: firstVisible, - below: totalItems - 1 - lastVisible, - }; -} - -/** - * Wrap the page string returned by `usePagination` with scroll indicators. - * - * Always renders both indicator lines when called (even if count is 0) so - * the total height stays stable as the user scrolls — preventing terminal - * jitter from line-count changes between renders. - */ -export function withScrollIndicators( - page: string, - totalItems: number, - active: number, - effectivePageSize: number, -): string { - const { above, below } = scrollBounds(totalItems, active, effectivePageSize); - const top = above > 0 ? styleText("dim", ` ${figures.arrowUp} ${above} more above`) : " "; - const bottom = below > 0 ? styleText("dim", ` ${figures.arrowDown} ${below} more below`) : " "; - return [top, page, bottom].join("\n"); } // --------------------------------------------------------------------------- -// Shared item helpers +// Choice types — preserve the existing public API // --------------------------------------------------------------------------- -function isSelectable(item: T | Separator): item is T & { disabled?: boolean | string } { - return !Separator.isSeparator(item) && !(item as { disabled?: boolean | string }).disabled; -} - export type NormalizedChoice = { value: Value; name: string; short: string; disabled: boolean | string; description?: string; - style?: (text: string, isActive: boolean) => string; }; +type SelectChoice = { + value: Value; + name?: string; + description?: string; + short?: string; + disabled?: boolean | string; +}; + +export function filterChoices( + choices: T[], + term: string | undefined, +): T[] { + if (!term) return choices; + const lower = term.toLowerCase(); + return choices.filter((c) => c.name.toLowerCase().includes(lower)); +} + export function normalizeChoices( choices: ReadonlyArray | Separator>, ): Array | Separator> { @@ -158,9 +64,7 @@ export function normalizeChoices( const name = String(choice); return { value: choice as Value, name, short: name, disabled: false }; } - const c = choice as SelectChoice & { - style?: (text: string, isActive: boolean) => string; - }; + const c = choice as SelectChoice; const name = c.name ?? String(c.value); const normalized: NormalizedChoice = { value: c.value, @@ -169,444 +73,106 @@ export function normalizeChoices( disabled: c.disabled ?? false, }; if (c.description) normalized.description = c.description; - if (c.style) normalized.style = c.style; return normalized; }); } +// Sentinel used so a Separator can be passed through clack as a disabled option. +const SEPARATOR_VALUE = Symbol("listage:separator"); + +// clack's `Option` is a conditional type that distributes over unions, +// so `Option` collapses into incompatible branches. We build +// option records that satisfy the non-primitive branch (label required) and +// cast at the boundary. +function toClackOptions( + items: ReadonlyArray | Separator>, +): ClackOption[] { + return items.map((item) => { + if (Separator.isSeparator(item)) { + return { + value: SEPARATOR_VALUE as unknown as Value, + label: item.separator, + disabled: true, + }; + } + return { + value: item.value, + label: item.name, + hint: item.description, + disabled: item.disabled ? true : undefined, + }; + }) as ClackOption[]; +} + +function unwrap(value: T | symbol): T { + if (isCancel(value)) throwUserAbort(); + if (value === SEPARATOR_VALUE) { + throw new Error("listage: separator received as selected value"); + } + return value as T; +} + // --------------------------------------------------------------------------- // Select prompt // --------------------------------------------------------------------------- -type SelectTheme = { - icon: { cursor: string }; - style: { - disabled: (text: string) => string; - description: (text: string) => string; - keysHelpTip: (keys: [key: string, action: string][]) => string | undefined; - }; - i18n: { disabledError: string }; -}; - -type SelectChoice = { - value: Value; - name?: string; - description?: string; - short?: string; - disabled?: boolean | string; -}; - export type SelectConfig = { message: string; choices: ReadonlyArray>; pageSize?: number; default?: Value; - theme?: PartialDeep>; -}; - -const selectTheme: SelectTheme = { - icon: { cursor: figures.pointer }, - style: { - disabled: (text: string) => styleText("dim", text), - description: (text: string) => styleText("cyan", text), - keysHelpTip: (keys: [key: string, action: string][]) => - keys - .map(([key, action]) => `${styleText("bold", key)} ${styleText("dim", action)}`) - .join(styleText("dim", " • ")), - }, - i18n: { disabledError: "This option is disabled and cannot be selected." }, }; -const rawSelect = createPrompt>((config, done) => { - const { pageSize = 7 } = config; - const theme = makeTheme(selectTheme, config.theme); - const [status, setStatus] = useState("idle"); - const prefix = usePrefix({ status, theme }); - const searchTimeoutRef = useRef>(); - - const items = useMemo(() => normalizeChoices(config.choices), [config.choices]); - - const bounds = useMemo(() => { - const first = items.findIndex(isSelectable); - const last = items.findLastIndex(isSelectable); - if (first === -1) { - throw new ValidationError("[select prompt] No selectable choices. All choices are disabled."); - } - return { first, last }; - }, [items]); - - const defaultItemIndex = useMemo(() => { - if (!("default" in config)) return -1; - return items.findIndex((item) => isSelectable(item) && item.value === config.default); - }, [config.default, items]); - - const [active, setActive] = useState(defaultItemIndex === -1 ? bounds.first : defaultItemIndex); - - const selectedChoice = items[active]; - if (selectedChoice == null || Separator.isSeparator(selectedChoice)) { - throw new Error("Active index does not point to a choice"); - } - - const [errorMsg, setError] = useState(); - - useKeypress((key, rl) => { - clearTimeout(searchTimeoutRef.current); - if (errorMsg) setError(undefined); - - if (isEnterKey(key)) { - if (selectedChoice.disabled) { - setError(theme.i18n.disabledError); - } else { - setStatus("done"); - done(selectedChoice.value); - } - } else if (isUpKey(key) || isDownKey(key)) { - rl.clearLine(0); - if ((isUpKey(key) && active !== bounds.first) || (isDownKey(key) && active !== bounds.last)) { - const offset = isUpKey(key) ? -1 : 1; - let next = active; - do { - next = (next + offset + items.length) % items.length; - } while (!isSelectable(items[next]!)); - setActive(next); - } - } else if (isNumberKey(key) && !Number.isNaN(Number(rl.line))) { - const selectedIndex = Number(rl.line) - 1; - let selectableIndex = -1; - const position = items.findIndex((item) => { - if (Separator.isSeparator(item)) return false; - selectableIndex++; - return selectableIndex === selectedIndex; - }); - const item = items[position]; - if (item != null && isSelectable(item)) setActive(position); - searchTimeoutRef.current = setTimeout(() => rl.clearLine(0), 700); - } else if (isBackspaceKey(key)) { - rl.clearLine(0); - } else { - // Type-ahead search - const searchTerm = rl.line.toLowerCase(); - const matchIndex = items.findIndex( - (item) => isSelectable(item) && item.name.toLowerCase().startsWith(searchTerm), - ); - if (matchIndex !== -1) setActive(matchIndex); - searchTimeoutRef.current = setTimeout(() => rl.clearLine(0), 700); - } - }); - - useEffect(() => () => clearTimeout(searchTimeoutRef.current), []); - - const message = theme.style.message(config.message, status); - const helpLine = theme.style.keysHelpTip([ - ["↑↓", "navigate"], - ["⏎", "select"], - ]); - - // Pagination with scroll indicators - const needsScroll = items.length > pageSize; - const effectivePageSize = needsScroll ? Math.max(pageSize - 2, 3) : pageSize; - - const page = usePagination({ - items, - active, - renderItem({ item, isActive }) { - if (Separator.isSeparator(item)) return ` ${item.separator}`; - const cursor = isActive ? theme.icon.cursor : " "; - if (item.disabled) { - const disabledLabel = typeof item.disabled === "string" ? item.disabled : "(disabled)"; - const disabledCursor = isActive ? theme.icon.cursor : "-"; - return theme.style.disabled(`${disabledCursor} ${item.name} ${disabledLabel}`); - } - const color = isActive ? theme.style.highlight : (x: string) => x; - return color(`${cursor} ${item.name}`); - }, - pageSize: effectivePageSize, - loop: false, - }); - - if (status === "done") { - return `${[prefix, message, theme.style.answer(selectedChoice.short)].filter(Boolean).join(" ")}${cursorShow}`; - } - - const pageWithScroll = needsScroll - ? withScrollIndicators(page, items.length, active, effectivePageSize) - : page; - - const { description } = selectedChoice; - const lines = [ - [prefix, message].filter(Boolean).join(" "), - pageWithScroll, - " ", - description ? theme.style.description(description) : "", - errorMsg ? theme.style.error(errorMsg) : "", - helpLine, - ] - .filter(Boolean) - .join("\n") - .trimEnd(); - - return `${lines}${cursorHide}`; -}); - -/** Select prompt with scroll indicators and piped-stdin TTY fallback. */ export async function select(config: SelectConfig): Promise { - const tty = ttyContext(); - try { - return (await rawSelect( - config as SelectConfig, - tty ? { input: tty.input } : undefined, - )) as Value; - } finally { - tty?.close(); - } + const items = normalizeChoices(config.choices); + const result = await clackSelect({ + message: config.message, + options: toClackOptions(items), + initialValue: config.default, + maxItems: config.pageSize, + }); + return unwrap(result); } // --------------------------------------------------------------------------- -// Search prompt +// Search prompt (autocomplete) // --------------------------------------------------------------------------- -type SearchTheme = { - icon: { cursor: string }; - style: { - disabled: (text: string) => string; - searchTerm: (text: string) => string; - description: (text: string) => string; - keysHelpTip: (keys: [key: string, action: string][]) => string | undefined; - }; -}; - -type SearchChoice = { - value: Value; - name?: string; - description?: string; - short?: string; - disabled?: boolean | string; - /** Per-choice style hook. Receives `${cursor} ${name}` plus whether the row is active. */ - style?: (text: string, isActive: boolean) => string; -}; +export type SearchChoice = SelectChoice; export type SearchConfig = { message: string; + /** + * One-shot source. Called once with `undefined`; the returned list is + * filtered client-side by clack via the `filter` callback as the user + * types. The async signal is accepted for signature compatibility but + * unused — clack drives cancellation itself. + */ source: ( term: string | undefined, - opt: { signal: AbortSignal }, + opts: { signal: AbortSignal }, ) => | ReadonlyArray> | Promise>>; - validate?: (value: Value) => boolean | string | Promise; pageSize?: number; default?: Value; - theme?: PartialDeep>; -}; - -const searchTheme: SearchTheme = { - icon: { cursor: figures.pointer }, - style: { - disabled: (text: string) => styleText("dim", `- ${text}`), - searchTerm: (text: string) => styleText("cyan", text), - description: (text: string) => styleText("cyan", text), - keysHelpTip: (keys: [key: string, action: string][]) => - keys - .map(([key, action]) => `${styleText("bold", key)} ${styleText("dim", action)}`) - .join(styleText("dim", " • ")), - }, }; -export type SearchItemTheme = { - icon: { cursor: string }; - style: { - disabled: (text: string) => string; - highlight: (text: string) => string; - }; -}; - -/** - * Render a single search-prompt row. Returns the rendered string the prompt - * paints for that line. A choice's `style` hook, when set, takes precedence - * over the default `theme.style.highlight` and is invoked with the cursor + - * name and whether the row is active. - */ -export function renderSearchItem( - item: NormalizedChoice | Separator, - isActive: boolean, - theme: SearchItemTheme, -): string { - if (Separator.isSeparator(item)) return ` ${item.separator}`; - if (item.disabled) { - const disabledLabel = typeof item.disabled === "string" ? item.disabled : "(disabled)"; - return theme.style.disabled(`${item.name} ${disabledLabel}`); - } - const cursor = isActive ? theme.icon.cursor : " "; - const line = `${cursor} ${item.name}`; - if (item.style) return item.style(line, isActive); - const color = isActive ? theme.style.highlight : (x: string) => x; - return color(line); -} - -const rawSearch = createPrompt>((config, done) => { - const { pageSize = 7, validate = () => true } = config; - const theme = makeTheme(searchTheme, config.theme); - const [status, setStatus] = useState("loading"); - const [searchTerm, setSearchTerm] = useState(""); - const [searchResults, setSearchResults] = useState | Separator>>( - [], - ); - const [searchError, setSearchError] = useState(); - const defaultApplied = useRef(false); - const prefix = usePrefix({ status, theme }); - - const bounds = useMemo(() => { - const first = searchResults.findIndex(isSelectable); - const last = searchResults.findLastIndex(isSelectable); - return { first, last }; - }, [searchResults]); - - const defaultActive = bounds.first === -1 ? 0 : bounds.first; - const [active = defaultActive, setActive] = useState(); - - useEffect(() => { - const controller = new AbortController(); - setStatus("loading"); - setSearchError(undefined); - - const fetchResults = async () => { - try { - const results = await config.source(searchTerm || undefined, { - signal: controller.signal, - }); - if (!controller.signal.aborted) { - const normalized = normalizeChoices(results as ReadonlyArray); - let initialActive: number | undefined; - if (!defaultApplied.current && "default" in config) { - const defaultIndex = normalized.findIndex( - (item) => isSelectable(item) && item.value === config.default, - ); - initialActive = defaultIndex === -1 ? undefined : defaultIndex; - defaultApplied.current = true; - } - setActive(initialActive); - setSearchError(undefined); - setSearchResults(normalized); - setStatus("idle"); - } - } catch (error: unknown) { - if (!controller.signal.aborted && error instanceof Error) { - setSearchError(error.message); - setStatus("idle"); - } - } - }; - - void fetchResults(); - return () => controller.abort(); - }, [searchTerm]); - - const selectedChoice = searchResults[active] as NormalizedChoice | undefined; - - useKeypress(async (key, rl) => { - if (isEnterKey(key)) { - if (selectedChoice) { - setStatus("loading"); - const isValid = await validate(selectedChoice.value); - setStatus("idle"); - if (isValid === true) { - setStatus("done"); - done(selectedChoice.value); - } else if (selectedChoice.name === searchTerm) { - setSearchError((isValid as string) || "You must provide a valid value"); - } else { - rl.write(selectedChoice.name); - setSearchTerm(selectedChoice.name); - } - } else { - rl.write(searchTerm); - } - } else if (isTabKey(key) && selectedChoice) { - rl.clearLine(0); - rl.write(selectedChoice.name); - setSearchTerm(selectedChoice.name); - } else if ( - status !== "loading" && - searchResults.length > 0 && - bounds.first !== -1 && - (isUpKey(key) || isDownKey(key)) - ) { - rl.clearLine(0); - if ((isUpKey(key) && active !== bounds.first) || (isDownKey(key) && active !== bounds.last)) { - const offset = isUpKey(key) ? -1 : 1; - let next = active; - do { - next = (next + offset + searchResults.length) % searchResults.length; - } while (!isSelectable(searchResults[next]!)); - setActive(next); - } - } else { - setSearchTerm(rl.line); - } - }); - - const message = theme.style.message(config.message, status); - const helpLine = theme.style.keysHelpTip([ - ["↑↓", "navigate"], - ["⏎", "select"], - ]); - - // Pagination with scroll indicators - const needsScroll = searchResults.length > pageSize; - const effectivePageSize = needsScroll ? Math.max(pageSize - 2, 3) : pageSize; - - const page = usePagination({ - items: searchResults, - active, - renderItem: ({ item, isActive }) => renderSearchItem(item, isActive, theme), - pageSize: effectivePageSize, - loop: false, - }); - - let error: string | undefined; - if (searchError) { - error = theme.style.error(searchError); - } else if (searchResults.length === 0 && searchTerm !== "" && status === "idle") { - error = theme.style.error("No results found"); - } - - if (status === "done" && selectedChoice) { - return `${[prefix, message, theme.style.answer(selectedChoice.short)].filter(Boolean).join(" ").trimEnd()}${cursorShow}`; - } - - const searchStr = theme.style.searchTerm(searchTerm); - - const pageWithScroll = - needsScroll && !error - ? withScrollIndicators(page, searchResults.length, active, effectivePageSize) - : page; - - const description = selectedChoice?.description; - const header = [prefix, message, searchStr].filter(Boolean).join(" ").trimEnd(); - const body = [ - error ?? pageWithScroll, - " ", - description ? theme.style.description(description) : "", - helpLine, - ] - .filter(Boolean) - .join("\n") - .trimEnd(); - - return [header, body]; -}); - -/** Search prompt with scroll indicators and piped-stdin TTY fallback. */ export async function search(config: SearchConfig): Promise { - const tty = ttyContext(); - try { - return (await rawSearch( - config as SearchConfig, - tty ? { input: tty.input } : undefined, - )) as Value; - } finally { - tty?.close(); - } + const controller = new AbortController(); + const raw = await config.source(undefined, { signal: controller.signal }); + const items = normalizeChoices(raw); + const options = toClackOptions(items); + + const result = await clackAutocomplete({ + message: config.message, + options, + initialValue: config.default, + maxItems: config.pageSize, + filter: (term, opt) => { + const label = (opt.label ?? String(opt.value)).toLowerCase(); + return label.includes(term.toLowerCase()); + }, + }); + return unwrap(result); } - -export { Separator }; From 6516d6d0d9d155920a0396bd4f4e3c6b8c371062 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:56:00 -0600 Subject: [PATCH 16/35] test(listage): rewrite tests against @clack/prompts internals --- packages/cli-core/src/lib/listage.test.ts | 355 +++++++++++++--------- 1 file changed, 203 insertions(+), 152 deletions(-) diff --git a/packages/cli-core/src/lib/listage.test.ts b/packages/cli-core/src/lib/listage.test.ts index df5869ef..f3107c77 100644 --- a/packages/cli-core/src/lib/listage.test.ts +++ b/packages/cli-core/src/lib/listage.test.ts @@ -1,85 +1,49 @@ -import { test, expect, describe, beforeEach } from "bun:test"; -import { - filterChoices, - normalizeChoices, - renderSearchItem, - scrollBounds, - Separator, - ttyContext, - withScrollIndicators, -} from "./listage.ts"; +import { test, expect, describe, mock, beforeEach } from "bun:test"; -describe("scrollBounds", () => { - test("returns zeros when all items fit on page", () => { - expect(scrollBounds(5, 0, 7)).toEqual({ above: 0, below: 0 }); - expect(scrollBounds(7, 3, 7)).toEqual({ above: 0, below: 0 }); - }); +// Sentinel for cancellation. Tests choose this symbol; the mocked +// @clack/core.isCancel below treats it as the clack cancel signal. +const cancelSymbol = Symbol.for("clack:cancel"); - test("at the top of a long list", () => { - // 20 items, active=0, pageSize=5 → first 5 visible - expect(scrollBounds(20, 0, 5)).toEqual({ above: 0, below: 15 }); - expect(scrollBounds(20, 1, 5)).toEqual({ above: 0, below: 15 }); - }); +interface RecordedCall { + config: Record; +} - test("in the middle of a long list", () => { - // 20 items, active=10, pageSize=5, middle=2 → firstVisible=8 - const result = scrollBounds(20, 10, 5); - expect(result.above).toBe(8); - expect(result.below).toBe(7); - expect(result.above + result.below + 5).toBe(20); - }); +let lastSelectCall: RecordedCall | undefined; +let selectResult: unknown = undefined; +let lastAutocompleteCall: RecordedCall | undefined; +let autocompleteResult: unknown = undefined; - test("near the bottom of a long list", () => { - // 20 items, active=19, pageSize=5 → last 5 visible - expect(scrollBounds(20, 19, 5)).toEqual({ above: 15, below: 0 }); - }); +mock.module("@clack/prompts", () => ({ + select: async (config: Record) => { + lastSelectCall = { config }; + return selectResult; + }, + autocomplete: async (config: Record) => { + lastAutocompleteCall = { config }; + return autocompleteResult; + }, + // Stubs for sibling tests that may share this process. + confirm: async () => true, + text: async () => "", + password: async () => "", + intro: () => {}, + outro: () => {}, + cancel: () => {}, + log: { info: () => {}, warn: () => {}, error: () => {}, success: () => {} }, + spinner: () => ({ start: () => {}, stop: () => {}, message: () => {} }), +})); - test("above + below + pageSize = totalItems (pageSize=5)", () => { - for (let active = 0; active < 20; active++) { - const { above, below } = scrollBounds(20, active, 5); - expect(above + below + 5).toBe(20); - } - }); +mock.module("@clack/core", () => ({ + isCancel: (value: unknown): value is symbol => value === cancelSymbol, +})); - test("above + below + pageSize = totalItems (pageSize=7, odd)", () => { - // Odd pageSize may drift by ±1 at boundaries but must never be catastrophically wrong - for (let active = 0; active < 20; active++) { - const { above, below } = scrollBounds(20, active, 7); - expect(above + below + 7).toBe(20); - } - }); -}); +const { select, search, filterChoices, normalizeChoices, Separator } = await import("./listage.ts"); -describe("withScrollIndicators", () => { - test("wraps page with indicator lines", () => { - const page = " item1\n❯ item2\n item3"; - const result = withScrollIndicators(page, 20, 10, 3); - const lines = result.split("\n"); - // Should always have top indicator, page lines, bottom indicator - expect(lines.length).toBe(5); // top + 3 page lines + bottom - expect(lines[0]).toContain("more above"); - expect(lines[4]).toContain("more below"); - }); - - test("shows empty placeholder lines at edges for stable height", () => { - const page = "❯ item1\n item2\n item3"; - // active=0, at top — above=0 but still shows a placeholder line - const result = withScrollIndicators(page, 10, 0, 3); - const lines = result.split("\n"); - expect(lines.length).toBe(5); - expect(lines[0]).toBe(" "); // empty placeholder - expect(lines[4]).toContain("more below"); - }); - - test("always renders both indicator lines for stable height", () => { - const page = "❯ item1\n item2\n item3"; - // Both at top (above=0) and bottom visible — both placeholders shown - const result = withScrollIndicators(page, 10, 0, 3); - const lines = result.split("\n"); - expect(lines.length).toBe(5); // top placeholder + 3 page lines + bottom - expect(lines[0]).toBe(" "); // empty top placeholder - expect(lines[4]).toContain("more below"); - }); +beforeEach(() => { + lastSelectCall = undefined; + selectResult = undefined; + lastAutocompleteCall = undefined; + autocompleteResult = undefined; }); describe("filterChoices", () => { @@ -105,7 +69,7 @@ describe("filterChoices", () => { test("matches partial names", () => { const result = filterChoices(choices, "xt"); - expect(result).toEqual([choices[0]!, choices[3]!]); // Next.js, Nuxt + expect(result).toEqual([choices[0]!, choices[3]!]); }); test("returns empty array when nothing matches", () => { @@ -114,108 +78,195 @@ describe("filterChoices", () => { }); describe("normalizeChoices", () => { - test("forwards style hook from choice to normalized item", () => { - const style = (text: string, isActive: boolean) => `[${isActive ? "on" : "off"}]${text}`; - // Cast through unknown: SelectChoice doesn't expose `style` at the type - // level, but normalizeChoices preserves it at runtime so SearchChoice - // callers can opt in. - const choices = [ - { value: "a", name: "A" }, - { value: "b", name: "B", style }, - ] as unknown as Parameters>[0]; - const result = normalizeChoices(choices); - const a = result[0] as Exclude<(typeof result)[number], Separator>; - const b = result[1] as Exclude<(typeof result)[number], Separator>; - expect(a.style).toBeUndefined(); - expect(b.style).toBe(style); - }); - - test("preserves separators", () => { - const sep = new Separator(); + test("normalizes primitive choices into name/value pairs", () => { + const result = normalizeChoices(["a", "b"]); + expect(result).toEqual([ + { value: "a", name: "a", short: "a", disabled: false }, + { value: "b", name: "b", short: "b", disabled: false }, + ]); + }); + + test("normalizes object choices and defaults name/short to value", () => { + const result = normalizeChoices([{ value: "x" }, { value: "y", name: "Y label" }]); + expect(result).toEqual([ + { value: "x", name: "x", short: "x", disabled: false }, + { value: "y", name: "Y label", short: "Y label", disabled: false }, + ]); + }); + + test("preserves description and disabled when present", () => { + const result = normalizeChoices([ + { value: "a", name: "A", description: "the A", disabled: "soon" }, + ]); + expect(result[0]).toEqual({ + value: "a", + name: "A", + short: "A", + disabled: "soon", + description: "the A", + }); + }); + + test("preserves separators verbatim", () => { + const sep = new Separator("---"); const result = normalizeChoices([{ value: "a", name: "A" }, sep, { value: "b", name: "B" }]); expect(Separator.isSeparator(result[0])).toBe(false); expect(Separator.isSeparator(result[1])).toBe(true); + expect(result[1]).toBe(sep); expect(Separator.isSeparator(result[2])).toBe(false); }); }); -describe("renderSearchItem", () => { - const theme = { - icon: { cursor: ">" }, - style: { - disabled: (text: string) => `[disabled]${text}`, - highlight: (text: string) => `[highlight]${text}`, - }, - }; - const baseItem = { - value: "a", - name: "Choice A", - short: "A", - disabled: false as boolean | string, - }; - - test("uses default highlight when active and no style hook is set", () => { - expect(renderSearchItem(baseItem, true, theme)).toBe("[highlight]> Choice A"); +describe("Separator", () => { + test("has a default rule string and identifies itself", () => { + const sep = new Separator(); + expect(Separator.isSeparator(sep)).toBe(true); + expect(typeof sep.separator).toBe("string"); + expect(sep.separator.length).toBeGreaterThan(0); }); - test("returns plain text when inactive and no style hook is set", () => { - expect(renderSearchItem(baseItem, false, theme)).toBe(" Choice A"); + test("accepts a custom separator label", () => { + const sep = new Separator("---"); + expect(sep.separator).toBe("---"); }); - test("invokes the style hook when set, bypassing the default highlight", () => { - const style = (text: string, isActive: boolean) => `[${isActive ? "on" : "off"}]${text}`; - const styled = { ...baseItem, style }; - expect(renderSearchItem(styled, true, theme)).toBe("[on]> Choice A"); - expect(renderSearchItem(styled, false, theme)).toBe("[off] Choice A"); + test("isSeparator rejects non-separator values", () => { + expect(Separator.isSeparator({ separator: "---" })).toBe(false); + expect(Separator.isSeparator(undefined)).toBe(false); + expect(Separator.isSeparator("---")).toBe(false); }); +}); - test("style hook receives cursor + name with no extra wrapping", () => { - let received: { text: string; isActive: boolean } | undefined; - const style = (text: string, isActive: boolean) => { - received = { text, isActive }; - return text; - }; - renderSearchItem({ ...baseItem, style }, true, theme); - expect(received).toEqual({ text: "> Choice A", isActive: true }); +describe("select", () => { + test("passes message, options, initialValue, and maxItems through to clack", async () => { + selectResult = "a"; + const result = await select({ + message: "Pick one", + choices: [ + { value: "a", name: "A", description: "first" }, + { value: "b", name: "B" }, + ], + default: "b", + pageSize: 5, + }); + + expect(result).toBe("a"); + expect(lastSelectCall?.config.message).toBe("Pick one"); + expect(lastSelectCall?.config.initialValue).toBe("b"); + expect(lastSelectCall?.config.maxItems).toBe(5); + const options = lastSelectCall?.config.options as Array>; + expect(options).toHaveLength(2); + expect(options[0]).toMatchObject({ value: "a", label: "A", hint: "first" }); + expect(options[1]).toMatchObject({ value: "b", label: "B" }); }); - test("renders separators verbatim with a leading space", () => { - expect(renderSearchItem(new Separator("---"), false, theme)).toBe(" ---"); + test("renders separators as disabled options with the separator label", async () => { + selectResult = "b"; + await select({ + message: "Pick", + choices: [{ value: "a", name: "A" }, new Separator("--- divider ---"), { value: "b" }], + }); + const options = lastSelectCall?.config.options as Array>; + expect(options).toHaveLength(3); + expect(options[1]).toMatchObject({ label: "--- divider ---", disabled: true }); + // The separator value is a sentinel symbol — not equal to either real value. + expect(typeof options[1]?.value).toBe("symbol"); }); - test("renders disabled choices with the disabled style and ignores style hook", () => { - const style = (text: string) => `[styled]${text}`; - const disabled = { ...baseItem, disabled: true as boolean | string, style }; - expect(renderSearchItem(disabled, false, theme)).toBe("[disabled]Choice A (disabled)"); + test("marks disabled choices on the clack option", async () => { + selectResult = "a"; + await select({ + message: "Pick", + choices: [ + { value: "a", name: "A" }, + { value: "b", name: "B", disabled: true }, + ], + }); + const options = lastSelectCall?.config.options as Array>; + expect(options[0]?.disabled).toBeUndefined(); + expect(options[1]?.disabled).toBe(true); }); - test("uses the disabled string label when provided", () => { - const disabled = { ...baseItem, disabled: "coming soon" as boolean | string }; - expect(renderSearchItem(disabled, false, theme)).toBe("[disabled]Choice A coming soon"); + test("throws UserAbortError when clack returns the cancel symbol", async () => { + selectResult = cancelSymbol; + await expect( + select({ message: "Pick", choices: [{ value: "a" }] }), + ).rejects.toMatchObject({ name: "UserAbortError" }); }); }); -describe("ttyContext", () => { - const originalIsTTY = process.stdin.isTTY; +describe("search", () => { + test("invokes source once, forwards options to clack, and returns the result", async () => { + autocompleteResult = "a"; + let sourceCalls = 0; + let lastTerm: string | undefined = "initial-marker"; + + const result = await search({ + message: "Search", + pageSize: 4, + default: "a", + source: (term) => { + sourceCalls += 1; + lastTerm = term; + return [ + { value: "a", name: "Apple" }, + { value: "b", name: "Banana" }, + ]; + }, + }); + + expect(result).toBe("a"); + expect(sourceCalls).toBe(1); + expect(lastTerm).toBeUndefined(); + expect(lastAutocompleteCall?.config.message).toBe("Search"); + expect(lastAutocompleteCall?.config.maxItems).toBe(4); + expect(lastAutocompleteCall?.config.initialValue).toBe("a"); + const options = lastAutocompleteCall?.config.options as Array>; + expect(options).toHaveLength(2); + expect(options[0]).toMatchObject({ value: "a", label: "Apple" }); + expect(options[1]).toMatchObject({ value: "b", label: "Banana" }); + }); + + test("filter callback matches labels case-insensitively", async () => { + autocompleteResult = "a"; + await search({ + message: "Search", + source: () => [ + { value: "a", name: "Apple" }, + { value: "b", name: "Banana" }, + ], + }); - beforeEach(() => { - process.stdin.isTTY = originalIsTTY; + const filter = lastAutocompleteCall?.config.filter as ( + term: string, + opt: { label?: string; value: unknown }, + ) => boolean; + expect(typeof filter).toBe("function"); + expect(filter("APP", { label: "Apple", value: "a" })).toBe(true); + expect(filter("ban", { label: "Banana", value: "b" })).toBe(true); + expect(filter("xyz", { label: "Apple", value: "a" })).toBe(false); + // Falls back to stringifying value when label is absent. + expect(filter("a", { value: "a" })).toBe(true); }); - test("returns undefined when stdin is a TTY", () => { - process.stdin.isTTY = true; - expect(ttyContext()).toBeUndefined(); + test("throws UserAbortError when clack returns the cancel symbol", async () => { + autocompleteResult = cancelSymbol; + await expect( + search({ + message: "Search", + source: () => [{ value: "a", name: "A" }], + }), + ).rejects.toMatchObject({ name: "UserAbortError" }); }); - test("returns context with input and close when stdin is not a TTY", () => { - process.stdin.isTTY = false; - const ctx = ttyContext(); - // On macOS/Linux with /dev/tty available, this should return a context - if (ctx) { - expect(ctx.input).toBeDefined(); - expect(typeof ctx.close).toBe("function"); - ctx.close(); - } - // On CI/Docker without a TTY, ttyContext may return undefined — both are valid + test("accepts a Promise from source", async () => { + autocompleteResult = "a"; + const result = await search({ + message: "Search", + source: async () => [{ value: "a", name: "A" }], + }); + expect(result).toBe("a"); + const options = lastAutocompleteCall?.config.options as Array>; + expect(options[0]).toMatchObject({ value: "a", label: "A" }); }); }); From a7c63532521a6f8d85a9d36038b9e5d3c45598e1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:56:26 -0600 Subject: [PATCH 17/35] test(listage): drop ttyContext from stubs and integration harness --- packages/cli-core/src/test/integration/lib/harness.ts | 1 - packages/cli-core/src/test/lib/listage-stubs.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/cli-core/src/test/integration/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index 39af6408..77e0fb05 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -202,7 +202,6 @@ mock.module("../../../lib/listage.ts", () => ({ return item instanceof Separator; } }, - ttyContext: () => undefined, })); mock.module("../../../lib/prompts.ts", () => ({ diff --git a/packages/cli-core/src/test/lib/listage-stubs.ts b/packages/cli-core/src/test/lib/listage-stubs.ts index 89a7f6a8..8bdf8eeb 100644 --- a/packages/cli-core/src/test/lib/listage-stubs.ts +++ b/packages/cli-core/src/test/lib/listage-stubs.ts @@ -5,5 +5,4 @@ export const listageStubs = { search: async () => undefined, filterChoices, Separator, - ttyContext: () => undefined, }; From cc36dbb4f1f548e3f97ed4416fcc0654709485b2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:58:51 -0600 Subject: [PATCH 18/35] feat(spinner): swap intro/outro/spinner to @clack/prompts --- packages/cli-core/src/lib/spinner.test.ts | 176 ++++++++++++++++++++++ packages/cli-core/src/lib/spinner.ts | 77 ++-------- 2 files changed, 189 insertions(+), 64 deletions(-) create mode 100644 packages/cli-core/src/lib/spinner.test.ts diff --git a/packages/cli-core/src/lib/spinner.test.ts b/packages/cli-core/src/lib/spinner.test.ts new file mode 100644 index 00000000..07e1b72b --- /dev/null +++ b/packages/cli-core/src/lib/spinner.test.ts @@ -0,0 +1,176 @@ +import { test, expect, mock, beforeEach } from "bun:test"; +import { isInsideGutter } from "./log.ts"; + +// ── Mock state for @clack/prompts ──────────────────────────────────────────── + +let lastIntroTitle: string | undefined; +let introCalls = 0; +let lastOutroLabel: string | undefined; +let outroCalls = 0; + +interface SpinnerCall { + type: "start" | "stop" | "error" | "message"; + message?: string; +} +let spinnerCalls: SpinnerCall[] = []; + +mock.module("@clack/prompts", () => ({ + intro: (title?: string) => { + introCalls++; + lastIntroTitle = title; + }, + outro: (label?: string) => { + outroCalls++; + lastOutroLabel = label; + }, + spinner: () => ({ + start: (message: string) => { + spinnerCalls.push({ type: "start", message }); + }, + stop: (message?: string) => { + spinnerCalls.push({ type: "stop", message }); + }, + message: (message?: string) => { + spinnerCalls.push({ type: "message", message }); + }, + error: (message?: string) => { + spinnerCalls.push({ type: "error", message }); + }, + }), + // Stubs for sibling test-process exports + cancel: () => {}, + log: { info: () => {}, warn: () => {}, error: () => {}, success: () => {} }, + confirm: async () => true, + text: async () => "", + password: async () => "", +})); + +mock.module("../mode.ts", () => ({ + isHuman: () => true, + isAgent: () => false, + getMode: () => "human", + setMode: () => {}, +})); + +// ── Stderr capture ─────────────────────────────────────────────────────────── + +let stderrChunks: string[] = []; +const originalWrite = process.stderr.write.bind(process.stderr); + +function captureStderr(fn: () => T): T { + stderrChunks = []; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }) as typeof process.stderr.write; + try { + return fn(); + } finally { + process.stderr.write = originalWrite; + } +} + +async function captureStderrAsync(fn: () => Promise): Promise { + stderrChunks = []; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }) as typeof process.stderr.write; + try { + return await fn(); + } finally { + process.stderr.write = originalWrite; + } +} + +const { intro, outro, bar, withSpinner } = await import("./spinner.ts"); + +beforeEach(() => { + introCalls = 0; + outroCalls = 0; + lastIntroTitle = undefined; + lastOutroLabel = undefined; + spinnerCalls = []; + stderrChunks = []; +}); + +test("intro forwards the title to clack and pushes the gutter prefix", () => { + expect(isInsideGutter()).toBe(false); + intro("Welcome"); + expect(introCalls).toBe(1); + expect(lastIntroTitle).toBe("Welcome"); + expect(isInsideGutter()).toBe(true); + // Cleanup so other tests don't see prefix leak + outro("Done"); + expect(isInsideGutter()).toBe(false); +}); + +test("outro forwards the label to clack and pops the gutter prefix", () => { + intro("Hello"); + expect(isInsideGutter()).toBe(true); + outro("All done"); + expect(outroCalls).toBe(1); + expect(lastOutroLabel).toBe("All done"); + expect(isInsideGutter()).toBe(false); +}); + +test("outro with string[] renders custom Next steps block and does not call clack outro", () => { + intro("Hello"); + captureStderr(() => { + outro(["Run `clerk dev`", "Open the dashboard"]); + }); + + // Custom block replaces clack's outro, so clack outro is not invoked. + expect(outroCalls).toBe(0); + expect(isInsideGutter()).toBe(false); + + const output = stderrChunks.join(""); + expect(output).toContain("Next steps"); + expect(output).toContain("Run `clerk dev`"); + expect(output).toContain("Open the dashboard"); +}); + +test("bar() writes a single │ line without throwing", () => { + captureStderr(() => { + bar(); + }); + const output = stderrChunks.join(""); + expect(output).toContain("│"); +}); + +test("withSpinner starts, runs fn, and stops with success message", async () => { + const result = await captureStderrAsync(() => + withSpinner("Loading...", async () => { + return 42; + }), + ); + + expect(result).toBe(42); + const types = spinnerCalls.map((c) => c.type); + expect(types).toEqual(["start", "stop"]); + expect(spinnerCalls[0]?.message).toBe("Loading..."); + // Default doneMessage trims trailing "..." + expect(spinnerCalls[1]?.message).toBe("Loading"); +}); + +test("withSpinner uses an explicit doneMessage when provided", async () => { + await captureStderrAsync(() => withSpinner("Fetching...", async () => undefined, "Fetched")); + + const stopCall = spinnerCalls.find((c) => c.type === "stop"); + expect(stopCall?.message).toBe("Fetched"); +}); + +test("withSpinner calls error() on the spinner and rethrows when fn throws", async () => { + const boom = new Error("kaboom"); + await expect( + captureStderrAsync(() => + withSpinner("Working...", async () => { + throw boom; + }), + ), + ).rejects.toBe(boom); + + const types = spinnerCalls.map((c) => c.type); + expect(types).toEqual(["start", "error"]); + expect(spinnerCalls[1]?.message).toBe("Failed"); +}); diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index d3bebfdc..0122b60c 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -1,46 +1,37 @@ +import { intro as clackIntro, outro as clackOutro, spinner as clackSpinner } from "@clack/prompts"; import { isHuman } from "../mode.ts"; -import { dim, cyan, green, red } from "./color.ts"; +import { dim, cyan } from "./color.ts"; import { pushPrefix, popPrefix } from "./log.ts"; -const FRAMES = ["◒", "◐", "◓", "◑"]; -const INTERVAL = 80; - const S_BAR = "│"; -const S_BAR_START = "┌"; const S_BAR_END = "└"; -const S_STEP_DONE = "◇"; -const S_STEP_ERROR = "■"; const stream = process.stderr; -const isInteractive = () => stream.isTTY && !process.env.CI; - -// --- Public API --- -/** Print intro bracket: ┌ title — prefixes log output with │ until outro(). */ +/** Print intro bracket and arrange for subsequent `log.*` lines to be gutter-prefixed. */ export function intro(title?: string) { if (!isHuman()) return; - const line = title ? `${dim(S_BAR_START)} ${title}` : dim(S_BAR_START); - stream.write(`${line}\n`); + clackIntro(title); pushPrefix(); } -/** Print outro bracket: └ message — restores normal log output. - * Pass a string[] to render as next steps after the bracket. */ +/** Print outro bracket; restores normal `log.*` output. Pass a string[] to render next steps. */ export function outro(messageOrSteps?: string | readonly string[]) { if (!isHuman()) return; - popPrefix(); - stream.write(`${dim(S_BAR)}\n`); if (Array.isArray(messageOrSteps)) { + popPrefix(); + stream.write(`${dim(S_BAR)}\n`); stream.write(`${dim(S_BAR_END)} ${dim("Next steps")}\n`); for (const step of messageOrSteps) { - stream.write(` ${cyan("\u2192")} ${step}\n`); + stream.write(` ${cyan("→")} ${step}\n`); } stream.write("\n"); - } else { - const label = messageOrSteps ?? "Done"; - stream.write(`${dim(S_BAR_END)} ${label}\n\n`); + return; } + + popPrefix(); + clackOutro(messageOrSteps ?? "Done"); } /** Print a bar separator: │ */ @@ -49,48 +40,6 @@ export function bar() { stream.write(`${dim(S_BAR)}\n`); } -function createSpinner() { - const interactive = isInteractive(); - let timer: ReturnType | undefined; - let frame = 0; - - return { - start(message: string) { - if (!interactive) { - stream.write(`${S_STEP_DONE} ${message}\n`); - return; - } - stream.write("\x1b[?25l"); // hide cursor - timer = setInterval(() => { - const char = cyan(FRAMES[frame++ % FRAMES.length]!); - stream.write(`\r\x1b[K${char} ${message}`); - }, INTERVAL); - }, - stop(finalMessage?: string) { - if (timer) { - clearInterval(timer); - timer = undefined; - } - if (!interactive) return; - stream.write(`\r\x1b[K`); - if (finalMessage) { - stream.write(`${green(S_STEP_DONE)} ${finalMessage}\n`); - } - stream.write("\x1b[?25h"); // show cursor - }, - error(finalMessage?: string) { - if (timer) { - clearInterval(timer); - timer = undefined; - } - if (!interactive) return; - stream.write(`\r\x1b[K`); - stream.write(`${red(S_STEP_ERROR)} ${finalMessage ?? "Failed"}\n`); - stream.write("\x1b[?25h"); - }, - }; -} - export async function withSpinner( message: string, fn: () => Promise, @@ -98,7 +47,7 @@ export async function withSpinner( ): Promise { if (!isHuman()) return fn(); - const s = createSpinner(); + const s = clackSpinner(); s.start(message); try { const result = await fn(); From 492954ffddbeba839a272ce9ffed4ab72f6ead90 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 12:59:50 -0600 Subject: [PATCH 19/35] refactor(cli): drop ExitPromptError handling; rely on UserAbortError --- packages/cli-core/src/cli-program.ts | 3 +-- packages/cli-core/src/lib/spinner.ts | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index a406fdcb..ec5d6604 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -40,7 +40,6 @@ import { throwUsageError, } from "./lib/errors.ts"; import { clerkHelpConfig } from "./lib/help.ts"; -import { ExitPromptError } from "@inquirer/core"; import { isAgent } from "./mode.ts"; import { log } from "./lib/log.ts"; import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; @@ -1022,7 +1021,7 @@ export async function runProgram( } catch (error) { const verbose = program.opts().verbose ?? false; - if (error instanceof UserAbortError || error instanceof ExitPromptError) { + if (error instanceof UserAbortError) { process.exit(EXIT_CODE.SUCCESS); } diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index 0122b60c..efc00326 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -18,9 +18,9 @@ export function intro(title?: string) { /** Print outro bracket; restores normal `log.*` output. Pass a string[] to render next steps. */ export function outro(messageOrSteps?: string | readonly string[]) { if (!isHuman()) return; + popPrefix(); - if (Array.isArray(messageOrSteps)) { - popPrefix(); + if (typeof messageOrSteps === "object") { stream.write(`${dim(S_BAR)}\n`); stream.write(`${dim(S_BAR_END)} ${dim("Next steps")}\n`); for (const step of messageOrSteps) { @@ -30,7 +30,6 @@ export function outro(messageOrSteps?: string | readonly string[]) { return; } - popPrefix(); clackOutro(messageOrSteps ?? "Done"); } From b60a05324e5efa9a0c4d0eaba90b24e1c48e83f9 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 13:01:50 -0600 Subject: [PATCH 20/35] refactor(prompts): route doctor and update confirms through lib/prompts.ts --- packages/cli-core/src/commands/doctor/index.ts | 2 +- packages/cli-core/src/commands/update/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/doctor/index.ts b/packages/cli-core/src/commands/doctor/index.ts index fcf5283d..93978026 100644 --- a/packages/cli-core/src/commands/doctor/index.ts +++ b/packages/cli-core/src/commands/doctor/index.ts @@ -93,7 +93,7 @@ export async function doctor(options: DoctorOptions = {}): Promise { log.info(bold("Auto-fix")); log.blank(); - const { confirm } = await import("@inquirer/prompts"); + const { confirm } = await import("../../lib/prompts.ts"); for (const result of uniqueFixable) { const fix = result.fix; diff --git a/packages/cli-core/src/commands/update/index.ts b/packages/cli-core/src/commands/update/index.ts index 8876fcb6..23b7a302 100644 --- a/packages/cli-core/src/commands/update/index.ts +++ b/packages/cli-core/src/commands/update/index.ts @@ -242,7 +242,7 @@ function detectPackageRunner(): "npx" | "bunx" | null { // ── Confirmation ───────────────────────────────────────────────────────────── async function confirmUpdate(currentVersion: string, latestVersion: string): Promise { - const { confirm } = await import("@inquirer/prompts"); + const { confirm } = await import("../../lib/prompts.ts"); return confirm({ message: `Update clerk ${currentVersion} → ${latestVersion}?`, default: true, From fe50a7f5c9501a83a3009ca5da4618af47b33ec8 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 13:03:12 -0600 Subject: [PATCH 21/35] build(deps): remove @inquirer/* packages --- bun.lock | 64 ------------------- packages/cli-core/package.json | 4 -- .../src/test/integration/lib/harness.ts | 22 ++----- packages/cli-core/src/test/lib/stubs.ts | 15 +---- 4 files changed, 10 insertions(+), 95 deletions(-) diff --git a/bun.lock b/bun.lock index 5b6df9f6..066fe1b6 100644 --- a/bun.lock +++ b/bun.lock @@ -32,10 +32,6 @@ "@clack/prompts": "^1.3.0", "@clerk/cli-extras": "workspace:*", "@commander-js/extra-typings": "^14.0.0", - "@inquirer/ansi": "^2.0.5", - "@inquirer/core": "^11.1.9", - "@inquirer/figures": "^2.0.5", - "@inquirer/prompts": "^8.4.2", "@napi-rs/keyring": "^1.3.0", "commander": "^14.0.3", "env-paths": "^4.0.0", @@ -122,38 +118,8 @@ "@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="], - "@inquirer/ansi": ["@inquirer/ansi@2.0.5", "", {}, "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw=="], - - "@inquirer/checkbox": ["@inquirer/checkbox@5.1.4", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.9", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-w6KF8ZYRvqHhROkOTHXYC3qIV/KYEu5o12oLqQySvch61vrYtRxNSHTONSdJqWiFJPlCUQAHT5OgOIyuTr+MHQ=="], - - "@inquirer/confirm": ["@inquirer/confirm@6.0.12", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og=="], - - "@inquirer/core": ["@inquirer/core@11.1.9", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg=="], - - "@inquirer/editor": ["@inquirer/editor@5.1.1", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/external-editor": "^3.0.0", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6y11LgmNpmn5D2aB5FgnCfBUBK8ZstwLCalyJmORcJZ/WrhOjm16mu6eSqIx8DnErxDqSLr+Jkp+GP8/Nwd5tA=="], - - "@inquirer/expand": ["@inquirer/expand@5.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-dF2zvrFo9LshkcB23/O1il13kBkBltWIXzut1evfbuBLXMiGIuC45c+ZQ0uukjCDsvI8OWqun4FRYMnzFCQa3g=="], - "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], - "@inquirer/figures": ["@inquirer/figures@2.0.5", "", {}, "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ=="], - - "@inquirer/input": ["@inquirer/input@5.0.12", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-uiMFBl4LqFzJClh80Q3f9hbOFJ6kgkDWI4LjAeBuyO6EanVVMF69AgOvpi1qdqjDSjDN6578B6nky9ceEpI+1Q=="], - - "@inquirer/number": ["@inquirer/number@4.0.12", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/vrwhEf7Xsuh+YlHF4IjSy3g1cyrQuPaSiHIxCEbLu8qnfvrcvJyCkoktOOF+xV9gSb77/G0n3h04RbMDW2sIg=="], - - "@inquirer/password": ["@inquirer/password@5.0.12", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-CBh7YHju623lxJRcAOo498ZUwIuMy63bqW/vVq0tQAZVv+lkWlHkP9ealYE1utWSisEShY5VMdzIXRmyEODzcQ=="], - - "@inquirer/prompts": ["@inquirer/prompts@8.4.2", "", { "dependencies": { "@inquirer/checkbox": "^5.1.4", "@inquirer/confirm": "^6.0.12", "@inquirer/editor": "^5.1.1", "@inquirer/expand": "^5.0.13", "@inquirer/input": "^5.0.12", "@inquirer/number": "^4.0.12", "@inquirer/password": "^5.0.12", "@inquirer/rawlist": "^5.2.8", "@inquirer/search": "^4.1.8", "@inquirer/select": "^5.1.4" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q=="], - - "@inquirer/rawlist": ["@inquirer/rawlist@5.2.8", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Su7FQvp5buZmCymN3PPoYv31ZQQX4ve2j02k7piGgKAWgE+AQRB5YoYVveGXcl3TZ9ldgRMSxj56YfDFmmaqLg=="], - - "@inquirer/search": ["@inquirer/search@4.1.8", "", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-fGiHKGD6DyPIYUWxoXnQTeXeyYqSOUrasDMABBmMHUalH/LxkuzY0xVRtimXAt1sUeeyYkVuKQx1bebMuN11Kw=="], - - "@inquirer/select": ["@inquirer/select@5.1.4", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.9", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-2kWcGKPMLAXAWRp1AH1SLsQmX+j0QjeljyXMUji9WMZC8nRDO0b7qquIGr6143E7KMLt3VAIGNXzwa/6PXQs4Q=="], - - "@inquirer/type": ["@inquirer/type@4.0.5", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q=="], - "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], @@ -294,8 +260,6 @@ "clerk": ["clerk@workspace:packages/cli"], - "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], - "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -384,8 +348,6 @@ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], - "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], - "nano-staged": ["nano-staged@1.0.2", "", { "bin": { "nano-staged": "lib/bin.js" } }, "sha512-Fytar3zHLY99nlMfqPPbraxZodqQAHPpdPRyYaplL+lB9DCR6pUrafxbG+Btz4+7fO5Rm/+DO4ZeDO/nLSUMhw=="], "os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="], @@ -490,32 +452,10 @@ "@clerk/testing/@clerk/shared": ["@clerk/shared@4.4.0", "", { "dependencies": { "@tanstack/query-core": "5.90.16", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-3iBX7Svp2XrSIgFk4VtyVq5OZsGStkMGqVfTBbbiFCbSKQ745OfM8j/c2wgpq5QdyavesoeDA6YiMWlpZM9/ng=="], - "@inquirer/checkbox/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], - - "@inquirer/confirm/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], - - "@inquirer/editor/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], - - "@inquirer/editor/@inquirer/external-editor": ["@inquirer/external-editor@3.0.0", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg=="], - - "@inquirer/expand/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], - "@inquirer/external-editor/chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "@inquirer/input/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], - - "@inquirer/number/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], - - "@inquirer/password/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], - - "@inquirer/rawlist/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], - - "@inquirer/search/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], - - "@inquirer/select/@inquirer/core": ["@inquirer/core@11.1.8", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA=="], - "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -526,10 +466,6 @@ "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], - "@inquirer/editor/@inquirer/external-editor/chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], - - "@inquirer/editor/@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], } } diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index 3a34e44a..4d7c177a 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -19,10 +19,6 @@ "@clack/prompts": "^1.3.0", "@clerk/cli-extras": "workspace:*", "@commander-js/extra-typings": "^14.0.0", - "@inquirer/ansi": "^2.0.5", - "@inquirer/core": "^11.1.9", - "@inquirer/figures": "^2.0.5", - "@inquirer/prompts": "^8.4.2", "@napi-rs/keyring": "^1.3.0", "commander": "^14.0.3", "env-paths": "^4.0.0", diff --git a/packages/cli-core/src/test/integration/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index 77e0fb05..1c0d0dd0 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -6,7 +6,7 @@ * test harness setup/teardown functions. * * WARNING: Do NOT add static imports of modules that transitively import any - * mocked module (credential-store, git, mode, inquirer, token-exchange, + * mocked module (credential-store, git, mode, prompts/listage, token-exchange, * auth-server, pkce). Bun's `mock.module()` must be registered before any * consumer loads the real module. All consuming imports must use dynamic * `await import(...)` AFTER the mock.module() calls below. @@ -106,7 +106,7 @@ mock.module( }) satisfies typeof import("../../../mode.ts"), ); -// ── Prompt queue (replaces @inquirer/prompts) ──────────────────────────────── +// ── Prompt queue (drives lib/prompts.ts and lib/listage.ts mocks) ──────────── type PromptType = "select" | "search" | "input" | "confirm" | "password" | "editor"; @@ -124,7 +124,7 @@ function dequeuePrompt(name: PromptType) { const queue = promptQueues[name]; if (queue.length === 0) { throw new Error( - `Unexpected call to @inquirer/prompts.${name}() during test. ` + + `Unexpected call to prompts.${name}() during test. ` + `Use a CLI flag (e.g. --yes) to bypass prompts, or queue a response with mockPrompts.${name}().`, ); } @@ -133,9 +133,10 @@ function dequeuePrompt(name: PromptType) { } /** - * Queue responses for `@inquirer/prompts` functions. Responses are consumed - * in FIFO order — the first queued value is returned by the first call to - * that prompt type, the second by the second call, and so on. + * Queue responses for prompt functions (driven by mocked `lib/prompts.ts` and + * `lib/listage.ts`). Responses are consumed in FIFO order — the first queued + * value is returned by the first call to that prompt type, the second by the + * second call, and so on. * * If a prompt is called with no queued responses, the test fails immediately * with a descriptive error. Unconsumed responses are detected during @@ -177,15 +178,6 @@ function assertPromptQueuesEmpty() { } } -mock.module("@inquirer/prompts", () => ({ - select: dequeuePrompt("select"), - search: dequeuePrompt("search"), - input: dequeuePrompt("input"), - confirm: dequeuePrompt("confirm"), - password: dequeuePrompt("password"), - editor: dequeuePrompt("editor"), -})); - mock.module("../../../lib/listage.ts", () => ({ select: dequeuePrompt("select"), search: dequeuePrompt("search"), diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index 6de0cac8..b5315367 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -86,19 +86,10 @@ export const gitStubs = { normalizeGitRemoteUrl: (url: string) => url, }; -export const promptsStubs = { - select: async () => undefined, - search: async () => undefined, - input: async () => "", - confirm: async () => true, - password: async () => "", - editor: async () => "{}", -}; - /** - * Stubs for `lib/prompts.ts`. Use these when mocking the wrapper module - * directly rather than `@inquirer/prompts`. The wrapper exposes `text` in - * place of inquirer's `input`. + * Stubs for `lib/prompts.ts` — the @clack/prompts-backed wrapper. Default + * responses return benign values so tests can mock the module without + * configuring each prompt explicitly. */ export const libPromptsStubs = { confirm: async () => true, From 57138ff3411863a545e24b77019586344d7c4e77 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 13:04:19 -0600 Subject: [PATCH 22/35] docs(changeset): refresh prompt UI with @clack/prompts --- .changeset/clack-prompts-migration.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clack-prompts-migration.md diff --git a/.changeset/clack-prompts-migration.md b/.changeset/clack-prompts-migration.md new file mode 100644 index 00000000..ef49cd3c --- /dev/null +++ b/.changeset/clack-prompts-migration.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Refresh the visual style of prompts, lists, spinners, and intro/outro brackets to use `@clack/prompts`, and bracket every interactive command with an operation-descriptive title. From 3f0448b4bc1a33fe1d2cc9e7352828ea31feb5fc Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 14:43:36 -0600 Subject: [PATCH 23/35] feat(intro): wrap apps commands --- .../cli-core/src/commands/apps/create.test.ts | 18 ++++++++++++++++-- packages/cli-core/src/commands/apps/create.ts | 15 +++++++++++---- packages/cli-core/src/commands/apps/list.ts | 11 +++++++++-- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/cli-core/src/commands/apps/create.test.ts b/packages/cli-core/src/commands/apps/create.test.ts index e0a943c2..e36298f7 100644 --- a/packages/cli-core/src/commands/apps/create.test.ts +++ b/packages/cli-core/src/commands/apps/create.test.ts @@ -17,6 +17,16 @@ mock.module("../../mode.ts", () => ({ getMode: () => "human", })); +const mockNextSteps = mock(); +mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: (msgOrSteps?: string | readonly string[]) => { + if (Array.isArray(msgOrSteps)) mockNextSteps(msgOrSteps); + }, + bar: () => {}, + withSpinner: async (_msg: string, fn: () => Promise) => fn(), +})); + const { create } = await import("./create.ts"); const mockApp = { @@ -106,10 +116,13 @@ describe("apps create", () => { }); test("shows next steps on stderr", async () => { + mockNextSteps.mockReset(); await runCreate("My SaaS App"); - expect(captured.err).toContain("clerk link"); - expect(captured.err).toContain("clerk env pull"); + const steps = mockNextSteps.mock.calls[0]?.[0] as string[] | undefined; + expect(steps).toBeDefined(); + expect(steps!.some((s) => s.includes("clerk link"))).toBe(true); + expect(steps!.some((s) => s.includes("clerk env pull"))).toBe(true); }); }); @@ -134,6 +147,7 @@ describe("apps create", () => { test("does not show next steps", async () => { mockIsAgent.mockReturnValue(true); + mockNextSteps.mockReset(); await runCreate("My SaaS App"); diff --git a/packages/cli-core/src/commands/apps/create.ts b/packages/cli-core/src/commands/apps/create.ts index 76acb6c7..f983a382 100644 --- a/packages/cli-core/src/commands/apps/create.ts +++ b/packages/cli-core/src/commands/apps/create.ts @@ -1,19 +1,26 @@ import { createApplication, fetchApplication } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; import { dim, cyan } from "../../lib/color.ts"; -import { withSpinner } from "../../lib/spinner.ts"; -import { printNextSteps, NEXT_STEPS } from "../../lib/next-steps.ts"; +import { withSpinner, intro, outro } from "../../lib/spinner.ts"; import { stripSecrets, displayName, printJson, type AppsOptions } from "./shared.ts"; import { log } from "../../lib/log.ts"; export async function create(name: string, options: AppsOptions = {}): Promise { + intro("Creating application"); + const app = await withSpinner("Creating application...", async () => { const created = await withApiContext(createApplication(name), "Failed to create application"); return withApiContext(fetchApplication(created.application_id), "Failed to fetch application"); }); - if (printJson(stripSecrets(app), options)) return; + if (printJson(stripSecrets(app), options)) { + outro(); + return; + } log.info(`Created ${cyan(displayName(app))} ${dim(app.application_id)}`); - printNextSteps(NEXT_STEPS.CREATE); + outro([ + `Run \`clerk link --app ${app.application_id}\` to connect this directory`, + "Run `clerk env pull` to fetch your environment variables", + ]); } diff --git a/packages/cli-core/src/commands/apps/list.ts b/packages/cli-core/src/commands/apps/list.ts index a8753476..2a5253e8 100644 --- a/packages/cli-core/src/commands/apps/list.ts +++ b/packages/cli-core/src/commands/apps/list.ts @@ -1,7 +1,7 @@ import { listApplications, type Application } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; import { dim, cyan } from "../../lib/color.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro } from "../../lib/spinner.ts"; import { stripSecrets, displayName, printJson, type AppsOptions } from "./shared.ts"; import { log } from "../../lib/log.ts"; @@ -25,14 +25,20 @@ function formatAppsTable(apps: Application[]): void { } export async function list(options: AppsOptions = {}): Promise { + intro("Listing applications"); + const result = await withSpinner("Fetching applications...", () => withApiContext(listApplications(), "Failed to list applications"), ); - if (printJson(result.map(stripSecrets), options)) return; + if (printJson(result.map(stripSecrets), options)) { + outro(); + return; + } if (result.length === 0) { log.warn("No applications found. Create one at https://dashboard.clerk.com"); + outro(); return; } @@ -40,4 +46,5 @@ export async function list(options: AppsOptions = {}): Promise { const count = result.length; log.info(`\n${count} application${count === 1 ? "" : "s"}`); + outro(); } From 14b5ddd3bac5ce0e352d7f0825605d586b9d3f04 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 14:43:40 -0600 Subject: [PATCH 24/35] feat(intro): wrap users commands --- .../src/commands/users/create.test.ts | 3 +++ .../cli-core/src/commands/users/create.ts | 26 +++++++++++++++++-- .../cli-core/src/commands/users/list.test.ts | 12 +++++---- packages/cli-core/src/commands/users/list.ts | 22 +++++++++------- .../cli-core/src/commands/users/menu.test.ts | 2 +- packages/cli-core/src/commands/users/menu.ts | 2 +- packages/cli-core/src/commands/users/open.ts | 2 +- 7 files changed, 49 insertions(+), 20 deletions(-) diff --git a/packages/cli-core/src/commands/users/create.test.ts b/packages/cli-core/src/commands/users/create.test.ts index fdae3024..1d5686d3 100644 --- a/packages/cli-core/src/commands/users/create.test.ts +++ b/packages/cli-core/src/commands/users/create.test.ts @@ -28,6 +28,9 @@ mock.module("./create-wizard.ts", () => ({ })); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/users/create.ts b/packages/cli-core/src/commands/users/create.ts index ac7f2eda..6c166b66 100644 --- a/packages/cli-core/src/commands/users/create.ts +++ b/packages/cli-core/src/commands/users/create.ts @@ -1,6 +1,6 @@ import { handleBapiError, resolveBapiSecretKey } from "../../lib/bapi-command.ts"; import { throwUsageError } from "../../lib/errors.ts"; -import { log } from "../../lib/log.ts"; +import { isInsideGutter, log } from "../../lib/log.ts"; import { buildCreateUserPayload, mergeUsersPayload, @@ -10,7 +10,7 @@ import { } from "../../lib/users.ts"; import { isHuman } from "../../mode.ts"; import { bapiRequest } from "../api/bapi.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro } from "../../lib/spinner.ts"; import { handleUsersBapiError, printUsersMutationResult } from "./output.ts"; import { registerUsersAction } from "./registry.ts"; import { runCreateWizard } from "./create-wizard.ts"; @@ -41,9 +41,13 @@ type ResolvedCreate = { export async function create(options: CreateUserOptions): Promise { const { payload, resolved } = await resolveCreate(options); + const nested = isInsideGutter(); + if (resolved.dryRun) { + if (!nested) intro("Creating user"); log.info("[dry-run] POST /v1/users"); log.data(JSON.stringify(redactUsersDisplayPayload(payload), null, 2)); + if (!nested) outro(); return; } @@ -53,6 +57,8 @@ export async function create(options: CreateUserOptions): Promise { instance: resolved.instance, }); + if (!nested) intro("Creating user"); + try { const response = await withSpinner("Creating user...", () => bapiRequest({ @@ -64,17 +70,33 @@ export async function create(options: CreateUserOptions): Promise { ); printUsersMutationResult("Created user", response.body, resolved); + if (!nested) { + const userId = extractUserId(response.body); + if (userId) { + outro([`Run \`clerk users open ${userId}\` to view this user in the dashboard`]); + } else { + outro(); + } + } } catch (error) { if (handleUsersBapiError(error, "Failed to create user", resolved)) { + if (!nested) outro("Failed"); return; } if (handleBapiError(error)) { + if (!nested) outro("Failed"); return; } throw error; } } +function extractUserId(body: unknown): string | undefined { + if (!body || typeof body !== "object" || Array.isArray(body)) return undefined; + const { id } = body as { id?: unknown }; + return typeof id === "string" && id.length > 0 ? id : undefined; +} + async function resolveCreate(options: CreateUserOptions): Promise { const { basePayload, resolved } = await resolveBasePayload(options); return { diff --git a/packages/cli-core/src/commands/users/list.test.ts b/packages/cli-core/src/commands/users/list.test.ts index a104e91a..cf6cbf2e 100644 --- a/packages/cli-core/src/commands/users/list.test.ts +++ b/packages/cli-core/src/commands/users/list.test.ts @@ -20,6 +20,9 @@ mock.module("./interactive/instance-context.ts", () => ({ const mockWithSpinner = mock((_msg: string, fn: () => Promise) => fn()); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + bar: () => {}, withSpinner: (...args: Parameters) => mockWithSpinner(...args), })); @@ -273,7 +276,7 @@ describe("users list", () => { }); }); - test("routes the table to stderr (under the gutter) when invoked inside an intro/outro block", async () => { + test("keeps the table on stdout even when invoked inside an intro/outro block", async () => { pushPrefix(); try { await runList(); @@ -281,10 +284,9 @@ describe("users list", () => { popPrefix(); } - expect(captured.out).toBe(""); - expect(captured.err).toContain("Alice Example"); - expect(captured.err).toContain("user_123"); - expect(captured.err).toContain("alice@example.com"); + expect(captured.out).toContain("Alice Example"); + expect(captured.out).toContain("user_123"); + expect(captured.out).toContain("alice@example.com"); expect(captured.err).toContain("2 users returned"); }); diff --git a/packages/cli-core/src/commands/users/list.ts b/packages/cli-core/src/commands/users/list.ts index d200bc17..a5527767 100644 --- a/packages/cli-core/src/commands/users/list.ts +++ b/packages/cli-core/src/commands/users/list.ts @@ -3,7 +3,7 @@ import { dim, cyan } from "../../lib/color.ts"; import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { isAgent, isHuman } from "../../mode.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro } from "../../lib/spinner.ts"; import { bapiRequest } from "../api/bapi.ts"; import { resolveUsersInstanceContext } from "./interactive/instance-context.ts"; import { registerUsersAction } from "./registry.ts"; @@ -118,20 +118,14 @@ function formatUsersTable(users: BapiUser[]): void { const idWidth = Math.max("USER ID".length, ...users.map((user) => user.id.length)) + COLUMN_PADDING; - // Inside an intro/outro block, route rows to stderr so the gutter prefix is - // applied. Direct invocations still get the table on stdout for piping. - const emit = isInsideGutter() - ? (line: string) => log.info(line) - : (line: string) => log.data(line); - - emit( + log.data( `${dim("NAME".padEnd(nameWidth))}${dim("USER ID".padEnd(idWidth))}${dim("PRIMARY IDENTIFIER")}`, ); for (const user of users) { const name = cyan(userDisplayName(user).padEnd(nameWidth)); const id = dim(user.id.padEnd(idWidth)); - emit(`${name}${id}${primaryIdentifier(user)}`); + log.data(`${name}${id}${primaryIdentifier(user)}`); } } @@ -168,6 +162,9 @@ async function resolveListSecretKey(options: UsersListOptions): Promise } export async function list(options: UsersListOptions = {}): Promise { + const nested = isInsideGutter(); + if (!nested) intro("Listing users"); + const secretKey = await resolveListSecretKey(options); const pageSize = options.limit ?? DEFAULT_LIMIT; const offset = options.offset ?? 0; @@ -187,10 +184,14 @@ export async function list(options: UsersListOptions = {}): Promise { const hasMore = allUsers.length > pageSize; const users = hasMore ? allUsers.slice(0, pageSize) : allUsers; - if (printJson({ data: users, hasMore }, options)) return; + if (printJson({ data: users, hasMore }, options)) { + if (!nested) outro(); + return; + } if (users.length === 0) { log.warn("No users found."); + if (!nested) outro(); return; } @@ -202,6 +203,7 @@ export async function list(options: UsersListOptions = {}): Promise { } else { log.info(summary); } + if (!nested) outro(); } registerUsersAction({ diff --git a/packages/cli-core/src/commands/users/menu.test.ts b/packages/cli-core/src/commands/users/menu.test.ts index caa8bc9e..925fa016 100644 --- a/packages/cli-core/src/commands/users/menu.test.ts +++ b/packages/cli-core/src/commands/users/menu.test.ts @@ -60,7 +60,7 @@ describe("usersMenu", () => { await usersMenu({ app: "app_123" }); - expect(mockIntro).toHaveBeenCalledWith("clerk users"); + expect(mockIntro).toHaveBeenCalledWith("Managing users"); expect(mockSelect).toHaveBeenCalled(); expect(handlerCalls).toEqual([{ app: "app_123" }]); }); diff --git a/packages/cli-core/src/commands/users/menu.ts b/packages/cli-core/src/commands/users/menu.ts index 9b928f3e..bc5312ea 100644 --- a/packages/cli-core/src/commands/users/menu.ts +++ b/packages/cli-core/src/commands/users/menu.ts @@ -22,7 +22,7 @@ export async function usersMenu(targeting: UsersActionTargeting = {}): Promise({ message: "What would you like to do?", choices: actions.map((action) => ({ diff --git a/packages/cli-core/src/commands/users/open.ts b/packages/cli-core/src/commands/users/open.ts index e7472a52..0e6c9ccd 100644 --- a/packages/cli-core/src/commands/users/open.ts +++ b/packages/cli-core/src/commands/users/open.ts @@ -128,7 +128,7 @@ export async function open(options: UsersOpenOptions = {}): Promise { return; } - intro("clerk users open"); + intro("Opening user"); log.info(`↗ Opening ${bold(target.appLabel)} (${target.instanceLabel}) → ${cyan(subpath)}`); log.info(` ${dim(url)}`); From 36f43bf1de6fbb117fd4031e321ee7911ba307f1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 14:44:55 -0600 Subject: [PATCH 25/35] feat(intro): wrap config commands --- packages/cli-core/src/commands/config/pull.test.ts | 3 +++ packages/cli-core/src/commands/config/pull.ts | 6 +++++- packages/cli-core/src/commands/config/push.test.ts | 3 +++ packages/cli-core/src/commands/config/push.ts | 9 ++++++++- packages/cli-core/src/commands/config/schema.ts | 5 +++++ 5 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/config/pull.test.ts b/packages/cli-core/src/commands/config/pull.test.ts index 702d7527..204c88ed 100644 --- a/packages/cli-core/src/commands/config/pull.test.ts +++ b/packages/cli-core/src/commands/config/pull.test.ts @@ -8,6 +8,9 @@ import { captureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../tes mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + bar: () => {}, withSpinner: async (msg: string, fn: () => Promise) => { const { log } = await import("../../lib/log.ts"); log.info(msg); diff --git a/packages/cli-core/src/commands/config/pull.ts b/packages/cli-core/src/commands/config/pull.ts index 675b8106..803ec2b0 100644 --- a/packages/cli-core/src/commands/config/pull.ts +++ b/packages/cli-core/src/commands/config/pull.ts @@ -1,7 +1,7 @@ import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfig } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; interface ConfigPullOptions { @@ -12,6 +12,8 @@ interface ConfigPullOptions { } export async function configPull(options: ConfigPullOptions): Promise { + intro("Pulling configuration"); + const ctx = await resolveAppContext(options); const config = await withSpinner( @@ -31,4 +33,6 @@ export async function configPull(options: ConfigPullOptions): Promise { } else { log.data(json); } + + outro(); } diff --git a/packages/cli-core/src/commands/config/push.test.ts b/packages/cli-core/src/commands/config/push.test.ts index 9317480b..6e11ce6d 100644 --- a/packages/cli-core/src/commands/config/push.test.ts +++ b/packages/cli-core/src/commands/config/push.test.ts @@ -16,6 +16,9 @@ mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); mock.module("../../lib/prompts.ts", () => libPromptsStubs); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/config/push.ts b/packages/cli-core/src/commands/config/push.ts index ae991a96..dda3cd30 100644 --- a/packages/cli-core/src/commands/config/push.ts +++ b/packages/cli-core/src/commands/config/push.ts @@ -4,7 +4,7 @@ import { isHuman } from "../../mode.ts"; import { throwUsageError, throwUserAbort, withApiContext, ERROR_CODE } from "../../lib/errors.ts"; import { confirm } from "../../lib/prompts.ts"; import { dim, bold, red, green } from "../../lib/color.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; @@ -28,6 +28,7 @@ type Operation = { config: Record, options?: { destructive?: boolean; dryRun?: boolean }, ) => Promise>; + title: string; }; const PUT_OP: Operation = { @@ -35,12 +36,14 @@ const PUT_OP: Operation = { verb: "Replacing", warning: "This will overwrite the entire instance configuration.", apiFn: putInstanceConfig, + title: "Replacing configuration", }; const PATCH_OP: Operation = { method: "PATCH", verb: "Updating", apiFn: patchInstanceConfig, + title: "Patching configuration", }; export async function configPut(options: ConfigPushOptions): Promise { @@ -73,6 +76,8 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise withApiContext( fetchInstanceConfig(ctx.appId, ctx.instanceId), @@ -85,6 +90,7 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise { diff --git a/packages/cli-core/src/commands/config/schema.ts b/packages/cli-core/src/commands/config/schema.ts index a092a187..cf8d8c03 100644 --- a/packages/cli-core/src/commands/config/schema.ts +++ b/packages/cli-core/src/commands/config/schema.ts @@ -1,6 +1,7 @@ import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfigSchema } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; +import { intro, outro } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; interface ConfigSchemaOptions { @@ -11,6 +12,8 @@ interface ConfigSchemaOptions { } export async function configSchema(options: ConfigSchemaOptions): Promise { + intro("Fetching configuration schema"); + const ctx = await resolveAppContext(options); log.info(`Pulling config schema from ${ctx.appLabel} (${ctx.instanceLabel})...`); @@ -28,4 +31,6 @@ export async function configSchema(options: ConfigSchemaOptions): Promise } else { log.data(json); } + + outro(); } From 30b9d9bb56e2d56000ec887a62155184c572f5a9 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 14:46:01 -0600 Subject: [PATCH 26/35] feat(intro): wrap orgs and billing toggles --- .../cli-core/src/commands/billing/index.test.ts | 3 +++ packages/cli-core/src/commands/billing/index.ts | 16 +++++++++++++--- .../cli-core/src/commands/orgs/index.test.ts | 3 +++ packages/cli-core/src/commands/orgs/index.ts | 16 +++++++++++++--- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/billing/index.test.ts b/packages/cli-core/src/commands/billing/index.test.ts index 1b829fa3..ab2b1506 100644 --- a/packages/cli-core/src/commands/billing/index.test.ts +++ b/packages/cli-core/src/commands/billing/index.test.ts @@ -15,6 +15,9 @@ mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); mock.module("../../lib/prompts.ts", () => libPromptsStubs); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/billing/index.ts b/packages/cli-core/src/commands/billing/index.ts index 2d8af333..513484c4 100644 --- a/packages/cli-core/src/commands/billing/index.ts +++ b/packages/cli-core/src/commands/billing/index.ts @@ -4,7 +4,8 @@ import { isAgent, isHuman } from "../../mode.ts"; import { log } from "../../lib/log.ts"; import { confirm } from "../../lib/prompts.ts"; import { detectPackageManager } from "../../lib/package-manager.ts"; -import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; +import { NEXT_STEPS } from "../../lib/next-steps.ts"; +import { intro, outro } from "../../lib/spinner.ts"; import { applyConfigPatch } from "../config/apply-patch.ts"; import { resolveSkillsRunner, runSkillsAdd } from "../skill/install.ts"; @@ -70,6 +71,8 @@ export async function billingEnable(options: BillingOptions): Promise { billing.user_enabled = true; } + intro("Enabling billing"); + const applied = await applyConfigPatch({ ctx, payload, @@ -80,11 +83,14 @@ export async function billingEnable(options: BillingOptions): Promise { dryRun: options.dryRun, }); - if (!applied || options.dryRun) return; + if (!applied || options.dryRun) { + outro(); + return; + } // `clerk init` doesn't bundle clerk-billing — it's opt-in. Surface it here. if (options.skills !== false) await offerBillingSkillInstall(options); - printNextSteps(NEXT_STEPS.ENABLE_BILLING); + outro(NEXT_STEPS.ENABLE_BILLING); } async function offerBillingSkillInstall(options: BillingOptions): Promise { @@ -127,6 +133,8 @@ export async function billingDisable(options: BillingOptions): Promise { if (targets.includes("orgs")) billing.organization_enabled = false; if (targets.includes("users")) billing.user_enabled = false; + intro("Disabling billing"); + await applyConfigPatch({ ctx, payload: { billing }, @@ -136,4 +144,6 @@ export async function billingDisable(options: BillingOptions): Promise { yes: options.yes, dryRun: options.dryRun, }); + + outro(); } diff --git a/packages/cli-core/src/commands/orgs/index.test.ts b/packages/cli-core/src/commands/orgs/index.test.ts index 45a68984..f314de7d 100644 --- a/packages/cli-core/src/commands/orgs/index.test.ts +++ b/packages/cli-core/src/commands/orgs/index.test.ts @@ -8,6 +8,9 @@ import { captureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../tes mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/orgs/index.ts b/packages/cli-core/src/commands/orgs/index.ts index 7dbd1fb6..9e776980 100644 --- a/packages/cli-core/src/commands/orgs/index.ts +++ b/packages/cli-core/src/commands/orgs/index.ts @@ -1,9 +1,9 @@ import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfig } from "../../lib/plapi.ts"; import { throwUsageError, withApiContext } from "../../lib/errors.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro } from "../../lib/spinner.ts"; import { isHuman } from "../../mode.ts"; -import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; +import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { applyConfigPatch } from "../config/apply-patch.ts"; interface OrgsOptions { @@ -45,6 +45,8 @@ export async function orgsEnable(options: OrgsOptions): Promise { orgSettings.max_allowed_memberships = parsePositiveInt(options.maxMembers, "--max-members"); } + intro("Enabling organizations"); + const applied = await applyConfigPatch({ ctx, payload: { organization_settings: orgSettings }, @@ -55,12 +57,18 @@ export async function orgsEnable(options: OrgsOptions): Promise { dryRun: options.dryRun, }); - if (applied && !options.dryRun) printNextSteps(NEXT_STEPS.ENABLE_ORGS); + if (applied && !options.dryRun) { + outro(NEXT_STEPS.ENABLE_ORGS); + } else { + outro(); + } } export async function orgsDisable(options: OrgsOptions): Promise { const ctx = await resolveAppContext(options); + intro("Disabling organizations"); + const current = await withSpinner("Fetching current config...", () => withApiContext( fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing", "organization_settings"]), @@ -93,4 +101,6 @@ export async function orgsDisable(options: OrgsOptions): Promise { : undefined, currentConfig: current, }); + + outro(); } From ea12f541a11cf982d597eebd31c2dad3f5b1b6a8 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 14:47:59 -0600 Subject: [PATCH 27/35] feat(intro): wrap auth, link, whoami, switch-env, unlink --- packages/cli-core/src/commands/auth/login.ts | 2 +- packages/cli-core/src/commands/auth/logout.ts | 6 ++++-- packages/cli-core/src/commands/link/index.ts | 2 +- .../cli-core/src/commands/switch-env/index.test.ts | 12 ++++++++++++ packages/cli-core/src/commands/switch-env/index.ts | 12 +++++++++--- packages/cli-core/src/commands/unlink/index.ts | 7 +++++-- packages/cli-core/src/commands/whoami/index.ts | 8 +++++--- 7 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index c2c3eab6..e7bbb900 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -92,7 +92,7 @@ async function performOAuthFlow(): Promise { export async function login(options: LoginOptions = {}): Promise { const { showNextSteps = true, yes } = options; - intro("clerk auth login"); + intro("Signing in"); const existingSession = await withSpinner("Checking session...", () => getExistingSession()); if (existingSession && !isHuman()) { diff --git a/packages/cli-core/src/commands/auth/logout.ts b/packages/cli-core/src/commands/auth/logout.ts index f1011b54..1fb2f9a2 100644 --- a/packages/cli-core/src/commands/auth/logout.ts +++ b/packages/cli-core/src/commands/auth/logout.ts @@ -1,11 +1,13 @@ import { deleteToken } from "../../lib/credential-store.ts"; import { clearAuth } from "../../lib/config.ts"; import { log } from "../../lib/log.ts"; -import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; +import { intro, outro } from "../../lib/spinner.ts"; +import { NEXT_STEPS } from "../../lib/next-steps.ts"; export async function logout(): Promise { + intro("Signing out"); await deleteToken(); await clearAuth(); log.success("Logged out successfully"); - printNextSteps(NEXT_STEPS.LOGOUT); + outro(NEXT_STEPS.LOGOUT); } diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 8e406a14..154b0536 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -55,7 +55,7 @@ export async function link(options: LinkOptions = {}): Promise { ); } - intro("clerk link"); + intro("Linking project"); if (existing && agent) { printExistingStatus(existing, normalizedRemote); diff --git a/packages/cli-core/src/commands/switch-env/index.test.ts b/packages/cli-core/src/commands/switch-env/index.test.ts index f51bdb90..acf134a7 100644 --- a/packages/cli-core/src/commands/switch-env/index.test.ts +++ b/packages/cli-core/src/commands/switch-env/index.test.ts @@ -1,4 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { log } from "../../lib/log.ts"; import { captureLog, configStubs, @@ -47,6 +48,17 @@ mock.module("../../lib/listage.ts", () => ({ select: (...args: unknown[]) => mockSelect(...args), })); +mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: (msgOrSteps?: string | readonly string[]) => { + if (Array.isArray(msgOrSteps)) { + for (const step of msgOrSteps) log.info(step); + } + }, + bar: () => {}, + withSpinner: async (_msg: string, fn: () => Promise) => fn(), +})); + const { switchEnv } = await import("./index.ts"); describe("switch-env", () => { diff --git a/packages/cli-core/src/commands/switch-env/index.ts b/packages/cli-core/src/commands/switch-env/index.ts index a0c893ba..cfb0b143 100644 --- a/packages/cli-core/src/commands/switch-env/index.ts +++ b/packages/cli-core/src/commands/switch-env/index.ts @@ -19,12 +19,15 @@ import { CliError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { isHuman } from "../../mode.ts"; import { select } from "../../lib/listage.ts"; -import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; +import { intro, outro } from "../../lib/spinner.ts"; +import { NEXT_STEPS } from "../../lib/next-steps.ts"; export async function switchEnv(environmentArg: string | undefined): Promise { const available = getAvailableEnvs(); const current = getCurrentEnvName(); + intro("Switching environment"); + // No argument: show interactive picker (human) or print info (non-interactive) let target = environmentArg; if (!target) { @@ -44,10 +47,12 @@ export async function switchEnv(environmentArg: string | undefined): Promise { const repoRoot = await getGitRepoRoot(); const displayPath = repoRoot ?? existing.path; + intro("Unlinking project"); + if (isHuman() && !options.yes) { const ok = await confirm({ message: `Unlink ${label} from ${displayPath}?`, @@ -41,5 +44,5 @@ export async function unlink(options: UnlinkOptions = {}): Promise { await removeProfile(existing.path); log.data(`\nUnlinked ${cyan(label)} from ${dim(displayPath)}`); - printNextSteps(NEXT_STEPS.UNLINK); + outro(NEXT_STEPS.UNLINK); } diff --git a/packages/cli-core/src/commands/whoami/index.ts b/packages/cli-core/src/commands/whoami/index.ts index 0e20b452..8b9127f0 100644 --- a/packages/cli-core/src/commands/whoami/index.ts +++ b/packages/cli-core/src/commands/whoami/index.ts @@ -1,10 +1,10 @@ import { getValidToken } from "../../lib/credential-store.ts"; import { fetchUserInfo } from "../../lib/token-exchange.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; import { AuthError } from "../../lib/errors.ts"; import { resolveProfile } from "../../lib/config.ts"; -import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; +import { NEXT_STEPS } from "../../lib/next-steps.ts"; export async function whoami() { const token = await getValidToken(); @@ -12,6 +12,8 @@ export async function whoami() { throw new AuthError({ reason: "not_logged_in" }); } + intro("Identifying user"); + let userInfo; try { userInfo = await withSpinner("Fetching account info...", () => fetchUserInfo(token)); @@ -26,5 +28,5 @@ export async function whoami() { } catch { // Best-effort only: don't fail whoami when local profile resolution fails. } - printNextSteps(isLinked ? NEXT_STEPS.WHOAMI_LINKED : NEXT_STEPS.WHOAMI); + outro(isLinked ? NEXT_STEPS.WHOAMI_LINKED : NEXT_STEPS.WHOAMI); } From ad6fb087aa8eb9813daf9095e2c36910f38eabc9 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 14:49:30 -0600 Subject: [PATCH 28/35] feat(intro): wrap env pull, api, skill install --- packages/cli-core/src/commands/api/index.ts | 143 ++++++++++-------- .../cli-core/src/commands/env/pull.test.ts | 3 + packages/cli-core/src/commands/env/pull.ts | 5 +- .../cli-core/src/commands/skill/install.ts | 14 +- 4 files changed, 95 insertions(+), 70 deletions(-) diff --git a/packages/cli-core/src/commands/api/index.ts b/packages/cli-core/src/commands/api/index.ts index 1bdf7624..5acc96a2 100644 --- a/packages/cli-core/src/commands/api/index.ts +++ b/packages/cli-core/src/commands/api/index.ts @@ -5,8 +5,8 @@ import { bapiRequest } from "./bapi.ts"; import { BapiError, ERROR_CODE, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; import { isHuman } from "../../mode.ts"; import { confirm } from "../../lib/prompts.ts"; -import { withSpinner } from "../../lib/spinner.ts"; -import { log } from "../../lib/log.ts"; +import { withSpinner, intro, outro } from "../../lib/spinner.ts"; +import { isInsideGutter, log } from "../../lib/log.ts"; export interface ApiOptions { method?: string; @@ -28,85 +28,94 @@ export async function api( filter: string | undefined, options: ApiOptions, ): Promise { - // Route: no args → interactive builder - if (!endpoint) { - const { apiInteractive } = await import("./interactive.ts"); - return apiInteractive(options); - } + const nested = isInsideGutter(); + if (!nested) intro("Calling Clerk API"); - // Route: "ls" → list endpoints - if (endpoint === "ls") { - const { apiLs } = await import("./ls.ts"); - return apiLs(filter, options); - } + try { + // Route: no args → interactive builder + if (!endpoint) { + const { apiInteractive } = await import("./interactive.ts"); + await apiInteractive(options); + return; + } - // 1. Resolve the request body - const body = await resolveBody(options); + // Route: "ls" → list endpoints + if (endpoint === "ls") { + const { apiLs } = await import("./ls.ts"); + await apiLs(filter, options); + return; + } - // 2. Determine HTTP method - const method = (options.method ?? (body ? "POST" : "GET")).toUpperCase(); + // 1. Resolve the request body + const body = await resolveBody(options); - // 3. Resolve authentication - let secretKey: string; - let baseUrl: string; + // 2. Determine HTTP method + const method = (options.method ?? (body ? "POST" : "GET")).toUpperCase(); - if (options.platform) { - secretKey = await getAuthToken(); - baseUrl = getPlapiBaseUrl(); - } else { - secretKey = await resolveBapiSecretKey(options); - baseUrl = getBapiBaseUrl(); - } + // 3. Resolve authentication + let secretKey: string; + let baseUrl: string; - // 4. Dry run - if (options.dryRun) { - log.info(`[dry-run] ${method} ${baseUrl}${normalizeBapiPath(endpoint)}`); - if (body) { - prettyPrint(body); + if (options.platform) { + secretKey = await getAuthToken(); + baseUrl = getPlapiBaseUrl(); + } else { + secretKey = await resolveBapiSecretKey(options); + baseUrl = getBapiBaseUrl(); } - return; - } - // 5. Confirmation for mutating methods - if (MUTATING_METHODS.has(method) && isHuman() && !options.yes) { - log.info(`\nAbout to ${method} ${endpoint}`); - if (body) { - prettyPrintToStderr(body); - } - const ok = await confirm({ message: "Proceed?" }); - if (!ok) { - throwUserAbort(); + // 4. Dry run + if (options.dryRun) { + log.info(`[dry-run] ${method} ${baseUrl}${normalizeBapiPath(endpoint)}`); + if (body) { + prettyPrint(body); + } + return; } - } - // 6. Execute request - try { - const response = await withSpinner("Executing request...", () => - bapiRequest({ - method, - path: endpoint, - secretKey, - body: body ?? undefined, - baseUrl, - }), - ); - - if (options.include) { - printHeaders(response.status, response.headers); + // 5. Confirmation for mutating methods + if (MUTATING_METHODS.has(method) && isHuman() && !options.yes) { + log.info(`\nAbout to ${method} ${endpoint}`); + if (body) { + prettyPrintToStderr(body); + } + const ok = await confirm({ message: "Proceed?" }); + if (!ok) { + throwUserAbort(); + } } - printBody(response.body); - } catch (error) { - // Handle BapiError locally to print the raw API response body to stdout - // (for piping), rather than propagating to the global error handler. - if (error instanceof BapiError) { + + // 6. Execute request + try { + const response = await withSpinner("Executing request...", () => + bapiRequest({ + method, + path: endpoint, + secretKey, + body: body ?? undefined, + baseUrl, + }), + ); + if (options.include) { - printHeaders(error.status, error.headers); + printHeaders(response.status, response.headers); } - prettyPrint(error.body); - process.exitCode = 1; - return; + printBody(response.body); + } catch (error) { + // Handle BapiError locally to print the raw API response body to stdout + // (for piping), rather than propagating to the global error handler. + if (error instanceof BapiError) { + if (options.include) { + printHeaders(error.status, error.headers); + } + prettyPrint(error.body); + process.exitCode = 1; + return; + } + throw error; } - throw error; + } finally { + if (!nested) outro(); } } diff --git a/packages/cli-core/src/commands/env/pull.test.ts b/packages/cli-core/src/commands/env/pull.test.ts index 328ce0cd..fb11d8ba 100644 --- a/packages/cli-core/src/commands/env/pull.test.ts +++ b/packages/cli-core/src/commands/env/pull.test.ts @@ -13,6 +13,9 @@ import { mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + bar: () => {}, withSpinner: async (msg: string, fn: () => Promise) => { console.error(msg); return fn(); diff --git a/packages/cli-core/src/commands/env/pull.ts b/packages/cli-core/src/commands/env/pull.ts index 6ccc4726..12fedd32 100644 --- a/packages/cli-core/src/commands/env/pull.ts +++ b/packages/cli-core/src/commands/env/pull.ts @@ -8,7 +8,7 @@ import { detectEnvFile, } from "../../lib/framework.ts"; import { CliError, ERROR_CODE, withApiContext } from "../../lib/errors.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; const DEV_LOCAL_ENV_FILE = ".env.development.local"; @@ -48,6 +48,8 @@ async function resolveTargetFile( } export async function pull(options: EnvPullOptions): Promise { + intro("Pulling environment variables"); + const cwd = options.cwd ?? process.cwd(); const [ctx, preferredEnvFile] = await Promise.all([ resolveAppContext({ ...options, cwd }), @@ -87,4 +89,5 @@ export async function pull(options: EnvPullOptions): Promise { }); log.info(`Environment variables written to ${displayPath}`); + outro(); } diff --git a/packages/cli-core/src/commands/skill/install.ts b/packages/cli-core/src/commands/skill/install.ts index 223f879c..acc169c6 100644 --- a/packages/cli-core/src/commands/skill/install.ts +++ b/packages/cli-core/src/commands/skill/install.ts @@ -36,6 +36,7 @@ import { import { isNonEmpty } from "../../lib/helpers/arrays.js"; import { detectPackageManager, type PackageManager } from "../../lib/package-manager.js"; import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.js"; +import { intro, outro } from "../../lib/spinner.js"; import clerkSkillMd from "../../../../../skills/clerk-cli/SKILL.md" with { type: "text" }; import clerkAuthMd from "../../../../../skills/clerk-cli/references/auth.md" with { type: "text" }; @@ -237,6 +238,8 @@ export interface SkillInstallOptions { * `clerk skill install` — standalone install of the bundled clerk skill. */ export async function skillInstall(options: SkillInstallOptions): Promise { + intro("Installing Clerk skill"); + const cwd = process.cwd(); const skipPrompt = options.yes ?? false; const interactive = isHuman() && !skipPrompt; @@ -244,12 +247,19 @@ export async function skillInstall(options: SkillInstallOptions): Promise const packageManager = options.pm ?? (await detectPackageManager(cwd)); const runner = await resolveSkillsRunner(packageManager, interactive); - if (!runner) return; + if (!runner) { + outro(); + return; + } const ok = await installClerkSkillCore(runner, cwd, interactive); - if (!ok) return; + if (!ok) { + outro(); + return; + } log.blank(); log.success("clerk-cli skill installed. AI agents now have Clerk context in this project."); printNextSteps(NEXT_STEPS.SKILL_INSTALL); + outro(); } From e4da42e99c75b873b1302164345612d85d477788 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 14:50:48 -0600 Subject: [PATCH 29/35] refactor(intro): retitle init, doctor, update, and open to operation style --- packages/cli-core/src/commands/doctor/index.ts | 2 +- packages/cli-core/src/commands/init/index.ts | 2 +- packages/cli-core/src/commands/open/index.ts | 2 +- packages/cli-core/src/commands/update/index.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/doctor/index.ts b/packages/cli-core/src/commands/doctor/index.ts index 93978026..c5d6ca25 100644 --- a/packages/cli-core/src/commands/doctor/index.ts +++ b/packages/cli-core/src/commands/doctor/index.ts @@ -62,7 +62,7 @@ function printResults(results: CheckResult[], options: DoctorOptions): void { export async function doctor(options: DoctorOptions = {}): Promise { if (!options.json) { - intro("clerk doctor"); + intro("Running diagnostics"); } const ctx = createDoctorContext(); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index a1cd333b..9391d40a 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -72,7 +72,7 @@ export async function init(options: InitOptions = {}) { nameOverride: options.name, }; - intro("clerk init"); + intro("Setting up Clerk"); const resolved = options.starter ? await handleStarter(cwd, frameworkOverride, overrides) diff --git a/packages/cli-core/src/commands/open/index.ts b/packages/cli-core/src/commands/open/index.ts index 927770fd..25413abe 100644 --- a/packages/cli-core/src/commands/open/index.ts +++ b/packages/cli-core/src/commands/open/index.ts @@ -84,7 +84,7 @@ export async function openDashboard( // Human mode — use intro/outro logging flow const target = subpath ? ` → ${cyan(subpath)}` : ""; - intro("clerk open"); + intro("Opening dashboard"); if (unknownPath) { log.warn(`"${subpath}" is not a known dashboard path. Opening anyway — verify the URL.`); diff --git a/packages/cli-core/src/commands/update/index.ts b/packages/cli-core/src/commands/update/index.ts index 23b7a302..5c7d49b3 100644 --- a/packages/cli-core/src/commands/update/index.ts +++ b/packages/cli-core/src/commands/update/index.ts @@ -261,7 +261,7 @@ export async function update(options: UpdateOptions): Promise { const channel = options.channel ?? getUpdateChannel(); - if (isHuman()) intro("clerk update"); + if (isHuman()) intro("Checking for updates"); const [latest, installDirs] = await Promise.all([ withSpinner("Checking for updates...", () => fetchLatestVersion(channel)).catch(() => { From a79d14959fe785fff926b9c109be659b5d48d0c8 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 May 2026 20:44:52 -0600 Subject: [PATCH 30/35] feat(intro): route human output through prompt rail --- packages/cli-core/src/commands/apps/create.ts | 2 +- .../cli-core/src/commands/apps/list.test.ts | 16 ++++++++-------- packages/cli-core/src/commands/apps/list.ts | 7 ++++--- .../cli-core/src/commands/users/create.test.ts | 5 ++++- packages/cli-core/src/commands/users/create.ts | 4 ++-- .../cli-core/src/commands/users/list.test.ts | 18 +++++++++--------- packages/cli-core/src/commands/users/list.ts | 18 +++++++++--------- packages/cli-core/src/commands/users/open.ts | 2 +- packages/cli-core/src/commands/whoami/index.ts | 1 + .../test/integration/users-commands.test.ts | 12 ++++++------ 10 files changed, 45 insertions(+), 40 deletions(-) diff --git a/packages/cli-core/src/commands/apps/create.ts b/packages/cli-core/src/commands/apps/create.ts index f983a382..7923280b 100644 --- a/packages/cli-core/src/commands/apps/create.ts +++ b/packages/cli-core/src/commands/apps/create.ts @@ -14,10 +14,10 @@ export async function create(name: string, options: AppsOptions = {}): Promise { await runList(); - expect(captured.out).toContain("My SaaS App"); - expect(captured.out).toContain("app_abc123"); - expect(captured.out).toContain("development, production"); - expect(captured.out).toContain("Side Project"); - expect(captured.out).toContain("app_xyz789"); + expect(captured.err).toContain("My SaaS App"); + expect(captured.err).toContain("app_abc123"); + expect(captured.err).toContain("development, production"); + expect(captured.err).toContain("Side Project"); + expect(captured.err).toContain("app_xyz789"); }); test("shows app id as name when name is absent", async () => { @@ -98,7 +98,7 @@ describe("apps list", () => { await runList(); - expect(captured.out).toContain("app_noname"); + expect(captured.err).toContain("app_noname"); }); test("does not show secret keys", async () => { @@ -106,8 +106,8 @@ describe("apps list", () => { await runList(); - expect(captured.out).not.toContain("sk_test_xxx"); - expect(captured.out).not.toContain("sk_live_xxx"); + expect(captured.err).not.toContain("sk_test_xxx"); + expect(captured.err).not.toContain("sk_live_xxx"); }); test("shows count summary on stderr", async () => { diff --git a/packages/cli-core/src/commands/apps/list.ts b/packages/cli-core/src/commands/apps/list.ts index 2a5253e8..c08b7279 100644 --- a/packages/cli-core/src/commands/apps/list.ts +++ b/packages/cli-core/src/commands/apps/list.ts @@ -14,13 +14,13 @@ function formatAppsTable(apps: Application[]): void { Math.max("APP ID".length, ...apps.map((a) => a.application_id.length)) + COLUMN_PADDING; const header = `${"NAME".padEnd(nameWidth)}${"APP ID".padEnd(idWidth)}ENVIRONMENTS`; - log.data(dim(header)); + log.info(dim(header)); for (const app of apps) { const name = displayName(app).padEnd(nameWidth); const id = dim(app.application_id.padEnd(idWidth)); const envs = app.instances.map((i) => i.environment_type).join(", "); - log.data(`${cyan(name)}${id}${envs}`); + log.info(`${cyan(name)}${id}${envs}`); } } @@ -32,10 +32,11 @@ export async function list(options: AppsOptions = {}): Promise { ); if (printJson(result.map(stripSecrets), options)) { - outro(); return; } + log.blank() + if (result.length === 0) { log.warn("No applications found. Create one at https://dashboard.clerk.com"); outro(); diff --git a/packages/cli-core/src/commands/users/create.test.ts b/packages/cli-core/src/commands/users/create.test.ts index 1d5686d3..58fa3f72 100644 --- a/packages/cli-core/src/commands/users/create.test.ts +++ b/packages/cli-core/src/commands/users/create.test.ts @@ -124,7 +124,10 @@ describe("users create", () => { }); expect(captured.err).toContain("[dry-run] POST /v1/users"); - expect(JSON.parse(captured.out)).toEqual({ + // Dry-run preview now renders to stderr (with gutter); stdout stays clean. + const previewMatch = captured.err.match(/\{[\s\S]*\}/); + expect(previewMatch).not.toBeNull(); + expect(JSON.parse(previewMatch![0])).toEqual({ email_address: ["alice@example.com"], password: "[REDACTED]", }); diff --git a/packages/cli-core/src/commands/users/create.ts b/packages/cli-core/src/commands/users/create.ts index 6c166b66..cd3e0887 100644 --- a/packages/cli-core/src/commands/users/create.ts +++ b/packages/cli-core/src/commands/users/create.ts @@ -46,8 +46,8 @@ export async function create(options: CreateUserOptions): Promise { if (resolved.dryRun) { if (!nested) intro("Creating user"); log.info("[dry-run] POST /v1/users"); - log.data(JSON.stringify(redactUsersDisplayPayload(payload), null, 2)); - if (!nested) outro(); + log.blank(); + log.info(JSON.stringify(redactUsersDisplayPayload(payload), null, 2)); return; } diff --git a/packages/cli-core/src/commands/users/list.test.ts b/packages/cli-core/src/commands/users/list.test.ts index cf6cbf2e..64730f7a 100644 --- a/packages/cli-core/src/commands/users/list.test.ts +++ b/packages/cli-core/src/commands/users/list.test.ts @@ -154,11 +154,11 @@ describe("users list", () => { test("prints a concise human-readable table by default", async () => { await runList(); - expect(captured.out).toContain("Alice Example"); - expect(captured.out).toContain("alice@example.com"); - expect(captured.out).toContain("user_123"); - expect(captured.out).toContain("bob"); - expect(captured.out).toContain("+15551234567"); + expect(captured.err).toContain("Alice Example"); + expect(captured.err).toContain("alice@example.com"); + expect(captured.err).toContain("user_123"); + expect(captured.err).toContain("bob"); + expect(captured.err).toContain("+15551234567"); expect(captured.err).toContain("2 users returned"); }); @@ -276,7 +276,7 @@ describe("users list", () => { }); }); - test("keeps the table on stdout even when invoked inside an intro/outro block", async () => { + test("routes the table to stderr (gutter rail) when invoked inside an intro/outro block", async () => { pushPrefix(); try { await runList(); @@ -284,9 +284,9 @@ describe("users list", () => { popPrefix(); } - expect(captured.out).toContain("Alice Example"); - expect(captured.out).toContain("user_123"); - expect(captured.out).toContain("alice@example.com"); + expect(captured.err).toContain("Alice Example"); + expect(captured.err).toContain("user_123"); + expect(captured.err).toContain("alice@example.com"); expect(captured.err).toContain("2 users returned"); }); diff --git a/packages/cli-core/src/commands/users/list.ts b/packages/cli-core/src/commands/users/list.ts index a5527767..a3fe74c9 100644 --- a/packages/cli-core/src/commands/users/list.ts +++ b/packages/cli-core/src/commands/users/list.ts @@ -118,14 +118,14 @@ function formatUsersTable(users: BapiUser[]): void { const idWidth = Math.max("USER ID".length, ...users.map((user) => user.id.length)) + COLUMN_PADDING; - log.data( + log.info( `${dim("NAME".padEnd(nameWidth))}${dim("USER ID".padEnd(idWidth))}${dim("PRIMARY IDENTIFIER")}`, ); for (const user of users) { const name = cyan(userDisplayName(user).padEnd(nameWidth)); const id = dim(user.id.padEnd(idWidth)); - log.data(`${name}${id}${primaryIdentifier(user)}`); + log.info(`${name}${id}${primaryIdentifier(user)}`); } } @@ -166,7 +166,7 @@ export async function list(options: UsersListOptions = {}): Promise { if (!nested) intro("Listing users"); const secretKey = await resolveListSecretKey(options); - const pageSize = options.limit ?? DEFAULT_LIMIT; + const limit = options.limit ?? DEFAULT_LIMIT; const offset = options.offset ?? 0; // Request one extra row so we can detect whether more pages exist without // a separate /users/count round-trip. The CLI's --limit caps at 250, so @@ -174,21 +174,22 @@ export async function list(options: UsersListOptions = {}): Promise { const response = await withSpinner("Fetching users...", () => bapiRequest({ method: "GET", - path: buildUsersListPath(options, pageSize + 1), + path: buildUsersListPath(options, limit + 1), secretKey, }), ); const body = response.body; const allUsers = Array.isArray(body) ? (body as BapiUser[]) : []; - const hasMore = allUsers.length > pageSize; - const users = hasMore ? allUsers.slice(0, pageSize) : allUsers; + const hasMore = allUsers.length > limit; + const users = hasMore ? allUsers.slice(0, limit) : allUsers; if (printJson({ data: users, hasMore }, options)) { - if (!nested) outro(); return; } + log.blank(); + if (users.length === 0) { log.warn("No users found."); if (!nested) outro(); @@ -198,8 +199,7 @@ export async function list(options: UsersListOptions = {}): Promise { formatUsersTable(users); const summary = `\n${users.length} user${users.length === 1 ? "" : "s"} returned`; if (hasMore) { - const nextOffset = offset + pageSize; - log.info(`${summary} (more available, re-run with \`--offset ${nextOffset}\`)`); + log.info(`${summary} (more available, re-run with \`--offset ${offset + limit}\`)`); } else { log.info(summary); } diff --git a/packages/cli-core/src/commands/users/open.ts b/packages/cli-core/src/commands/users/open.ts index 0e6c9ccd..0393dcf4 100644 --- a/packages/cli-core/src/commands/users/open.ts +++ b/packages/cli-core/src/commands/users/open.ts @@ -187,7 +187,7 @@ export async function open(options: UsersOpenOptions = {}): Promise { const appLabel = target.appLabel ?? target.appId; const instanceLabel = target.instanceLabel ?? target.instanceId; - intro("clerk users open"); + intro("Opening user"); log.info(`↗ Opening ${bold(appLabel)} (${instanceLabel}) → ${cyan(subpath)}`); log.info(` ${dim(url)}`); diff --git a/packages/cli-core/src/commands/whoami/index.ts b/packages/cli-core/src/commands/whoami/index.ts index 8b9127f0..2b39f9af 100644 --- a/packages/cli-core/src/commands/whoami/index.ts +++ b/packages/cli-core/src/commands/whoami/index.ts @@ -20,6 +20,7 @@ export async function whoami() { } catch { throw new AuthError({ reason: "session_expired" }); } + log.data(userInfo.email); let isLinked = false; diff --git a/packages/cli-core/src/test/integration/users-commands.test.ts b/packages/cli-core/src/test/integration/users-commands.test.ts index 659949be..4dee21c9 100644 --- a/packages/cli-core/src/test/integration/users-commands.test.ts +++ b/packages/cli-core/src/test/integration/users-commands.test.ts @@ -154,10 +154,10 @@ describe("users commands", () => { ); expect(stderr).toContain("[dry-run] POST /v1/users"); - expect(JSON.parse(stdout)).toEqual({ - email_address: ["alice@example.com"], - password: "[REDACTED]", - }); + // Dry-run preview now renders to stderr (with the intro/outro gutter); stdout stays clean. + expect(stderr).toContain('"alice@example.com"'); + expect(stderr).toContain('"[REDACTED]"'); + expect(stdout).toBe(""); expect(findBapiCreateRequest()).toBeUndefined(); }); @@ -287,8 +287,8 @@ describe("users commands", () => { const { stdout, stderr } = await clerk("--mode", mode, "users", "list"); if (mode === "human") { - expect(stdout).toContain("John Doe"); - expect(stdout).toContain("john@example.com"); + expect(stderr).toContain("John Doe"); + expect(stderr).toContain("john@example.com"); expect(stderr).toContain("1 user returned"); } else { expect(JSON.parse(stdout)).toEqual({ data: MOCK_USERS, hasMore: false }); From 92c20a73b253d25ad2e107c02e9730f69f73c109 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 21 May 2026 14:10:14 -0600 Subject: [PATCH 31/35] feat(intro): route apps list through clack ui --- .../cli-core/src/commands/apps/list.test.ts | 40 ++++++++++++------- packages/cli-core/src/commands/apps/list.ts | 18 ++++----- packages/cli-core/src/lib/spinner.ts | 7 ++-- packages/cli-core/src/lib/ui.ts | 39 ++++++++++++++++++ packages/cli-core/src/test/lib/stubs.ts | 38 ++++++++++++++++++ 5 files changed, 114 insertions(+), 28 deletions(-) create mode 100644 packages/cli-core/src/lib/ui.ts diff --git a/packages/cli-core/src/commands/apps/list.test.ts b/packages/cli-core/src/commands/apps/list.test.ts index 6172caa9..ebbc210a 100644 --- a/packages/cli-core/src/commands/apps/list.test.ts +++ b/packages/cli-core/src/commands/apps/list.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { captureLog } from "../../test/lib/stubs.ts"; +import { captureLog, captureUi } from "../../test/lib/stubs.ts"; const mockListApplications = mock(); mock.module("../../lib/plapi.ts", () => ({ @@ -53,15 +53,19 @@ describe("apps list", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let captured: ReturnType; + let uiCapture: ReturnType; beforeEach(() => { mockIsAgent.mockReturnValue(false); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); + uiCapture = captureUi(); + uiCapture.install(); captured = captureLog(); }); afterEach(() => { + uiCapture.teardown(); captured.teardown(); mockListApplications.mockReset(); mockIsAgent.mockReset(); @@ -73,17 +77,20 @@ describe("apps list", () => { return captured.run(() => list(options)); } + const stdoutOut = () => uiCapture.out; + describe("compact table (default)", () => { test("lists apps with name, id, and environments", async () => { mockListApplications.mockResolvedValue(mockApps); await runList(); - expect(captured.err).toContain("My SaaS App"); - expect(captured.err).toContain("app_abc123"); - expect(captured.err).toContain("development, production"); - expect(captured.err).toContain("Side Project"); - expect(captured.err).toContain("app_xyz789"); + const out = stdoutOut(); + expect(out).toContain("My SaaS App"); + expect(out).toContain("app_abc123"); + expect(out).toContain("development, production"); + expect(out).toContain("Side Project"); + expect(out).toContain("app_xyz789"); }); test("shows app id as name when name is absent", async () => { @@ -98,7 +105,7 @@ describe("apps list", () => { await runList(); - expect(captured.err).toContain("app_noname"); + expect(stdoutOut()).toContain("app_noname"); }); test("does not show secret keys", async () => { @@ -106,16 +113,17 @@ describe("apps list", () => { await runList(); - expect(captured.err).not.toContain("sk_test_xxx"); - expect(captured.err).not.toContain("sk_live_xxx"); + const out = stdoutOut(); + expect(out).not.toContain("sk_test_xxx"); + expect(out).not.toContain("sk_live_xxx"); }); - test("shows count summary on stderr", async () => { + test("shows count summary", async () => { mockListApplications.mockResolvedValue(mockApps); await runList(); - expect(captured.err).toContain("2 applications"); + expect(stdoutOut()).toContain("2 applications"); }); test("shows singular count for one app", async () => { @@ -123,8 +131,9 @@ describe("apps list", () => { await runList(); - expect(captured.err).toContain("1 application"); - expect(captured.err).not.toContain("1 applications"); + const out = stdoutOut(); + expect(out).toContain("1 application"); + expect(out).not.toContain("1 applications"); }); }); @@ -167,8 +176,9 @@ describe("apps list", () => { await runList(); - expect(captured.err).toContain("No applications found"); - expect(captured.err).toContain("dashboard.clerk.com"); + const out = stdoutOut(); + expect(out).toContain("No applications found"); + expect(out).toContain("dashboard.clerk.com"); }); test("outputs empty JSON array when --json flag is set", async () => { diff --git a/packages/cli-core/src/commands/apps/list.ts b/packages/cli-core/src/commands/apps/list.ts index c08b7279..cd0e2cd3 100644 --- a/packages/cli-core/src/commands/apps/list.ts +++ b/packages/cli-core/src/commands/apps/list.ts @@ -2,8 +2,8 @@ import { listApplications, type Application } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; import { dim, cyan } from "../../lib/color.ts"; import { withSpinner, intro, outro } from "../../lib/spinner.ts"; +import { ui } from "../../lib/ui.ts"; import { stripSecrets, displayName, printJson, type AppsOptions } from "./shared.ts"; -import { log } from "../../lib/log.ts"; const COLUMN_PADDING = 2; @@ -14,14 +14,14 @@ function formatAppsTable(apps: Application[]): void { Math.max("APP ID".length, ...apps.map((a) => a.application_id.length)) + COLUMN_PADDING; const header = `${"NAME".padEnd(nameWidth)}${"APP ID".padEnd(idWidth)}ENVIRONMENTS`; - log.info(dim(header)); - - for (const app of apps) { + const rows = apps.map((app) => { const name = displayName(app).padEnd(nameWidth); const id = dim(app.application_id.padEnd(idWidth)); const envs = app.instances.map((i) => i.environment_type).join(", "); - log.info(`${cyan(name)}${id}${envs}`); - } + return `${cyan(name)}${id}${envs}`; + }); + + ui.message([dim(header), ...rows]); } export async function list(options: AppsOptions = {}): Promise { @@ -35,10 +35,8 @@ export async function list(options: AppsOptions = {}): Promise { return; } - log.blank() - if (result.length === 0) { - log.warn("No applications found. Create one at https://dashboard.clerk.com"); + ui.warn("No applications found. Create one at https://dashboard.clerk.com"); outro(); return; } @@ -46,6 +44,6 @@ export async function list(options: AppsOptions = {}): Promise { formatAppsTable(result); const count = result.length; - log.info(`\n${count} application${count === 1 ? "" : "s"}`); + ui.message(`${count} application${count === 1 ? "" : "s"}`); outro(); } diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index efc00326..ccd57843 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -2,6 +2,7 @@ import { intro as clackIntro, outro as clackOutro, spinner as clackSpinner } fro import { isHuman } from "../mode.ts"; import { dim, cyan } from "./color.ts"; import { pushPrefix, popPrefix } from "./log.ts"; +import { getUiOutput } from "./ui.ts"; const S_BAR = "│"; const S_BAR_END = "└"; @@ -11,7 +12,7 @@ const stream = process.stderr; /** Print intro bracket and arrange for subsequent `log.*` lines to be gutter-prefixed. */ export function intro(title?: string) { if (!isHuman()) return; - clackIntro(title); + clackIntro(title, { output: getUiOutput() }); pushPrefix(); } @@ -30,7 +31,7 @@ export function outro(messageOrSteps?: string | readonly string[]) { return; } - clackOutro(messageOrSteps ?? "Done"); + clackOutro(messageOrSteps ?? "Done", { output: getUiOutput() }); } /** Print a bar separator: │ */ @@ -46,7 +47,7 @@ export async function withSpinner( ): Promise { if (!isHuman()) return fn(); - const s = clackSpinner(); + const s = clackSpinner({ output: getUiOutput() }); s.start(message); try { const result = await fn(); diff --git a/packages/cli-core/src/lib/ui.ts b/packages/cli-core/src/lib/ui.ts new file mode 100644 index 00000000..058d837d --- /dev/null +++ b/packages/cli-core/src/lib/ui.ts @@ -0,0 +1,39 @@ +import type { Writable } from "node:stream"; +import { log as clackLog } from "@clack/prompts"; + +type LogOptions = Parameters[1]; + +let outputStream: Writable | undefined; + +export function setUiOutput(stream: Writable | undefined) { + outputStream = stream; +} + +export function getUiOutput(): Writable | undefined { + return outputStream; +} + +function withOutput(opts?: T): T { + return { ...opts, output: opts?.output ?? outputStream } as T; +} + +export const ui = { + message(msg?: string | string[], opts?: LogOptions) { + clackLog.message(msg, withOutput(opts)); + }, + info(msg: string, opts?: LogOptions) { + clackLog.info(msg, withOutput(opts)); + }, + success(msg: string, opts?: LogOptions) { + clackLog.success(msg, withOutput(opts)); + }, + warn(msg: string, opts?: LogOptions) { + clackLog.warn(msg, withOutput(opts)); + }, + error(msg: string, opts?: LogOptions) { + clackLog.error(msg, withOutput(opts)); + }, + step(msg: string, opts?: LogOptions) { + clackLog.step(msg, withOutput(opts)); + }, +}; diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index b5315367..5ff27e1c 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -1,5 +1,7 @@ +import { Writable } from "node:stream"; import type { spyOn } from "bun:test"; import { withCapturedLogs } from "../../lib/log.ts"; +import { setUiOutput } from "../../lib/ui.ts"; export function capturedOutput(spy: ReturnType): string { return spy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); @@ -32,6 +34,42 @@ export function captureLog() { }; } +class MockWritable extends Writable { + buffer: string[] = []; + isTTY = false; + columns = 80; + rows = 20; + + override _write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + this.buffer.push(typeof chunk === "string" ? chunk : chunk.toString()); + callback(); + } +} + +/** + * Route `ui.*` (clack-backed log helpers) output into an in-memory buffer. + * Install in `beforeEach`, tear down in `afterEach`. + */ +export function captureUi() { + const stream = new MockWritable(); + return { + stream, + get out() { + return stream.buffer.join(""); + }, + install() { + setUiOutput(stream); + }, + teardown() { + setUiOutput(undefined); + }, + }; +} + const noop = async () => {}; export const configStubs = { From a4b1990b7307f821f9e4a5f202a4958a1719bb74 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 21 May 2026 15:13:23 -0600 Subject: [PATCH 32/35] fix(review): address prompt lifecycle feedback - pair users list intro/outro around human output only - avoid app create UI wrapping in JSON and agent output - ensure config push closes intro/outro across early exits --- packages/cli-core/src/commands/apps/create.ts | 40 ++++--- packages/cli-core/src/commands/config/push.ts | 103 +++++++++--------- packages/cli-core/src/commands/users/list.ts | 75 +++++++------ 3 files changed, 117 insertions(+), 101 deletions(-) diff --git a/packages/cli-core/src/commands/apps/create.ts b/packages/cli-core/src/commands/apps/create.ts index 7923280b..7a862668 100644 --- a/packages/cli-core/src/commands/apps/create.ts +++ b/packages/cli-core/src/commands/apps/create.ts @@ -3,24 +3,34 @@ import { withApiContext } from "../../lib/errors.ts"; import { dim, cyan } from "../../lib/color.ts"; import { withSpinner, intro, outro } from "../../lib/spinner.ts"; import { stripSecrets, displayName, printJson, type AppsOptions } from "./shared.ts"; -import { log } from "../../lib/log.ts"; +import { isInsideGutter, log } from "../../lib/log.ts"; +import { isAgent } from "../../mode.ts"; export async function create(name: string, options: AppsOptions = {}): Promise { - intro("Creating application"); + const shouldWrap = !isInsideGutter() && !options.json && !isAgent(); + if (shouldWrap) intro("Creating application"); - const app = await withSpinner("Creating application...", async () => { - const created = await withApiContext(createApplication(name), "Failed to create application"); - return withApiContext(fetchApplication(created.application_id), "Failed to fetch application"); - }); + let nextSteps: string[] | undefined; + try { + const app = await withSpinner("Creating application...", async () => { + const created = await withApiContext(createApplication(name), "Failed to create application"); + return withApiContext( + fetchApplication(created.application_id), + "Failed to fetch application", + ); + }); - if (printJson(stripSecrets(app), options)) { - return; - } + if (printJson(stripSecrets(app), options)) { + return; + } - log.blank(); - log.info(`Created ${cyan(displayName(app))} ${dim(app.application_id)}`); - outro([ - `Run \`clerk link --app ${app.application_id}\` to connect this directory`, - "Run `clerk env pull` to fetch your environment variables", - ]); + log.blank(); + log.info(`Created ${cyan(displayName(app))} ${dim(app.application_id)}`); + nextSteps = [ + `Run \`clerk link --app ${app.application_id}\` to connect this directory`, + "Run `clerk env pull` to fetch your environment variables", + ]; + } finally { + if (shouldWrap) outro(nextSteps); + } } diff --git a/packages/cli-core/src/commands/config/push.ts b/packages/cli-core/src/commands/config/push.ts index dda3cd30..c0f8710b 100644 --- a/packages/cli-core/src/commands/config/push.ts +++ b/packages/cli-core/src/commands/config/push.ts @@ -5,7 +5,7 @@ import { throwUsageError, throwUserAbort, withApiContext, ERROR_CODE } from "../ import { confirm } from "../../lib/prompts.ts"; import { dim, bold, red, green } from "../../lib/color.ts"; import { withSpinner, intro, outro } from "../../lib/spinner.ts"; -import { log } from "../../lib/log.ts"; +import { isInsideGutter, log } from "../../lib/log.ts"; import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; interface ConfigPushOptions { @@ -76,64 +76,67 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise - withApiContext( - fetchInstanceConfig(ctx.appId, ctx.instanceId), - "Failed to fetch current config", - ), - ); - delete currentConfig.config_version; + try { + const currentConfig = await withSpinner("Fetching current config...", () => + withApiContext( + fetchInstanceConfig(ctx.appId, ctx.instanceId), + "Failed to fetch current config", + ), + ); + delete currentConfig.config_version; - const isPatch = op.method === "PATCH"; + const isPatch = op.method === "PATCH"; - if (!hasConfigChanges(currentConfig, configPayload, isPatch)) { - log.info(options.dryRun ? "[dry-run] No changes detected" : "No changes detected"); - outro(); - return; - } + if (!hasConfigChanges(currentConfig, configPayload, isPatch)) { + log.info(options.dryRun ? "[dry-run] No changes detected" : "No changes detected"); + return; + } - const prefix = options.dryRun ? `[dry-run] Proposing ${op.method}` : op.verb; - log.info(`\n${prefix} config on ${ctx.appLabel} (${ctx.instanceLabel}):\n`); - printDiff(currentConfig, configPayload, isPatch); + const prefix = options.dryRun ? `[dry-run] Proposing ${op.method}` : op.verb; + log.info(`\n${prefix} config on ${ctx.appLabel} (${ctx.instanceLabel}):\n`); + printDiff(currentConfig, configPayload, isPatch); - if (!options.dryRun && isHuman() && !options.yes) { - if (op.warning) { - log.warn(`${op.warning}`); - } - const ok = await confirm({ message: "Proceed?" }); - if (!ok) { - throwUserAbort(); + if (!options.dryRun && isHuman() && !options.yes) { + if (op.warning) { + log.warn(`${op.warning}`); + } + const ok = await confirm({ message: "Proceed?" }); + if (!ok) { + throwUserAbort(); + } } - } - const spinnerMsg = options.dryRun - ? `[dry-run] Validating config on ${ctx.appLabel} (${ctx.instanceLabel})...` - : `${op.verb} config on ${ctx.appLabel} (${ctx.instanceLabel})...`; - const result = await withSpinner(spinnerMsg, () => - withApiContext( - op.apiFn(ctx.appId, ctx.instanceId, configPayload, { - destructive: options.destructive, - dryRun: options.dryRun, - }), - options.dryRun ? "Dry-run failed" : "Failed to push config", - ), - ); - log.data(JSON.stringify(result, null, 2)); - log.success( - options.dryRun - ? "[dry-run] Validation passed — no changes applied" - : "Config pushed successfully", - ); - if (options.dryRun) { - printNextSteps( - op.method === "PATCH" ? NEXT_STEPS.CONFIG_DRY_RUN_PATCH : NEXT_STEPS.CONFIG_DRY_RUN_PUT, + const spinnerMsg = options.dryRun + ? `[dry-run] Validating config on ${ctx.appLabel} (${ctx.instanceLabel})...` + : `${op.verb} config on ${ctx.appLabel} (${ctx.instanceLabel})...`; + const result = await withSpinner(spinnerMsg, () => + withApiContext( + op.apiFn(ctx.appId, ctx.instanceId, configPayload, { + destructive: options.destructive, + dryRun: options.dryRun, + }), + options.dryRun ? "Dry-run failed" : "Failed to push config", + ), ); - } else { - printNextSteps(NEXT_STEPS.CONFIG_PUSH); + log.data(JSON.stringify(result, null, 2)); + log.success( + options.dryRun + ? "[dry-run] Validation passed — no changes applied" + : "Config pushed successfully", + ); + if (options.dryRun) { + printNextSteps( + op.method === "PATCH" ? NEXT_STEPS.CONFIG_DRY_RUN_PATCH : NEXT_STEPS.CONFIG_DRY_RUN_PUT, + ); + } else { + printNextSteps(NEXT_STEPS.CONFIG_PUSH); + } + } finally { + if (shouldWrap) outro(); } - outro(); } export async function readInput(options: { file?: string; json?: string }): Promise { diff --git a/packages/cli-core/src/commands/users/list.ts b/packages/cli-core/src/commands/users/list.ts index a3fe74c9..2044e959 100644 --- a/packages/cli-core/src/commands/users/list.ts +++ b/packages/cli-core/src/commands/users/list.ts @@ -163,47 +163,50 @@ async function resolveListSecretKey(options: UsersListOptions): Promise export async function list(options: UsersListOptions = {}): Promise { const nested = isInsideGutter(); - if (!nested) intro("Listing users"); - - const secretKey = await resolveListSecretKey(options); - const limit = options.limit ?? DEFAULT_LIMIT; - const offset = options.offset ?? 0; - // Request one extra row so we can detect whether more pages exist without - // a separate /users/count round-trip. The CLI's --limit caps at 250, so - // pageSize + 1 always fits under BAPI's MaxLimit of 500. - const response = await withSpinner("Fetching users...", () => - bapiRequest({ - method: "GET", - path: buildUsersListPath(options, limit + 1), - secretKey, - }), - ); - - const body = response.body; - const allUsers = Array.isArray(body) ? (body as BapiUser[]) : []; - const hasMore = allUsers.length > limit; - const users = hasMore ? allUsers.slice(0, limit) : allUsers; + const shouldWrap = !nested && !options.json && !isAgent(); + if (shouldWrap) intro("Listing users"); - if (printJson({ data: users, hasMore }, options)) { - return; - } + try { + const secretKey = await resolveListSecretKey(options); + const limit = options.limit ?? DEFAULT_LIMIT; + const offset = options.offset ?? 0; + // Request one extra row so we can detect whether more pages exist without + // a separate /users/count round-trip. The CLI's --limit caps at 250, so + // pageSize + 1 always fits under BAPI's MaxLimit of 500. + const response = await withSpinner("Fetching users...", () => + bapiRequest({ + method: "GET", + path: buildUsersListPath(options, limit + 1), + secretKey, + }), + ); + + const body = response.body; + const allUsers = Array.isArray(body) ? (body as BapiUser[]) : []; + const hasMore = allUsers.length > limit; + const users = hasMore ? allUsers.slice(0, limit) : allUsers; + + if (printJson({ data: users, hasMore }, options)) { + return; + } - log.blank(); + log.blank(); - if (users.length === 0) { - log.warn("No users found."); - if (!nested) outro(); - return; - } + if (users.length === 0) { + log.warn("No users found."); + return; + } - formatUsersTable(users); - const summary = `\n${users.length} user${users.length === 1 ? "" : "s"} returned`; - if (hasMore) { - log.info(`${summary} (more available, re-run with \`--offset ${offset + limit}\`)`); - } else { - log.info(summary); + formatUsersTable(users); + const summary = `\n${users.length} user${users.length === 1 ? "" : "s"} returned`; + if (hasMore) { + log.info(`${summary} (more available, re-run with \`--offset ${offset + limit}\`)`); + } else { + log.info(summary); + } + } finally { + if (shouldWrap) outro(); } - if (!nested) outro(); } registerUsersAction({ From 1cb2d504546e98336d96a3db6b451d54668c6e02 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 29 May 2026 12:16:30 -0600 Subject: [PATCH 33/35] fix(prompts): preserve clack prompt edge cases --- packages/cli-core/src/lib/listage.test.ts | 160 +++++++++++++++++++- packages/cli-core/src/lib/listage.ts | 174 ++++++++++++++++++---- packages/cli-core/src/lib/prompts.test.ts | 73 +++++++-- packages/cli-core/src/lib/prompts.ts | 109 +++++++++++--- 4 files changed, 454 insertions(+), 62 deletions(-) diff --git a/packages/cli-core/src/lib/listage.test.ts b/packages/cli-core/src/lib/listage.test.ts index bed5e0a2..a2fd3f09 100644 --- a/packages/cli-core/src/lib/listage.test.ts +++ b/packages/cli-core/src/lib/listage.test.ts @@ -1,4 +1,4 @@ -import { test, expect, describe, mock, beforeEach } from "bun:test"; +import { test, expect, describe, mock, beforeEach, spyOn } from "bun:test"; // Sentinel for cancellation. Tests choose this symbol; the mocked // @clack/prompts.isCancel below treats it as the clack cancel signal. @@ -41,6 +41,10 @@ beforeEach(() => { selectResult = undefined; lastAutocompleteCall = undefined; autocompleteResult = undefined; + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); }); describe("filterChoices", () => { @@ -190,6 +194,24 @@ describe("select", () => { select({ message: "Pick", choices: [{ value: "a" }] }), ).rejects.toMatchObject({ name: "UserAbortError" }); }); + + test("opens the controlling terminal when stdin is not a TTY", async () => { + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); + selectResult = "a"; + const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; + const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( + mockStream as never, + ); + + await select({ message: "Pick", choices: [{ value: "a" }] }); + + const expectedPath = process.platform === "win32" ? "CONIN$" : "/dev/tty"; + expect(createReadStreamSpy).toHaveBeenCalledWith(expectedPath); + expect(lastSelectCall?.config.input).toBe(mockStream); + expect(mockStream.close).toHaveBeenCalled(); + + createReadStreamSpy.mockRestore(); + }); }); describe("search", () => { @@ -218,13 +240,135 @@ describe("search", () => { expect(lastAutocompleteCall?.config.message).toBe("Search"); expect(lastAutocompleteCall?.config.maxItems).toBe(4); expect(lastAutocompleteCall?.config.initialValue).toBe("a"); - const options = lastAutocompleteCall?.config.options as Array>; + const optionsFn = lastAutocompleteCall?.config.options as (this: { + userInput: string; + }) => Array>; + const options = optionsFn.call({ userInput: "" }); expect(options).toHaveLength(2); expect(options[0]).toMatchObject({ value: "a", label: "Apple" }); expect(options[1]).toMatchObject({ value: "b", label: "Banana" }); }); - test("filter callback matches labels case-insensitively", async () => { + test("invokes source again with the typed term when clack requests filtered options", async () => { + autocompleteResult = "remote-user"; + const terms: Array = []; + + await search({ + message: "Search users", + source: (term) => { + terms.push(term); + return [{ value: term ?? "initial", name: term ? `User ${term}` : "Initial user" }]; + }, + }); + + const options = lastAutocompleteCall?.config.options as (this: { + userInput: string; + }) => unknown; + const refined = options.call({ userInput: "remote" }) as Array>; + + expect(terms).toEqual([undefined, "remote"]); + expect(refined[0]).toMatchObject({ value: "remote", label: "User remote" }); + }); + + test("refreshes clack state when async source results arrive for the typed term", async () => { + autocompleteResult = "remote-user"; + + await search({ + message: "Search users", + source: async (term) => [ + { value: term ?? "initial", name: term ? `User ${term}` : "Initial user" }, + ], + }); + + const options = lastAutocompleteCall?.config.options as (this: { + userInput: string; + filteredOptions: Array>; + selectedValues: unknown[]; + focusedValue: unknown; + render: () => void; + }) => Array>; + const prompt: { + userInput: string; + filteredOptions: Array>; + selectedValues: unknown[]; + focusedValue: unknown; + render: ReturnType; + } = { + userInput: "remote", + filteredOptions: [], + selectedValues: [], + focusedValue: undefined, + render: mock(), + }; + const loading = options.call(prompt); + expect(loading[0]).toMatchObject({ label: "Loading results...", disabled: true }); + + await new Promise((resolve) => queueMicrotask(resolve)); + + expect(prompt.filteredOptions[0]).toMatchObject({ value: "remote", label: "User remote" }); + expect(prompt.selectedValues).toEqual(["remote"]); + expect(prompt.focusedValue).toBe("remote"); + expect(prompt.render).toHaveBeenCalled(); + }); + + test("rejects submission while autocomplete has no selected value", async () => { + autocompleteResult = "initial"; + const result = await search({ + message: "Search users", + source: () => [{ value: "initial", name: "Initial user" }], + }); + + const validate = lastAutocompleteCall?.config.validate as (value: unknown) => unknown; + expect(result).toBe("initial"); + expect(validate(undefined)).toBe("Select an option to continue"); + expect(validate("initial")).toBeUndefined(); + }); + + test("renders async source errors for typed terms without rejecting out of band", async () => { + autocompleteResult = "initial"; + + await search({ + message: "Search users", + source: async (term) => { + if (term === "remote") throw new Error("Network down"); + return [{ value: "initial", name: "Initial user" }]; + }, + }); + + const options = lastAutocompleteCall?.config.options as (this: { + userInput: string; + filteredOptions: Array>; + selectedValues: unknown[]; + focusedValue: unknown; + render: () => void; + }) => Array>; + const prompt: { + userInput: string; + filteredOptions: Array>; + selectedValues: unknown[]; + focusedValue: unknown; + render: ReturnType; + } = { + userInput: "remote", + filteredOptions: [], + selectedValues: [], + focusedValue: undefined, + render: mock(), + }; + + options.call(prompt); + await new Promise((resolve) => queueMicrotask(resolve)); + + expect(prompt.filteredOptions[0]).toMatchObject({ + label: "Network down", + disabled: true, + }); + expect(prompt.selectedValues).toEqual([]); + expect(prompt.focusedValue).toBeUndefined(); + expect(prompt.render).toHaveBeenCalled(); + }); + + test("filter accepts source-provided options", async () => { autocompleteResult = "a"; await search({ message: "Search", @@ -240,10 +384,7 @@ describe("search", () => { ) => boolean; expect(typeof filter).toBe("function"); expect(filter("APP", { label: "Apple", value: "a" })).toBe(true); - expect(filter("ban", { label: "Banana", value: "b" })).toBe(true); - expect(filter("xyz", { label: "Apple", value: "a" })).toBe(false); - // Falls back to stringifying value when label is absent. - expect(filter("a", { value: "a" })).toBe(true); + expect(filter("xyz", { label: "Apple", value: "a" })).toBe(true); }); test("throws UserAbortError when clack returns the cancel symbol", async () => { @@ -263,7 +404,10 @@ describe("search", () => { source: async () => [{ value: "a", name: "A" }], }); expect(result).toBe("a"); - const options = lastAutocompleteCall?.config.options as Array>; + const optionsFn = lastAutocompleteCall?.config.options as (this: { + userInput: string; + }) => Array>; + const options = optionsFn.call({ userInput: "" }); expect(options[0]).toMatchObject({ value: "a", label: "A" }); }); }); diff --git a/packages/cli-core/src/lib/listage.ts b/packages/cli-core/src/lib/listage.ts index 7f30083e..7c27bd3a 100644 --- a/packages/cli-core/src/lib/listage.ts +++ b/packages/cli-core/src/lib/listage.ts @@ -4,6 +4,8 @@ * is translated to UserAbortError at the wrapper boundary. */ +import { createReadStream } from "node:fs"; +import type { Readable } from "node:stream"; import { select as clackSelect, autocomplete as clackAutocomplete, @@ -12,6 +14,23 @@ import { } from "@clack/prompts"; import { throwUserAbort } from "./errors.ts"; +// --------------------------------------------------------------------------- +// Shared utilities +// --------------------------------------------------------------------------- + +const TTY_PATH = process.platform === "win32" ? "CONIN$" : "/dev/tty"; + +export function ttyContext(): { input: Readable; close: () => void } | undefined { + if (process.stdin.isTTY) return undefined; + try { + const input = createReadStream(TTY_PATH); + input.on("error", () => {}); + return { input, close: () => input.close() }; + } catch { + return undefined; + } +} + // --------------------------------------------------------------------------- // Separator — kept as a tiny local class so call sites compile unchanged. // Rendered as a disabled clack option with a dim label. @@ -112,6 +131,10 @@ function unwrap(value: T | symbol): T { return value as T; } +function isPromiseLike(value: T | Promise): value is Promise { + return typeof (value as Promise)?.then === "function"; +} + // --------------------------------------------------------------------------- // Select prompt // --------------------------------------------------------------------------- @@ -125,13 +148,19 @@ export type SelectConfig = { export async function select(config: SelectConfig): Promise { const items = normalizeChoices(config.choices); - const result = await clackSelect({ - message: config.message, - options: toClackOptions(items), - initialValue: config.default, - maxItems: config.pageSize, - }); - return unwrap(result); + const tty = ttyContext(); + try { + const result = await clackSelect({ + message: config.message, + options: toClackOptions(items), + initialValue: config.default, + maxItems: config.pageSize, + input: tty?.input, + }); + return unwrap(result); + } finally { + tty?.close(); + } } // --------------------------------------------------------------------------- @@ -143,10 +172,8 @@ export type SearchChoice = SelectChoice; export type SearchConfig = { message: string; /** - * One-shot source. Called once with `undefined`; the returned list is - * filtered client-side by clack via the `filter` callback as the user - * types. The async signal is accepted for signature compatibility but - * unused — clack drives cancellation itself. + * Source called with the current search term. Async sources are cached per + * term and refresh the prompt when their results arrive. */ source: ( term: string | undefined, @@ -158,21 +185,114 @@ export type SearchConfig = { default?: Value; }; +type AutocompleteContext = { + userInput?: string; + filteredOptions?: Array<{ value: unknown; label?: string; hint?: string; disabled?: boolean }>; + selectedValues?: unknown[]; + focusedValue?: unknown; +}; + export async function search(config: SearchConfig): Promise { - const controller = new AbortController(); - const raw = await config.source(undefined, { signal: controller.signal }); - const items = normalizeChoices(raw); - const options = toClackOptions(items); - - const result = await clackAutocomplete({ - message: config.message, - options, - initialValue: config.default, - maxItems: config.pageSize, - filter: (term, opt) => { - const label = (opt.label ?? String(opt.value)).toLowerCase(); - return label.includes(term.toLowerCase()); - }, - }); - return unwrap(result); + const cache = new Map[]>(); + const pending = new Map>(); + + const normalizeTerm = (term: string | undefined) => term ?? ""; + const toSourceTerm = (term: string) => (term === "" ? undefined : term); + const disabledOption = (label: string): ClackOption[] => + [ + { + value: SEPARATOR_VALUE as unknown as Value, + label, + disabled: true, + }, + ] as ClackOption[]; + const loading = () => disabledOption("Loading results..."); + + const setCache = (term: string, raw: ReadonlyArray>) => { + cache.set(term, toClackOptions(normalizeChoices(raw))); + }; + + const setError = (term: string, error: unknown) => { + cache.set(term, disabledOption(error instanceof Error ? error.message : String(error))); + }; + + const refresh = (term: string, prompt: AutocompleteContext | undefined) => { + if (!prompt || prompt.userInput !== term) return; + const options = cache.get(term); + if (!options) return; + + const first = options.find((option) => !option.disabled); + prompt.filteredOptions = options as Array<{ + value: unknown; + label?: string; + hint?: string; + disabled?: boolean; + }>; + prompt.focusedValue = first?.value; + prompt.selectedValues = first ? [first.value] : []; + (prompt as AutocompleteContext & { render?: () => void }).render?.(); + }; + + const load = (term: string, prompt?: AutocompleteContext): ClackOption[] => { + const cached = cache.get(term); + if (cached) return cached; + + if (!pending.has(term)) { + const controller = new AbortController(); + let result: + | ReadonlyArray> + | Promise>>; + try { + result = config.source(toSourceTerm(term), { signal: controller.signal }); + } catch (error) { + setError(term, error); + refresh(term, prompt); + return cache.get(term)!; + } + + if (isPromiseLike(result)) { + pending.set( + term, + result + .then((raw) => { + setCache(term, raw); + refresh(term, prompt); + }) + .catch((error) => { + setError(term, error); + refresh(term, prompt); + }) + .finally(() => { + pending.delete(term); + }), + ); + } else { + setCache(term, result); + return cache.get(term)!; + } + } + + return loading(); + }; + + const initialController = new AbortController(); + setCache("", await config.source(undefined, { signal: initialController.signal })); + + const tty = ttyContext(); + try { + const result = await clackAutocomplete({ + message: config.message, + options: function (this: AutocompleteContext) { + return load(normalizeTerm(this.userInput), this); + }, + initialValue: config.default, + maxItems: config.pageSize, + filter: () => true, + validate: (value) => (value === undefined ? "Select an option to continue" : undefined), + input: tty?.input, + }); + return unwrap(result); + } finally { + tty?.close(); + } } diff --git a/packages/cli-core/src/lib/prompts.test.ts b/packages/cli-core/src/lib/prompts.test.ts index aa997d93..548f6bfe 100644 --- a/packages/cli-core/src/lib/prompts.test.ts +++ b/packages/cli-core/src/lib/prompts.test.ts @@ -1,4 +1,4 @@ -import { test, expect, mock, beforeEach } from "bun:test"; +import { test, expect, mock, beforeEach, spyOn } from "bun:test"; import { captureLog } from "../test/lib/stubs.ts"; // Sentinel for cancellation. Tests choose this symbol; the mocked @@ -9,6 +9,7 @@ let lastConfirmConfig: Record | undefined; let confirmResult: boolean | symbol = true; let lastTextConfig: Record | undefined; let textResult: string | symbol = ""; +let textResults: Array = []; let lastPasswordConfig: Record | undefined; let passwordResult: string | symbol = ""; @@ -26,7 +27,7 @@ mock.module("@clack/prompts", () => ({ }, text: async (config: Record) => { lastTextConfig = config; - return textResult; + return textResults.length > 0 ? textResults.shift()! : textResult; }, password: async (config: Record) => { lastPasswordConfig = config; @@ -63,10 +64,15 @@ beforeEach(() => { confirmResult = true; lastTextConfig = undefined; textResult = ""; + textResults = []; lastPasswordConfig = undefined; passwordResult = ""; editorCalls = []; editorResults = []; + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); }); test("confirm passes message to clack and returns true", async () => { @@ -90,6 +96,23 @@ test("confirm translates default to initialValue", async () => { expect(lastConfirmConfig).toEqual({ message: "Continue?", initialValue: false }); }); +test("confirm opens the controlling terminal when stdin is not a TTY", async () => { + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); + const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; + const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( + mockStream as never, + ); + + await confirm({ message: "Continue?" }); + + const expectedPath = process.platform === "win32" ? "CONIN$" : "/dev/tty"; + expect(createReadStreamSpy).toHaveBeenCalledWith(expectedPath); + expect(lastConfirmConfig?.input).toBe(mockStream); + expect(mockStream.close).toHaveBeenCalled(); + + createReadStreamSpy.mockRestore(); +}); + test("confirm throws UserAbortError when clack returns cancel symbol", async () => { confirmResult = cancelSymbol; @@ -116,12 +139,10 @@ test("text forwards default, placeholder, and validate to clack", async () => { const validate = (v: string | undefined) => (v?.trim() ? undefined : "required"); await text({ message: "Name?", default: "anon", placeholder: "type a name", validate }); - expect(lastTextConfig).toEqual({ - message: "Name?", - initialValue: "anon", - placeholder: "type a name", - validate, - }); + expect(lastTextConfig?.message).toBe("Name?"); + expect(lastTextConfig?.initialValue).toBe("anon"); + expect(lastTextConfig?.placeholder).toBe("type a name"); + expect(typeof lastTextConfig?.validate).toBe("function"); }); test("text throws UserAbortError when clack returns cancel symbol", async () => { @@ -132,6 +153,29 @@ test("text throws UserAbortError when clack returns cancel symbol", async () => }); }); +test("text maps true validation results to clack success", async () => { + textResult = "value"; + await text({ message: "Name?", validate: () => true }); + + const validate = lastTextConfig?.validate as (value: string | undefined) => unknown; + expect(validate("value")).toBeUndefined(); +}); + +test("text re-prompts when async validation rejects a submitted value", async () => { + textResults = ["missing.json", "valid.json"]; + const captured = captureLog(); + + const result = await captured.run(() => + text({ + message: "Path?", + validate: async (value) => (value === "valid.json" ? true : "File not found"), + }), + ); + + expect(result).toBe("valid.json"); + expect(captured.err).toContain("File not found"); +}); + test("password passes message to clack and returns the typed value", async () => { passwordResult = "s3cret"; const result = await password({ message: "Secret?" }); @@ -141,11 +185,20 @@ test("password passes message to clack and returns the typed value", async () => }); test("password forwards validate to clack", async () => { - passwordResult = "ok"; + passwordResult = "long-enough"; const validate = (v: string | undefined) => ((v?.length ?? 0) >= 8 ? undefined : "too short"); await password({ message: "Secret?", validate }); - expect(lastPasswordConfig).toEqual({ message: "Secret?", validate }); + expect(lastPasswordConfig?.message).toBe("Secret?"); + expect(typeof lastPasswordConfig?.validate).toBe("function"); +}); + +test("password maps true validation results to clack success", async () => { + passwordResult = "s3cret"; + await password({ message: "Secret?", validate: () => true }); + + const validate = lastPasswordConfig?.validate as (value: string | undefined) => unknown; + expect(validate("s3cret")).toBeUndefined(); }); test("password throws UserAbortError when clack returns cancel symbol", async () => { diff --git a/packages/cli-core/src/lib/prompts.ts b/packages/cli-core/src/lib/prompts.ts index 59506aa9..3c4ff11a 100644 --- a/packages/cli-core/src/lib/prompts.ts +++ b/packages/cli-core/src/lib/prompts.ts @@ -12,23 +12,72 @@ import { } from "@clack/prompts"; import { editAsync } from "external-editor"; import { throwUserAbort } from "./errors.ts"; +import { ttyContext } from "./listage.ts"; import { log } from "./log.ts"; type ValidationResult = string | Error | true | undefined; type Validate = (value: string | undefined) => ValidationResult | Promise; +type SyncValidate = (value: string | undefined) => string | Error | undefined; function unwrap(value: T | symbol): T { if (isCancel(value)) throwUserAbort(); return value as T; } +function isPromiseLike(value: T | Promise): value is Promise { + return typeof (value as Promise)?.then === "function"; +} + +function validationError(result: ValidationResult): string | Error | undefined { + return result === true ? undefined : result; +} + +function createValidator(validate: Validate | undefined): + | { + sync: SyncValidate; + final: (value: string | undefined) => Promise; + } + | undefined { + if (!validate) return undefined; + + let last: + | { + value: string | undefined; + result: ValidationResult | Promise; + } + | undefined; + + return { + sync(value) { + const result = validate(value); + last = { value, result }; + if (isPromiseLike(result)) return undefined; + return validationError(result); + }, + async final(value) { + const result = last && last.value === value ? last.result : validate(value); + return validationError(await result); + }, + }; +} + +function logValidationError(error: string | Error) { + log.warn(typeof error === "string" ? error : error.message); +} + /** Yes/no confirmation. */ export async function confirm(config: { message: string; default?: boolean }): Promise { - const result = await clackConfirm({ - message: config.message, - initialValue: config.default, - }); - return unwrap(result); + const tty = ttyContext(); + try { + const result = await clackConfirm({ + message: config.message, + initialValue: config.default, + input: tty?.input, + }); + return unwrap(result); + } finally { + tty?.close(); + } } /** Single-line text input. */ @@ -38,22 +87,48 @@ export async function text(config: { placeholder?: string; validate?: Validate; }): Promise { - const result = await clackText({ - message: config.message, - initialValue: config.default, - placeholder: config.placeholder, - validate: config.validate as (value: string | undefined) => string | Error | undefined, - }); - return unwrap(result); + const validator = createValidator(config.validate); + + for (;;) { + const tty = ttyContext(); + try { + const result = await clackText({ + message: config.message, + initialValue: config.default, + placeholder: config.placeholder, + validate: validator?.sync, + input: tty?.input, + }); + const value = unwrap(result); + const error = await validator?.final(value); + if (!error) return value; + logValidationError(error); + } finally { + tty?.close(); + } + } } /** Masked password input. */ export async function password(config: { message: string; validate?: Validate }): Promise { - const result = await clackPassword({ - message: config.message, - validate: config.validate as (value: string | undefined) => string | Error | undefined, - }); - return unwrap(result); + const validator = createValidator(config.validate); + + for (;;) { + const tty = ttyContext(); + try { + const result = await clackPassword({ + message: config.message, + validate: validator?.sync, + input: tty?.input, + }); + const value = unwrap(result); + const error = await validator?.final(value); + if (!error) return value; + logValidationError(error); + } finally { + tty?.close(); + } + } } /** Multi-line editor input. Shells out to $EDITOR via external-editor. */ From 189e8b54e3c45f46b69b4a2d14e100ef28d52e9c Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 1 Jun 2026 10:21:43 -0400 Subject: [PATCH 34/35] fix(prompts): correct interactive command outro statuses --- .changeset/clack-prompts-migration.md | 2 +- .../cli-core/src/commands/api/index.test.ts | 27 +++++++++- packages/cli-core/src/commands/api/index.ts | 27 ++++++++-- .../src/commands/api/interactive.test.ts | 2 +- .../cli-core/src/commands/apps/create.test.ts | 1 + packages/cli-core/src/commands/apps/create.ts | 19 +++++-- .../cli-core/src/commands/apps/list.test.ts | 2 + packages/cli-core/src/commands/apps/list.ts | 54 +++++++++++++------ .../src/commands/billing/index.test.ts | 1 + .../cli-core/src/commands/config/pull.test.ts | 1 + .../cli-core/src/commands/config/push.test.ts | 1 + packages/cli-core/src/commands/config/push.ts | 27 ++++++++-- .../src/commands/deploy/index.test.ts | 12 +++-- .../cli-core/src/commands/deploy/index.ts | 5 +- .../cli-core/src/commands/env/pull.test.ts | 1 + .../cli-core/src/commands/link/index.test.ts | 1 + .../cli-core/src/commands/open/index.test.ts | 1 + .../cli-core/src/commands/orgs/index.test.ts | 9 +--- .../src/commands/switch-env/index.test.ts | 1 + .../src/commands/unlink/index.test.ts | 2 +- .../src/commands/users/create-wizard.test.ts | 1 + .../src/commands/users/create.test.ts | 3 +- .../cli-core/src/commands/users/create.ts | 23 +++++--- .../cli-core/src/commands/users/list.test.ts | 1 + packages/cli-core/src/commands/users/list.ts | 20 +++++-- .../cli-core/src/commands/users/menu.test.ts | 1 + .../cli-core/src/commands/users/open.test.ts | 1 + packages/cli-core/src/lib/spinner.test.ts | 14 ++++- packages/cli-core/src/lib/spinner.ts | 10 ++++ 29 files changed, 212 insertions(+), 58 deletions(-) diff --git a/.changeset/clack-prompts-migration.md b/.changeset/clack-prompts-migration.md index ef49cd3c..90ee7da7 100644 --- a/.changeset/clack-prompts-migration.md +++ b/.changeset/clack-prompts-migration.md @@ -2,4 +2,4 @@ "clerk": minor --- -Refresh the visual style of prompts, lists, spinners, and intro/outro brackets to use `@clack/prompts`, and bracket every interactive command with an operation-descriptive title. +Refresh the visual style of prompts, lists, spinners, and intro/outro brackets to use `@clack/prompts`, and make interactive command endings reflect success, failure, or paused cancellation status. diff --git a/packages/cli-core/src/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index 07322dcf..e2b59988 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, UserAbortError } from "../../lib/errors.ts"; import { useCaptureLog, credentialStoreStubs, @@ -171,7 +171,11 @@ mock.module("../../lib/config.ts", () => ({ }, })); -mock.module("../../lib/prompts.ts", () => libPromptsStubs); +const mockConfirm = mock(async (_config?: unknown) => true); +mock.module("../../lib/prompts.ts", () => ({ + ...libPromptsStubs, + confirm: (config: unknown) => mockConfirm(config), +})); const { _setConfigDir } = (await import("../../lib/config.ts")) as any; const { setMode } = (await import("../../mode.ts")) as any; @@ -209,6 +213,8 @@ describe("api command", () => { throw new Error("process.exit"); }); stubFetch(async () => new Response(JSON.stringify(mockUsers), { status: 200 })); + mockConfirm.mockReset(); + mockConfirm.mockResolvedValue(true); }); afterEach(async () => { @@ -479,12 +485,29 @@ describe("api command", () => { }); test("prints API error response body to stdout and exits 1", async () => { + setMode("human"); const errorBody = { errors: [{ message: "not found", code: "resource_not_found" }] }; stubFetch(async () => new Response(JSON.stringify(errorBody), { status: 404 })); await runApi("/users/bad_id"); expect(process.exitCode).toBe(1); expect(captured.out).toContain(JSON.stringify(errorBody, null, 2)); + expect(captured.err).toContain("Failed"); + expect(captured.err).not.toContain("Done"); + }); + + test("shows Paused with instructions when a confirmation prompt is cancelled", async () => { + setMode("human"); + mockConfirm.mockImplementation(async () => { + throw new UserAbortError(); + }); + + await expect( + runApi("/users", { method: "POST", data: "{}", yes: false }), + ).rejects.toBeInstanceOf(UserAbortError); + expect(captured.err).toContain("Paused"); + expect(captured.err).toContain("Run this command again to continue."); + expect(captured.err).not.toContain("Done"); }); test("--include shows headers on error responses too", async () => { diff --git a/packages/cli-core/src/commands/api/index.ts b/packages/cli-core/src/commands/api/index.ts index 5acc96a2..46617965 100644 --- a/packages/cli-core/src/commands/api/index.ts +++ b/packages/cli-core/src/commands/api/index.ts @@ -2,10 +2,17 @@ import { getAuthToken } from "../../lib/plapi.ts"; import { getBapiBaseUrl, getPlapiBaseUrl } from "../../lib/environment.ts"; import { normalizeBapiPath, resolveBapiSecretKey } from "../../lib/bapi-command.ts"; import { bapiRequest } from "./bapi.ts"; -import { BapiError, ERROR_CODE, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; +import { + BapiError, + ERROR_CODE, + UserAbortError, + isPromptExitError, + throwUsageError, + throwUserAbort, +} from "../../lib/errors.ts"; import { isHuman } from "../../mode.ts"; import { confirm } from "../../lib/prompts.ts"; -import { withSpinner, intro, outro } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; export interface ApiOptions { @@ -30,6 +37,7 @@ export async function api( ): Promise { const nested = isInsideGutter(); if (!nested) intro("Calling Clerk API"); + let closeStatus: "success" | "failed" | "paused" | undefined; try { // Route: no args → interactive builder @@ -101,6 +109,7 @@ export async function api( printHeaders(response.status, response.headers); } printBody(response.body); + closeStatus = "success"; } catch (error) { // Handle BapiError locally to print the raw API response body to stdout // (for piping), rather than propagating to the global error handler. @@ -110,12 +119,24 @@ export async function api( } prettyPrint(error.body); process.exitCode = 1; + closeStatus = "failed"; return; } throw error; } + } catch (error) { + closeStatus = error instanceof UserAbortError || isPromptExitError(error) ? "paused" : "failed"; + throw error; } finally { - if (!nested) outro(); + if (!nested) { + if (closeStatus === "paused") { + pausedOutro(); + } else if (closeStatus === "failed") { + outro("Failed"); + } else { + outro(); + } + } } } diff --git a/packages/cli-core/src/commands/api/interactive.test.ts b/packages/cli-core/src/commands/api/interactive.test.ts index 2bc118f3..43c08312 100644 --- a/packages/cli-core/src/commands/api/interactive.test.ts +++ b/packages/cli-core/src/commands/api/interactive.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { useCaptureLog, promptsStubs, listageStubs, stubFetch } from "../../test/lib/stubs.ts"; +import { useCaptureLog, listageStubs, stubFetch } from "../../test/lib/stubs.ts"; let _mode = "human"; mock.module("../../mode.ts", () => ({ diff --git a/packages/cli-core/src/commands/apps/create.test.ts b/packages/cli-core/src/commands/apps/create.test.ts index 1ab3e115..83267bcd 100644 --- a/packages/cli-core/src/commands/apps/create.test.ts +++ b/packages/cli-core/src/commands/apps/create.test.ts @@ -23,6 +23,7 @@ mock.module("../../lib/spinner.ts", () => ({ outro: (msgOrSteps?: string | readonly string[]) => { if (Array.isArray(msgOrSteps)) mockNextSteps(msgOrSteps); }, + pausedOutro: () => {}, bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/apps/create.ts b/packages/cli-core/src/commands/apps/create.ts index 7a862668..efe2befa 100644 --- a/packages/cli-core/src/commands/apps/create.ts +++ b/packages/cli-core/src/commands/apps/create.ts @@ -1,7 +1,7 @@ import { createApplication, fetchApplication } from "../../lib/plapi.ts"; -import { withApiContext } from "../../lib/errors.ts"; +import { UserAbortError, isPromptExitError, withApiContext } from "../../lib/errors.ts"; import { dim, cyan } from "../../lib/color.ts"; -import { withSpinner, intro, outro } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; import { stripSecrets, displayName, printJson, type AppsOptions } from "./shared.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { isAgent } from "../../mode.ts"; @@ -11,6 +11,7 @@ export async function create(name: string, options: AppsOptions = {}): Promise { const created = await withApiContext(createApplication(name), "Failed to create application"); @@ -30,7 +31,19 @@ export async function create(name: string, options: AppsOptions = {}): Promise { expect(parsed).toHaveLength(2); expect(parsed[0].application_id).toBe("app_abc123"); expect(parsed[0].name).toBe("My SaaS App"); + expect(stdoutOut()).toBe(""); }); test("outputs JSON in agent mode", async () => { @@ -186,6 +187,7 @@ describe("apps list", () => { const parsed = JSON.parse(captured.out); expect(parsed).toEqual([]); + expect(stdoutOut()).toBe(""); }); test("outputs empty JSON array in agent mode", async () => { diff --git a/packages/cli-core/src/commands/apps/list.ts b/packages/cli-core/src/commands/apps/list.ts index cd0e2cd3..c3ef3b51 100644 --- a/packages/cli-core/src/commands/apps/list.ts +++ b/packages/cli-core/src/commands/apps/list.ts @@ -1,9 +1,11 @@ import { listApplications, type Application } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; import { dim, cyan } from "../../lib/color.ts"; -import { withSpinner, intro, outro } from "../../lib/spinner.ts"; +import { UserAbortError, isPromptExitError } from "../../lib/errors.ts"; +import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; import { ui } from "../../lib/ui.ts"; import { stripSecrets, displayName, printJson, type AppsOptions } from "./shared.ts"; +import { isAgent } from "../../mode.ts"; const COLUMN_PADDING = 2; @@ -25,25 +27,43 @@ function formatAppsTable(apps: Application[]): void { } export async function list(options: AppsOptions = {}): Promise { - intro("Listing applications"); + const shouldWrap = !options.json && !isAgent(); + if (shouldWrap) intro("Listing applications"); + let closeStatus: "success" | "failed" | "paused" | undefined; - const result = await withSpinner("Fetching applications...", () => - withApiContext(listApplications(), "Failed to list applications"), - ); + try { + const fetchApps = () => withApiContext(listApplications(), "Failed to list applications"); + const result = shouldWrap + ? await withSpinner("Fetching applications...", fetchApps) + : await fetchApps(); - if (printJson(result.map(stripSecrets), options)) { - return; - } + if (printJson(result.map(stripSecrets), options)) { + return; + } - if (result.length === 0) { - ui.warn("No applications found. Create one at https://dashboard.clerk.com"); - outro(); - return; - } + if (result.length === 0) { + ui.warn("No applications found. Create one at https://dashboard.clerk.com"); + closeStatus = "success"; + return; + } - formatAppsTable(result); + formatAppsTable(result); - const count = result.length; - ui.message(`${count} application${count === 1 ? "" : "s"}`); - outro(); + const count = result.length; + ui.message(`${count} application${count === 1 ? "" : "s"}`); + closeStatus = "success"; + } catch (error) { + closeStatus = error instanceof UserAbortError || isPromptExitError(error) ? "paused" : "failed"; + throw error; + } finally { + if (shouldWrap) { + if (closeStatus === "paused") { + pausedOutro(); + } else if (closeStatus === "failed") { + outro("Failed"); + } else if (closeStatus === "success") { + outro(); + } + } + } } diff --git a/packages/cli-core/src/commands/billing/index.test.ts b/packages/cli-core/src/commands/billing/index.test.ts index 4f259fd1..5583be37 100644 --- a/packages/cli-core/src/commands/billing/index.test.ts +++ b/packages/cli-core/src/commands/billing/index.test.ts @@ -17,6 +17,7 @@ mock.module("../../lib/prompts.ts", () => libPromptsStubs); mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, + pausedOutro: () => {}, bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/config/pull.test.ts b/packages/cli-core/src/commands/config/pull.test.ts index a0ea8421..4a1973c8 100644 --- a/packages/cli-core/src/commands/config/pull.test.ts +++ b/packages/cli-core/src/commands/config/pull.test.ts @@ -10,6 +10,7 @@ mock.module("../../lib/git.ts", () => gitStubs); mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, + pausedOutro: () => {}, bar: () => {}, withSpinner: async (msg: string, fn: () => Promise) => { const { log } = await import("../../lib/log.ts"); diff --git a/packages/cli-core/src/commands/config/push.test.ts b/packages/cli-core/src/commands/config/push.test.ts index 3ca77cd6..780eddde 100644 --- a/packages/cli-core/src/commands/config/push.test.ts +++ b/packages/cli-core/src/commands/config/push.test.ts @@ -18,6 +18,7 @@ mock.module("../../lib/prompts.ts", () => libPromptsStubs); mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, + pausedOutro: () => {}, bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/config/push.ts b/packages/cli-core/src/commands/config/push.ts index c0f8710b..36c61be1 100644 --- a/packages/cli-core/src/commands/config/push.ts +++ b/packages/cli-core/src/commands/config/push.ts @@ -1,10 +1,17 @@ import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfig, putInstanceConfig, patchInstanceConfig } from "../../lib/plapi.ts"; import { isHuman } from "../../mode.ts"; -import { throwUsageError, throwUserAbort, withApiContext, ERROR_CODE } from "../../lib/errors.ts"; +import { + UserAbortError, + isPromptExitError, + throwUsageError, + throwUserAbort, + withApiContext, + ERROR_CODE, +} from "../../lib/errors.ts"; import { confirm } from "../../lib/prompts.ts"; import { dim, bold, red, green } from "../../lib/color.ts"; -import { withSpinner, intro, outro } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; @@ -78,6 +85,7 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise @@ -92,6 +100,7 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise { await expect(collectCustomDomain()).resolves.toBe("example.com"); }); - test("Ctrl-C before changes are made reports cancelled instead of done", async () => { + test("Ctrl-C before changes are made reports paused instead of done", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); mockConfirm.mockRejectedValueOnce(promptExitError()); @@ -936,11 +936,12 @@ describe("deploy", () => { const config = await readConfig(); expect(config.profiles[process.cwd()]?.instances.production).toBeUndefined(); const terminalOutput = stripAnsi(captured.err); - expect(terminalOutput).toContain("Cancelled"); + expect(terminalOutput).toContain("Paused"); + expect(terminalOutput).toContain("Run `clerk deploy` again"); expect(terminalOutput).not.toContain("Done"); }); - test("Ctrl-C at domain collection reports cancelled instead of done", async () => { + test("Ctrl-C at domain collection reports paused instead of done", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); mockConfirm.mockResolvedValueOnce(true); @@ -951,7 +952,8 @@ describe("deploy", () => { const config = await readConfig(); expect(config.profiles[process.cwd()]?.instances.production).toBeUndefined(); const terminalOutput = stripAnsi(captured.err); - expect(terminalOutput).toContain("Cancelled"); + expect(terminalOutput).toContain("Paused"); + expect(terminalOutput).toContain("Run `clerk deploy` again"); expect(terminalOutput).not.toContain("Done"); }); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 0dcc12c1..eb8fdbcb 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,6 +1,6 @@ import { isAgent } from "../../mode.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; -import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts"; +import { bar, intro, outro, pausedOutro, withSpinner } from "../../lib/spinner.ts"; import { CliError, ERROR_CODE, @@ -29,6 +29,7 @@ import { dnsRecords, nextStepsBlock, pendingDnsRecords, + pausedOperationNotice, printPlan, productionSummary, } from "./copy.ts"; @@ -86,7 +87,7 @@ export async function deploy(_options: DeployOptions = {}) { outro("Paused"); } if (isPromptExitError(error) && isInsideGutter()) { - outro("Cancelled"); + pausedOutro(pausedOperationNotice()); throw new UserAbortError(); } throw error; diff --git a/packages/cli-core/src/commands/env/pull.test.ts b/packages/cli-core/src/commands/env/pull.test.ts index 8d900be1..53b2c52c 100644 --- a/packages/cli-core/src/commands/env/pull.test.ts +++ b/packages/cli-core/src/commands/env/pull.test.ts @@ -15,6 +15,7 @@ mock.module("../../lib/git.ts", () => gitStubs); mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, + pausedOutro: () => {}, bar: () => {}, withSpinner: async (msg: string, fn: () => Promise) => { console.error(msg); diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index 7b3b4a2f..96819075 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -94,6 +94,7 @@ mock.module("../../lib/listage.ts", () => ({ mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, + pausedOutro: () => {}, bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/open/index.test.ts b/packages/cli-core/src/commands/open/index.test.ts index 94db3e62..a3d6ce63 100644 --- a/packages/cli-core/src/commands/open/index.test.ts +++ b/packages/cli-core/src/commands/open/index.test.ts @@ -18,6 +18,7 @@ mock.module("../../lib/open.ts", () => ({ mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, + pausedOutro: () => {}, })); const { openDashboard, buildDashboardUrl } = await import("./index.ts"); diff --git a/packages/cli-core/src/commands/orgs/index.test.ts b/packages/cli-core/src/commands/orgs/index.test.ts index 60e3879d..88f65d89 100644 --- a/packages/cli-core/src/commands/orgs/index.test.ts +++ b/packages/cli-core/src/commands/orgs/index.test.ts @@ -3,19 +3,14 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { _setConfigDir, setProfile } from "../../lib/config.ts"; -import { - useCaptureLog, - credentialStoreStubs, - gitStubs, - promptsStubs, - stubFetch, -} from "../../test/lib/stubs.ts"; +import { useCaptureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, + pausedOutro: () => {}, bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/switch-env/index.test.ts b/packages/cli-core/src/commands/switch-env/index.test.ts index 66894676..720e527f 100644 --- a/packages/cli-core/src/commands/switch-env/index.test.ts +++ b/packages/cli-core/src/commands/switch-env/index.test.ts @@ -55,6 +55,7 @@ mock.module("../../lib/spinner.ts", () => ({ for (const step of msgOrSteps) log.info(step); } }, + pausedOutro: () => {}, bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/unlink/index.test.ts b/packages/cli-core/src/commands/unlink/index.test.ts index 88b5bd2a..3ff330b9 100644 --- a/packages/cli-core/src/commands/unlink/index.test.ts +++ b/packages/cli-core/src/commands/unlink/index.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; -import { useCaptureLog, configStubs, gitStubs, promptsStubs } from "../../test/lib/stubs.ts"; +import { useCaptureLog, configStubs, gitStubs } from "../../test/lib/stubs.ts"; const mockIsAgent = mock(); const mockIsHuman = mock(); diff --git a/packages/cli-core/src/commands/users/create-wizard.test.ts b/packages/cli-core/src/commands/users/create-wizard.test.ts index fcd25c15..dd545d94 100644 --- a/packages/cli-core/src/commands/users/create-wizard.test.ts +++ b/packages/cli-core/src/commands/users/create-wizard.test.ts @@ -27,6 +27,7 @@ mock.module("../../lib/spinner.ts", () => ({ withSpinner: async (_msg: string, fn: () => Promise) => fn(), intro: () => {}, outro: () => {}, + pausedOutro: () => {}, bar: () => {}, })); diff --git a/packages/cli-core/src/commands/users/create.test.ts b/packages/cli-core/src/commands/users/create.test.ts index f6a79baf..bcfcf857 100644 --- a/packages/cli-core/src/commands/users/create.test.ts +++ b/packages/cli-core/src/commands/users/create.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { useCaptureLog, promptsStubs } from "../../test/lib/stubs.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; import { BapiError, CliError, ERROR_CODE, EXIT_CODE } from "../../lib/errors.ts"; const mockResolveBapiSecretKey = mock(); @@ -30,6 +30,7 @@ mock.module("./create-wizard.ts", () => ({ mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, + pausedOutro: () => {}, bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/users/create.ts b/packages/cli-core/src/commands/users/create.ts index cd3e0887..ddbcefa1 100644 --- a/packages/cli-core/src/commands/users/create.ts +++ b/packages/cli-core/src/commands/users/create.ts @@ -1,5 +1,5 @@ import { handleBapiError, resolveBapiSecretKey } from "../../lib/bapi-command.ts"; -import { throwUsageError } from "../../lib/errors.ts"; +import { UserAbortError, isPromptExitError, throwUsageError } from "../../lib/errors.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { buildCreateUserPayload, @@ -8,9 +8,9 @@ import { readUsersPayloadInput, redactUsersDisplayPayload, } from "../../lib/users.ts"; -import { isHuman } from "../../mode.ts"; +import { isAgent, isHuman } from "../../mode.ts"; import { bapiRequest } from "../api/bapi.ts"; -import { withSpinner, intro, outro } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; import { handleUsersBapiError, printUsersMutationResult } from "./output.ts"; import { registerUsersAction } from "./registry.ts"; import { runCreateWizard } from "./create-wizard.ts"; @@ -42,12 +42,14 @@ export async function create(options: CreateUserOptions): Promise { const { payload, resolved } = await resolveCreate(options); const nested = isInsideGutter(); + const shouldWrap = !nested && !resolved.json && !isAgent(); if (resolved.dryRun) { - if (!nested) intro("Creating user"); + if (shouldWrap) intro("Creating user"); log.info("[dry-run] POST /v1/users"); log.blank(); log.info(JSON.stringify(redactUsersDisplayPayload(payload), null, 2)); + if (shouldWrap) outro(); return; } @@ -57,7 +59,7 @@ export async function create(options: CreateUserOptions): Promise { instance: resolved.instance, }); - if (!nested) intro("Creating user"); + if (shouldWrap) intro("Creating user"); try { const response = await withSpinner("Creating user...", () => @@ -70,7 +72,7 @@ export async function create(options: CreateUserOptions): Promise { ); printUsersMutationResult("Created user", response.body, resolved); - if (!nested) { + if (shouldWrap) { const userId = extractUserId(response.body); if (userId) { outro([`Run \`clerk users open ${userId}\` to view this user in the dashboard`]); @@ -80,13 +82,18 @@ export async function create(options: CreateUserOptions): Promise { } } catch (error) { if (handleUsersBapiError(error, "Failed to create user", resolved)) { - if (!nested) outro("Failed"); + if (shouldWrap) outro("Failed"); return; } if (handleBapiError(error)) { - if (!nested) outro("Failed"); + if (shouldWrap) outro("Failed"); return; } + if (shouldWrap && (error instanceof UserAbortError || isPromptExitError(error))) { + pausedOutro(); + } else if (shouldWrap) { + outro("Failed"); + } throw error; } } diff --git a/packages/cli-core/src/commands/users/list.test.ts b/packages/cli-core/src/commands/users/list.test.ts index 5fd6c3fe..0f8a9699 100644 --- a/packages/cli-core/src/commands/users/list.test.ts +++ b/packages/cli-core/src/commands/users/list.test.ts @@ -22,6 +22,7 @@ const mockWithSpinner = mock((_msg: string, fn: () => Promise) => fn()) mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, + pausedOutro: () => {}, bar: () => {}, withSpinner: (...args: Parameters) => mockWithSpinner(...args), })); diff --git a/packages/cli-core/src/commands/users/list.ts b/packages/cli-core/src/commands/users/list.ts index 2044e959..c52eadc8 100644 --- a/packages/cli-core/src/commands/users/list.ts +++ b/packages/cli-core/src/commands/users/list.ts @@ -1,9 +1,9 @@ import { resolveBapiSecretKey } from "../../lib/bapi-command.ts"; import { dim, cyan } from "../../lib/color.ts"; -import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, UserAbortError, isPromptExitError } from "../../lib/errors.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { isAgent, isHuman } from "../../mode.ts"; -import { withSpinner, intro, outro } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; import { bapiRequest } from "../api/bapi.ts"; import { resolveUsersInstanceContext } from "./interactive/instance-context.ts"; import { registerUsersAction } from "./registry.ts"; @@ -165,6 +165,7 @@ export async function list(options: UsersListOptions = {}): Promise { const nested = isInsideGutter(); const shouldWrap = !nested && !options.json && !isAgent(); if (shouldWrap) intro("Listing users"); + let closeStatus: "success" | "failed" | "paused" | undefined; try { const secretKey = await resolveListSecretKey(options); @@ -194,6 +195,7 @@ export async function list(options: UsersListOptions = {}): Promise { if (users.length === 0) { log.warn("No users found."); + closeStatus = "success"; return; } @@ -204,8 +206,20 @@ export async function list(options: UsersListOptions = {}): Promise { } else { log.info(summary); } + closeStatus = "success"; + } catch (error) { + closeStatus = error instanceof UserAbortError || isPromptExitError(error) ? "paused" : "failed"; + throw error; } finally { - if (shouldWrap) outro(); + if (shouldWrap) { + if (closeStatus === "paused") { + pausedOutro(); + } else if (closeStatus === "failed") { + outro("Failed"); + } else if (closeStatus === "success") { + outro(); + } + } } } diff --git a/packages/cli-core/src/commands/users/menu.test.ts b/packages/cli-core/src/commands/users/menu.test.ts index eded0190..1c20e8e9 100644 --- a/packages/cli-core/src/commands/users/menu.test.ts +++ b/packages/cli-core/src/commands/users/menu.test.ts @@ -17,6 +17,7 @@ mock.module("../../lib/listage.ts", () => ({ mock.module("../../lib/spinner.ts", () => ({ intro: (...args: unknown[]) => mockIntro(...args), outro: (...args: unknown[]) => mockOutro(...args), + pausedOutro: () => {}, bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/users/open.test.ts b/packages/cli-core/src/commands/users/open.test.ts index 0b211258..58eb2dc5 100644 --- a/packages/cli-core/src/commands/users/open.test.ts +++ b/packages/cli-core/src/commands/users/open.test.ts @@ -30,6 +30,7 @@ mock.module("../../lib/open.ts", () => ({ mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, + pausedOutro: () => {}, withSpinner: (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/lib/spinner.test.ts b/packages/cli-core/src/lib/spinner.test.ts index 4534f591..ee3acb06 100644 --- a/packages/cli-core/src/lib/spinner.test.ts +++ b/packages/cli-core/src/lib/spinner.test.ts @@ -83,7 +83,7 @@ async function captureStderrAsync(fn: () => Promise): Promise { } } -const { intro, outro, bar, withSpinner } = await import("./spinner.ts"); +const { intro, outro, pausedOutro, bar, withSpinner } = await import("./spinner.ts"); beforeEach(() => { introCalls = 0; @@ -130,6 +130,18 @@ test("outro with string[] renders custom Next steps block and does not call clac expect(output).toContain("Open the dashboard"); }); +test("pausedOutro renders Paused with resume instructions and pops the gutter prefix", () => { + intro("Hello"); + captureStderr(() => { + pausedOutro("Run this command again to continue."); + }); + + expect(isInsideGutter()).toBe(false); + const output = stderrChunks.join(""); + expect(output).toContain("Paused"); + expect(output).toContain("Run this command again to continue."); +}); + test("bar() writes a single │ line without throwing", () => { captureStderr(() => { bar(); diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index a4db3bbb..a75d0dd0 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -7,6 +7,7 @@ import { getUiOutput } from "./ui.ts"; const S_BAR = "│"; const S_BAR_END = "└"; +const PAUSED_INSTRUCTION = "Run this command again to continue."; const logUiOutput = new Writable({ write(chunk, _encoding, callback) { @@ -55,6 +56,15 @@ export function outro(messageOrSteps?: string | readonly string[]) { }); } +/** Print a paused outro with the instruction needed to resume later. */ +export function pausedOutro(instruction = PAUSED_INSTRUCTION) { + if (!isHuman()) return; + popPrefix(); + writeUi(`${dim(S_BAR)}\n`); + writeUi(`${dim(S_BAR_END)} Paused\n`); + writeUi(` ${cyan("→")} ${instruction}\n\n`); +} + /** Print a bar separator: │ */ export function bar() { if (!isHuman()) return; From 06e233aae6b9f1ec332f8a86b1293c0392b3d0a9 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 1 Jun 2026 12:01:20 -0400 Subject: [PATCH 35/35] fix(review): address PR #305 prompt gutter feedback - add shared gutter wrapper for guaranteed intro/outro cleanup - route unguarded prompt rail commands through the wrapper - merge duplicate apps list error imports --- packages/cli-core/src/commands/apps/list.ts | 3 +- .../src/commands/billing/index.test.ts | 4 + .../cli-core/src/commands/billing/index.ts | 57 ++++++------ .../cli-core/src/commands/config/pull.test.ts | 4 + packages/cli-core/src/commands/config/pull.ts | 40 ++++---- .../cli-core/src/commands/config/schema.ts | 34 ++++--- .../cli-core/src/commands/env/pull.test.ts | 4 + packages/cli-core/src/commands/env/pull.ts | 83 +++++++++-------- .../cli-core/src/commands/orgs/index.test.ts | 4 + packages/cli-core/src/commands/orgs/index.ts | 92 +++++++++---------- packages/cli-core/src/lib/spinner.test.ts | 71 +++++++++++++- packages/cli-core/src/lib/spinner.ts | 40 ++++++++ 12 files changed, 273 insertions(+), 163 deletions(-) diff --git a/packages/cli-core/src/commands/apps/list.ts b/packages/cli-core/src/commands/apps/list.ts index c3ef3b51..a19885ce 100644 --- a/packages/cli-core/src/commands/apps/list.ts +++ b/packages/cli-core/src/commands/apps/list.ts @@ -1,7 +1,6 @@ import { listApplications, type Application } from "../../lib/plapi.ts"; -import { withApiContext } from "../../lib/errors.ts"; +import { UserAbortError, isPromptExitError, withApiContext } from "../../lib/errors.ts"; import { dim, cyan } from "../../lib/color.ts"; -import { UserAbortError, isPromptExitError } from "../../lib/errors.ts"; import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; import { ui } from "../../lib/ui.ts"; import { stripSecrets, displayName, printJson, type AppsOptions } from "./shared.ts"; diff --git a/packages/cli-core/src/commands/billing/index.test.ts b/packages/cli-core/src/commands/billing/index.test.ts index 5583be37..43f95a2e 100644 --- a/packages/cli-core/src/commands/billing/index.test.ts +++ b/packages/cli-core/src/commands/billing/index.test.ts @@ -19,6 +19,10 @@ mock.module("../../lib/spinner.ts", () => ({ outro: () => {}, pausedOutro: () => {}, bar: () => {}, + withGutter: async ( + _title: string, + fn: (controls: { setNextSteps: (steps: readonly string[]) => void }) => Promise, + ) => fn({ setNextSteps: () => {} }), withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/billing/index.ts b/packages/cli-core/src/commands/billing/index.ts index 513484c4..05804dd6 100644 --- a/packages/cli-core/src/commands/billing/index.ts +++ b/packages/cli-core/src/commands/billing/index.ts @@ -5,7 +5,7 @@ import { log } from "../../lib/log.ts"; import { confirm } from "../../lib/prompts.ts"; import { detectPackageManager } from "../../lib/package-manager.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; -import { intro, outro } from "../../lib/spinner.ts"; +import { withGutter } from "../../lib/spinner.ts"; import { applyConfigPatch } from "../config/apply-patch.ts"; import { resolveSkillsRunner, runSkillsAdd } from "../skill/install.ts"; @@ -71,26 +71,23 @@ export async function billingEnable(options: BillingOptions): Promise { billing.user_enabled = true; } - intro("Enabling billing"); - - const applied = await applyConfigPatch({ - ctx, - payload, - verb: `Enabling billing for ${describeTargets(targets)}`, - successMessage: `Billing enabled for ${describeTargets(targets)}`, - failureContext: "Failed to enable billing", - yes: options.yes, - dryRun: options.dryRun, - }); + await withGutter("Enabling billing", async ({ setNextSteps }) => { + const applied = await applyConfigPatch({ + ctx, + payload, + verb: `Enabling billing for ${describeTargets(targets)}`, + successMessage: `Billing enabled for ${describeTargets(targets)}`, + failureContext: "Failed to enable billing", + yes: options.yes, + dryRun: options.dryRun, + }); - if (!applied || options.dryRun) { - outro(); - return; - } + if (!applied || options.dryRun) return; - // `clerk init` doesn't bundle clerk-billing — it's opt-in. Surface it here. - if (options.skills !== false) await offerBillingSkillInstall(options); - outro(NEXT_STEPS.ENABLE_BILLING); + // `clerk init` doesn't bundle clerk-billing — it's opt-in. Surface it here. + if (options.skills !== false) await offerBillingSkillInstall(options); + setNextSteps(NEXT_STEPS.ENABLE_BILLING); + }); } async function offerBillingSkillInstall(options: BillingOptions): Promise { @@ -133,17 +130,15 @@ export async function billingDisable(options: BillingOptions): Promise { if (targets.includes("orgs")) billing.organization_enabled = false; if (targets.includes("users")) billing.user_enabled = false; - intro("Disabling billing"); - - await applyConfigPatch({ - ctx, - payload: { billing }, - verb: `Disabling billing for ${describeTargets(targets)}`, - successMessage: `Billing disabled for ${describeTargets(targets)}`, - failureContext: "Failed to disable billing", - yes: options.yes, - dryRun: options.dryRun, + await withGutter("Disabling billing", async () => { + await applyConfigPatch({ + ctx, + payload: { billing }, + verb: `Disabling billing for ${describeTargets(targets)}`, + successMessage: `Billing disabled for ${describeTargets(targets)}`, + failureContext: "Failed to disable billing", + yes: options.yes, + dryRun: options.dryRun, + }); }); - - outro(); } diff --git a/packages/cli-core/src/commands/config/pull.test.ts b/packages/cli-core/src/commands/config/pull.test.ts index 4a1973c8..fed5d17d 100644 --- a/packages/cli-core/src/commands/config/pull.test.ts +++ b/packages/cli-core/src/commands/config/pull.test.ts @@ -12,6 +12,10 @@ mock.module("../../lib/spinner.ts", () => ({ outro: () => {}, pausedOutro: () => {}, bar: () => {}, + withGutter: async ( + _title: string, + fn: (controls: { setNextSteps: (steps: readonly string[]) => void }) => Promise, + ) => fn({ setNextSteps: () => {} }), withSpinner: async (msg: string, fn: () => Promise) => { const { log } = await import("../../lib/log.ts"); log.info(msg); diff --git a/packages/cli-core/src/commands/config/pull.ts b/packages/cli-core/src/commands/config/pull.ts index 803ec2b0..64a68dbd 100644 --- a/packages/cli-core/src/commands/config/pull.ts +++ b/packages/cli-core/src/commands/config/pull.ts @@ -1,7 +1,7 @@ import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfig } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; -import { withSpinner, intro, outro } from "../../lib/spinner.ts"; +import { withGutter, withSpinner } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; interface ConfigPullOptions { @@ -12,27 +12,25 @@ interface ConfigPullOptions { } export async function configPull(options: ConfigPullOptions): Promise { - intro("Pulling configuration"); + await withGutter("Pulling configuration", async () => { + const ctx = await resolveAppContext(options); - const ctx = await resolveAppContext(options); + const config = await withSpinner( + `Pulling config from ${ctx.appLabel} (${ctx.instanceLabel})...`, + () => + withApiContext( + fetchInstanceConfig(ctx.appId, ctx.instanceId, options.keys), + "Failed to fetch config", + ), + ); - const config = await withSpinner( - `Pulling config from ${ctx.appLabel} (${ctx.instanceLabel})...`, - () => - withApiContext( - fetchInstanceConfig(ctx.appId, ctx.instanceId, options.keys), - "Failed to fetch config", - ), - ); + const json = JSON.stringify(config, null, 2); - const json = JSON.stringify(config, null, 2); - - if (options.output) { - await Bun.write(options.output, json + "\n"); - log.success(`Config written to ${options.output}`); - } else { - log.data(json); - } - - outro(); + if (options.output) { + await Bun.write(options.output, json + "\n"); + log.success(`Config written to ${options.output}`); + } else { + log.data(json); + } + }); } diff --git a/packages/cli-core/src/commands/config/schema.ts b/packages/cli-core/src/commands/config/schema.ts index cf8d8c03..4762f497 100644 --- a/packages/cli-core/src/commands/config/schema.ts +++ b/packages/cli-core/src/commands/config/schema.ts @@ -1,7 +1,7 @@ import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfigSchema } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; -import { intro, outro } from "../../lib/spinner.ts"; +import { withGutter } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; interface ConfigSchemaOptions { @@ -12,25 +12,23 @@ interface ConfigSchemaOptions { } export async function configSchema(options: ConfigSchemaOptions): Promise { - intro("Fetching configuration schema"); + await withGutter("Fetching configuration schema", async () => { + const ctx = await resolveAppContext(options); - const ctx = await resolveAppContext(options); + log.info(`Pulling config schema from ${ctx.appLabel} (${ctx.instanceLabel})...`); - log.info(`Pulling config schema from ${ctx.appLabel} (${ctx.instanceLabel})...`); + const schema = await withApiContext( + fetchInstanceConfigSchema(ctx.appId, ctx.instanceId, options.keys), + "Failed to fetch config schema", + ); - const schema = await withApiContext( - fetchInstanceConfigSchema(ctx.appId, ctx.instanceId, options.keys), - "Failed to fetch config schema", - ); + const json = JSON.stringify(schema, null, 2); - const json = JSON.stringify(schema, null, 2); - - if (options.output) { - await Bun.write(options.output, json + "\n"); - log.success(`Schema written to ${options.output}`); - } else { - log.data(json); - } - - outro(); + if (options.output) { + await Bun.write(options.output, json + "\n"); + log.success(`Schema written to ${options.output}`); + } else { + log.data(json); + } + }); } diff --git a/packages/cli-core/src/commands/env/pull.test.ts b/packages/cli-core/src/commands/env/pull.test.ts index 53b2c52c..8acbbd5e 100644 --- a/packages/cli-core/src/commands/env/pull.test.ts +++ b/packages/cli-core/src/commands/env/pull.test.ts @@ -17,6 +17,10 @@ mock.module("../../lib/spinner.ts", () => ({ outro: () => {}, pausedOutro: () => {}, bar: () => {}, + withGutter: async ( + _title: string, + fn: (controls: { setNextSteps: (steps: readonly string[]) => void }) => Promise, + ) => fn({ setNextSteps: () => {} }), withSpinner: async (msg: string, fn: () => Promise) => { console.error(msg); return fn(); diff --git a/packages/cli-core/src/commands/env/pull.ts b/packages/cli-core/src/commands/env/pull.ts index 12fedd32..fb65d3dc 100644 --- a/packages/cli-core/src/commands/env/pull.ts +++ b/packages/cli-core/src/commands/env/pull.ts @@ -8,7 +8,7 @@ import { detectEnvFile, } from "../../lib/framework.ts"; import { CliError, ERROR_CODE, withApiContext } from "../../lib/errors.ts"; -import { withSpinner, intro, outro } from "../../lib/spinner.ts"; +import { withGutter, withSpinner } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; const DEV_LOCAL_ENV_FILE = ".env.development.local"; @@ -48,46 +48,45 @@ async function resolveTargetFile( } export async function pull(options: EnvPullOptions): Promise { - intro("Pulling environment variables"); - - const cwd = options.cwd ?? process.cwd(); - const [ctx, preferredEnvFile] = await Promise.all([ - resolveAppContext({ ...options, cwd }), - detectEnvFile(cwd), - ]); - const targetFile = await resolveTargetFile(cwd, options.file, preferredEnvFile); - const displayPath = options.file ?? basename(targetFile); - - await withSpinner(`Pulling env vars from ${ctx.instanceLabel} instance...`, async () => { - const app = await withApiContext(fetchApplication(ctx.appId), "Failed to fetch API keys"); - - const matched = app.instances.find((i) => i.instance_id === ctx.instanceId); - if (!matched) { - throw new CliError(`Instance ${ctx.instanceId} not found in application response.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - docsUrl: "https://clerk.com/docs/guides/development/managing-environments", - }); - } - - const publishableKeyName = await detectPublishableKeyName(cwd); - const secretKeyName = await detectSecretKeyName(cwd); - - const file = Bun.file(targetFile); - const existingContent = (await file.exists()) ? await file.text() : ""; - - const lines = parseEnvFile(existingContent); - const vars: Record = { - [publishableKeyName]: matched.publishable_key, - }; - if (matched.secret_key) { - vars[secretKeyName] = matched.secret_key; - } - const merged = mergeEnvVars(lines, vars); - const output = serializeEnvFile(merged); - - await Bun.write(targetFile, output); + await withGutter("Pulling environment variables", async () => { + const cwd = options.cwd ?? process.cwd(); + const [ctx, preferredEnvFile] = await Promise.all([ + resolveAppContext({ ...options, cwd }), + detectEnvFile(cwd), + ]); + const targetFile = await resolveTargetFile(cwd, options.file, preferredEnvFile); + const displayPath = options.file ?? basename(targetFile); + + await withSpinner(`Pulling env vars from ${ctx.instanceLabel} instance...`, async () => { + const app = await withApiContext(fetchApplication(ctx.appId), "Failed to fetch API keys"); + + const matched = app.instances.find((i) => i.instance_id === ctx.instanceId); + if (!matched) { + throw new CliError(`Instance ${ctx.instanceId} not found in application response.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + docsUrl: "https://clerk.com/docs/guides/development/managing-environments", + }); + } + + const publishableKeyName = await detectPublishableKeyName(cwd); + const secretKeyName = await detectSecretKeyName(cwd); + + const file = Bun.file(targetFile); + const existingContent = (await file.exists()) ? await file.text() : ""; + + const lines = parseEnvFile(existingContent); + const vars: Record = { + [publishableKeyName]: matched.publishable_key, + }; + if (matched.secret_key) { + vars[secretKeyName] = matched.secret_key; + } + const merged = mergeEnvVars(lines, vars); + const output = serializeEnvFile(merged); + + await Bun.write(targetFile, output); + }); + + log.info(`Environment variables written to ${displayPath}`); }); - - log.info(`Environment variables written to ${displayPath}`); - outro(); } diff --git a/packages/cli-core/src/commands/orgs/index.test.ts b/packages/cli-core/src/commands/orgs/index.test.ts index 88f65d89..8983ab18 100644 --- a/packages/cli-core/src/commands/orgs/index.test.ts +++ b/packages/cli-core/src/commands/orgs/index.test.ts @@ -12,6 +12,10 @@ mock.module("../../lib/spinner.ts", () => ({ outro: () => {}, pausedOutro: () => {}, bar: () => {}, + withGutter: async ( + _title: string, + fn: (controls: { setNextSteps: (steps: readonly string[]) => void }) => Promise, + ) => fn({ setNextSteps: () => {} }), withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/orgs/index.ts b/packages/cli-core/src/commands/orgs/index.ts index 9e776980..d93ba7f0 100644 --- a/packages/cli-core/src/commands/orgs/index.ts +++ b/packages/cli-core/src/commands/orgs/index.ts @@ -1,7 +1,7 @@ import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfig } from "../../lib/plapi.ts"; import { throwUsageError, withApiContext } from "../../lib/errors.ts"; -import { withSpinner, intro, outro } from "../../lib/spinner.ts"; +import { withGutter, withSpinner } from "../../lib/spinner.ts"; import { isHuman } from "../../mode.ts"; import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { applyConfigPatch } from "../config/apply-patch.ts"; @@ -45,62 +45,58 @@ export async function orgsEnable(options: OrgsOptions): Promise { orgSettings.max_allowed_memberships = parsePositiveInt(options.maxMembers, "--max-members"); } - intro("Enabling organizations"); + await withGutter("Enabling organizations", async ({ setNextSteps }) => { + const applied = await applyConfigPatch({ + ctx, + payload: { organization_settings: orgSettings }, + verb: "Enabling organizations", + successMessage: "Organizations enabled", + failureContext: "Failed to enable organizations", + yes: options.yes, + dryRun: options.dryRun, + }); - const applied = await applyConfigPatch({ - ctx, - payload: { organization_settings: orgSettings }, - verb: "Enabling organizations", - successMessage: "Organizations enabled", - failureContext: "Failed to enable organizations", - yes: options.yes, - dryRun: options.dryRun, + if (applied && !options.dryRun) { + setNextSteps(NEXT_STEPS.ENABLE_ORGS); + } }); - - if (applied && !options.dryRun) { - outro(NEXT_STEPS.ENABLE_ORGS); - } else { - outro(); - } } export async function orgsDisable(options: OrgsOptions): Promise { const ctx = await resolveAppContext(options); - intro("Disabling organizations"); + await withGutter("Disabling organizations", async () => { + const current = await withSpinner("Fetching current config...", () => + withApiContext( + fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing", "organization_settings"]), + "Failed to fetch config", + ), + ); - const current = await withSpinner("Fetching current config...", () => - withApiContext( - fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing", "organization_settings"]), - "Failed to fetch config", - ), - ); + const billing = current.billing as Record | undefined; + const orgBillingOn = billing?.organization_enabled === true; - const billing = current.billing as Record | undefined; - const orgBillingOn = billing?.organization_enabled === true; + // Agent mode: refuse rather than warn-then-mutate (warn-then-mutate in CI + // logs reads as "the warning was heeded" when it wasn't). + if (orgBillingOn && !isHuman() && !options.yes) { + throwUsageError( + "Organization billing is enabled. Disabling organizations would leave `billing.organization_enabled` stranded. " + + "Run `clerk disable billing --for orgs` first, or pass --yes to override.", + ); + } - // Agent mode: refuse rather than warn-then-mutate (warn-then-mutate in CI - // logs reads as "the warning was heeded" when it wasn't). - if (orgBillingOn && !isHuman() && !options.yes) { - throwUsageError( - "Organization billing is enabled. Disabling organizations would leave `billing.organization_enabled` stranded. " + - "Run `clerk disable billing --for orgs` first, or pass --yes to override.", - ); - } - - await applyConfigPatch({ - ctx, - payload: { organization_settings: { enabled: false } }, - verb: "Disabling organizations", - successMessage: "Organizations disabled", - failureContext: "Failed to disable organizations", - yes: options.yes, - dryRun: options.dryRun, - warning: orgBillingOn - ? "Organization billing is currently enabled. Disabling organizations will leave `billing.organization_enabled` stranded — consider running `clerk disable billing --for orgs` separately." - : undefined, - currentConfig: current, + await applyConfigPatch({ + ctx, + payload: { organization_settings: { enabled: false } }, + verb: "Disabling organizations", + successMessage: "Organizations disabled", + failureContext: "Failed to disable organizations", + yes: options.yes, + dryRun: options.dryRun, + warning: orgBillingOn + ? "Organization billing is currently enabled. Disabling organizations will leave `billing.organization_enabled` stranded — consider running `clerk disable billing --for orgs` separately." + : undefined, + currentConfig: current, + }); }); - - outro(); } diff --git a/packages/cli-core/src/lib/spinner.test.ts b/packages/cli-core/src/lib/spinner.test.ts index ee3acb06..b5fc5ffc 100644 --- a/packages/cli-core/src/lib/spinner.test.ts +++ b/packages/cli-core/src/lib/spinner.test.ts @@ -83,7 +83,8 @@ async function captureStderrAsync(fn: () => Promise): Promise { } } -const { intro, outro, pausedOutro, bar, withSpinner } = await import("./spinner.ts"); +const { intro, outro, pausedOutro, bar, withSpinner, withGutter } = await import("./spinner.ts"); +const { UserAbortError } = await import("./errors.ts"); beforeEach(() => { introCalls = 0; @@ -142,6 +143,74 @@ test("pausedOutro renders Paused with resume instructions and pops the gutter pr expect(output).toContain("Run this command again to continue."); }); +test("withGutter opens and closes the gutter on success", async () => { + const result = await withGutter("Hello", async () => { + expect(isInsideGutter()).toBe(true); + return 42; + }); + + expect(result).toBe(42); + expect(introCalls).toBe(1); + expect(lastIntroTitle).toBe("Hello"); + expect(outroCalls).toBe(1); + expect(lastOutroLabel).toBe("Done"); + expect(isInsideGutter()).toBe(false); +}); + +test("withGutter renders next steps on success", async () => { + await captureStderrAsync(() => + withGutter("Hello", async ({ setNextSteps }) => { + setNextSteps(["Run `clerk dev`"]); + }), + ); + + expect(outroCalls).toBe(0); + expect(isInsideGutter()).toBe(false); + expect(stderrChunks.join("")).toContain("Run `clerk dev`"); +}); + +test("withGutter closes as Failed and rethrows on errors", async () => { + const boom = new Error("kaboom"); + await expect( + withGutter("Hello", async () => { + throw boom; + }), + ).rejects.toBe(boom); + + expect(outroCalls).toBe(1); + expect(lastOutroLabel).toBe("Failed"); + expect(isInsideGutter()).toBe(false); +}); + +test("withGutter closes as Paused and rethrows on prompt aborts", async () => { + await expect( + captureStderrAsync(() => + withGutter("Hello", async () => { + throw new UserAbortError(); + }), + ), + ).rejects.toBeInstanceOf(UserAbortError); + + expect(outroCalls).toBe(0); + expect(isInsideGutter()).toBe(false); + expect(stderrChunks.join("")).toContain("Paused"); +}); + +test("withGutter skips wrapping when requested", async () => { + const result = await withGutter( + "Hello", + async () => { + expect(isInsideGutter()).toBe(false); + return 42; + }, + { skip: true }, + ); + + expect(result).toBe(42); + expect(introCalls).toBe(0); + expect(outroCalls).toBe(0); +}); + test("bar() writes a single │ line without throwing", () => { captureStderr(() => { bar(); diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index a75d0dd0..b482eeb0 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -2,6 +2,7 @@ import { Writable } from "node:stream"; import { intro as clackIntro, outro as clackOutro, spinner as clackSpinner } from "@clack/prompts"; import { isHuman } from "../mode.ts"; import { dim, cyan } from "./color.ts"; +import { UserAbortError, isPromptExitError } from "./errors.ts"; import { log, pushPrefix, popPrefix } from "./log.ts"; import { getUiOutput } from "./ui.ts"; @@ -75,6 +76,45 @@ export type SpinnerControls = { update(message: string): void; }; +/** + * Controls for commands wrapped by {@link withGutter}. + */ +export type GutterControls = { + setNextSteps(steps: readonly string[]): void; +}; + +/** + * Run a command inside an intro/outro gutter and guarantee the gutter closes. + */ +export async function withGutter( + title: string, + fn: (controls: GutterControls) => Promise, + options?: { skip?: boolean }, +): Promise { + let nextSteps: readonly string[] | undefined; + const controls: GutterControls = { + setNextSteps(steps) { + nextSteps = steps; + }, + }; + + if (options?.skip || !isHuman()) return fn(controls); + + intro(title); + try { + const result = await fn(controls); + outro(nextSteps); + return result; + } catch (error) { + if (error instanceof UserAbortError || isPromptExitError(error)) { + pausedOutro(); + } else { + outro("Failed"); + } + throw error; + } +} + export async function withSpinner( message: string, fn: (controls: SpinnerControls) => Promise,