diff --git a/src/commands/project.ts b/src/commands/project.ts index 2cfc0088..5e837799 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -3995,11 +3995,18 @@ function renderHackFolderReadme(opts: { async function requireProjectContext(startDir: string) { const ctx = await findProjectContext(startDir); if (!ctx) { - throw new Error( + throw new MissingProjectContextError(); + } + return ctx; +} + +class MissingProjectContextError extends Error { + constructor() { + super( `No ${HACK_PROJECT_DIR_PRIMARY}/ (or legacy .dev/) found. Run: hack init` ); + this.name = "MissingProjectContextError"; } - return ctx; } type RemoteLifecycleAction = "up" | "down" | "restart"; @@ -4116,11 +4123,20 @@ async function handleUp({ readonly ctx: CliContext; readonly args: UpArgs; }): Promise { - const project = await resolveProjectForArgs({ - ctx, - pathOpt: args.options.path, - projectOpt: args.options.project, - }); + let project: Awaited>; + try { + project = await resolveProjectForArgs({ + ctx, + pathOpt: args.options.path, + projectOpt: args.options.project, + }); + } catch (error: unknown) { + if (error instanceof MissingProjectContextError) { + logger.error({ message: error.message }); + return 1; + } + throw error; + } const detach = args.options.detach; const branch = resolveBranchSlug(args.options.branch); const profiles = parseCsvList(args.options.profile); diff --git a/tests/project-up-command.test.ts b/tests/project-up-command.test.ts new file mode 100644 index 00000000..e462f3a9 --- /dev/null +++ b/tests/project-up-command.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +type CapturedRunResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +let tempDir: string | null = null; +let originalSetupSyncMode: string | undefined; +let originalLogger: string | undefined; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "hack-up-missing-project-")); + originalSetupSyncMode = process.env.HACK_SETUP_SYNC_MODE; + originalLogger = process.env.HACK_LOGGER; + process.env.HACK_SETUP_SYNC_MODE = "off"; + process.env.HACK_LOGGER = "console"; +}); + +afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + if (originalSetupSyncMode !== undefined) { + process.env.HACK_SETUP_SYNC_MODE = originalSetupSyncMode; + } else { + process.env.HACK_SETUP_SYNC_MODE = undefined; + } + if (originalLogger !== undefined) { + process.env.HACK_LOGGER = originalLogger; + } else { + process.env.HACK_LOGGER = undefined; + } +}); + +test("up without .hack prints a user message without stack trace", async () => { + if (!tempDir) { + throw new Error("Missing temp directory"); + } + + const result = await runCliWithCapturedOutput(["up", "--path", tempDir]); + + expect(result.exitCode).toBe(1); + + const combinedOutput = `${result.stdout}\n${result.stderr}`; + expect(combinedOutput).toContain( + "No .hack/ (or legacy .dev/) found. Run: hack init" + ); + expect(combinedOutput).not.toContain("at requireProjectContext"); + expect(combinedOutput).not.toContain("at async handleUp"); + expect(combinedOutput).not.toContain("ERROR Error:"); +}); + +test("up still reports unrelated usage errors", async () => { + const result = await runCliWithCapturedOutput([ + "up", + "--definitely-not-a-real-flag", + ]); + + expect(result.exitCode).toBe(1); + const combinedOutput = `${result.stdout}\n${result.stderr}`; + expect(combinedOutput).toContain("Unknown option"); + expect(combinedOutput).toContain("--definitely-not-a-real-flag"); + expect(combinedOutput).toContain("Usage:"); +}); + +async function runCliWithCapturedOutput( + args: readonly string[] +): Promise { + let stdout = ""; + let stderr = ""; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as typeof process.stdout.write; + + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as typeof process.stderr.write; + + try { + const { runCli } = await import("../src/cli/run.ts"); + const exitCode = await runCli(args); + return { exitCode, stdout, stderr }; + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + } +}