1import { PlatformServices } from "@/Util/PlatformServices.ts";
2import { describe, expect, it } from "alchemy-test";
3import { nodePath, nodeSupportsDevMode } from "../nodeProbe.ts";
4import * as Effect from "effect/Effect";
5import * as Stream from "effect/Stream";
6import * as FileSystem from "effect/FileSystem";
7import * as ChildProcess from "effect/process/ChildProcess";
8import { fileURLToPath } from "node:url";
9
10const CLI = fileURLToPath(new URL("../../bin/cli.js", import.meta.url));
11
12/**
13 * Run the CLI with no TTY on any stdio and return its exit code. Each
14 * invocation is a real `bun bin/cli.js` child with `ALCHEMY_HOME` pointed at
15 * a throwaway directory, so these pin the exit-code contract scripts and
16 * agents rely on — 0 only when the command completed — without touching the
17 * real `~/.alchemy`.
18 */
19const exitCodeOf = (
20 args: ReadonlyArray<string>,
21 env: Record<string, string> = {},
22 runtime = "bun",
23) =>
24 Effect.gen(function* () {
25 const fs = yield* FileSystem.FileSystem;
26 const home = yield* fs.makeTempDirectoryScoped({
27 prefix: "alchemy-exit-codes-",
28 });
29 const handle = yield* ChildProcess.make(runtime, [CLI, ...args], {
30 env: { ALCHEMY_HOME: home, ...env },
31 extendEnv: true,
32 stdin: "ignore",
33 stdout: "ignore",
34 stderr: "ignore",
35 killSignal: "SIGTERM",
36 forceKillAfter: "1 second",
37 });
38 return yield* handle.exitCode;
39 }).pipe(Effect.scoped, Effect.provide(PlatformServices));
40
41/** Like {@link exitCodeOf}, but from an empty project directory with stderr captured. */
42const runInEmptyProject = (args: ReadonlyArray<string>, runtime = "bun") =>
43 Effect.gen(function* () {
44 const fs = yield* FileSystem.FileSystem;
45 const home = yield* fs.makeTempDirectoryScoped({
46 prefix: "alchemy-exit-codes-",
47 });
48 const project = yield* fs.makeTempDirectoryScoped({
49 prefix: "alchemy-empty-project-",
50 });
51 const handle = yield* ChildProcess.make(runtime, [CLI, ...args], {
52 cwd: project,
53 env: { ALCHEMY_HOME: home },
54 extendEnv: true,
55 stdin: "ignore",
56 stdout: "ignore",
57 stderr: "pipe",
58 killSignal: "SIGTERM",
59 forceKillAfter: "1 second",
60 });
61 const [stderr, exitCode] = yield* Effect.all(
62 [handle.stderr.pipe(Stream.decodeText, Stream.mkString), handle.exitCode],
63 { concurrency: 2 },
64 );
65 return { stderr, exitCode };
66 }).pipe(Effect.scoped, Effect.provide(PlatformServices));
67
68describe("CLI exit codes", { tags: ["unit", "local"] }, () => {
69 it.live("dev without a stack entrypoint reports it and exits 1", () =>
70 Effect.gen(function* () {
71 const { stderr, exitCode } = yield* runInEmptyProject(["dev"]);
72 expect(exitCode).toBe(1);
73 expect(stderr).toContain(
74 "Stack entrypoint 'alchemy.run.ts' does not exist",
75 );
76 expect(stderr).not.toContain("PlatformError");
77 expect(stderr).not.toContain("at Effect.fn");
78 }),
79 );
80
81 it.live.skipIf(!nodeSupportsDevMode)(
82 "dev without a stack entrypoint reports it and exits 1 under node",
83 () =>
84 Effect.gen(function* () {
85 const { stderr, exitCode } = yield* runInEmptyProject(
86 ["dev"],
87 nodePath!,
88 );
89 expect(exitCode).toBe(1);
90 expect(stderr).toContain(
91 "Stack entrypoint 'alchemy.run.ts' does not exist",
92 );
93 expect(stderr).not.toContain("PlatformError");
94 }),
95 );
96
97 it.live("plan accepts --adopt like deploy --dry-run", () =>
98 Effect.gen(function* () {
99 const { stderr, exitCode } = yield* runInEmptyProject([
100 "plan",
101 "--adopt",
102 ]);
103 expect(exitCode).toBe(1);
104 expect(stderr).not.toContain("Unrecognized flag");
105 expect(stderr).toContain(
106 "Stack entrypoint 'alchemy.run.ts' does not exist",
107 );
108 }),
109 );
110
111 it.live("bare `profile` without a terminal prints help and exits 1", () =>
112 Effect.gen(function* () {
113 expect(yield* exitCodeOf(["profile"])).toBe(1);
114 }),
115 );
116
117 it.live("bare `state` without a terminal prints help and exits 1", () =>
118 Effect.gen(function* () {
119 expect(yield* exitCodeOf(["state"])).toBe(1);
120 }),
121 );
122
123 it.live("--no-input is accepted and forces a plain, working run", () =>
124 Effect.gen(function* () {
125 expect(yield* exitCodeOf(["--no-input", "profile", "list"])).toBe(0);
126 }),
127 );
128
129 it.live("--help exits 0", () =>
130 Effect.gen(function* () {
131 expect(yield* exitCodeOf(["--help"])).toBe(0);
132 }),
133 );
134
135 // Pins buildless node dev: the launcher must run the checkout's .ts/.tsx
136 // source under plain node (type stripping + the register-dev-mode hooks)
137 // — --help renders the TSX help view through the full terminal runtime.
138 // Stderr is captured: this is the one test in the file whose failure
139 // mode is environmental, so "expected 9 to be 0" alone is useless.
140 it.live.skipIf(!nodeSupportsDevMode)(
141 "--help exits 0 under node from source, no build required",
142 () =>
143 Effect.gen(function* () {
144 const fs = yield* FileSystem.FileSystem;
145 const home = yield* fs.makeTempDirectoryScoped({
146 prefix: "alchemy-exit-codes-",
147 });
148 const handle = yield* ChildProcess.make(nodePath!, [CLI, "--help"], {
149 // The test runner is bun and marks every child's env as
150 // bun-invoked; blank the markers so the launcher takes its
151 // node path like a real `node bin/cli.js` invocation.
152 env: {
153 ALCHEMY_HOME: home,
154 npm_execpath: "",
155 npm_config_user_agent: "",
156 },
157 extendEnv: true,
158 stdin: "ignore",
159 stdout: "ignore",
160 stderr: "pipe",
161 killSignal: "SIGTERM",
162 forceKillAfter: "1 second",
163 });
164 const [stderr, exitCode] = yield* Effect.all(
165 [
166 handle.stderr.pipe(
167 Stream.decodeText,
168 Stream.runCollect,
169 Effect.map((chunks) => chunks.join("")),
170 ),
171 handle.exitCode,
172 ],
173 { concurrency: 2 },
174 );
175 expect({
176 exitCode,
177 stderr: exitCode === 0 ? "" : stderr,
178 }).toEqual({ exitCode: 0, stderr: "" });
179 }).pipe(Effect.scoped, Effect.provide(PlatformServices)),
180 );
181
182 it.live("provider check-env exits 1 when a required var is missing", () =>
183 Effect.gen(function* () {
184 expect(
185 yield* exitCodeOf(["provider", "check-env", "--provider", "neon"], {
186 NEON_API_KEY: "",
187 }),
188 ).toBe(1);
189 }),
190 );
191
192 it.live("provider check-env exits 0 when the contract is satisfied", () =>
193 Effect.gen(function* () {
194 expect(
195 yield* exitCodeOf(["provider", "check-env", "--provider", "neon"], {
196 NEON_API_KEY: "napi_test_key",
197 }),
198 ).toBe(0);
199 }),
200 );
201
202 it.live("provider check-env accepts an explicit profile", () =>
203 Effect.gen(function* () {
204 expect(
205 yield* exitCodeOf(
206 [
207 "provider",
208 "check-env",
209 "--profile",
210 "default",
211 "--provider",
212 "neon",
213 ],
214 { NEON_API_KEY: "napi_test_key" },
215 ),
216 ).toBe(0);
217 }),
218 );
219});