@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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { createNodeHost, createNodeSignals } from "@venn-lang/contracts/node";
|
|
4
|
+
import { createFetchClient } from "@venn-lang/http";
|
|
5
|
+
import { createNodeServer } from "@venn-lang/http/node";
|
|
6
|
+
import type { RunFilter } from "@venn-lang/runtime";
|
|
7
|
+
import { loadEnv, loadManifest } from "../manifest/index.js";
|
|
8
|
+
import type { Reporter, RunTotals } from "../reporters/index.js";
|
|
9
|
+
import { pickReporter, reportProblems } from "../reporters/index.js";
|
|
10
|
+
import { collectSourceFiles } from "../run/collect-files.js";
|
|
11
|
+
import { createNodeModuleIo } from "../run/node-io.js";
|
|
12
|
+
import { createNpmLoader } from "../run/npm-loader.js";
|
|
13
|
+
import { type RunFileOutcome, runFile } from "../run/run-file.js";
|
|
14
|
+
import { createShutdown, installHooks, type Shutdown } from "../shutdown/index.js";
|
|
15
|
+
import { setProgramTitle } from "../title/index.js";
|
|
16
|
+
|
|
17
|
+
/** Everything `venn run` accepts. */
|
|
18
|
+
export interface RunOptions {
|
|
19
|
+
file: string;
|
|
20
|
+
reporter?: string;
|
|
21
|
+
tags?: string;
|
|
22
|
+
flow?: string;
|
|
23
|
+
step?: string;
|
|
24
|
+
env?: string;
|
|
25
|
+
bail?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What one pass over the files needs to carry from start to finish. */
|
|
29
|
+
interface RunPass {
|
|
30
|
+
files: readonly string[];
|
|
31
|
+
options: RunOptions;
|
|
32
|
+
reporter: Reporter;
|
|
33
|
+
shutdown: Shutdown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `venn test <file|directory>`: run every matching flow, then report.
|
|
38
|
+
*
|
|
39
|
+
* @param options - The file or directory, the reporter, and the filters.
|
|
40
|
+
* @returns The exit code: whatever a flow's `exit` named, else 0 when nothing
|
|
41
|
+
* failed, else 1. Also 1 when the path holds no `.vn` file.
|
|
42
|
+
*/
|
|
43
|
+
export async function runCommand(options: RunOptions): Promise<number> {
|
|
44
|
+
const files = await collectSourceFiles(resolve(options.file));
|
|
45
|
+
if (files.length === 0) return noFiles(options.file);
|
|
46
|
+
const reporter = pickReporter(options.reporter);
|
|
47
|
+
const shutdown = createShutdown();
|
|
48
|
+
setProgramTitle({ command: "test", target: options.file });
|
|
49
|
+
installHooks({ signals: createNodeSignals(), shutdown, exit: (code) => process.exit(code) });
|
|
50
|
+
const totals = await runAll({ files, options, reporter, shutdown });
|
|
51
|
+
reporter.finish(totals);
|
|
52
|
+
// A suite that called `exit` named its own verdict, even `exit 0` after a
|
|
53
|
+
// failure, which is the point of saying it.
|
|
54
|
+
return totals.exitCode ?? (totals.failed === 0 ? 0 : 1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function runAll(pass: RunPass): Promise<RunTotals> {
|
|
58
|
+
const totals: RunTotals = { passed: 0, failed: 0, files: 0, ms: 0 };
|
|
59
|
+
const started = Date.now();
|
|
60
|
+
for (const file of pass.files) {
|
|
61
|
+
pass.reporter.beginFile(file);
|
|
62
|
+
await runOne({ pass, file, totals });
|
|
63
|
+
// `exit` ends the command, not just the file it was called in.
|
|
64
|
+
if (totals.exitCode !== undefined) break;
|
|
65
|
+
if (pass.options.bail && totals.failed > 0) break;
|
|
66
|
+
}
|
|
67
|
+
totals.ms = Date.now() - started;
|
|
68
|
+
return totals;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One file, and the servers it started closed with it.
|
|
73
|
+
*
|
|
74
|
+
* A suite that leaves a port bound holds it for the whole run, so a hundred
|
|
75
|
+
* files that serve would end up holding a hundred ports. They are also on the
|
|
76
|
+
* shutdown list, for the run that ends by signal instead of by finishing.
|
|
77
|
+
*/
|
|
78
|
+
async function runOne(args: { pass: RunPass; file: string; totals: RunTotals }): Promise<void> {
|
|
79
|
+
const servers = createNodeServer();
|
|
80
|
+
const forget = args.pass.shutdown.add(() => servers.closeAll());
|
|
81
|
+
try {
|
|
82
|
+
tally(args.totals, await runFile(await buildArgs(args.file, args.pass, servers)));
|
|
83
|
+
} finally {
|
|
84
|
+
await servers.closeAll();
|
|
85
|
+
forget();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function tally(totals: RunTotals, outcome: RunFileOutcome): void {
|
|
90
|
+
if (outcome.problems.length > 0) {
|
|
91
|
+
reportProblems(outcome.problems);
|
|
92
|
+
totals.files += 1;
|
|
93
|
+
totals.failed += 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (outcome.result?.exitCode !== undefined) totals.exitCode = outcome.result.exitCode;
|
|
97
|
+
const passed = outcome.result?.passed ?? 0;
|
|
98
|
+
const failed = outcome.result?.failed ?? 0;
|
|
99
|
+
// A file whose flows were all filtered out is not part of the run.
|
|
100
|
+
if (passed + failed > 0) totals.files += 1;
|
|
101
|
+
totals.passed += passed;
|
|
102
|
+
totals.failed += failed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function buildArgs(
|
|
106
|
+
file: string,
|
|
107
|
+
pass: RunPass,
|
|
108
|
+
httpServer: ReturnType<typeof createNodeServer>,
|
|
109
|
+
) {
|
|
110
|
+
const found = await loadManifest(file);
|
|
111
|
+
const manifest = found?.manifest;
|
|
112
|
+
return {
|
|
113
|
+
source: await readFile(file, "utf8"),
|
|
114
|
+
uri: file,
|
|
115
|
+
host: createNodeHost(),
|
|
116
|
+
sink: pass.reporter.sink,
|
|
117
|
+
httpClient: createFetchClient(),
|
|
118
|
+
httpServer,
|
|
119
|
+
filter: filterOf(pass.options),
|
|
120
|
+
bail: pass.options.bail,
|
|
121
|
+
env: await loadEnv({
|
|
122
|
+
manifest,
|
|
123
|
+
name: pass.options.env ?? "local",
|
|
124
|
+
dir: found?.dir ?? dirname(file),
|
|
125
|
+
}),
|
|
126
|
+
io: createNodeModuleIo({
|
|
127
|
+
paths: manifest?.paths ?? {},
|
|
128
|
+
rootDir: found?.dir ?? dirname(file),
|
|
129
|
+
}),
|
|
130
|
+
npm: createNpmLoader({ root: found?.dir ?? dirname(file) }),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function filterOf(options: RunOptions): RunFilter {
|
|
135
|
+
return { tags: parseTags(options.tags), flow: options.flow, step: options.step };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseTags(tags: string | undefined): readonly string[] | undefined {
|
|
139
|
+
if (!tags) return undefined;
|
|
140
|
+
return tags
|
|
141
|
+
.split(",")
|
|
142
|
+
.map((tag) => tag.trim())
|
|
143
|
+
.filter((tag) => tag !== "");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function noFiles(path: string): number {
|
|
147
|
+
process.stderr.write(`No .vn files found at ${path}\n`);
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { createNodeConsole, createNodeHost, createNodeSignals } from "@venn-lang/contracts/node";
|
|
4
|
+
import { createFetchClient } from "@venn-lang/http";
|
|
5
|
+
import { createNodeServer, type NodeHttpServer } from "@venn-lang/http/node";
|
|
6
|
+
import { envDirOf, loadEnv, loadManifest } from "../manifest/index.js";
|
|
7
|
+
import { createProblemSink, errorLine, reportProblems } from "../reporters/index.js";
|
|
8
|
+
import type { Ending } from "../run/ending.types.js";
|
|
9
|
+
import { exitCodeOf } from "../run/exit-code.js";
|
|
10
|
+
import { createNodeModuleIo } from "../run/node-io.js";
|
|
11
|
+
import { createNpmLoader } from "../run/npm-loader.js";
|
|
12
|
+
import { type RunFileArgs, runFile } from "../run/run-file.js";
|
|
13
|
+
import { shouldLeave } from "../run/should-leave.js";
|
|
14
|
+
import { createShutdown, installHooks, type Shutdown } from "../shutdown/index.js";
|
|
15
|
+
import { setProgramTitle } from "../title/index.js";
|
|
16
|
+
|
|
17
|
+
/** Everything `venn run` (script mode) accepts. */
|
|
18
|
+
export interface ScriptOptions {
|
|
19
|
+
file: string;
|
|
20
|
+
args?: readonly string[];
|
|
21
|
+
env?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `venn run <file>`: execute the file as a program, its statements in order.
|
|
26
|
+
*
|
|
27
|
+
* A program that serves keeps running after the last statement, which is the
|
|
28
|
+
* point of it, so what it opened is closed on the way out instead: whether the
|
|
29
|
+
* way out is a signal, a fault, or a crash mid-script.
|
|
30
|
+
*
|
|
31
|
+
* @returns The code to leave with, and whether the process should go now.
|
|
32
|
+
*/
|
|
33
|
+
export async function scriptCommand(options: ScriptOptions): Promise<Ending> {
|
|
34
|
+
const uri = resolve(options.file);
|
|
35
|
+
const servers = createNodeServer();
|
|
36
|
+
const shutdown = hooked({ servers, file: uri });
|
|
37
|
+
try {
|
|
38
|
+
const outcome = await runFile(await scriptArgs({ options, uri, servers, shutdown }));
|
|
39
|
+
if (outcome.problems.length > 0) return { code: report(outcome.problems), leave: true };
|
|
40
|
+
return await ending(exitCodeOf(outcome.result), asked(outcome.result), shutdown);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
await shutdown.close();
|
|
43
|
+
return { code: crashed(error), leave: true };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Whether the program said when to stop, rather than simply running out of lines. */
|
|
48
|
+
function asked(result: { exitCode?: number } | undefined): boolean {
|
|
49
|
+
return result?.exitCode !== undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Leave, or stay and let the loop decide.
|
|
54
|
+
*
|
|
55
|
+
* A program that asked to exit gets what it asked for, but tidily: whatever it
|
|
56
|
+
* opened is given back first, which is the half `process.exit` on its own skips.
|
|
57
|
+
* One that merely reached its last line is not asked to stop, because a server
|
|
58
|
+
* has not finished just because the file has.
|
|
59
|
+
*/
|
|
60
|
+
async function ending(code: number, requested: boolean, shutdown: Shutdown): Promise<Ending> {
|
|
61
|
+
if (!shouldLeave({ code, requested })) return { code, leave: false };
|
|
62
|
+
await shutdown.close();
|
|
63
|
+
return { code, leave: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Give the process its hooks, its name, and this run's servers to close. */
|
|
67
|
+
function hooked(args: { servers: NodeHttpServer; file: string }): Shutdown {
|
|
68
|
+
const shutdown = createShutdown();
|
|
69
|
+
setProgramTitle({ command: "run", target: args.file });
|
|
70
|
+
shutdown.add(() => args.servers.closeAll());
|
|
71
|
+
installHooks({ signals: createNodeSignals(), shutdown, exit: (code) => process.exit(code) });
|
|
72
|
+
return shutdown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Everything a script run needs from the machine: the file, the manifest, the ports. */
|
|
76
|
+
async function scriptArgs(args: {
|
|
77
|
+
options: ScriptOptions;
|
|
78
|
+
uri: string;
|
|
79
|
+
servers: NodeHttpServer;
|
|
80
|
+
shutdown: Shutdown;
|
|
81
|
+
}): Promise<RunFileArgs> {
|
|
82
|
+
const { options, uri } = args;
|
|
83
|
+
const found = await loadManifest(uri);
|
|
84
|
+
const manifest = found?.manifest;
|
|
85
|
+
return {
|
|
86
|
+
source: await readFile(uri, "utf8"),
|
|
87
|
+
uri,
|
|
88
|
+
mode: "script",
|
|
89
|
+
env: await loadEnv({
|
|
90
|
+
manifest,
|
|
91
|
+
name: options.env ?? "local",
|
|
92
|
+
dir: found?.dir ?? envDirOf(uri),
|
|
93
|
+
}),
|
|
94
|
+
io: createNodeModuleIo({
|
|
95
|
+
paths: manifest?.paths ?? {},
|
|
96
|
+
rootDir: found?.dir ?? dirname(uri),
|
|
97
|
+
}),
|
|
98
|
+
npm: createNpmLoader({ root: found?.dir ?? dirname(uri) }),
|
|
99
|
+
cleanup: args.shutdown,
|
|
100
|
+
...nodePorts({ argv: options.args ?? [], servers: args.servers }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The real implementations behind the ports a script talks through. */
|
|
105
|
+
function nodePorts(args: {
|
|
106
|
+
argv: readonly string[];
|
|
107
|
+
servers: NodeHttpServer;
|
|
108
|
+
}): Pick<RunFileArgs, "host" | "sink" | "httpClient" | "httpServer" | "console"> {
|
|
109
|
+
return {
|
|
110
|
+
host: createNodeHost(),
|
|
111
|
+
sink: createProblemSink(),
|
|
112
|
+
httpClient: createFetchClient(),
|
|
113
|
+
httpServer: args.servers,
|
|
114
|
+
console: createNodeConsole({ argv: args.argv }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function report(problems: Parameters<typeof reportProblems>[0]): number {
|
|
119
|
+
reportProblems(problems);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** An error thrown mid-script: print it the way the program would see it, then fail. */
|
|
124
|
+
function crashed(error: unknown): number {
|
|
125
|
+
process.stderr.write(`${errorLine(error)}\n`);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { bold, dim } from "../reporters/colors.js";
|
|
4
|
+
import { installSiteOf } from "../upgrade/install-site.js";
|
|
5
|
+
import { isNewer, latestVersion } from "../upgrade/latest-version.js";
|
|
6
|
+
import type { InstallSite, UpgradeOptions } from "../upgrade/upgrade.types.js";
|
|
7
|
+
import { runUpgrade } from "../upgrade/upgrade-command.js";
|
|
8
|
+
import { refusalFor, upgradeCommandFor } from "../upgrade/upgrade-plan.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `venn upgrade`: move a global install to the latest published version.
|
|
12
|
+
*
|
|
13
|
+
* Nothing is changed without saying what will run and asking first, because a
|
|
14
|
+
* command that rewrites what is on the machine has to be one the user chose.
|
|
15
|
+
*
|
|
16
|
+
* @param version The version running now, compared against the registry.
|
|
17
|
+
* @returns 0 when already current or the upgrade succeeded, 1 otherwise.
|
|
18
|
+
*/
|
|
19
|
+
export async function upgradeCommand(args: {
|
|
20
|
+
version: string;
|
|
21
|
+
options: UpgradeOptions;
|
|
22
|
+
}): Promise<number> {
|
|
23
|
+
const site = currentSite();
|
|
24
|
+
const refusal = refusalFor(site);
|
|
25
|
+
if (refusal) return fail(refusal);
|
|
26
|
+
|
|
27
|
+
const latest = await latestVersion();
|
|
28
|
+
if (!latest) return fail("Could not reach the registry to check for a newer version.");
|
|
29
|
+
|
|
30
|
+
if (!isNewer({ current: args.version, candidate: latest })) {
|
|
31
|
+
process.stdout.write(`venn ${args.version} is already the latest.\n`);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
return upgradeTo({ latest, site, options: args.options, version: args.version });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `fileURLToPath`, not the URL itself: the site is decided by comparing against
|
|
39
|
+
* the working directory, and "file:///c:/x" never starts with "c:/x", which made
|
|
40
|
+
* every install inside a project look global.
|
|
41
|
+
*/
|
|
42
|
+
function currentSite(): InstallSite {
|
|
43
|
+
return installSiteOf({ path: fileURLToPath(import.meta.url), cwd: process.cwd() });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fail(message: string): number {
|
|
47
|
+
process.stderr.write(`${message}\n`);
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function upgradeTo(args: {
|
|
52
|
+
latest: string;
|
|
53
|
+
site: InstallSite;
|
|
54
|
+
options: UpgradeOptions;
|
|
55
|
+
version: string;
|
|
56
|
+
}): Promise<number> {
|
|
57
|
+
const command = upgradeCommandFor(args.site.manager)?.join(" ") ?? "";
|
|
58
|
+
process.stdout.write(`${bold(`venn ${args.version}`)} ${dim("->")} ${bold(args.latest)}\n`);
|
|
59
|
+
process.stdout.write(`${dim(command)}\n`);
|
|
60
|
+
if (!(args.options.yes || args.options.dryRun) && !(await confirmed())) {
|
|
61
|
+
process.stdout.write("Nothing was changed.\n");
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
const code = await runUpgrade({ site: args.site, options: args.options });
|
|
65
|
+
if (code !== 0) process.stderr.write(permissionHelp(command));
|
|
66
|
+
return code;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function confirmed(): Promise<boolean> {
|
|
70
|
+
if (!process.stdin.isTTY) return false;
|
|
71
|
+
const io = createInterface({ input: process.stdin, output: process.stdout });
|
|
72
|
+
const answer = await io.question("Upgrade now? [y/N] ");
|
|
73
|
+
io.close();
|
|
74
|
+
return answer.trim().toLowerCase().startsWith("y");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The usual cause of a failed global install, said plainly. */
|
|
78
|
+
function permissionHelp(command: string): string {
|
|
79
|
+
return [
|
|
80
|
+
"",
|
|
81
|
+
"The upgrade did not complete. A global install often needs elevated rights.",
|
|
82
|
+
`Try running it yourself: ${command}`,
|
|
83
|
+
`Or install where you own the files: npm config set prefix ~/.npm-global`,
|
|
84
|
+
"",
|
|
85
|
+
].join("\n");
|
|
86
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import type { PluginDefinition } from "@venn-lang/sdk";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `venn verify-plugin <path>`: import a plugin module, print what it declares,
|
|
7
|
+
* and check its shape.
|
|
8
|
+
*
|
|
9
|
+
* @param args - `path` is the module to import, relative to the working
|
|
10
|
+
* directory or absolute.
|
|
11
|
+
* @returns 0 when the plugin names itself and a namespace, 1 when it does not.
|
|
12
|
+
* @throws Error when the module exports nothing that looks like a plugin.
|
|
13
|
+
*/
|
|
14
|
+
export async function verifyPluginCommand(args: { path: string }): Promise<number> {
|
|
15
|
+
const plugin = await loadPlugin(args.path);
|
|
16
|
+
const out = process.stdout;
|
|
17
|
+
out.write(`Plugin: ${plugin.name} (namespace "${plugin.namespace}")\n`);
|
|
18
|
+
out.write(` actions: ${plugin.actions?.length ?? 0}\n`);
|
|
19
|
+
out.write(` matchers: ${plugin.matchers?.length ?? 0}\n`);
|
|
20
|
+
out.write(` resources: ${plugin.resources?.length ?? 0}\n`);
|
|
21
|
+
const ok = Boolean(plugin.name && plugin.namespace);
|
|
22
|
+
out.write(ok ? "\n✓ plugin shape is valid\n" : "\n✗ plugin shape is invalid\n");
|
|
23
|
+
return ok ? 0 : 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function loadPlugin(path: string): Promise<PluginDefinition> {
|
|
27
|
+
const mod = (await import(pathToFileURL(resolve(path)).href)) as Record<string, unknown>;
|
|
28
|
+
const plugin = mod.default ?? Object.values(mod).find(isPlugin);
|
|
29
|
+
if (!isPlugin(plugin)) throw new Error(`No plugin export found in ${path}`);
|
|
30
|
+
return plugin;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isPlugin(value: unknown): value is PluginDefinition {
|
|
34
|
+
return typeof value === "object" && value !== null && "namespace" in value && "name" in value;
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @venn-lang/cli: the `venn` binary (process boundary) plus the embeddable runFile.
|
|
2
|
+
// The only package that touches node:* and binds concrete implementations.
|
|
3
|
+
|
|
4
|
+
export { runCommand, verifyPluginCommand } from "./commands/index.js";
|
|
5
|
+
export { createStdoutSink, reportProblems } from "./reporters/index.js";
|
|
6
|
+
export type { RunFileOutcome } from "./run/run-file.js";
|
|
7
|
+
export { runFile } from "./run/run-file.js";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: ${name} is a placeholder in a dotenv path, not a JavaScript template.
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { dotenvFiles, type Manifest, parseDotenv } from "@venn-lang/contracts";
|
|
5
|
+
|
|
6
|
+
export interface EnvArgs {
|
|
7
|
+
manifest: Manifest | undefined;
|
|
8
|
+
/** The selected environment: `--env staging`, or `local`. */
|
|
9
|
+
name: string;
|
|
10
|
+
/** Any file the manifest points at is resolved from here. */
|
|
11
|
+
dir: string;
|
|
12
|
+
/** The process environment. Injected so a test can hand over its own. */
|
|
13
|
+
processEnv?: Record<string, string | undefined>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every variable a run can see, lowest precedence first:
|
|
18
|
+
*
|
|
19
|
+
* 1. `[env.<name>]` in `venn.toml`, the documented default, committed.
|
|
20
|
+
* 2. the dotenv files, in the order they are listed.
|
|
21
|
+
* 3. the real environment the process was started with.
|
|
22
|
+
*
|
|
23
|
+
* The real environment wins because that is how CI passes a token in, and a
|
|
24
|
+
* value set on the command line should never lose to a file in the repository.
|
|
25
|
+
*/
|
|
26
|
+
export async function loadEnv(args: EnvArgs): Promise<Record<string, unknown>> {
|
|
27
|
+
const declared = { ...(args.manifest?.env[args.name] ?? {}), ...(await readFiles(args)) };
|
|
28
|
+
const real = overrides(declared, args.processEnv ?? process.env);
|
|
29
|
+
return { ...declared, ...real, name: args.name };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The real environment overrides what was declared. It does not add to it.
|
|
34
|
+
*
|
|
35
|
+
* A shell holds hundreds of entries; letting them all become `env.*` would put
|
|
36
|
+
* `PATH` and `TEMP` in the editor's completion and let a typo silently read
|
|
37
|
+
* something from the machine. Declaring a name, in `venn.toml` or a dotenv file
|
|
38
|
+
* and with any value, is what says "this program reads this". A value that
|
|
39
|
+
* exists only in CI and is never declared belongs to `secrets.*`, which reads
|
|
40
|
+
* the environment directly and redacts what it returns.
|
|
41
|
+
*/
|
|
42
|
+
function overrides(
|
|
43
|
+
declared: Record<string, string>,
|
|
44
|
+
source: Record<string, string | undefined>,
|
|
45
|
+
): Record<string, string> {
|
|
46
|
+
const out: Record<string, string> = {};
|
|
47
|
+
for (const name of Object.keys(declared)) {
|
|
48
|
+
const value = source[name];
|
|
49
|
+
if (value !== undefined) out[name] = value;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readFiles(args: EnvArgs): Promise<Record<string, string>> {
|
|
55
|
+
const names = dotenvFiles({ configured: args.manifest?.envFiles, name: args.name });
|
|
56
|
+
const out: Record<string, string> = {};
|
|
57
|
+
for (const each of names) Object.assign(out, await readOne(resolve(args.dir, each)));
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A file that is not there is not a problem: most projects have none of them. */
|
|
62
|
+
async function readOne(path: string): Promise<Record<string, string>> {
|
|
63
|
+
const content = await readFile(path, "utf8").catch(() => undefined);
|
|
64
|
+
return content === undefined ? {} : parseDotenv(content);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Where dotenv files are looked for: the folder holding the flow. */
|
|
68
|
+
export function envDirOf(sourceUri: string): string {
|
|
69
|
+
return dirname(sourceUri);
|
|
70
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import type { Manifest } from "@venn-lang/contracts";
|
|
3
|
+
import { createNodeFs } from "@venn-lang/contracts/node";
|
|
4
|
+
import { findProject, isInside, normalise, type Package, type Project } from "@venn-lang/project";
|
|
5
|
+
|
|
6
|
+
export interface LoadedManifest {
|
|
7
|
+
manifest: Manifest;
|
|
8
|
+
/**
|
|
9
|
+
* The directory the settings are anchored to.
|
|
10
|
+
*
|
|
11
|
+
* Carried because a `[paths]` alias is written relative to the project, not
|
|
12
|
+
* to the folder the `.vn` file happens to sit in: a member of a workspace
|
|
13
|
+
* reads the aliases its root declared.
|
|
14
|
+
*/
|
|
15
|
+
dir: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The manifest that governs a `.vn` file.
|
|
20
|
+
*
|
|
21
|
+
* Found by walking up to the project the file belongs to, rather than by
|
|
22
|
+
* reading whatever happens to sit in the same folder. That is what makes a
|
|
23
|
+
* command work from anywhere: `venn test packages/api/src/login.vn` sees the
|
|
24
|
+
* same environments and aliases as running it from inside `packages/api`.
|
|
25
|
+
*
|
|
26
|
+
* Undefined when nothing governs the file: living outside a project is normal.
|
|
27
|
+
*/
|
|
28
|
+
export async function loadManifest(sourceUri: string): Promise<LoadedManifest | undefined> {
|
|
29
|
+
const file = normalise(resolve(sourceUri));
|
|
30
|
+
const { project } = await findProject({ fs: createNodeFs(), from: normalise(dirname(file)) });
|
|
31
|
+
if (!project) return undefined;
|
|
32
|
+
const owner = owningPackage(project, file);
|
|
33
|
+
if (owner) return { manifest: owner.manifest, dir: owner.dir };
|
|
34
|
+
return { manifest: project.rootManifest, dir: project.root };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The innermost member holding this file: the one whose settings are nearest. */
|
|
38
|
+
function owningPackage(project: Project, file: string): Package | undefined {
|
|
39
|
+
return [...project.packages]
|
|
40
|
+
.filter((one) => isInside(file, one.dir))
|
|
41
|
+
.sort((a, b) => b.dir.length - a.dir.length)[0];
|
|
42
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createNodeFs } from "@venn-lang/contracts/node";
|
|
2
|
+
import { type BuildTarget, join, type Package } from "@venn-lang/project";
|
|
3
|
+
|
|
4
|
+
const TESTS_DIR = "tests";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a package's test suite lives.
|
|
8
|
+
*
|
|
9
|
+
* `tests/` and nothing else. Running everything in the package instead would
|
|
10
|
+
* sweep up `src/main.vn`, a program rather than a suite, and report a run
|
|
11
|
+
* nobody asked for. A package with no `tests/` contributes nothing, which is
|
|
12
|
+
* the truth about it rather than a failure.
|
|
13
|
+
*/
|
|
14
|
+
export async function testPaths(packages: readonly Package[]): Promise<string[]> {
|
|
15
|
+
const fs = createNodeFs();
|
|
16
|
+
const found: string[] = [];
|
|
17
|
+
for (const one of packages) {
|
|
18
|
+
const dir = join(one.dir, TESTS_DIR);
|
|
19
|
+
if (await fs.exists(dir)) found.push(dir);
|
|
20
|
+
}
|
|
21
|
+
return found;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every `.vn` a package owns: what `check` and `fmt` act on. */
|
|
25
|
+
export function sourcePaths(packages: readonly Package[]): string[] {
|
|
26
|
+
return packages.map((one) => one.dir);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The program `venn run` means when it is given no file.
|
|
31
|
+
*
|
|
32
|
+
* One `bin` is unambiguous. Several is a question only the author can answer,
|
|
33
|
+
* so it is asked rather than guessed: picking the first would be a coin toss
|
|
34
|
+
* that looks like a decision.
|
|
35
|
+
*/
|
|
36
|
+
export function binTarget(args: {
|
|
37
|
+
packages: readonly Package[];
|
|
38
|
+
name?: string;
|
|
39
|
+
}): { path: string } | { ambiguous: readonly string[] } | undefined {
|
|
40
|
+
const bins = args.packages.flatMap((one) =>
|
|
41
|
+
one.targets.filter(isBin).map((target) => ({ target, dir: one.dir })),
|
|
42
|
+
);
|
|
43
|
+
const wanted = args.name ? bins.filter((one) => one.target.name === args.name) : bins;
|
|
44
|
+
if (wanted.length === 1 && wanted[0]) {
|
|
45
|
+
return { path: join(wanted[0].dir, wanted[0].target.path) };
|
|
46
|
+
}
|
|
47
|
+
if (wanted.length === 0) return undefined;
|
|
48
|
+
return { ambiguous: wanted.map((one) => one.target.name) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isBin(target: BuildTarget): boolean {
|
|
52
|
+
return target.kind === "bin";
|
|
53
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { binTarget, sourcePaths, testPaths } from "./command-targets.js";
|
|
2
|
+
export { type CommandKind, resolveTargets } from "./resolve-targets.js";
|
|
3
|
+
export {
|
|
4
|
+
type SelectArgs,
|
|
5
|
+
type Selection,
|
|
6
|
+
selectPackages,
|
|
7
|
+
unknownPackage,
|
|
8
|
+
} from "./select-packages.js";
|
|
9
|
+
export { targetsOrExit, worst } from "./targets-or-exit.js";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { binTarget, sourcePaths, testPaths } from "./command-targets.js";
|
|
2
|
+
import { selectPackages, unknownPackage } from "./select-packages.js";
|
|
3
|
+
|
|
4
|
+
/** What a command was pointed at, once the project has had its say. */
|
|
5
|
+
export interface ResolvedTargets {
|
|
6
|
+
paths: readonly string[];
|
|
7
|
+
/** What to print and stop for, when the request cannot be answered. */
|
|
8
|
+
problem?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type CommandKind = "test" | "check" | "run";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The paths a command acts on: the one it was given, or the ones the project
|
|
15
|
+
* says it means.
|
|
16
|
+
*
|
|
17
|
+
* An explicit path always wins and is never second-guessed: `venn test
|
|
18
|
+
* examples/` tests `examples/`, project or no project. Everything else is the
|
|
19
|
+
* workspace answering the question the bare command asked.
|
|
20
|
+
*/
|
|
21
|
+
export async function resolveTargets(args: {
|
|
22
|
+
kind: CommandKind;
|
|
23
|
+
target?: string;
|
|
24
|
+
packageName?: string;
|
|
25
|
+
binName?: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
}): Promise<ResolvedTargets> {
|
|
28
|
+
if (args.target) return { paths: [args.target] };
|
|
29
|
+
const selection = await selectPackages({ from: args.cwd, packageName: args.packageName });
|
|
30
|
+
if (!selection) return { paths: [], problem: NO_PROJECT };
|
|
31
|
+
if (args.packageName && selection.packages.length === 0) {
|
|
32
|
+
return { paths: [], problem: unknownPackage(selection.project, args.packageName) };
|
|
33
|
+
}
|
|
34
|
+
return forKind({ ...args, selection });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const NO_PROJECT =
|
|
38
|
+
"VN2101 · no venn.toml here, or in any folder above it.\n" +
|
|
39
|
+
" give a path, or start a project with `venn init`\n";
|
|
40
|
+
|
|
41
|
+
async function forKind(args: {
|
|
42
|
+
kind: CommandKind;
|
|
43
|
+
binName?: string;
|
|
44
|
+
selection: Awaited<ReturnType<typeof selectPackages>>;
|
|
45
|
+
}): Promise<ResolvedTargets> {
|
|
46
|
+
const packages = args.selection?.packages ?? [];
|
|
47
|
+
if (args.kind === "check") return { paths: sourcePaths(packages) };
|
|
48
|
+
if (args.kind === "test") return testTargets(await testPaths(packages));
|
|
49
|
+
return runTarget(packages, args.binName);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function testTargets(paths: readonly string[]): ResolvedTargets {
|
|
53
|
+
if (paths.length > 0) return { paths };
|
|
54
|
+
return { paths: [], problem: "no `tests/` directory in the packages selected.\n" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function runTarget(
|
|
58
|
+
packages: Parameters<typeof binTarget>[0]["packages"],
|
|
59
|
+
name?: string,
|
|
60
|
+
): ResolvedTargets {
|
|
61
|
+
const found = binTarget({ packages, name });
|
|
62
|
+
if (!found) return { paths: [], problem: "no program to run here — expected `src/main.vn`.\n" };
|
|
63
|
+
if ("ambiguous" in found) {
|
|
64
|
+
return {
|
|
65
|
+
paths: [],
|
|
66
|
+
problem: `several programs here. Pick one: --bin ${found.ambiguous.join(" | --bin ")}\n`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { paths: [found.path] };
|
|
70
|
+
}
|