Skip to content

Commit 081f358

Browse files
authored
Merge pull request #1 from rhymiz/codex/fix-up-missing-hack-stack
fix(project): suppress missing .hack stack trace for hack up
2 parents 78f3590 + ced1f59 commit 081f358

2 files changed

Lines changed: 123 additions & 7 deletions

File tree

src/commands/project.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3995,11 +3995,18 @@ function renderHackFolderReadme(opts: {
39953995
async function requireProjectContext(startDir: string) {
39963996
const ctx = await findProjectContext(startDir);
39973997
if (!ctx) {
3998-
throw new Error(
3998+
throw new MissingProjectContextError();
3999+
}
4000+
return ctx;
4001+
}
4002+
4003+
class MissingProjectContextError extends Error {
4004+
constructor() {
4005+
super(
39994006
`No ${HACK_PROJECT_DIR_PRIMARY}/ (or legacy .dev/) found. Run: hack init`
40004007
);
4008+
this.name = "MissingProjectContextError";
40014009
}
4002-
return ctx;
40034010
}
40044011

40054012
type RemoteLifecycleAction = "up" | "down" | "restart";
@@ -4116,11 +4123,20 @@ async function handleUp({
41164123
readonly ctx: CliContext;
41174124
readonly args: UpArgs;
41184125
}): Promise<number> {
4119-
const project = await resolveProjectForArgs({
4120-
ctx,
4121-
pathOpt: args.options.path,
4122-
projectOpt: args.options.project,
4123-
});
4126+
let project: Awaited<ReturnType<typeof requireProjectContext>>;
4127+
try {
4128+
project = await resolveProjectForArgs({
4129+
ctx,
4130+
pathOpt: args.options.path,
4131+
projectOpt: args.options.project,
4132+
});
4133+
} catch (error: unknown) {
4134+
if (error instanceof MissingProjectContextError) {
4135+
logger.error({ message: error.message });
4136+
return 1;
4137+
}
4138+
throw error;
4139+
}
41244140
const detach = args.options.detach;
41254141
const branch = resolveBranchSlug(args.options.branch);
41264142
const profiles = parseCsvList(args.options.profile);

tests/project-up-command.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { afterEach, beforeEach, expect, test } from "bun:test";
2+
import { mkdtemp, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
type CapturedRunResult = {
7+
readonly exitCode: number;
8+
readonly stdout: string;
9+
readonly stderr: string;
10+
};
11+
12+
let tempDir: string | null = null;
13+
let originalSetupSyncMode: string | undefined;
14+
let originalLogger: string | undefined;
15+
16+
beforeEach(async () => {
17+
tempDir = await mkdtemp(join(tmpdir(), "hack-up-missing-project-"));
18+
originalSetupSyncMode = process.env.HACK_SETUP_SYNC_MODE;
19+
originalLogger = process.env.HACK_LOGGER;
20+
process.env.HACK_SETUP_SYNC_MODE = "off";
21+
process.env.HACK_LOGGER = "console";
22+
});
23+
24+
afterEach(async () => {
25+
if (tempDir) {
26+
await rm(tempDir, { recursive: true, force: true });
27+
tempDir = null;
28+
}
29+
if (originalSetupSyncMode !== undefined) {
30+
process.env.HACK_SETUP_SYNC_MODE = originalSetupSyncMode;
31+
} else {
32+
process.env.HACK_SETUP_SYNC_MODE = undefined;
33+
}
34+
if (originalLogger !== undefined) {
35+
process.env.HACK_LOGGER = originalLogger;
36+
} else {
37+
process.env.HACK_LOGGER = undefined;
38+
}
39+
});
40+
41+
test("up without .hack prints a user message without stack trace", async () => {
42+
if (!tempDir) {
43+
throw new Error("Missing temp directory");
44+
}
45+
46+
const result = await runCliWithCapturedOutput(["up", "--path", tempDir]);
47+
48+
expect(result.exitCode).toBe(1);
49+
50+
const combinedOutput = `${result.stdout}\n${result.stderr}`;
51+
expect(combinedOutput).toContain(
52+
"No .hack/ (or legacy .dev/) found. Run: hack init"
53+
);
54+
expect(combinedOutput).not.toContain("at requireProjectContext");
55+
expect(combinedOutput).not.toContain("at async handleUp");
56+
expect(combinedOutput).not.toContain("ERROR Error:");
57+
});
58+
59+
test("up still reports unrelated usage errors", async () => {
60+
const result = await runCliWithCapturedOutput([
61+
"up",
62+
"--definitely-not-a-real-flag",
63+
]);
64+
65+
expect(result.exitCode).toBe(1);
66+
const combinedOutput = `${result.stdout}\n${result.stderr}`;
67+
expect(combinedOutput).toContain("Unknown option");
68+
expect(combinedOutput).toContain("--definitely-not-a-real-flag");
69+
expect(combinedOutput).toContain("Usage:");
70+
});
71+
72+
async function runCliWithCapturedOutput(
73+
args: readonly string[]
74+
): Promise<CapturedRunResult> {
75+
let stdout = "";
76+
let stderr = "";
77+
const originalStdoutWrite = process.stdout.write;
78+
const originalStderrWrite = process.stderr.write;
79+
80+
process.stdout.write = ((chunk: string | Uint8Array) => {
81+
stdout +=
82+
typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
83+
return true;
84+
}) as typeof process.stdout.write;
85+
86+
process.stderr.write = ((chunk: string | Uint8Array) => {
87+
stderr +=
88+
typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
89+
return true;
90+
}) as typeof process.stderr.write;
91+
92+
try {
93+
const { runCli } = await import("../src/cli/run.ts");
94+
const exitCode = await runCli(args);
95+
return { exitCode, stdout, stderr };
96+
} finally {
97+
process.stdout.write = originalStdoutWrite;
98+
process.stderr.write = originalStderrWrite;
99+
}
100+
}

0 commit comments

Comments
 (0)