1import { PlatformServices } from "@/Util/PlatformServices.ts";
2import { describe, expect, it } from "alchemy-test";
3import * as Console from "effect/Console";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as Schema from "effect/Schema";
8import * as Stream from "effect/Stream";
9import * as ChildProcess from "effect/process/ChildProcess";
10
11const tarball = process.env.ALCHEMY_CLI_PACKAGE;
12const tarballDirectory = process.env.ALCHEMY_CLI_PACKAGES;
13const enabled = !!(tarball || tarballDirectory);
14const selectedManager = process.env.ALCHEMY_CLI_PACKAGE_MANAGER;
15const managers = ["npm", "pnpm", "bun"] as const;
16type Manager = (typeof managers)[number];
17
18interface Invocation {
19 readonly command: string;
20 readonly args: ReadonlyArray<string>;
21 readonly runtime: "bun" | "node";
22}
23
24const invocations = (manager: Manager): ReadonlyArray<Invocation> => [
25 {
26 command: "node",
27 args: ["node_modules/alchemy/bin/cli.js"],
28 runtime: "node",
29 },
30 { command: "bun", args: ["node_modules/alchemy/bin/cli.js"], runtime: "bun" },
31 { command: "bun", args: ["--bun", "alchemy"], runtime: "bun" },
32 {
33 command: "bun",
34 args: ["x", "--bun", "--no-install", "alchemy"],
35 runtime: "bun",
36 },
37 ...packageCommands[manager],
38];
39
40const packageCommands: Record<Manager, ReadonlyArray<Invocation>> = {
41 npm: [
42 { command: "npm", args: ["run", "cli", "--"], runtime: "node" },
43 {
44 command: "npm",
45 args: ["exec", "--offline", "--", "alchemy"],
46 runtime: "node",
47 },
48 { command: "npx", args: ["--no-install", "alchemy"], runtime: "node" },
49 ],
50 pnpm: [
51 { command: "pnpm", args: ["run", "cli"], runtime: "node" },
52 { command: "pnpm", args: ["exec", "alchemy"], runtime: "node" },
53 { command: "pnpm", args: ["alchemy"], runtime: "node" },
54 ],
55 bun: [
56 { command: "bun", args: ["run", "cli"], runtime: "bun" },
57 { command: "bun", args: ["run", "--bun", "cli"], runtime: "bun" },
58 { command: "bun", args: ["alchemy"], runtime: "bun" },
59 ],
60};
61
62const Probe = Schema.fromJsonString(
63 Schema.Struct({
64 runtime: Schema.String,
65 nodeEnv: Schema.String,
66 home: Schema.String,
67 credentialVariables: Schema.Array(Schema.String),
68 cwd: Schema.String,
69 entry: Schema.String,
70 alchemy: Schema.String,
71 args: Schema.Array(Schema.String),
72 }),
73);
74
75const canary = (manager: Manager) =>
76 Effect.gen(function* () {
77 const fs = yield* FileSystem.FileSystem;
78 const path = yield* Path.Path;
79 const packageDir = yield* path.fromFileUrl(
80 new URL("../../", import.meta.url),
81 );
82 const checkout = yield* fs.realPath(path.resolve(packageDir, "../.."));
83 const project = yield* fs.makeTempDirectoryScoped({
84 prefix: `alchemy-package-${manager}-`,
85 });
86 const projectRoot = yield* fs.realPath(project);
87 expect(projectRoot.startsWith(`${checkout}${path.sep}`)).toBe(false);
88
89 const dependencies: Record<string, string> = {};
90 for (const name of [
91 "effect",
92 "@effect/platform-bun",
93 "@effect/platform-node",
94 ]) {
95 const manifest = yield* fs.readFileString(
96 path.join(packageDir, "node_modules", name, "package.json"),
97 );
98 const { version } = yield* Schema.decodeUnknownEffect(
99 Schema.fromJsonString(Schema.Struct({ version: Schema.String })),
100 )(manifest);
101 dependencies[name] = version;
102 }
103 yield* fs.copyFile(
104 path.join(packageDir, "test/Cli/fixtures/package-stack.ts"),
105 path.join(project, "stack.run.ts"),
106 );
107 const inherited = yield* Effect.sync(() => process.env);
108 const home = path.join(projectRoot, "home");
109 yield* fs.makeDirectory(home);
110 for (const config of [".npmrc", "global.npmrc"]) {
111 yield* fs.writeFileString(path.join(home, config), "");
112 }
113 // Pass only runtime paths and temporary directories, never the caller's
114 // credentials, module hooks, registry configuration, or user profiles.
115 const env = {
116 CI: "true",
117 HOME: home,
118 USERPROFILE: home,
119 XDG_CONFIG_HOME: path.join(home, ".config"),
120 npm_config_userconfig: path.join(home, ".npmrc"),
121 npm_config_globalconfig: path.join(home, "global.npmrc"),
122 TMPDIR: inherited.TMPDIR,
123 TMP: inherited.TMP,
124 TEMP: inherited.TEMP,
125 SystemRoot: inherited.SystemRoot,
126 COMSPEC: inherited.COMSPEC,
127 PATHEXT: inherited.PATHEXT,
128 BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0",
129 ALCHEMY_HOME: path.join(home, ".alchemy"),
130 PATH: inherited.PATH?.split(process.platform === "win32" ? ";" : ":")
131 .filter((part) => !part.startsWith(checkout))
132 .join(process.platform === "win32" ? ";" : ":"),
133 };
134 const run = (
135 command: string,
136 args: ReadonlyArray<string>,
137 nodeEnv?: string,
138 installing = false,
139 ) =>
140 Effect.gen(function* () {
141 yield* Console.log(
142 `${manager}: ${command} ${args.join(" ")} (NODE_ENV=${nodeEnv ?? "unset"})`,
143 );
144 const handle = yield* ChildProcess.make(command, args, {
145 cwd: project,
146 env: { ...env, NODE_ENV: nodeEnv },
147 extendEnv: false,
148 stdin: "ignore",
149 stdout: "pipe",
150 stderr: "pipe",
151 forceKillAfter: "1 second",
152 });
153 const [stdout, stderr, exitCode] = yield* Effect.all(
154 [
155 handle.stdout.pipe(Stream.decodeText, Stream.mkString),
156 handle.stderr.pipe(Stream.decodeText, Stream.mkString),
157 handle.exitCode,
158 ],
159 { concurrency: 3 },
160 );
161 return { stdout, stderr, exitCode };
162 }).pipe(
163 Effect.scoped,
164 Effect.timeout(installing ? "90 seconds" : "20 seconds"),
165 );
166
167 const packedFiles = tarballDirectory
168 ? (yield* fs.readDirectory(path.resolve(tarballDirectory)))
169 .filter((name) => name.endsWith(".tgz"))
170 .map((name) => path.resolve(tarballDirectory, name))
171 : [path.resolve(tarball!)];
172 const overrides: Record<string, string> = {};
173 for (const packed of packedFiles) {
174 expect((yield* fs.stat(packed)).type).toBe("File");
175 const manifest = yield* run("tar", [
176 "-xOf",
177 packed,
178 "package/package.json",
179 ]);
180 expect(manifest.exitCode).toBe(0);
181 const { name } = yield* Schema.decodeUnknownEffect(
182 Schema.fromJsonString(Schema.Struct({ name: Schema.String })),
183 )(manifest.stdout);
184 dependencies[name] = `file:${packed}`;
185 if (name !== "alchemy") overrides[name] = dependencies[name];
186 }
187 expect(dependencies.alchemy).toBeDefined();
188 yield* fs.writeFileString(
189 path.join(project, "package.json"),
190 JSON.stringify({
191 name: "alchemy-cli-package-canary",
192 private: true,
193 type: "module",
194 scripts: { cli: "alchemy" },
195 dependencies,
196 overrides:
197 manager === "npm"
198 ? Object.fromEntries(
199 Object.keys(overrides).map((name) => [name, `$${name}`]),
200 )
201 : overrides,
202 }),
203 );
204 if (manager === "pnpm") {
205 yield* fs.writeFileString(
206 path.join(project, "pnpm-workspace.yaml"),
207 JSON.stringify({
208 allowBuilds: { esbuild: true, workerd: true },
209 overrides,
210 }),
211 );
212 }
213 const installed = yield* run(
214 manager,
215 manager === "npm"
216 ? ["install", "--no-audit", "--no-fund"]
217 : manager === "bun"
218 ? ["install", "--minimum-release-age=0"]
219 : ["install"],
220 undefined,
221 true,
222 );
223 if (installed.exitCode !== 0) {
224 yield* Console.error(installed.stdout + installed.stderr);
225 }
226 expect({
227 code: installed.exitCode,
228 output:
229 installed.exitCode === 0 ? "" : installed.stdout + installed.stderr,
230 }).toEqual({ code: 0, output: "" });
231 for (const name of Object.keys(dependencies)) {
232 const resolved = yield* fs.realPath(
233 path.join(project, "node_modules", name),
234 );
235 expect(resolved.startsWith(`${projectRoot}${path.sep}`)).toBe(true);
236 }
237 for (const scenario of [
238 { nodeEnv: undefined, jsx: undefined },
239 { nodeEnv: "development", jsx: "react-jsxdev" },
240 { nodeEnv: "production", jsx: "preserve" },
241 ]) {
242 if (scenario.jsx !== undefined) {
243 yield* fs.writeFileString(
244 path.join(project, "tsconfig.json"),
245 JSON.stringify({
246 compilerOptions: {
247 jsx: scenario.jsx,
248 ...(scenario.jsx === "preserve"
249 ? { jsxImportSource: "solid-js" }
250 : {}),
251 },
252 }),
253 );
254 }
255 for (const invocation of invocations(manager)) {
256 const args = ["deploy", "stack.run.ts", "--stage", "canary", "--yes"];
257 const result = yield* run(
258 invocation.command,
259 [...invocation.args, ...args],
260 scenario.nodeEnv,
261 );
262 expect({
263 code: result.exitCode,
264 output: result.exitCode === 0 ? "" : result.stdout + result.stderr,
265 }).toEqual({ code: 0, output: "" });
266 expect(result.stdout).toContain("Packed CLI progress");
267 expect(result.stdout).toContain("Packed CLI deployment complete");
268 const line = result.stdout
269 .split("\n")
270 .find((line) => line.startsWith("CLI_PACKAGE_PROBE="));
271 expect(line).toBeDefined();
272 const probe = yield* Schema.decodeUnknownEffect(Probe)(
273 line!.slice("CLI_PACKAGE_PROBE=".length),
274 );
275 expect(probe.runtime).toBe(invocation.runtime);
276 expect(probe.nodeEnv).toBe("production");
277 expect(probe.home).toBe(home);
278 expect(probe.credentialVariables).toEqual([]);
279 expect(probe.cwd).toBe(projectRoot);
280 expect(probe.args).toEqual(args);
281 const entry = yield* fs.realPath(probe.entry);
282 const imported = yield* fs.realPath(
283 yield* path.fromFileUrl(new URL(probe.alchemy)),
284 );
285 expect(
286 entry.startsWith(`${projectRoot}${path.sep}node_modules${path.sep}`),
287 ).toBe(true);
288 expect(
289 imported.startsWith(
290 `${projectRoot}${path.sep}node_modules${path.sep}`,
291 ),
292 ).toBe(true);
293 }
294 }
295 for (const invocation of invocations(manager)) {
296 const result = yield* run(
297 invocation.command,
298 [...invocation.args, "profile"],
299 "development",
300 );
301 expect(result.exitCode).toBe(1);
302 expect(result.stdout + result.stderr).not.toContain("jsxDEV");
303 }
304 const destroyed = yield* run("bun", [
305 "--bun",
306 "alchemy",
307 "destroy",
308 "stack.run.ts",
309 "--stage",
310 "canary",
311 "--yes",
312 ]);
313 expect({
314 code: destroyed.exitCode,
315 output:
316 destroyed.exitCode === 0 ? "" : destroyed.stdout + destroyed.stderr,
317 }).toEqual({ code: 0, output: "" });
318 }).pipe(Effect.scoped, Effect.provide(PlatformServices));
319
320describe.sequential(
321 "packed CLI outside the checkout",
322 { tags: ["live"] },
323 () => {
324 if (
325 enabled &&
326 selectedManager !== undefined &&
327 !managers.some((manager) => manager === selectedManager)
328 ) {
329 throw new Error(
330 `Unknown ALCHEMY_CLI_PACKAGE_MANAGER: ${selectedManager}`,
331 );
332 }
333 for (const manager of managers) {
334 it.live.skipIf(
335 !enabled ||
336 (selectedManager !== undefined && selectedManager !== manager),
337 )(
338 `installs with ${manager} and runs production CLI across runtimes and entrypoints`,
339 () => canary(manager),
340 { timeout: 120_000 },
341 );
342 }
343 },
344);