zyrk 0.4.8 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -141,8 +141,10 @@ zyrk add package-name # edit this importer, preserve other select
141
141
 
142
142
  Frozen/offline installs, live peers, patches, mutable registry packages, inspection,
143
143
  and repair have workspace fixtures. Builds that reach live workspace sources are
144
- blocked before execution. Workspace npm-lock adoption and portable workspace
145
- export still need adapters and fail explicitly. Recursive execution supports
144
+ blocked before execution. Existing npm v2/v3 workspace `package-lock.json` selections can be adopted with
145
+ `zyrk import --from npm`, preserving exact versions, aliases and live members.
146
+ Adoption validates the complete graph before project changes; npm shrinkwrap
147
+ precedence and unsupported bundled/external links fail explicitly. Workspace-root production exports include live member closures and assets. Recursive execution supports
146
148
  member scripts; filters are exact names or paths, not arbitrary glob expressions.
147
149
 
148
150
  ### Run a one-off tool
@@ -230,9 +232,12 @@ node ./dist-runtime/dist/server.js
230
232
 
231
233
  The exporter validates that links stay inside the output and excludes the host
232
234
  store. Generic export supports shared production graphs and relocatable private
233
- JavaScript outputs, including selected patches and generated files. Native binaries,
234
- host-path-dependent outputs, workspace export, and arbitrary shell startup adapters
235
- remain unsupported. Exports validate active generation/profile metadata and reject
235
+ JavaScript outputs, including selected patches and generated files. Workspace-root exports include live production members, peer contexts, cycles
236
+ and assets. A restricted Linux x64 ELF64 N-API copy recipe supports dependency-free
237
+ addons using `napi_create_int32` and `napi_set_named_property` with matching
238
+ Node/ABI/build profiles. Native bytes are copied unchanged after loader validation.
239
+ Other native formats/API imports, external libraries, loader paths, host-path-dependent
240
+ outputs and arbitrary shell startup adapters remain unsupported. Exports validate active generation/profile metadata and reject
236
241
  credentials or escaping links. Structural validation does not prove application health. Run an
237
242
  application smoke test from the exported directory with host-store access removed.
238
243
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- v0.4.8
1
+ v0.5.0
@@ -1 +1 @@
1
- {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/commands/deploy.ts"],"names":[],"mappings":"AAyDA,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC7B,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IACvB,MAAM,EAAE;QAAE,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAC1C,MAAM,EAAE;QAAE,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAC1C,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB,CAAC;AAwEF,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,MAAM,CAAC,CA8YjB"}
1
+ {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/commands/deploy.ts"],"names":[],"mappings":"AAmEA,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC7B,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IACvB,MAAM,EAAE;QAAE,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAC1C,MAAM,EAAE;QAAE,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAC1C,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB,CAAC;AAwEF,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,MAAM,CAAC,CA0ZjB"}
@@ -1,7 +1,11 @@
1
- /** Self-contained pure-package deployment and explicit local Docker commands. */
1
+ /** Self-contained runtime deployment and explicit local Docker commands. */
2
2
  import { constants } from "node:fs";
3
3
  import { open, copyFile, chmod, lstat, mkdir, mkdtemp, readFile, readdir, readlink, realpath, rename, rm, symlink, writeFile, } from "node:fs/promises";
4
4
  import * as path from "node:path";
5
+ import { discoverWorkspaces, findWorkspaceRoot, } from "../install/workspaces.js";
6
+ import { readWorkspaceLock } from "../install/workspace-lock.js";
7
+ import { validateNativeExport } from "./native-export.js";
8
+ import { workspaceRegistryOccurrences, workspaceContextId, } from "../install/workspace-graph.js";
5
9
  import { findProjectBoundary, resolveStoreRoot } from "../config/paths.js";
6
10
  import { ZyrkError } from "../errors.js";
7
11
  import { lockedGraph } from "../install/lock.js";
@@ -81,7 +85,8 @@ function output(options, result, human) {
81
85
  : human + "\n");
82
86
  }
83
87
  export async function runDeployCommand(options) {
84
- const boundary = await findProjectBoundary(options.cwd);
88
+ const boundary = (await findWorkspaceRoot(options.cwd)) ??
89
+ (await findProjectBoundary(options.cwd));
85
90
  if (!boundary)
86
91
  throw error("ZYRK_MANIFEST_INVALID", "Cannot find a project to deploy.");
87
92
  const project = await realpath(boundary);
@@ -125,7 +130,7 @@ export async function runDeployCommand(options) {
125
130
  const rawLock = await readJson(path.join(project, "package-lock.json"));
126
131
  if (manifest.workspaces !== undefined ||
127
132
  (object(rawLock.zyrk) && rawLock.zyrk.version === 2))
128
- throw error("ZYRK_DEPLOY_NONRELOCATABLE", "Workspace runtime exports require a validated live-importer deployment adapter.");
133
+ return await exportWorkspace(options, project, manifest, state, out, parent, files, entry);
129
134
  const doc = validateLockDoc(rawLock, "project package-lock.json");
130
135
  const resolved = lockedGraph(doc);
131
136
  const privateGraph = state.linker === "private";
@@ -168,6 +173,8 @@ export async function runDeployCommand(options) {
168
173
  root: resolveStoreRoot({ env: options.env }).root,
169
174
  });
170
175
  store.requireInitialized();
176
+ if (within(store.root, out))
177
+ throw error("ZYRK_DEPLOY_ESCAPE", "Deployment output must be outside the managed store.");
171
178
  const barrier = await acquireLock(path.join(store.paths.locksDir, "liveness.lock"), { name: "portable deployment retention" });
172
179
  try {
173
180
  const installedKeys = new Set(Array.isArray(state.contexts)
@@ -261,9 +268,7 @@ export async function runDeployCommand(options) {
261
268
  await symlink(e.target, target);
262
269
  }
263
270
  else {
264
- if (e.path.endsWith(".node"))
265
- throw error("ZYRK_DEPLOY_NONRELOCATABLE", "Native addon runtime compatibility requires a validated deployment adapter.");
266
- await copyFile(path.join(sourceDir, e.path), target);
271
+ await scanOrCopyPrivateFile(path.join(sourceDir, e.path), target, [project, store.root], privateBudget);
267
272
  await chmod(target, e.mode === "exec" ? 0o755 : 0o644);
268
273
  }
269
274
  }
@@ -311,8 +316,7 @@ export async function runDeployCommand(options) {
311
316
  const entryStat = await lstat(path.join(stage, entry)).catch(() => null);
312
317
  if (!entryStat?.isFile())
313
318
  throw error("ZYRK_DEPLOY_NONRELOCATABLE", "The selected runtime Node entry is missing.");
314
- if (privateGraph)
315
- await scanPrivateExport(stage, [project, store.root], privateBudget);
319
+ await scanPrivateExport(stage, [project, store.root], privateBudget);
316
320
  await validateLinks(stage, stage);
317
321
  await writeFile(path.join(stage, "zyrk-deploy.json"), JSON.stringify({
318
322
  schemaVersion: 1,
@@ -473,7 +477,7 @@ async function docker(options, project, manifest) {
473
477
  buildScript: build ?? null,
474
478
  port: Number(portText),
475
479
  }, null, 2) + "\n",
476
- "ZYRK_DOCKER.md": `Review Dockerfile and .dockerignore before building. Runtime files: ${files.join(", ")}. Runtime entry: node ${entry}. Build script: ${build ?? "none"}. Container port: ${portText}.\n\nThe pinned CLI tarball must remain at its declared path. Dependencies are fetched with an optional BuildKit npmrc secret, then installed, built, and exported without networking or registry secrets. No host node_modules or store enters the runtime image. Adapt build inputs and credential exclusions to your project; generated files do not prove application health. Relocatable JavaScript output from ready private generations is supported, including patches and approved build output. Native outputs, embedded host paths, live workspace exports, and arbitrary shell start commands require a validated adapter and are rejected by this generic exporter.\n\nBuild: zyrk docker build --tag your-app:local\nRun: zyrk docker run --tag your-app:local --port ${portText}\nSupply private registry credentials to Docker BuildKit directly with docker build --secret id=npmrc,src=<credential-file> when required; zyrk never embeds them.\n`,
480
+ "ZYRK_DOCKER.md": `Review Dockerfile and .dockerignore before building. Runtime files: ${files.join(", ")}. Runtime entry: node ${entry}. Build script: ${build ?? "none"}. Container port: ${portText}.\n\nThe pinned CLI tarball must remain at its declared path. Dependencies are fetched with an optional BuildKit npmrc secret, then installed, built, and exported without networking or registry secrets. No host node_modules or store enters the runtime image. Adapt build inputs and credential exclusions to your project; generated files do not prove application health. Relocatable JavaScript output from ready private generations is supported, including patches and approved build output. Live workspace runtime closures and a restricted Linux ELF64 N-API native copy recipe are supported. The native v1 recipe is limited to Linux x64 ELF64 addons importing napi_create_int32 and napi_set_named_property (plus validated weak toolchain hooks), with exact installed Node/ABI/profile checks. It copies bytes unchanged and runs no package code during export. Native addons with external libraries, loader search paths, unknown formats or API imports, embedded absolute paths, and arbitrary shell start commands require another validated adapter and are rejected by this exporter. Workspace member payloads include their live files and assets except node_modules; credential filenames and links outside each source root are rejected.\n\nBuild: zyrk docker build --tag your-app:local\nRun: zyrk docker run --tag your-app:local --port ${portText}\nSupply private registry credentials to Docker BuildKit directly with docker build --secret id=npmrc,src=<credential-file> when required; zyrk never embeds them.\n`,
477
481
  };
478
482
  for (const name of Object.keys(contents))
479
483
  if (await lstat(path.join(project, name)).catch(() => null))
@@ -667,8 +671,23 @@ async function scanOrCopyPrivateFile(source, target, forbidden, budget) {
667
671
  const metadata = await input.stat();
668
672
  if (!metadata.isFile())
669
673
  throw error("ZYRK_DEPLOY_ESCAPE", "Private payload contains a special filesystem entry.");
670
- if (/\.node$/i.test(source))
671
- throw error("ZYRK_DEPLOY_NONRELOCATABLE", "Native dependency outputs require a validated export adapter.");
674
+ let nativeSnapshot;
675
+ if (/\.node$/i.test(source)) {
676
+ if (metadata.size > 64 * 1024 * 1024)
677
+ throw error("ZYRK_DEPLOY_NONRELOCATABLE", "Native addon exceeds the validated export size budget.");
678
+ const bytes = Buffer.alloc(metadata.size);
679
+ let offset = 0;
680
+ while (offset < bytes.length) {
681
+ const read = await input.read(bytes, offset, bytes.length - offset, offset);
682
+ if (!read.bytesRead)
683
+ throw error("ZYRK_DEPLOY_NONRELOCATABLE", "Native addon changed during export validation.");
684
+ offset += read.bytesRead;
685
+ }
686
+ validateNativeExport(bytes);
687
+ // Scan and copy these exact validated bytes. Private source files remain
688
+ // mutable; reopening or rereading them would discard the validation.
689
+ nativeSnapshot = bytes;
690
+ }
672
691
  if (target !== undefined)
673
692
  output = await open(target, "wx", metadata.mode & 0o111 ? 0o755 : 0o644);
674
693
  const needles = [
@@ -684,16 +703,23 @@ async function scanOrCopyPrivateFile(source, target, forbidden, budget) {
684
703
  let fileBytes = 0;
685
704
  const chunk = Buffer.allocUnsafe(1024 * 1024);
686
705
  for (;;) {
687
- const { bytesRead } = await input.read(chunk, 0, chunk.length, null);
706
+ let bytes;
707
+ if (nativeSnapshot) {
708
+ bytes = nativeSnapshot.subarray(fileBytes, fileBytes + chunk.length);
709
+ }
710
+ else {
711
+ const read = await input.read(chunk, 0, chunk.length, null);
712
+ bytes = chunk.subarray(0, read.bytesRead);
713
+ }
714
+ const bytesRead = bytes.length;
688
715
  if (!bytesRead)
689
716
  break;
690
- const bytes = chunk.subarray(0, bytesRead);
691
717
  fileBytes += bytesRead;
692
718
  budget.bytes += bytesRead;
693
719
  checkPrivateBudget(budget);
694
720
  if (fileBytes > 512 * 1024 * 1024)
695
721
  throw error("ZYRK_DEPLOY_NONRELOCATABLE", "A private generated file exceeds the deployment byte budget.");
696
- if (first && nativeMagic(bytes))
722
+ if (first && nativeMagic(bytes) && !/\.node$/i.test(source))
697
723
  throw error("ZYRK_DEPLOY_NONRELOCATABLE", "Native dependency outputs require a validated export adapter.");
698
724
  first = false;
699
725
  const inspect = Buffer.concat([previous, bytes]);
@@ -759,3 +785,310 @@ async function scanPrivateExport(root, forbidden, budget) {
759
785
  await scanOrCopyPrivateFile(file, undefined, forbidden, budget);
760
786
  }
761
787
  }
788
+ /** Export the selected importer closure; live files are copied, never entered into CAS. */
789
+ async function exportWorkspace(options, project, manifest, state, out, parent, files, entry) {
790
+ const workspace = await discoverWorkspaces(project);
791
+ const locked = await readWorkspaceLock(project, workspace);
792
+ if (!locked ||
793
+ locked.doc.zyrk?.manifest !== workspace.manifestFingerprint ||
794
+ state.lockDigest !== sha512Hex(canonicalJson(locked.doc)))
795
+ throw error("ZYRK_LOCK_MISMATCH", "Workspace deployment manifests, lock and installed generation disagree.");
796
+ if (state.readiness !== "ready")
797
+ throw error("ZYRK_SCRIPT_BLOCKED", "Cannot export an unready workspace generation.");
798
+ if (state.platform !== process.platform || state.arch !== process.arch)
799
+ throw error("ZYRK_PLATFORM_UNSUPPORTED", "Workspace export requires the recorded target platform and architecture.");
800
+ const policy = object(manifest.zyrk) ? manifest.zyrk : {};
801
+ if (locked.doc.zyrk.scripts !== scriptPolicyFingerprint(policy.scripts))
802
+ throw error("ZYRK_LOCK_MISMATCH", "Workspace deployment build policy changed after installation.");
803
+ const origins = new Set(locked.graph.nodes
804
+ .filter((n) => n.kind === "registry")
805
+ .map((n) => n.origin));
806
+ if (origins.size > 1)
807
+ throw error("ZYRK_DEPLOY_NONRELOCATABLE", "Mixed-origin workspace export requires a validated registry adapter.");
808
+ if (origins.size === 1) {
809
+ const patches = await loadProjectPatches(project);
810
+ const expected = configFingerprint({
811
+ registry: [...origins][0],
812
+ linker: "shared",
813
+ platform: currentPlatform(),
814
+ ...(Array.isArray(policy.mutablePackages)
815
+ ? { mutablePackages: policy.mutablePackages }
816
+ : {}),
817
+ ...(patches.length
818
+ ? {
819
+ patches: sha512Hex(canonicalJson(patches.map((p) => ({
820
+ sourceId: p.sourceId,
821
+ digest: p.digest,
822
+ })))),
823
+ }
824
+ : {}),
825
+ });
826
+ if (locked.doc.zyrk.config !== expected)
827
+ throw error("ZYRK_LOCK_MISMATCH", "Workspace deployment configuration or patches changed after installation.");
828
+ }
829
+ const store = await Store.open({
830
+ root: resolveStoreRoot({ env: options.env }).root,
831
+ });
832
+ store.requireInitialized();
833
+ if (within(store.root, out))
834
+ throw error("ZYRK_DEPLOY_ESCAPE", "Deployment output must be outside the managed store.");
835
+ const barrier = await acquireLock(path.join(store.paths.locksDir, "liveness.lock"), { name: "workspace deployment retention" });
836
+ try {
837
+ const graph = locked.graph, byKey = new Map(graph.nodes.map((n) => [n.key, n]));
838
+ const selected = new Set();
839
+ const visit = (key) => {
840
+ if (selected.has(key))
841
+ return;
842
+ const n = byKey.get(key);
843
+ if (!n)
844
+ throw error("ZYRK_OFFLINE_MISSING", "Workspace runtime edge is missing.");
845
+ selected.add(key);
846
+ for (const e of n.edges)
847
+ if (!(n.kind === "local" && e.kind === "dev"))
848
+ visit(e.target);
849
+ };
850
+ const rootKey = graph.importers["."];
851
+ if (!rootKey)
852
+ throw error("ZYRK_LOCK_MISMATCH", "Workspace root importer is missing.");
853
+ visit(rootKey);
854
+ const directories = new Map();
855
+ for (const key of selected) {
856
+ const n = byKey.get(key);
857
+ if (n.kind === "local")
858
+ directories.set(key, workspace.importers.find((i) => i.id === n.importer).directory);
859
+ }
860
+ // Resolve recorded projection edges rather than enumerating arbitrary node_modules entries.
861
+ const queue = [...directories.keys()], checked = new Set();
862
+ while (queue.length) {
863
+ const key = queue.shift();
864
+ if (checked.has(key))
865
+ continue;
866
+ checked.add(key);
867
+ const n = byKey.get(key), directory = directories.get(key);
868
+ for (const e of n.edges) {
869
+ if (!selected.has(e.target) || (n.kind === "local" && e.kind === "dev"))
870
+ continue;
871
+ const slot = n.kind === "local" || e.name === n.name
872
+ ? path.join(directory, "node_modules", e.name)
873
+ : path.join(directory, n.name.startsWith("@") ? "../.." : "..", e.name);
874
+ const actual = await realpath(slot).catch(() => null), meta = await lstat(slot).catch(() => null);
875
+ if (!actual ||
876
+ !meta?.isSymbolicLink() ||
877
+ (directories.has(e.target) && directories.get(e.target) !== actual))
878
+ throw error("ZYRK_DEPLOY_ESCAPE", "Workspace dependency projection disagrees with its recorded context.");
879
+ directories.set(e.target, actual);
880
+ queue.push(e.target);
881
+ }
882
+ }
883
+ for (const [key, source] of directories)
884
+ if (key !== rootKey && (within(source, out) || within(out, source)))
885
+ throw error("ZYRK_DEPLOY_ESCAPE", "Deployment output overlaps a selected workspace runtime source.");
886
+ const registryNodes = workspaceRegistryOccurrences(graph);
887
+ const registryKeys = new Set(registryNodes.map((n) => n.key));
888
+ const privateKeys = new Set();
889
+ for (let changed = true; changed;) {
890
+ changed = false;
891
+ for (const n of registryNodes)
892
+ if (!privateKeys.has(n.key) &&
893
+ n.edges.some((e) => !registryKeys.has(e.target) || privateKeys.has(e.target))) {
894
+ privateKeys.add(n.key);
895
+ changed = true;
896
+ }
897
+ }
898
+ const sourceGraph = createGraphDoc(registryNodes.map((n) => ({
899
+ ...n,
900
+ deps: n.edges.filter((e) => registryKeys.has(e.target)),
901
+ })));
902
+ const pureGraph = createGraphDoc(registryNodes
903
+ .filter((n) => !privateKeys.has(n.key))
904
+ .map((n) => ({ ...n, deps: n.edges })));
905
+ const sharedDirectories = selectRealizationDirs(store, pureGraph);
906
+ const productionGraph = {
907
+ ...graph,
908
+ nodes: graph.nodes.map((n) => ({
909
+ ...n,
910
+ edges: n.edges.filter((e) => !(n.kind === "local" && e.kind === "dev")),
911
+ })),
912
+ };
913
+ const db = openStoreDb(store.paths.dbFile);
914
+ const registered = new Set();
915
+ try {
916
+ if (typeof state.generationId !== "string" ||
917
+ !/^gen1_[a-z2-7]+$/.test(state.generationId) ||
918
+ state.projectId !== `project1_${sha512Hex(project).slice(0, 32)}` ||
919
+ state.storeRoot !== store.root ||
920
+ state.workspaceRoot !== project)
921
+ throw error("ZYRK_STORE_CORRUPT", "Workspace generation identity is invalid.");
922
+ const generation = db
923
+ .prepare("SELECT readiness,state,graph_digest FROM generations WHERE generation_id=? AND project_id=?")
924
+ .get(state.generationId, state.projectId);
925
+ if (generation?.readiness !== "ready" ||
926
+ generation.state !== "active" ||
927
+ generation.graph_digest !== state.graphDigest)
928
+ throw error("ZYRK_STORE_CORRUPT", "Workspace deployment requires its active ready generation.");
929
+ const rows = db
930
+ .prepare("SELECT ri.path,ri.instance_id FROM generation_instances gi JOIN realization_instances ri ON gi.instance_id=ri.instance_id WHERE gi.generation_id=? AND ri.verification_state='verified'")
931
+ .all(state.generationId);
932
+ if (!Array.isArray(state.instances) ||
933
+ rows.length !== state.instances.length ||
934
+ rows.some((row) => !state.instances.includes(row.instance_id)))
935
+ throw error("ZYRK_STORE_CORRUPT", "Workspace instance metadata disagrees with its selected generation.");
936
+ rows.forEach((row) => registered.add(row.path));
937
+ }
938
+ finally {
939
+ db.close();
940
+ }
941
+ if (state.linker === "private" && !object(state.buildProfile))
942
+ throw error("ZYRK_DEPLOY_NONRELOCATABLE", "Workspace private generation has no supported build profile.");
943
+ const nativeProfile = state.buildProfile;
944
+ if (object(nativeProfile) &&
945
+ (typeof nativeProfile.candidate !== "string" ||
946
+ !/^[0-9a-f]{128}$/.test(nativeProfile.candidate) ||
947
+ state.privateDir !==
948
+ path.join(project, ".zyrk-local/generations", String(state.generationId))))
949
+ throw error("ZYRK_STORE_CORRUPT", "Workspace private generation profile or stable directory is invalid.");
950
+ if (object(nativeProfile) &&
951
+ (nativeProfile.node !== process.version ||
952
+ nativeProfile.abi !== process.versions.modules ||
953
+ nativeProfile.platform !== process.platform ||
954
+ nativeProfile.arch !== process.arch))
955
+ throw error("ZYRK_PLATFORM_UNSUPPORTED", "Workspace private artifacts require their exact recorded Node ABI and profile.");
956
+ for (const key of selected) {
957
+ const n = byKey.get(key), directory = directories.get(key);
958
+ if (!directory)
959
+ throw error("ZYRK_OFFLINE_MISSING", "A required workspace runtime package is not installed.");
960
+ if (n.kind === "registry") {
961
+ const context = n.name.startsWith("@")
962
+ ? path.resolve(directory, "../../..")
963
+ : path.resolve(directory, "../..");
964
+ if (!registered.has(context) ||
965
+ (!within(store.paths.realizationsDir, context) &&
966
+ !within(path.join(project, ".zyrk-local/generations", String(state.generationId)), context)))
967
+ throw error("ZYRK_DEPLOY_ESCAPE", "Workspace registry dependency is outside its registered realization.");
968
+ if (within(store.paths.realizationsDir, context)) {
969
+ if (privateKeys.has(key) ||
970
+ packageDirIn(sharedDirectories.get(key), n.name) !== directory)
971
+ throw error("ZYRK_DEPLOY_ESCAPE", "Shared workspace registry dependency selects an unexpected context.");
972
+ await verifyRealization({
973
+ store,
974
+ graph: pureGraph,
975
+ rootKey: key,
976
+ contextId: contextIdFor(pureGraph, key),
977
+ realizationDirs: sharedDirectories,
978
+ });
979
+ }
980
+ else {
981
+ const expectedContexts = privateKeys.has(key)
982
+ ? [
983
+ workspaceContextId(graph, key, String(state.projectId)),
984
+ workspaceContextId(productionGraph, key, String(state.projectId)),
985
+ ]
986
+ : [contextIdFor(sourceGraph, key)];
987
+ if (!expectedContexts.some((id) => path.join(project, ".zyrk-local/generations", String(state.generationId), id) === context))
988
+ throw error("ZYRK_DEPLOY_ESCAPE", "Private workspace dependency selects an unexpected live context.");
989
+ }
990
+ const m = await readJson(path.join(directory, "package.json"));
991
+ if (m.name !== n.name || m.version !== n.version)
992
+ throw error("ZYRK_STORE_CORRUPT", "Workspace runtime package identity disagrees with its lock.");
993
+ }
994
+ }
995
+ const stage = await mkdtemp(path.join(parent, ".zyrk-deploy-"));
996
+ try {
997
+ const budget = { entries: 0, bytes: 0, started: Date.now() }, targets = new Map([[rootKey, stage]]);
998
+ for (const key of selected)
999
+ if (key !== rootKey)
1000
+ targets.set(key, packageDirIn(path.join(stage, "node_modules/.zyrk/portable", sha512Hex(key).slice(0, 40)), byKey.get(key).name));
1001
+ for (const file of files)
1002
+ await copyApplication(path.join(project, file), path.join(stage, file), project);
1003
+ for (const key of selected) {
1004
+ const n = byKey.get(key), target = targets.get(key), source = directories.get(key);
1005
+ if (key !== rootKey) {
1006
+ const skip = new Set();
1007
+ if (n.kind === "local")
1008
+ skip.add("node_modules");
1009
+ else
1010
+ for (const e of n.edges)
1011
+ if (e.name === n.name)
1012
+ skip.add(path.join("node_modules", ...e.name.split("/")));
1013
+ await copyPrivatePayload(source, target, source, target, skip, [project, store.root], budget);
1014
+ }
1015
+ const sourceManifest = n.kind === "local"
1016
+ ? workspace.importers.find((i) => i.id === n.importer).manifest
1017
+ : await readJson(path.join(source, "package.json"));
1018
+ const exported = { ...sourceManifest };
1019
+ delete exported.devDependencies;
1020
+ delete exported.zyrk;
1021
+ delete exported.packageManager;
1022
+ delete exported.workspaces;
1023
+ await writeFile(path.join(target, "package.json"), JSON.stringify(exported, null, 2) + "\n");
1024
+ }
1025
+ for (const key of selected) {
1026
+ const n = byKey.get(key);
1027
+ for (const e of n.edges)
1028
+ if (selected.has(e.target) &&
1029
+ !(n.kind === "local" && e.kind === "dev")) {
1030
+ await relativeLink(path.join(targets.get(key), "node_modules", e.name), targets.get(e.target));
1031
+ const child = byKey.get(e.target);
1032
+ const rawBin = child.kind === "registry"
1033
+ ? child.bin
1034
+ : workspace.importers.find((i) => i.id === child.importer)
1035
+ .manifest.bin;
1036
+ const bins = typeof rawBin === "string"
1037
+ ? { [child.name.split("/").at(-1)]: rawBin }
1038
+ : object(rawBin)
1039
+ ? rawBin
1040
+ : {};
1041
+ for (const [name, relative] of Object.entries(bins)) {
1042
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name) ||
1043
+ typeof relative !== "string")
1044
+ throw error("ZYRK_DEPLOY_ESCAPE", "Workspace executable declaration is unsafe.");
1045
+ const pkg = targets.get(e.target), file = path.resolve(pkg, relative);
1046
+ if (!within(pkg, file))
1047
+ throw error("ZYRK_DEPLOY_ESCAPE", "Workspace executable escapes its runtime package.");
1048
+ const metadata = await lstat(file).catch(() => null);
1049
+ if (!metadata?.isFile())
1050
+ throw error("ZYRK_DEPLOY_NONRELOCATABLE", "A required workspace executable is missing.");
1051
+ const binDir = path.join(targets.get(key), "node_modules/.bin");
1052
+ await mkdir(binDir, { recursive: true });
1053
+ if (await lstat(path.join(binDir, name)).catch(() => null))
1054
+ throw error("ZYRK_DEPLOY_NONRELOCATABLE", "Workspace runtime executable names conflict.");
1055
+ if (metadata.mode & 0o111)
1056
+ await relativeLink(path.join(binDir, name), file);
1057
+ else
1058
+ await writeFile(path.join(binDir, name), '#!/bin/sh\nexec node "$(dirname "$0")"/' +
1059
+ shellQuote(path.relative(binDir, file).split(path.sep).join("/")) +
1060
+ ' "$@"\n', { mode: 0o755 });
1061
+ }
1062
+ }
1063
+ }
1064
+ if (!(await lstat(path.join(stage, entry)).catch(() => null))?.isFile())
1065
+ throw error("ZYRK_DEPLOY_NONRELOCATABLE", "The selected runtime Node entry is missing.");
1066
+ await scanPrivateExport(stage, [project, store.root], budget);
1067
+ await validateLinks(stage, stage);
1068
+ await writeFile(path.join(stage, "zyrk-deploy.json"), JSON.stringify({
1069
+ schemaVersion: 1,
1070
+ platform: process.platform,
1071
+ arch: process.arch,
1072
+ node: process.version,
1073
+ entry: ["node", entry],
1074
+ requirements: ["Node.js " + process.versions.node.split(".")[0]],
1075
+ packages: selected.size - 1,
1076
+ files,
1077
+ validation: "structural; application smoke test not run",
1078
+ }, null, 2) + "\n");
1079
+ await rename(stage, out);
1080
+ output(options, {
1081
+ packages: selected.size - 1,
1082
+ entry: ["node", entry],
1083
+ validation: "structural",
1084
+ }, `Exported ${selected.size - 1} workspace runtime packages. Start with: node ${entry}. Structural validation passed; no application smoke test ran.`);
1085
+ return 0;
1086
+ }
1087
+ finally {
1088
+ await rm(stage, { recursive: true, force: true });
1089
+ }
1090
+ }
1091
+ finally {
1092
+ await barrier.release();
1093
+ }
1094
+ }
@@ -0,0 +1,2 @@
1
+ export declare function validateNativeExport(bytes: Buffer): void;
2
+ //# sourceMappingURL=native-export.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-export.d.ts","sourceRoot":"","sources":["../../src/commands/native-export.ts"],"names":[],"mappings":"AAYA,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CA4MxD"}
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Validated Linux ELF64 N-API copy recipe (§12.3.1).
3
+ *
4
+ * Only position-independent addons with no external dynamic-library imports,
5
+ * loader search paths or text relocations qualify. Undefined symbols are Node's
6
+ * tested integer/property entry points and the weak ELF toolchain hooks below.
7
+ * This recipe
8
+ * copies bytes unchanged; it never executes package code or rewrites binaries.
9
+ * The caller checks the exact build profile and scans host paths/credentials.
10
+ * Other native formats and dependency recipes require dedicated adapters.
11
+ */
12
+ import { ZyrkError } from "../errors.js";
13
+ export function validateNativeExport(bytes) {
14
+ const fail = () => {
15
+ throw new ZyrkError("ZYRK_DEPLOY_NONRELOCATABLE", "Native artifact is outside the supported Linux ELF64 N-API export recipe.", {
16
+ remedy: "Use a position-independent N-API addon without external dynamic libraries or loader paths, or supply a validated target rebuild adapter.",
17
+ });
18
+ };
19
+ const range = (offset, size) => {
20
+ if (!Number.isSafeInteger(offset) ||
21
+ !Number.isSafeInteger(size) ||
22
+ offset < 0 ||
23
+ size < 0 ||
24
+ offset + size > bytes.length)
25
+ fail();
26
+ };
27
+ if (process.platform !== "linux" ||
28
+ process.arch !== "x64" ||
29
+ bytes.length < 64 ||
30
+ bytes.length > 64 * 1024 * 1024 ||
31
+ bytes.subarray(0, 4).toString("hex") !== "7f454c46" ||
32
+ bytes[4] !== 2 ||
33
+ bytes[5] !== 1 ||
34
+ bytes[6] !== 1)
35
+ fail();
36
+ if (bytes.readUInt16LE(16) !== 3 ||
37
+ bytes.readUInt16LE(18) !== 62 ||
38
+ bytes.readUInt16LE(52) !== 64 ||
39
+ bytes.readUInt32LE(20) !== 1)
40
+ fail();
41
+ const programs = Number(bytes.readBigUInt64LE(32)), programSize = bytes.readUInt16LE(54), programCount = bytes.readUInt16LE(56);
42
+ if (programSize !== 56 || programCount === 0 || programCount > 1024)
43
+ fail();
44
+ range(programs, programSize * programCount);
45
+ let loaderDynamic;
46
+ const segments = [];
47
+ for (let i = 0; i < programCount; i++) {
48
+ const p = programs + i * programSize, type = bytes.readUInt32LE(p), offset = Number(bytes.readBigUInt64LE(p + 8)), fileSize = Number(bytes.readBigUInt64LE(p + 32)), memorySize = Number(bytes.readBigUInt64LE(p + 40)), address = Number(bytes.readBigUInt64LE(p + 16));
49
+ if (!Number.isSafeInteger(address) ||
50
+ !Number.isSafeInteger(memorySize) ||
51
+ fileSize > memorySize)
52
+ fail();
53
+ range(offset, fileSize);
54
+ if (type === 3)
55
+ fail();
56
+ if (type === 1)
57
+ segments.push({ offset, size: fileSize, address });
58
+ if (type === 2) {
59
+ if (loaderDynamic)
60
+ fail();
61
+ if (fileSize !== memorySize)
62
+ fail();
63
+ loaderDynamic = { offset, size: fileSize, address };
64
+ }
65
+ }
66
+ const table = Number(bytes.readBigUInt64LE(40)), size = bytes.readUInt16LE(58), count = bytes.readUInt16LE(60);
67
+ if (size !== 64 || count === 0 || count > 4096)
68
+ fail();
69
+ range(table, size * count);
70
+ const section = (index) => {
71
+ if (index < 0 || index >= count)
72
+ fail();
73
+ const p = table + index * size;
74
+ return {
75
+ type: bytes.readUInt32LE(p + 4),
76
+ address: Number(bytes.readBigUInt64LE(p + 16)),
77
+ offset: Number(bytes.readBigUInt64LE(p + 24)),
78
+ size: Number(bytes.readBigUInt64LE(p + 32)),
79
+ link: bytes.readUInt32LE(p + 40),
80
+ entry: Number(bytes.readBigUInt64LE(p + 56)),
81
+ };
82
+ };
83
+ const mapped = (region) => {
84
+ if (!segments.some((segment) => region.offset >= segment.offset &&
85
+ region.offset + region.size <= segment.offset + segment.size &&
86
+ region.address === segment.address + region.offset - segment.offset))
87
+ fail();
88
+ };
89
+ const tags = new Map();
90
+ let symbols, strings;
91
+ let registration = false, api = false, dynamic = false;
92
+ const validatedApi = new Set([
93
+ "napi_create_int32",
94
+ "napi_set_named_property",
95
+ ]);
96
+ const weakHooks = new Set([
97
+ "__cxa_finalize",
98
+ "__gmon_start__",
99
+ "_ITM_registerTMCloneTable",
100
+ "_ITM_deregisterTMCloneTable",
101
+ ]);
102
+ for (let i = 0; i < count; i++) {
103
+ const s = section(i);
104
+ if (s.type !== 6 && s.type !== 11)
105
+ continue;
106
+ range(s.offset, s.size);
107
+ if (s.type === 6) {
108
+ if (dynamic ||
109
+ !loaderDynamic ||
110
+ loaderDynamic.offset !== s.offset ||
111
+ loaderDynamic.size !== s.size ||
112
+ loaderDynamic.address !== s.address)
113
+ fail();
114
+ mapped(s);
115
+ dynamic = true;
116
+ if (s.entry !== 16 || s.size % 16 !== 0)
117
+ fail();
118
+ let terminated = false;
119
+ for (let p = s.offset; p < s.offset + s.size; p += 16) {
120
+ const tag = bytes.readBigInt64LE(p), value = bytes.readBigUInt64LE(p + 8);
121
+ if (tag === 0n) {
122
+ terminated = true;
123
+ break;
124
+ }
125
+ if ([5n, 6n, 10n, 11n].includes(tag) && tags.has(tag))
126
+ fail();
127
+ tags.set(tag, value);
128
+ if ([
129
+ 1n,
130
+ 15n,
131
+ 22n,
132
+ 29n,
133
+ 0x7fffffffn,
134
+ 0x7ffffffdn,
135
+ 0x6ffffefbn,
136
+ 0x6ffffefcn,
137
+ ].includes(tag) ||
138
+ (tag === 30n && (value & 4n) !== 0n))
139
+ fail();
140
+ }
141
+ if (!terminated)
142
+ fail();
143
+ }
144
+ else {
145
+ if (symbols)
146
+ fail();
147
+ symbols = s;
148
+ mapped(s);
149
+ if (s.entry !== 24 || s.size % 24 !== 0)
150
+ fail();
151
+ const names = section(s.link);
152
+ strings = names;
153
+ mapped(names);
154
+ if (names.type !== 3)
155
+ fail();
156
+ range(names.offset, names.size);
157
+ for (let p = s.offset; p < s.offset + s.size; p += 24) {
158
+ const nameOffset = bytes.readUInt32LE(p), info = bytes[p + 4], index = bytes.readUInt16LE(p + 6);
159
+ if (nameOffset >= names.size)
160
+ fail();
161
+ const start = names.offset + nameOffset, end = bytes.indexOf(0, start);
162
+ if (end < start || end >= names.offset + names.size)
163
+ fail();
164
+ const name = bytes.subarray(start, end).toString("utf8");
165
+ if (index === 0 && name) {
166
+ if (validatedApi.has(name))
167
+ api = true;
168
+ else if (!(info >> 4 === 2 && weakHooks.has(name)))
169
+ fail();
170
+ }
171
+ if (name === "napi_register_module_v1" &&
172
+ index !== 0 &&
173
+ (info & 15) === 2)
174
+ registration = true;
175
+ }
176
+ }
177
+ }
178
+ if (!dynamic || !registration || !api || !symbols || !strings)
179
+ return fail();
180
+ if (tags.get(6n) !== BigInt(symbols.address) ||
181
+ tags.get(11n) !== 24n ||
182
+ tags.get(5n) !== BigInt(strings.address) ||
183
+ tags.get(10n) !== BigInt(strings.size))
184
+ fail();
185
+ // The dependency-free recipe has no legitimate external filesystem prerequisite.
186
+ // Reject literal absolute path strings; encoded paths require another adapter.
187
+ for (const text of bytes.toString("latin1").split("\0")) {
188
+ if (/^[\x20-\x7e]{4,}$/.test(text) &&
189
+ /(?:^|[\s"'=])(?:\/[A-Za-z0-9_.~-]|[A-Za-z]:[\\/])/.test(text))
190
+ fail();
191
+ }
192
+ }
@@ -0,0 +1,5 @@
1
+ import type { NpmLockDoc } from "../lockfile/types.ts";
2
+ import type { WorkspaceProject } from "./workspaces.ts";
3
+ import type { WorkspaceGraph } from "./workspace-graph.ts";
4
+ export declare function importNpmWorkspaceLock(doc: NpmLockDoc, project: WorkspaceProject, origin: string): WorkspaceGraph;
5
+ //# sourceMappingURL=import-npm-workspace.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"import-npm-workspace.d.ts","sourceRoot":"","sources":["../../src/install/import-npm-workspace.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAQvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxD,OAAO,KAAK,EACV,cAAc,EAGf,MAAM,sBAAsB,CAAC;AAE9B,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,UAAU,EACf,OAAO,EAAE,gBAAgB,EACzB,MAAM,EAAE,MAAM,GACb,cAAc,CAyThB"}
@@ -0,0 +1,283 @@
1
+ /** Translate npm's physical virtual tree without selecting new registry versions. */
2
+ import * as path from "node:path";
3
+ import { satisfies } from "semver";
4
+ import { validateLockDoc } from "../lockfile/read.js";
5
+ import { sha512Hex } from "../util/hash.js";
6
+ import { canonicalJson } from "../util/json.js";
7
+ import { importNpmLock } from "./import-npm.js";
8
+ import { lockMismatch, cleanUrl } from "./lock.js";
9
+ import { ZyrkError } from "../errors.js";
10
+ import { assertWorkspacePlatform } from "./workspace-graph.js";
11
+ export function importNpmWorkspaceLock(doc, project, origin) {
12
+ if (!doc ||
13
+ typeof doc !== "object" ||
14
+ Array.isArray(doc) ||
15
+ !doc.packages ||
16
+ typeof doc.packages !== "object" ||
17
+ Array.isArray(doc.packages) ||
18
+ Object.values(doc.packages).some((e) => !e || typeof e !== "object" || Array.isArray(e)))
19
+ throw lockMismatch("npm workspace lock has malformed package entries");
20
+ const portable = structuredClone(doc);
21
+ // Relative file declarations are representable only when they name a
22
+ // discovered live importer. Validate their targets before URL validation.
23
+ const importerIds = new Set(project.importers.map((i) => (i.id === "." ? "" : i.id)));
24
+ for (const [location, entry] of Object.entries(portable.packages ?? {}))
25
+ for (const group of [
26
+ "dependencies",
27
+ "devDependencies",
28
+ "optionalDependencies",
29
+ "peerDependencies",
30
+ ])
31
+ if (entry[group] && typeof entry[group] === "object")
32
+ for (const [name, spec] of Object.entries(entry[group]))
33
+ if (typeof spec === "string" && spec.startsWith("file:")) {
34
+ const relative = spec.slice(5);
35
+ if (!importerIds.has(location) ||
36
+ relative.startsWith("/") ||
37
+ relative.includes("\\") ||
38
+ /^[A-Za-z]:/.test(relative) ||
39
+ !importerIds.has(path.posix.normalize(path.posix.join(location, relative))))
40
+ throw lockMismatch("npm file declaration is not a declared workspace");
41
+ entry[group][name] = "workspace:*";
42
+ }
43
+ // npm v2 also has a redundant legacy dependency view. Do not adopt a
44
+ // second authority; it is still scanned for unsafe metadata.
45
+ const sanitizeLegacyLinks = (value) => {
46
+ if (typeof value === "string" &&
47
+ value.startsWith("file:") &&
48
+ importerIds.has(value.slice(5)))
49
+ return "workspace:*";
50
+ if (Array.isArray(value))
51
+ return value.map(sanitizeLegacyLinks);
52
+ if (value && typeof value === "object")
53
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [
54
+ key,
55
+ sanitizeLegacyLinks(child),
56
+ ]));
57
+ return value;
58
+ };
59
+ if (portable.dependencies)
60
+ portable.dependencies = sanitizeLegacyLinks(portable.dependencies);
61
+ validateLockDoc(portable, "package-lock.json");
62
+ if (doc.zyrk ||
63
+ ![2, 3].includes(doc.lockfileVersion ?? 0) ||
64
+ !doc.packages ||
65
+ !cleanUrl(origin))
66
+ throw lockMismatch("workspace adoption needs an unannotated npm v2/v3 lock");
67
+ const packages = doc.packages;
68
+ const graph = { nodes: [], importers: {}, omissions: [] };
69
+ const locations = new Map();
70
+ const local = new Map(project.importers.map((i) => [i.id === "." ? "" : i.id, i]));
71
+ for (const [location, entry] of Object.entries(packages)) {
72
+ if (location.includes("\\") ||
73
+ location.startsWith("/") ||
74
+ location.split("/").some((s) => s === ".." || s === ".") ||
75
+ location.includes("\0"))
76
+ throw lockMismatch("unsafe npm package location");
77
+ if (entry.link === true &&
78
+ (typeof entry.resolved !== "string" ||
79
+ !local.has(entry.resolved) ||
80
+ entry.resolved === ""))
81
+ throw lockMismatch("npm link does not select a declared workspace");
82
+ if (entry.inBundle === true)
83
+ throw lockMismatch("bundled npm selections are unsupported");
84
+ }
85
+ for (const [location, importer] of local) {
86
+ const entry = packages[location];
87
+ if (!entry || entry.link)
88
+ throw lockMismatch("npm lock lacks a workspace importer");
89
+ for (const field of [
90
+ "dependencies",
91
+ "devDependencies",
92
+ "optionalDependencies",
93
+ "peerDependencies",
94
+ "peerDependenciesMeta",
95
+ "workspaces",
96
+ ])
97
+ if (canonicalJson(entry[field] ?? {}) !==
98
+ canonicalJson(importer.manifest[field] ?? {}))
99
+ throw lockMismatch("npm workspace manifest declarations differ");
100
+ if (location &&
101
+ ((entry.name !== undefined && entry.name !== importer.manifest.name) ||
102
+ entry.version !== importer.manifest.version))
103
+ throw lockMismatch("npm workspace identity differs");
104
+ const node = {
105
+ kind: "local",
106
+ key: `workspace:${importer.id}`,
107
+ name: importer.manifest.name ?? "workspace-root",
108
+ version: importer.manifest.version ?? "0.0.0",
109
+ importer: importer.id,
110
+ edges: [],
111
+ peers: {},
112
+ optionalPeers: [],
113
+ };
114
+ graph.importers[importer.id] = node.key;
115
+ graph.nodes.push(node);
116
+ locations.set(location, node);
117
+ }
118
+ const find = (from, name) => {
119
+ let current = from;
120
+ for (;;) {
121
+ if (!current.endsWith("node_modules")) {
122
+ const slot = `${current ? current + "/" : ""}node_modules/${name}`;
123
+ if (Object.hasOwn(packages, slot))
124
+ return slot;
125
+ }
126
+ if (!current)
127
+ return undefined;
128
+ current = current.includes("/")
129
+ ? current.slice(0, current.lastIndexOf("/"))
130
+ : "";
131
+ }
132
+ };
133
+ const populated = new Set();
134
+ const load = (location, name, spec, from) => {
135
+ const entry = packages[location];
136
+ if (entry.link === true) {
137
+ const target = locations.get(entry.resolved);
138
+ const range = spec.startsWith("workspace:") ? spec.slice(10) : spec;
139
+ if (spec.startsWith("file:")
140
+ ? path.posix.normalize(path.posix.join(from, spec.slice(5))) !==
141
+ (target.kind === "local" ? target.importer : "")
142
+ : name !== target.name ||
143
+ (!["*", "^", "~"].includes(range) &&
144
+ !satisfies(target.version, range)))
145
+ throw lockMismatch("npm workspace link disagrees with requirement");
146
+ return target;
147
+ }
148
+ // Reuse the strict single-selection adapter's archive, alias, source and
149
+ // platform validation. Dependencies are wired below using npm locations.
150
+ const shallow = { ...entry };
151
+ delete shallow.dependencies;
152
+ delete shallow.optionalDependencies;
153
+ delete shallow.peerDependencies;
154
+ const selected = importNpmLock({
155
+ lockfileVersion: 3,
156
+ packages: { "": {}, [`node_modules/${name}`]: shallow },
157
+ }, { [name]: spec }, origin).nodes[0];
158
+ let node = locations.get(location);
159
+ if (!node) {
160
+ const { peerBindings: _bindings, ...source } = selected;
161
+ node = {
162
+ ...source,
163
+ key: `${selected.name}@${selected.version}#${sha512Hex(location).slice(0, 16)}`,
164
+ kind: "registry",
165
+ edges: [],
166
+ peers: {},
167
+ optionalPeers: [],
168
+ };
169
+ assertWorkspacePlatform({ nodes: [node], importers: {}, omissions: [] });
170
+ graph.nodes.push(node);
171
+ locations.set(location, node);
172
+ }
173
+ try {
174
+ populate(location, node);
175
+ }
176
+ catch (cause) {
177
+ populated.delete(location);
178
+ locations.delete(location);
179
+ graph.nodes = graph.nodes.filter((n) => n !== node);
180
+ throw cause;
181
+ }
182
+ return node;
183
+ };
184
+ const populate = (location, node) => {
185
+ if (populated.has(location))
186
+ return;
187
+ if (populated.size >= 10000)
188
+ throw lockMismatch("npm import resource limit exceeded");
189
+ populated.add(location);
190
+ const entry = packages[location];
191
+ const requirements = new Map();
192
+ for (const [field, kind] of [
193
+ ["dependencies", "prod"],
194
+ ["devDependencies", "dev"],
195
+ ["optionalDependencies", "optional"],
196
+ ["peerDependencies", "peer"],
197
+ ]) {
198
+ if (kind === "dev" && node.kind !== "local")
199
+ continue;
200
+ const values = entry[field];
201
+ if (values === undefined)
202
+ continue;
203
+ if (!values || typeof values !== "object" || Array.isArray(values))
204
+ throw lockMismatch("invalid npm dependency declarations");
205
+ for (const [name, spec] of Object.entries(values)) {
206
+ if (typeof spec !== "string")
207
+ throw lockMismatch("invalid npm dependency requirement");
208
+ // A peer declaration does not replace an existing regular dependency.
209
+ if (kind !== "peer" || !requirements.has(name))
210
+ requirements.set(name, { spec, kind });
211
+ }
212
+ }
213
+ for (const [name, requirement] of requirements) {
214
+ const meta = entry.peerDependenciesMeta;
215
+ const optionalPeer = requirement.kind === "peer" && meta?.[name]?.optional === true;
216
+ if (optionalPeer)
217
+ node.optionalPeers.push(name);
218
+ const targetLocation = find(location, name);
219
+ if (targetLocation === undefined) {
220
+ if (optionalPeer) {
221
+ node.peers[name] = null;
222
+ continue;
223
+ }
224
+ if (requirement.kind === "optional")
225
+ continue;
226
+ throw lockMismatch("npm workspace lock is missing a required selection");
227
+ }
228
+ let target;
229
+ try {
230
+ target = load(targetLocation, name, requirement.spec, location);
231
+ }
232
+ catch (cause) {
233
+ if (requirement.kind === "optional" &&
234
+ cause instanceof ZyrkError &&
235
+ cause.code === "ZYRK_PLATFORM_UNSUPPORTED") {
236
+ graph.omissions.push({ name, parent: node.key, reason: "platform" });
237
+ continue;
238
+ }
239
+ throw cause;
240
+ }
241
+ node.edges.push({ name, target: target.key, ...requirement });
242
+ if (requirement.kind === "peer")
243
+ node.peers[name] = target.key;
244
+ }
245
+ // npm allows the same name in dependencies and peerDependencies. Its
246
+ // ordinary edge keeps its kind, but the peer range and binding still apply.
247
+ const peers = entry.peerDependencies;
248
+ const meta = entry.peerDependenciesMeta;
249
+ for (const [name, spec] of Object.entries(peers ?? {})) {
250
+ if (Object.hasOwn(node.peers, name))
251
+ continue;
252
+ const optional = meta?.[name]?.optional === true;
253
+ if (optional)
254
+ node.optionalPeers.push(name);
255
+ const targetLocation = find(location, name);
256
+ if (targetLocation === undefined) {
257
+ if (!optional)
258
+ throw lockMismatch("npm workspace lock is missing a required peer");
259
+ node.peers[name] = null;
260
+ continue;
261
+ }
262
+ const target = load(targetLocation, name, spec, location);
263
+ node.peers[name] = target.key;
264
+ if (!node.edges.some((edge) => edge.name === name))
265
+ node.edges.push({ name, target: target.key, kind: "peer", spec });
266
+ }
267
+ };
268
+ for (const [location] of local)
269
+ populate(location, locations.get(location));
270
+ const reachable = new Set(), pending = Object.values(graph.importers), byKey = new Map(graph.nodes.map((n) => [n.key, n]));
271
+ while (pending.length) {
272
+ const key = pending.pop();
273
+ if (reachable.has(key))
274
+ continue;
275
+ reachable.add(key);
276
+ for (const edge of byKey.get(key).edges)
277
+ pending.push(edge.target);
278
+ }
279
+ graph.nodes = graph.nodes.filter((n) => reachable.has(n.key));
280
+ graph.omissions = graph.omissions.filter((o) => reachable.has(o.parent));
281
+ graph.nodes.sort((a, b) => a.key.localeCompare(b.key));
282
+ return graph;
283
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-install.d.ts","sourceRoot":"","sources":["../../src/install/workspace-install.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AA8E9D,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAmpBxB"}
1
+ {"version":3,"file":"workspace-install.d.ts","sourceRoot":"","sources":["../../src/install/workspace-install.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAyH9D,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAopBxB"}
@@ -4,6 +4,7 @@ import { constants } from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { discoverWorkspaces, findWorkspaceRoot, assertNoLiveSourceBuilds, } from "./workspaces.js";
6
6
  import { resolveWorkspaceGraph, workspaceRegistryOccurrences, workspaceContextId, assertWorkspacePlatform, } from "./workspace-graph.js";
7
+ import { importNpmWorkspaceLock } from "./import-npm-workspace.js";
7
8
  import { readWorkspaceLock, workspaceLockDoc } from "./workspace-lock.js";
8
9
  import { publishWorkspaceProjection, recoverWorkspaceProjection, workspaceProjectionPaths, } from "./workspace-transaction.js";
9
10
  import { ZyrkError } from "../errors.js";
@@ -48,10 +49,40 @@ async function realDirectory(directory) {
48
49
  if (!st.isDirectory() || st.isSymbolicLink())
49
50
  throw lockMismatch("workspace control parent must be a real directory");
50
51
  }
52
+ async function readNpmWorkspaceImport(root, project, options) {
53
+ if (options.frozen || options.offline)
54
+ throw lockMismatch("npm workspace adoption needs a non-frozen online install");
55
+ if (await pathExists(path.join(root, "npm-shrinkwrap.json")))
56
+ throw lockMismatch("npm-shrinkwrap.json takes npm precedence; workspace adoption currently supports package-lock.json only");
57
+ let npmDoc;
58
+ try {
59
+ npmDoc = JSON.parse(await readFile(path.join(root, "package-lock.json"), "utf8"));
60
+ }
61
+ catch {
62
+ throw lockMismatch("npm workspace adoption needs an existing readable JSON lock");
63
+ }
64
+ const graph = importNpmWorkspaceLock(npmDoc, project, options.registry.origin);
65
+ // Prove the complete graph and ordinary npm projection are representable
66
+ // before any project state is created. Actual policy fingerprints are added
67
+ // under the install lock after policy validation.
68
+ workspaceLockDoc(project, graph, {
69
+ manifest: project.manifestFingerprint,
70
+ scripts: scriptPolicyFingerprint(undefined),
71
+ config: configFingerprint({
72
+ registry: options.registry.origin,
73
+ linker: "shared",
74
+ }),
75
+ });
76
+ return graph;
77
+ }
51
78
  export async function installWorkspaceProject(options) {
52
79
  const root = await findWorkspaceRoot(options.projectDir);
53
80
  if (!root)
54
81
  throw lockMismatch("no authoritative workspace root");
82
+ // Refuse untranslatable locks before creating control directories or
83
+ // recovering a previous generation. Revalidate under the project lock below.
84
+ if (options.importNpm)
85
+ await readNpmWorkspaceImport(root, await discoverWorkspaces(root), options);
55
86
  await realDirectory(path.join(root, ".zyrk-local"));
56
87
  const held = await acquireLock(path.join(root, ".zyrk-local/project.lock"), {
57
88
  name: "workspace install",
@@ -66,8 +97,6 @@ export async function installWorkspaceProject(options) {
66
97
  i.id,
67
98
  await readFile(path.join(i.directory, "package.json"), "utf8"),
68
99
  ])));
69
- if (options.importNpm)
70
- unsupported("Workspace npm-lock adoption needs its multi-importer adapter.");
71
100
  const mutation = await prepareWorkspaceMutation(project, options);
72
101
  if (manifest.overrides !== undefined ||
73
102
  manifest.bundledDependencies !== undefined ||
@@ -110,7 +139,12 @@ export async function installWorkspaceProject(options) {
110
139
  ...(patchDigest ? { patches: patchDigest } : {}),
111
140
  }),
112
141
  };
113
- let existing = await readWorkspaceLock(root, project, options.registry.origin), graph;
142
+ const imported = options.importNpm
143
+ ? await readNpmWorkspaceImport(root, project, options)
144
+ : undefined;
145
+ let existing = options.importNpm
146
+ ? null
147
+ : await readWorkspaceLock(root, project, options.registry.origin), graph = imported;
114
148
  if (existing) {
115
149
  const ext = existing.doc.zyrk;
116
150
  if (!mutation.changed &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zyrk",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
4
4
  "description": "Storage-efficient Node package manager: one verified store per user, shared across projects through links.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "devDependencies": {
34
34
  "@npmcli/arborist": "10.0.3",
35
- "@types/node": "^24.19.2",
35
+ "@types/node": "^26.6.5",
36
36
  "@types/semver": "^7.8.0",
37
37
  "prettier": "3.9.10",
38
38
  "typescript": "^7.0.2",