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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
349 changes: 1 addition & 348 deletions bun.lock

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"@anthropic-ai/sdk": "^0.39.0",
"@biomejs/biome": "2.3.8",
"@clack/prompts": "^0.11.0",
"@mastra/client-js": "^1.4.0",
"@sentry/api": "^0.94.0",
"@sentry/node-core": "10.47.0",
"@sentry/sqlish": "^1.0.0",
Expand Down
10 changes: 5 additions & 5 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* sentry init
*
* Initialize Sentry in a project using the remote wizard workflow.
* Communicates with the Mastra API via suspend/resume to perform
* local filesystem operations and interactive prompts.
* Streams progress from the init API and performs any requested
* local filesystem operations or interactive prompts on the CLI side.
*
* Supports two optional positionals with smart disambiguation:
* sentry init — auto-detect everything, dir = cwd
Expand Down Expand Up @@ -262,9 +262,9 @@ export const initCommand = buildCommand<
await resolveTarget(targetArg);

// 5. Start background org detection when org is not yet known.
// The prefetch runs concurrently with the preamble, the wizard startup,
// and all early suspend/resume rounds — by the time the wizard needs the
// org (inside createSentryProject), the result is already cached.
// The prefetch runs concurrently with the preamble, wizard startup,
// and early streamed action round-trips — by the time the wizard needs
// the org (inside createSentryProject), the result is already cached.
if (!explicitOrg) {
warmOrgDetection(targetDir);
}
Expand Down
14 changes: 5 additions & 9 deletions src/lib/init/constants.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
export const MASTRA_API_URL =
process.env.MASTRA_API_URL ??
"https://sentry-init-agent.getsentry.workers.dev";

export const WORKFLOW_ID = "sentry-wizard";
export const INIT_API_URL =
process.env.INIT_API_URL ?? "https://sentry-init-agent.getsentry.workers.dev";

export const SENTRY_DOCS_URL = "https://docs.sentry.io/platforms/";

export const MAX_FILE_BYTES = 262_144; // 256KB per file
export const MAX_OUTPUT_BYTES = 65_536; // 64KB stdout/stderr truncation
export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000; // 2 minutes
export const API_TIMEOUT_MS = 120_000; // 2 minutes timeout for Mastra API calls
export const API_TIMEOUT_MS = 120_000; // 2 minutes timeout for init API calls
export const STREAM_CONNECT_TIMEOUT_MS = 30_000; // 30 seconds to establish/re-establish the stream
export const MAX_STREAM_RECONNECTS = 8;

// Exit codes returned by the remote workflow
export const EXIT_PLATFORM_NOT_DETECTED = 20;
export const EXIT_DEPENDENCY_INSTALL_FAILED = 30;
export const EXIT_VERIFICATION_FAILED = 50;

// Step ID used in dry-run special-case logic
export const VERIFY_CHANGES_STEP = "verify-changes";

// The feature that is always included in every setup
export const REQUIRED_FEATURE = "errorMonitoring";
18 changes: 8 additions & 10 deletions src/lib/init/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
EXIT_PLATFORM_NOT_DETECTED,
EXIT_VERIFICATION_FAILED,
} from "./constants.js";
import type { WizardOutput, WorkflowRunResult } from "./types.js";
import type { InitErrorEvent, WizardOutput } from "./types.js";

type ChangedFile = NonNullable<WizardOutput["changedFiles"]>[number];

Expand Down Expand Up @@ -160,8 +160,7 @@ function buildSummary(output: WizardOutput): string {
return sections.join("\n\n");
}

export function formatResult(result: WorkflowRunResult): void {
const output: WizardOutput = result.result ?? {};
export function formatResult(output: WizardOutput): void {
const md = buildSummary(output);

if (md.length > 0) {
Expand All @@ -182,11 +181,10 @@ export function formatResult(result: WorkflowRunResult): void {
outro("Sentry SDK installed successfully!");
}

export function formatError(result: WorkflowRunResult): void {
const inner = result.result;
const message =
result.error ?? inner?.message ?? "Wizard failed with an unknown error";
const exitCode = inner?.exitCode ?? 1;
export function formatError(error: InitErrorEvent): void {
const output = error.output;
const message = error.message || "Wizard failed with an unknown error";
const exitCode = error.exitCode ?? output?.exitCode ?? 1;

log.error(String(message));

Expand All @@ -195,7 +193,7 @@ export function formatError(result: WorkflowRunResult): void {
"Hint: Could not detect your project's platform. Check that the directory contains a valid project."
);
} else if (exitCode === EXIT_DEPENDENCY_INSTALL_FAILED) {
const commands = inner?.commands;
const commands = error.commands ?? output?.commands;
if (commands?.length) {
log.warn(
`You can install dependencies manually:\n${commands.map((cmd) => ` $ ${cmd}`).join("\n")}`
Expand All @@ -205,7 +203,7 @@ export function formatError(result: WorkflowRunResult): void {
log.warn("Hint: Fix the verification issues and run 'sentry init' again.");
}

const docsUrl = inner?.docsUrl;
const docsUrl = error.docsUrl ?? output?.docsUrl;
if (docsUrl) {
log.info(`Docs: ${terminalLink(docsUrl)}`);
}
Expand Down
112 changes: 112 additions & 0 deletions src/lib/init/stream-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { InitEvent } from "./types.js";

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function isWizardOutput(value: unknown): boolean {
return isRecord(value);
}

/**
* Validate a streamed init event before the wizard runner consumes it.
*/
export function assertInitEvent(raw: unknown): InitEvent {
if (!isRecord(raw) || typeof raw.type !== "string") {
throw new Error("Invalid init event");
}

switch (raw.type) {
case "status":
if (typeof raw.message !== "string") {
throw new Error("Invalid status event");
}
return raw as InitEvent;
case "action_request":
if (
typeof raw.actionId !== "string" ||
(raw.kind !== "tool" && raw.kind !== "prompt") ||
typeof raw.name !== "string"
) {
throw new Error("Invalid action_request event");
}
return raw as InitEvent;
case "action_result":
if (typeof raw.actionId !== "string" || typeof raw.ok !== "boolean") {
throw new Error("Invalid action_result event");
}
return raw as InitEvent;
case "warning":
if (typeof raw.message !== "string") {
throw new Error("Invalid warning event");
}
return raw as InitEvent;
case "summary":
if (!isWizardOutput(raw.output)) {
throw new Error("Invalid summary event");
}
return raw as InitEvent;
case "error":
if (typeof raw.message !== "string") {
throw new Error("Invalid error event");
}
return raw as InitEvent;
case "done":
if (typeof raw.ok !== "boolean") {
throw new Error("Invalid done event");
}
return raw as InitEvent;
default:
throw new Error(`Unknown init event type: ${String(raw.type)}`);
}
}

/**
* Read the CLI progress stream as NDJSON and invoke `onEvent` for each typed event.
*/
export async function readNdjsonStream(
response: Response,
onEvent: (event: InitEvent) => Promise<void>
): Promise<number> {
if (!response.body) {
throw new Error("Init stream response had no body");
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventCount = 0;

try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}

buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");

while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line) {
await onEvent(assertInitEvent(JSON.parse(line)));
eventCount += 1;
}
newlineIndex = buffer.indexOf("\n");
}
}

buffer += decoder.decode();
const trailing = buffer.trim();
if (trailing) {
await onEvent(assertInitEvent(JSON.parse(trailing)));
eventCount += 1;
}
} finally {
reader.releaseLock();
}

return eventCount;
}
41 changes: 27 additions & 14 deletions src/lib/init/tools/apply-patchset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,27 +38,40 @@ export async function applyPatchset(
}

const applied: Array<{ path: string; action: string }> = [];
const failed: Array<{ path: string; action: string; error: string }> = [];

for (const patch of payload.params.patches) {
const absPath = safePath(payload.cwd, patch.path);
try {
const absPath = safePath(payload.cwd, patch.path);

if (patch.action === "modify") {
try {
await fs.promises.access(absPath);
} catch {
return {
ok: false,
error: `Cannot modify "${patch.path}": file does not exist`,
data: { applied },
};
if (patch.action === "modify") {
try {
await fs.promises.access(absPath);
} catch {
throw new Error(`Cannot modify "${patch.path}": file does not exist`);
}
}

await applySinglePatch(absPath, patch, context.authToken);
applied.push({ path: patch.path, action: patch.action });
} catch (error) {
failed.push({
path: patch.path,
action: patch.action,
error: error instanceof Error ? error.message : String(error),
});
}
}

await applySinglePatch(absPath, patch, context.authToken);
applied.push({ path: patch.path, action: patch.action });
if (failed.length > 0) {
return {
ok: false,
error: `Applied ${applied.length} of ${applied.length + failed.length} patches; ${failed.length} failed.`,
data: { applied, failed },
};
}

return { ok: true, data: { applied } };
return { ok: true, data: { applied, failed: [] } };
}

function applyPatchsetDryRun(payload: ApplyPatchsetPayload): ToolResult {
Expand All @@ -75,7 +88,7 @@ function applyPatchsetDryRun(payload: ApplyPatchsetPayload): ToolResult {
applied.push({ path: patch.path, action: patch.action });
}

return { ok: true, data: { applied } };
return { ok: true, data: { applied, failed: [] } };
}

async function applySinglePatch(
Expand Down
Loading
Loading