zyrk 0.2.3 → 0.3.5

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.
@@ -0,0 +1,777 @@
1
+ /** Self-contained pure-package deployment and explicit local Docker commands. */
2
+ import { spawn } from "node:child_process";
3
+ import {
4
+ copyFile,
5
+ chmod,
6
+ lstat,
7
+ mkdir,
8
+ mkdtemp,
9
+ readFile,
10
+ readdir,
11
+ readlink,
12
+ realpath,
13
+ rename,
14
+ rm,
15
+ symlink,
16
+ writeFile,
17
+ } from "node:fs/promises";
18
+ import * as path from "node:path";
19
+ import { findProjectBoundary, resolveStoreRoot } from "../config/paths.ts";
20
+ import { ZyrkError, type ErrorCode } from "../errors.ts";
21
+ import { lockedGraph } from "../install/lock.ts";
22
+ import { validateLockDoc } from "../lockfile/read.ts";
23
+ import {
24
+ manifestFingerprint,
25
+ type ManifestDeclaration,
26
+ } from "../lockfile/fingerprints.ts";
27
+ import { createGraphDoc, contextIdFor, type GraphDoc } from "../store/graph.ts";
28
+ import { Store } from "../store/layout.ts";
29
+ import { readSourceManifest } from "../store/manifests.ts";
30
+ import { packageDirIn, realizationDirFor } from "../store/realize.ts";
31
+ import { verifyRealization } from "../store/verify.ts";
32
+ import { sha512Hex, sha512HexOfFile } from "../util/hash.ts";
33
+ import { canonicalJson } from "../util/json.ts";
34
+ import { acquireLock } from "../util/locks.ts";
35
+ import { closeCode } from "../util/proc.ts";
36
+
37
+ type Obj = Record<string, unknown>;
38
+ function object(v: unknown): v is Obj {
39
+ return typeof v === "object" && v !== null && !Array.isArray(v);
40
+ }
41
+ function error(code: ErrorCode, message: string): ZyrkError {
42
+ return new ZyrkError(code, message, {
43
+ remedy:
44
+ "Select explicit runtime files and a direct Node entry, install a ready graph, and use a new output directory.",
45
+ });
46
+ }
47
+ export type DeployCommandOptions = {
48
+ command: "deploy" | "docker";
49
+ args: readonly string[];
50
+ cwd: string;
51
+ env: NodeJS.ProcessEnv;
52
+ stdout: { write(chunk: string): unknown };
53
+ stderr: { write(chunk: string): unknown };
54
+ json?: boolean;
55
+ };
56
+ function flags(args: readonly string[]): Map<string, string[]> {
57
+ const result = new Map<string, string[]>();
58
+ for (let i = 0; i < args.length; i++) {
59
+ const item = args[i]!;
60
+ const split = item.indexOf("=");
61
+ const key = split < 0 ? item : item.slice(0, split);
62
+ const value = split < 0 ? args[++i] : item.slice(split + 1);
63
+ if (!key.startsWith("--") || !value || value.startsWith("--"))
64
+ throw error(
65
+ "ZYRK_USAGE",
66
+ "Docker/deploy options require explicit values.",
67
+ );
68
+ result.set(key, [...(result.get(key) ?? []), value]);
69
+ }
70
+ return result;
71
+ }
72
+ function one(f: Map<string, string[]>, key: string): string | undefined {
73
+ const values = f.get(key);
74
+ if ((values?.length ?? 0) > 1)
75
+ throw error("ZYRK_USAGE", "An option was supplied more than once.");
76
+ return values?.[0];
77
+ }
78
+ function within(root: string, file: string): boolean {
79
+ const rel = path.relative(root, file);
80
+ return (
81
+ rel === "" ||
82
+ (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel))
83
+ );
84
+ }
85
+ function relativeFile(value: string): string {
86
+ if (
87
+ !value ||
88
+ path.isAbsolute(value) ||
89
+ value.split(/[\\/]/).some((p) => !p || p === "." || p === "..") ||
90
+ /[\0\r\n*?\[\]]/.test(value)
91
+ )
92
+ throw error(
93
+ "ZYRK_USAGE",
94
+ "Runtime file selections must be concrete safe project-relative paths.",
95
+ );
96
+ return value;
97
+ }
98
+ function sensitive(name: string): boolean {
99
+ return (
100
+ /^(?:\.env(?:\..*)?|\.npmrc|\.netrc|\.git|\.zyrk-local|node_modules|\.ssh)$/i.test(
101
+ name,
102
+ ) || /\.(?:pem|key|p12|pfx)$/i.test(name)
103
+ );
104
+ }
105
+ async function readJson(file: string): Promise<Obj> {
106
+ try {
107
+ const doc: unknown = JSON.parse(await readFile(file, "utf8"));
108
+ if (object(doc)) return doc;
109
+ } catch {}
110
+ throw error(
111
+ "ZYRK_MANIFEST_INVALID",
112
+ "Cannot read deployment project metadata.",
113
+ );
114
+ }
115
+ function output(options: DeployCommandOptions, result: Obj, human: string) {
116
+ options.stdout.write(
117
+ options.json
118
+ ? JSON.stringify({
119
+ schemaVersion: 1,
120
+ command: options.command,
121
+ ...result,
122
+ }) + "\n"
123
+ : human + "\n",
124
+ );
125
+ }
126
+
127
+ export async function runDeployCommand(
128
+ options: DeployCommandOptions,
129
+ ): Promise<number> {
130
+ const boundary = await findProjectBoundary(options.cwd);
131
+ if (!boundary)
132
+ throw error("ZYRK_MANIFEST_INVALID", "Cannot find a project to deploy.");
133
+ const project = await realpath(boundary);
134
+ const manifest = await readJson(path.join(project, "package.json"));
135
+ if (options.command === "docker") return docker(options, project, manifest);
136
+ const f = flags(options.args);
137
+ for (const key of f.keys())
138
+ if (!["--out", "--omit", "--files", "--entry"].includes(key))
139
+ throw error("ZYRK_USAGE", "Unknown deploy option.");
140
+ if (one(f, "--omit") !== undefined && one(f, "--omit") !== "dev")
141
+ throw error("ZYRK_USAGE", "Deploy supports only --omit=dev.");
142
+ const destination = one(f, "--out");
143
+ if (!destination)
144
+ throw error("ZYRK_USAGE", "Deploy requires --out <new-directory>.");
145
+ const out = path.resolve(options.cwd, destination);
146
+ if (out === project || within(out, project))
147
+ throw error(
148
+ "ZYRK_DEPLOY_ESCAPE",
149
+ "Deployment output overlaps the project input.",
150
+ );
151
+ const parent = await realpath(path.dirname(out)).catch(() => {
152
+ throw error("ZYRK_USAGE", "Deployment output parent must already exist.");
153
+ });
154
+ const actualOut = path.join(parent, path.basename(out));
155
+ if (actualOut !== out || actualOut === project || within(actualOut, project))
156
+ throw error(
157
+ "ZYRK_DEPLOY_ESCAPE",
158
+ "Deployment output resolves through a link or overlaps project input.",
159
+ );
160
+ if (await lstat(out).catch(() => null))
161
+ throw error(
162
+ "ZYRK_USAGE",
163
+ "Deployment output already exists; choose a new directory.",
164
+ );
165
+ const files = runtimeFiles(f, manifest);
166
+ const entry = entryFile(f, manifest);
167
+ if (!files.some((file) => within(file, entry)))
168
+ throw error(
169
+ "ZYRK_DEPLOY_NONRELOCATABLE",
170
+ "The Node entry is absent from the selected runtime files.",
171
+ );
172
+ for (const file of files)
173
+ if (
174
+ within(path.join(project, file), out) ||
175
+ within(out, path.join(project, file))
176
+ )
177
+ throw error(
178
+ "ZYRK_DEPLOY_ESCAPE",
179
+ "Deployment output overlaps selected runtime files.",
180
+ );
181
+ const control = await lstat(path.join(project, ".zyrk-local")).catch(
182
+ () => null,
183
+ );
184
+ if (control && (!control.isDirectory() || control.isSymbolicLink()))
185
+ throw error("ZYRK_STORE_CORRUPT", "Project control directory is unsafe.");
186
+ const projectLock = await acquireLock(
187
+ path.join(project, ".zyrk-local/project.lock"),
188
+ { name: "portable deployment" },
189
+ );
190
+ try {
191
+ const state = await readJson(
192
+ path.join(project, "node_modules/.zyrk/install-state.json"),
193
+ );
194
+ const doc = validateLockDoc(
195
+ await readJson(path.join(project, "package-lock.json")),
196
+ "project package-lock.json",
197
+ );
198
+ const resolved = lockedGraph(doc);
199
+ if (state.readiness !== "ready")
200
+ throw error(
201
+ "ZYRK_SCRIPT_BLOCKED",
202
+ "Cannot export an unready or intentionally unbuilt generation.",
203
+ );
204
+ if (
205
+ state.lockDigest !== sha512Hex(canonicalJson(doc)) ||
206
+ doc.zyrk?.manifest !==
207
+ manifestFingerprint(manifest as ManifestDeclaration)
208
+ )
209
+ throw error(
210
+ "ZYRK_LOCK_MISMATCH",
211
+ "Deployment manifest, lock, and installed generation disagree.",
212
+ );
213
+ if (state.platform !== process.platform || state.arch !== process.arch)
214
+ throw error(
215
+ "ZYRK_PLATFORM_UNSUPPORTED",
216
+ "Deployment requires artifacts for the current platform and architecture.",
217
+ );
218
+ const store = await Store.open({
219
+ root: resolveStoreRoot({ env: options.env }).root,
220
+ });
221
+ store.requireInitialized();
222
+ const barrier = await acquireLock(
223
+ path.join(store.paths.locksDir, "liveness.lock"),
224
+ { name: "portable deployment retention" },
225
+ );
226
+ try {
227
+ const installedKeys = new Set(
228
+ Array.isArray(state.contexts)
229
+ ? state.contexts.filter((v): v is string => typeof v === "string")
230
+ : [],
231
+ );
232
+ const installedNodes = resolved.nodes.filter((n) =>
233
+ installedKeys.has(n.key),
234
+ );
235
+ const graph = createGraphDoc(
236
+ installedNodes.map((n) => ({ ...n, deps: n.edges })),
237
+ );
238
+ const selectedRoots: Record<string, string> = {};
239
+ const declarations = {
240
+ ...(object(manifest.dependencies) ? manifest.dependencies : {}),
241
+ ...(object(manifest.optionalDependencies)
242
+ ? manifest.optionalDependencies
243
+ : {}),
244
+ };
245
+ for (const name of Object.keys(declarations)) {
246
+ const key = doc.zyrk?.roots["."]?.[name];
247
+ if (key && installedKeys.has(key)) selectedRoots[name] = key;
248
+ else if (
249
+ !object(manifest.optionalDependencies) ||
250
+ manifest.optionalDependencies[name] === undefined
251
+ )
252
+ throw error(
253
+ "ZYRK_OFFLINE_MISSING",
254
+ "A required runtime dependency is not installed.",
255
+ );
256
+ }
257
+ const selected = new Set<string>();
258
+ const byKey = new Map(graph.nodes.map((n) => [n.key, n]));
259
+ const visit = (key: string) => {
260
+ if (selected.has(key)) return;
261
+ selected.add(key);
262
+ const node = byKey.get(key);
263
+ if (!node)
264
+ throw error(
265
+ "ZYRK_OFFLINE_MISSING",
266
+ "A runtime dependency edge is missing.",
267
+ );
268
+ for (const edge of node.edges) visit(edge.target);
269
+ };
270
+ Object.values(selectedRoots).forEach(visit);
271
+ for (const key of selected)
272
+ await verifyRealization({
273
+ store,
274
+ graph,
275
+ rootKey: key,
276
+ contextId: contextIdFor(graph, key),
277
+ });
278
+ const stage = await mkdtemp(path.join(parent, ".zyrk-deploy-"));
279
+ try {
280
+ for (const file of files)
281
+ await copyApplication(
282
+ path.join(project, file),
283
+ path.join(stage, file),
284
+ project,
285
+ );
286
+ const deployedManifest = { ...manifest };
287
+ delete deployedManifest.devDependencies;
288
+ delete deployedManifest.zyrk;
289
+ delete deployedManifest.packageManager;
290
+ await writeFile(
291
+ path.join(stage, "package.json"),
292
+ JSON.stringify(deployedManifest, null, 2) + "\n",
293
+ );
294
+ const packageDirs = new Map<string, string>();
295
+ for (const key of selected) {
296
+ const node = byKey.get(key)!;
297
+ const context = contextIdFor(graph, key);
298
+ const sourceDir = packageDirIn(
299
+ realizationDirFor(store, context),
300
+ node.name,
301
+ );
302
+ const targetDir = packageDirIn(
303
+ path.join(stage, "node_modules/.zyrk/portable", context),
304
+ node.name,
305
+ );
306
+ packageDirs.set(key, targetDir);
307
+ await mkdir(targetDir, { recursive: true });
308
+ const source = await readSourceManifest(store, node.sourceId);
309
+ for (const e of source.manifest.entries) {
310
+ const target = path.join(targetDir, e.path);
311
+ if (!within(targetDir, target))
312
+ throw error(
313
+ "ZYRK_DEPLOY_ESCAPE",
314
+ "A dependency source path escapes its portable package.",
315
+ );
316
+ await mkdir(path.dirname(target), { recursive: true });
317
+ if (e.type === "dir") await mkdir(target, { recursive: true });
318
+ else if (e.type === "symlink") {
319
+ if (
320
+ path.isAbsolute(e.target) ||
321
+ !within(targetDir, path.resolve(path.dirname(target), e.target))
322
+ )
323
+ throw error(
324
+ "ZYRK_DEPLOY_ESCAPE",
325
+ "A package symlink escapes its portable package.",
326
+ );
327
+ await symlink(e.target, target);
328
+ } else {
329
+ if (e.path.endsWith(".node"))
330
+ throw error(
331
+ "ZYRK_DEPLOY_NONRELOCATABLE",
332
+ "Native addon runtime compatibility requires a validated deployment adapter.",
333
+ );
334
+ await copyFile(path.join(sourceDir, e.path), target);
335
+ await chmod(target, e.mode === "exec" ? 0o755 : 0o644);
336
+ }
337
+ }
338
+ }
339
+ for (const key of selected) {
340
+ const node = byKey.get(key)!,
341
+ pkg = packageDirs.get(key)!;
342
+ for (const edge of node.edges) {
343
+ const modules =
344
+ edge.name === node.name
345
+ ? path.join(pkg, "node_modules")
346
+ : path.dirname(
347
+ node.name.startsWith("@") ? path.dirname(pkg) : pkg,
348
+ );
349
+ await relativeLink(
350
+ path.join(modules, edge.name),
351
+ packageDirs.get(edge.target)!,
352
+ );
353
+ }
354
+ }
355
+ for (const [name, key] of Object.entries(selectedRoots))
356
+ await relativeLink(
357
+ path.join(stage, "node_modules", name),
358
+ packageDirs.get(key)!,
359
+ );
360
+ // Local launchers are recomputed from selected roots; their targets stay portable.
361
+ for (const [name, key] of Object.entries(selectedRoots)) {
362
+ const node = doc.zyrk!.nodes[key]!;
363
+ for (const [bin, target] of Object.entries(node.bin ?? {})) {
364
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(bin))
365
+ throw error(
366
+ "ZYRK_DEPLOY_ESCAPE",
367
+ "Unsafe executable name in portable graph.",
368
+ );
369
+ const file = path.resolve(packageDirs.get(key)!, target);
370
+ if (!within(packageDirs.get(key)!, file))
371
+ throw error(
372
+ "ZYRK_DEPLOY_ESCAPE",
373
+ "Portable executable escapes its package.",
374
+ );
375
+ const stat = await lstat(file).catch(() => null);
376
+ if (!stat?.isFile())
377
+ throw error(
378
+ "ZYRK_DEPLOY_NONRELOCATABLE",
379
+ "A required package executable is missing.",
380
+ );
381
+ if ((stat.mode & 0o111) !== 0)
382
+ await relativeLink(
383
+ path.join(stage, "node_modules/.bin", bin),
384
+ file,
385
+ );
386
+ else {
387
+ await mkdir(path.join(stage, "node_modules/.bin"), {
388
+ recursive: true,
389
+ });
390
+ const rel = path
391
+ .relative(path.join(stage, "node_modules/.bin"), file)
392
+ .split(path.sep)
393
+ .join("/");
394
+ await writeFile(
395
+ path.join(stage, "node_modules/.bin", bin),
396
+ '#!/bin/sh\nexec node "$(dirname "$0")"/' +
397
+ shellQuote(rel) +
398
+ ' "$@"\n',
399
+ { mode: 0o755 },
400
+ );
401
+ }
402
+ }
403
+ void name;
404
+ }
405
+ const entryStat = await lstat(path.join(stage, entry)).catch(
406
+ () => null,
407
+ );
408
+ if (!entryStat?.isFile())
409
+ throw error(
410
+ "ZYRK_DEPLOY_NONRELOCATABLE",
411
+ "The selected runtime Node entry is missing.",
412
+ );
413
+ await validateLinks(stage, stage);
414
+ await writeFile(
415
+ path.join(stage, "zyrk-deploy.json"),
416
+ JSON.stringify(
417
+ {
418
+ schemaVersion: 1,
419
+ platform: process.platform,
420
+ arch: process.arch,
421
+ node: process.version,
422
+ entry: ["node", entry],
423
+ requirements: ["Node.js " + process.versions.node.split(".")[0]],
424
+ packages: selected.size,
425
+ files,
426
+ validation: "structural; application smoke test not run",
427
+ },
428
+ null,
429
+ 2,
430
+ ) + "\n",
431
+ );
432
+ await rename(stage, out);
433
+ output(
434
+ options,
435
+ {
436
+ packages: selected.size,
437
+ entry: ["node", entry],
438
+ validation: "structural",
439
+ },
440
+ `Exported ${selected.size} runtime dependencies. Start with: node ${entry}. Structural validation passed; no application smoke test ran.`,
441
+ );
442
+ return 0;
443
+ } finally {
444
+ await rm(stage, { recursive: true, force: true });
445
+ }
446
+ } finally {
447
+ await barrier.release();
448
+ }
449
+ } finally {
450
+ await projectLock.release();
451
+ }
452
+ }
453
+ function runtimeFiles(f: Map<string, string[]>, manifest: Obj): string[] {
454
+ const raw = f.get("--files") ?? manifest.files;
455
+ if (
456
+ !Array.isArray(raw) ||
457
+ !raw.length ||
458
+ raw.some((v) => typeof v !== "string")
459
+ )
460
+ throw error(
461
+ "ZYRK_USAGE",
462
+ "Declare runtime files with repeated --files paths or a package.json files array.",
463
+ );
464
+ return [...new Set(raw.map((v) => relativeFile(String(v))))];
465
+ }
466
+ function entryFile(f: Map<string, string[]>, manifest: Obj): string {
467
+ let entry = one(f, "--entry");
468
+ if (
469
+ !entry &&
470
+ object(manifest.scripts) &&
471
+ typeof manifest.scripts.start === "string"
472
+ )
473
+ entry = /^node\s+([a-zA-Z0-9_./-]+)$/.exec(manifest.scripts.start)?.[1];
474
+ if (!entry)
475
+ throw error(
476
+ "ZYRK_USAGE",
477
+ "Choose a direct Node runtime entry with --entry <project-relative-file>.",
478
+ );
479
+ return relativeFile(entry);
480
+ }
481
+ async function copyApplication(
482
+ source: string,
483
+ target: string,
484
+ project: string,
485
+ ): Promise<void> {
486
+ if (path.relative(project, source).split(path.sep).some(sensitive))
487
+ throw error(
488
+ "ZYRK_POLICY_DENIED",
489
+ "A runtime file selection includes credentials, control state, or development dependencies.",
490
+ );
491
+ const stat = await lstat(source).catch(() => null);
492
+ if (!stat)
493
+ throw error(
494
+ "ZYRK_DEPLOY_NONRELOCATABLE",
495
+ "A selected runtime file is missing.",
496
+ );
497
+ if (stat.isSymbolicLink())
498
+ throw error(
499
+ "ZYRK_DEPLOY_ESCAPE",
500
+ "Application runtime selections must contain real files and directories.",
501
+ );
502
+ if (stat.isDirectory()) {
503
+ await mkdir(target, { recursive: true });
504
+ for (const name of await readdir(source))
505
+ await copyApplication(
506
+ path.join(source, name),
507
+ path.join(target, name),
508
+ project,
509
+ );
510
+ } else if (stat.isFile()) {
511
+ await mkdir(path.dirname(target), { recursive: true });
512
+ await copyFile(source, target);
513
+ await chmod(target, stat.mode & 0o111 ? 0o755 : 0o644);
514
+ } else
515
+ throw error(
516
+ "ZYRK_DEPLOY_ESCAPE",
517
+ "Special files cannot be exported as application files.",
518
+ );
519
+ }
520
+ async function relativeLink(link: string, target: string) {
521
+ await mkdir(path.dirname(link), { recursive: true });
522
+ const existing = await lstat(link).catch(() => null);
523
+ if (existing) {
524
+ if (
525
+ existing.isSymbolicLink() &&
526
+ (await readlink(link)) === path.relative(path.dirname(link), target)
527
+ )
528
+ return;
529
+ throw error(
530
+ "ZYRK_DEPLOY_NONRELOCATABLE",
531
+ "Conflicting dependency or executable slots exist in the runtime graph.",
532
+ );
533
+ }
534
+ await symlink(path.relative(path.dirname(link), target), link);
535
+ }
536
+ async function validateLinks(root: string, dir: string): Promise<void> {
537
+ for (const name of await readdir(dir)) {
538
+ const file = path.join(dir, name),
539
+ stat = await lstat(file);
540
+ if (stat.isSymbolicLink()) {
541
+ const target = await readlink(file);
542
+ if (
543
+ path.isAbsolute(target) ||
544
+ !within(root, path.resolve(path.dirname(file), target)) ||
545
+ !within(root, await realpath(file))
546
+ )
547
+ throw error(
548
+ "ZYRK_DEPLOY_ESCAPE",
549
+ "A portable link escapes the deployment boundary.",
550
+ );
551
+ } else if (stat.isDirectory()) await validateLinks(root, file);
552
+ }
553
+ }
554
+ function shellQuote(value: string): string {
555
+ return "'" + value.replaceAll("'", "'\\''") + "'";
556
+ }
557
+ async function docker(
558
+ options: DeployCommandOptions,
559
+ project: string,
560
+ manifest: Obj,
561
+ ): Promise<number> {
562
+ const operation = options.args[0];
563
+ if (!["init", "build", "run"].includes(operation ?? ""))
564
+ throw error("ZYRK_USAGE", "Select docker init, build, or run.");
565
+ const f = flags(options.args.slice(1));
566
+ if (operation === "init") {
567
+ for (const key of f.keys())
568
+ if (
569
+ ![
570
+ "--node-image",
571
+ "--cli-artifact",
572
+ "--cli-sha512",
573
+ "--build-script",
574
+ "--entry",
575
+ "--files",
576
+ "--port",
577
+ ].includes(key)
578
+ )
579
+ throw error("ZYRK_USAGE", "Unknown docker init option.");
580
+ const image = one(f, "--node-image");
581
+ if (
582
+ !image ||
583
+ !/^node:(?:24|26)[a-zA-Z0-9._-]*@sha256:[0-9a-f]{64}$/.test(image)
584
+ )
585
+ throw error(
586
+ "ZYRK_USAGE",
587
+ "Docker init requires a supported Node image pinned by sha256 digest.",
588
+ );
589
+ const artifact = relativeFile(one(f, "--cli-artifact") ?? "");
590
+ const digest = one(f, "--cli-sha512");
591
+ if (!digest || !/^[0-9a-f]{128}$/.test(digest))
592
+ throw error(
593
+ "ZYRK_USAGE",
594
+ "Docker init requires the pinned CLI artifact sha512 digest.",
595
+ );
596
+ const actual = await realpath(path.join(project, artifact)).catch(() => {
597
+ throw error("ZYRK_USAGE", "The pinned CLI artifact is missing.");
598
+ });
599
+ if (!within(project, actual) || (await sha512HexOfFile(actual)) !== digest)
600
+ throw error(
601
+ "ZYRK_INTEGRITY_FAILED",
602
+ "The pinned CLI artifact failed its project boundary or digest check.",
603
+ );
604
+ const entry = entryFile(f, manifest),
605
+ files = runtimeFiles(f, manifest);
606
+ if (!files.some((file) => within(file, entry)))
607
+ throw error(
608
+ "ZYRK_DEPLOY_NONRELOCATABLE",
609
+ "The runtime entry must belong to the selected production file set.",
610
+ );
611
+ const build = one(f, "--build-script");
612
+ if (
613
+ build &&
614
+ (!object(manifest.scripts) || typeof manifest.scripts[build] !== "string")
615
+ )
616
+ throw error(
617
+ "ZYRK_USAGE",
618
+ "The selected Docker build script is not declared.",
619
+ );
620
+ const portText = one(f, "--port") ?? "3000";
621
+ if (
622
+ !/^\d+$/.test(portText) ||
623
+ Number(portText) < 1 ||
624
+ Number(portText) > 65535
625
+ )
626
+ throw error("ZYRK_USAGE", "Choose a runtime port between 1 and 65535.");
627
+ const artifactJson = JSON.stringify([artifact, "/tmp/zyrk-cli.tgz"]);
628
+ const deployArgs =
629
+ files.map((file) => " --files " + shellQuote(file)).join("") +
630
+ " --entry " +
631
+ shellQuote(entry);
632
+ const dockerfile = `# syntax=docker/dockerfile:1\n# Generated inputs are recorded in zyrk-docker.json; review before building.\nFROM ${image} AS build\nWORKDIR /app\nCOPY ${artifactJson}\nRUN echo '${digest} /tmp/zyrk-cli.tgz' | sha512sum --check - && npm install --global /tmp/zyrk-cli.tgz && rm /tmp/zyrk-cli.tgz\nENV ZYRK_STORE_DIR=/zyrk-store\nCOPY . .\nRUN --mount=type=secret,id=npmrc,target=/root/.npmrc zyrk fetch --frozen-lockfile\nRUN --network=none zyrk install --frozen-lockfile --offline${build ? " && zyrk run " + shellQuote(build) : ""} && zyrk deploy --out /runtime-output --omit=dev${deployArgs}\nFROM ${image} AS runtime\nWORKDIR /app\nENV NODE_ENV=production\nCOPY --from=build --chown=node:node /runtime-output/ /app/\nUSER node\nEXPOSE ${portText}\nCMD ${JSON.stringify(["node", entry])}\n`;
633
+ const contents: Record<string, string> = {
634
+ Dockerfile: dockerfile,
635
+ ".dockerignore":
636
+ "node_modules\n.git\n.env\n.env.*\n**/.env\n**/.env.*\n.npmrc\n**/.npmrc\n.netrc\n**/.netrc\n**/*.pem\n**/*.key\n.zyrk-local\ndist-runtime\nnpm-debug.log*\n",
637
+ "zyrk-docker.json":
638
+ JSON.stringify(
639
+ {
640
+ schemaVersion: 1,
641
+ nodeImage: image,
642
+ cliArtifact: artifact,
643
+ cliSha512: digest,
644
+ entry,
645
+ files,
646
+ buildScript: build ?? null,
647
+ port: Number(portText),
648
+ },
649
+ null,
650
+ 2,
651
+ ) + "\n",
652
+ "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. Native/private build adapters and arbitrary shell start commands are not supported 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`,
653
+ };
654
+ for (const name of Object.keys(contents))
655
+ if (await lstat(path.join(project, name)).catch(() => null))
656
+ throw error(
657
+ "ZYRK_USAGE",
658
+ "Docker setup files already exist; review or move them before generating new files.",
659
+ );
660
+ const created: string[] = [];
661
+ try {
662
+ for (const [name, content] of Object.entries(contents)) {
663
+ await writeFile(path.join(project, name), content, {
664
+ flag: "wx",
665
+ mode: 0o644,
666
+ });
667
+ created.push(name);
668
+ }
669
+ } catch (cause) {
670
+ for (const name of created) await rm(path.join(project, name));
671
+ throw cause;
672
+ }
673
+ output(
674
+ options,
675
+ {
676
+ files: Object.keys(contents),
677
+ entry: ["node", entry],
678
+ port: Number(portText),
679
+ nodeImage: image,
680
+ },
681
+ `Created reviewable Docker setup files. Runtime: node ${entry}; port ${portText}; image ${image}. Build script: ${build ?? "none"}.`,
682
+ );
683
+ return 0;
684
+ }
685
+ for (const key of f.keys())
686
+ if (
687
+ !["--tag", "--port"].includes(key) ||
688
+ (operation === "build" && key === "--port")
689
+ )
690
+ throw error("ZYRK_USAGE", "Unknown Docker build/run option.");
691
+ const tag = one(f, "--tag");
692
+ if (
693
+ !tag ||
694
+ !/^([a-zA-Z0-9][a-zA-Z0-9._/-]*)(?::[a-zA-Z0-9_][a-zA-Z0-9_.-]*)?$/.test(
695
+ tag,
696
+ )
697
+ )
698
+ throw error(
699
+ "ZYRK_USAGE",
700
+ "Docker build/run requires an explicit local image --tag.",
701
+ );
702
+ const config = await readJson(path.join(project, "zyrk-docker.json"));
703
+ if (
704
+ config.schemaVersion !== 1 ||
705
+ typeof config.port !== "number" ||
706
+ !Number.isInteger(config.port) ||
707
+ config.port < 1 ||
708
+ config.port > 65535
709
+ )
710
+ throw error(
711
+ "ZYRK_MANIFEST_INVALID",
712
+ "Docker runtime declaration is invalid.",
713
+ );
714
+ const args =
715
+ operation === "build"
716
+ ? ["build", "--tag", tag, "."]
717
+ : [
718
+ "run",
719
+ "--rm",
720
+ "--publish",
721
+ `${one(f, "--port") ?? String(config.port)}:${config.port}`,
722
+ tag,
723
+ ];
724
+ if (
725
+ operation === "run" &&
726
+ !/^\d{1,5}$/.test(one(f, "--port") ?? String(config.port))
727
+ )
728
+ throw error("ZYRK_USAGE", "Choose an explicit numeric host port.");
729
+ const code = await subprocess(options, "docker", args, project, {
730
+ ...options.env,
731
+ DOCKER_BUILDKIT: "1",
732
+ });
733
+ if (code === 0)
734
+ output(
735
+ options,
736
+ { operation, tag, exitCode: code },
737
+ `Docker ${operation} completed for ${tag}. Application health was not tested.`,
738
+ );
739
+ return code;
740
+ }
741
+ async function subprocess(
742
+ options: DeployCommandOptions,
743
+ command: string,
744
+ args: string[],
745
+ cwd: string,
746
+ env: NodeJS.ProcessEnv,
747
+ ): Promise<number> {
748
+ return new Promise((resolve, reject) => {
749
+ const child = spawn(command, args, {
750
+ cwd,
751
+ env,
752
+ stdio: ["inherit", "pipe", "pipe"],
753
+ });
754
+ child.stdout?.on("data", (c: Buffer) => options.stdout.write(c.toString()));
755
+ child.stderr?.on("data", (c: Buffer) => options.stderr.write(c.toString()));
756
+ const signals: NodeJS.Signals[] = ["SIGTERM", "SIGINT", "SIGHUP"];
757
+ const listeners = signals.map((sig) => {
758
+ const listener = () => {
759
+ child.kill(sig);
760
+ };
761
+ process.on(sig, listener);
762
+ return [sig, listener] as const;
763
+ });
764
+ child.once("error", () =>
765
+ reject(
766
+ error(
767
+ "ZYRK_BUILD_FAILED",
768
+ "Cannot launch Docker; install the Docker CLI and enable a reachable BuildKit-capable daemon.",
769
+ ),
770
+ ),
771
+ );
772
+ child.once("close", (code, signal) => {
773
+ for (const [sig, listener] of listeners) process.off(sig, listener);
774
+ resolve(closeCode(code, signal));
775
+ });
776
+ });
777
+ }