1import * as Effect from "effect/Effect";
2import * as Exit from "effect/Exit";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as YAML from "yaml";
6
7/**
8 * Resolve `catalog:` dependency specifiers in a cloned fixture's
9 * `package.json` to the concrete versions pinned in the workspace's
10 * `pnpm-workspace.yaml`. Fixtures keep `catalog:` refs so versions stay
11 * single-sourced, but a clone that installs OUTSIDE the workspace (e.g.
12 * the Nextjs fixture, cloned to the OS temp dir so OpenNext's output
13 * tracing sees a plain node_modules tree) has no workspace context —
14 * `bun install` fails on every `catalog:` ref without this rewrite.
15 */
16const resolveCatalogSpecifiers = Effect.fn(function* (dir: string) {
17 const fs = yield* FileSystem.FileSystem;
18 const path = yield* Path.Path;
19 const manifestPath = path.join(dir, "package.json");
20 if (!(yield* fs.exists(manifestPath))) {
21 return;
22 }
23 // Fixture.ts lives at packages/alchemy/test/Cloudflare/Utils/ — the
24 // workspace root (and its pnpm-workspace.yaml) is five levels up.
25 const workspaceYaml = path.join(
26 import.meta.dirname,
27 "../../../../..",
28 "pnpm-workspace.yaml",
29 );
30 const workspace = YAML.parse(yield* fs.readFileString(workspaceYaml)) as {
31 catalog?: Record<string, string>;
32 catalogs?: Record<string, Record<string, string>>;
33 };
34 const manifest = JSON.parse(yield* fs.readFileString(manifestPath)) as {
35 [section: string]: unknown;
36 };
37 let changed = false;
38 for (const section of [
39 "dependencies",
40 "devDependencies",
41 "peerDependencies",
42 "optionalDependencies",
43 ]) {
44 const deps = manifest[section] as Record<string, string> | undefined;
45 if (!deps) continue;
46 for (const [name, spec] of Object.entries(deps)) {
47 if (!spec.startsWith("catalog:")) continue;
48 const catalogName = spec.slice("catalog:".length);
49 const version =
50 catalogName === ""
51 ? workspace.catalog?.[name]
52 : workspace.catalogs?.[catalogName]?.[name];
53 if (version === undefined) {
54 return yield* Effect.fail(
55 new Error(
56 `cloneFixture: ${name} uses "${spec}" but pnpm-workspace.yaml has no such catalog entry`,
57 ),
58 );
59 }
60 deps[name] = version;
61 changed = true;
62 }
63 }
64 if (changed) {
65 yield* fs.writeFileString(
66 manifestPath,
67 `${JSON.stringify(manifest, null, 2)}\n`,
68 );
69 }
70});
71
72/**
73 * Recursively copy `sourceDir` into a fresh `fs.makeTempDirectory`
74 * (with `prefix`), preserving file modes. Returns the absolute path of
75 * the new temp directory.
76 *
77 * Used by tests that want to mutate a fixture's `src/` files without
78 * polluting the source-controlled fixture or racing with other tests
79 * sharing the same fixture path.
80 *
81 * - `tempRoot`, when provided, places the temp dir under a specific
82 * parent directory instead of the OS temp root. This matters for
83 * Vite tests because Vite's `vite:build-html` plugin can't express
84 * project roots that sit outside the current working directory; an
85 * under-workspace temp dir keeps the relative path representable.
86 * - `entries`, when provided, restricts the copy to a specific subset
87 * of top-level entries. Defaults to copying everything in
88 * `sourceDir`.
89 */
90export const cloneFixture = Effect.fn(function* (
91 sourceDir: string,
92 options: {
93 prefix: string;
94 tempRoot?: string;
95 entries?: string[];
96 },
97) {
98 const fs = yield* FileSystem.FileSystem;
99 const path = yield* Path.Path;
100
101 if (options.tempRoot) {
102 yield* fs.makeDirectory(options.tempRoot, { recursive: true });
103 }
104 // Realpath the temp dir: macOS temp roots are symlinks (/tmp, /var/folders
105 // → /private/...), and file watchers (Turbopack HMR) report events under
106 // the real path — a symlinked project root never sees invalidations.
107 const dir = yield* fs.realPath(
108 yield* fs.makeTempDirectory({
109 prefix: options.prefix,
110 directory: options.tempRoot,
111 }),
112 );
113
114 const entries = options.entries ?? (yield* fs.readDirectory(sourceDir));
115
116 const copyTree = (relativePath: string): Effect.Effect<void, any, any> =>
117 Effect.gen(function* () {
118 const from = path.join(sourceDir, relativePath);
119 const to = path.join(dir, relativePath);
120 const stat = yield* fs.stat(from);
121 if (stat.type === "Directory") {
122 yield* fs.makeDirectory(to, { recursive: true });
123 const children = yield* fs.readDirectory(from);
124 for (const child of children) {
125 yield* copyTree(path.join(relativePath, child));
126 }
127 } else {
128 const contents = yield* fs.readFile(from);
129 yield* fs.writeFile(to, contents);
130 // Preserve executable bit so copied build scripts still run.
131 const mode = Number(stat.mode);
132 if (mode & 0o111) {
133 yield* fs.chmod(to, mode);
134 }
135 }
136 });
137
138 for (const entry of entries) {
139 yield* copyTree(entry);
140 }
141
142 yield* resolveCatalogSpecifiers(dir);
143
144 yield* Effect.addFinalizer(
145 Exit.match({
146 onSuccess: () => Effect.ignore(fs.remove(dir, { recursive: true })),
147 onFailure: () => Effect.void,
148 }),
149 );
150
151 return dir;
152});