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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 67 additions & 42 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,31 @@ async function resolveTarget(targetArg: string | undefined): Promise<{
}
}

/**
* Schedule a forced `process.exit` for after the current error-handling
* chain unwinds. `sentry init` is a terminal command: Bun's global fetch
* dispatcher (MastraClient) and the `/dev/tty` stream opened by
* `forwardFreshTtyToStdin` keep the libuv loop alive, so a natural exit
* hangs the shell. Using `setImmediate` (rather than exiting inline)
* lets Stricli's `exceptionWhileRunningCommand` run first — it reports to
* Sentry, sets `process.exitCode`, and renders the error — and only then
* does this callback fire and terminate the process with that code.
* See https://github.com/getsentry/cli/issues/798.
*/
function scheduleForceExit(): void {
setImmediate(() => {
const code = process.exitCode;
if (typeof code === "number") {
process.exit(code);
}
if (typeof code === "string") {
const parsed = Number.parseInt(code, 10);
process.exit(Number.isNaN(parsed) ? 0 : parsed);
}
process.exit(0);
});
}

export const initCommand = buildCommand<
InitFlags,
[string?, string?],
Expand Down Expand Up @@ -239,52 +264,52 @@ export const initCommand = buildCommand<
first?: string,
second?: string
) {
// 1. Classify positionals into target vs directory
const { target: targetArg, directory: dirArg } = classifyArgs(
first,
second
);

// 2. Resolve directory
const targetDir = dirArg ? path.resolve(this.cwd, dirArg) : this.cwd;
try {
// 1. Classify positionals into target vs directory
const { target: targetArg, directory: dirArg } = classifyArgs(
first,
second
);

// 3. Parse features
const featuresList = flags.features
?.flatMap((f) => f.split(FEATURE_DELIMITER))
.map((f) => f.trim())
.filter(Boolean);
// 2. Resolve directory
const targetDir = dirArg ? path.resolve(this.cwd, dirArg) : this.cwd;

// 4. Resolve target → org + project
// Validation of user-provided slugs happens inside resolveTarget.
// For bare slugs, if no existing project is found, the slug becomes
// the name for a new project (org resolved later by the wizard).
const { org: explicitOrg, project: explicitProject } =
await resolveTarget(targetArg);
// 3. Parse features
const featuresList = flags.features
?.flatMap((f) => f.split(FEATURE_DELIMITER))
.map((f) => f.trim())
.filter(Boolean);

// 5. Start background org detection when org is not yet known.
// The prefetch runs concurrently with the preamble, the wizard startup,
// and all early suspend/resume rounds — by the time the wizard needs the
// org (inside createSentryProject), the result is already cached.
if (!explicitOrg) {
warmOrgDetection(targetDir);
}
// 4. Resolve target → org + project
// Validation of user-provided slugs happens inside resolveTarget.
// For bare slugs, if no existing project is found, the slug becomes
// the name for a new project (org resolved later by the wizard).
const { org: explicitOrg, project: explicitProject } =
await resolveTarget(targetArg);

// 6. Run the wizard
await runWizard({
directory: targetDir,
yes: flags.yes,
dryRun: flags["dry-run"],
features: featuresList,
team: flags.team,
org: explicitOrg,
project: explicitProject,
});
// 5. Start background org detection when org is not yet known.
// The prefetch runs concurrently with the preamble, the wizard startup,
// and all early suspend/resume rounds — by the time the wizard needs the
// org (inside createSentryProject), the result is already cached.
if (!explicitOrg) {
warmOrgDetection(targetDir);
}

// Force exit after the wizard completes. `sentry init` is a terminal
// command, and Bun's global fetch dispatcher (used by MastraClient) can
// hold keep-alive sockets open past the wizard, leaving the libuv loop
// alive and the shell appearing to hang. `process.exit` flushes stdio
// and releases those handles unconditionally.
process.exit(process.exitCode ?? 0);
// 6. Run the wizard
await runWizard({
directory: targetDir,
yes: flags.yes,
dryRun: flags["dry-run"],
features: featuresList,
team: flags.team,
org: explicitOrg,
project: explicitProject,
});
} finally {
// Fires on success AND failure. On error, the throw still propagates
// to Stricli, which reports + renders it before the scheduled exit
// runs. See scheduleForceExit() for the full rationale.
scheduleForceExit();
}
},
});
102 changes: 91 additions & 11 deletions test/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import path from "node:path";
import { initCommand } from "../../src/commands/init.js";
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
import * as projectsApi from "../../src/lib/api/projects.js";
import { ContextError, ValidationError } from "../../src/lib/errors.js";
import {
ApiError,
ContextError,
ValidationError,
WizardError,
} from "../../src/lib/errors.js";
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
import * as prefetchNs from "../../src/lib/init/org-prefetch.js";
import { resetPrefetch } from "../../src/lib/init/org-prefetch.js";
Expand Down Expand Up @@ -53,6 +58,15 @@ function makeContext(cwd = "/projects/app") {
};
}

/**
* Drain any pending `setImmediate` callbacks. The init command schedules
* a forced `process.exit` via `setImmediate` in its `finally` block; tests
* need to await it before asserting on `exitSpy`.
*/
function flushImmediates(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}

const DEFAULT_FLAGS = { yes: true, "dry-run": false } as const;

beforeEach(() => {
Expand All @@ -78,15 +92,18 @@ beforeEach(() => {
() => {}
);
// The init command force-exits after the wizard to release Bun's fetch
// keep-alive sockets (src/commands/init.ts). Tests call `func` directly,
// so without this stub `process.exit` would terminate the test runner
// mid-suite.
// keep-alive sockets (src/commands/init.ts). The exit is scheduled via
// setImmediate, so tests can still await func — the stub just prevents
// the scheduled callback from actually terminating the test runner.
exitSpy = spyOn(process, "exit").mockImplementation((() => {
// intentionally no-op — see comment above
}) as never);
});

afterEach(() => {
afterEach(async () => {
// Drain the force-exit setImmediate scheduled by init's `finally` so
// it doesn't fire during a later test and pollute exitSpy call counts.
await flushImmediates();
runWizardSpy.mockRestore();
findProjectsSpy.mockRestore();
warmSpy.mockRestore();
Expand Down Expand Up @@ -346,16 +363,16 @@ describe("init command func", () => {
describe("error cases", () => {
test("two paths throws ContextError", async () => {
const ctx = makeContext();
expect(func.call(ctx, DEFAULT_FLAGS, "./dir1", "./dir2")).rejects.toThrow(
ContextError
);
await expect(
func.call(ctx, DEFAULT_FLAGS, "./dir1", "./dir2")
).rejects.toThrow(ContextError);
});

test("two targets throws ContextError", async () => {
const ctx = makeContext();
expect(func.call(ctx, DEFAULT_FLAGS, "acme/", "other/")).rejects.toThrow(
ContextError
);
await expect(
func.call(ctx, DEFAULT_FLAGS, "acme/", "other/")
).rejects.toThrow(ContextError);
});

test("org slug with whitespace is rejected by validateResourceId", async () => {
Expand Down Expand Up @@ -446,4 +463,67 @@ describe("init command func", () => {
);
});
});

// ── Force-exit scheduling (issue #798) ────────────────────────────────
//
// `sentry init` schedules a `process.exit` via setImmediate in its
// `finally` block so the shell doesn't hang on Bun's fetch keep-alive
// sockets / forwarded /dev/tty after the command finishes. The error
// itself is left to Stricli's normal handler (app.ts:295) — init only
// ensures the process actually terminates.

describe("force-exit scheduling", () => {
test("schedules process.exit after successful wizard run", async () => {
exitSpy.mockClear();
const ctx = makeContext();
await func.call(ctx, DEFAULT_FLAGS);
await flushImmediates();
expect(exitSpy).toHaveBeenCalledWith(0);
});

test("schedules process.exit when the wizard throws", async () => {
runWizardSpy.mockImplementation(() =>
Promise.reject(new WizardError("boom", { rendered: true }))
);
exitSpy.mockClear();
const ctx = makeContext();
// Error still propagates — Stricli renders/reports it. We only
// verify init schedules the forced exit regardless.
await expect(func.call(ctx, DEFAULT_FLAGS)).rejects.toThrow(WizardError);
await flushImmediates();
expect(exitSpy).toHaveBeenCalled();
});

test("schedules process.exit on pre-wizard API failure", async () => {
findProjectsSpy.mockImplementation(() =>
Promise.reject(new ApiError("Forbidden", 403))
);
exitSpy.mockClear();
const ctx = makeContext();
await expect(
func.call(ctx, DEFAULT_FLAGS, "some-project")
).rejects.toThrow(ApiError);
expect(runWizardSpy).not.toHaveBeenCalled();
await flushImmediates();
expect(exitSpy).toHaveBeenCalled();
});

test("forwards Stricli-set exit code to process.exit", async () => {
runWizardSpy.mockImplementation(() =>
Promise.reject(new Error("network down"))
);
exitSpy.mockClear();
const ctx = makeContext();
await expect(func.call(ctx, DEFAULT_FLAGS)).rejects.toThrow(
"network down"
);
// Simulate what Stricli's exceptionWhileRunningCommand does after
// the func rejects — it sets process.exitCode before the setImmediate
// callback fires.
process.exitCode = 42;
await flushImmediates();
expect(exitSpy).toHaveBeenCalledWith(42);
process.exitCode = 0; // reset for subsequent tests
});
});
});
Loading