zyrk 0.4.9 → 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
@@ -144,8 +144,7 @@ and repair have workspace fixtures. Builds that reach live workspace sources are
144
144
  blocked before execution. Existing npm v2/v3 workspace `package-lock.json` selections can be adopted with
145
145
  `zyrk import --from npm`, preserving exact versions, aliases and live members.
146
146
  Adoption validates the complete graph before project changes; npm shrinkwrap
147
- precedence and unsupported bundled/external links fail explicitly. Portable
148
- workspace export still needs its adapter. Recursive execution supports
147
+ precedence and unsupported bundled/external links fail explicitly. Workspace-root production exports include live member closures and assets. Recursive execution supports
149
148
  member scripts; filters are exact names or paths, not arbitrary glob expressions.
150
149
 
151
150
  ### Run a one-off tool
@@ -233,9 +232,12 @@ node ./dist-runtime/dist/server.js
233
232
 
234
233
  The exporter validates that links stay inside the output and excludes the host
235
234
  store. Generic export supports shared production graphs and relocatable private
236
- JavaScript outputs, including selected patches and generated files. Native binaries,
237
- host-path-dependent outputs, workspace export, and arbitrary shell startup adapters
238
- 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
239
241
  credentials or escaping links. Structural validation does not prove application health. Run an
240
242
  application smoke test from the exported directory with host-store access removed.
241
243
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- v0.4.9
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zyrk",
3
- "version": "0.4.9",
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",