Skip to content

feat(init): rewrite wizard client for Vercel Workflow + Sandbox server#850

Draft
betegon wants to merge 4 commits intomainfrom
feat/init-vercel-workflow-client
Draft

feat(init): rewrite wizard client for Vercel Workflow + Sandbox server#850
betegon wants to merge 4 commits intomainfrom
feat/init-vercel-workflow-client

Conversation

@betegon
Copy link
Copy Markdown
Member

@betegon betegon commented Apr 27, 2026

Companion to getsentry/cli-init-api#114 — the server-side rewrite. Read the server PR first; this one focuses on the client side.

TL;DR

Replaces the old Mastra-driven CLI flow (wizard-runner.ts, workflow-inputs.ts) with a thin NDJSON consumer that talks to the new Nitro server. The CLI is now:

  • a stream consumer (NDJSON over GET /api/init/{runId}/stream),
  • a tool dispatcher (executes tool requests against the user's local FS / shell),
  • a renderer (clack prompts + spinner + final summary).

All AI work happens server-side. Feature selection moves from a hardcoded upfront prompt to an agent-driven propose_features call.

  • 21 files changed, +1,443 / -1,886 (net −443 LOC).
  • 63 init-related tests pass.

Glossary (reviewers new to this stack)

  • NDJSON stream: each line is one JSON event. The CLI fetches /api/init/{runId}/stream and dispatches events by type.
  • Action: a request from the workflow for the CLI to do something locally — execute a tool (list-dir, apply-patchset, …), prompt the user (multi-select, confirm), etc. Each action has a unique actionId; the CLI POSTs the result back to /api/init/actions/{actionId}.
  • Bridge: the inverse path. Tools running in the Vercel Sandbox bridge over to the CLI through the workflow. Reviewers don't need to think about it from the CLI side — just know that an action_request event on the stream means "run this locally and post the result back".

Architecture (CLI viewpoint)

sequenceDiagram
  participant User
  participant CLI as init-runner.ts
  participant Server as Nitro server
  participant Loop as stream + dispatch loop

  User->>CLI: sentry init [./dir] [--features ...]
  CLI->>CLI: preflight (auth, org, team, project, DSN)
  CLI->>Server: POST /api/init {input}
  Server-->>CLI: 202 {runId}
  CLI->>Server: GET /api/init/{runId}/stream

  loop NDJSON events
    Server-->>Loop: status / action_request / summary / done
    alt status
      Loop->>User: spinner.message(...)
    else action_request kind=tool
      Loop->>Loop: executeTool(name, params, cwd)
      Loop->>Server: POST /api/init/actions/{actionId} {ok, output}
    else action_request kind=prompt
      Loop->>User: clack multiselect / confirm
      Loop->>Server: POST /api/init/actions/{actionId} {features}
    else summary
      Loop->>Loop: state.finalOutput = output
    else done
      Loop->>User: formatResult / formatError
    end
  end
Loading

Key files

File Purpose
src/lib/init/init-runner.ts Entry runner: preflight → start → stream → dispatch. Replaces wizard-runner.ts.
src/lib/init/transport.ts fetch helpers: startInit, openInitStream, resumeInitAction.
src/lib/init/stream-parser.ts readNdjsonStream + zod-validated InitEvent types.
src/lib/init/interactive.ts Maps prompt_request events to clack multiselect / select / confirm.
src/lib/init/select-features.ts FEATURE_LABELS + normaliseFromFlag for the --features override. No upfront prompt anymore.
src/lib/init/ensure-project.ts Preflight: create / look up a Sentry project so the workflow gets a real DSN.
src/lib/init/types.ts InitEvent, InitStartInput, InitActionRequestEvent, etc. — wire-format types shared with the server.

Code snippets

1. Entry: starting the run

// src/lib/init/init-runner.ts
const context = await resolveInitContext(opts);                // auth + org + team + project
const project = await ensureSentryProject(context);            // get / create project + DSN
const overrideFeatures = normaliseFromFlag(context.features);  // optional --features override

const startInput: InitStartInput = {
  directory,
  yes,
  dryRun,
  ...(overrideFeatures.length > 0 ? { features: overrideFeatures } : {}),
  org: project.orgSlug,
  team: project.teamSlug,
  project: project.projectSlug,
  existingProject: { /* slug, id, dsn, url */ },
  sentryAuthToken: context.authToken,
  cliVersion: CLI_VERSION,
};

const started = await startInit(startInput, { baseUrl: INIT_API_URL });  // POST /api/init

There is no upfront feature prompt. The agent decides which features apply after inspecting the repo. --features remains an override for CI / non-interactive use.

2. The stream loop with reconnect

// src/lib/init/init-runner.ts
async function consumeStreamUntilTerminal({ runId, ... }) {
  let attempt = 0;
  while (true) {
    try {
      const stream = await openInitStream(runId, {
        baseUrl: INIT_API_URL,
        startIndex: state.nextStartIndex,    // resume mid-run after disconnects
      });
      for await (const event of readNdjsonStream(stream)) {
        await handleEvent(event, ...);
        if (event.type === \"done\") return;
      }
    } catch (err) {
      if (++attempt >= MAX_STREAM_RECONNECTS) throw err;
      await sleep(1000 * 2 ** attempt);    // exponential backoff
    }
  }
}

Bun/undici's fetch body has an idle timeout that kills long-lived streams. We accept that and reconnect at the same startIndex so we resume mid-run without dropping events.

3. Dispatching an action

// src/lib/init/init-runner.ts
async function handleEvent(event: InitEvent, ...) {
  state.nextStartIndex += 1;

  if (event.type === \"action_request\") {
    const result = event.kind === \"tool\"
      ? await executeTool(event.name, event.payload, cwd)        // existing CLI tools
      : await handleInteractive(event.payload, { yes, dryRun }); // clack prompts

    await resumeInitAction(event.actionId, { ok: true, output: result });
  }
  if (event.type === \"summary\") state.finalOutput = event.output;
  if (event.type === \"done\")    state.done = event;
}

executeTool reuses the existing .cli/ tools (read, glob, run-command, apply-patchset, etc.) so the agent's MCP calls map 1:1 to functions we already trust.

4. The agent-driven feature picker (no upfront prompt)

// src/lib/init/interactive.ts (multi-select handler)
async function handleMultiSelect(payload: MultiSelectPayload, options: InteractiveContext) {
  const available = payload.availableFeatures ?? [];
  const optional = sortFeatures(available.filter((f) => f !== REQUIRED_FEATURE));

  const selected = await multiselect({
    message: `${payload.prompt}\\n${hints.join(\"\\n\")}`,
    options: optional.map((id) => ({
      value: id,
      label: FEATURE_LABELS[id]?.label ?? id,
      hint: FEATURE_LABELS[id]?.hint,
    })),
    required: false,
  });
  // ...
}

The availableFeatures list comes from the server's propose_features MCP call — the agent has already filtered features that don't apply (e.g. no Session Replay on a server-only Node app). The CLI just renders labels.

UX before / after

Before (PR base)

◆ Select Sentry features to enable. Error monitoring is always on.
│  ◻ Performance Monitoring (Tracing)
│  ◻ Logging
│  ◻ Session Replay              ← shown even if user has no UI
│  ◻ Profiling
│  ◻ AI Agent Monitoring         ← shown even with no AI deps
│  ◻ User Feedback               ← shown even on a server-only app
│  ◻ Source Maps
│  ◻ Cron Monitoring             ← shown even with no scheduled jobs

The agent then has to ignore the user's irrelevant picks.

After (this PR)

◇ Connecting to wizard...
◇ Detecting platform...
◇ Researching Sentry docs...

◆ Select the Sentry features to enable for this project. Error monitoring is always on.
│  Error monitoring is always included
│  space=toggle, a=all, enter=confirm
│  ◻ Performance Monitoring (Tracing)
│  ◻ Logging
│  ◻ Session Replay
│  ◻ Profiling
│  ◻ User Feedback
│  ◻ Source Maps

Only the features that actually apply to a Next.js app appear. AI Monitoring and Cron Monitoring are absent because the agent didn't see signals for them.

Removed

  • src/lib/init/wizard-runner.ts (623 lines) — the old Mastra/D1-aware runner.
  • src/lib/init/workflow-inputs.ts (152 lines) — Mastra-specific input shaping.
  • test/lib/init/wizard-runner.test.ts (634 lines) — covered the old runner.

Added

  • src/lib/init/init-runner.ts (512 lines) — new entry runner.
  • src/lib/init/transport.ts (224 lines) — fetch + NDJSON helpers.
  • src/lib/init/stream-parser.ts (111 lines) — readNdjsonStream + zod-validated events.
  • src/lib/init/select-features.ts (151 lines) — labels + flag parser.
  • src/lib/init/ensure-project.ts (135 lines) — preflight project creation.
  • test/lib/init/select-features.test.ts (76 lines) — flag parsing + label rendering.
  • New cases in test/lib/init/interactive.test.ts for the agent-driven multi-select.

How to run locally

Requires a running server (see getsentry/cli-init-api#114 for setup).

SENTRY_INIT_API_URL=http://localhost:3000 bun run /Users/bete/code/cli/src/bin.ts init ./path/to/project

Test plan

  • bunx tsc --noEmit clean.
  • bun test test/lib/init/ test/commands/init.test.ts green (63 tests).
  • Local e2e against a Next.js project: confirm no upfront feature prompt, the analysis-then-pick UX shows up, the run completes with a green summary.
  • Re-run with --features tracing,logs and confirm the multiselect is skipped.
  • Disconnect (Wi-Fi off / Bun fetch idle timeout) and confirm the stream auto-reconnects mid-run.

Made with Cursor

betegon and others added 3 commits April 26, 2026 17:00
Replaces the Mastra-suspend-resume client with a thin NDJSON-stream
client that talks to the new Nitro+Hono server in `cli-init-api`.

- Drop `wizard-runner.ts` + `workflow-inputs.ts` + `@mastra/client-js`.
- New `init-runner.ts`: preflight (banner, git, org/team/project,
  features) -> POST /api/init -> consume stream with reconnect +
  startIndex (handles Bun fetch idle-body timeouts).
- New `transport.ts`, `stream-parser.ts`, `interactive.ts` ported from
  the bailing-cli prototype.
- New `ensure-project.ts` and `select-features.ts` so the workflow
  receives a complete `{org, team, project, dsn, features}` upfront.
- Existing tool registry (`lib/init/tools/`) is reused as-is; the
  server addresses operations by the same canonical names.
- `formatters.ts`: take `WizardOutput` / `InitErrorEvent` directly
  (no `WorkflowRunResult` indirection).
- Rename `MASTRA_API_URL` -> `INIT_API_URL` (still reads
  `SENTRY_INIT_API_URL` for back-compat).

Test: `bun test test/commands/init.test.ts` updated to spy on
`init-runner.runInit` instead of the deleted `wizard-runner`.

Made-with: Cursor
The CLI no longer prompts the user with a hardcoded list of 8 Sentry
features before the workflow even starts. The sandboxed agent
analyses the project + docs, calls a new propose_features MCP tool
on the server with only the relevant subset, and the CLI renders
that tailored multiselect via the existing prompt_request bridge.

- init-runner: drop the upfront selectFeatures() call. --features
  flag still works as a non-interactive override (CI / --yes); when
  provided it's normalised via normaliseFromFlag and sent on
  InitStartInput so the agent skips its own proposal.
- select-features: keep FEATURE_LABELS, sortFeatures, and
  normaliseFromFlag as a labels-and-flag module (no more upfront
  prompt). Drop selectFeatures(). Add a few alternate aliases and
  the metrics label.
- interactive: multi-select handler now looks up FEATURE_LABELS for
  label + hint, sorts the agent-proposed IDs into canonical display
  order, and renders the agent's prompt body verbatim.
- clack-utils: STEP_LABELS keys on propose-features (matches the
  new bridge action name).
- docs: describe the analyze-then-pick flow + --features override.
- tests: new select-features.test.ts covering normaliseFromFlag /
  sortFeatures / FEATURE_LABELS; extended interactive.test.ts with
  rendering + sorting cases for the propose-features flow.

Made-with: Cursor
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 27, 2026

PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://cli.sentry.dev/_preview/pr-850/

Built to branch gh-pages at 2026-04-27 15:15 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

Comment thread src/lib/init/init-runner.ts Outdated
EXIT_VERIFICATION_FAILED,
} from "./constants.js";
import type { WizardOutput, WorkflowRunResult } from "./types.js";
import type { InitErrorEvent, WizardOutput } from "./types.js";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary formatter uses stale feature label map

Medium Severity

formatters.ts imports featureLabel from clack-utils.ts, which uses the old FEATURE_INFO map. That map lacks the new canonical tracing ID introduced by select-features.ts. If the workflow summary includes tracing in its features list, the final output renders the raw string "tracing" instead of "Performance Monitoring (Tracing)". The interactive prompt in interactive.ts already switched to FEATURE_LABELS from select-features.ts, creating an inconsistency between the picker and the summary.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 33a8892. Configure here.

Comment thread src/lib/init/transport.ts
message: text || response.statusText,
retryable: false,
};
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Response body consumed twice in error reader

Low Severity

readErrorPayload calls response.json() when the content type is JSON. If that succeeds but the parsed payload lacks an error string field, the code falls through to response.text(). Since the body stream was already consumed by json(), text() returns an empty string, causing the error message to degrade to the generic response.statusText. This loses any useful error details the server may have sent in a different JSON shape.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 33a8892. Configure here.

Comment thread src/lib/init/init-runner.ts Outdated
Comment thread src/lib/init/init-runner.ts
Comment thread src/lib/init/transport.ts
Two issues kept biting long wizard runs:

1. The agent's first turn always burned a bridge round-trip on
   list_dir + read_files of common config files, which was visible
   to the user as "Listing ." pinning the spinner for ~3 minutes
   when the agent's first read happened to coincide with a
   workflow replay.

2. Idle reconnects after a Cloudflare 524 / dropped stream would
   either retry forever with no backoff or die on the first 404,
   depending on which path tripped first.

Project-context preflight (workflow-inputs.ts):
- Local snapshot of the working directory listing, common config
  files (package.json, tsconfig.json, next.config.*, etc.), and
  Sentry presence detection.
- Sent on InitStartInput.projectContext and inlined into the agent
  user prompt so phase 1 starts with everything it needs.
- Capped to MAX_FILE_BYTES per file to keep the start payload small.

Status-aware reconnection (init-runner.ts, transport.ts, constants.ts):
- consumeStream / handleStreamClosure / resumeRun mirror the
  birthday-card-generator example: fetch run status before reopening
  the stream, count failures against MAX_STATUS_FAILURES, cap
  exponential backoff at MAX_RECONNECT_DELAY_MS.
- openInitStream failures fall back into handleStreamClosure rather
  than throwing immediately, so a transient 5xx during the agent's
  long-running tool calls no longer kills the whole run.
- stream-parser accepts heartbeat events from the new server-side
  NDJSON heartbeat.

Drops the upfront features multiselect — the agent now drives that
through the propose_features tool — and threads --features through
as an override instead.

Made-with: Cursor
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7499355. Configure here.

// Stream errored mid-read (idle timeout / network blip). Same
// recovery as a clean close: ask the run for its status and let
// `handleStreamClosure` decide.
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream catch block swallows user cancellation errors

High Severity

The bare catch {} in consumeStream swallows all errors from readNdjsonStream, including WizardCancelledError. When a user presses Ctrl+C during an interactive prompt, the cancellation error is correctly re-thrown through performActionRequest and handleEvent, but then silently caught here. Execution falls through to handleStreamClosure, which sees no terminal state and reconnects — effectively ignoring the user's cancellation. The catch needs to re-throw WizardCancelledError before falling through to reconnect logic.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7499355. Configure here.

Comment on lines +109 to +111
const ALIASES: Record<string, SelectableFeatureId> = {
errors: "tracing", // backward-compat
performance: "tracing",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The --features errors flag incorrectly enables tracing (performance monitoring) due to a backward-compatibility alias, instead of being a no-op for the already-implicit errorMonitoring.
Severity: MEDIUM

Suggested Fix

Remove the alias mapping errors to tracing. The normaliseFromFlag function should be updated to either silently ignore the errors feature flag or warn the user that the flag is redundant. This would prevent the silent, unintended activation of the performance tracing feature.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/lib/init/select-features.ts#L109-L111

Potential issue: The `ALIASES` constant in `select-features.ts` incorrectly maps the
`errors` feature to `tracing`. When a user provides the `--features errors` flag,
intending to configure error monitoring, the system silently enables performance tracing
instead. This occurs because the `normaliseFromFlag` function resolves `errors` to
`tracing`. Since error monitoring is always implicitly enabled, the `errors` flag should
ideally be a no-op or trigger a warning, not enable an unrelated feature like
performance tracing.

@betegon betegon marked this pull request as draft April 27, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant