@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,27 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { resolveAlias } from "@venn-lang/contracts";
|
|
4
|
+
import type { ModuleIo } from "@venn-lang/runtime";
|
|
5
|
+
|
|
6
|
+
/** Where a `#alias/…` specifier is resolved from. */
|
|
7
|
+
interface Roots {
|
|
8
|
+
paths: Record<string, string>;
|
|
9
|
+
rootDir: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Node-backed module IO: read files, resolve relative specifiers against the
|
|
14
|
+
* importer, and `#alias/…` specifiers through `[paths]` in `venn.toml`.
|
|
15
|
+
*/
|
|
16
|
+
export function createNodeModuleIo(roots: Roots): ModuleIo {
|
|
17
|
+
return {
|
|
18
|
+
read: (uri) => readFile(uri, "utf8"),
|
|
19
|
+
resolve: (base, spec) => resolveSpec(spec, base, roots),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveSpec(spec: string, base: string, roots: Roots): string {
|
|
24
|
+
if (spec.startsWith(".")) return resolve(dirname(base), spec);
|
|
25
|
+
const alias = resolveAlias({ spec, paths: roots.paths });
|
|
26
|
+
return alias ? resolve(roots.rootDir, alias.dir, alias.rest) : spec;
|
|
27
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import type { NpmModules } from "@venn-lang/runtime";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Loading an installed package, the way Node loads one.
|
|
8
|
+
*
|
|
9
|
+
* Resolution is Node's own, rooted at `target/`, the directory holding the
|
|
10
|
+
* generated `package.json` and the `node_modules` beside it. `exports` maps,
|
|
11
|
+
* conditions, scoped names and the rest are what a package means, and a second
|
|
12
|
+
* implementation of them would agree with Node right up until it did not.
|
|
13
|
+
*/
|
|
14
|
+
export function createNpmLoader(args: { root: string }): NpmModules {
|
|
15
|
+
const target = join(args.root, "target");
|
|
16
|
+
const from = createRequire(pathToFileURL(join(target, "package.json")));
|
|
17
|
+
return {
|
|
18
|
+
async load(spec) {
|
|
19
|
+
const found = resolvePath(from, spec);
|
|
20
|
+
if (!found) return undefined;
|
|
21
|
+
return (await import(pathToFileURL(found).href)) as Record<string, unknown>;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Resolver = { resolve: (spec: string) => string };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Where the package lives, or nothing when it is not installed.
|
|
30
|
+
*
|
|
31
|
+
* Not installed is an ordinary state: the manifest asks for a package and
|
|
32
|
+
* nobody has run `venn install` yet. The import that named it reports that
|
|
33
|
+
* itself, which is a better place to say it than inside a resolver.
|
|
34
|
+
*/
|
|
35
|
+
function resolvePath(from: Resolver, spec: string): string | undefined {
|
|
36
|
+
try {
|
|
37
|
+
return from.resolve(spec);
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { TypeSpec } from "@venn-lang/types";
|
|
4
|
+
|
|
5
|
+
/** Where derived types are kept: derived, so under `target/` with the rest. */
|
|
6
|
+
export function typesDir(root: string): string {
|
|
7
|
+
return join(root, "target", "types");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Derive what each installed package publishes, and keep it.
|
|
12
|
+
*
|
|
13
|
+
* Written once, at install, rather than worked out on every check: reading a
|
|
14
|
+
* package's declarations through the compiler takes about a second for a large
|
|
15
|
+
* one, and the answer only changes when what is installed does.
|
|
16
|
+
*/
|
|
17
|
+
export async function deriveTypes(args: {
|
|
18
|
+
root: string;
|
|
19
|
+
packages: readonly string[];
|
|
20
|
+
}): Promise<{ name: string; total: number; typed: number }[]> {
|
|
21
|
+
// Loaded when it is needed and not before: it pulls in the TypeScript
|
|
22
|
+
// compiler, which is ten megabytes that `venn run` should never pay for.
|
|
23
|
+
const { readPackageTypes } = await import("@venn-lang/dts");
|
|
24
|
+
const from = join(args.root, "target", "package.json");
|
|
25
|
+
const dir = typesDir(args.root);
|
|
26
|
+
await mkdir(dir, { recursive: true });
|
|
27
|
+
const found: { name: string; total: number; typed: number }[] = [];
|
|
28
|
+
for (const name of args.packages) {
|
|
29
|
+
const types = readPackageTypes({ package: name, from });
|
|
30
|
+
await writeFile(fileFor(dir, name), `${JSON.stringify(types, null, 2)}\n`, "utf8");
|
|
31
|
+
found.push({
|
|
32
|
+
name,
|
|
33
|
+
total: types.covered.total,
|
|
34
|
+
typed: types.covered.total - types.covered.dynamic,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What was derived earlier, by the specifier each was derived for. */
|
|
41
|
+
export async function loadDerivedTypes(args: {
|
|
42
|
+
root: string;
|
|
43
|
+
packages: readonly string[];
|
|
44
|
+
}): Promise<Map<string, Record<string, TypeSpec>>> {
|
|
45
|
+
const dir = typesDir(args.root);
|
|
46
|
+
const out = new Map<string, Record<string, TypeSpec>>();
|
|
47
|
+
for (const name of args.packages) {
|
|
48
|
+
const text = await readFile(fileFor(dir, name), "utf8").catch(() => undefined);
|
|
49
|
+
const parsed = text && parse(text);
|
|
50
|
+
if (parsed) out.set(name, parsed.exports);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A scope holds a slash and a file name cannot, so `@types/node` becomes `@types__node`. */
|
|
56
|
+
function fileFor(dir: string, name: string): string {
|
|
57
|
+
return join(dir, `${name.replace("/", "__")}.json`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parse(text: string): { exports: Record<string, TypeSpec> } | undefined {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(text) as { exports: Record<string, TypeSpec> };
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type Console, ConsolePort, type Host } from "@venn-lang/contracts";
|
|
2
|
+
import { type Problem, parse } from "@venn-lang/core";
|
|
3
|
+
import { type HttpClient, HttpClientPort, type HttpServer, HttpServerPort } from "@venn-lang/http";
|
|
4
|
+
import { createNodeServer } from "@venn-lang/http/node";
|
|
5
|
+
import {
|
|
6
|
+
type CleanupSink,
|
|
7
|
+
checkImports,
|
|
8
|
+
createRunner,
|
|
9
|
+
type EventSink,
|
|
10
|
+
type ModuleIo,
|
|
11
|
+
type NpmModules,
|
|
12
|
+
type RunFilter,
|
|
13
|
+
type RunResult,
|
|
14
|
+
resolveImports,
|
|
15
|
+
} from "@venn-lang/runtime";
|
|
16
|
+
import { allPlugins, stdlibPortBindings } from "@venn-lang/stdlib";
|
|
17
|
+
|
|
18
|
+
/** What one `.vn` file amounted to. */
|
|
19
|
+
export interface RunFileOutcome {
|
|
20
|
+
/** Everything refused or reported. Empty when the file ran clean. */
|
|
21
|
+
problems: Problem[];
|
|
22
|
+
/** Absent when nothing ran: the parse or the imports stopped it first. */
|
|
23
|
+
result?: RunResult;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The source to run, the implementations behind its ports, and how to run it. */
|
|
27
|
+
export interface RunFileArgs {
|
|
28
|
+
source: string;
|
|
29
|
+
uri: string;
|
|
30
|
+
host: Host;
|
|
31
|
+
sink: EventSink;
|
|
32
|
+
httpClient: HttpClient;
|
|
33
|
+
/** Injected when the caller needs to close the servers itself, as `venn run` does. */
|
|
34
|
+
httpServer?: HttpServer;
|
|
35
|
+
console?: Console;
|
|
36
|
+
filter?: RunFilter;
|
|
37
|
+
bail?: boolean;
|
|
38
|
+
env?: Record<string, unknown>;
|
|
39
|
+
io?: ModuleIo;
|
|
40
|
+
/** How an installed package is loaded, when the host can load one. */
|
|
41
|
+
npm?: NpmModules;
|
|
42
|
+
/** Where the program registers what it opened, so the host can close it. */
|
|
43
|
+
cleanup?: CleanupSink;
|
|
44
|
+
/** "test" runs the flows; "script" executes the file top to bottom. */
|
|
45
|
+
mode?: "test" | "script";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse and run a `.vn` source with the full stdlib loaded.
|
|
50
|
+
*
|
|
51
|
+
* The HttpClient, HttpServer and Console ports take injected implementations
|
|
52
|
+
* (real in the CLI, fakes in tests); every other port takes the binding
|
|
53
|
+
* `@venn-lang/stdlib` supplies.
|
|
54
|
+
*
|
|
55
|
+
* @param args - The source and its uri, the host and the ports, and how to run
|
|
56
|
+
* it: the filter, the mode, the environment, the module and package loaders.
|
|
57
|
+
* @returns The problems and, when anything ran, the run's result. A failing
|
|
58
|
+
* flow is a `Problem` here, not an exception.
|
|
59
|
+
* @throws Whatever an action or a loaded module let escape the runner.
|
|
60
|
+
*/
|
|
61
|
+
export async function runFile(args: RunFileArgs): Promise<RunFileOutcome> {
|
|
62
|
+
const { ast, problems } = parse(args.source, { uri: args.uri });
|
|
63
|
+
if (problems.length > 0) return { problems };
|
|
64
|
+
const io = args.io;
|
|
65
|
+
const resolved = io
|
|
66
|
+
? await resolveImports({ document: ast, uri: args.uri, io, npm: args.npm })
|
|
67
|
+
: undefined;
|
|
68
|
+
const graph =
|
|
69
|
+
resolved && io
|
|
70
|
+
? { modules: resolved.modules, resolve: io.resolve, npm: resolved.npm }
|
|
71
|
+
: undefined;
|
|
72
|
+
// Refused before anything runs. An import that names something the other file
|
|
73
|
+
// never published would otherwise surface halfway through, as a value that
|
|
74
|
+
// was quietly `undefined` until something called it.
|
|
75
|
+
const bad = graph ? checkImports({ document: ast, uri: args.uri, graph }) : [];
|
|
76
|
+
if (bad.length > 0) return { problems: bad };
|
|
77
|
+
const runner = createRunner({
|
|
78
|
+
host: args.host,
|
|
79
|
+
plugins: allPlugins,
|
|
80
|
+
sink: args.sink,
|
|
81
|
+
ports: bindings(args),
|
|
82
|
+
uri: args.uri,
|
|
83
|
+
filter: args.filter,
|
|
84
|
+
bail: args.bail,
|
|
85
|
+
env: args.env,
|
|
86
|
+
moduleFragments: resolved?.fragments,
|
|
87
|
+
modules: graph,
|
|
88
|
+
moduleDecos: resolved?.decos,
|
|
89
|
+
cleanup: args.cleanup,
|
|
90
|
+
});
|
|
91
|
+
const result = args.mode === "script" ? await runner.script(ast) : await runner.run(ast);
|
|
92
|
+
// The result carries its own problems, a decorator that refused the program
|
|
93
|
+
// among them, so both travel back together rather than one replacing the other.
|
|
94
|
+
return { problems: result.problems ?? [], result };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function bindings(args: RunFileArgs) {
|
|
98
|
+
const console = args.console ? [{ port: ConsolePort, impl: args.console }] : [];
|
|
99
|
+
// After the stdlib: the last binding wins, and these are the real ones.
|
|
100
|
+
return [
|
|
101
|
+
{ port: HttpClientPort, impl: args.httpClient },
|
|
102
|
+
...stdlibPortBindings,
|
|
103
|
+
{ port: HttpServerPort, impl: args.httpServer ?? createNodeServer() },
|
|
104
|
+
...console,
|
|
105
|
+
];
|
|
106
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the process should go now, or hand the decision to the event loop.
|
|
3
|
+
*
|
|
4
|
+
* The distinction the exit code alone cannot carry: `exit 0` and "ran off the
|
|
5
|
+
* end" both arrive as zero, and only one of them is a request to stop. A
|
|
6
|
+
* program that merely finished its last line may still be serving; one that
|
|
7
|
+
* asked to leave, or that ended badly, is done either way.
|
|
8
|
+
*/
|
|
9
|
+
export function shouldLeave(args: { code: number; requested: boolean }): boolean {
|
|
10
|
+
return args.requested || args.code !== 0;
|
|
11
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Block,
|
|
3
|
+
type Document,
|
|
4
|
+
type FlowDecl,
|
|
5
|
+
type IfStmt,
|
|
6
|
+
isBlock,
|
|
7
|
+
isFragmentDecl,
|
|
8
|
+
isIfStmt,
|
|
9
|
+
isRunStmt,
|
|
10
|
+
isStepDecl,
|
|
11
|
+
type Statement,
|
|
12
|
+
} from "@venn-lang/core";
|
|
13
|
+
|
|
14
|
+
interface Walk {
|
|
15
|
+
document: Document;
|
|
16
|
+
/** Fragments already entered, so a recursive `run` cannot loop forever. */
|
|
17
|
+
seen: Set<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every step title a flow can reach: nested in groups, loops, branches and
|
|
22
|
+
* `try`, and followed through `run <fragment>` into fragments of the same file.
|
|
23
|
+
*/
|
|
24
|
+
export function stepTitlesOf(flow: FlowDecl, document: Document): string[] {
|
|
25
|
+
return fromBlock(flow.body, { document, seen: new Set() });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fromBlock(block: Block, walk: Walk): string[] {
|
|
29
|
+
return block.stmts.flatMap((stmt) => fromStatement(stmt, walk));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fromStatement(stmt: Statement, walk: Walk): string[] {
|
|
33
|
+
if (isStepDecl(stmt)) return [stmt.title, ...fromBlock(stmt.body, walk)];
|
|
34
|
+
if (isRunStmt(stmt)) return fromFragment(stmt.target, walk);
|
|
35
|
+
if (isIfStmt(stmt)) return fromIf(stmt, walk);
|
|
36
|
+
return nestedBlocks(stmt).flatMap((block) => fromBlock(block, walk));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fromIf(stmt: IfStmt, walk: Walk): string[] {
|
|
40
|
+
const then = fromBlock(stmt.then, walk);
|
|
41
|
+
const otherwise = stmt.otherwise;
|
|
42
|
+
if (!otherwise) return then;
|
|
43
|
+
const tail = isBlock(otherwise) ? fromBlock(otherwise, walk) : fromStatement(otherwise, walk);
|
|
44
|
+
return [...then, ...tail];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fromFragment(name: string, walk: Walk): string[] {
|
|
48
|
+
if (walk.seen.has(name)) return [];
|
|
49
|
+
const decl = walk.document.decls.find((node) => isFragmentDecl(node) && node.name === name);
|
|
50
|
+
if (!isFragmentDecl(decl)) return [];
|
|
51
|
+
walk.seen.add(name);
|
|
52
|
+
return fromBlock(decl.body, walk);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// group / forEach / repeat / while / parallel / race / try all carry blocks.
|
|
56
|
+
function nestedBlocks(stmt: Statement): Block[] {
|
|
57
|
+
const node = stmt as { body?: Block; handler?: Block; finalizer?: Block };
|
|
58
|
+
return [node.body, node.handler, node.finalizer].filter((block): block is Block =>
|
|
59
|
+
Array.isArray(block?.stmts),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Leave, Shutdown } from "./shutdown.types.js";
|
|
2
|
+
|
|
3
|
+
/** How long a close may take before the program stops waiting for it. */
|
|
4
|
+
const GRACE_MS = 5_000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Leaving, done properly: close what is open, then go.
|
|
8
|
+
*
|
|
9
|
+
* Two things make this more than `await close(); exit()`. A second signal means
|
|
10
|
+
* the user is done asking nicely, so it leaves at once. And a closer that never
|
|
11
|
+
* settles must not hold the program hostage, so the wait has a deadline: an
|
|
12
|
+
* unclean exit beats a process that cannot be stopped.
|
|
13
|
+
*/
|
|
14
|
+
export function createLeave(args: {
|
|
15
|
+
shutdown: Shutdown;
|
|
16
|
+
exit: (code: number) => void;
|
|
17
|
+
graceMs?: number;
|
|
18
|
+
}): Leave {
|
|
19
|
+
let leaving = false;
|
|
20
|
+
return (code) => {
|
|
21
|
+
if (leaving) return args.exit(code);
|
|
22
|
+
leaving = true;
|
|
23
|
+
void finish({ ...args, code });
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function finish(args: {
|
|
28
|
+
shutdown: Shutdown;
|
|
29
|
+
exit: (code: number) => void;
|
|
30
|
+
graceMs?: number;
|
|
31
|
+
code: number;
|
|
32
|
+
}): Promise<void> {
|
|
33
|
+
await Promise.race([args.shutdown.close(), deadline(args.graceMs ?? GRACE_MS)]);
|
|
34
|
+
args.exit(args.code);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A timer that does not itself keep the program alive. */
|
|
38
|
+
function deadline(ms: number): Promise<void> {
|
|
39
|
+
return new Promise<void>((resolve) => {
|
|
40
|
+
setTimeout(resolve, ms).unref();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Closer, Shutdown } from "./shutdown.types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The registry: what to close, and the promise that it happened.
|
|
5
|
+
*
|
|
6
|
+
* Closing runs newest first, because a thing opened later may be standing on one
|
|
7
|
+
* opened earlier. One closer that throws must not strand the rest: leaving is
|
|
8
|
+
* the goal, and a half-closed program still has to go.
|
|
9
|
+
*/
|
|
10
|
+
export function createShutdown(): Shutdown {
|
|
11
|
+
const closers = new Set<Closer>();
|
|
12
|
+
let closing: Promise<void> | undefined;
|
|
13
|
+
return {
|
|
14
|
+
add: (closer) => {
|
|
15
|
+
closers.add(closer);
|
|
16
|
+
return () => {
|
|
17
|
+
closers.delete(closer);
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
close: () => {
|
|
21
|
+
closing ??= closeAll(closers);
|
|
22
|
+
return closing;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function closeAll(closers: Set<Closer>): Promise<void> {
|
|
28
|
+
for (const closer of [...closers].reverse()) {
|
|
29
|
+
await Promise.resolve()
|
|
30
|
+
.then(() => closer())
|
|
31
|
+
.catch(() => {});
|
|
32
|
+
}
|
|
33
|
+
closers.clear();
|
|
34
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createLeave } from "./create-leave.js";
|
|
2
|
+
export { createShutdown } from "./create-shutdown.js";
|
|
3
|
+
export { installExitHook } from "./install-exit-hook.js";
|
|
4
|
+
export { installFaultHooks } from "./install-fault-hooks.js";
|
|
5
|
+
export { type HooksArgs, installHooks } from "./install-hooks.js";
|
|
6
|
+
export { installSignalHooks } from "./install-signal-hooks.js";
|
|
7
|
+
export type { Closer, Leave, Shutdown, Unregister } from "./shutdown.types.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import type { Shutdown, Unregister } from "./shutdown.types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The quiet ending: a program that simply ran out of things to do.
|
|
6
|
+
*
|
|
7
|
+
* `beforeExit` is the only moment a script without a server gets, so its
|
|
8
|
+
* `teardown` has to run here or it never runs at all. The timer is what buys
|
|
9
|
+
* the closing its time: a promise alone does not hold the loop open, and
|
|
10
|
+
* without it Node could leave mid-cleanup.
|
|
11
|
+
*/
|
|
12
|
+
export function installExitHook(shutdown: Shutdown): Unregister {
|
|
13
|
+
const onBeforeExit = () => {
|
|
14
|
+
const hold = setInterval(() => {}, 1_000);
|
|
15
|
+
void shutdown.close().finally(() => clearInterval(hold));
|
|
16
|
+
};
|
|
17
|
+
process.once("beforeExit", onBeforeExit);
|
|
18
|
+
return () => {
|
|
19
|
+
process.off("beforeExit", onBeforeExit);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { errorLine } from "../reporters/index.js";
|
|
3
|
+
import type { Leave, Unregister } from "./shutdown.types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The two ways a program dies without being asked to.
|
|
7
|
+
*
|
|
8
|
+
* Node's default for both is to print a stack and vanish, losing the sockets,
|
|
9
|
+
* the reason and the exit code at once. Catching them means the failure is
|
|
10
|
+
* stated in the language's own voice and the machine gets its resources back on
|
|
11
|
+
* the way out.
|
|
12
|
+
*/
|
|
13
|
+
export function installFaultHooks(args: {
|
|
14
|
+
leave: Leave;
|
|
15
|
+
report?: (message: string) => void;
|
|
16
|
+
}): Unregister {
|
|
17
|
+
const onFault = (cause: unknown) => fault({ ...args, cause });
|
|
18
|
+
process.on("uncaughtException", onFault);
|
|
19
|
+
process.on("unhandledRejection", onFault);
|
|
20
|
+
return () => {
|
|
21
|
+
process.off("uncaughtException", onFault);
|
|
22
|
+
process.off("unhandledRejection", onFault);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fault(args: { leave: Leave; report?: (message: string) => void; cause: unknown }): void {
|
|
27
|
+
const write = args.report ?? ((message: string) => process.stderr.write(`${message}\n`));
|
|
28
|
+
write(errorLine(args.cause));
|
|
29
|
+
args.leave(1);
|
|
30
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { SignalSource } from "@venn-lang/contracts";
|
|
2
|
+
import { createLeave } from "./create-leave.js";
|
|
3
|
+
import { installExitHook } from "./install-exit-hook.js";
|
|
4
|
+
import { installFaultHooks } from "./install-fault-hooks.js";
|
|
5
|
+
import { installSignalHooks } from "./install-signal-hooks.js";
|
|
6
|
+
import type { Leave, Shutdown } from "./shutdown.types.js";
|
|
7
|
+
|
|
8
|
+
/** Everything the process-level hooks need from the outside world. */
|
|
9
|
+
export interface HooksArgs {
|
|
10
|
+
signals: SignalSource;
|
|
11
|
+
shutdown: Shutdown;
|
|
12
|
+
/** How the program leaves. Injected so a test can watch instead of dying. */
|
|
13
|
+
exit: (code: number) => void;
|
|
14
|
+
/** Where a fault is announced. Defaults to stderr. */
|
|
15
|
+
report?: (message: string) => void;
|
|
16
|
+
graceMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hand the process its hooks. The three ways a program can end (asked to stop,
|
|
21
|
+
* broken, or simply finished) all end the same way.
|
|
22
|
+
*
|
|
23
|
+
* Returns the `leave` these hooks share, so a command that decides to stop on
|
|
24
|
+
* its own terms unwinds through exactly the same path.
|
|
25
|
+
*/
|
|
26
|
+
export function installHooks(args: HooksArgs): Leave {
|
|
27
|
+
const leave = createLeave({
|
|
28
|
+
shutdown: args.shutdown,
|
|
29
|
+
exit: args.exit,
|
|
30
|
+
graceMs: args.graceMs,
|
|
31
|
+
});
|
|
32
|
+
installSignalHooks({ signals: args.signals, leave });
|
|
33
|
+
installFaultHooks({ leave, report: args.report });
|
|
34
|
+
installExitHook(args.shutdown);
|
|
35
|
+
return leave;
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ALL_SIGNALS, type SignalSource, type SystemSignal } from "@venn-lang/contracts";
|
|
2
|
+
import type { Leave, Unregister } from "./shutdown.types.js";
|
|
3
|
+
|
|
4
|
+
/** What the shell means by each signal, in exit codes. */
|
|
5
|
+
const CODES: Record<SystemSignal, number> = {
|
|
6
|
+
SIGINT: 130,
|
|
7
|
+
SIGTERM: 143,
|
|
8
|
+
SIGBREAK: 130,
|
|
9
|
+
SIGHUP: 129,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every way a system can ask this program to stop, wired to the same ending.
|
|
14
|
+
*
|
|
15
|
+
* `Ctrl+C` is the one everybody knows; `SIGTERM` is how a supervisor or a CI
|
|
16
|
+
* runner asks; `SIGBREAK` is Windows' Ctrl+Break; `SIGHUP` is the terminal
|
|
17
|
+
* closing with the program still inside it. All four deserve the same care.
|
|
18
|
+
*/
|
|
19
|
+
export function installSignalHooks(args: { signals: SignalSource; leave: Leave }): Unregister {
|
|
20
|
+
const offs = ALL_SIGNALS.map((signal) =>
|
|
21
|
+
args.signals.on(signal, () => args.leave(CODES[signal])),
|
|
22
|
+
);
|
|
23
|
+
return () => {
|
|
24
|
+
for (const off of offs) off();
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Something the program opened that has to be given back before it leaves. */
|
|
2
|
+
export type Closer = () => Promise<void> | void;
|
|
3
|
+
|
|
4
|
+
/** Drops a registration, for work whose lifetime is shorter than the process. */
|
|
5
|
+
export type Unregister = () => void;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The list of things this process still owes the machine.
|
|
9
|
+
*
|
|
10
|
+
* Whoever opens something registers how to close it; whoever is leaving calls
|
|
11
|
+
* `close` once and everything unwinds, newest first.
|
|
12
|
+
*/
|
|
13
|
+
export interface Shutdown {
|
|
14
|
+
add(closer: Closer): Unregister;
|
|
15
|
+
/** Close everything. Runs once; later callers await the same pass. */
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** How the program leaves, once there is nothing left to give back. */
|
|
20
|
+
export type Leave = (code: number) => void;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
/** How long a title may get before it stops being readable in a tab. */
|
|
4
|
+
const MAX = 60;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What this process calls itself: `venn run server.vn`, not `node`.
|
|
8
|
+
*
|
|
9
|
+
* The terminal tab is the only place a long-running program announces itself,
|
|
10
|
+
* and "node" says nothing about which program, which file, or whose.
|
|
11
|
+
*/
|
|
12
|
+
export function programTitle(args: { command: string; target?: string }): string {
|
|
13
|
+
const name = args.target ? basename(args.target) : "";
|
|
14
|
+
return `venn ${args.command}${name ? ` ${name}` : ""}`.slice(0, MAX);
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { programTitle } from "./program-title.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Name the process after the program it is running.
|
|
6
|
+
*
|
|
7
|
+
* Until something says otherwise, Windows reports the executable path (the
|
|
8
|
+
* `node` the terminal tab shows) and puts it back when the process exits. So
|
|
9
|
+
* this is set once and never undone: restoring it by hand would write that path
|
|
10
|
+
* over whatever title the terminal had before, which is worse than doing
|
|
11
|
+
* nothing.
|
|
12
|
+
*/
|
|
13
|
+
export function setProgramTitle(args: { command: string; target?: string }): void {
|
|
14
|
+
process.title = programTitle(args);
|
|
15
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { installSiteOf } from "./install-site.js";
|
|
2
|
+
export { isNewer, latestVersion } from "./latest-version.js";
|
|
3
|
+
export type { InstallSite, PackageManager, UpgradeOptions } from "./upgrade.types.js";
|
|
4
|
+
export { runUpgrade } from "./upgrade-command.js";
|
|
5
|
+
export { PACKAGE_NAME, refusalFor, upgradeCommandFor } from "./upgrade-plan.js";
|
|
6
|
+
export { VERSION } from "./version.js";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { InstallSite, PackageManager } from "./upgrade.types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where each manager keeps what it installed for the user rather than for a
|
|
5
|
+
* project. A copy sitting under one of these is global wherever it was invoked
|
|
6
|
+
* from, which matters because several of them live under the home directory:
|
|
7
|
+
* running from `~` would otherwise make a global install look like a local one.
|
|
8
|
+
*/
|
|
9
|
+
const GLOBAL_ROOTS: readonly string[] = [
|
|
10
|
+
"/pnpm/global/",
|
|
11
|
+
"/.bun/install/global/",
|
|
12
|
+
"/bun/install/global/",
|
|
13
|
+
"/lib/node_modules/",
|
|
14
|
+
"/npm/node_modules/",
|
|
15
|
+
"/nodejs/node_modules/",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Which manager installed this copy, and whether it did so globally.
|
|
20
|
+
*
|
|
21
|
+
* Read from the path the CLI is running out of rather than from the
|
|
22
|
+
* environment: `npm_config_user_agent` only exists while a package script is
|
|
23
|
+
* running, and is empty when the binary is invoked directly, which is every
|
|
24
|
+
* time anyone actually uses it.
|
|
25
|
+
*
|
|
26
|
+
* Each manager keeps its global root somewhere unmistakable, so the path is
|
|
27
|
+
* enough to tell them apart on all three operating systems.
|
|
28
|
+
*
|
|
29
|
+
* @param path Where the running CLI lives. Must be a plain path: pass
|
|
30
|
+
* `fileURLToPath(import.meta.url)`, since a `file:///` URL would be read as
|
|
31
|
+
* living outside every project.
|
|
32
|
+
* @param cwd The directory the user invoked it from.
|
|
33
|
+
* @returns The manager and whether the install is global, or `unknown` when the
|
|
34
|
+
* path matches nothing recognisable.
|
|
35
|
+
*/
|
|
36
|
+
export function installSiteOf(args: { path: string; cwd: string }): InstallSite {
|
|
37
|
+
const path = normalise(args.path);
|
|
38
|
+
const manager = managerOf(path);
|
|
39
|
+
if (!manager) return { manager: "unknown", global: false };
|
|
40
|
+
return { manager, global: isGlobal(path, normalise(args.cwd)) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalise(path: string): string {
|
|
44
|
+
return path.split("\\").join("/").toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function managerOf(path: string): PackageManager | undefined {
|
|
48
|
+
if (path.includes("/.bun/") || path.includes("/bun/install/global/")) return "bun";
|
|
49
|
+
if (path.includes("/pnpm/")) return "pnpm";
|
|
50
|
+
if (path.includes("/yarn/") || path.includes("/.yarn/")) return "yarn";
|
|
51
|
+
return path.includes("/node_modules/") ? "npm" : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A copy under the working directory belongs to that project, not to the user,
|
|
56
|
+
* unless it sits in a global root that happens to be an ancestor of it.
|
|
57
|
+
*
|
|
58
|
+
* Upgrading a copy the project owns would move a version its manifest still
|
|
59
|
+
* pins, so the next install would put the old one back and the user would be
|
|
60
|
+
* left wondering which of the two is running.
|
|
61
|
+
*/
|
|
62
|
+
function isGlobal(path: string, cwd: string): boolean {
|
|
63
|
+
if (isGlobalRoot(path)) return true;
|
|
64
|
+
return !path.startsWith(`${cwd}/`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isGlobalRoot(path: string): boolean {
|
|
68
|
+
if (GLOBAL_ROOTS.some((root) => path.includes(root))) return true;
|
|
69
|
+
return path.includes("/yarn/") && path.includes("/global/");
|
|
70
|
+
}
|