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,598 @@
1
+ /** Project inspection and explicit execution (SPEC 12.2, 18.3.1, D-1). */
2
+ import { spawn } from "node:child_process";
3
+ import { access, lstat, readFile, readdir, realpath } from "node:fs/promises";
4
+ import { constants } from "node:fs";
5
+ import * as path from "node:path";
6
+ import { hostname } from "node:os";
7
+ import {
8
+ findProjectBoundary,
9
+ resolveConfigFile,
10
+ resolveStoreRoot,
11
+ } from "../config/paths.ts";
12
+ import { ZyrkError, type ErrorCode } from "../errors.ts";
13
+ import { lockedGraph, packageName } from "../install/lock.ts";
14
+ import { validateLockDoc } from "../lockfile/read.ts";
15
+ import {
16
+ manifestFingerprint,
17
+ type ManifestDeclaration,
18
+ } from "../lockfile/fingerprints.ts";
19
+ import { type ZyrkLockExtension } from "../lockfile/types.ts";
20
+ import { PROJECT_STATE_DIR } from "../project/projection.ts";
21
+ import { openStoreDb } from "../store/db.ts";
22
+ import { Store } from "../store/layout.ts";
23
+ import { acquireLock } from "../util/locks.ts";
24
+ import { closeCode, currentProcessIdentity } from "../util/proc.ts";
25
+ import { randomId } from "../util/ids.ts";
26
+ import { canonicalJson } from "../util/json.ts";
27
+ import { sha512Hex } from "../util/hash.ts";
28
+ import { CLI_VERSION } from "../version.ts";
29
+
30
+ type Obj = Record<string, unknown>;
31
+ function object(value: unknown): value is Obj {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+ function fail(code: ErrorCode, message: string): ZyrkError {
35
+ return new ZyrkError(code, message, {
36
+ remedy:
37
+ "Review the project manifest, run zyrk install to restore its selected graph, and retry.",
38
+ });
39
+ }
40
+ async function json(file: string): Promise<unknown> {
41
+ try {
42
+ return JSON.parse(await readFile(file, "utf8"));
43
+ } catch {
44
+ throw fail(
45
+ "ZYRK_MANIFEST_INVALID",
46
+ "Cannot read project metadata as JSON.",
47
+ );
48
+ }
49
+ }
50
+ export type ProjectCommandOptions = {
51
+ command: string;
52
+ args: readonly string[];
53
+ cwd: string;
54
+ env: NodeJS.ProcessEnv;
55
+ stdout: { write(chunk: string): unknown };
56
+ stderr: { write(chunk: string): unknown };
57
+ json?: boolean;
58
+ };
59
+ type State = {
60
+ generationId: string;
61
+ projectId: string;
62
+ graphDigest: string;
63
+ lockDigest: string;
64
+ readiness: string;
65
+ direct: { name: string; target: string }[];
66
+ };
67
+ function validateState(raw: unknown): State {
68
+ if (
69
+ !object(raw) ||
70
+ raw.schemaVersion !== 1 ||
71
+ typeof raw.generationId !== "string" ||
72
+ !/^gen1_[\w-]+$/.test(raw.generationId) ||
73
+ typeof raw.projectId !== "string" ||
74
+ typeof raw.graphDigest !== "string" ||
75
+ typeof raw.lockDigest !== "string" ||
76
+ typeof raw.readiness !== "string" ||
77
+ !Array.isArray(raw.direct) ||
78
+ raw.direct.some(
79
+ (e) =>
80
+ !object(e) ||
81
+ typeof e.name !== "string" ||
82
+ !packageName(e.name) ||
83
+ typeof e.target !== "string",
84
+ )
85
+ )
86
+ throw fail(
87
+ "ZYRK_STORE_CORRUPT",
88
+ "Project install state is missing or malformed.",
89
+ );
90
+ return raw as unknown as State;
91
+ }
92
+ async function load(
93
+ project: string,
94
+ ): Promise<{ state: State; extension: ZyrkLockExtension }> {
95
+ const state = validateState(
96
+ await json(path.join(project, "node_modules/.zyrk/install-state.json")),
97
+ );
98
+ let doc;
99
+ try {
100
+ doc = validateLockDoc(
101
+ await json(path.join(project, "package-lock.json")),
102
+ "project package-lock.json",
103
+ );
104
+ lockedGraph(doc);
105
+ } catch {
106
+ throw fail(
107
+ "ZYRK_LOCK_MISMATCH",
108
+ "Cannot inspect the installed graph: package-lock.json has no compatible zyrk extension.",
109
+ );
110
+ }
111
+ if (
112
+ sha512Hex(canonicalJson(doc)) !== state.lockDigest ||
113
+ doc.zyrk === undefined
114
+ )
115
+ throw fail(
116
+ "ZYRK_LOCK_MISMATCH",
117
+ "The installed generation and package-lock.json disagree.",
118
+ );
119
+ return { state, extension: doc.zyrk };
120
+ }
121
+ function emit(
122
+ options: ProjectCommandOptions,
123
+ result: unknown,
124
+ human: string,
125
+ ): void {
126
+ options.stdout.write(
127
+ options.json
128
+ ? JSON.stringify({
129
+ schemaVersion: 1,
130
+ command: options.command,
131
+ ...(result as Obj),
132
+ }) + "\n"
133
+ : human + "\n",
134
+ );
135
+ }
136
+
137
+ export async function runProjectCommand(
138
+ options: ProjectCommandOptions,
139
+ ): Promise<number> {
140
+ const boundary = await findProjectBoundary(options.cwd);
141
+ if (!boundary)
142
+ throw fail("ZYRK_MANIFEST_INVALID", "Cannot find a project package.json.");
143
+ const project = await realpath(boundary);
144
+ const manifest = await json(path.join(project, "package.json"));
145
+ if (!object(manifest))
146
+ throw fail(
147
+ "ZYRK_MANIFEST_INVALID",
148
+ "Project package.json must be an object.",
149
+ );
150
+ const executing = options.command === "run" || options.command === "exec";
151
+ if (
152
+ (!executing && options.command !== "why" && options.args.length !== 0) ||
153
+ (options.command === "why" &&
154
+ (options.args.length !== 1 || !packageName(options.args[0] ?? "")))
155
+ )
156
+ throw fail("ZYRK_USAGE", "Invalid project command arguments.");
157
+ if (executing && !options.args[0])
158
+ throw fail(
159
+ "ZYRK_USAGE",
160
+ "Select a project script or installed executable.",
161
+ );
162
+ // Never follow a replaced control directory when acquiring the project lock.
163
+ const control = await lstat(path.join(project, PROJECT_STATE_DIR)).catch(
164
+ () => null,
165
+ );
166
+ if (control && (!control.isDirectory() || control.isSymbolicLink()))
167
+ throw fail(
168
+ "ZYRK_STORE_CORRUPT",
169
+ "Project control directory is not a real directory.",
170
+ );
171
+ const held = await acquireLock(
172
+ path.join(project, PROJECT_STATE_DIR, "project.lock"),
173
+ { name: "project execution or inspection" },
174
+ );
175
+ try {
176
+ if (options.command === "doctor")
177
+ return await doctor(options, project, manifest);
178
+ const { state, extension } = await load(project);
179
+ if (options.command === "list" || options.command === "why") {
180
+ const activeKeys = new Set<string>();
181
+ const visit = (key: string): void => {
182
+ if (activeKeys.has(key)) return;
183
+ activeKeys.add(key);
184
+ for (const target of Object.values(extension.nodes[key]?.edges ?? {}))
185
+ visit(target);
186
+ };
187
+ for (const entry of state.direct) {
188
+ const key = extension.roots["."]?.[entry.name];
189
+ if (key !== undefined) visit(key);
190
+ }
191
+ const installed = Object.entries(extension.nodes).filter(([key]) =>
192
+ activeKeys.has(key),
193
+ );
194
+ const details = await retentionDetails(options.env, state);
195
+ const selected =
196
+ options.command === "why"
197
+ ? installed.filter(([, n]) => n.name === options.args[0])
198
+ : installed;
199
+ const nodes = selected.map(([key, n]) => ({
200
+ key,
201
+ name: n.name,
202
+ version: n.version,
203
+ source: n.source,
204
+ peers: n.peers,
205
+ contexts: details
206
+ .filter((d) => d.source === n.source)
207
+ .map((d) => d.context),
208
+ retainingProjects: [
209
+ ...new Set(
210
+ details
211
+ .filter((d) => d.source === n.source)
212
+ .flatMap((d) => d.projects),
213
+ ),
214
+ ],
215
+ dependencies: n.edges,
216
+ direct: Object.entries(extension.roots["."] ?? {})
217
+ .filter(([, target]) => target === key)
218
+ .map(([name]) => name),
219
+ requiredBy: installed.flatMap(([parent, node]) =>
220
+ Object.entries(node.edges)
221
+ .filter(([, target]) => target === key)
222
+ .map(([name]) => ({ parent, name })),
223
+ ),
224
+ realization: "shared",
225
+ }));
226
+ if (options.command === "why" && !nodes.length)
227
+ throw fail(
228
+ "ZYRK_RESOLUTION_FAILED",
229
+ "The requested package is absent from the installed graph.",
230
+ );
231
+ emit(
232
+ options,
233
+ {
234
+ generationId: state.generationId,
235
+ graphDigest: state.graphDigest,
236
+ readiness: state.readiness,
237
+ nodes,
238
+ },
239
+ nodes
240
+ .map(
241
+ (n) =>
242
+ `${n.name}@${n.version}${n.direct.length ? " (direct: " + n.direct.join(", ") + ")" : ""}${n.requiredBy.length ? " <- " + n.requiredBy.map((p) => extension.nodes[p.parent]?.name).join(", ") : ""}`,
243
+ )
244
+ .join("\n") || "No installed dependencies.",
245
+ );
246
+ return 0;
247
+ }
248
+ if (!executing) throw fail("ZYRK_USAGE", "Unknown project command.");
249
+ if (state.readiness !== "ready")
250
+ throw fail(
251
+ "ZYRK_SCRIPT_BLOCKED",
252
+ "Cannot execute an intentionally unbuilt or unready dependency generation.",
253
+ );
254
+ let command: string, args: string[];
255
+ const selected = options.args[0]!;
256
+ if (options.command === "run") {
257
+ await authorizeScript(options.env);
258
+ const scripts = manifest.scripts;
259
+ if (
260
+ !object(scripts) ||
261
+ !Object.hasOwn(scripts, selected) ||
262
+ typeof scripts[selected] !== "string"
263
+ )
264
+ throw fail(
265
+ "ZYRK_USAGE",
266
+ "The selected project script is not declared.",
267
+ );
268
+ const forwarded = options.args.slice(options.args[1] === "--" ? 2 : 1);
269
+ command = "/bin/sh";
270
+ args = [
271
+ "-c",
272
+ scripts[selected] +
273
+ (forwarded.length ? " " + forwarded.map(quote).join(" ") : ""),
274
+ ];
275
+ } else {
276
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(selected))
277
+ throw fail(
278
+ "ZYRK_USAGE",
279
+ "Select an installed executable by its local bin name.",
280
+ );
281
+ command = path.join(project, "node_modules/.bin", selected);
282
+ try {
283
+ await access(command, constants.X_OK);
284
+ if (!(await lstat(await realpath(command))).isFile()) throw new Error();
285
+ } catch {
286
+ throw fail(
287
+ "ZYRK_RESOLUTION_FAILED",
288
+ "The selected executable is not installed locally; exec never fetches packages.",
289
+ );
290
+ }
291
+ args = options.args.slice(1);
292
+ }
293
+ const env = {
294
+ ...options.env,
295
+ PATH:
296
+ path.join(project, "node_modules/.bin") +
297
+ path.delimiter +
298
+ (options.env.PATH ?? ""),
299
+ INIT_CWD: options.cwd,
300
+ npm_package_json: path.join(project, "package.json"),
301
+ npm_execpath: path.resolve(import.meta.dirname, "../../bin/zyrk.js"),
302
+ npm_config_user_agent: `zyrk/${CLI_VERSION} node/${process.version} ${process.platform} ${process.arch}`,
303
+ };
304
+ if (options.command === "run")
305
+ Object.assign(env, {
306
+ npm_lifecycle_event: selected,
307
+ npm_lifecycle_script: (manifest.scripts as Obj)[selected],
308
+ });
309
+ return await leasedChild(options, state, command, args, project, env);
310
+ } finally {
311
+ await held.release();
312
+ }
313
+ }
314
+ function quote(value: string): string {
315
+ return "'" + value.replaceAll("'", "'\\''") + "'";
316
+ }
317
+ async function authorizeScript(env: NodeJS.ProcessEnv): Promise<void> {
318
+ let raw: unknown;
319
+ try {
320
+ raw = JSON.parse(await readFile(resolveConfigFile({ env }), "utf8"));
321
+ } catch (error) {
322
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
323
+ throw fail(
324
+ "ZYRK_POLICY_DENIED",
325
+ "Cannot read user execution policy; project script execution is denied.",
326
+ );
327
+ }
328
+ // No permissive interpretation of security settings not implemented by this runner.
329
+ if (
330
+ !object(raw) ||
331
+ Object.keys(raw).some((key) => /script|policy|security/i.test(key))
332
+ )
333
+ throw fail(
334
+ "ZYRK_POLICY_DENIED",
335
+ "Configured user script policy cannot be enforced by this runner; project script execution is denied.",
336
+ );
337
+ }
338
+ async function leasedChild(
339
+ options: ProjectCommandOptions,
340
+ state: State,
341
+ command: string,
342
+ args: string[],
343
+ cwd: string,
344
+ env: NodeJS.ProcessEnv,
345
+ ): Promise<number> {
346
+ let store: Store;
347
+ try {
348
+ store = await Store.open({
349
+ root: resolveStoreRoot({ env: options.env }).root,
350
+ });
351
+ store.requireInitialized();
352
+ } catch {
353
+ throw fail(
354
+ "ZYRK_STORE_UNSAFE",
355
+ "Cannot lease the installed graph in the selected user store.",
356
+ );
357
+ }
358
+ const barrier = await acquireLock(
359
+ path.join(store.paths.locksDir, "liveness.lock"),
360
+ { name: "generation lease registration" },
361
+ );
362
+ const db = openStoreDb(store.paths.dbFile);
363
+ const lease = "lease1_" + randomId("", 16);
364
+ try {
365
+ const generation = db
366
+ .prepare(
367
+ "SELECT generation_id FROM generations WHERE generation_id = ? AND project_id = ? AND graph_digest = ? AND state = 'active'",
368
+ )
369
+ .get(state.generationId, state.projectId, state.graphDigest);
370
+ if (!generation)
371
+ throw fail(
372
+ "ZYRK_STORE_CORRUPT",
373
+ "Cannot lease the installed generation: its store registration is absent or inconsistent.",
374
+ );
375
+ const instances = db
376
+ .prepare(
377
+ "SELECT instance_id FROM generation_instances WHERE generation_id = ? ORDER BY instance_id",
378
+ )
379
+ .all(state.generationId)
380
+ .map((row) => row.instance_id);
381
+ const identity = currentProcessIdentity(),
382
+ now = new Date().toISOString();
383
+ db.prepare("INSERT INTO leases VALUES (?, ?, ?, ?, ?, 'active', ?, ?)").run(
384
+ lease,
385
+ identity.pid,
386
+ identity.startId,
387
+ hostname(),
388
+ JSON.stringify({
389
+ generationId: state.generationId,
390
+ graphDigest: state.graphDigest,
391
+ instances,
392
+ }),
393
+ now,
394
+ now,
395
+ );
396
+ } catch (error) {
397
+ db.close();
398
+ throw error;
399
+ } finally {
400
+ await barrier.release();
401
+ }
402
+ try {
403
+ return await new Promise<number>((resolve, reject) => {
404
+ const child = spawn(command, args, {
405
+ cwd,
406
+ env,
407
+ stdio: ["inherit", "pipe", "pipe"],
408
+ detached: process.platform !== "win32",
409
+ });
410
+ // Once spawned, the child owns liveness even if the wrapper is killed.
411
+ // A null start discriminator intentionally retains a recycled PID conservatively.
412
+ if (child.pid !== undefined)
413
+ db.prepare(
414
+ "UPDATE leases SET holder_pid=?, holder_start_id=NULL WHERE lease_id=?",
415
+ ).run(child.pid, lease);
416
+ const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"];
417
+ const listeners = signals.map((signal) => {
418
+ const listener = () => {
419
+ try {
420
+ if (child.pid)
421
+ process.kill(
422
+ process.platform === "win32" ? child.pid : -child.pid,
423
+ signal,
424
+ );
425
+ } catch {
426
+ /* A finished child needs no forwarding. */
427
+ }
428
+ };
429
+ process.on(signal, listener);
430
+ return [signal, listener] as const;
431
+ });
432
+ child.stdout?.on("data", (chunk: Buffer) =>
433
+ options.stdout.write(chunk.toString()),
434
+ );
435
+ child.stderr?.on("data", (chunk: Buffer) =>
436
+ options.stderr.write(chunk.toString()),
437
+ );
438
+ child.once("error", () => {
439
+ reject(
440
+ fail(
441
+ "ZYRK_BUILD_FAILED",
442
+ "Cannot launch the selected project command.",
443
+ ),
444
+ );
445
+ });
446
+ child.once("close", (code, signal) => {
447
+ for (const [sig, listener] of listeners) process.off(sig, listener);
448
+ resolve(closeCode(code, signal));
449
+ });
450
+ });
451
+ } finally {
452
+ db.prepare(
453
+ "UPDATE leases SET state='released', heartbeat_at=? WHERE lease_id=?",
454
+ ).run(new Date().toISOString(), lease);
455
+ db.close();
456
+ }
457
+ }
458
+ async function doctor(
459
+ options: ProjectCommandOptions,
460
+ project: string,
461
+ manifest: Obj,
462
+ ): Promise<number> {
463
+ const diagnostics: {
464
+ code: string;
465
+ severity: "error" | "warning";
466
+ message: string;
467
+ }[] = [];
468
+ const add = (
469
+ code: string,
470
+ message: string,
471
+ severity: "error" | "warning" = "error",
472
+ ) => diagnostics.push({ code, severity, message });
473
+ try {
474
+ const { state, extension } = await load(project);
475
+ if (
476
+ manifestFingerprint(manifest as ManifestDeclaration) !==
477
+ extension.manifest
478
+ )
479
+ add(
480
+ "ZYRK_LOCK_MISMATCH",
481
+ "Dependency declarations differ from the installed lock.",
482
+ );
483
+ if (state.readiness !== "ready")
484
+ add(
485
+ "ZYRK_SCRIPT_BLOCKED",
486
+ "Installed dependencies are intentionally unbuilt or unready.",
487
+ );
488
+ const expected = new Set(state.direct.map((entry) => entry.name));
489
+ for (const item of await readdir(path.join(project, "node_modules"))) {
490
+ if (item.startsWith(".")) continue;
491
+ const names = item.startsWith("@")
492
+ ? (await readdir(path.join(project, "node_modules", item))).map(
493
+ (name) => `${item}/${name}`,
494
+ )
495
+ : [item];
496
+ for (const name of names)
497
+ if (!expected.has(name))
498
+ add(
499
+ "ZYRK_STORE_CORRUPT",
500
+ "Unexpected package entry exists outside the installed projection.",
501
+ );
502
+ }
503
+ for (const entry of state.direct) {
504
+ try {
505
+ const link = path.join(project, "node_modules", entry.name);
506
+ if (
507
+ !(await lstat(link)).isSymbolicLink() ||
508
+ (await realpath(link)) !== (await realpath(entry.target))
509
+ )
510
+ throw new Error();
511
+ } catch {
512
+ add(
513
+ "ZYRK_STORE_CORRUPT",
514
+ `Installed dependency link for ${entry.name} is missing or changed.`,
515
+ );
516
+ }
517
+ }
518
+ } catch (error) {
519
+ add(
520
+ error instanceof ZyrkError ? error.code : "ZYRK_STORE_CORRUPT",
521
+ "Project install state or its lock is missing, invalid, or inconsistent.",
522
+ );
523
+ }
524
+ try {
525
+ const store = await Store.open({
526
+ root: resolveStoreRoot({ env: options.env }).root,
527
+ });
528
+ if (!store.initialized)
529
+ add("ZYRK_STORE_UNSAFE", "The selected user store is not initialized.");
530
+ } catch {
531
+ add(
532
+ "ZYRK_STORE_UNSAFE",
533
+ "The selected user store failed ownership, format, or filesystem safety checks.",
534
+ );
535
+ }
536
+ if (options.env.NODE_PATH)
537
+ add(
538
+ "ZYRK_POLICY_DENIED",
539
+ "NODE_PATH enables ambient package lookup outside the selected graph.",
540
+ "warning",
541
+ );
542
+ if (
543
+ /--preserve-symlinks(?:-main)?(?:\s|=|$)/.test(
544
+ options.env.NODE_OPTIONS ?? "",
545
+ )
546
+ )
547
+ add(
548
+ "ZYRK_POLICY_DENIED",
549
+ "Symlink preservation in NODE_OPTIONS can change dependency and singleton resolution.",
550
+ "warning",
551
+ );
552
+ const ok = !diagnostics.some((d) => d.severity === "error");
553
+ emit(
554
+ options,
555
+ { ok, diagnostics },
556
+ diagnostics.length
557
+ ? diagnostics
558
+ .map((d) => `${d.severity}: ${d.code}: ${d.message}`)
559
+ .join("\n")
560
+ : "Project lock, direct dependency links, readiness, and store safety checks passed.",
561
+ );
562
+ return ok ? 0 : 1;
563
+ }
564
+
565
+ async function retentionDetails(
566
+ env: NodeJS.ProcessEnv,
567
+ state: State,
568
+ ): Promise<{ source: string; context: string; projects: string[] }[]> {
569
+ try {
570
+ const store = await Store.open({ root: resolveStoreRoot({ env }).root });
571
+ if (!store.initialized) return [];
572
+ const db = openStoreDb(store.paths.dbFile);
573
+ try {
574
+ const rows = db
575
+ .prepare(
576
+ `SELECT r.source_id, gi.context_id FROM generation_instances gi JOIN realizations r ON r.context_id=gi.context_id WHERE gi.generation_id=?`,
577
+ )
578
+ .all(state.generationId);
579
+ return rows.map((row) => ({
580
+ source: String(row.source_id),
581
+ context: String(row.context_id),
582
+ projects: db
583
+ .prepare(
584
+ `SELECT DISTINCT g.project_id FROM generations g JOIN generation_instances gi ON gi.generation_id=g.generation_id JOIN roots root ON root.generation_id=g.generation_id WHERE gi.context_id=? AND root.state <> 'removed' ORDER BY g.project_id`,
585
+ )
586
+ .all(row.context_id!)
587
+ .map((p) => String(p.project_id)),
588
+ }));
589
+ } finally {
590
+ db.close();
591
+ }
592
+ } catch {
593
+ throw fail(
594
+ "ZYRK_STORE_CORRUPT",
595
+ "Cannot inspect the installed generation retention metadata.",
596
+ );
597
+ }
598
+ }