@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.
- package/LICENSE +21 -0
- package/README.md +284 -0
- package/dist/bin/venn.mjs +16 -0
- package/dist/cli.mjs +56378 -0
- package/dist/index.d.mts +98 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +916 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +67 -0
- package/src/bin/venn.ts +14 -0
- package/src/cli.ts +299 -0
- package/src/commands/build.ts +97 -0
- package/src/commands/check.ts +143 -0
- package/src/commands/deps.ts +228 -0
- package/src/commands/fmt.ts +55 -0
- package/src/commands/index.ts +2 -0
- package/src/commands/inside-workspace.ts +20 -0
- package/src/commands/list.ts +53 -0
- package/src/commands/new.ts +66 -0
- package/src/commands/run.ts +149 -0
- package/src/commands/script.ts +127 -0
- package/src/commands/upgrade.ts +86 -0
- package/src/commands/verify-plugin.ts +35 -0
- package/src/index.ts +7 -0
- package/src/manifest/index.ts +2 -0
- package/src/manifest/load-env.ts +70 -0
- package/src/manifest/load-manifest.ts +42 -0
- package/src/project/command-targets.ts +53 -0
- package/src/project/index.ts +9 -0
- package/src/project/resolve-targets.ts +70 -0
- package/src/project/select-packages.ts +37 -0
- package/src/project/targets-or-exit.ts +28 -0
- package/src/reporters/colors.ts +17 -0
- package/src/reporters/dot-sink.ts +18 -0
- package/src/reporters/error-line.ts +14 -0
- package/src/reporters/index.ts +9 -0
- package/src/reporters/junit-sink.ts +39 -0
- package/src/reporters/ndjson-stdout.ts +13 -0
- package/src/reporters/pick-reporter.ts +28 -0
- package/src/reporters/pretty/diff-lines.ts +53 -0
- package/src/reporters/pretty/index.ts +1 -0
- package/src/reporters/pretty/pretty-reporter.ts +122 -0
- package/src/reporters/pretty/pretty.types.ts +29 -0
- package/src/reporters/pretty/render.ts +88 -0
- package/src/reporters/problem-reporter.ts +13 -0
- package/src/reporters/problem-sink.ts +20 -0
- package/src/reporters/reporter.types.ts +21 -0
- package/src/run/collect-files.ts +43 -0
- package/src/run/ending.types.ts +17 -0
- package/src/run/exit-code.ts +15 -0
- package/src/run/node-io.ts +27 -0
- package/src/run/npm-loader.ts +41 -0
- package/src/run/package-types.ts +66 -0
- package/src/run/run-file.ts +106 -0
- package/src/run/should-leave.ts +11 -0
- package/src/run/step-titles.ts +61 -0
- package/src/shutdown/create-leave.ts +42 -0
- package/src/shutdown/create-shutdown.ts +34 -0
- package/src/shutdown/index.ts +7 -0
- package/src/shutdown/install-exit-hook.ts +21 -0
- package/src/shutdown/install-fault-hooks.ts +30 -0
- package/src/shutdown/install-hooks.ts +36 -0
- package/src/shutdown/install-signal-hooks.ts +26 -0
- package/src/shutdown/shutdown.types.ts +20 -0
- package/src/title/index.ts +2 -0
- package/src/title/program-title.ts +15 -0
- package/src/title/set-program-title.ts +15 -0
- package/src/upgrade/index.ts +6 -0
- package/src/upgrade/install-site.ts +70 -0
- package/src/upgrade/latest-version.ts +50 -0
- package/src/upgrade/upgrade-command.ts +32 -0
- package/src/upgrade/upgrade-plan.ts +36 -0
- package/src/upgrade/upgrade.types.ts +17 -0
- 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
|
+
"<": "<",
|
|
135
|
+
">": ">",
|
|
136
|
+
"&": "&",
|
|
137
|
+
"\"": """
|
|
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
|