Skip to content

Commit c69da8b

Browse files
betegonclaude
andcommitted
refactor(init): add typed interfaces, validate-all-then-execute, and runtime assertions
- Add WizardOutput, SelectPayload, MultiSelectPayload, ConfirmPayload, SuspendPayload types; refactor InteractivePayload as discriminated union - Remove ~20 unsafe casts across formatters, interactive, and wizard-runner - Restructure runCommands to validate all commands before executing any - Add assertWorkflowResult/assertSuspendPayload runtime validation for server responses - Add tests for malformed responses, batch validation, and dry-run paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent be2e9ad commit c69da8b

File tree

10 files changed

+247
-95
lines changed

10 files changed

+247
-95
lines changed

src/lib/init/constants.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
export const MASTRA_API_URL =
2-
process.env.SENTRY_WIZARD_API_URL ??
3-
"https://sentry-init-agent.getsentry.workers.dev";
1+
export const MASTRA_API_URL = "http://localhost:8787";
42

53
export const WORKFLOW_ID = "sentry-wizard";
64

src/lib/init/formatters.ts

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ import {
1212
EXIT_SENTRY_ALREADY_INSTALLED,
1313
EXIT_VERIFICATION_FAILED,
1414
} from "./constants.js";
15-
16-
type WizardOutput = Record<string, unknown>;
15+
import type { WizardOutput, WorkflowRunResult } from "./types.js";
1716

1817
function fileActionIcon(action: string): string {
1918
if (action === "create") {
@@ -35,14 +34,12 @@ function buildSummaryLines(output: WizardOutput): string[] {
3534
lines.push(`Directory: ${output.projectDir}`);
3635
}
3736

38-
const features = output.features as string[] | undefined;
39-
if (features?.length) {
40-
lines.push(`Features: ${features.map(featureLabel).join(", ")}`);
37+
if (output.features?.length) {
38+
lines.push(`Features: ${output.features.map(featureLabel).join(", ")}`);
4139
}
4240

43-
const commands = output.commands as string[] | undefined;
44-
if (commands?.length) {
45-
lines.push(`Commands: ${commands.join("; ")}`);
41+
if (output.commands?.length) {
42+
lines.push(`Commands: ${output.commands.join("; ")}`);
4643
}
4744
if (output.sentryProjectUrl) {
4845
lines.push(`Project: ${output.sentryProjectUrl}`);
@@ -51,9 +48,7 @@ function buildSummaryLines(output: WizardOutput): string[] {
5148
lines.push(`Docs: ${output.docsUrl}`);
5249
}
5350

54-
const changedFiles = output.changedFiles as
55-
| Array<{ action: string; path: string }>
56-
| undefined;
51+
const changedFiles = output.changedFiles;
5752
if (changedFiles?.length) {
5853
lines.push("");
5954
lines.push("Changed files:");
@@ -65,17 +60,16 @@ function buildSummaryLines(output: WizardOutput): string[] {
6560
return lines;
6661
}
6762

68-
export function formatResult(result: WizardOutput): void {
69-
const output = (result.result as WizardOutput) ?? result;
63+
export function formatResult(result: WorkflowRunResult): void {
64+
const output: WizardOutput = result.result ?? {};
7065
const lines = buildSummaryLines(output);
7166

7267
if (lines.length > 0) {
7368
note(lines.join("\n"), "Setup complete");
7469
}
7570

76-
const warnings = output.warnings as string[] | undefined;
77-
if (warnings?.length) {
78-
for (const w of warnings) {
71+
if (output.warnings?.length) {
72+
for (const w of output.warnings) {
7973
log.warn(w);
8074
}
8175
}
@@ -85,11 +79,11 @@ export function formatResult(result: WizardOutput): void {
8579
outro("Sentry SDK installed successfully!");
8680
}
8781

88-
export function formatError(result: WizardOutput): void {
89-
const inner = result.result as WizardOutput | undefined;
82+
export function formatError(result: WorkflowRunResult): void {
83+
const inner = result.result;
9084
const message =
9185
result.error ?? inner?.message ?? "Wizard failed with an unknown error";
92-
const exitCode = (inner?.exitCode as number) ?? 1;
86+
const exitCode = inner?.exitCode ?? 1;
9387

9488
log.error(String(message));
9589

@@ -100,7 +94,7 @@ export function formatError(result: WizardOutput): void {
10094
"Hint: Could not detect your project's platform. Check that the directory contains a valid project."
10195
);
10296
} else if (exitCode === EXIT_DEPENDENCY_INSTALL_FAILED) {
103-
const commands = inner?.commands as string[] | undefined;
97+
const commands = inner?.commands;
10498
if (commands?.length) {
10599
log.warn(
106100
`You can install dependencies manually:\n${commands.map((cmd) => ` $ ${cmd}`).join("\n")}`

src/lib/init/interactive.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,19 @@ import { confirm, log, multiselect, select } from "@clack/prompts";
1010
import chalk from "chalk";
1111
import { abortIfCancelled, featureHint, featureLabel } from "./clack-utils.js";
1212
import { REQUIRED_FEATURE } from "./constants.js";
13-
import type { InteractivePayload, WizardOptions } from "./types.js";
13+
import type {
14+
ConfirmPayload,
15+
InteractivePayload,
16+
MultiSelectPayload,
17+
SelectPayload,
18+
WizardOptions,
19+
} from "./types.js";
1420

1521
export async function handleInteractive(
1622
payload: InteractivePayload,
1723
options: WizardOptions
1824
): Promise<Record<string, unknown>> {
19-
const { kind } = payload;
20-
21-
switch (kind) {
25+
switch (payload.kind) {
2226
case "select":
2327
return await handleSelect(payload, options);
2428
case "multi-select":
@@ -31,16 +35,11 @@ export async function handleInteractive(
3135
}
3236

3337
async function handleSelect(
34-
payload: InteractivePayload,
38+
payload: SelectPayload,
3539
options: WizardOptions
3640
): Promise<Record<string, unknown>> {
37-
const apps =
38-
(payload.apps as Array<{
39-
name: string;
40-
path: string;
41-
framework?: string;
42-
}>) ?? [];
43-
const items = (payload.options as string[]) ?? apps.map((a) => a.name);
41+
const apps = payload.apps ?? [];
42+
const items = payload.options ?? apps.map((a) => a.name);
4443

4544
if (items.length === 0) {
4645
return { cancelled: true };
@@ -73,13 +72,10 @@ async function handleSelect(
7372
}
7473

7574
async function handleMultiSelect(
76-
payload: InteractivePayload,
75+
payload: MultiSelectPayload,
7776
options: WizardOptions
7877
): Promise<Record<string, unknown>> {
79-
const available =
80-
(payload.availableFeatures as string[]) ??
81-
(payload.options as string[]) ??
82-
[];
78+
const available = payload.availableFeatures ?? payload.options ?? [];
8379

8480
if (available.length === 0) {
8581
return { features: [] };
@@ -131,7 +127,7 @@ async function handleMultiSelect(
131127
}
132128

133129
async function handleConfirm(
134-
payload: InteractivePayload,
130+
payload: ConfirmPayload,
135131
options: WizardOptions
136132
): Promise<Record<string, unknown>> {
137133
const isExample =

src/lib/init/local-ops.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,15 @@ async function runCommands(
331331
const { cwd, params } = payload;
332332
const timeoutMs = params.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
333333

334+
// Phase 1: Validate ALL commands upfront (including dry-run)
335+
for (const command of params.commands) {
336+
const validationError = validateCommand(command);
337+
if (validationError) {
338+
return { ok: false, error: validationError };
339+
}
340+
}
341+
342+
// Phase 2: Execute (skip in dry-run)
334343
const results: Array<{
335344
command: string;
336345
exitCode: number;
@@ -349,11 +358,6 @@ async function runCommands(
349358
continue;
350359
}
351360

352-
const validationError = validateCommand(command);
353-
if (validationError) {
354-
return { ok: false, error: validationError };
355-
}
356-
357361
const result = await runSingleCommand(command, cwd, timeoutMs);
358362
results.push(result);
359363
if (result.exitCode !== 0) {

src/lib/init/types.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,22 +81,62 @@ export type LocalOpResult = {
8181
data?: unknown;
8282
};
8383

84+
// Wizard output — typed shape of the `result` field returned by the server
85+
86+
export type WizardOutput = {
87+
platform?: string;
88+
projectDir?: string;
89+
features?: string[];
90+
commands?: string[];
91+
changedFiles?: Array<{ action: string; path: string }>;
92+
warnings?: string[];
93+
exitCode?: number;
94+
docsUrl?: string;
95+
sentryProjectUrl?: string;
96+
message?: string;
97+
};
98+
8499
// Interactive suspend payloads
85100

86-
export type InteractivePayload = {
101+
export type InteractivePayload =
102+
| SelectPayload
103+
| MultiSelectPayload
104+
| ConfirmPayload;
105+
106+
export type SelectPayload = {
87107
type: "interactive";
108+
kind: "select";
88109
prompt: string;
89-
kind: "select" | "multi-select" | "confirm";
90-
[key: string]: unknown;
110+
options?: string[];
111+
apps?: Array<{ name: string; path: string; framework?: string }>;
91112
};
92113

114+
export type MultiSelectPayload = {
115+
type: "interactive";
116+
kind: "multi-select";
117+
prompt: string;
118+
availableFeatures?: string[];
119+
options?: string[];
120+
};
121+
122+
export type ConfirmPayload = {
123+
type: "interactive";
124+
kind: "confirm";
125+
prompt: string;
126+
purpose?: string;
127+
};
128+
129+
// Combined suspend payload — either a local-op or an interactive prompt
130+
131+
export type SuspendPayload = LocalOpPayload | InteractivePayload;
132+
93133
// Workflow run result
94134

95135
export type WorkflowRunResult = {
96136
status: "suspended" | "success" | "failed";
97137
suspended?: string[][];
98138
steps?: Record<string, { suspendPayload?: unknown }>;
99139
suspendPayload?: unknown;
100-
result?: unknown;
140+
result?: WizardOutput;
101141
error?: string;
102142
};

0 commit comments

Comments
 (0)