@venn-lang/cli 0.1.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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/dist/bin/venn.mjs +16 -0
  4. package/dist/cli.mjs +56378 -0
  5. package/dist/index.d.mts +98 -0
  6. package/dist/index.d.mts.map +1 -0
  7. package/dist/index.mjs +916 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +67 -0
  10. package/src/bin/venn.ts +14 -0
  11. package/src/cli.ts +299 -0
  12. package/src/commands/build.ts +97 -0
  13. package/src/commands/check.ts +143 -0
  14. package/src/commands/deps.ts +228 -0
  15. package/src/commands/fmt.ts +55 -0
  16. package/src/commands/index.ts +2 -0
  17. package/src/commands/inside-workspace.ts +20 -0
  18. package/src/commands/list.ts +53 -0
  19. package/src/commands/new.ts +66 -0
  20. package/src/commands/run.ts +149 -0
  21. package/src/commands/script.ts +127 -0
  22. package/src/commands/upgrade.ts +86 -0
  23. package/src/commands/verify-plugin.ts +35 -0
  24. package/src/index.ts +7 -0
  25. package/src/manifest/index.ts +2 -0
  26. package/src/manifest/load-env.ts +70 -0
  27. package/src/manifest/load-manifest.ts +42 -0
  28. package/src/project/command-targets.ts +53 -0
  29. package/src/project/index.ts +9 -0
  30. package/src/project/resolve-targets.ts +70 -0
  31. package/src/project/select-packages.ts +37 -0
  32. package/src/project/targets-or-exit.ts +28 -0
  33. package/src/reporters/colors.ts +17 -0
  34. package/src/reporters/dot-sink.ts +18 -0
  35. package/src/reporters/error-line.ts +14 -0
  36. package/src/reporters/index.ts +9 -0
  37. package/src/reporters/junit-sink.ts +39 -0
  38. package/src/reporters/ndjson-stdout.ts +13 -0
  39. package/src/reporters/pick-reporter.ts +28 -0
  40. package/src/reporters/pretty/diff-lines.ts +53 -0
  41. package/src/reporters/pretty/index.ts +1 -0
  42. package/src/reporters/pretty/pretty-reporter.ts +122 -0
  43. package/src/reporters/pretty/pretty.types.ts +29 -0
  44. package/src/reporters/pretty/render.ts +88 -0
  45. package/src/reporters/problem-reporter.ts +13 -0
  46. package/src/reporters/problem-sink.ts +20 -0
  47. package/src/reporters/reporter.types.ts +21 -0
  48. package/src/run/collect-files.ts +43 -0
  49. package/src/run/ending.types.ts +17 -0
  50. package/src/run/exit-code.ts +15 -0
  51. package/src/run/node-io.ts +27 -0
  52. package/src/run/npm-loader.ts +41 -0
  53. package/src/run/package-types.ts +66 -0
  54. package/src/run/run-file.ts +106 -0
  55. package/src/run/should-leave.ts +11 -0
  56. package/src/run/step-titles.ts +61 -0
  57. package/src/shutdown/create-leave.ts +42 -0
  58. package/src/shutdown/create-shutdown.ts +34 -0
  59. package/src/shutdown/index.ts +7 -0
  60. package/src/shutdown/install-exit-hook.ts +21 -0
  61. package/src/shutdown/install-fault-hooks.ts +30 -0
  62. package/src/shutdown/install-hooks.ts +36 -0
  63. package/src/shutdown/install-signal-hooks.ts +26 -0
  64. package/src/shutdown/shutdown.types.ts +20 -0
  65. package/src/title/index.ts +2 -0
  66. package/src/title/program-title.ts +15 -0
  67. package/src/title/set-program-title.ts +15 -0
  68. package/src/upgrade/index.ts +6 -0
  69. package/src/upgrade/install-site.ts +70 -0
  70. package/src/upgrade/latest-version.ts +50 -0
  71. package/src/upgrade/upgrade-command.ts +32 -0
  72. package/src/upgrade/upgrade-plan.ts +36 -0
  73. package/src/upgrade/upgrade.types.ts +17 -0
  74. package/src/upgrade/version.ts +21 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,916 @@
1
+ import { createRequire } from "node:module";
2
+ import { readFile, readdir, stat } from "node:fs/promises";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
+ import { createNodeFs, createNodeHost, createNodeSignals } from "@venn-lang/contracts/node";
5
+ import { HttpClientPort, HttpServerPort, createFetchClient } from "@venn-lang/http";
6
+ import { createNodeServer } from "@venn-lang/http/node";
7
+ import { ALL_SIGNALS, ConsolePort, VennError, dotenvFiles, parseDotenv, resolveAlias } from "@venn-lang/contracts";
8
+ import { findProject, isInside, normalise } from "@venn-lang/project";
9
+ import { checkImports, createNdjsonSink, createRunner, resolveImports } from "@venn-lang/runtime";
10
+ import { formatValue, parse } from "@venn-lang/core";
11
+ import { pathToFileURL } from "node:url";
12
+ import { allPlugins, stdlibPortBindings } from "@venn-lang/stdlib";
13
+ import process$1 from "node:process";
14
+ //#region src/manifest/load-env.ts
15
+ /**
16
+ * Every variable a run can see, lowest precedence first:
17
+ *
18
+ * 1. `[env.<name>]` in `venn.toml`, the documented default, committed.
19
+ * 2. the dotenv files, in the order they are listed.
20
+ * 3. the real environment the process was started with.
21
+ *
22
+ * The real environment wins because that is how CI passes a token in, and a
23
+ * value set on the command line should never lose to a file in the repository.
24
+ */
25
+ async function loadEnv(args) {
26
+ const declared = {
27
+ ...args.manifest?.env[args.name] ?? {},
28
+ ...await readFiles(args)
29
+ };
30
+ const real = overrides(declared, args.processEnv ?? process.env);
31
+ return {
32
+ ...declared,
33
+ ...real,
34
+ name: args.name
35
+ };
36
+ }
37
+ /**
38
+ * The real environment overrides what was declared. It does not add to it.
39
+ *
40
+ * A shell holds hundreds of entries; letting them all become `env.*` would put
41
+ * `PATH` and `TEMP` in the editor's completion and let a typo silently read
42
+ * something from the machine. Declaring a name, in `venn.toml` or a dotenv file
43
+ * and with any value, is what says "this program reads this". A value that
44
+ * exists only in CI and is never declared belongs to `secrets.*`, which reads
45
+ * the environment directly and redacts what it returns.
46
+ */
47
+ function overrides(declared, source) {
48
+ const out = {};
49
+ for (const name of Object.keys(declared)) {
50
+ const value = source[name];
51
+ if (value !== void 0) out[name] = value;
52
+ }
53
+ return out;
54
+ }
55
+ async function readFiles(args) {
56
+ const names = dotenvFiles({
57
+ configured: args.manifest?.envFiles,
58
+ name: args.name
59
+ });
60
+ const out = {};
61
+ for (const each of names) Object.assign(out, await readOne(resolve(args.dir, each)));
62
+ return out;
63
+ }
64
+ /** A file that is not there is not a problem: most projects have none of them. */
65
+ async function readOne(path) {
66
+ const content = await readFile(path, "utf8").catch(() => void 0);
67
+ return content === void 0 ? {} : parseDotenv(content);
68
+ }
69
+ //#endregion
70
+ //#region src/manifest/load-manifest.ts
71
+ /**
72
+ * The manifest that governs a `.vn` file.
73
+ *
74
+ * Found by walking up to the project the file belongs to, rather than by
75
+ * reading whatever happens to sit in the same folder. That is what makes a
76
+ * command work from anywhere: `venn test packages/api/src/login.vn` sees the
77
+ * same environments and aliases as running it from inside `packages/api`.
78
+ *
79
+ * Undefined when nothing governs the file: living outside a project is normal.
80
+ */
81
+ async function loadManifest(sourceUri) {
82
+ const file = normalise(resolve(sourceUri));
83
+ const { project } = await findProject({
84
+ fs: createNodeFs(),
85
+ from: normalise(dirname(file))
86
+ });
87
+ if (!project) return void 0;
88
+ const owner = owningPackage(project, file);
89
+ if (owner) return {
90
+ manifest: owner.manifest,
91
+ dir: owner.dir
92
+ };
93
+ return {
94
+ manifest: project.rootManifest,
95
+ dir: project.root
96
+ };
97
+ }
98
+ /** The innermost member holding this file: the one whose settings are nearest. */
99
+ function owningPackage(project, file) {
100
+ return [...project.packages].filter((one) => isInside(file, one.dir)).sort((a, b) => b.dir.length - a.dir.length)[0];
101
+ }
102
+ //#endregion
103
+ //#region src/reporters/dot-sink.ts
104
+ /** Terminal reporter: one char per assertion, a summary line at the end. */
105
+ function createDotSink() {
106
+ return { emit: (envelope) => write$1(envelope) };
107
+ }
108
+ function write$1(envelope) {
109
+ if (envelope.kind === "expect.passed") process.stdout.write(".");
110
+ else if (envelope.kind === "expect.failed") process.stdout.write("F");
111
+ else if (envelope.kind === "run.finished") writeSummary(envelope);
112
+ }
113
+ function writeSummary(envelope) {
114
+ const data = envelope.data;
115
+ process.stdout.write(`\n${data.passed} passed, ${data.failed} failed (${data.durationMs}ms)\n`);
116
+ }
117
+ //#endregion
118
+ //#region src/reporters/error-line.ts
119
+ /**
120
+ * One line for one failure, however it reached us.
121
+ *
122
+ * A `VennError` already knows how it wants to read, and leads with its code so
123
+ * the failure is googlable; anything else is a stray from below the language and
124
+ * gets its message, never `[object Object]`.
125
+ */
126
+ function errorLine(error) {
127
+ if (error instanceof VennError) return `${error.code} ${error.message}`;
128
+ const message = error?.message;
129
+ return typeof message === "string" && message !== "" ? message : String(error);
130
+ }
131
+ //#endregion
132
+ //#region src/reporters/junit-sink.ts
133
+ const XML_ESCAPES = {
134
+ "<": "&lt;",
135
+ ">": "&gt;",
136
+ "&": "&amp;",
137
+ "\"": "&quot;"
138
+ };
139
+ /** Accumulate flow results and emit a JUnit XML document on run.finished. */
140
+ function createJunitSink(args) {
141
+ const flows = [];
142
+ return { emit: (envelope) => {
143
+ if (envelope.kind === "flow.finished") flows.push(envelope.data);
144
+ else if (envelope.kind === "run.finished") args.write(toJunit(flows));
145
+ } };
146
+ }
147
+ function toJunit(flows) {
148
+ const failures = flows.filter((flow) => flow.status === "failed").length;
149
+ const cases = flows.map(toCase).join("\n");
150
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite tests="${flows.length}" failures="${failures}">\n${cases}\n</testsuite>\n`;
151
+ }
152
+ function toCase(flow) {
153
+ const body = flow.status === "failed" ? "<failure/>" : "";
154
+ return ` <testcase name="${escapeXml(flow.title)}">${body}</testcase>`;
155
+ }
156
+ function escapeXml(text) {
157
+ return text.replace(/[<>&"]/g, (char) => XML_ESCAPES[char] ?? char);
158
+ }
159
+ //#endregion
160
+ //#region src/reporters/ndjson-stdout.ts
161
+ /**
162
+ * An event sink that writes each envelope to stdout as one line of NDJSON: the
163
+ * stream a script or a CI job parses.
164
+ */
165
+ function createStdoutSink() {
166
+ return createNdjsonSink({ write: (line) => {
167
+ process.stdout.write(line);
168
+ } });
169
+ }
170
+ //#endregion
171
+ //#region src/reporters/colors.ts
172
+ const ESC = String.fromCharCode(27);
173
+ const ENABLED = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && process.env.TERM !== "dumb";
174
+ function style(open, close) {
175
+ return (text) => ENABLED ? `${ESC}[${open}m${text}${ESC}[${close}m` : text;
176
+ }
177
+ const bold = style(1, 22);
178
+ const dim = style(2, 22);
179
+ const red = style(31, 39);
180
+ const green = style(32, 39);
181
+ const cyan = style(36, 39);
182
+ const inverse = style(7, 27);
183
+ //#endregion
184
+ //#region src/reporters/pretty/diff-lines.ts
185
+ const INDENT = " ";
186
+ const COLUMN = 8;
187
+ /**
188
+ * The body of a failure: the two sides as the kernel compared them, field by
189
+ * field (§16). The title says what went wrong in one line; this says where.
190
+ */
191
+ function diffLines(diff) {
192
+ if (diff.kind === "fields") return fieldLines(diff.label, diff.entries);
193
+ if (diff.kind === "json") return [head(diff.path), ...pairLines(formatValue(diff.expected), formatValue(diff.actual))];
194
+ return pairLines(diff.expected, diff.actual);
195
+ }
196
+ function fieldLines(label, entries) {
197
+ const width = Math.max(0, ...entries.map((entry) => entry.path.length));
198
+ const lines = [head(label)];
199
+ for (const [index, entry] of entries.entries()) lines.push(...entryLines({
200
+ entry,
201
+ width,
202
+ last: index === entries.length - 1
203
+ }));
204
+ return lines;
205
+ }
206
+ /** One field: a single line when both sides agree, two when they do not. */
207
+ function entryLines(args) {
208
+ const { entry, last, width } = args;
209
+ const branch = `${INDENT}${dim(last ? "└" : "├")} ${entry.path.padEnd(width)}`;
210
+ if (entry.same) return [`${branch} ${column("same")} ${dim(entry.expected)}`];
211
+ const gutter = `${INDENT}${dim(last ? " " : "│")} ${" ".repeat(width)}`;
212
+ return [`${branch} ${column("expected")} ${green(entry.expected)}`, `${gutter} ${column("actual")} ${red(entry.actual)}`];
213
+ }
214
+ function pairLines(expected, actual) {
215
+ return [`${INDENT}${column("expected")} ${green(expected)}`, `${INDENT}${column("actual")} ${red(actual)}`];
216
+ }
217
+ function head(label) {
218
+ return `${INDENT}${bold(label)}`;
219
+ }
220
+ function column(label) {
221
+ return dim(label.padEnd(COLUMN));
222
+ }
223
+ //#endregion
224
+ //#region src/reporters/pretty/render.ts
225
+ const PASS = "✓";
226
+ const FAIL = "✗";
227
+ /** The banner naming the file under test. Flows add their own leading blank line. */
228
+ function header(file) {
229
+ return `\n${inverse(cyan(" RUN "))} ${dim(shorten(file))}`;
230
+ }
231
+ /** A flow opens a branch of the tree. */
232
+ function flowLine(title) {
233
+ return `\n ${dim("❯")} ${bold(title)}`;
234
+ }
235
+ /** A step closes with its verdict and how long it took. */
236
+ function stepLine(args) {
237
+ return ` ${args.passed ? green(PASS) : red(FAIL)} ${args.title} ${dim(`${args.ms}ms`)}`;
238
+ }
239
+ /** Why a step failed, shown inline right under it. */
240
+ function reasonLine(failure) {
241
+ return ` ${red("→")} ${failure.title}`;
242
+ }
243
+ /** A `log` line the flow emitted, shown under its step like console output. */
244
+ function logLine(message) {
245
+ const [first = "", ...rest] = message.split("\n");
246
+ return [` ${dim("›")} ${first}`, ...rest.map((line) => ` ${line}`)].join("\n");
247
+ }
248
+ function failuresBlock(failures) {
249
+ if (failures.length === 0) return "";
250
+ const blocks = failures.map((failure, index) => block(failure, index + 1));
251
+ return `\n${inverse(red(" FAILURES "))}\n\n${blocks.join("\n\n")}\n`;
252
+ }
253
+ function summary(args) {
254
+ const line = [args.failed > 0 ? red(`${args.failed} failed`) : void 0, green(`${args.passed} passed`)].filter(Boolean).join(dim(" | "));
255
+ const total = dim(`(${args.passed + args.failed})`);
256
+ return `${args.files && args.files > 1 ? `\n ${dim("Files")} ${args.files}` : ""}\n ${dim("Tests")} ${line} ${total}\n ${dim(" Time")} ${dim(`${args.ms}ms`)}\n`;
257
+ }
258
+ /** `examples/testing/01-first-flow.vn:12:5`, relative to where the CLI was invoked. */
259
+ function locationOf(problem) {
260
+ const span = problem.span;
261
+ if (!span?.uri) return void 0;
262
+ return `${shorten(span.uri)}:${span.line}:${span.column}`;
263
+ }
264
+ function block(failure, index) {
265
+ const lines = [` ${red(`${index})`)} ${bold(where(failure))}`, ` ${red(failure.code)} ${failure.title}`];
266
+ if (failure.location) lines.push(` ${dim(`at ${failure.location}`)}`);
267
+ if (failure.diff) lines.push("", ...diffLines(failure.diff));
268
+ return lines.join("\n");
269
+ }
270
+ /**
271
+ * Which flow and step it happened in. A hook that failed belongs to neither, so
272
+ * it says so instead of borrowing the last step's name.
273
+ */
274
+ function where(failure) {
275
+ return [failure.flow, failure.step].filter(Boolean).join(` ${dim("›")} `) || "lifecycle";
276
+ }
277
+ function shorten(file) {
278
+ const path = relative(process.cwd(), file);
279
+ return path && !path.startsWith("..") ? path.replace(/\\/g, "/") : file;
280
+ }
281
+ //#endregion
282
+ //#region src/reporters/pretty/pretty-reporter.ts
283
+ /**
284
+ * A live tree: each file opens a banner, each flow a branch, and each step
285
+ * prints its verdict as soon as it settles. Everything that failed across every
286
+ * file is repeated at the end with its code and source location.
287
+ */
288
+ function createPrettyReporter() {
289
+ const state = {
290
+ flow: "",
291
+ step: "",
292
+ inStep: false,
293
+ stepStartedAt: 0,
294
+ current: [],
295
+ logs: [],
296
+ failures: []
297
+ };
298
+ return {
299
+ sink: { emit: (envelope) => handle(envelope, state) },
300
+ beginFile: (file) => {
301
+ state.pendingFile = file;
302
+ },
303
+ finish: (totals) => finishRun(state, totals)
304
+ };
305
+ }
306
+ function handle(envelope, state) {
307
+ const data = envelope.data;
308
+ if (envelope.kind === "flow.started") beginFlow(state, data);
309
+ else if (envelope.kind === "step.started") beginStep(state, envelope, data);
310
+ else if (envelope.kind === "expect.failed") failed(state, data);
311
+ else if (envelope.kind === "step.finished") endStep(state, envelope, data);
312
+ else if (envelope.kind === "flow.finished") state.flow = "";
313
+ else if (envelope.kind === "log") logged(state, data);
314
+ }
315
+ /**
316
+ * A failure inside a step waits for that step's verdict, to print under it. One
317
+ * that arrives between steps (a `setup` or a `teardown` that blew up) has no
318
+ * verdict coming, and holding it would let the next `step.started` wipe it, so
319
+ * it goes straight to the summary.
320
+ */
321
+ function failed(state, data) {
322
+ const failure = fromProblem(state, data);
323
+ if (state.inStep) state.current.push(failure);
324
+ else state.failures.push(failure);
325
+ }
326
+ function beginFlow(state, data) {
327
+ if (state.pendingFile) {
328
+ write(header(state.pendingFile));
329
+ state.pendingFile = void 0;
330
+ }
331
+ state.flow = String(data.title ?? "");
332
+ write(flowLine(state.flow));
333
+ }
334
+ function beginStep(state, envelope, data) {
335
+ state.step = String(data.title ?? "");
336
+ state.inStep = true;
337
+ state.stepStartedAt = Date.parse(envelope.ts);
338
+ state.current = [];
339
+ state.logs = [];
340
+ }
341
+ function endStep(state, envelope, data) {
342
+ const ms = Math.max(0, Date.parse(envelope.ts) - state.stepStartedAt);
343
+ write(stepLine({
344
+ title: String(data.title ?? ""),
345
+ passed: data.status === "passed",
346
+ ms
347
+ }));
348
+ for (const line of state.logs) write(logLine(line));
349
+ for (const failure of state.current) write(reasonLine(failure));
350
+ state.failures.push(...state.current);
351
+ state.current = [];
352
+ state.logs = [];
353
+ state.inStep = false;
354
+ }
355
+ /**
356
+ * `log "…"` prints under its step, like console output in vitest. An error
357
+ * thrown mid-flow also arrives as a log, and that one is kept for the summary.
358
+ */
359
+ function logged(state, data) {
360
+ const message = String(data.message ?? "");
361
+ if (data.level === "error") state.failures.push({
362
+ flow: state.flow,
363
+ step: state.step,
364
+ code: "VN7001",
365
+ title: message
366
+ });
367
+ else state.logs.push(message);
368
+ }
369
+ function finishRun(state, totals) {
370
+ write(failuresBlock(state.failures));
371
+ write(summary(totals));
372
+ }
373
+ function fromProblem(state, data) {
374
+ const problem = data.problem;
375
+ return {
376
+ flow: state.flow,
377
+ step: state.inStep ? state.step : "",
378
+ code: problem.code,
379
+ title: problem.title,
380
+ location: locationOf(problem),
381
+ diff: problem.diff
382
+ };
383
+ }
384
+ function write(text) {
385
+ if (text) process.stdout.write(`${text}\n`);
386
+ }
387
+ //#endregion
388
+ //#region src/reporters/pick-reporter.ts
389
+ /**
390
+ * Choose a reporter by name. With no `--reporter`, a terminal gets the readable
391
+ * tree and anything piped gets NDJSON, so scripts and CI keep the
392
+ * machine-readable stream they can parse.
393
+ */
394
+ function pickReporter(name) {
395
+ if (name === "pretty") return createPrettyReporter();
396
+ if (name === "ndjson") return passive(createStdoutSink());
397
+ if (name === "dot") return passive(createDotSink());
398
+ if (name === "junit") return passive(junit());
399
+ return process.stdout.isTTY ? createPrettyReporter() : passive(createStdoutSink());
400
+ }
401
+ function passive(sink) {
402
+ return {
403
+ sink,
404
+ beginFile: () => {},
405
+ finish: () => {}
406
+ };
407
+ }
408
+ function junit() {
409
+ return createJunitSink({ write: (xml) => process.stdout.write(xml) });
410
+ }
411
+ //#endregion
412
+ //#region src/reporters/problem-reporter.ts
413
+ /**
414
+ * Print compile-time problems to stderr, one code and title per problem with
415
+ * its source location beneath. The terminal surface of the §16 model.
416
+ */
417
+ function reportProblems(problems) {
418
+ for (const problem of problems) {
419
+ process.stderr.write(`${problem.code} · ${problem.title}\n`);
420
+ const { uri, line, column } = problem.span;
421
+ process.stderr.write(` at ${uri}:${line}:${column}\n`);
422
+ }
423
+ }
424
+ //#endregion
425
+ //#region src/run/collect-files.ts
426
+ const EXTENSION = ".vn";
427
+ const SKIP = /* @__PURE__ */ new Set([
428
+ "node_modules",
429
+ "dist",
430
+ ".git"
431
+ ]);
432
+ /**
433
+ * The `.vn` files a path names: the file itself, or every one under a
434
+ * directory, walked recursively and sorted so runs are reproducible.
435
+ */
436
+ async function collectSourceFiles(path) {
437
+ const info = await stat(path).catch(() => void 0);
438
+ if (!info) return [];
439
+ if (info.isFile()) return path.endsWith(EXTENSION) ? [path] : [];
440
+ return (await walk(path)).sort();
441
+ }
442
+ async function walk(directory) {
443
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
444
+ const found = [];
445
+ for (const entry of entries) if (entry.isDirectory() && !SKIP.has(entry.name)) found.push(...await walk(join(directory, entry.name)));
446
+ else if (entry.isFile() && entry.name.endsWith(EXTENSION)) found.push(join(directory, entry.name));
447
+ return found;
448
+ }
449
+ //#endregion
450
+ //#region src/run/node-io.ts
451
+ /**
452
+ * Node-backed module IO: read files, resolve relative specifiers against the
453
+ * importer, and `#alias/…` specifiers through `[paths]` in `venn.toml`.
454
+ */
455
+ function createNodeModuleIo(roots) {
456
+ return {
457
+ read: (uri) => readFile(uri, "utf8"),
458
+ resolve: (base, spec) => resolveSpec(spec, base, roots)
459
+ };
460
+ }
461
+ function resolveSpec(spec, base, roots) {
462
+ if (spec.startsWith(".")) return resolve(dirname(base), spec);
463
+ const alias = resolveAlias({
464
+ spec,
465
+ paths: roots.paths
466
+ });
467
+ return alias ? resolve(roots.rootDir, alias.dir, alias.rest) : spec;
468
+ }
469
+ //#endregion
470
+ //#region src/run/npm-loader.ts
471
+ /**
472
+ * Loading an installed package, the way Node loads one.
473
+ *
474
+ * Resolution is Node's own, rooted at `target/`, the directory holding the
475
+ * generated `package.json` and the `node_modules` beside it. `exports` maps,
476
+ * conditions, scoped names and the rest are what a package means, and a second
477
+ * implementation of them would agree with Node right up until it did not.
478
+ */
479
+ function createNpmLoader(args) {
480
+ const from = createRequire(pathToFileURL(join(join(args.root, "target"), "package.json")));
481
+ return { async load(spec) {
482
+ const found = resolvePath(from, spec);
483
+ if (!found) return void 0;
484
+ return await import(pathToFileURL(found).href);
485
+ } };
486
+ }
487
+ /**
488
+ * Where the package lives, or nothing when it is not installed.
489
+ *
490
+ * Not installed is an ordinary state: the manifest asks for a package and
491
+ * nobody has run `venn install` yet. The import that named it reports that
492
+ * itself, which is a better place to say it than inside a resolver.
493
+ */
494
+ function resolvePath(from, spec) {
495
+ try {
496
+ return from.resolve(spec);
497
+ } catch {
498
+ return;
499
+ }
500
+ }
501
+ //#endregion
502
+ //#region src/run/run-file.ts
503
+ /**
504
+ * Parse and run a `.vn` source with the full stdlib loaded.
505
+ *
506
+ * The HttpClient, HttpServer and Console ports take injected implementations
507
+ * (real in the CLI, fakes in tests); every other port takes the binding
508
+ * `@venn-lang/stdlib` supplies.
509
+ *
510
+ * @param args - The source and its uri, the host and the ports, and how to run
511
+ * it: the filter, the mode, the environment, the module and package loaders.
512
+ * @returns The problems and, when anything ran, the run's result. A failing
513
+ * flow is a `Problem` here, not an exception.
514
+ * @throws Whatever an action or a loaded module let escape the runner.
515
+ */
516
+ async function runFile(args) {
517
+ const { ast, problems } = parse(args.source, { uri: args.uri });
518
+ if (problems.length > 0) return { problems };
519
+ const io = args.io;
520
+ const resolved = io ? await resolveImports({
521
+ document: ast,
522
+ uri: args.uri,
523
+ io,
524
+ npm: args.npm
525
+ }) : void 0;
526
+ const graph = resolved && io ? {
527
+ modules: resolved.modules,
528
+ resolve: io.resolve,
529
+ npm: resolved.npm
530
+ } : void 0;
531
+ const bad = graph ? checkImports({
532
+ document: ast,
533
+ uri: args.uri,
534
+ graph
535
+ }) : [];
536
+ if (bad.length > 0) return { problems: bad };
537
+ const runner = createRunner({
538
+ host: args.host,
539
+ plugins: allPlugins,
540
+ sink: args.sink,
541
+ ports: bindings(args),
542
+ uri: args.uri,
543
+ filter: args.filter,
544
+ bail: args.bail,
545
+ env: args.env,
546
+ moduleFragments: resolved?.fragments,
547
+ modules: graph,
548
+ moduleDecos: resolved?.decos,
549
+ cleanup: args.cleanup
550
+ });
551
+ const result = args.mode === "script" ? await runner.script(ast) : await runner.run(ast);
552
+ return {
553
+ problems: result.problems ?? [],
554
+ result
555
+ };
556
+ }
557
+ function bindings(args) {
558
+ const console = args.console ? [{
559
+ port: ConsolePort,
560
+ impl: args.console
561
+ }] : [];
562
+ return [
563
+ {
564
+ port: HttpClientPort,
565
+ impl: args.httpClient
566
+ },
567
+ ...stdlibPortBindings,
568
+ {
569
+ port: HttpServerPort,
570
+ impl: args.httpServer ?? createNodeServer()
571
+ },
572
+ ...console
573
+ ];
574
+ }
575
+ //#endregion
576
+ //#region src/shutdown/create-leave.ts
577
+ /** How long a close may take before the program stops waiting for it. */
578
+ const GRACE_MS = 5e3;
579
+ /**
580
+ * Leaving, done properly: close what is open, then go.
581
+ *
582
+ * Two things make this more than `await close(); exit()`. A second signal means
583
+ * the user is done asking nicely, so it leaves at once. And a closer that never
584
+ * settles must not hold the program hostage, so the wait has a deadline: an
585
+ * unclean exit beats a process that cannot be stopped.
586
+ */
587
+ function createLeave(args) {
588
+ let leaving = false;
589
+ return (code) => {
590
+ if (leaving) return args.exit(code);
591
+ leaving = true;
592
+ finish({
593
+ ...args,
594
+ code
595
+ });
596
+ };
597
+ }
598
+ async function finish(args) {
599
+ await Promise.race([args.shutdown.close(), deadline(args.graceMs ?? GRACE_MS)]);
600
+ args.exit(args.code);
601
+ }
602
+ /** A timer that does not itself keep the program alive. */
603
+ function deadline(ms) {
604
+ return new Promise((resolve) => {
605
+ setTimeout(resolve, ms).unref();
606
+ });
607
+ }
608
+ //#endregion
609
+ //#region src/shutdown/create-shutdown.ts
610
+ /**
611
+ * The registry: what to close, and the promise that it happened.
612
+ *
613
+ * Closing runs newest first, because a thing opened later may be standing on one
614
+ * opened earlier. One closer that throws must not strand the rest: leaving is
615
+ * the goal, and a half-closed program still has to go.
616
+ */
617
+ function createShutdown() {
618
+ const closers = /* @__PURE__ */ new Set();
619
+ let closing;
620
+ return {
621
+ add: (closer) => {
622
+ closers.add(closer);
623
+ return () => {
624
+ closers.delete(closer);
625
+ };
626
+ },
627
+ close: () => {
628
+ closing ??= closeAll(closers);
629
+ return closing;
630
+ }
631
+ };
632
+ }
633
+ async function closeAll(closers) {
634
+ for (const closer of [...closers].reverse()) await Promise.resolve().then(() => closer()).catch(() => {});
635
+ closers.clear();
636
+ }
637
+ //#endregion
638
+ //#region src/shutdown/install-exit-hook.ts
639
+ /**
640
+ * The quiet ending: a program that simply ran out of things to do.
641
+ *
642
+ * `beforeExit` is the only moment a script without a server gets, so its
643
+ * `teardown` has to run here or it never runs at all. The timer is what buys
644
+ * the closing its time: a promise alone does not hold the loop open, and
645
+ * without it Node could leave mid-cleanup.
646
+ */
647
+ function installExitHook(shutdown) {
648
+ const onBeforeExit = () => {
649
+ const hold = setInterval(() => {}, 1e3);
650
+ shutdown.close().finally(() => clearInterval(hold));
651
+ };
652
+ process$1.once("beforeExit", onBeforeExit);
653
+ return () => {
654
+ process$1.off("beforeExit", onBeforeExit);
655
+ };
656
+ }
657
+ //#endregion
658
+ //#region src/shutdown/install-fault-hooks.ts
659
+ /**
660
+ * The two ways a program dies without being asked to.
661
+ *
662
+ * Node's default for both is to print a stack and vanish, losing the sockets,
663
+ * the reason and the exit code at once. Catching them means the failure is
664
+ * stated in the language's own voice and the machine gets its resources back on
665
+ * the way out.
666
+ */
667
+ function installFaultHooks(args) {
668
+ const onFault = (cause) => fault({
669
+ ...args,
670
+ cause
671
+ });
672
+ process$1.on("uncaughtException", onFault);
673
+ process$1.on("unhandledRejection", onFault);
674
+ return () => {
675
+ process$1.off("uncaughtException", onFault);
676
+ process$1.off("unhandledRejection", onFault);
677
+ };
678
+ }
679
+ function fault(args) {
680
+ (args.report ?? ((message) => process$1.stderr.write(`${message}\n`)))(errorLine(args.cause));
681
+ args.leave(1);
682
+ }
683
+ //#endregion
684
+ //#region src/shutdown/install-signal-hooks.ts
685
+ /** What the shell means by each signal, in exit codes. */
686
+ const CODES = {
687
+ SIGINT: 130,
688
+ SIGTERM: 143,
689
+ SIGBREAK: 130,
690
+ SIGHUP: 129
691
+ };
692
+ /**
693
+ * Every way a system can ask this program to stop, wired to the same ending.
694
+ *
695
+ * `Ctrl+C` is the one everybody knows; `SIGTERM` is how a supervisor or a CI
696
+ * runner asks; `SIGBREAK` is Windows' Ctrl+Break; `SIGHUP` is the terminal
697
+ * closing with the program still inside it. All four deserve the same care.
698
+ */
699
+ function installSignalHooks(args) {
700
+ const offs = ALL_SIGNALS.map((signal) => args.signals.on(signal, () => args.leave(CODES[signal])));
701
+ return () => {
702
+ for (const off of offs) off();
703
+ };
704
+ }
705
+ //#endregion
706
+ //#region src/shutdown/install-hooks.ts
707
+ /**
708
+ * Hand the process its hooks. The three ways a program can end (asked to stop,
709
+ * broken, or simply finished) all end the same way.
710
+ *
711
+ * Returns the `leave` these hooks share, so a command that decides to stop on
712
+ * its own terms unwinds through exactly the same path.
713
+ */
714
+ function installHooks(args) {
715
+ const leave = createLeave({
716
+ shutdown: args.shutdown,
717
+ exit: args.exit,
718
+ graceMs: args.graceMs
719
+ });
720
+ installSignalHooks({
721
+ signals: args.signals,
722
+ leave
723
+ });
724
+ installFaultHooks({
725
+ leave,
726
+ report: args.report
727
+ });
728
+ installExitHook(args.shutdown);
729
+ return leave;
730
+ }
731
+ //#endregion
732
+ //#region src/title/program-title.ts
733
+ /** How long a title may get before it stops being readable in a tab. */
734
+ const MAX = 60;
735
+ /**
736
+ * What this process calls itself: `venn run server.vn`, not `node`.
737
+ *
738
+ * The terminal tab is the only place a long-running program announces itself,
739
+ * and "node" says nothing about which program, which file, or whose.
740
+ */
741
+ function programTitle(args) {
742
+ const name = args.target ? basename(args.target) : "";
743
+ return `venn ${args.command}${name ? ` ${name}` : ""}`.slice(0, MAX);
744
+ }
745
+ //#endregion
746
+ //#region src/title/set-program-title.ts
747
+ /**
748
+ * Name the process after the program it is running.
749
+ *
750
+ * Until something says otherwise, Windows reports the executable path (the
751
+ * `node` the terminal tab shows) and puts it back when the process exits. So
752
+ * this is set once and never undone: restoring it by hand would write that path
753
+ * over whatever title the terminal had before, which is worse than doing
754
+ * nothing.
755
+ */
756
+ function setProgramTitle(args) {
757
+ process$1.title = programTitle(args);
758
+ }
759
+ //#endregion
760
+ //#region src/commands/run.ts
761
+ /**
762
+ * `venn test <file|directory>`: run every matching flow, then report.
763
+ *
764
+ * @param options - The file or directory, the reporter, and the filters.
765
+ * @returns The exit code: whatever a flow's `exit` named, else 0 when nothing
766
+ * failed, else 1. Also 1 when the path holds no `.vn` file.
767
+ */
768
+ async function runCommand(options) {
769
+ const files = await collectSourceFiles(resolve(options.file));
770
+ if (files.length === 0) return noFiles(options.file);
771
+ const reporter = pickReporter(options.reporter);
772
+ const shutdown = createShutdown();
773
+ setProgramTitle({
774
+ command: "test",
775
+ target: options.file
776
+ });
777
+ installHooks({
778
+ signals: createNodeSignals(),
779
+ shutdown,
780
+ exit: (code) => process.exit(code)
781
+ });
782
+ const totals = await runAll({
783
+ files,
784
+ options,
785
+ reporter,
786
+ shutdown
787
+ });
788
+ reporter.finish(totals);
789
+ return totals.exitCode ?? (totals.failed === 0 ? 0 : 1);
790
+ }
791
+ async function runAll(pass) {
792
+ const totals = {
793
+ passed: 0,
794
+ failed: 0,
795
+ files: 0,
796
+ ms: 0
797
+ };
798
+ const started = Date.now();
799
+ for (const file of pass.files) {
800
+ pass.reporter.beginFile(file);
801
+ await runOne({
802
+ pass,
803
+ file,
804
+ totals
805
+ });
806
+ if (totals.exitCode !== void 0) break;
807
+ if (pass.options.bail && totals.failed > 0) break;
808
+ }
809
+ totals.ms = Date.now() - started;
810
+ return totals;
811
+ }
812
+ /**
813
+ * One file, and the servers it started closed with it.
814
+ *
815
+ * A suite that leaves a port bound holds it for the whole run, so a hundred
816
+ * files that serve would end up holding a hundred ports. They are also on the
817
+ * shutdown list, for the run that ends by signal instead of by finishing.
818
+ */
819
+ async function runOne(args) {
820
+ const servers = createNodeServer();
821
+ const forget = args.pass.shutdown.add(() => servers.closeAll());
822
+ try {
823
+ tally(args.totals, await runFile(await buildArgs(args.file, args.pass, servers)));
824
+ } finally {
825
+ await servers.closeAll();
826
+ forget();
827
+ }
828
+ }
829
+ function tally(totals, outcome) {
830
+ if (outcome.problems.length > 0) {
831
+ reportProblems(outcome.problems);
832
+ totals.files += 1;
833
+ totals.failed += 1;
834
+ return;
835
+ }
836
+ if (outcome.result?.exitCode !== void 0) totals.exitCode = outcome.result.exitCode;
837
+ const passed = outcome.result?.passed ?? 0;
838
+ const failed = outcome.result?.failed ?? 0;
839
+ if (passed + failed > 0) totals.files += 1;
840
+ totals.passed += passed;
841
+ totals.failed += failed;
842
+ }
843
+ async function buildArgs(file, pass, httpServer) {
844
+ const found = await loadManifest(file);
845
+ const manifest = found?.manifest;
846
+ return {
847
+ source: await readFile(file, "utf8"),
848
+ uri: file,
849
+ host: createNodeHost(),
850
+ sink: pass.reporter.sink,
851
+ httpClient: createFetchClient(),
852
+ httpServer,
853
+ filter: filterOf(pass.options),
854
+ bail: pass.options.bail,
855
+ env: await loadEnv({
856
+ manifest,
857
+ name: pass.options.env ?? "local",
858
+ dir: found?.dir ?? dirname(file)
859
+ }),
860
+ io: createNodeModuleIo({
861
+ paths: manifest?.paths ?? {},
862
+ rootDir: found?.dir ?? dirname(file)
863
+ }),
864
+ npm: createNpmLoader({ root: found?.dir ?? dirname(file) })
865
+ };
866
+ }
867
+ function filterOf(options) {
868
+ return {
869
+ tags: parseTags(options.tags),
870
+ flow: options.flow,
871
+ step: options.step
872
+ };
873
+ }
874
+ function parseTags(tags) {
875
+ if (!tags) return void 0;
876
+ return tags.split(",").map((tag) => tag.trim()).filter((tag) => tag !== "");
877
+ }
878
+ function noFiles(path) {
879
+ process.stderr.write(`No .vn files found at ${path}\n`);
880
+ return 1;
881
+ }
882
+ //#endregion
883
+ //#region src/commands/verify-plugin.ts
884
+ /**
885
+ * `venn verify-plugin <path>`: import a plugin module, print what it declares,
886
+ * and check its shape.
887
+ *
888
+ * @param args - `path` is the module to import, relative to the working
889
+ * directory or absolute.
890
+ * @returns 0 when the plugin names itself and a namespace, 1 when it does not.
891
+ * @throws Error when the module exports nothing that looks like a plugin.
892
+ */
893
+ async function verifyPluginCommand(args) {
894
+ const plugin = await loadPlugin(args.path);
895
+ const out = process.stdout;
896
+ out.write(`Plugin: ${plugin.name} (namespace "${plugin.namespace}")\n`);
897
+ out.write(` actions: ${plugin.actions?.length ?? 0}\n`);
898
+ out.write(` matchers: ${plugin.matchers?.length ?? 0}\n`);
899
+ out.write(` resources: ${plugin.resources?.length ?? 0}\n`);
900
+ const ok = Boolean(plugin.name && plugin.namespace);
901
+ out.write(ok ? "\n✓ plugin shape is valid\n" : "\n✗ plugin shape is invalid\n");
902
+ return ok ? 0 : 1;
903
+ }
904
+ async function loadPlugin(path) {
905
+ const mod = await import(pathToFileURL(resolve(path)).href);
906
+ const plugin = mod.default ?? Object.values(mod).find(isPlugin);
907
+ if (!isPlugin(plugin)) throw new Error(`No plugin export found in ${path}`);
908
+ return plugin;
909
+ }
910
+ function isPlugin(value) {
911
+ return typeof value === "object" && value !== null && "namespace" in value && "name" in value;
912
+ }
913
+ //#endregion
914
+ export { createStdoutSink, reportProblems, runCommand, runFile, verifyPluginCommand };
915
+
916
+ //# sourceMappingURL=index.mjs.map