@venn-lang/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/dist/bin/venn.mjs +16 -0
  4. package/dist/cli.mjs +56378 -0
  5. package/dist/index.d.mts +98 -0
  6. package/dist/index.d.mts.map +1 -0
  7. package/dist/index.mjs +916 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +67 -0
  10. package/src/bin/venn.ts +14 -0
  11. package/src/cli.ts +299 -0
  12. package/src/commands/build.ts +97 -0
  13. package/src/commands/check.ts +143 -0
  14. package/src/commands/deps.ts +228 -0
  15. package/src/commands/fmt.ts +55 -0
  16. package/src/commands/index.ts +2 -0
  17. package/src/commands/inside-workspace.ts +20 -0
  18. package/src/commands/list.ts +53 -0
  19. package/src/commands/new.ts +66 -0
  20. package/src/commands/run.ts +149 -0
  21. package/src/commands/script.ts +127 -0
  22. package/src/commands/upgrade.ts +86 -0
  23. package/src/commands/verify-plugin.ts +35 -0
  24. package/src/index.ts +7 -0
  25. package/src/manifest/index.ts +2 -0
  26. package/src/manifest/load-env.ts +70 -0
  27. package/src/manifest/load-manifest.ts +42 -0
  28. package/src/project/command-targets.ts +53 -0
  29. package/src/project/index.ts +9 -0
  30. package/src/project/resolve-targets.ts +70 -0
  31. package/src/project/select-packages.ts +37 -0
  32. package/src/project/targets-or-exit.ts +28 -0
  33. package/src/reporters/colors.ts +17 -0
  34. package/src/reporters/dot-sink.ts +18 -0
  35. package/src/reporters/error-line.ts +14 -0
  36. package/src/reporters/index.ts +9 -0
  37. package/src/reporters/junit-sink.ts +39 -0
  38. package/src/reporters/ndjson-stdout.ts +13 -0
  39. package/src/reporters/pick-reporter.ts +28 -0
  40. package/src/reporters/pretty/diff-lines.ts +53 -0
  41. package/src/reporters/pretty/index.ts +1 -0
  42. package/src/reporters/pretty/pretty-reporter.ts +122 -0
  43. package/src/reporters/pretty/pretty.types.ts +29 -0
  44. package/src/reporters/pretty/render.ts +88 -0
  45. package/src/reporters/problem-reporter.ts +13 -0
  46. package/src/reporters/problem-sink.ts +20 -0
  47. package/src/reporters/reporter.types.ts +21 -0
  48. package/src/run/collect-files.ts +43 -0
  49. package/src/run/ending.types.ts +17 -0
  50. package/src/run/exit-code.ts +15 -0
  51. package/src/run/node-io.ts +27 -0
  52. package/src/run/npm-loader.ts +41 -0
  53. package/src/run/package-types.ts +66 -0
  54. package/src/run/run-file.ts +106 -0
  55. package/src/run/should-leave.ts +11 -0
  56. package/src/run/step-titles.ts +61 -0
  57. package/src/shutdown/create-leave.ts +42 -0
  58. package/src/shutdown/create-shutdown.ts +34 -0
  59. package/src/shutdown/index.ts +7 -0
  60. package/src/shutdown/install-exit-hook.ts +21 -0
  61. package/src/shutdown/install-fault-hooks.ts +30 -0
  62. package/src/shutdown/install-hooks.ts +36 -0
  63. package/src/shutdown/install-signal-hooks.ts +26 -0
  64. package/src/shutdown/shutdown.types.ts +20 -0
  65. package/src/title/index.ts +2 -0
  66. package/src/title/program-title.ts +15 -0
  67. package/src/title/set-program-title.ts +15 -0
  68. package/src/upgrade/index.ts +6 -0
  69. package/src/upgrade/install-site.ts +70 -0
  70. package/src/upgrade/latest-version.ts +50 -0
  71. package/src/upgrade/upgrade-command.ts +32 -0
  72. package/src/upgrade/upgrade-plan.ts +36 -0
  73. package/src/upgrade/upgrade.types.ts +17 -0
  74. package/src/upgrade/version.ts +21 -0
@@ -0,0 +1,143 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { createNodeHost } from "@venn-lang/contracts/node";
4
+ import {
5
+ checkTypes,
6
+ type Document,
7
+ type FragmentDecl,
8
+ importedTypes,
9
+ isPackageSpecifier,
10
+ isValueImport,
11
+ type Problem,
12
+ parse,
13
+ } from "@venn-lang/core";
14
+ import {
15
+ buildRegistry,
16
+ checkDocument,
17
+ checkImports,
18
+ collectFragments,
19
+ createTypeCatalog,
20
+ resolveImports,
21
+ } from "@venn-lang/runtime";
22
+ import { allPlugins } from "@venn-lang/stdlib";
23
+ import type { TypeSpec } from "@venn-lang/types";
24
+ import { loadManifest } from "../manifest/index.js";
25
+ import { reportProblems } from "../reporters/index.js";
26
+ import { everySourceUnder } from "../run/collect-files.js";
27
+ import { createNodeModuleIo } from "../run/node-io.js";
28
+ import { loadDerivedTypes } from "../run/package-types.js";
29
+
30
+ /**
31
+ * `venn check <file|folder>`: statically resolve actions, matchers, imports and
32
+ * types without running. A folder is walked.
33
+ *
34
+ * @returns 0 when nothing was found, 1 when problems were reported or the paths
35
+ * hold no `.vn` file.
36
+ */
37
+ export async function checkCommand(args: { paths: readonly string[] }): Promise<number> {
38
+ const files = await everySourceUnder(args.paths);
39
+ if (files.length === 0) {
40
+ process.stderr.write(`No .vn files found at ${args.paths.join(", ")}\n`);
41
+ return 1;
42
+ }
43
+ // Gathered across every file and reported once: a project that checks clean
44
+ // should say so once, not once per file.
45
+ const found = await checkProblems(args.paths);
46
+ return found.problems.length > 0 ? report(found.problems) : ok(files.length);
47
+ }
48
+
49
+ /**
50
+ * The same walk without the printing, for whoever wants the answer rather than
51
+ * the report: `build` records the count, `check` prints the list.
52
+ */
53
+ export async function checkProblems(
54
+ paths: readonly string[],
55
+ ): Promise<{ files: number; problems: Problem[] }> {
56
+ const files = await everySourceUnder(paths);
57
+ const problems: Problem[] = [];
58
+ for (const file of files) problems.push(...(await problemsIn(file)));
59
+ return { files: files.length, problems };
60
+ }
61
+
62
+ async function problemsIn(uri: string): Promise<Problem[]> {
63
+ const { ast, problems } = parse(await readFile(uri, "utf8"), { uri });
64
+ if (problems.length > 0) return problems;
65
+ const project = await loadManifest(uri);
66
+ const manifest = project?.manifest;
67
+ // Anchored where the manifest lives, not where the file does: a member of a
68
+ // workspace reads the aliases its root declared.
69
+ const io = createNodeModuleIo({
70
+ paths: manifest?.paths ?? {},
71
+ rootDir: project?.dir ?? dirname(uri),
72
+ });
73
+ const { fragments: imported, decos, modules } = await resolveImports({ document: ast, uri, io });
74
+ const registry = buildRegistry({ plugins: allPlugins, caps: createNodeHost().caps });
75
+ const found = checkDocument({
76
+ document: ast,
77
+ registry,
78
+ fragments: names(ast, imported),
79
+ env: declaredEnv(manifest),
80
+ uri,
81
+ });
82
+ const catalog = createTypeCatalog(allPlugins);
83
+ const graph = { modules, resolve: io.resolve };
84
+ // The imported names' types come from the files they were written in, so a
85
+ // wrong argument to an imported function is caught here rather than at run time.
86
+ const imports = importedTypes({
87
+ ...graph,
88
+ document: ast,
89
+ uri,
90
+ catalog,
91
+ packages: await packageTypesFor({ document: ast, root: project?.dir }),
92
+ });
93
+ return [
94
+ ...found,
95
+ ...checkImports({ document: ast, uri, graph }),
96
+ ...checkTypes(ast, { uri, catalog, decos, imports }).problems,
97
+ ];
98
+ }
99
+
100
+ /**
101
+ * The derived types of the packages this file imports.
102
+ *
103
+ * Read from `target/types/`, where the install wrote them. Absent means nobody
104
+ * has installed anything yet, so every imported name is `dynamic`, which is the
105
+ * truth about it rather than a failure.
106
+ */
107
+ async function packageTypesFor(args: {
108
+ document: Document;
109
+ root?: string;
110
+ }): Promise<Map<string, Record<string, TypeSpec>>> {
111
+ if (!args.root) return new Map();
112
+ const wanted = args.document.imports
113
+ .filter(isValueImport)
114
+ .map((decl) => decl.path)
115
+ .filter(isPackageSpecifier);
116
+ return wanted.length > 0 ? loadDerivedTypes({ root: args.root, packages: wanted }) : new Map();
117
+ }
118
+
119
+ /**
120
+ * Every variable any `[env.*]` section declares. Undefined without a manifest:
121
+ * with nothing to compare against, every `env.*` read would look undeclared.
122
+ */
123
+ function declaredEnv(
124
+ manifest: { env: Record<string, Record<string, string>> } | undefined,
125
+ ): readonly string[] | undefined {
126
+ const sections = Object.values(manifest?.env ?? {});
127
+ return sections.length > 0 ? [...new Set(sections.flatMap(Object.keys))] : undefined;
128
+ }
129
+
130
+ function names(document: Document, imported: ReadonlyMap<string, FragmentDecl>): Set<string> {
131
+ return new Set([...collectFragments(document).keys(), ...imported.keys()]);
132
+ }
133
+
134
+ function report(problems: Problem[]): number {
135
+ reportProblems(problems);
136
+ return 1;
137
+ }
138
+
139
+ function ok(files: number): number {
140
+ const many = files === 1 ? "" : ` in ${files} files`;
141
+ process.stdout.write(`✓ no problems found${many}\n`);
142
+ return 0;
143
+ }
@@ -0,0 +1,228 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { addDependency, removeDependency } from "@venn-lang/contracts";
3
+ import { createNodeFs, createNodeSpawn } from "@venn-lang/contracts/node";
4
+ import {
5
+ describeDrift,
6
+ isSafeSpec,
7
+ join,
8
+ MANIFEST_FILE,
9
+ managerCommand,
10
+ type Package,
11
+ type Project,
12
+ type ProxiedVerb,
13
+ packageJsonFor,
14
+ readLockfile,
15
+ targetDir,
16
+ verifyLock,
17
+ writeLockfile,
18
+ } from "@venn-lang/project";
19
+ import { selectPackages, unknownPackage } from "../project/index.js";
20
+ import { deriveTypes } from "../run/package-types.js";
21
+
22
+ export interface DepsArgs {
23
+ verb: ProxiedVerb;
24
+ /** `--frozen`: install, then refuse anything the lock did not record. */
25
+ frozen?: boolean;
26
+ /** Specs such as `zod`, `zod@^4` or `@types/node`, passed through untouched. */
27
+ packages: readonly string[];
28
+ dev?: boolean;
29
+ packageName?: string;
30
+ }
31
+
32
+ /**
33
+ * `venn add · remove · update · install`: the four verbs, run underneath by
34
+ * whichever package manager `[tooling]` names.
35
+ *
36
+ * The manifest is edited first and the manager is run second, against a
37
+ * `package.json` generated into `target/`. That ordering is what makes
38
+ * `venn.toml` the source rather than a copy: whatever the tool decides, the
39
+ * file a person reads was written from what they asked for.
40
+ */
41
+ export async function depsCommand(args: DepsArgs): Promise<number> {
42
+ const selection = await selectPackages({ from: process.cwd(), packageName: args.packageName });
43
+ if (!selection) {
44
+ process.stderr.write("VN2101 · no venn.toml here, or in any folder above it.\n");
45
+ return 1;
46
+ }
47
+ if (args.packageName && selection.packages.length === 0) {
48
+ process.stderr.write(unknownPackage(selection.project, args.packageName));
49
+ return 1;
50
+ }
51
+ const unsafe = args.packages.filter((spec) => !isSafeSpec(spec));
52
+ if (unsafe.length > 0) {
53
+ process.stderr.write(`VN2105 · not a package name: ${unsafe.join(", ")}\n`);
54
+ return 1;
55
+ }
56
+ const target = selection.packages[0] ?? holder(selection.project);
57
+ if (!target) {
58
+ process.stderr.write("VN2104 · no package here to add a dependency to.\n");
59
+ return 1;
60
+ }
61
+ await editManifest({ ...args, dir: target.dir });
62
+ // Read again: the manifest just changed, and the project in hand was read
63
+ // before it did, so installing from that one would install what the file
64
+ // asked for before the command ran.
65
+ const after = await selectPackages({ from: process.cwd() });
66
+ if (!after) return 1;
67
+ const code = await install(after.project, args.frozen === true);
68
+ if (code !== 0) return code;
69
+ await pinResolved({ ...args, dir: target.dir, root: after.project.root });
70
+ return 0;
71
+ }
72
+
73
+ /**
74
+ * Write back the version that was actually resolved.
75
+ *
76
+ * `venn add zod` asks for whatever is newest, and a manifest left reading
77
+ * `zod = "latest"` pins nothing a reader can act on. `^` because asking for the
78
+ * newest means this version and the compatible ones after it.
79
+ */
80
+ async function pinResolved(args: DepsArgs & { dir: string; root: string }): Promise<void> {
81
+ const asked = args.packages.filter((spec) => spec.indexOf("@", 1) < 0);
82
+ if (args.verb !== "add" || asked.length === 0) return;
83
+ const lock = await readLockfile({ fs: createNodeFs(), root: args.root });
84
+ const path = join(args.dir, MANIFEST_FILE);
85
+ let text = await readFile(path, "utf8");
86
+ for (const name of asked) {
87
+ const found = lock?.packages.find((one) => one.name === name);
88
+ if (found) {
89
+ text = addDependency({
90
+ text,
91
+ name,
92
+ version: `^${found.version}`,
93
+ table: args.dev ? "dev-dependencies" : undefined,
94
+ });
95
+ }
96
+ }
97
+ await writeFile(path, text, "utf8");
98
+ }
99
+
100
+ /**
101
+ * What is installed, against what the lock recorded.
102
+ *
103
+ * A lock nobody checks is decoration. Verifying it turns "the registry answered
104
+ * differently today" into something a build refuses to start with, rather than
105
+ * something found in production.
106
+ */
107
+ async function checkAgainstLock(root: string): Promise<number> {
108
+ const fs = createNodeFs();
109
+ const lock = await readLockfile({ fs, root });
110
+ if (!lock) {
111
+ process.stderr.write("VN2106 · --frozen, but there is no venn.lock to check against.\n");
112
+ return 1;
113
+ }
114
+ const drift = await verifyLock({ fs, root, lock });
115
+ if (drift.length === 0) return 0;
116
+ const lines = describeDrift(drift);
117
+ process.stderr.write(`VN2107 · what is installed is not what the lock says.\n${lines}\n`);
118
+ return 1;
119
+ }
120
+
121
+ /** A workspace root that is not itself a package cannot hold a dependency. */
122
+ function holder(project: Project): Package | undefined {
123
+ return project.packages.find((one) => one.dir === project.root);
124
+ }
125
+
126
+ /**
127
+ * The manifest, rewritten where it stands.
128
+ *
129
+ * `update` and `install` change nothing here: they are about what is on disk,
130
+ * not about what was asked for.
131
+ */
132
+ async function editManifest(args: DepsArgs & { dir: string }): Promise<void> {
133
+ if (args.verb === "update" || args.verb === "install") return;
134
+ const path = join(args.dir, MANIFEST_FILE);
135
+ let text = await readFile(path, "utf8");
136
+ const table = args.dev ? "dev-dependencies" : undefined;
137
+ for (const spec of args.packages) {
138
+ const { name, version } = split(spec);
139
+ text =
140
+ args.verb === "add"
141
+ ? addDependency({ text, name, version, table })
142
+ : removeDependency({ text, name, table });
143
+ }
144
+ await writeFile(path, text, "utf8");
145
+ }
146
+
147
+ /**
148
+ * `zod@^4`: the name, and what was asked for.
149
+ *
150
+ * A scope starts with `@` and is not a version, so the separator is looked for
151
+ * after the first character.
152
+ */
153
+ function split(spec: string): { name: string; version: string } {
154
+ const at = spec.indexOf("@", 1);
155
+ if (at < 0) return { name: spec, version: "latest" };
156
+ return { name: spec.slice(0, at), version: spec.slice(at + 1) };
157
+ }
158
+
159
+ /**
160
+ * Generate the `package.json` the manager reads, run it there, and record what
161
+ * came out. The manager writes `node_modules` beside the file it was pointed
162
+ * at, so pointing it at `target/` puts them where Node will look for them.
163
+ */
164
+ async function install(project: Project, frozen: boolean): Promise<number> {
165
+ const fs = createNodeFs();
166
+ const dir = targetDir(project.root);
167
+ const members = project.packages.map((one) => one.manifest);
168
+ const json = packageJsonFor({ manifest: project.rootManifest, members });
169
+ await fs.write(join(dir, "package.json"), new TextEncoder().encode(json));
170
+ const code = await run({ manager: project.rootManifest.tooling.manager, dir });
171
+ if (code !== 0) return code;
172
+ // Under `--frozen` the lock is the input, not the output: writing it first
173
+ // and then checking against what was just written is a check that cannot fail.
174
+ if (frozen) return checkAgainstLock(project.root);
175
+ const lock = await writeLockfile({
176
+ fs,
177
+ root: project.root,
178
+ manager: project.rootManifest.tooling.manager,
179
+ });
180
+ process.stdout.write(`locked ${lock.packages.length} package(s)\n`);
181
+ await reportTypes(project.root, asked(project));
182
+ return 0;
183
+ }
184
+
185
+ /** The packages the project asked for by name, not everything that came with them. */
186
+ function asked(project: Project): string[] {
187
+ const all = project.packages.flatMap((one) => [
188
+ ...one.manifest.dependencies,
189
+ ...one.manifest.devDependencies,
190
+ ]);
191
+ return [...new Set(all.filter((dep) => dep.path === undefined).map((dep) => dep.name))];
192
+ }
193
+
194
+ /**
195
+ * Derive the types each package publishes, and say how much came across.
196
+ *
197
+ * A measured number rather than a claim: "94% of exports typed" can be checked
198
+ * and driven up, where "fully compatible" would be false for any package built
199
+ * on conditional types.
200
+ */
201
+ async function reportTypes(root: string, packages: readonly string[]): Promise<void> {
202
+ if (packages.length === 0) return;
203
+ const found = await deriveTypes({ root, packages });
204
+ for (const one of found) {
205
+ const pct = one.total === 0 ? 0 : Math.round((one.typed / one.total) * 100);
206
+ const detail = one.total === 0 ? "no types published" : `${pct}% of ${one.total} exports typed`;
207
+ process.stdout.write(` ${one.name}: ${detail}
208
+ `);
209
+ }
210
+ }
211
+
212
+ async function run(args: { manager: string; dir: string }): Promise<number> {
213
+ const spec = managerCommand({
214
+ manager: args.manager as never,
215
+ verb: "install",
216
+ platform: process.platform,
217
+ });
218
+ const handle = createNodeSpawn().spawn({
219
+ command: spec.command,
220
+ args: spec.args,
221
+ cwd: args.dir,
222
+ shell: spec.shell,
223
+ onOutput: (chunk) => process.stdout.write(chunk),
224
+ });
225
+ const result = await handle.wait();
226
+ if (result.code !== 0) process.stderr.write(`\n${args.manager} exited with ${result.code}\n`);
227
+ return result.code;
228
+ }
@@ -0,0 +1,55 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { relative } from "node:path";
3
+ import { formatOptionsFrom, formatText } from "@venn-lang/core";
4
+ import { loadManifest } from "../manifest/index.js";
5
+ import { dim, green, yellow } from "../reporters/colors.js";
6
+ import { everySourceUnder } from "../run/collect-files.js";
7
+
8
+ /** Everything `venn fmt` accepts. */
9
+ export interface FmtOptions {
10
+ paths: readonly string[];
11
+ check?: boolean;
12
+ }
13
+
14
+ /**
15
+ * `venn fmt <file|folder>`: format in place, or with `--check` report what would
16
+ * change and fail, which is what CI wants.
17
+ *
18
+ * @returns 0 when nothing needs changing, 1 under `--check` when a file would
19
+ * change or when the paths hold no `.vn` file.
20
+ */
21
+ export async function fmtCommand(options: FmtOptions): Promise<number> {
22
+ const files = await everySourceUnder(options.paths);
23
+ if (files.length === 0) {
24
+ process.stderr.write(`No .vn files found at ${options.paths.join(", ")}\n`);
25
+ return 1;
26
+ }
27
+ const changed: string[] = [];
28
+ for (const file of files) {
29
+ if (await reformat(file, options.check === true)) changed.push(file);
30
+ }
31
+ return report(changed, files.length, options.check === true);
32
+ }
33
+
34
+ async function reformat(file: string, check: boolean): Promise<boolean> {
35
+ const source = await readFile(file, "utf8");
36
+ const found = await loadManifest(file);
37
+ const formatted = formatText(source, formatOptionsFrom(found?.manifest.format));
38
+ if (formatted === source) return false;
39
+ if (!check) await writeFile(file, formatted, "utf8");
40
+ return true;
41
+ }
42
+
43
+ function report(changed: readonly string[], total: number, check: boolean): number {
44
+ for (const file of changed) {
45
+ process.stdout.write(` ${check ? yellow("~") : green("✓")} ${shorten(file)}\n`);
46
+ }
47
+ const verb = check ? "would change" : "formatted";
48
+ process.stdout.write(`\n ${dim(verb)} ${changed.length}/${total}\n`);
49
+ return check && changed.length > 0 ? 1 : 0;
50
+ }
51
+
52
+ function shorten(file: string): string {
53
+ const path = relative(process.cwd(), file);
54
+ return path && !path.startsWith("..") ? path.replace(/\\/g, "/") : file;
55
+ }
@@ -0,0 +1,2 @@
1
+ export { runCommand } from "./run.js";
2
+ export { verifyPluginCommand } from "./verify-plugin.js";
@@ -0,0 +1,20 @@
1
+ import { createNodeFs } from "@venn-lang/contracts/node";
2
+ import { ancestors, matchesMember, readManifest, relativeTo } from "@venn-lang/project";
3
+
4
+ /**
5
+ * Whether a workspace above this path will claim it as a member.
6
+ *
7
+ * Asked before the directory exists, so the member globs are matched as text
8
+ * rather than expanded against the disk. The nearest workspace answers: a root
9
+ * whose `members` do not name this path is not this path's root, and creating a
10
+ * package it will not pick up should not produce a manifest that waits on it.
11
+ */
12
+ export async function insideWorkspace(dir: string): Promise<boolean> {
13
+ const fs = createNodeFs();
14
+ for (const above of ancestors(dir).slice(1)) {
15
+ const manifest = await readManifest({ fs, dir: above });
16
+ if (!manifest?.workspace) continue;
17
+ return matchesMember({ path: relativeTo(dir, above), patterns: manifest.workspace.members });
18
+ }
19
+ return false;
20
+ }
@@ -0,0 +1,53 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { relative, resolve } from "node:path";
3
+ import { type Document, type FlowDecl, isFlowDecl, parse } from "@venn-lang/core";
4
+ import { matchesTitle } from "@venn-lang/runtime";
5
+ import { bold, cyan, dim } from "../reporters/colors.js";
6
+ import { collectSourceFiles } from "../run/collect-files.js";
7
+ import { stepTitlesOf } from "../run/step-titles.js";
8
+
9
+ /** Everything `venn list` accepts. */
10
+ export interface ListOptions {
11
+ file: string;
12
+ flow?: string;
13
+ step?: string;
14
+ }
15
+
16
+ /**
17
+ * `venn list <file|directory>`: what would run, without running it.
18
+ *
19
+ * @returns 0, or 1 when the path holds no `.vn` file.
20
+ */
21
+ export async function listCommand(options: ListOptions): Promise<number> {
22
+ const files = await collectSourceFiles(resolve(options.file));
23
+ if (files.length === 0) {
24
+ process.stderr.write(`No .vn files found at ${options.file}\n`);
25
+ return 1;
26
+ }
27
+ for (const file of files) await listFile(file, options);
28
+ return 0;
29
+ }
30
+
31
+ async function listFile(file: string, options: ListOptions): Promise<void> {
32
+ const { ast } = parse(await readFile(file, "utf8"), { uri: file });
33
+ const flows = ast.decls.filter(isFlowDecl).filter((f) => matchesTitle(f.title, options.flow));
34
+ if (flows.length === 0) return;
35
+ write(`\n${cyan(shorten(file))}`);
36
+ for (const flow of flows) writeFlow(flow, ast, options.step);
37
+ }
38
+
39
+ function writeFlow(flow: FlowDecl, document: Document, step: string | undefined): void {
40
+ write(` ${dim("❯")} ${bold(flow.title)}`);
41
+ for (const title of stepTitlesOf(flow, document)) {
42
+ if (matchesTitle(title, step)) write(` ${dim("•")} ${title}`);
43
+ }
44
+ }
45
+
46
+ function shorten(file: string): string {
47
+ const path = relative(process.cwd(), file);
48
+ return path && !path.startsWith("..") ? path.replace(/\\/g, "/") : file;
49
+ }
50
+
51
+ function write(text: string): void {
52
+ process.stdout.write(`${text}\n`);
53
+ }
@@ -0,0 +1,66 @@
1
+ import { resolve } from "node:path";
2
+ import { createNodeFs } from "@venn-lang/contracts/node";
3
+ import {
4
+ MANIFEST_FILE,
5
+ normalise,
6
+ type ScaffoldFile,
7
+ type ScaffoldKind,
8
+ scaffold,
9
+ } from "@venn-lang/project";
10
+ import { insideWorkspace } from "./inside-workspace.js";
11
+
12
+ export interface NewArgs {
13
+ /** Where the project goes. For `init` this is the directory it is run in. */
14
+ dir: string;
15
+ name: string;
16
+ kind: ScaffoldKind;
17
+ /** Print what would be written and write nothing. */
18
+ dryRun?: boolean;
19
+ }
20
+
21
+ /**
22
+ * `venn new <name>` and `venn init`: start a project.
23
+ *
24
+ * Refuses to write over a manifest that is already there. Starting a project on
25
+ * top of one that exists is never what was meant, and the cost of being wrong
26
+ * about that is somebody's work.
27
+ */
28
+ export async function newCommand(args: NewArgs): Promise<number> {
29
+ // Rooted at the directory being created, so every path below is relative to
30
+ // it. The file system joins its root onto what it is given, so an absolute
31
+ // path would write one path inside another.
32
+ const dir = normalise(resolve(args.dir));
33
+ const fs = createNodeFs({ root: dir });
34
+ if (await fs.exists(MANIFEST_FILE)) {
35
+ process.stderr.write(`VN2102 · there is already a ${MANIFEST_FILE} in ${dir}\n`);
36
+ return 1;
37
+ }
38
+ const files = scaffold({
39
+ kind: args.kind,
40
+ name: args.name,
41
+ insideWorkspace: args.kind === "workspace" ? false : await insideWorkspace(dir),
42
+ });
43
+ if (args.dryRun) return describe(files, dir);
44
+ const encoder = new TextEncoder();
45
+ for (const file of files) await fs.write(file.path, encoder.encode(file.content));
46
+ return announce(args, dir, files);
47
+ }
48
+
49
+ function describe(files: readonly ScaffoldFile[], dir: string): number {
50
+ process.stdout.write(`would write into ${dir}:\n`);
51
+ for (const file of files) process.stdout.write(` ${file.path}\n`);
52
+ return 0;
53
+ }
54
+
55
+ const NEXT: Record<ScaffoldKind, string> = {
56
+ lib: "write src/lib.vn, and `pub` what others should reach",
57
+ bin: "venn run src/main.vn",
58
+ workspace: "venn new packages/api --bin",
59
+ };
60
+
61
+ function announce(args: NewArgs, dir: string, files: readonly ScaffoldFile[]): number {
62
+ process.stdout.write(`created ${args.kind} ${args.name} in ${dir}\n`);
63
+ for (const file of files) process.stdout.write(` ${file.path}\n`);
64
+ process.stdout.write(`\nnext: ${NEXT[args.kind]}\n`);
65
+ return 0;
66
+ }