1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import { ChildProcess } from "effect/process";
5import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
6import { constants as fsConstants } from "node:fs";
7import { builtinModules } from "node:module";
8import { parse as parseYaml } from "yaml";
9import { exec } from "../Util/exec.ts";
10import { sha256, sha256Object } from "../Util/sha256.ts";
11import { BundleError } from "./Bundle.ts";
12
13export interface InstalledPackageFile {
14 readonly path: string;
15 readonly content: Uint8Array<ArrayBufferLike>;
16 /** Complete Unix mode, including the file type bits. */
17 readonly mode?: number;
18}
19
20type JsonRecord = Record<string, unknown>;
21
22export type PackageInstall =
23 | ReadonlyArray<string>
24 | Readonly<Record<string, string>>;
25
26export type NpmInstallRunner = (
27 directory: string,
28 args: ReadonlyArray<string>,
29) => Effect.Effect<void, unknown>;
30
31export interface ResolveInstallTargetsOptions {
32 readonly cwd: string;
33 /** Normalized package-root → requested version map (from {@link normalizeInstallTargets}). */
34 readonly requested: Readonly<Record<string, string>>;
35}
36
37export type PackageOverride = string | PackageOverrideMap;
38
39export interface PackageOverrideMap {
40 readonly [dependencyName: string]: PackageOverride;
41}
42
43export type PackageOverrides = Readonly<Record<string, PackageOverride>>;
44
45export interface PackageInstallPlan {
46 readonly resolved: Readonly<Record<string, string>>;
47 readonly overrides: PackageOverrides;
48}
49
50export interface PackageInstallIdentity extends PackageInstallPlan {
51 readonly lockfile?: {
52 readonly name: string;
53 readonly hash: string;
54 };
55}
56
57export interface HashPackageInstallIdentityOptions {
58 readonly bundleHash: string;
59 readonly identity: PackageInstallIdentity;
60 readonly architecture: "x86_64" | "arm64";
61}
62
63export interface InstallResolvedPackagesOptions {
64 /** Package-root → concrete npm version map (from {@link resolveInstallTargets}). */
65 readonly resolved: Readonly<Record<string, string>>;
66 /** Locked parent-package → child-package versions used to pin transitives. */
67 readonly overrides?: PackageOverrides;
68 readonly architecture: "x86_64" | "arm64";
69 readonly runNpmInstall?: NpmInstallRunner;
70}
71
72export interface InstallPackagesOptions {
73 readonly cwd: string;
74 readonly install?: PackageInstall;
75 readonly architecture: "x86_64" | "arm64";
76 readonly runNpmInstall?: NpmInstallRunner;
77}
78
79interface PackageJson {
80 readonly dependencies?: Record<string, string>;
81 readonly optionalDependencies?: Record<string, string>;
82 readonly devDependencies?: Record<string, string>;
83 readonly catalog?: Record<string, string>;
84 readonly catalogs?: Record<string, Record<string, string>>;
85 readonly workspaces?: unknown;
86}
87
88interface CatalogSource {
89 readonly catalog?: Record<string, string>;
90 readonly catalogs?: Record<string, Record<string, string>>;
91}
92
93const builtins = new Set(
94 builtinModules.flatMap((name) => [name, `node:${name}`]),
95);
96
97/**
98 * Unix mode for symlink entries in the ZIP archive. Effect's FileSystem
99 * exposes `readLink` but not `lstat`, so the symlink type bits must be
100 * synthesized after identifying a link. The permission bits are conventional
101 * for symlinks and are ignored by Unix filesystems.
102 */
103const symbolicLinkMode = fsConstants.S_IFLNK | 0o777;
104
105const incompatibleVersionPrefixes = [
106 "workspace:",
107 "file:",
108 "link:",
109 "portal:",
110 "patch:",
111] as const;
112
113const lockfileNames = [
114 "bun.lock",
115 "bun.lockb",
116 "package-lock.json",
117 "pnpm-lock.yaml",
118 "yarn.lock",
119] as const;
120
121/**
122 * Parses a module specifier into its package root, or `undefined` when the
123 * specifier is not a bare package import (relative path, builtin, glob, subpath
124 * imports, etc.).
125 */
126export function parsePackageRoot(specifier: string): string | undefined {
127 const root = parsePackageRootFromSpecifier(specifier);
128 return root === specifier ? root : undefined;
129}
130
131/**
132 * Parses a bare package specifier or subpath import into its package root.
133 */
134export function parsePackageRootFromSpecifier(
135 specifier: string,
136): string | undefined {
137 if (
138 specifier.length === 0 ||
139 builtins.has(specifier) ||
140 specifier.startsWith(".") ||
141 specifier.startsWith("/") ||
142 specifier.startsWith("\\") ||
143 specifier.includes("*") ||
144 specifier.includes("?") ||
145 specifier.includes(":") ||
146 specifier.includes("\0")
147 ) {
148 return undefined;
149 }
150
151 const segments = specifier.split("/");
152 if (specifier.startsWith("@")) {
153 return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined;
154 }
155 return segments[0];
156}
157
158/** Whether `moduleId` is `root` itself or a subpath import of it. */
159export function matchesPackageRoot(moduleId: string, root: string): boolean {
160 return moduleId === root || moduleId.startsWith(`${root}/`);
161}
162
163export function npmInstallArgs(
164 architecture: "x86_64" | "arm64",
165): ReadonlyArray<string> {
166 return npmCommandArgs("ci", architecture);
167}
168
169export function npmLockfileArgs(
170 architecture: "x86_64" | "arm64",
171): ReadonlyArray<string> {
172 return [
173 ...npmCommandArgs("install", architecture),
174 "--package-lock-only",
175 "--ignore-scripts",
176 ];
177}
178
179export function npmPlainInstallArgs(
180 architecture: "x86_64" | "arm64",
181): ReadonlyArray<string> {
182 return npmCommandArgs("install", architecture);
183}
184
185const npmCommandArgs = (
186 command: "ci" | "install",
187 architecture: "x86_64" | "arm64",
188): ReadonlyArray<string> => {
189 const npmArchitecture = architecture === "arm64" ? "arm64" : "x64";
190 return [
191 command,
192 "--force",
193 "--platform=linux",
194 "--os=linux",
195 `--arch=${npmArchitecture}`,
196 `--cpu=${npmArchitecture}`,
197 "--libc=glibc",
198 ];
199};
200
201/**
202 * Normalizes and validates a `build.install` declaration to a
203 * package-root → requested-version map. Array entries default to `"*"`.
204 */
205export function normalizeInstallTargets(
206 install: PackageInstall | undefined,
207): Effect.Effect<Record<string, string>, BundleError> {
208 if (!install) return Effect.succeed({});
209 const entries: ReadonlyArray<readonly [string, string]> = Array.isArray(
210 install,
211 )
212 ? install.map((dep) => [dep, "*"] as const)
213 : Object.entries(install);
214
215 const requested: Record<string, string> = {};
216 for (const [dep, version] of entries) {
217 const root = parsePackageRoot(dep);
218 if (root === undefined) {
219 return Effect.fail(
220 new BundleError({
221 message: `Invalid package name '${dep}' in build.install. Use a package root like 'sharp', not a subpath or bare specifier.`,
222 }),
223 );
224 }
225 requested[root] = version;
226 }
227 return Effect.succeed(requested);
228}
229
230/**
231 * Resolves the npm-compatible version for every requested package, reading the
232 * nearest source `package.json` and pnpm/Bun catalogs. Does not run npm.
233 */
234export function resolveInstallTargets(
235 options: ResolveInstallTargetsOptions,
236): Effect.Effect<
237 Record<string, string>,
238 BundleError,
239 FileSystem.FileSystem | Path.Path | ChildProcessSpawner
240> {
241 return resolvePackageInstallPlan(options).pipe(
242 Effect.map((plan) => ({ ...plan.resolved })),
243 );
244}
245
246export function resolvePackageInstallPlan(
247 options: ResolveInstallTargetsOptions,
248): Effect.Effect<
249 PackageInstallPlan,
250 BundleError,
251 FileSystem.FileSystem | Path.Path | ChildProcessSpawner
252> {
253 const packageNames = Object.keys(options.requested).sort();
254 if (packageNames.length === 0) {
255 return Effect.succeed({ resolved: {}, overrides: {} });
256 }
257
258 return Effect.gen(function* () {
259 const sourcePackageJson = yield* readSourcePackageJson(options.cwd);
260 const resolved: Record<string, string> = {};
261 for (const packageName of packageNames) {
262 resolved[packageName] = yield* resolveInstallVersion(
263 options.cwd,
264 sourcePackageJson,
265 packageName,
266 options.requested[packageName],
267 );
268 }
269 const lockfilePath = yield* findUp(options.cwd, lockfileNames);
270 if (lockfilePath === undefined) {
271 return { resolved, overrides: {} };
272 }
273 return yield* pinInstallVersionsFromLockfile({
274 cwd: options.cwd,
275 lockfilePath,
276 packageJson: sourcePackageJson,
277 requested: options.requested,
278 resolved,
279 });
280 }).pipe(Effect.mapError(toBundleError));
281}
282
283/**
284 * Resolves the package install identity used by Lambda diffing. The lockfile
285 * fingerprint makes range-preserving dependency updates trigger a new artifact.
286 */
287export function resolvePackageInstallIdentity(
288 options: ResolveInstallTargetsOptions,
289): Effect.Effect<
290 PackageInstallIdentity,
291 BundleError,
292 FileSystem.FileSystem | Path.Path | ChildProcessSpawner
293> {
294 return Effect.gen(function* () {
295 const plan = yield* resolvePackageInstallPlan(options);
296 if (Object.keys(plan.resolved).length === 0) {
297 return plan;
298 }
299 const lockfile = yield* readNearestLockfileFingerprint(options.cwd);
300 return { ...plan, lockfile };
301 });
302}
303
304export function hashPackageInstallIdentity(
305 options: HashPackageInstallIdentityOptions,
306): Effect.Effect<string> {
307 return sha256Object({
308 bundle: options.bundleHash,
309 install: options.identity.resolved,
310 overrides: options.identity.overrides,
311 lockfile: options.identity.lockfile,
312 architecture: options.architecture,
313 });
314}
315
316/**
317 * Installs already-resolved dependencies into an isolated npm artifact targeting
318 * Linux and the function's architecture, returning the artifact's files.
319 */
320export function installResolvedPackages(
321 options: InstallResolvedPackagesOptions,
322): Effect.Effect<
323 ReadonlyArray<InstalledPackageFile>,
324 BundleError,
325 FileSystem.FileSystem | Path.Path | ChildProcessSpawner
326> {
327 const packageNames = Object.keys(options.resolved).sort();
328 if (packageNames.length === 0) return Effect.succeed([]);
329
330 const runInstall = (
331 directory: string,
332 args: ReadonlyArray<string>,
333 ): Effect.Effect<void, BundleError, ChildProcessSpawner> =>
334 options.runNpmInstall === undefined
335 ? runNpmInstall(directory, args)
336 : options
337 .runNpmInstall(directory, args)
338 .pipe(Effect.mapError(toBundleError));
339
340 return Effect.gen(function* () {
341 const fileSystem = yield* FileSystem.FileSystem;
342 const pathService = yield* Path.Path;
343
344 return yield* Effect.acquireUseRelease(
345 fileSystem.makeTempDirectory({ prefix: "alchemy-lambda-packages-" }),
346 (directory) =>
347 Effect.gen(function* () {
348 const hasOverrides =
349 options.overrides !== undefined &&
350 Object.keys(options.overrides).length > 0;
351 const manifest = JSON.stringify(
352 {
353 private: true,
354 dependencies: options.resolved,
355 ...(hasOverrides ? { overrides: options.overrides } : {}),
356 },
357 null,
358 2,
359 );
360 yield* fileSystem.writeFileString(
361 pathService.join(directory, "package.json"),
362 `${manifest}\n`,
363 );
364 if (hasOverrides) {
365 // Generate a lock so `npm ci` reproduces the override-pinned graph.
366 yield* runInstall(directory, npmLockfileArgs(options.architecture));
367 yield* runInstall(directory, npmInstallArgs(options.architecture));
368 } else {
369 yield* runInstall(
370 directory,
371 npmPlainInstallArgs(options.architecture),
372 );
373 }
374 return yield* readArtifactFiles(directory);
375 }),
376 (directory) =>
377 fileSystem.remove(directory, { recursive: true }).pipe(Effect.ignore),
378 );
379 }).pipe(Effect.mapError(toBundleError));
380}
381
382/**
383 * Convenience flow for callers that do not need to defer installation.
384 */
385export function installPackages(
386 options: InstallPackagesOptions,
387): Effect.Effect<
388 ReadonlyArray<InstalledPackageFile>,
389 BundleError,
390 FileSystem.FileSystem | Path.Path | ChildProcessSpawner
391> {
392 return Effect.gen(function* () {
393 const requested = yield* normalizeInstallTargets(options.install);
394 const plan = yield* resolvePackageInstallPlan({
395 cwd: options.cwd,
396 requested,
397 });
398 return yield* installResolvedPackages({
399 ...plan,
400 architecture: options.architecture,
401 runNpmInstall: options.runNpmInstall,
402 });
403 });
404}
405
406const runNpmInstall = (
407 directory: string,
408 args: ReadonlyArray<string>,
409): Effect.Effect<void, BundleError, ChildProcessSpawner> =>
410 Effect.sync(() =>
411 ChildProcess.setCwd(
412 ChildProcess.make("npm", args, {
413 shell: false,
414 env: { ...process.env },
415 }),
416 directory,
417 ),
418 ).pipe(
419 Effect.flatMap(exec),
420 Effect.scoped,
421 Effect.mapError((cause) => {
422 const message = cause instanceof Error ? cause.message : String(cause);
423 return new BundleError({
424 message: message.includes("ENOENT")
425 ? "Failed to run 'npm install' for build.install: 'npm' was not found on PATH. build.install shells out to npm (even in Bun/pnpm projects), so Node.js/npm must be installed."
426 : `Failed to run 'npm install' for build.install: ${message}`,
427 cause,
428 });
429 }),
430 Effect.flatMap(({ exitCode, stderr }) =>
431 exitCode === 0
432 ? Effect.void
433 : Effect.fail(
434 new BundleError({
435 message: `npm install for build.install failed with exit code ${exitCode}: ${stderr}`,
436 }),
437 ),
438 ),
439 );
440
441const printBunBinaryLockfile = (
442 lockfilePath: string,
443): Effect.Effect<string, BundleError, ChildProcessSpawner> =>
444 Effect.sync(() =>
445 ChildProcess.make("bun", [lockfilePath], {
446 shell: false,
447 env: { ...process.env },
448 }),
449 ).pipe(
450 Effect.flatMap(exec),
451 Effect.scoped,
452 Effect.mapError((cause) => {
453 const message = cause instanceof Error ? cause.message : String(cause);
454 return new BundleError({
455 message: message.includes("ENOENT")
456 ? `Failed to inspect legacy Bun lockfile '${lockfilePath}': 'bun' was not found on PATH. Reading a binary bun.lockb shells out to bun; install Bun or migrate to the text-based bun.lock format.`
457 : `Failed to inspect legacy Bun lockfile '${lockfilePath}'`,
458 cause,
459 });
460 }),
461 Effect.flatMap(({ exitCode, stdout, stderr }) =>
462 exitCode === 0
463 ? Effect.succeed(stdout)
464 : Effect.fail(
465 new BundleError({
466 message: `Failed to inspect legacy Bun lockfile '${lockfilePath}': bun exited with code ${exitCode}: ${stderr}`,
467 }),
468 ),
469 ),
470 );
471
472const readSourcePackageJson = (cwd: string) =>
473 Effect.gen(function* () {
474 const fs = yield* FileSystem.FileSystem;
475 const path = yield* Path.Path;
476 const content = yield* fs.readFileString(path.join(cwd, "package.json"));
477 return yield* Effect.try({
478 try: () => JSON.parse(content) as PackageJson,
479 catch: (cause) =>
480 new BundleError({
481 message: `Failed to parse package.json for Lambda externals from '${cwd}'`,
482 cause,
483 }),
484 });
485 }).pipe(
486 Effect.mapError(
487 (cause) =>
488 new BundleError({
489 message: `Failed to read package.json for Lambda externals from '${cwd}'`,
490 cause,
491 }),
492 ),
493 );
494
495const resolveInstallVersion = (
496 cwd: string,
497 packageJson: PackageJson,
498 packageName: string,
499 installVersion: string | undefined,
500) =>
501 Effect.gen(function* () {
502 let version = installVersion;
503 if (version === undefined || version === "" || version === "*") {
504 version =
505 packageJson.dependencies?.[packageName] ??
506 packageJson.optionalDependencies?.[packageName] ??
507 packageJson.devDependencies?.[packageName] ??
508 "*";
509 }
510
511 if (version.startsWith("catalog:")) {
512 return yield* resolveCatalogVersion(cwd, packageName, version);
513 }
514
515 for (const prefix of incompatibleVersionPrefixes) {
516 if (version.startsWith(prefix)) {
517 return yield* Effect.fail(
518 new BundleError({
519 message: `External package '${packageName}' uses '${version}', which cannot be installed in an isolated Lambda artifact. Pin an npm-compatible version in package.json or build.install.`,
520 }),
521 );
522 }
523 }
524
525 return version;
526 });
527
528const pinInstallVersionsFromLockfile = (options: {
529 readonly cwd: string;
530 readonly lockfilePath: string;
531 readonly packageJson: PackageJson;
532 readonly requested: Readonly<Record<string, string>>;
533 readonly resolved: Readonly<Record<string, string>>;
534}): Effect.Effect<
535 PackageInstallPlan,
536 BundleError,
537 FileSystem.FileSystem | Path.Path | ChildProcessSpawner
538> =>
539 Effect.gen(function* () {
540 const fs = yield* FileSystem.FileSystem;
541 const path = yield* Path.Path;
542 const lockfileName = path.basename(options.lockfilePath);
543 const lockfileDirectory = path.dirname(options.lockfilePath);
544 const importer = path
545 .relative(lockfileDirectory, options.cwd)
546 .replaceAll("\\", "/");
547 const candidates = Object.keys(options.resolved).filter((packageName) => {
548 const requested = options.requested[packageName];
549 const declared = declaredPackageVersion(options.packageJson, packageName);
550 return (
551 declared !== undefined &&
552 (requested === undefined ||
553 requested === "" ||
554 requested === "*" ||
555 requested === declared)
556 );
557 });
558 if (candidates.length === 0) {
559 return { resolved: { ...options.resolved }, overrides: {} };
560 }
561
562 const content =
563 lockfileName === "bun.lockb"
564 ? yield* printBunBinaryLockfile(options.lockfilePath)
565 : yield* fs.readFileString(options.lockfilePath).pipe(
566 Effect.mapError(
567 (cause) =>
568 new BundleError({
569 message: `Failed to read package-manager lockfile for Lambda externals from '${options.cwd}'`,
570 cause,
571 }),
572 ),
573 );
574 const plan = yield* Effect.try({
575 try: () =>
576 parseLockedInstallPlan({
577 name: lockfileName === "bun.lockb" ? "yarn.lock" : lockfileName,
578 content,
579 importer,
580 candidates,
581 packageJson: options.packageJson,
582 resolved: Object.fromEntries(
583 Object.entries(options.resolved).filter(
584 ([packageName]) => !candidates.includes(packageName),
585 ),
586 ),
587 }),
588 catch: (cause) =>
589 new BundleError({
590 message: `Failed to resolve locked Lambda package versions from '${options.lockfilePath}'`,
591 cause,
592 }),
593 });
594
595 for (const packageName of candidates) {
596 if (plan.resolved[packageName] === undefined) {
597 return yield* Effect.fail(
598 new BundleError({
599 message: `Could not resolve a locked version for '${packageName}' from '${options.lockfilePath}'. Pin an exact npm-compatible version in build.install or refresh the package-manager lockfile.`,
600 }),
601 );
602 }
603 }
604
605 return plan;
606 });
607
608interface LockParseOptions {
609 readonly name: string;
610 readonly content: string;
611 readonly importer: string;
612 readonly candidates: ReadonlyArray<string>;
613 readonly packageJson: PackageJson;
614 readonly resolved: Readonly<Record<string, string>>;
615}
616
617type MutablePackageOverride = string | MutablePackageOverrideMap;
618interface MutablePackageOverrideMap {
619 [dependencyName: string]: MutablePackageOverride;
620}
621type MutablePackageOverrides = Record<string, MutablePackageOverride>;
622
623interface LockedGraphRoot<Node> {
624 readonly node: Node;
625 readonly rootSelector: string;
626}
627
628interface LockedGraphDependency<Node> {
629 readonly dependencyName: string;
630 readonly installSpec: string;
631 readonly node?: Node;
632}
633
634/**
635 * Builds npm `overrides` pinning every dependency name reachable from each
636 * root. npm override keys apply to the whole subtree below the matched
637 * package (most-specific rule wins), so a name that resolves to a single
638 * version anywhere under the root needs one flat rule. Nested rules are only
639 * emitted along the branches where the lockfile resolves the same name to
640 * different versions, which keeps the overrides object proportional to the
641 * dependency graph instead of to its (potentially exponential) path count.
642 */
643const addLockedGraphOverrides = <Node>(
644 overrides: MutablePackageOverrides,
645 roots: ReadonlyArray<LockedGraphRoot<Node>>,
646 nodeId: (node: Node) => unknown,
647 dependencies: (node: Node) => ReadonlyArray<LockedGraphDependency<Node>>,
648): void => {
649 const edgeMemo = new Map<
650 unknown,
651 ReadonlyArray<LockedGraphDependency<Node>>
652 >();
653 const edgesOf = (node: Node): ReadonlyArray<LockedGraphDependency<Node>> => {
654 const id = nodeId(node);
655 let edges = edgeMemo.get(id);
656 if (edges === undefined) {
657 edges = dependencies(node);
658 edgeMemo.set(id, edges);
659 }
660 return edges;
661 };
662
663 // name → set of install specs observed anywhere in the subtree (memoized).
664 const specsMemo = new Map<
665 unknown,
666 ReadonlyMap<string, ReadonlySet<string>>
667 >();
668 const specsOf = (start: Node): ReadonlyMap<string, ReadonlySet<string>> => {
669 const startId = nodeId(start);
670 const memoized = specsMemo.get(startId);
671 if (memoized !== undefined) return memoized;
672 const specs = new Map<string, Set<string>>();
673 const visited = new Set<unknown>([startId]);
674 const queue: Node[] = [start];
675 while (queue.length > 0) {
676 const node = queue.pop()!;
677 for (const edge of edgesOf(node)) {
678 let set = specs.get(edge.dependencyName);
679 if (set === undefined) {
680 set = new Set();
681 specs.set(edge.dependencyName, set);
682 }
683 set.add(edge.installSpec);
684 if (edge.node === undefined) continue;
685 const childId = nodeId(edge.node);
686 if (visited.has(childId)) continue;
687 visited.add(childId);
688 queue.push(edge.node);
689 }
690 }
691 specsMemo.set(startId, specs);
692 return specs;
693 };
694
695 const asOverrideMap = (
696 parent: MutablePackageOverrideMap,
697 dependencyName: string,
698 ): MutablePackageOverrideMap => {
699 const existing = parent[dependencyName];
700 if (typeof existing === "object") return existing;
701 const record: MutablePackageOverrideMap = {};
702 if (typeof existing === "string") record["."] = existing;
703 parent[dependencyName] = record;
704 return record;
705 };
706
707 // Resolves the still-conflicted names inside `node`'s subtree, nesting only
708 // as deep as required to disambiguate. Names that stay ambiguous along an
709 // identical override path are left unpinned (npm overrides cannot express
710 // them); everything else is pinned exactly.
711 const resolveConflicts = (
712 out: MutablePackageOverrideMap,
713 node: Node,
714 conflicted: ReadonlySet<string>,
715 path: ReadonlySet<unknown>,
716 ): void => {
717 const still = new Set<string>();
718 const specs = specsOf(node);
719 for (const name of conflicted) {
720 const set = specs.get(name);
721 if (set === undefined) continue;
722 if (set.size === 1) {
723 const spec = [...set][0]!;
724 const existing = out[name];
725 if (existing === undefined) out[name] = spec;
726 else if (typeof existing === "object" && existing["."] === undefined) {
727 existing["."] = spec;
728 }
729 } else {
730 still.add(name);
731 }
732 }
733 if (still.size === 0) return;
734 for (const edge of edgesOf(node)) {
735 // Pin the direct occurrence of a name that stays conflicted deeper down.
736 if (still.has(edge.dependencyName)) {
737 const record = asOverrideMap(out, edge.dependencyName);
738 if (record["."] === undefined) record["."] = edge.installSpec;
739 }
740 if (edge.node === undefined) continue;
741 const childId = nodeId(edge.node);
742 if (path.has(childId)) continue;
743 const childSpecs = specsOf(edge.node);
744 if (![...still].some((name) => childSpecs.has(name))) continue;
745 const record = asOverrideMap(out, edge.dependencyName);
746 if (record["."] === undefined) record["."] = edge.installSpec;
747 resolveConflicts(record, edge.node, still, new Set([...path, childId]));
748 }
749 };
750
751 for (const { node, rootSelector } of roots) {
752 const existing = overrides[rootSelector];
753 const rootMap: MutablePackageOverrideMap =
754 typeof existing === "object" ? existing : {};
755 if (typeof existing === "string") rootMap["."] = existing;
756 const specs = specsOf(node);
757 if (specs.size === 0) continue;
758 overrides[rootSelector] = rootMap;
759 const conflicted = new Set<string>();
760 for (const [name, set] of specs) {
761 if (set.size === 1) rootMap[name] = [...set][0]!;
762 else conflicted.add(name);
763 }
764 if (conflicted.size > 0) {
765 resolveConflicts(rootMap, node, conflicted, new Set([nodeId(node)]));
766 }
767 }
768};
769
770const parseLockedInstallPlan = (
771 options: LockParseOptions,
772): PackageInstallPlan => {
773 switch (options.name) {
774 case "package-lock.json":
775 return parsePackageLock(options);
776 case "pnpm-lock.yaml":
777 return parsePnpmLock(options);
778 case "bun.lock":
779 return parseBunLock(options);
780 case "yarn.lock":
781 return parseYarnLock(options);
782 default:
783 return { resolved: { ...options.resolved }, overrides: {} };
784 }
785};
786
787const parsePackageLock = (options: {
788 readonly content: string;
789 readonly importer: string;
790 readonly candidates: ReadonlyArray<string>;
791 readonly packageJson: PackageJson;
792 readonly resolved: Readonly<Record<string, string>>;
793}): PackageInstallPlan => {
794 const lockfile = asRecord(JSON.parse(options.content));
795 const packages = asRecord(lockfile?.packages);
796 const dependencies = asRecord(lockfile?.dependencies);
797 const importerKey = options.importer === "." ? "" : options.importer;
798 const importer = asRecord(packages?.[importerKey]);
799 const resolved = { ...options.resolved };
800 const overrides: MutablePackageOverrides = {};
801 const roots: Array<LockedGraphRoot<string>> = [];
802
803 for (const packageName of options.candidates) {
804 const importerSpec = dependencyValue(importer, packageName);
805 const declared = declaredPackageVersion(options.packageJson, packageName);
806 if (
807 importerSpec !== undefined &&
808 declared !== undefined &&
809 importerSpec !== declared
810 ) {
811 continue;
812 }
813
814 for (const packagePath of nodeModulesLookupPaths(
815 importerKey,
816 packageName,
817 )) {
818 const version = stringValue(asRecord(packages?.[packagePath])?.version);
819 if (version !== undefined) {
820 const entry = asRecord(packages?.[packagePath]);
821 resolved[packageName] = npmLockedInstallSpec(
822 packageName,
823 entry,
824 version,
825 );
826 roots.push({
827 node: packagePath,
828 rootSelector: `${stringValue(entry?.name) ?? packageName}@${version}`,
829 });
830 break;
831 }
832 }
833
834 if (
835 resolved[packageName] === options.resolved[packageName] &&
836 importerKey === ""
837 ) {
838 const entry = asRecord(dependencies?.[packageName]);
839 const version = stringValue(entry?.version);
840 if (version !== undefined) {
841 resolved[packageName] = npmLockedInstallSpec(
842 packageName,
843 entry,
844 version,
845 );
846 }
847 }
848 }
849
850 if (packages !== undefined) {
851 addLockedGraphOverrides(
852 overrides,
853 roots,
854 (packagePath) => packagePath,
855 (packagePath) => {
856 const value = packages[packagePath];
857 const entry = asRecord(value);
858 if (entry === undefined) return [];
859 return Object.keys(dependencySpecifiers(entry)).flatMap(
860 (dependencyName): ReadonlyArray<LockedGraphDependency<string>> => {
861 const childPath = resolvePackageLockDependencyPath(
862 packages,
863 packagePath,
864 dependencyName,
865 );
866 if (childPath === undefined) return [];
867 const child = asRecord(packages[childPath]);
868 const childVersion = stringValue(child?.version);
869 if (childVersion === undefined) return [];
870 return [
871 {
872 dependencyName,
873 installSpec: npmLockedInstallSpec(
874 dependencyName,
875 child,
876 childVersion,
877 ),
878 node: childPath,
879 },
880 ];
881 },
882 );
883 },
884 );
885 } else if (dependencies !== undefined) {
886 addPackageLockV1Overrides(dependencies, overrides, options.candidates);
887 }
888 return { resolved, overrides };
889};
890
891const parsePnpmLock = (options: {
892 readonly content: string;
893 readonly importer: string;
894 readonly candidates: ReadonlyArray<string>;
895 readonly packageJson: PackageJson;
896 readonly resolved: Readonly<Record<string, string>>;
897}): PackageInstallPlan => {
898 const lockfile = asRecord(parseYaml(options.content));
899 const importers = asRecord(lockfile?.importers);
900 const importerKey = options.importer === "" ? "." : options.importer;
901 const importer =
902 asRecord(importers?.[importerKey]) ??
903 (importerKey === "." ? lockfile : undefined);
904 const resolved = { ...options.resolved };
905 const overrides: MutablePackageOverrides = {};
906 const packages =
907 asRecord(lockfile?.snapshots) ?? asRecord(lockfile?.packages) ?? {};
908 const roots: Array<LockedGraphRoot<string>> = [];
909
910 for (const packageName of options.candidates) {
911 const entry = dependencyEntry(importer, packageName);
912 const record = asRecord(entry);
913 const specifier = stringValue(record?.specifier);
914 const declared = declaredPackageVersion(options.packageJson, packageName);
915 if (
916 specifier !== undefined &&
917 declared !== undefined &&
918 specifier !== declared
919 ) {
920 continue;
921 }
922 const rawVersion =
923 typeof entry === "string" ? entry : stringValue(record?.version);
924 const installSpec = pnpmInstallSpec(packageName, rawVersion);
925 if (installSpec !== undefined) {
926 resolved[packageName] = installSpec;
927 const packageKey = findPnpmPackageKey(packages, packageName, rawVersion);
928 const root =
929 packageKey === undefined ? undefined : parsePnpmPackageKey(packageKey);
930 if (packageKey !== undefined && root !== undefined) {
931 roots.push({
932 node: packageKey,
933 rootSelector: `${root.name}@${root.version}`,
934 });
935 }
936 }
937 }
938
939 addLockedGraphOverrides(
940 overrides,
941 roots,
942 (packageKey) => packageKey,
943 (packageKey) => {
944 const value = packages[packageKey];
945 const parent = parsePnpmPackageKey(packageKey);
946 const entry = asRecord(value);
947 if (parent === undefined || entry === undefined) return [];
948 return Object.entries(dependencySpecifiers(entry)).flatMap(
949 ([dependencyName, dependencyEntryValue]): ReadonlyArray<
950 LockedGraphDependency<string>
951 > => {
952 const rawVersion =
953 typeof dependencyEntryValue === "string"
954 ? dependencyEntryValue
955 : stringValue(asRecord(dependencyEntryValue)?.version);
956 const installSpec = pnpmInstallSpec(dependencyName, rawVersion);
957 if (installSpec === undefined) return [];
958 const childKey = findPnpmPackageKey(
959 packages,
960 dependencyName,
961 rawVersion,
962 );
963 return [{ dependencyName, installSpec, node: childKey }];
964 },
965 );
966 },
967 );
968 return { resolved, overrides };
969};
970
971const parseBunLock = (options: {
972 readonly content: string;
973 readonly importer: string;
974 readonly candidates: ReadonlyArray<string>;
975 readonly packageJson: PackageJson;
976 readonly resolved: Readonly<Record<string, string>>;
977}): PackageInstallPlan => {
978 const lockfile = asRecord(parseJsonc(options.content));
979 const packages = asRecord(lockfile?.packages);
980 const workspaces = asRecord(lockfile?.workspaces);
981 const importerKey = options.importer === "." ? "" : options.importer;
982 const workspace = asRecord(workspaces?.[importerKey]);
983 const workspaceName = stringValue(workspace?.name);
984 const resolved = { ...options.resolved };
985 const overrides: MutablePackageOverrides = {};
986 const roots: Array<LockedGraphRoot<string>> = [];
987 if (packages === undefined) return { resolved, overrides };
988
989 for (const packageName of options.candidates) {
990 const declared = declaredPackageVersion(options.packageJson, packageName);
991 const workspaceSpecifier = dependencyValue(workspace, packageName);
992 if (
993 declared === undefined ||
994 workspaceSpecifier === undefined ||
995 workspaceSpecifier !== declared
996 )
997 continue;
998 const expectedName = npmAliasName(packageName, declared);
999 const direct = findBunPackageDescriptor(
1000 packages,
1001 [
1002 ...(workspaceName === undefined
1003 ? []
1004 : [`${workspaceName}/${packageName}`]),
1005 packageName,
1006 ],
1007 expectedName,
1008 );
1009 if (direct !== undefined) {
1010 resolved[packageName] = lockedInstallSpec(
1011 packageName,
1012 direct.name,
1013 direct.version,
1014 );
1015 roots.push({
1016 node: direct.key,
1017 rootSelector: `${direct.name}@${direct.version}`,
1018 });
1019 }
1020 }
1021
1022 addLockedGraphOverrides(
1023 overrides,
1024 roots,
1025 (packageKey) => packageKey,
1026 (packageKey) => {
1027 const value = packages[packageKey];
1028 const parent = parseBunPackageDescriptor(value);
1029 const metadata = Array.isArray(value) ? asRecord(value[2]) : undefined;
1030 if (parent === undefined || metadata === undefined) return [];
1031 return Object.entries(dependencySpecifiers(metadata)).flatMap(
1032 ([dependencyName, dependencySpecifier]): ReadonlyArray<
1033 LockedGraphDependency<string>
1034 > => {
1035 if (typeof dependencySpecifier !== "string") return [];
1036 const expectedName = npmAliasName(
1037 dependencyName,
1038 dependencySpecifier,
1039 );
1040 const child = findBunPackageDescriptor(
1041 packages,
1042 [`${packageKey}/${dependencyName}`, dependencyName],
1043 expectedName,
1044 );
1045 if (child === undefined) return [];
1046 return [
1047 {
1048 dependencyName,
1049 installSpec: lockedInstallSpec(
1050 dependencyName,
1051 child.name,
1052 child.version,
1053 ),
1054 node: child.key,
1055 },
1056 ];
1057 },
1058 );
1059 },
1060 );
1061 return { resolved, overrides };
1062};
1063
1064const parseYarnLock = (options: {
1065 readonly content: string;
1066 readonly candidates: ReadonlyArray<string>;
1067 readonly packageJson: PackageJson;
1068 readonly resolved: Readonly<Record<string, string>>;
1069}): PackageInstallPlan => {
1070 const entries = parseYarnEntries(options.content);
1071 const resolved = { ...options.resolved };
1072 const overrides: MutablePackageOverrides = {};
1073 const roots: Array<LockedGraphRoot<YarnLockEntry>> = [];
1074 for (const packageName of options.candidates) {
1075 const specifier = declaredPackageVersion(options.packageJson, packageName);
1076 if (specifier === undefined) continue;
1077 const entry = findYarnEntry(entries, packageName, specifier);
1078 if (entry !== undefined) {
1079 const actualName = yarnResolutionName(entry.resolution) ?? packageName;
1080 resolved[packageName] = lockedInstallSpec(
1081 packageName,
1082 actualName,
1083 entry.version,
1084 );
1085 roots.push({
1086 node: entry,
1087 rootSelector: `${actualName}@${entry.version}`,
1088 });
1089 }
1090 }
1091
1092 addLockedGraphOverrides(
1093 overrides,
1094 roots,
1095 (entry) => entry,
1096 (entry) =>
1097 Object.entries(entry.dependencies).flatMap(
1098 ([dependencyName, dependencySpecifier]): ReadonlyArray<
1099 LockedGraphDependency<YarnLockEntry>
1100 > => {
1101 const child = findYarnEntry(
1102 entries,
1103 dependencyName,
1104 dependencySpecifier,
1105 );
1106 if (child === undefined) return [];
1107 const actualName =
1108 yarnResolutionName(child.resolution) ?? dependencyName;
1109 return [
1110 {
1111 dependencyName,
1112 installSpec: lockedInstallSpec(
1113 dependencyName,
1114 actualName,
1115 child.version,
1116 ),
1117 node: child,
1118 },
1119 ];
1120 },
1121 ),
1122 );
1123 return { resolved, overrides };
1124};
1125
1126const dependencySpecifiers = (entry: JsonRecord): JsonRecord => ({
1127 ...asRecord(entry.dependencies),
1128 ...asRecord(entry.optionalDependencies),
1129 ...asRecord(entry.peerDependencies),
1130});
1131
1132const lockedInstallSpec = (
1133 dependencyName: string,
1134 actualName: string,
1135 version: string,
1136): string =>
1137 actualName === dependencyName ? version : `npm:${actualName}@${version}`;
1138
1139const npmLockedInstallSpec = (
1140 dependencyName: string,
1141 entry: JsonRecord | undefined,
1142 version: string,
1143): string =>
1144 lockedInstallSpec(
1145 dependencyName,
1146 stringValue(entry?.name) ?? dependencyName,
1147 version,
1148 );
1149
1150const resolvePackageLockDependencyPath = (
1151 packages: JsonRecord,
1152 packagePath: string,
1153 dependencyName: string,
1154): string | undefined => {
1155 let current = packagePath;
1156 while (true) {
1157 const candidate = `${current}/node_modules/${dependencyName}`;
1158 if (packages[candidate] !== undefined) return candidate;
1159 const marker = current.lastIndexOf("/node_modules/");
1160 if (marker === -1) break;
1161 current = current.slice(0, marker);
1162 }
1163 const rootCandidate = `node_modules/${dependencyName}`;
1164 return packages[rootCandidate] === undefined ? undefined : rootCandidate;
1165};
1166
1167const addPackageLockV1Overrides = (
1168 dependencies: JsonRecord,
1169 overrides: MutablePackageOverrides,
1170 rootNames: ReadonlyArray<string>,
1171): void => {
1172 type Node = {
1173 readonly entry: JsonRecord;
1174 readonly scopes: ReadonlyArray<JsonRecord>;
1175 };
1176 const roots = rootNames.flatMap(
1177 (rootName): ReadonlyArray<LockedGraphRoot<Node>> => {
1178 const entry = asRecord(dependencies[rootName]);
1179 const version = stringValue(entry?.version);
1180 return entry === undefined || version === undefined
1181 ? []
1182 : [
1183 {
1184 node: { entry, scopes: [dependencies] },
1185 rootSelector: `${stringValue(entry.name) ?? rootName}@${version}`,
1186 },
1187 ];
1188 },
1189 );
1190 addLockedGraphOverrides(
1191 overrides,
1192 roots,
1193 ({ entry }) => entry,
1194 ({ entry, scopes }) => {
1195 const nested = asRecord(entry.dependencies) ?? {};
1196 const requires = asRecord(entry.requires) ?? {};
1197 return Object.keys(requires).flatMap(
1198 (dependencyName): ReadonlyArray<LockedGraphDependency<Node>> => {
1199 const child = [nested, ...scopes]
1200 .map((scope) => asRecord(scope[dependencyName]))
1201 .find((candidate) => candidate !== undefined);
1202 const version = stringValue(child?.version);
1203 if (child === undefined || version === undefined) return [];
1204 return [
1205 {
1206 dependencyName,
1207 installSpec: npmLockedInstallSpec(dependencyName, child, version),
1208 node: { entry: child, scopes: [nested, ...scopes] },
1209 },
1210 ];
1211 },
1212 );
1213 },
1214 );
1215};
1216
1217const pnpmInstallSpec = (
1218 dependencyName: string,
1219 rawVersion: string | undefined,
1220): string | undefined => {
1221 if (
1222 rawVersion === undefined ||
1223 rawVersion.startsWith("link:") ||
1224 rawVersion.startsWith("workspace:") ||
1225 rawVersion.startsWith("file:")
1226 ) {
1227 return undefined;
1228 }
1229 let version = rawVersion.startsWith("/") ? rawVersion.slice(1) : rawVersion;
1230 const peerSuffix = version.indexOf("(");
1231 if (peerSuffix !== -1) version = version.slice(0, peerSuffix);
1232 if (version.startsWith("npm:")) {
1233 const descriptor = parsePackageDescriptor(version.slice("npm:".length));
1234 return descriptor === undefined
1235 ? undefined
1236 : lockedInstallSpec(dependencyName, descriptor.name, descriptor.version);
1237 }
1238 if (version.startsWith(`${dependencyName}@`)) {
1239 version = version.slice(dependencyName.length + 1);
1240 }
1241 return version;
1242};
1243
1244const findPnpmPackageKey = (
1245 packages: JsonRecord,
1246 dependencyName: string,
1247 rawVersion: string | undefined,
1248): string | undefined => {
1249 if (rawVersion === undefined) return undefined;
1250 let version = rawVersion.startsWith("/") ? rawVersion.slice(1) : rawVersion;
1251 let actualName = dependencyName;
1252 if (version.startsWith("npm:")) {
1253 const descriptor = parsePackageDescriptor(version.slice("npm:".length));
1254 if (descriptor === undefined) return undefined;
1255 actualName = descriptor.name;
1256 version = descriptor.version;
1257 } else if (version.startsWith(`${dependencyName}@`)) {
1258 version = version.slice(dependencyName.length + 1);
1259 }
1260 const candidate = `${actualName}@${version}`;
1261 if (packages[candidate] !== undefined) return candidate;
1262 const legacyCandidate = `/${candidate}`;
1263 return packages[legacyCandidate] === undefined ? undefined : legacyCandidate;
1264};
1265
1266const parsePnpmPackageKey = (
1267 packageKey: string,
1268): { name: string; version: string } | undefined => {
1269 let descriptor = packageKey.startsWith("/")
1270 ? packageKey.slice(1)
1271 : packageKey;
1272 const peerSuffix = descriptor.indexOf("(");
1273 if (peerSuffix !== -1) descriptor = descriptor.slice(0, peerSuffix);
1274 return parsePackageDescriptor(descriptor);
1275};
1276
1277const parsePackageDescriptor = (
1278 descriptor: string,
1279): { name: string; version: string } | undefined => {
1280 const separator = descriptor.lastIndexOf("@");
1281 if (separator <= 0 || separator === descriptor.length - 1) return undefined;
1282 return {
1283 name: descriptor.slice(0, separator),
1284 version: descriptor.slice(separator + 1),
1285 };
1286};
1287
1288const npmAliasName = (dependencyName: string, specifier: string): string => {
1289 if (!specifier.startsWith("npm:")) return dependencyName;
1290 return (
1291 parsePackageDescriptor(specifier.slice("npm:".length))?.name ??
1292 dependencyName
1293 );
1294};
1295
1296const findBunPackageDescriptor = (
1297 packages: JsonRecord,
1298 candidateKeys: ReadonlyArray<string>,
1299 expectedName: string,
1300): { key: string; name: string; version: string } | undefined => {
1301 for (const candidateKey of candidateKeys) {
1302 const descriptor = parseBunPackageDescriptor(packages[candidateKey]);
1303 if (descriptor?.name === expectedName)
1304 return { key: candidateKey, ...descriptor };
1305 }
1306 const matches = Object.entries(packages)
1307 .map(([key, value]) => {
1308 const descriptor = parseBunPackageDescriptor(value);
1309 return descriptor === undefined ? undefined : { key, ...descriptor };
1310 })
1311 .filter(
1312 (entry): entry is { key: string; name: string; version: string } =>
1313 entry?.name === expectedName,
1314 );
1315 return matches.length === 1 ? matches[0] : undefined;
1316};
1317
1318interface YarnLockEntry {
1319 readonly selectors: ReadonlyArray<string>;
1320 readonly version: string;
1321 readonly resolution?: string;
1322 readonly dependencies: Readonly<Record<string, string>>;
1323}
1324
1325/**
1326 * Parses a yarn lockfile into a top-level record. Yarn berry lockfiles are
1327 * valid YAML; the frozen v1 format (`key "value"` pairs without colons) gets
1328 * a small dedicated parser so no yarn dependency is required.
1329 */
1330const parseYarnLockfile = (content: string): JsonRecord => {
1331 if (!content.includes("# yarn lockfile v1")) {
1332 try {
1333 const parsed = asRecord(parseYaml(content));
1334 if (parsed !== undefined) return parsed;
1335 } catch {
1336 // Fall through to the v1 parser for YAML-incompatible files.
1337 }
1338 }
1339 return parseYarnV1Lockfile(content);
1340};
1341
1342const parseYarnV1Lockfile = (content: string): JsonRecord => {
1343 const root: JsonRecord = {};
1344 const stack: Array<{ indent: number; node: JsonRecord }> = [
1345 { indent: -1, node: root },
1346 ];
1347 for (const rawLine of content.split("\n")) {
1348 const line = rawLine.replace(/\r$/, "");
1349 const text = line.trim();
1350 if (text === "" || text.startsWith("#")) continue;
1351 const indent = line.length - line.trimStart().length;
1352 while (stack.length > 1 && indent <= stack[stack.length - 1]!.indent) {
1353 stack.pop();
1354 }
1355 const parent = stack[stack.length - 1]!.node;
1356 if (text.endsWith(":")) {
1357 // Block header: an entry selector list or a nested map like
1358 // `dependencies:`. Individual selectors may be quoted.
1359 const key = text
1360 .slice(0, -1)
1361 .split(",")
1362 .map((selector) => stripYarnQuotes(selector.trim()))
1363 .join(", ");
1364 const child: JsonRecord = {};
1365 parent[key] = child;
1366 stack.push({ indent, node: child });
1367 } else {
1368 // `key "value"` pair; the key itself may be quoted (scoped packages).
1369 let key: string;
1370 let rest: string;
1371 if (text.startsWith('"')) {
1372 const end = text.indexOf('"', 1);
1373 if (end === -1) continue;
1374 key = text.slice(1, end);
1375 rest = text.slice(end + 1).trim();
1376 } else {
1377 const separator = text.indexOf(" ");
1378 if (separator === -1) continue;
1379 key = text.slice(0, separator);
1380 rest = text.slice(separator + 1).trim();
1381 }
1382 parent[key] = stripYarnQuotes(rest);
1383 }
1384 }
1385 return root;
1386};
1387
1388const stripYarnQuotes = (value: string): string =>
1389 value.startsWith('"') && value.endsWith('"') && value.length >= 2
1390 ? value.slice(1, -1)
1391 : value;
1392
1393const parseYarnEntries = (content: string): ReadonlyArray<YarnLockEntry> => {
1394 return Object.entries(parseYarnLockfile(content)).flatMap(
1395 ([selectorList, value]): ReadonlyArray<YarnLockEntry> => {
1396 if (selectorList === "__metadata") return [];
1397 const entry = asRecord(value);
1398 const version = stringValue(entry?.version);
1399 if (version === undefined) return [];
1400 const dependencies = Object.fromEntries(
1401 Object.entries({
1402 ...asRecord(entry?.dependencies),
1403 ...asRecord(entry?.optionalDependencies),
1404 }).filter(
1405 (item): item is [string, string] => typeof item[1] === "string",
1406 ),
1407 );
1408 return [
1409 {
1410 selectors: selectorList.split(",").map((selector) => selector.trim()),
1411 version,
1412 resolution: stringValue(entry?.resolution),
1413 dependencies,
1414 },
1415 ];
1416 },
1417 );
1418};
1419
1420const findYarnEntry = (
1421 entries: ReadonlyArray<YarnLockEntry>,
1422 dependencyName: string,
1423 specifier: string,
1424): YarnLockEntry | undefined => {
1425 const expected = `${dependencyName}@${specifier}`;
1426 const berryExpected = specifier.startsWith("npm:")
1427 ? expected
1428 : `${dependencyName}@npm:${specifier}`;
1429 return entries.find((entry) =>
1430 entry.selectors.some(
1431 (selector) => selector === expected || selector === berryExpected,
1432 ),
1433 );
1434};
1435
1436const yarnResolutionName = (
1437 resolution: string | undefined,
1438): string | undefined => {
1439 if (resolution === undefined) return undefined;
1440 const npmMarker = resolution.indexOf("@npm:");
1441 return npmMarker === -1 ? undefined : resolution.slice(0, npmMarker);
1442};
1443
1444const dependencyEntry = (
1445 importer: JsonRecord | undefined,
1446 packageName: string,
1447): unknown =>
1448 asRecord(importer?.dependencies)?.[packageName] ??
1449 asRecord(importer?.optionalDependencies)?.[packageName] ??
1450 asRecord(importer?.devDependencies)?.[packageName];
1451
1452const dependencyValue = (
1453 importer: JsonRecord | undefined,
1454 packageName: string,
1455): string | undefined => stringValue(dependencyEntry(importer, packageName));
1456
1457const declaredPackageVersion = (
1458 packageJson: PackageJson,
1459 packageName: string,
1460): string | undefined =>
1461 packageJson.dependencies?.[packageName] ??
1462 packageJson.optionalDependencies?.[packageName] ??
1463 packageJson.devDependencies?.[packageName];
1464
1465const nodeModulesLookupPaths = (
1466 importer: string,
1467 packageName: string,
1468): ReadonlyArray<string> => {
1469 const paths: string[] = [];
1470 let current = importer;
1471 while (true) {
1472 paths.push(
1473 current === ""
1474 ? `node_modules/${packageName}`
1475 : `${current}/node_modules/${packageName}`,
1476 );
1477 if (current === "") break;
1478 const separator = current.lastIndexOf("/");
1479 current = separator === -1 ? "" : current.slice(0, separator);
1480 }
1481 return paths;
1482};
1483
1484const parseBunPackageDescriptor = (
1485 value: unknown,
1486): { name: string; version: string } | undefined => {
1487 if (!Array.isArray(value) || typeof value[0] !== "string") {
1488 return undefined;
1489 }
1490 return parsePackageDescriptor(value[0]);
1491};
1492
1493/**
1494 * Parses `bun.lock` JSONC (comments + trailing commas) without relying on the
1495 * Bun runtime, so lockfile pinning also works when alchemy runs under Node.
1496 */
1497const parseJsonc = (content: string): unknown =>
1498 JSON.parse(stripJsonc(content));
1499
1500const stripJsonc = (content: string): string => {
1501 let out = "";
1502 let i = 0;
1503 const length = content.length;
1504 while (i < length) {
1505 const char = content[i]!;
1506 if (char === '"') {
1507 out += char;
1508 i++;
1509 while (i < length) {
1510 const stringChar = content[i]!;
1511 out += stringChar;
1512 i++;
1513 if (stringChar === "\\") {
1514 if (i < length) {
1515 out += content[i];
1516 i++;
1517 }
1518 } else if (stringChar === '"') {
1519 break;
1520 }
1521 }
1522 continue;
1523 }
1524 if (char === "/" && content[i + 1] === "/") {
1525 while (i < length && content[i] !== "\n") i++;
1526 continue;
1527 }
1528 if (char === "/" && content[i + 1] === "*") {
1529 i += 2;
1530 while (i < length && !(content[i] === "*" && content[i + 1] === "/")) i++;
1531 i += 2;
1532 continue;
1533 }
1534 if (char === ",") {
1535 // Drop the comma when the next significant token closes the container.
1536 let j = i + 1;
1537 while (j < length) {
1538 const nextChar = content[j]!;
1539 if (nextChar === "/" && content[j + 1] === "/") {
1540 while (j < length && content[j] !== "\n") j++;
1541 } else if (nextChar === "/" && content[j + 1] === "*") {
1542 j += 2;
1543 while (j < length && !(content[j] === "*" && content[j + 1] === "/"))
1544 j++;
1545 j += 2;
1546 } else if (
1547 nextChar === " " ||
1548 nextChar === "\t" ||
1549 nextChar === "\n" ||
1550 nextChar === "\r"
1551 ) {
1552 j++;
1553 } else {
1554 break;
1555 }
1556 }
1557 if (content[j] === "}" || content[j] === "]") {
1558 i++;
1559 continue;
1560 }
1561 out += char;
1562 i++;
1563 continue;
1564 }
1565 out += char;
1566 i++;
1567 }
1568 return out;
1569};
1570
1571const asRecord = (value: unknown): JsonRecord | undefined =>
1572 typeof value === "object" && value !== null && !Array.isArray(value)
1573 ? (value as JsonRecord)
1574 : undefined;
1575
1576const stringValue = (value: unknown): string | undefined =>
1577 typeof value === "string" ? value : undefined;
1578
1579const resolveCatalogVersion = (
1580 cwd: string,
1581 packageName: string,
1582 version: string,
1583) =>
1584 Effect.gen(function* () {
1585 const fs = yield* FileSystem.FileSystem;
1586 const workspacePath = yield* findUp(cwd, ["pnpm-workspace.yaml"]);
1587 if (workspacePath !== undefined) {
1588 const content = yield* fs.readFileString(workspacePath);
1589 const workspace = parseYaml(content) as CatalogSource;
1590 const resolved = resolveCatalogEntry(packageName, version, workspace);
1591 if (resolved === undefined) {
1592 return yield* Effect.fail(
1593 new BundleError({
1594 message: `Could not resolve catalog version for '${packageName}' (${version}) from ${workspacePath}. Pin an npm-compatible version explicitly.`,
1595 }),
1596 );
1597 }
1598 return resolved;
1599 }
1600
1601 const bunResolved = yield* resolveBunCatalogVersion(
1602 cwd,
1603 packageName,
1604 version,
1605 );
1606 if (bunResolved !== undefined) {
1607 return bunResolved;
1608 }
1609
1610 return yield* Effect.fail(
1611 new BundleError({
1612 message: `Could not resolve catalog version for '${packageName}' (${version}): no pnpm-workspace.yaml or Bun catalog found. Pin an npm-compatible version explicitly.`,
1613 }),
1614 );
1615 });
1616
1617const findUp = (
1618 cwd: string,
1619 filenames: ReadonlyArray<string>,
1620): Effect.Effect<
1621 string | undefined,
1622 BundleError,
1623 FileSystem.FileSystem | Path.Path
1624> =>
1625 Effect.gen(function* () {
1626 const fs = yield* FileSystem.FileSystem;
1627 const path = yield* Path.Path;
1628 for (const filename of filenames) {
1629 const candidate = path.join(cwd, filename);
1630 if (yield* fs.exists(candidate).pipe(Effect.mapError(toBundleError))) {
1631 return candidate;
1632 }
1633 }
1634 const parent = path.dirname(cwd);
1635 if (parent === cwd) {
1636 return undefined;
1637 }
1638 return yield* findUp(parent, filenames);
1639 });
1640
1641const readNearestLockfileFingerprint = (
1642 cwd: string,
1643): Effect.Effect<
1644 PackageInstallIdentity["lockfile"],
1645 BundleError,
1646 FileSystem.FileSystem | Path.Path
1647> =>
1648 Effect.gen(function* () {
1649 const fs = yield* FileSystem.FileSystem;
1650 const path = yield* Path.Path;
1651 const lockfilePath = yield* findUp(cwd, lockfileNames);
1652 if (lockfilePath === undefined) {
1653 return undefined;
1654 }
1655 const content = yield* fs.readFile(lockfilePath);
1656 return {
1657 name: path.basename(lockfilePath),
1658 hash: yield* sha256(content),
1659 };
1660 }).pipe(
1661 Effect.mapError(
1662 (cause) =>
1663 new BundleError({
1664 message: `Failed to read package-manager lockfile for Lambda externals from '${cwd}'`,
1665 cause,
1666 }),
1667 ),
1668 );
1669
1670const resolveBunCatalogVersion = (
1671 cwd: string,
1672 packageName: string,
1673 version: string,
1674) =>
1675 Effect.gen(function* () {
1676 const fs = yield* FileSystem.FileSystem;
1677 const path = yield* Path.Path;
1678 let currentDir = cwd;
1679 while (true) {
1680 const packagePath = path.join(currentDir, "package.json");
1681 if (yield* fs.exists(packagePath)) {
1682 const content = yield* fs.readFileString(packagePath);
1683 const manifest = JSON.parse(content) as PackageJson;
1684 const source = parseBunCatalogSource(manifest);
1685 if (source !== undefined) {
1686 const resolved = resolveCatalogEntry(packageName, version, source);
1687 if (resolved === undefined) {
1688 return yield* Effect.fail(
1689 new BundleError({
1690 message: `Could not resolve catalog version for '${packageName}' (${version}) from ${packagePath}. Pin an npm-compatible version explicitly.`,
1691 }),
1692 );
1693 }
1694 return resolved;
1695 }
1696 }
1697
1698 const parent = path.dirname(currentDir);
1699 if (parent === currentDir) {
1700 return undefined;
1701 }
1702 currentDir = parent;
1703 }
1704 });
1705
1706const parseBunCatalogSource = (
1707 manifest: PackageJson,
1708): CatalogSource | undefined => {
1709 const workspaceSource = parseBunWorkspacesCatalogSource(manifest.workspaces);
1710 const catalog = workspaceSource?.catalog ?? manifest.catalog;
1711 const catalogs =
1712 manifest.catalogs !== undefined || workspaceSource?.catalogs !== undefined
1713 ? { ...manifest.catalogs, ...workspaceSource?.catalogs }
1714 : undefined;
1715 return catalog !== undefined || catalogs !== undefined
1716 ? { catalog, catalogs }
1717 : undefined;
1718};
1719
1720const parseBunWorkspacesCatalogSource = (
1721 workspaces: unknown,
1722): CatalogSource | undefined => {
1723 if (
1724 typeof workspaces !== "object" ||
1725 workspaces === null ||
1726 Array.isArray(workspaces)
1727 ) {
1728 return undefined;
1729 }
1730 const record = workspaces as PackageJson;
1731 if (record.catalog === undefined && record.catalogs === undefined) {
1732 return undefined;
1733 }
1734 return {
1735 catalog: record.catalog,
1736 catalogs: record.catalogs,
1737 };
1738};
1739
1740const resolveCatalogEntry = (
1741 packageName: string,
1742 version: string,
1743 source: CatalogSource,
1744): string | undefined => {
1745 const catalogName = version.slice("catalog:".length).trim();
1746 let catalog: Record<string, string> | undefined;
1747 if (catalogName === "" || catalogName === "default") {
1748 catalog = source.catalog ?? source.catalogs?.default;
1749 } else {
1750 catalog = source.catalogs?.[catalogName];
1751 }
1752 return catalog?.[packageName];
1753};
1754
1755const readArtifactFiles = (directory: string) =>
1756 Effect.gen(function* () {
1757 const fs = yield* FileSystem.FileSystem;
1758 const path = yield* Path.Path;
1759 const relativePaths = yield* fs.readDirectory(directory, {
1760 recursive: true,
1761 });
1762 const files: InstalledPackageFile[] = [];
1763 for (const relativePath of [...relativePaths].sort((a, b) =>
1764 a.localeCompare(b),
1765 )) {
1766 const absolutePath = path.join(directory, relativePath);
1767 const linkTarget = yield* fs
1768 .readLink(absolutePath)
1769 .pipe(Effect.catch(() => Effect.succeed(undefined)));
1770 if (linkTarget !== undefined) {
1771 files.push({
1772 path: relativePath.replaceAll("\\", "/"),
1773 content: yield* Effect.sync(() =>
1774 new TextEncoder().encode(linkTarget),
1775 ),
1776 mode: symbolicLinkMode,
1777 });
1778 continue;
1779 }
1780 const stat = yield* fs.stat(absolutePath);
1781 if (stat.type !== "File") continue;
1782 files.push({
1783 path: relativePath.replaceAll("\\", "/"),
1784 content: yield* fs.readFile(absolutePath),
1785 mode: stat.mode,
1786 });
1787 }
1788 return files;
1789 }).pipe(
1790 Effect.mapError(
1791 (cause) =>
1792 new BundleError({
1793 message: "Failed to read installed Lambda external packages",
1794 cause,
1795 }),
1796 ),
1797 );
1798
1799function toBundleError(cause: unknown): BundleError {
1800 return cause instanceof BundleError
1801 ? cause
1802 : new BundleError({
1803 message: cause instanceof Error ? cause.message : String(cause),
1804 cause,
1805 });
1806}