@venn-lang/project 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 +176 -0
- package/dist/index.d.ts +599 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +970 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
- package/src/discover/conventional-targets.ts +49 -0
- package/src/discover/find-project.ts +126 -0
- package/src/discover/index.ts +4 -0
- package/src/discover/load-package.ts +60 -0
- package/src/discover/read-manifest.ts +23 -0
- package/src/glob/expand-members.ts +45 -0
- package/src/glob/index.ts +2 -0
- package/src/glob/matches-member.ts +32 -0
- package/src/index.ts +67 -0
- package/src/model/project.types.ts +55 -0
- package/src/npm/hash-package.ts +65 -0
- package/src/npm/index.ts +12 -0
- package/src/npm/lockfile.ts +49 -0
- package/src/npm/lockfile.types.ts +42 -0
- package/src/npm/manager-command.ts +85 -0
- package/src/npm/package-json.ts +59 -0
- package/src/npm/read-installed.ts +72 -0
- package/src/npm/verify-lock.ts +78 -0
- package/src/paths/index.ts +10 -0
- package/src/paths/paths.ts +103 -0
- package/src/scaffold/index.ts +2 -0
- package/src/scaffold/scaffold.ts +61 -0
- package/src/scaffold/scaffold.types.ts +23 -0
- package/src/target/build-record.ts +45 -0
- package/src/target/index.ts +15 -0
- package/src/target/layout.ts +50 -0
- package/src/workspace/index.ts +2 -0
- package/src/workspace/inherit.ts +60 -0
- package/src/workspace/member-dirs.ts +34 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
import { BIN_DIR, LIB_ROOT, MAIN_ROOT, createTomlManifest } from "@venn-lang/contracts";
|
|
2
|
+
//#region src/paths/paths.ts
|
|
3
|
+
/**
|
|
4
|
+
* Path arithmetic on manifest paths, with no `node:path`.
|
|
5
|
+
*
|
|
6
|
+
* This package runs in a Web Worker as well as in the CLI, so paths are handled
|
|
7
|
+
* as text with forward slashes. A Windows path arrives with backslashes and is
|
|
8
|
+
* normalised on the way in, which is the one place the difference is allowed to
|
|
9
|
+
* exist.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* One path, written the one way the rest of this package understands.
|
|
13
|
+
*
|
|
14
|
+
* @returns The path with backslashes turned into forward slashes, runs of
|
|
15
|
+
* separators collapsed, and any trailing separator dropped. `"/"` stays `"/"`.
|
|
16
|
+
*/
|
|
17
|
+
function normalise(path) {
|
|
18
|
+
const flat = path.replaceAll("\\", "/").replace(/\/+/g, "/");
|
|
19
|
+
return flat.length > 1 ? flat.replace(/\/$/, "") : flat;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Joins path segments, ignoring empty ones.
|
|
23
|
+
*
|
|
24
|
+
* @returns The joined path, normalised. No `..` is resolved: these are manifest
|
|
25
|
+
* paths, and what a manifest wrote is what gets written down.
|
|
26
|
+
*/
|
|
27
|
+
function join(...parts) {
|
|
28
|
+
return normalise(parts.filter((part) => part !== "").join("/"));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The directory holding this path.
|
|
32
|
+
*
|
|
33
|
+
* @returns The parent, or `undefined` when there is nothing above. A relative
|
|
34
|
+
* path with one segment has the empty string as its parent, which is the
|
|
35
|
+
* directory it was written against; stopping short of it would leave a walk up
|
|
36
|
+
* from `packages/api` never reaching the workspace root beside it.
|
|
37
|
+
*/
|
|
38
|
+
function parentOf(path) {
|
|
39
|
+
const flat = normalise(path);
|
|
40
|
+
if (flat === "" || flat === "/") return void 0;
|
|
41
|
+
const slash = flat.lastIndexOf("/");
|
|
42
|
+
if (slash < 0) return "";
|
|
43
|
+
return slash === 0 ? "/" : flat.slice(0, slash);
|
|
44
|
+
}
|
|
45
|
+
/** The last segment of a path, which is the file or directory's own name. */
|
|
46
|
+
function baseName(path) {
|
|
47
|
+
const flat = normalise(path);
|
|
48
|
+
return flat.slice(flat.lastIndexOf("/") + 1);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Every directory from `path` up to the top, `path` itself first.
|
|
52
|
+
*
|
|
53
|
+
* @returns The chain, ending in `""` for a relative path and `"/"` for an
|
|
54
|
+
* absolute one.
|
|
55
|
+
*/
|
|
56
|
+
function ancestors(path) {
|
|
57
|
+
const found = [];
|
|
58
|
+
for (let at = normalise(path); at !== void 0; at = parentOf(at)) found.push(at);
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
/** Whether `path` is `base` or sits inside it. */
|
|
62
|
+
function isInside(path, base) {
|
|
63
|
+
const one = normalise(path);
|
|
64
|
+
const other = normalise(base);
|
|
65
|
+
return one === other || one.startsWith(`${other}/`);
|
|
66
|
+
}
|
|
67
|
+
/** `path` written relative to `base`, or the path itself when it is not inside. */
|
|
68
|
+
function relativeTo(path, base) {
|
|
69
|
+
const one = normalise(path);
|
|
70
|
+
const other = normalise(base);
|
|
71
|
+
return one.startsWith(`${other}/`) ? one.slice(other.length + 1) : one;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A relative path written in one directory, rewritten to mean the same thing
|
|
75
|
+
* from another inside it.
|
|
76
|
+
*
|
|
77
|
+
* A workspace root writes `"#shared" = "./shared"` once and every member uses
|
|
78
|
+
* it, but a member reads it from further down, where `./shared` is somewhere
|
|
79
|
+
* else entirely.
|
|
80
|
+
*
|
|
81
|
+
* @param args.declaredIn Where the path was written.
|
|
82
|
+
* @param args.usedIn Where it will be read.
|
|
83
|
+
* @returns The path with one `../` per directory of depth. An absolute path is
|
|
84
|
+
* returned unchanged: it already means the same thing everywhere.
|
|
85
|
+
*/
|
|
86
|
+
function reanchor(args) {
|
|
87
|
+
if (!args.path.startsWith(".")) return args.path;
|
|
88
|
+
const down = relativeTo(args.usedIn, args.declaredIn);
|
|
89
|
+
const depth = down === normalise(args.usedIn) ? 0 : down.split("/").filter(Boolean).length;
|
|
90
|
+
if (depth === 0) return args.path;
|
|
91
|
+
return `${"../".repeat(depth)}${args.path.replace(/^\.\//, "")}`;
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/discover/conventional-targets.ts
|
|
95
|
+
/**
|
|
96
|
+
* What a package builds when its manifest does not say.
|
|
97
|
+
*
|
|
98
|
+
* `src/lib.vn` is a library, `src/main.vn` is a program, and each file in
|
|
99
|
+
* `src/bin/` is another program. A package that builds the obvious thing should
|
|
100
|
+
* not have to write it down. A declared target always wins, matched by kind and
|
|
101
|
+
* name, so writing `[lib]` with a different `path` moves the library rather
|
|
102
|
+
* than adding a second one.
|
|
103
|
+
*
|
|
104
|
+
* @param args.declared What the manifest spelled out.
|
|
105
|
+
* @param args.packageName Names the `lib` and the `src/main.vn` program.
|
|
106
|
+
* @returns The declared targets, followed by every convention found on disk.
|
|
107
|
+
*/
|
|
108
|
+
async function conventionalTargets(args) {
|
|
109
|
+
const found = [...args.declared];
|
|
110
|
+
const add = async (target) => {
|
|
111
|
+
if (found.some((one) => one.kind === target.kind && one.name === target.name)) return;
|
|
112
|
+
if (await args.fs.exists(join(args.dir, target.path))) found.push(target);
|
|
113
|
+
};
|
|
114
|
+
await add({
|
|
115
|
+
kind: "lib",
|
|
116
|
+
name: args.packageName,
|
|
117
|
+
path: LIB_ROOT
|
|
118
|
+
});
|
|
119
|
+
await add({
|
|
120
|
+
kind: "bin",
|
|
121
|
+
name: args.packageName,
|
|
122
|
+
path: MAIN_ROOT
|
|
123
|
+
});
|
|
124
|
+
for (const name of await binNames(args.fs, args.dir)) await add({
|
|
125
|
+
kind: "bin",
|
|
126
|
+
name,
|
|
127
|
+
path: `${BIN_DIR}/${name}.vn`
|
|
128
|
+
});
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
/** Each `.vn` in `src/bin/` is a program named after the file. */
|
|
132
|
+
async function binNames(fs, dir) {
|
|
133
|
+
return (await fs.list(join(dir, BIN_DIR))).filter((entry) => !entry.directory && entry.name.endsWith(".vn")).map((entry) => entry.name.slice(0, -3)).sort();
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/workspace/inherit.ts
|
|
137
|
+
/**
|
|
138
|
+
* A member manifest with what the workspace root supplies filled in.
|
|
139
|
+
*
|
|
140
|
+
* Two things are inherited and they work differently. `[workspace.package]` is
|
|
141
|
+
* a default: the member's own value wins wherever it wrote one. A dependency
|
|
142
|
+
* marked `{ workspace = true }` is a request, so the root's answer replaces
|
|
143
|
+
* whatever was there. Writing both a version and `workspace = true` is a
|
|
144
|
+
* contradiction, and the request is the deliberate half.
|
|
145
|
+
*
|
|
146
|
+
* @param args.from The workspace root's manifest. A root with no `[workspace]`
|
|
147
|
+
* table supplies nothing, and the member is returned untouched.
|
|
148
|
+
* @returns The merged manifest. The member's own `name` always survives.
|
|
149
|
+
*/
|
|
150
|
+
function inherit(args) {
|
|
151
|
+
const shared = args.from.workspace;
|
|
152
|
+
if (!shared) return args.manifest;
|
|
153
|
+
const pkg = withDefaults(args.manifest.package, shared.package);
|
|
154
|
+
return {
|
|
155
|
+
...args.manifest,
|
|
156
|
+
name: pkg.name,
|
|
157
|
+
version: pkg.version ?? args.manifest.version,
|
|
158
|
+
package: pkg,
|
|
159
|
+
dependencies: pinned(args.manifest.dependencies, shared.dependencies),
|
|
160
|
+
devDependencies: pinned(args.manifest.devDependencies, shared.dependencies),
|
|
161
|
+
...settings(args.manifest, args.from)
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The environments and path aliases a member gets from its root.
|
|
166
|
+
*
|
|
167
|
+
* A workspace almost always wants `[env.staging]` and `#shared` written once.
|
|
168
|
+
* The alternative, every member repeating every variable, drifts silently until
|
|
169
|
+
* two members disagree about a URL. A member that writes its own key wins, per
|
|
170
|
+
* key rather than per table.
|
|
171
|
+
*/
|
|
172
|
+
function settings(own, root) {
|
|
173
|
+
const env = { ...root.env };
|
|
174
|
+
for (const [name, vars] of Object.entries(own.env)) env[name] = {
|
|
175
|
+
...env[name],
|
|
176
|
+
...vars
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
env,
|
|
180
|
+
paths: {
|
|
181
|
+
...root.paths,
|
|
182
|
+
...own.paths
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** The member's own value wins; the root fills only what was left unsaid. */
|
|
187
|
+
function withDefaults(own, shared) {
|
|
188
|
+
const merged = { ...own };
|
|
189
|
+
for (const [key, value] of Object.entries(shared)) if (merged[key] === void 0 || merged[key] === "") merged[key] = value;
|
|
190
|
+
return {
|
|
191
|
+
...merged,
|
|
192
|
+
name: own.name
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function pinned(deps, shared) {
|
|
196
|
+
return deps.map((dep) => {
|
|
197
|
+
if (!dep.fromWorkspace) return dep;
|
|
198
|
+
const found = shared.find((one) => one.name === dep.name);
|
|
199
|
+
return found ? {
|
|
200
|
+
...found,
|
|
201
|
+
optional: dep.optional,
|
|
202
|
+
fromWorkspace: true
|
|
203
|
+
} : dep;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region src/glob/expand-members.ts
|
|
208
|
+
/**
|
|
209
|
+
* Expands a workspace's `members` patterns against the disk.
|
|
210
|
+
*
|
|
211
|
+
* `*` stands for one path segment, the way Cargo reads it, so `packages/*` is
|
|
212
|
+
* every package one level down and nothing deeper. `**` is deliberately not
|
|
213
|
+
* read: a member list that can reach arbitrarily far is one nobody can predict.
|
|
214
|
+
*
|
|
215
|
+
* @param args.root The workspace root the patterns are written against.
|
|
216
|
+
* @returns The directories matched, duplicates dropped, in pattern order with
|
|
217
|
+
* names sorted inside each pattern, so two machines list a workspace alike.
|
|
218
|
+
*/
|
|
219
|
+
async function expandMembers(args) {
|
|
220
|
+
const found = [];
|
|
221
|
+
for (const pattern of args.patterns) for (const dir of await expandOne(args.fs, args.root, pattern)) if (!found.includes(dir)) found.push(dir);
|
|
222
|
+
return found;
|
|
223
|
+
}
|
|
224
|
+
async function expandOne(fs, root, pattern) {
|
|
225
|
+
let at = [root];
|
|
226
|
+
for (const segment of pattern.split("/").filter((part) => part !== "")) at = segment === "*" ? await childrenOf(fs, at) : at.map((dir) => join(dir, segment));
|
|
227
|
+
return at;
|
|
228
|
+
}
|
|
229
|
+
async function childrenOf(fs, dirs) {
|
|
230
|
+
const found = [];
|
|
231
|
+
for (const dir of dirs) {
|
|
232
|
+
const names = (await fs.list(dir)).filter((entry) => entry.directory).map((entry) => entry.name);
|
|
233
|
+
for (const name of names.sort()) found.push(join(dir, name));
|
|
234
|
+
}
|
|
235
|
+
return found;
|
|
236
|
+
}
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region src/glob/matches-member.ts
|
|
239
|
+
/**
|
|
240
|
+
* Whether a path would be caught by a workspace's `members`, without looking at
|
|
241
|
+
* the disk.
|
|
242
|
+
*
|
|
243
|
+
* Needed before the directory exists: creating `packages/api` inside a
|
|
244
|
+
* workspace should produce a manifest that inherits, and expanding the globs
|
|
245
|
+
* cannot answer that yet because there is nothing there to find.
|
|
246
|
+
*
|
|
247
|
+
* @returns `true` when some pattern matches segment for segment, `*` standing
|
|
248
|
+
* for exactly one segment.
|
|
249
|
+
*/
|
|
250
|
+
function matchesMember(args) {
|
|
251
|
+
const parts = segmentsOf(args.path);
|
|
252
|
+
return args.patterns.some((pattern) => matchesOne(parts, segmentsOf(pattern)));
|
|
253
|
+
}
|
|
254
|
+
function matchesOne(path, pattern) {
|
|
255
|
+
if (path.length !== pattern.length) return false;
|
|
256
|
+
return pattern.every((part, index) => part === "*" || part === path[index]);
|
|
257
|
+
}
|
|
258
|
+
function segmentsOf(path) {
|
|
259
|
+
return normalise(path).split("/").filter((part) => part !== "" && part !== ".");
|
|
260
|
+
}
|
|
261
|
+
//#endregion
|
|
262
|
+
//#region src/workspace/member-dirs.ts
|
|
263
|
+
/**
|
|
264
|
+
* The directories a workspace's members occupy, exclusions already applied.
|
|
265
|
+
*
|
|
266
|
+
* @returns The member directories, normalised. One holding no `venn.toml` is
|
|
267
|
+
* dropped rather than reported: a glob describes a shape, and `packages/*`
|
|
268
|
+
* catching a `dist` folder on the way past is the glob working, not the
|
|
269
|
+
* workspace being wrong.
|
|
270
|
+
*/
|
|
271
|
+
async function memberDirs(args) {
|
|
272
|
+
const matched = await expandMembers({
|
|
273
|
+
fs: args.fs,
|
|
274
|
+
root: args.root,
|
|
275
|
+
patterns: args.workspace.members
|
|
276
|
+
});
|
|
277
|
+
const excluded = args.workspace.exclude.map((path) => normalise(join(args.root, path)));
|
|
278
|
+
const kept = matched.filter((dir) => !excluded.some((one) => isInside(dir, one)));
|
|
279
|
+
return withManifest(args.fs, kept);
|
|
280
|
+
}
|
|
281
|
+
async function withManifest(fs, dirs) {
|
|
282
|
+
const found = [];
|
|
283
|
+
for (const dir of dirs) if (await fs.exists(join(dir, "venn.toml"))) found.push(normalise(dir));
|
|
284
|
+
return found;
|
|
285
|
+
}
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region src/discover/read-manifest.ts
|
|
288
|
+
/** The file that makes a directory a package or a workspace root. */
|
|
289
|
+
const MANIFEST_FILE = "venn.toml";
|
|
290
|
+
/**
|
|
291
|
+
* Reads and parses the `venn.toml` in one directory.
|
|
292
|
+
*
|
|
293
|
+
* @returns The manifest, or `undefined` when the file is absent or unreadable.
|
|
294
|
+
* A directory that cannot be read is a directory without a manifest, so the
|
|
295
|
+
* caller keeps walking instead of failing.
|
|
296
|
+
*/
|
|
297
|
+
async function readManifest(args) {
|
|
298
|
+
const path = join(args.dir, MANIFEST_FILE);
|
|
299
|
+
if (!await args.fs.exists(path)) return void 0;
|
|
300
|
+
const bytes = await args.fs.read(path).catch(() => void 0);
|
|
301
|
+
if (!bytes) return void 0;
|
|
302
|
+
return createTomlManifest({ content: new TextDecoder().decode(bytes) }).load();
|
|
303
|
+
}
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/discover/load-package.ts
|
|
306
|
+
/**
|
|
307
|
+
* One package: its manifest, whatever it inherits, and what it builds.
|
|
308
|
+
*
|
|
309
|
+
* Inheritance is applied here rather than at the point of use, so nothing
|
|
310
|
+
* downstream ever holds a manifest that is only half the answer.
|
|
311
|
+
*
|
|
312
|
+
* @param args.dir The directory holding the `venn.toml`.
|
|
313
|
+
* @returns The package, or `undefined` when that directory has no manifest.
|
|
314
|
+
*/
|
|
315
|
+
async function loadPackage(args) {
|
|
316
|
+
const own = await readManifest({
|
|
317
|
+
fs: args.fs,
|
|
318
|
+
dir: args.dir
|
|
319
|
+
});
|
|
320
|
+
if (!own) return void 0;
|
|
321
|
+
const manifest = anchorPaths({
|
|
322
|
+
manifest: args.workspace ? inherit({
|
|
323
|
+
manifest: own,
|
|
324
|
+
from: args.workspace
|
|
325
|
+
}) : own,
|
|
326
|
+
own,
|
|
327
|
+
args
|
|
328
|
+
});
|
|
329
|
+
const targets = await conventionalTargets({
|
|
330
|
+
fs: args.fs,
|
|
331
|
+
dir: args.dir,
|
|
332
|
+
declared: manifest.targets,
|
|
333
|
+
packageName: manifest.name
|
|
334
|
+
});
|
|
335
|
+
return {
|
|
336
|
+
dir: args.dir,
|
|
337
|
+
manifest,
|
|
338
|
+
targets
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Inherited path aliases, rewritten to mean the same place from down here.
|
|
343
|
+
*
|
|
344
|
+
* An alias the member wrote itself is already anchored where it will be read,
|
|
345
|
+
* so only the ones that came from above move. Hence the member's own table is
|
|
346
|
+
* consulted rather than the merged one.
|
|
347
|
+
*/
|
|
348
|
+
function anchorPaths(input) {
|
|
349
|
+
const root = input.args.workspaceDir;
|
|
350
|
+
if (root === void 0 || root === input.args.dir) return input.manifest;
|
|
351
|
+
const paths = {};
|
|
352
|
+
for (const [alias, value] of Object.entries(input.manifest.paths)) paths[alias] = input.own.paths[alias] !== void 0 ? value : reanchor({
|
|
353
|
+
path: value,
|
|
354
|
+
declaredIn: root,
|
|
355
|
+
usedIn: input.args.dir
|
|
356
|
+
});
|
|
357
|
+
return {
|
|
358
|
+
...input.manifest,
|
|
359
|
+
paths
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/discover/find-project.ts
|
|
364
|
+
/**
|
|
365
|
+
* The project a path belongs to: the nearest package, and the root that owns it.
|
|
366
|
+
*
|
|
367
|
+
* Walking up is what lets a command run from anywhere inside a project, the way
|
|
368
|
+
* `cargo` and `git` do. A package is claimed by an ancestor workspace only when
|
|
369
|
+
* that workspace's members name it, so a project checked out inside another
|
|
370
|
+
* never joins it by accident.
|
|
371
|
+
*
|
|
372
|
+
* @param args.fs Where the manifests are read from.
|
|
373
|
+
* @param args.from Any path inside the project; the walk starts here.
|
|
374
|
+
* @returns The project with its members loaded and inheritance applied, or one
|
|
375
|
+
* `VN2101` problem when no `venn.toml` sits here or above. Reading a project
|
|
376
|
+
* never throws: a failure comes back as a problem.
|
|
377
|
+
*/
|
|
378
|
+
async function findProject(args) {
|
|
379
|
+
const nearest = await nearestManifest(args.fs, args.from);
|
|
380
|
+
if (!nearest) return { problems: [noManifest(args.from)] };
|
|
381
|
+
if (nearest.manifest.workspace) return workspaceAt(args.fs, nearest.dir, nearest.manifest);
|
|
382
|
+
const owner = await owningWorkspace(args.fs, nearest.dir);
|
|
383
|
+
return owner ? workspaceAt(args.fs, owner.dir, owner.manifest) : lonePackage(args.fs, nearest.dir);
|
|
384
|
+
}
|
|
385
|
+
async function nearestManifest(fs, from) {
|
|
386
|
+
for (const dir of ancestors(from)) {
|
|
387
|
+
const manifest = await readManifest({
|
|
388
|
+
fs,
|
|
389
|
+
dir
|
|
390
|
+
});
|
|
391
|
+
if (manifest) return {
|
|
392
|
+
dir,
|
|
393
|
+
manifest
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/** The nearest workspace above this package that lists it among its members. */
|
|
398
|
+
async function owningWorkspace(fs, dir) {
|
|
399
|
+
for (const above of ancestors(dir).slice(1)) {
|
|
400
|
+
const manifest = await readManifest({
|
|
401
|
+
fs,
|
|
402
|
+
dir: above
|
|
403
|
+
});
|
|
404
|
+
if (!manifest?.workspace) continue;
|
|
405
|
+
if ((await memberDirs({
|
|
406
|
+
fs,
|
|
407
|
+
root: above,
|
|
408
|
+
workspace: manifest.workspace
|
|
409
|
+
})).includes(normalise(dir))) return {
|
|
410
|
+
dir: above,
|
|
411
|
+
manifest
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
async function lonePackage(fs, dir) {
|
|
416
|
+
const found = await loadPackage({
|
|
417
|
+
fs,
|
|
418
|
+
dir
|
|
419
|
+
});
|
|
420
|
+
if (!found) return { problems: [noManifest(dir)] };
|
|
421
|
+
return {
|
|
422
|
+
project: {
|
|
423
|
+
root: dir,
|
|
424
|
+
isWorkspace: false,
|
|
425
|
+
rootManifest: found.manifest,
|
|
426
|
+
packages: [found],
|
|
427
|
+
defaultPackages: [found]
|
|
428
|
+
},
|
|
429
|
+
problems: []
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
async function workspaceAt(fs, root, manifest) {
|
|
433
|
+
const settings = manifest.workspace;
|
|
434
|
+
if (!settings) return lonePackage(fs, root);
|
|
435
|
+
const packages = await loadEach({
|
|
436
|
+
fs,
|
|
437
|
+
dirs: await memberDirs({
|
|
438
|
+
fs,
|
|
439
|
+
root,
|
|
440
|
+
workspace: settings
|
|
441
|
+
}),
|
|
442
|
+
workspace: manifest,
|
|
443
|
+
workspaceDir: root
|
|
444
|
+
});
|
|
445
|
+
const rootPackage = manifest.name === "" ? void 0 : await loadPackage({
|
|
446
|
+
fs,
|
|
447
|
+
dir: root
|
|
448
|
+
});
|
|
449
|
+
const all = rootPackage ? [rootPackage, ...packages] : packages;
|
|
450
|
+
return {
|
|
451
|
+
project: {
|
|
452
|
+
root,
|
|
453
|
+
isWorkspace: true,
|
|
454
|
+
rootManifest: manifest,
|
|
455
|
+
packages: all,
|
|
456
|
+
defaultPackages: defaults(all, settings.defaultMembers, root)
|
|
457
|
+
},
|
|
458
|
+
problems: []
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
async function loadEach(args) {
|
|
462
|
+
const found = [];
|
|
463
|
+
for (const dir of args.dirs) {
|
|
464
|
+
const one = await loadPackage({
|
|
465
|
+
fs: args.fs,
|
|
466
|
+
dir,
|
|
467
|
+
workspace: args.workspace,
|
|
468
|
+
workspaceDir: args.workspaceDir
|
|
469
|
+
});
|
|
470
|
+
if (one) found.push(one);
|
|
471
|
+
}
|
|
472
|
+
return found;
|
|
473
|
+
}
|
|
474
|
+
/** `default-members`, or every member when the root did not narrow it. */
|
|
475
|
+
function defaults(all, names, root) {
|
|
476
|
+
if (names.length === 0) return [...all];
|
|
477
|
+
const wanted = new Set(names.map((name) => join(root, name)));
|
|
478
|
+
return all.filter((one) => wanted.has(one.dir) || wanted.has(one.manifest.name));
|
|
479
|
+
}
|
|
480
|
+
function noManifest(path) {
|
|
481
|
+
return {
|
|
482
|
+
code: "VN2101",
|
|
483
|
+
title: "No venn.toml here, or in any folder above it.",
|
|
484
|
+
path: normalise(path)
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
//#endregion
|
|
488
|
+
//#region src/npm/hash-package.ts
|
|
489
|
+
/** Directories inside a package that are not part of it. */
|
|
490
|
+
const SKIP = /* @__PURE__ */ new Set([
|
|
491
|
+
"node_modules",
|
|
492
|
+
".git",
|
|
493
|
+
".bin"
|
|
494
|
+
]);
|
|
495
|
+
/**
|
|
496
|
+
* A hash of everything a package installed.
|
|
497
|
+
*
|
|
498
|
+
* Not the registry's integrity hash, which lives in each manager's own lock in
|
|
499
|
+
* each manager's format. This is computed from what landed on disk, which every
|
|
500
|
+
* manager produces the same way. The guarantee differs: the registry's hash
|
|
501
|
+
* says "this is the tarball that was served", this one says "this is the tree
|
|
502
|
+
* that was installed when the lock was written". Trust on first use, and any
|
|
503
|
+
* divergence after that is caught.
|
|
504
|
+
*
|
|
505
|
+
* A hash of hashes rather than of the concatenated bytes, because a package can
|
|
506
|
+
* hold tens of megabytes and digesting it in one buffer costs more than the
|
|
507
|
+
* answer is worth.
|
|
508
|
+
*
|
|
509
|
+
* @param args.dir The installed package directory.
|
|
510
|
+
* @returns `sha256-…` over every file under `dir`, sorted by path, with
|
|
511
|
+
* `node_modules`, `.git` and `.bin` skipped. A file that cannot be read is left
|
|
512
|
+
* out rather than raising.
|
|
513
|
+
*/
|
|
514
|
+
async function hashPackage(args) {
|
|
515
|
+
const files = (await filesUnder(args.fs, args.dir, "")).sort();
|
|
516
|
+
const lines = [];
|
|
517
|
+
for (const path of files) {
|
|
518
|
+
const bytes = await args.fs.read(join(args.dir, path));
|
|
519
|
+
lines.push(`${path}\t${await digest(bytes)}`);
|
|
520
|
+
}
|
|
521
|
+
return `sha256-${await digest(new TextEncoder().encode(lines.join("\n")))}`;
|
|
522
|
+
}
|
|
523
|
+
async function filesUnder(fs, root, at) {
|
|
524
|
+
const found = [];
|
|
525
|
+
for (const entry of await fs.list(join(root, at))) {
|
|
526
|
+
const path = at === "" ? entry.name : `${at}/${entry.name}`;
|
|
527
|
+
if (!entry.directory) found.push(path);
|
|
528
|
+
else if (!SKIP.has(entry.name)) found.push(...await filesUnder(fs, root, path));
|
|
529
|
+
}
|
|
530
|
+
return found;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Web Crypto, so this runs wherever the rest of this package runs.
|
|
534
|
+
*
|
|
535
|
+
* The assertion narrows which buffer backs the array rather than claiming the
|
|
536
|
+
* array is a buffer: `BufferSource` wants a view onto an `ArrayBuffer`, and
|
|
537
|
+
* these come from a file read and from `TextEncoder`, both of which give one.
|
|
538
|
+
*/
|
|
539
|
+
async function digest(bytes) {
|
|
540
|
+
const hashed = await crypto.subtle.digest("SHA-256", bytes);
|
|
541
|
+
return base64(new Uint8Array(hashed));
|
|
542
|
+
}
|
|
543
|
+
function base64(bytes) {
|
|
544
|
+
let binary = "";
|
|
545
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
546
|
+
return btoa(binary);
|
|
547
|
+
}
|
|
548
|
+
//#endregion
|
|
549
|
+
//#region src/npm/lockfile.types.ts
|
|
550
|
+
/** The lock file's name, at the project root beside the manifest. */
|
|
551
|
+
const LOCK_FILE = "venn.lock";
|
|
552
|
+
/** The format version written into every lock. */
|
|
553
|
+
const LOCK_VERSION = 1;
|
|
554
|
+
//#endregion
|
|
555
|
+
//#region src/target/layout.ts
|
|
556
|
+
/**
|
|
557
|
+
* Where everything a build produces or fetches goes.
|
|
558
|
+
*
|
|
559
|
+
* One directory at the workspace root holds every derived thing, the way
|
|
560
|
+
* Cargo's `target/` does, so deleting it costs time and nothing else.
|
|
561
|
+
*
|
|
562
|
+
* The dependencies live inside it, and that placement is load-bearing rather
|
|
563
|
+
* than tidiness: Node resolves a package by walking up from the importing file
|
|
564
|
+
* looking for `node_modules`, and built output sits in `target/debug` or
|
|
565
|
+
* `target/release`, one level below `target/node_modules`. The ordinary
|
|
566
|
+
* resolver finds them with no loader and no symlink. Moving the output out of
|
|
567
|
+
* `target/` breaks that quietly.
|
|
568
|
+
*/
|
|
569
|
+
const TARGET_DIR = "target";
|
|
570
|
+
/** The names inside `target/`. */
|
|
571
|
+
const TARGET_LAYOUT = {
|
|
572
|
+
/** What the package manager installs, and what Node's resolver will find. */
|
|
573
|
+
modules: "node_modules",
|
|
574
|
+
/** Compiled addons, kept apart because they are per-platform, not per-project. */
|
|
575
|
+
native: "native_modules",
|
|
576
|
+
/** Built output, one directory per profile. */
|
|
577
|
+
debug: "debug",
|
|
578
|
+
release: "release"
|
|
579
|
+
};
|
|
580
|
+
/** The `target/` of a project, given its root. */
|
|
581
|
+
function targetDir(root) {
|
|
582
|
+
return join(root, TARGET_DIR);
|
|
583
|
+
}
|
|
584
|
+
/** Where the package manager installs, and where Node's resolver will look. */
|
|
585
|
+
function modulesDir(root) {
|
|
586
|
+
return join(targetDir(root), TARGET_LAYOUT.modules);
|
|
587
|
+
}
|
|
588
|
+
/** Where compiled addons go, kept apart because they are per-platform. */
|
|
589
|
+
function nativeModulesDir(root) {
|
|
590
|
+
return join(targetDir(root), TARGET_LAYOUT.native);
|
|
591
|
+
}
|
|
592
|
+
/** Where a build of this profile is written. */
|
|
593
|
+
function outputDir(args) {
|
|
594
|
+
return join(targetDir(args.root), TARGET_LAYOUT[args.profile]);
|
|
595
|
+
}
|
|
596
|
+
//#endregion
|
|
597
|
+
//#region src/target/build-record.ts
|
|
598
|
+
/** The record's name, inside the profile's output directory. */
|
|
599
|
+
const RECORD_FILE = "build.json";
|
|
600
|
+
/**
|
|
601
|
+
* Writes what a build produced, where that build's outputs go.
|
|
602
|
+
*
|
|
603
|
+
* It answers "what does this project build, and did it hold together", and it
|
|
604
|
+
* is what an incremental build compares against once there is generated code to
|
|
605
|
+
* skip.
|
|
606
|
+
*
|
|
607
|
+
* @returns The path written.
|
|
608
|
+
* @throws Whatever the file system raises when the write fails.
|
|
609
|
+
*/
|
|
610
|
+
async function writeBuildRecord(args) {
|
|
611
|
+
const path = join(outputDir({
|
|
612
|
+
root: args.root,
|
|
613
|
+
profile: args.record.profile
|
|
614
|
+
}), RECORD_FILE);
|
|
615
|
+
const text = `${JSON.stringify(args.record, null, 2)}\n`;
|
|
616
|
+
await args.fs.write(path, new TextEncoder().encode(text));
|
|
617
|
+
return path;
|
|
618
|
+
}
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region src/npm/read-installed.ts
|
|
621
|
+
/**
|
|
622
|
+
* Every package installed, read from `target/node_modules`.
|
|
623
|
+
*
|
|
624
|
+
* The installed tree is the strongest statement there is about what a project
|
|
625
|
+
* resolved to: it is what will actually be imported, whichever tool put it
|
|
626
|
+
* there and whatever that tool's own lock says. One walk, the same for every
|
|
627
|
+
* manager. A scope is a directory of packages rather than a package, so
|
|
628
|
+
* `@types/node` is found one level further down.
|
|
629
|
+
*
|
|
630
|
+
* @param args.root The project root, not the modules directory.
|
|
631
|
+
* @returns Every package with its version and content hash, in name order.
|
|
632
|
+
* Dot-directories and anything without a readable `package.json` are skipped,
|
|
633
|
+
* because a directory that is not a package is not a package.
|
|
634
|
+
*/
|
|
635
|
+
async function readInstalled(args) {
|
|
636
|
+
const modules = modulesDir(args.root);
|
|
637
|
+
const found = [];
|
|
638
|
+
for (const entry of await args.fs.list(modules)) {
|
|
639
|
+
if (!entry.directory || entry.name.startsWith(".")) continue;
|
|
640
|
+
if (entry.name.startsWith("@")) found.push(...await scoped(args.fs, modules, entry.name));
|
|
641
|
+
else pushIf(found, await readPackage(args.fs, join(modules, entry.name)));
|
|
642
|
+
}
|
|
643
|
+
return found.sort((a, b) => a.name.localeCompare(b.name));
|
|
644
|
+
}
|
|
645
|
+
async function scoped(fs, modules, scope) {
|
|
646
|
+
const found = [];
|
|
647
|
+
for (const entry of await fs.list(join(modules, scope))) if (entry.directory) pushIf(found, await readPackage(fs, join(modules, scope, entry.name)));
|
|
648
|
+
return found;
|
|
649
|
+
}
|
|
650
|
+
function pushIf(into, one) {
|
|
651
|
+
if (one) into.push(one);
|
|
652
|
+
}
|
|
653
|
+
async function readPackage(fs, dir) {
|
|
654
|
+
const bytes = await fs.read(join(dir, "package.json")).catch(() => void 0);
|
|
655
|
+
if (!bytes) return void 0;
|
|
656
|
+
const data = parse(new TextDecoder().decode(bytes));
|
|
657
|
+
if (typeof data?.name !== "string" || typeof data.version !== "string") return void 0;
|
|
658
|
+
return {
|
|
659
|
+
name: data.name,
|
|
660
|
+
version: data.version,
|
|
661
|
+
integrity: await hashPackage({
|
|
662
|
+
fs,
|
|
663
|
+
dir
|
|
664
|
+
}),
|
|
665
|
+
...deps(data.dependencies)
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
function deps(value) {
|
|
669
|
+
if (typeof value !== "object" || value === null) return {};
|
|
670
|
+
const entries = Object.entries(value).map(([k, v]) => [k, String(v)]);
|
|
671
|
+
return entries.length > 0 ? { dependencies: Object.fromEntries(entries) } : {};
|
|
672
|
+
}
|
|
673
|
+
function parse(text) {
|
|
674
|
+
try {
|
|
675
|
+
return JSON.parse(text);
|
|
676
|
+
} catch {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
//#endregion
|
|
681
|
+
//#region src/npm/lockfile.ts
|
|
682
|
+
/**
|
|
683
|
+
* Writes `venn.lock` from what is installed.
|
|
684
|
+
*
|
|
685
|
+
* It goes at the project root, beside the manifest, and is meant to be
|
|
686
|
+
* committed. The manager's own lock stays inside `target/`, which is derived
|
|
687
|
+
* and thrown away.
|
|
688
|
+
*
|
|
689
|
+
* @param args.manager Which tool did the resolving, recorded in the file.
|
|
690
|
+
* @returns The lock as written.
|
|
691
|
+
* @throws Whatever the file system raises when the write fails.
|
|
692
|
+
*/
|
|
693
|
+
async function writeLockfile(args) {
|
|
694
|
+
const lock = {
|
|
695
|
+
version: 1,
|
|
696
|
+
manager: args.manager,
|
|
697
|
+
packages: await readInstalled({
|
|
698
|
+
fs: args.fs,
|
|
699
|
+
root: args.root
|
|
700
|
+
})
|
|
701
|
+
};
|
|
702
|
+
const text = `${JSON.stringify(lock, null, 2)}\n`;
|
|
703
|
+
await args.fs.write(join(args.root, LOCK_FILE), new TextEncoder().encode(text));
|
|
704
|
+
return lock;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Reads the lock this project committed.
|
|
708
|
+
*
|
|
709
|
+
* @returns The lock, or `undefined` when the file is absent or will not parse.
|
|
710
|
+
* A lock nobody can read is a project without one, which `install` can fix.
|
|
711
|
+
*/
|
|
712
|
+
async function readLockfile(args) {
|
|
713
|
+
const bytes = await args.fs.read(join(args.root, LOCK_FILE)).catch(() => void 0);
|
|
714
|
+
if (!bytes) return void 0;
|
|
715
|
+
try {
|
|
716
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
717
|
+
} catch {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
//#endregion
|
|
722
|
+
//#region src/npm/manager-command.ts
|
|
723
|
+
/**
|
|
724
|
+
* The command a verb becomes, in the manager this project chose.
|
|
725
|
+
*
|
|
726
|
+
* The interface is ours, the resolution is not: working out which versions
|
|
727
|
+
* satisfy which ranges is years of work against three moving targets, so the
|
|
728
|
+
* four verbs are spelled once here and handed to the tool the project named.
|
|
729
|
+
*
|
|
730
|
+
* @param args.packages Specifiers to act on. Pass each through
|
|
731
|
+
* {@link isSafeSpec} first when `shell` comes back `true`.
|
|
732
|
+
* @returns The command, its arguments, and whether a shell is required.
|
|
733
|
+
*/
|
|
734
|
+
function managerCommand(args) {
|
|
735
|
+
const dev = args.dev ? ["-D"] : [];
|
|
736
|
+
return {
|
|
737
|
+
command: args.manager,
|
|
738
|
+
args: [
|
|
739
|
+
verbFor(args),
|
|
740
|
+
...dev,
|
|
741
|
+
...args.packages ?? []
|
|
742
|
+
],
|
|
743
|
+
shell: args.platform === "win32"
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
/** npm spells `add` and `remove` its own way; every other manager agrees. */
|
|
747
|
+
function verbFor(args) {
|
|
748
|
+
return args.manager === "npm" ? NPM[args.verb] : NON_NPM[args.verb];
|
|
749
|
+
}
|
|
750
|
+
const NON_NPM = {
|
|
751
|
+
add: "add",
|
|
752
|
+
remove: "remove",
|
|
753
|
+
update: "update",
|
|
754
|
+
install: "install"
|
|
755
|
+
};
|
|
756
|
+
const NPM = {
|
|
757
|
+
add: "install",
|
|
758
|
+
remove: "uninstall",
|
|
759
|
+
update: "update",
|
|
760
|
+
install: "install"
|
|
761
|
+
};
|
|
762
|
+
/** A package name, optionally scoped, optionally with a version range. */
|
|
763
|
+
const SPEC = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[A-Za-z0-9.^~><=*-]+)?$/;
|
|
764
|
+
/**
|
|
765
|
+
* Whether a string is a package specifier and nothing else.
|
|
766
|
+
*
|
|
767
|
+
* The command goes through a shell on Windows, and a shell reads `&`, `|` and
|
|
768
|
+
* `>` as instructions rather than text, so `venn add "x & del /q ."` would
|
|
769
|
+
* delete a directory while looking like an install. A real package name holds
|
|
770
|
+
* none of those characters, so checking costs nothing and closes the hole
|
|
771
|
+
* instead of documenting it.
|
|
772
|
+
*
|
|
773
|
+
* @returns `true` when the string is safe to hand to a shell.
|
|
774
|
+
*/
|
|
775
|
+
function isSafeSpec(spec) {
|
|
776
|
+
return SPEC.test(spec);
|
|
777
|
+
}
|
|
778
|
+
//#endregion
|
|
779
|
+
//#region src/npm/package-json.ts
|
|
780
|
+
/**
|
|
781
|
+
* The `package.json` a package manager is shown, generated from `venn.toml`.
|
|
782
|
+
*
|
|
783
|
+
* Nobody edits it and nobody commits it: the manifest is the source, this is
|
|
784
|
+
* what the tool underneath happens to read. It is written into `target/`, and
|
|
785
|
+
* that placement is the whole trick: a manager writes `node_modules` beside the
|
|
786
|
+
* `package.json` it was pointed at, so the modules land in
|
|
787
|
+
* `target/node_modules` with nothing fighting anything.
|
|
788
|
+
*
|
|
789
|
+
* @param args.members Their dependencies are merged in too, when the root is a
|
|
790
|
+
* workspace.
|
|
791
|
+
* @returns The file's text, marked private, with path dependencies left out and
|
|
792
|
+
* `[patch]` turned into `overrides`.
|
|
793
|
+
*/
|
|
794
|
+
function packageJsonFor(args) {
|
|
795
|
+
const all = [args.manifest, ...args.members ?? []];
|
|
796
|
+
const body = {
|
|
797
|
+
name: `${args.manifest.name || "venn-project"}-target`,
|
|
798
|
+
private: true,
|
|
799
|
+
type: "module",
|
|
800
|
+
dependencies: merged(all.flatMap((one) => one.dependencies)),
|
|
801
|
+
devDependencies: merged(all.flatMap((one) => one.devDependencies)),
|
|
802
|
+
...overrides(args.manifest.patch)
|
|
803
|
+
};
|
|
804
|
+
return `${JSON.stringify(body, null, 2)}\n`;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* The versions asked for, by name.
|
|
808
|
+
*
|
|
809
|
+
* A dependency on a path is not the package manager's business: it is another
|
|
810
|
+
* package in this workspace, resolved by the language, and handing it over as a
|
|
811
|
+
* version range would send the tool looking for it in the registry.
|
|
812
|
+
*/
|
|
813
|
+
function merged(deps) {
|
|
814
|
+
const out = {};
|
|
815
|
+
for (const dep of deps) {
|
|
816
|
+
if (dep.path !== void 0) continue;
|
|
817
|
+
out[dep.name] = dep.version ?? "*";
|
|
818
|
+
}
|
|
819
|
+
return sorted(out);
|
|
820
|
+
}
|
|
821
|
+
/** `[patch]`: the version this project insists on, wherever it is asked for. */
|
|
822
|
+
function overrides(patch) {
|
|
823
|
+
const found = merged(patch);
|
|
824
|
+
return Object.keys(found).length > 0 ? { overrides: found } : {};
|
|
825
|
+
}
|
|
826
|
+
/** Written in name order, so regenerating it produces no diff of its own. */
|
|
827
|
+
function sorted(entries) {
|
|
828
|
+
return Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)));
|
|
829
|
+
}
|
|
830
|
+
//#endregion
|
|
831
|
+
//#region src/npm/verify-lock.ts
|
|
832
|
+
/**
|
|
833
|
+
* Checks what is installed against what the lock says should be.
|
|
834
|
+
*
|
|
835
|
+
* This is what the hashes are for. Checked, they turn a registry that answered
|
|
836
|
+
* differently today, or a file edited by hand inside `node_modules`, into
|
|
837
|
+
* something a build refuses to start with rather than something found in
|
|
838
|
+
* production.
|
|
839
|
+
*
|
|
840
|
+
* @returns One `Drift` per difference, empty when the tree matches. A lock
|
|
841
|
+
* entry carrying no `integrity` is not checked for contents, so a lock written
|
|
842
|
+
* before hashes existed still passes.
|
|
843
|
+
*/
|
|
844
|
+
async function verifyLock(args) {
|
|
845
|
+
const installed = await readInstalled({
|
|
846
|
+
fs: args.fs,
|
|
847
|
+
root: args.root
|
|
848
|
+
});
|
|
849
|
+
const byName = new Map(installed.map((one) => [one.name, one]));
|
|
850
|
+
const drift = args.lock.packages.flatMap((locked) => compare(locked, byName.get(locked.name)));
|
|
851
|
+
const expected = new Set(args.lock.packages.map((one) => one.name));
|
|
852
|
+
const extra = installed.filter((one) => !expected.has(one.name));
|
|
853
|
+
return [...drift, ...extra.map((one) => ({
|
|
854
|
+
name: one.name,
|
|
855
|
+
kind: "unexpected"
|
|
856
|
+
}))];
|
|
857
|
+
}
|
|
858
|
+
function compare(locked, found) {
|
|
859
|
+
if (!found) return [{
|
|
860
|
+
name: locked.name,
|
|
861
|
+
kind: "missing",
|
|
862
|
+
expected: locked.version
|
|
863
|
+
}];
|
|
864
|
+
if (found.version !== locked.version) return [{
|
|
865
|
+
name: locked.name,
|
|
866
|
+
kind: "version",
|
|
867
|
+
expected: locked.version,
|
|
868
|
+
found: found.version
|
|
869
|
+
}];
|
|
870
|
+
if (locked.integrity && found.integrity !== locked.integrity) return [{
|
|
871
|
+
name: locked.name,
|
|
872
|
+
kind: "contents",
|
|
873
|
+
expected: locked.integrity
|
|
874
|
+
}];
|
|
875
|
+
return [];
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Renders drift for a person to read.
|
|
879
|
+
*
|
|
880
|
+
* @returns One indented line per package that differs, in the voice of what
|
|
881
|
+
* went wrong. Empty for an empty list.
|
|
882
|
+
*/
|
|
883
|
+
function describeDrift(drift) {
|
|
884
|
+
return drift.map(lineFor).join("\n");
|
|
885
|
+
}
|
|
886
|
+
const LINES = {
|
|
887
|
+
missing: (one) => ` ${one.name}@${one.expected} is in the lock but not installed`,
|
|
888
|
+
unexpected: (one) => ` ${one.name} is installed but not in the lock`,
|
|
889
|
+
version: (one) => ` ${one.name}: lock says ${one.expected}, installed is ${one.found}`,
|
|
890
|
+
contents: (one) => ` ${one.name}: installed files differ from what was locked`
|
|
891
|
+
};
|
|
892
|
+
function lineFor(one) {
|
|
893
|
+
return LINES[one.kind](one);
|
|
894
|
+
}
|
|
895
|
+
//#endregion
|
|
896
|
+
//#region src/scaffold/scaffold.ts
|
|
897
|
+
/**
|
|
898
|
+
* The files a new project starts as.
|
|
899
|
+
*
|
|
900
|
+
* Pure: it says what should exist and the caller writes it, so `--dry-run`, the
|
|
901
|
+
* tests and the disk all see one answer. What is generated is deliberately
|
|
902
|
+
* small, because a starting point that has to be deleted before it can be used
|
|
903
|
+
* is worse than one that is missing something.
|
|
904
|
+
*
|
|
905
|
+
* @returns The manifest, a source file for anything that is not a workspace,
|
|
906
|
+
* and a `.gitignore` unless a workspace above already owns one.
|
|
907
|
+
*/
|
|
908
|
+
function scaffold(request) {
|
|
909
|
+
const files = [{
|
|
910
|
+
path: "venn.toml",
|
|
911
|
+
content: manifestFor(request)
|
|
912
|
+
}];
|
|
913
|
+
if (request.kind !== "workspace") files.push(sourceFor(request));
|
|
914
|
+
if (!request.insideWorkspace) files.push(ignoreFile());
|
|
915
|
+
return files;
|
|
916
|
+
}
|
|
917
|
+
/** A member leaves out what it inherits; writing it down would only shadow it. */
|
|
918
|
+
function manifestFor(request) {
|
|
919
|
+
if (request.kind === "workspace") return workspaceManifest();
|
|
920
|
+
const version = request.insideWorkspace ? [] : ["version = \"0.1.0\""];
|
|
921
|
+
return [
|
|
922
|
+
"[package]",
|
|
923
|
+
`name = "${request.name}"`,
|
|
924
|
+
...version,
|
|
925
|
+
"",
|
|
926
|
+
"[dependencies]",
|
|
927
|
+
""
|
|
928
|
+
].join("\n");
|
|
929
|
+
}
|
|
930
|
+
function workspaceManifest() {
|
|
931
|
+
return [
|
|
932
|
+
"[workspace]",
|
|
933
|
+
"members = [\"packages/*\"]",
|
|
934
|
+
"",
|
|
935
|
+
"# Metadata a member inherits by leaving it out.",
|
|
936
|
+
"[workspace.package]",
|
|
937
|
+
"version = \"0.1.0\"",
|
|
938
|
+
"",
|
|
939
|
+
"# Versions a member takes with `dep = { workspace = true }`.",
|
|
940
|
+
"[workspace.dependencies]",
|
|
941
|
+
""
|
|
942
|
+
].join("\n");
|
|
943
|
+
}
|
|
944
|
+
/** A library exports; a program runs. Each starts as the smallest of itself. */
|
|
945
|
+
function sourceFor(request) {
|
|
946
|
+
if (request.kind === "lib") return {
|
|
947
|
+
path: "src/lib.vn",
|
|
948
|
+
content: [
|
|
949
|
+
"pub fn greet(name: string) -> string {",
|
|
950
|
+
" \"Hello, ${name}\"",
|
|
951
|
+
"}",
|
|
952
|
+
""
|
|
953
|
+
].join("\n")
|
|
954
|
+
};
|
|
955
|
+
return {
|
|
956
|
+
path: "src/main.vn",
|
|
957
|
+
content: `print "Hello from ${request.name}"\n`
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
/** Everything derived lives in one directory, so ignoring it is one line. */
|
|
961
|
+
function ignoreFile() {
|
|
962
|
+
return {
|
|
963
|
+
path: ".gitignore",
|
|
964
|
+
content: `${TARGET_DIR}/\n`
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
//#endregion
|
|
968
|
+
export { LOCK_FILE, LOCK_VERSION, MANIFEST_FILE, RECORD_FILE, TARGET_DIR, TARGET_LAYOUT, ancestors, baseName, conventionalTargets, describeDrift, expandMembers, findProject, hashPackage, inherit, isInside, isSafeSpec, join, loadPackage, managerCommand, matchesMember, memberDirs, modulesDir, nativeModulesDir, normalise, outputDir, packageJsonFor, parentOf, readInstalled, readLockfile, readManifest, reanchor, relativeTo, scaffold, targetDir, verifyLock, writeBuildRecord, writeLockfile };
|
|
969
|
+
|
|
970
|
+
//# sourceMappingURL=index.js.map
|