@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.
@@ -0,0 +1,55 @@
1
+ import type { BuildTarget, Manifest } from "@venn-lang/contracts";
2
+
3
+ /**
4
+ * A package on disk: one `venn.toml`, and what it turned out to describe.
5
+ *
6
+ * `targets` is what the manifest declared plus what the conventions filled in.
7
+ * The difference between the two matters to whoever writes a manifest and to
8
+ * nobody downstream, so it is settled here rather than carried further.
9
+ */
10
+ export interface Package {
11
+ /** The directory holding the `venn.toml`, with no trailing separator. */
12
+ dir: string;
13
+ manifest: Manifest;
14
+ targets: readonly BuildTarget[];
15
+ }
16
+
17
+ /**
18
+ * What a command is acting on: one root, and every package under it.
19
+ *
20
+ * A lone package is a workspace of one, so nothing downstream has to ask which
21
+ * it is. `isWorkspace` is for the two places that genuinely differ: where
22
+ * `target/` goes, and what a bare command means.
23
+ */
24
+ export interface Project {
25
+ /** The workspace root, or the package's own directory when there is none. */
26
+ root: string;
27
+ isWorkspace: boolean;
28
+ /**
29
+ * The manifest at the root, carried whether or not the root is a package.
30
+ *
31
+ * A workspace root often declares no `[package]` at all and still carries the
32
+ * `[env]` tables and path aliases everything under it uses, so it cannot be
33
+ * inferred from the member list.
34
+ */
35
+ rootManifest: Manifest;
36
+ /** Every member, in the order the globs found them. A lone package is one. */
37
+ packages: readonly Package[];
38
+ /** What a command with no target acts on: `default-members`, or all of them. */
39
+ defaultPackages: readonly Package[];
40
+ }
41
+
42
+ /** Reading a project failed, and why. Never a raw error from the disk. */
43
+ export interface ProjectProblem {
44
+ code: string;
45
+ title: string;
46
+ /** The path the problem is about, when there is one. */
47
+ path?: string;
48
+ }
49
+
50
+ /** The result of a lookup: the project, or why there is none. */
51
+ export interface FoundProject {
52
+ /** Absent when no manifest was found, in which case `problems` says so. */
53
+ project?: Project;
54
+ problems: readonly ProjectProblem[];
55
+ }
@@ -0,0 +1,65 @@
1
+ import type { FileSystem } from "@venn-lang/contracts";
2
+ import { join } from "../paths/index.js";
3
+
4
+ /** Directories inside a package that are not part of it. */
5
+ const SKIP = new Set(["node_modules", ".git", ".bin"]);
6
+
7
+ /**
8
+ * A hash of everything a package installed.
9
+ *
10
+ * Not the registry's integrity hash, which lives in each manager's own lock in
11
+ * each manager's format. This is computed from what landed on disk, which every
12
+ * manager produces the same way. The guarantee differs: the registry's hash
13
+ * says "this is the tarball that was served", this one says "this is the tree
14
+ * that was installed when the lock was written". Trust on first use, and any
15
+ * divergence after that is caught.
16
+ *
17
+ * A hash of hashes rather than of the concatenated bytes, because a package can
18
+ * hold tens of megabytes and digesting it in one buffer costs more than the
19
+ * answer is worth.
20
+ *
21
+ * @param args.dir The installed package directory.
22
+ * @returns `sha256-…` over every file under `dir`, sorted by path, with
23
+ * `node_modules`, `.git` and `.bin` skipped. A file that cannot be read is left
24
+ * out rather than raising.
25
+ */
26
+ export async function hashPackage(args: { fs: FileSystem; dir: string }): Promise<string> {
27
+ const files = (await filesUnder(args.fs, args.dir, "")).sort();
28
+ const lines: string[] = [];
29
+ for (const path of files) {
30
+ // Deliberately unguarded: the walk above already found this file, so a read
31
+ // that fails is a real fault. Skipping it would hash a subset of the tree
32
+ // and call it the tree, which is the one answer this function must not give.
33
+ const bytes = await args.fs.read(join(args.dir, path));
34
+ lines.push(`${path}\t${await digest(bytes)}`);
35
+ }
36
+ return `sha256-${await digest(new TextEncoder().encode(lines.join("\n")))}`;
37
+ }
38
+
39
+ async function filesUnder(fs: FileSystem, root: string, at: string): Promise<string[]> {
40
+ const found: string[] = [];
41
+ for (const entry of await fs.list(join(root, at))) {
42
+ const path = at === "" ? entry.name : `${at}/${entry.name}`;
43
+ if (!entry.directory) found.push(path);
44
+ else if (!SKIP.has(entry.name)) found.push(...(await filesUnder(fs, root, path)));
45
+ }
46
+ return found;
47
+ }
48
+
49
+ /**
50
+ * Web Crypto, so this runs wherever the rest of this package runs.
51
+ *
52
+ * The assertion narrows which buffer backs the array rather than claiming the
53
+ * array is a buffer: `BufferSource` wants a view onto an `ArrayBuffer`, and
54
+ * these come from a file read and from `TextEncoder`, both of which give one.
55
+ */
56
+ async function digest(bytes: Uint8Array): Promise<string> {
57
+ const hashed = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>);
58
+ return base64(new Uint8Array(hashed));
59
+ }
60
+
61
+ function base64(bytes: Uint8Array): string {
62
+ let binary = "";
63
+ for (const byte of bytes) binary += String.fromCharCode(byte);
64
+ return btoa(binary);
65
+ }
@@ -0,0 +1,12 @@
1
+ export { hashPackage } from "./hash-package.js";
2
+ export { readLockfile, writeLockfile } from "./lockfile.js";
3
+ export { LOCK_FILE, LOCK_VERSION, type LockedPackage, type Lockfile } from "./lockfile.types.js";
4
+ export {
5
+ isSafeSpec,
6
+ type ManagerCommand,
7
+ managerCommand,
8
+ type ProxiedVerb,
9
+ } from "./manager-command.js";
10
+ export { packageJsonFor } from "./package-json.js";
11
+ export { readInstalled } from "./read-installed.js";
12
+ export { type Drift, describeDrift, verifyLock } from "./verify-lock.js";
@@ -0,0 +1,49 @@
1
+ import type { FileSystem } from "@venn-lang/contracts";
2
+ import { join } from "../paths/index.js";
3
+ import { LOCK_FILE, LOCK_VERSION, type Lockfile } from "./lockfile.types.js";
4
+ import { readInstalled } from "./read-installed.js";
5
+
6
+ /**
7
+ * Writes `venn.lock` from what is installed.
8
+ *
9
+ * It goes at the project root, beside the manifest, and is meant to be
10
+ * committed. The manager's own lock stays inside `target/`, which is derived
11
+ * and thrown away.
12
+ *
13
+ * @param args.manager Which tool did the resolving, recorded in the file.
14
+ * @returns The lock as written.
15
+ * @throws Whatever the file system raises when the write fails.
16
+ */
17
+ export async function writeLockfile(args: {
18
+ fs: FileSystem;
19
+ root: string;
20
+ manager: string;
21
+ }): Promise<Lockfile> {
22
+ const lock: Lockfile = {
23
+ version: LOCK_VERSION,
24
+ manager: args.manager,
25
+ packages: await readInstalled({ fs: args.fs, root: args.root }),
26
+ };
27
+ const text = `${JSON.stringify(lock, null, 2)}\n`;
28
+ await args.fs.write(join(args.root, LOCK_FILE), new TextEncoder().encode(text));
29
+ return lock;
30
+ }
31
+
32
+ /**
33
+ * Reads the lock this project committed.
34
+ *
35
+ * @returns The lock, or `undefined` when the file is absent or will not parse.
36
+ * A lock nobody can read is a project without one, which `install` can fix.
37
+ */
38
+ export async function readLockfile(args: {
39
+ fs: FileSystem;
40
+ root: string;
41
+ }): Promise<Lockfile | undefined> {
42
+ const bytes = await args.fs.read(join(args.root, LOCK_FILE)).catch(() => undefined);
43
+ if (!bytes) return undefined;
44
+ try {
45
+ return JSON.parse(new TextDecoder().decode(bytes)) as Lockfile;
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ }
@@ -0,0 +1,42 @@
1
+ /** One package that ended up installed. */
2
+ export interface LockedPackage {
3
+ name: string;
4
+ version: string;
5
+ /**
6
+ * `sha256-…` over the files this package installed.
7
+ *
8
+ * Not the registry's integrity hash, which lives in each manager's own lock
9
+ * in each manager's format. This is computed from what landed on disk and
10
+ * catches any divergence from the moment the lock was written. Absent in a
11
+ * lock written before hashes existed. See `hashPackage`.
12
+ */
13
+ integrity?: string;
14
+ /** What it says it needs, so the graph can be read without the disk. */
15
+ dependencies?: Record<string, string>;
16
+ }
17
+
18
+ /**
19
+ * `venn.lock`: what this project resolved to, in a form no tool owns.
20
+ *
21
+ * Read from what is actually installed rather than translated out of whichever
22
+ * manager's lock produced it, because tracking three formats that change
23
+ * without asking would mean two machines building different things the day the
24
+ * translation drifts.
25
+ *
26
+ * It pins the exact version of every package and a hash of the files each one
27
+ * installed, so `venn install --frozen` can refuse a tree that has drifted,
28
+ * whether a registry answered differently or something was changed by hand.
29
+ */
30
+ export interface Lockfile {
31
+ version: 1;
32
+ /** Which tool did the resolving. The answer can differ between them. */
33
+ manager: string;
34
+ /** Every package installed, in name order. */
35
+ packages: readonly LockedPackage[];
36
+ }
37
+
38
+ /** The lock file's name, at the project root beside the manifest. */
39
+ export const LOCK_FILE = "venn.lock";
40
+
41
+ /** The format version written into every lock. */
42
+ export const LOCK_VERSION = 1;
@@ -0,0 +1,85 @@
1
+ import type { PackageManagerName } from "@venn-lang/contracts";
2
+
3
+ /** The four verbs Venn proxies to whichever package manager the project chose. */
4
+ export type ProxiedVerb = "add" | "remove" | "update" | "install";
5
+
6
+ /** A package manager invocation, ready to spawn. */
7
+ export interface ManagerCommand {
8
+ command: string;
9
+ args: readonly string[];
10
+ /**
11
+ * Whether it has to go through a shell.
12
+ *
13
+ * Every one of these managers ships as a script, and on Windows that means a
14
+ * `.cmd`, which Node refuses to spawn directly because `.cmd` files were used
15
+ * to slip arguments past programs that believed they were spawning an
16
+ * executable. So a shell it is, and because a shell re-reads what it is
17
+ * handed, what goes in is checked first. See {@link isSafeSpec}.
18
+ */
19
+ shell: boolean;
20
+ }
21
+
22
+ /**
23
+ * The command a verb becomes, in the manager this project chose.
24
+ *
25
+ * The interface is ours, the resolution is not: working out which versions
26
+ * satisfy which ranges is years of work against three moving targets, so the
27
+ * four verbs are spelled once here and handed to the tool the project named.
28
+ *
29
+ * @param args.packages Specifiers to act on. Pass each through
30
+ * {@link isSafeSpec} first when `shell` comes back `true`.
31
+ * @returns The command, its arguments, and whether a shell is required.
32
+ */
33
+ export function managerCommand(args: {
34
+ manager: PackageManagerName;
35
+ verb: ProxiedVerb;
36
+ packages?: readonly string[];
37
+ dev?: boolean;
38
+ /** `process.platform`. It decides whether a shell is needed. */
39
+ platform: string;
40
+ }): ManagerCommand {
41
+ // `-D` means "a development dependency" in every one of them.
42
+ const dev = args.dev ? ["-D"] : [];
43
+ return {
44
+ command: args.manager,
45
+ args: [verbFor(args), ...dev, ...(args.packages ?? [])],
46
+ shell: args.platform === "win32",
47
+ };
48
+ }
49
+
50
+ /** npm spells `add` and `remove` its own way; every other manager agrees. */
51
+ function verbFor(args: { manager: PackageManagerName; verb: ProxiedVerb }): string {
52
+ return args.manager === "npm" ? NPM[args.verb] : NON_NPM[args.verb];
53
+ }
54
+
55
+ const NON_NPM: Record<ProxiedVerb, string> = {
56
+ add: "add",
57
+ remove: "remove",
58
+ update: "update",
59
+ install: "install",
60
+ };
61
+
62
+ const NPM: Record<ProxiedVerb, string> = {
63
+ add: "install",
64
+ remove: "uninstall",
65
+ update: "update",
66
+ install: "install",
67
+ };
68
+
69
+ /** A package name, optionally scoped, optionally with a version range. */
70
+ const SPEC = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[A-Za-z0-9.^~><=*-]+)?$/;
71
+
72
+ /**
73
+ * Whether a string is a package specifier and nothing else.
74
+ *
75
+ * The command goes through a shell on Windows, and a shell reads `&`, `|` and
76
+ * `>` as instructions rather than text, so `venn add "x & del /q ."` would
77
+ * delete a directory while looking like an install. A real package name holds
78
+ * none of those characters, so checking costs nothing and closes the hole
79
+ * instead of documenting it.
80
+ *
81
+ * @returns `true` when the string is safe to hand to a shell.
82
+ */
83
+ export function isSafeSpec(spec: string): boolean {
84
+ return SPEC.test(spec);
85
+ }
@@ -0,0 +1,59 @@
1
+ import type { Dependency, Manifest } from "@venn-lang/contracts";
2
+
3
+ /**
4
+ * The `package.json` a package manager is shown, generated from `venn.toml`.
5
+ *
6
+ * Nobody edits it and nobody commits it: the manifest is the source, this is
7
+ * what the tool underneath happens to read. It is written into `target/`, and
8
+ * that placement is the whole trick: a manager writes `node_modules` beside the
9
+ * `package.json` it was pointed at, so the modules land in
10
+ * `target/node_modules` with nothing fighting anything.
11
+ *
12
+ * @param args.members Their dependencies are merged in too, when the root is a
13
+ * workspace.
14
+ * @returns The file's text, marked private, with path dependencies left out and
15
+ * `[patch]` turned into `overrides`.
16
+ */
17
+ export function packageJsonFor(args: {
18
+ manifest: Manifest;
19
+ /** Every member's dependencies as well, when the root is a workspace. */
20
+ members?: readonly Manifest[];
21
+ }): string {
22
+ const all = [args.manifest, ...(args.members ?? [])];
23
+ const body = {
24
+ name: `${args.manifest.name || "venn-project"}-target`,
25
+ private: true,
26
+ type: "module",
27
+ dependencies: merged(all.flatMap((one) => one.dependencies)),
28
+ devDependencies: merged(all.flatMap((one) => one.devDependencies)),
29
+ ...overrides(args.manifest.patch),
30
+ };
31
+ return `${JSON.stringify(body, null, 2)}\n`;
32
+ }
33
+
34
+ /**
35
+ * The versions asked for, by name.
36
+ *
37
+ * A dependency on a path is not the package manager's business: it is another
38
+ * package in this workspace, resolved by the language, and handing it over as a
39
+ * version range would send the tool looking for it in the registry.
40
+ */
41
+ function merged(deps: readonly Dependency[]): Record<string, string> {
42
+ const out: Record<string, string> = {};
43
+ for (const dep of deps) {
44
+ if (dep.path !== undefined) continue;
45
+ out[dep.name] = dep.version ?? "*";
46
+ }
47
+ return sorted(out);
48
+ }
49
+
50
+ /** `[patch]`: the version this project insists on, wherever it is asked for. */
51
+ function overrides(patch: readonly Dependency[]): { overrides?: Record<string, string> } {
52
+ const found = merged(patch);
53
+ return Object.keys(found).length > 0 ? { overrides: found } : {};
54
+ }
55
+
56
+ /** Written in name order, so regenerating it produces no diff of its own. */
57
+ function sorted(entries: Record<string, string>): Record<string, string> {
58
+ return Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)));
59
+ }
@@ -0,0 +1,72 @@
1
+ import type { FileSystem } from "@venn-lang/contracts";
2
+ import { join } from "../paths/index.js";
3
+ import { modulesDir } from "../target/index.js";
4
+ import { hashPackage } from "./hash-package.js";
5
+ import type { LockedPackage } from "./lockfile.types.js";
6
+
7
+ /**
8
+ * Every package installed, read from `target/node_modules`.
9
+ *
10
+ * The installed tree is the strongest statement there is about what a project
11
+ * resolved to: it is what will actually be imported, whichever tool put it
12
+ * there and whatever that tool's own lock says. One walk, the same for every
13
+ * manager. A scope is a directory of packages rather than a package, so
14
+ * `@types/node` is found one level further down.
15
+ *
16
+ * @param args.root The project root, not the modules directory.
17
+ * @returns Every package with its version and content hash, in name order.
18
+ * Dot-directories and anything without a readable `package.json` are skipped,
19
+ * because a directory that is not a package is not a package.
20
+ */
21
+ export async function readInstalled(args: {
22
+ fs: FileSystem;
23
+ root: string;
24
+ }): Promise<LockedPackage[]> {
25
+ const modules = modulesDir(args.root);
26
+ const found: LockedPackage[] = [];
27
+ for (const entry of await args.fs.list(modules)) {
28
+ if (!entry.directory || entry.name.startsWith(".")) continue;
29
+ if (entry.name.startsWith("@")) found.push(...(await scoped(args.fs, modules, entry.name)));
30
+ else pushIf(found, await readPackage(args.fs, join(modules, entry.name)));
31
+ }
32
+ return found.sort((a, b) => a.name.localeCompare(b.name));
33
+ }
34
+
35
+ async function scoped(fs: FileSystem, modules: string, scope: string): Promise<LockedPackage[]> {
36
+ const found: LockedPackage[] = [];
37
+ for (const entry of await fs.list(join(modules, scope))) {
38
+ if (entry.directory) pushIf(found, await readPackage(fs, join(modules, scope, entry.name)));
39
+ }
40
+ return found;
41
+ }
42
+
43
+ function pushIf(into: LockedPackage[], one: LockedPackage | undefined): void {
44
+ if (one) into.push(one);
45
+ }
46
+
47
+ async function readPackage(fs: FileSystem, dir: string): Promise<LockedPackage | undefined> {
48
+ const bytes = await fs.read(join(dir, "package.json")).catch(() => undefined);
49
+ if (!bytes) return undefined;
50
+ const data = parse(new TextDecoder().decode(bytes));
51
+ if (typeof data?.name !== "string" || typeof data.version !== "string") return undefined;
52
+ return {
53
+ name: data.name,
54
+ version: data.version,
55
+ integrity: await hashPackage({ fs, dir }),
56
+ ...deps(data.dependencies),
57
+ };
58
+ }
59
+
60
+ function deps(value: unknown): { dependencies?: Record<string, string> } {
61
+ if (typeof value !== "object" || value === null) return {};
62
+ const entries = Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, String(v)]);
63
+ return entries.length > 0 ? { dependencies: Object.fromEntries(entries) } : {};
64
+ }
65
+
66
+ function parse(text: string): Record<string, unknown> | undefined {
67
+ try {
68
+ return JSON.parse(text) as Record<string, unknown>;
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
@@ -0,0 +1,78 @@
1
+ import type { FileSystem } from "@venn-lang/contracts";
2
+ import type { Lockfile } from "./lockfile.types.js";
3
+ import { readInstalled } from "./read-installed.js";
4
+
5
+ /** How one package differs from what the lock recorded. */
6
+ export interface Drift {
7
+ name: string;
8
+ /**
9
+ * In the lock but absent, installed but not locked, a different version, or
10
+ * the same version with different files.
11
+ */
12
+ kind: "missing" | "unexpected" | "version" | "contents";
13
+ /** What the lock recorded: a version, or an `integrity` hash. */
14
+ expected?: string;
15
+ /** What is on disk, when the two can be compared side by side. */
16
+ found?: string;
17
+ }
18
+
19
+ /**
20
+ * Checks what is installed against what the lock says should be.
21
+ *
22
+ * This is what the hashes are for. Checked, they turn a registry that answered
23
+ * differently today, or a file edited by hand inside `node_modules`, into
24
+ * something a build refuses to start with rather than something found in
25
+ * production.
26
+ *
27
+ * @returns One `Drift` per difference, empty when the tree matches. A lock
28
+ * entry carrying no `integrity` is not checked for contents, so a lock written
29
+ * before hashes existed still passes.
30
+ */
31
+ export async function verifyLock(args: {
32
+ fs: FileSystem;
33
+ root: string;
34
+ lock: Lockfile;
35
+ }): Promise<Drift[]> {
36
+ const installed = await readInstalled({ fs: args.fs, root: args.root });
37
+ const byName = new Map(installed.map((one) => [one.name, one]));
38
+ const drift = args.lock.packages.flatMap((locked) => compare(locked, byName.get(locked.name)));
39
+ const expected = new Set(args.lock.packages.map((one) => one.name));
40
+ const extra = installed.filter((one) => !expected.has(one.name));
41
+ return [...drift, ...extra.map((one) => ({ name: one.name, kind: "unexpected" as const }))];
42
+ }
43
+
44
+ type Locked = Lockfile["packages"][number];
45
+
46
+ function compare(locked: Locked, found: Locked | undefined): Drift[] {
47
+ if (!found) return [{ name: locked.name, kind: "missing", expected: locked.version }];
48
+ if (found.version !== locked.version) {
49
+ return [{ name: locked.name, kind: "version", expected: locked.version, found: found.version }];
50
+ }
51
+ // Only when the lock carried one. A lock written before hashes existed is
52
+ // still a lock, and refusing it would break a project for being older.
53
+ if (locked.integrity && found.integrity !== locked.integrity) {
54
+ return [{ name: locked.name, kind: "contents", expected: locked.integrity }];
55
+ }
56
+ return [];
57
+ }
58
+
59
+ /**
60
+ * Renders drift for a person to read.
61
+ *
62
+ * @returns One indented line per package that differs, in the voice of what
63
+ * went wrong. Empty for an empty list.
64
+ */
65
+ export function describeDrift(drift: readonly Drift[]): string {
66
+ return drift.map(lineFor).join("\n");
67
+ }
68
+
69
+ const LINES: Record<Drift["kind"], (drift: Drift) => string> = {
70
+ missing: (one) => ` ${one.name}@${one.expected} is in the lock but not installed`,
71
+ unexpected: (one) => ` ${one.name} is installed but not in the lock`,
72
+ version: (one) => ` ${one.name}: lock says ${one.expected}, installed is ${one.found}`,
73
+ contents: (one) => ` ${one.name}: installed files differ from what was locked`,
74
+ };
75
+
76
+ function lineFor(one: Drift): string {
77
+ return LINES[one.kind](one);
78
+ }
@@ -0,0 +1,10 @@
1
+ export {
2
+ ancestors,
3
+ baseName,
4
+ isInside,
5
+ join,
6
+ normalise,
7
+ parentOf,
8
+ reanchor,
9
+ relativeTo,
10
+ } from "./paths.js";
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Path arithmetic on manifest paths, with no `node:path`.
3
+ *
4
+ * This package runs in a Web Worker as well as in the CLI, so paths are handled
5
+ * as text with forward slashes. A Windows path arrives with backslashes and is
6
+ * normalised on the way in, which is the one place the difference is allowed to
7
+ * exist.
8
+ */
9
+
10
+ /**
11
+ * One path, written the one way the rest of this package understands.
12
+ *
13
+ * @returns The path with backslashes turned into forward slashes, runs of
14
+ * separators collapsed, and any trailing separator dropped. `"/"` stays `"/"`.
15
+ */
16
+ export function normalise(path: string): string {
17
+ const flat = path.replaceAll("\\", "/").replace(/\/+/g, "/");
18
+ return flat.length > 1 ? flat.replace(/\/$/, "") : flat;
19
+ }
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
+ export function join(...parts: readonly string[]): string {
28
+ const joined = parts.filter((part) => part !== "").join("/");
29
+ return normalise(joined);
30
+ }
31
+
32
+ /**
33
+ * The directory holding this path.
34
+ *
35
+ * @returns The parent, or `undefined` when there is nothing above. A relative
36
+ * path with one segment has the empty string as its parent, which is the
37
+ * directory it was written against; stopping short of it would leave a walk up
38
+ * from `packages/api` never reaching the workspace root beside it.
39
+ */
40
+ export function parentOf(path: string): string | undefined {
41
+ const flat = normalise(path);
42
+ if (flat === "" || flat === "/") return undefined;
43
+ const slash = flat.lastIndexOf("/");
44
+ if (slash < 0) return "";
45
+ return slash === 0 ? "/" : flat.slice(0, slash);
46
+ }
47
+
48
+ /** The last segment of a path, which is the file or directory's own name. */
49
+ export function baseName(path: string): string {
50
+ const flat = normalise(path);
51
+ return flat.slice(flat.lastIndexOf("/") + 1);
52
+ }
53
+
54
+ /**
55
+ * Every directory from `path` up to the top, `path` itself first.
56
+ *
57
+ * @returns The chain, ending in `""` for a relative path and `"/"` for an
58
+ * absolute one.
59
+ */
60
+ export function ancestors(path: string): string[] {
61
+ const found: string[] = [];
62
+ // Tested against undefined, not for truth: the top of a relative walk is the
63
+ // empty string, which is falsy but is still a directory to look in.
64
+ for (let at: string | undefined = normalise(path); at !== undefined; at = parentOf(at)) {
65
+ found.push(at);
66
+ }
67
+ return found;
68
+ }
69
+
70
+ /** Whether `path` is `base` or sits inside it. */
71
+ export function isInside(path: string, base: string): boolean {
72
+ const one = normalise(path);
73
+ const other = normalise(base);
74
+ return one === other || one.startsWith(`${other}/`);
75
+ }
76
+
77
+ /** `path` written relative to `base`, or the path itself when it is not inside. */
78
+ export function relativeTo(path: string, base: string): string {
79
+ const one = normalise(path);
80
+ const other = normalise(base);
81
+ return one.startsWith(`${other}/`) ? one.slice(other.length + 1) : one;
82
+ }
83
+
84
+ /**
85
+ * A relative path written in one directory, rewritten to mean the same thing
86
+ * from another inside it.
87
+ *
88
+ * A workspace root writes `"#shared" = "./shared"` once and every member uses
89
+ * it, but a member reads it from further down, where `./shared` is somewhere
90
+ * else entirely.
91
+ *
92
+ * @param args.declaredIn Where the path was written.
93
+ * @param args.usedIn Where it will be read.
94
+ * @returns The path with one `../` per directory of depth. An absolute path is
95
+ * returned unchanged: it already means the same thing everywhere.
96
+ */
97
+ export function reanchor(args: { path: string; declaredIn: string; usedIn: string }): string {
98
+ if (!args.path.startsWith(".")) return args.path;
99
+ const down = relativeTo(args.usedIn, args.declaredIn);
100
+ const depth = down === normalise(args.usedIn) ? 0 : down.split("/").filter(Boolean).length;
101
+ if (depth === 0) return args.path;
102
+ return `${"../".repeat(depth)}${args.path.replace(/^\.\//, "")}`;
103
+ }
@@ -0,0 +1,2 @@
1
+ export { scaffold } from "./scaffold.js";
2
+ export type { ScaffoldFile, ScaffoldKind, ScaffoldRequest } from "./scaffold.types.js";