Skip to content

Commit 3507c35

Browse files
feat(init): surface server-provided detail in spinner messages (#588)
## Summary - Add optional `detail?: string` field to all `LocalOpPayload` types - Update spinner message logic to prefer `detail` over generic `"${label} (${operation})..."` format - Add tests for both detail-present and detail-absent cases Closes getsentry/cli-init-api#11 ## Example Before: `Installing dependencies (run-commands)...` After (when server provides detail): `npm install @sentry/nextjs @sentry/profiling-node`
1 parent 0276f76 commit 3507c35

File tree

4 files changed

+126
-2
lines changed

4 files changed

+126
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,4 @@ src/sdk.generated.d.cts
5959

6060
# OpenCode
6161
.opencode/
62+
opencode.json*

src/lib/init/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export type LocalOpPayload =
3030
export type ListDirPayload = {
3131
type: "local-op";
3232
operation: "list-dir";
33+
/** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */
34+
detail?: string;
3335
cwd: string;
3436
params: {
3537
path: string;
@@ -42,6 +44,8 @@ export type ListDirPayload = {
4244
export type ReadFilesPayload = {
4345
type: "local-op";
4446
operation: "read-files";
47+
/** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */
48+
detail?: string;
4549
cwd: string;
4650
params: {
4751
paths: string[];
@@ -52,6 +56,8 @@ export type ReadFilesPayload = {
5256
export type FileExistsBatchPayload = {
5357
type: "local-op";
5458
operation: "file-exists-batch";
59+
/** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */
60+
detail?: string;
5561
cwd: string;
5662
params: {
5763
paths: string[];
@@ -61,6 +67,8 @@ export type FileExistsBatchPayload = {
6167
export type RunCommandsPayload = {
6268
type: "local-op";
6369
operation: "run-commands";
70+
/** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */
71+
detail?: string;
6472
cwd: string;
6573
params: {
6674
commands: string[];
@@ -71,6 +79,8 @@ export type RunCommandsPayload = {
7179
export type ApplyPatchsetPayload = {
7280
type: "local-op";
7381
operation: "apply-patchset";
82+
/** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */
83+
detail?: string;
7484
cwd: string;
7585
params: {
7686
patches: Array<{
@@ -84,6 +94,8 @@ export type ApplyPatchsetPayload = {
8494
export type CreateSentryProjectPayload = {
8595
type: "local-op";
8696
operation: "create-sentry-project";
97+
/** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */
98+
detail?: string;
8799
cwd: string;
88100
params: {
89101
name: string;

src/lib/init/wizard-runner.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ function nextPhase(
5858
return names[Math.min(phase - 1, names.length - 1)] ?? "done";
5959
}
6060

61+
/**
62+
* Truncate a spinner message to fit within the terminal width.
63+
* Leaves room for the spinner character and padding.
64+
*/
65+
function truncateForTerminal(message: string): string {
66+
// Reserve space for spinner (2 chars) + some padding
67+
const maxWidth = (process.stdout.columns || 80) - 4;
68+
if (message.length <= maxWidth) {
69+
return message;
70+
}
71+
return `${message.slice(0, maxWidth - 1)}…`;
72+
}
73+
6174
async function handleSuspendedStep(
6275
ctx: StepContext,
6376
stepPhases: Map<string, number>,
@@ -67,8 +80,10 @@ async function handleSuspendedStep(
6780
const label = STEP_LABELS[stepId] ?? stepId;
6881

6982
if (payload.type === "local-op") {
70-
const detail = payload.operation ? ` (${payload.operation})` : "";
71-
spin.message(`${label}${detail}...`);
83+
const message = payload.detail
84+
? payload.detail
85+
: `${label} (${payload.operation})...`;
86+
spin.message(truncateForTerminal(message));
7287

7388
const localResult = await handleLocalOp(payload, options);
7489

test/lib/init/wizard-runner.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,102 @@ describe("runWizard", () => {
485485
expect(payload.operation).toBe("list-dir");
486486
});
487487

488+
test("uses detail field for spinner message when present", async () => {
489+
mockStartResult = {
490+
status: "suspended",
491+
suspended: [["install-deps"]],
492+
steps: {
493+
"install-deps": {
494+
suspendPayload: {
495+
type: "local-op",
496+
operation: "run-commands",
497+
detail: "npm install @sentry/nextjs @sentry/profiling-node",
498+
cwd: "/app",
499+
params: {
500+
commands: ["npm install @sentry/nextjs @sentry/profiling-node"],
501+
},
502+
},
503+
},
504+
},
505+
};
506+
mockResumeResults = [{ status: "success" }];
507+
508+
await runWizard(makeOptions());
509+
510+
expect(spinnerMock.message).toHaveBeenCalledWith(
511+
"npm install @sentry/nextjs @sentry/profiling-node"
512+
);
513+
});
514+
515+
test("falls back to generic message when detail is absent", async () => {
516+
mockStartResult = {
517+
status: "suspended",
518+
suspended: [["install-deps"]],
519+
steps: {
520+
"install-deps": {
521+
suspendPayload: {
522+
type: "local-op",
523+
operation: "run-commands",
524+
cwd: "/app",
525+
params: { commands: ["npm install @sentry/nextjs"] },
526+
},
527+
},
528+
},
529+
};
530+
mockResumeResults = [{ status: "success" }];
531+
532+
await runWizard(makeOptions());
533+
534+
expect(spinnerMock.message).toHaveBeenCalledWith(
535+
"Installing dependencies (run-commands)..."
536+
);
537+
});
538+
539+
test("truncates detail message when terminal is narrow", async () => {
540+
const origColumns = process.stdout.columns;
541+
Object.defineProperty(process.stdout, "columns", {
542+
value: 40,
543+
configurable: true,
544+
});
545+
546+
const longDetail =
547+
"npm install @sentry/nextjs @sentry/profiling-node @sentry/browser";
548+
mockStartResult = {
549+
status: "suspended",
550+
suspended: [["install-deps"]],
551+
steps: {
552+
"install-deps": {
553+
suspendPayload: {
554+
type: "local-op",
555+
operation: "run-commands",
556+
detail: longDetail,
557+
cwd: "/app",
558+
params: { commands: [longDetail] },
559+
},
560+
},
561+
},
562+
};
563+
mockResumeResults = [{ status: "success" }];
564+
565+
try {
566+
await runWizard(makeOptions());
567+
568+
// 40 columns - 4 reserved = 36 max, truncated with "…"
569+
const call = spinnerMock.message.mock.calls.find((c: string[]) =>
570+
c[0]?.includes("npm install")
571+
) as string[] | undefined;
572+
expect(call).toBeDefined();
573+
const msg = call?.[0] ?? "";
574+
expect(msg.length).toBeLessThanOrEqual(36);
575+
expect(msg.endsWith("…")).toBe(true);
576+
} finally {
577+
Object.defineProperty(process.stdout, "columns", {
578+
value: origColumns,
579+
configurable: true,
580+
});
581+
}
582+
});
583+
488584
test("dispatches interactive payload to handleInteractive", async () => {
489585
mockStartResult = {
490586
status: "suspended",

0 commit comments

Comments
 (0)