@inthedark/wb 0.0.7 → 0.0.9
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/README.md +48 -18
- package/WorkbenchArcTree.d.mts +71 -0
- package/WorkbenchArcTree.mjs +185 -0
- package/WorkbenchBootstrap.mjs +21 -45
- package/WorkbenchBootstrapCommand.mjs +39 -4
- package/WorkbenchInstallationRecord.d.mts +18 -0
- package/WorkbenchInstallationRecord.mjs +57 -0
- package/arc-tree-manifest.mjs +41 -0
- package/arc-tree-preload.mjs +160 -0
- package/index.d.ts +20 -3
- package/index.mjs +11 -2
- package/package.json +10 -2
- package/wb +4 -1
- package/workbench-daemon.d.mts +38 -0
- package/workbench-daemon.mjs +76 -0
package/README.md
CHANGED
|
@@ -21,34 +21,63 @@ npx @inthedark/wb
|
|
|
21
21
|
npx @inthedark/wb connect
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
## philosophies
|
|
25
|
+
|
|
26
|
+
- what the human says goes
|
|
27
|
+
- harnesses should own agent mistake prevention via mechanical tools. make mistakes impossible or difficult to walk into
|
|
28
|
+
- for projects sandboxes are the right approach
|
|
29
|
+
- allow the user to do anything in minimal clicks
|
|
30
|
+
- reinventing the wheel is worth it way more than in the past
|
|
31
|
+
|
|
24
32
|
## features
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
selling points first:
|
|
35
|
+
|
|
36
|
+
#### first class shared workspace support
|
|
37
|
+
a lot of work is not worktree->pr shaped; agents claim the files they need and other agents don't freak out about workspace dirt, they don't *see* it. they queue up behind each other for their claims. even has a git view that helps you easily commit unclaimed dirt
|
|
38
|
+
|
|
39
|
+
#### fully customisable instructions
|
|
40
|
+
base instructions get improved constantly; but YOUR CONTROL is important. you can override it piecemeal or in entirety, *with* support for comments & imports
|
|
41
|
+
|
|
42
|
+
#### consolidated tool & sandbox behaviour across harnesses
|
|
43
|
+
codex, opencode 2, and claude code agents all have workbench tools and only the most basic of their native tools left like edit tools
|
|
44
|
+
|
|
45
|
+
#### customisable voice to text
|
|
46
|
+
what if voice to text was like telling someone what to do in a rich text editor
|
|
38
47
|
|
|
39
|
-
|
|
48
|
+
#### webdev mockups using real project components & css
|
|
49
|
+
configure build+css scripts to handle the agent's created tsx
|
|
50
|
+
|
|
51
|
+
#### .env secrets
|
|
52
|
+
store secrets in ui, reference from .env, see dedicated docs below
|
|
53
|
+
|
|
54
|
+
#### agents can search repos with comfy shell fs tools
|
|
55
|
+
vfs-powered, so doesn't require the repo being on disk entirely
|
|
56
|
+
|
|
57
|
+
### ux
|
|
58
|
+
- thread status inbox, snooze threads while working to make them hide until done
|
|
59
|
+
- human readable displays for common shell commands
|
|
60
|
+
- near-instant loads
|
|
61
|
+
- smooth per-token animations, or animate in whole blocks if you prefer
|
|
62
|
+
|
|
63
|
+
### obligatory
|
|
40
64
|
- built on top of other harnesses (codex, opencode 2, claude code) so you can use subscriptions
|
|
65
|
+
- cross-harness subagents
|
|
66
|
+
- browser use
|
|
67
|
+
- saved profiles for harness/model/effort/window/fast mode/agent identity
|
|
68
|
+
- mcp servers (agents must ask to enable tools by default)
|
|
69
|
+
|
|
70
|
+
### misc
|
|
71
|
+
- automatic compaction on turn start past configurable time & token thresholds (reduce cache write costs)
|
|
41
72
|
- tailscale integration
|
|
42
73
|
- daemon sleeps when idle, leaving only a light http wake service up
|
|
43
74
|
|
|
44
|
-
unpolished/unfinished/broken stuff (coming soon tm!!!!!)
|
|
75
|
+
### unpolished/unfinished/broken stuff (coming soon tm!!!!!)
|
|
45
76
|
- soon, autocomplete/suggestions extension for vscode & similar (highly configurable, able to be FAST)
|
|
46
77
|
- worktree->pr workflow is unsupported (gonna be honest i have been in a cave working solo on wb for so long that i have not needed this yet)
|
|
47
|
-
- browse AX & UX
|
|
48
78
|
- search UX
|
|
49
79
|
- i'd like more git ui
|
|
50
|
-
-
|
|
51
|
-
- the codebase itself could use a polish pass, esp. the react & tailwind
|
|
80
|
+
- the codebase's refactors from its (very) humble beginnings are not complete
|
|
52
81
|
|
|
53
82
|
## tech
|
|
54
83
|
|
|
@@ -58,7 +87,8 @@ note: have tried to keep dependencies minimal for the most part
|
|
|
58
87
|
`/daemon/host` — wake service
|
|
59
88
|
`/daemon/server` — workbench harness
|
|
60
89
|
`/daemon/voice` — vtt service (rust)
|
|
61
|
-
`/daemon/repo` — vfs service (go)
|
|
90
|
+
`/daemon/repo` — vfs service (go)
|
|
91
|
+
`/daemon/relay` — linux sandbox loopback relay (go)
|
|
62
92
|
`/integration` — integrations in third party software
|
|
63
93
|
`/instructions` — instructions for agents, copied into the user's `~/.workbench` folder whenever read
|
|
64
94
|
`/app/server` — server for frontend app
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - WorkbenchArcTreeOptions: cwd, hold-own, environment, fetch, cancellation and warning boundaries for loading.
|
|
4
|
+
* - WorkbenchArcTreeRepository: one repository's daemon view: tree, HEAD and other threads' held paths.
|
|
5
|
+
* - WorkbenchArcTreeNodeEnvironmentOptions: base environment and manifest parent directory for Node children.
|
|
6
|
+
* - default WorkbenchArcTree: the worktree as the calling agent thread builds it, readable in process and by Node
|
|
7
|
+
* children; the plain filesystem outside agent threads or when the daemon view fails.
|
|
8
|
+
*/
|
|
9
|
+
export interface WorkbenchArcTreeOptions {
|
|
10
|
+
/** Directory whose repositories to view; relative reads resolve from it. Defaults to `process.cwd()`. */
|
|
11
|
+
cwd?: string;
|
|
12
|
+
/** Hold the caller's own dirty claims back too, for a matched "before" build. */
|
|
13
|
+
holdOwn?: boolean;
|
|
14
|
+
/** Agent identity and data root come from here. Defaults to `process.env`. */
|
|
15
|
+
env?: NodeJS.ProcessEnv;
|
|
16
|
+
fetch?: typeof globalThis.fetch;
|
|
17
|
+
/** Aborting stops the daemon request and Git reads, and rejects instead of falling back. */
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
/** Fallback and dropped-entry notices. Silent by default. */
|
|
20
|
+
warn?: (message: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WorkbenchArcTreeRepository {
|
|
24
|
+
/** Absolute repository root. */
|
|
25
|
+
root: string;
|
|
26
|
+
head: string | null;
|
|
27
|
+
/** Git tree of the worktree as the caller builds it. */
|
|
28
|
+
tree: string;
|
|
29
|
+
/** Repository-relative paths each other thread holds. */
|
|
30
|
+
held: ReadonlyArray<{ threadId: string; paths: readonly string[] }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface WorkbenchArcTreeNodeEnvironmentOptions {
|
|
34
|
+
/** `NODE_OPTIONS` is appended to, not replaced. Defaults to `process.env`. */
|
|
35
|
+
env?: NodeJS.ProcessEnv;
|
|
36
|
+
/** Parent of the manifest directory, relative to `cwd`. Defaults to `node_modules/.cache/wb-arc-tree`. Only the first call's value applies. */
|
|
37
|
+
directory?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default class WorkbenchArcTree implements AsyncDisposable {
|
|
41
|
+
private constructor();
|
|
42
|
+
/**
|
|
43
|
+
* Outside agent threads, the plain filesystem without asking the daemon. A daemon or Git failure warns and also
|
|
44
|
+
* yields the plain filesystem, so callers still run, visibly against the live worktree. Never rejects for
|
|
45
|
+
* unavailability; only `signal` aborts reject.
|
|
46
|
+
*/
|
|
47
|
+
static load(options?: WorkbenchArcTreeOptions): Promise<WorkbenchArcTree>;
|
|
48
|
+
/** A tree holding `files` (paths relative to `cwd`; null where the tree lacks them) without asking the daemon. */
|
|
49
|
+
static fromFiles(cwd: string, files: ReadonlyMap<string, Buffer | null>, repositories?: readonly WorkbenchArcTreeRepository[]): WorkbenchArcTree;
|
|
50
|
+
|
|
51
|
+
readonly source: "arc" | "filesystem";
|
|
52
|
+
readonly cwd: string;
|
|
53
|
+
/** Absolute held paths mapped to arc tree bytes, or null where the arc tree lacks them. Unlisted paths read from disk. */
|
|
54
|
+
readonly files: ReadonlyMap<string, Buffer | null>;
|
|
55
|
+
readonly repositories: readonly WorkbenchArcTreeRepository[];
|
|
56
|
+
readonly isEmpty: boolean;
|
|
57
|
+
|
|
58
|
+
/** Held bytes, ENOENT where the arc tree lacks the path, otherwise disk. */
|
|
59
|
+
readFile(path: string): Promise<Buffer>;
|
|
60
|
+
readFile(path: string, encoding: BufferEncoding): Promise<string>;
|
|
61
|
+
exists(path: string): boolean;
|
|
62
|
+
/** Absolute held paths this tree has but disk lacks; in-process reads see them, Node children cannot. */
|
|
63
|
+
readonly deletedOnDisk: string[];
|
|
64
|
+
/**
|
|
65
|
+
* Variables to merge into a Node child's environment so it and its Node descendants read this tree. Empty when
|
|
66
|
+
* nothing is held. Writes a manifest that `dispose` removes.
|
|
67
|
+
*/
|
|
68
|
+
nodeEnvironment(options?: WorkbenchArcTreeNodeEnvironmentOptions): Promise<Record<string, string>>;
|
|
69
|
+
dispose(): Promise<void>;
|
|
70
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
71
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - default WorkbenchArcTree: the worktree as the calling agent thread builds it (other threads' held paths at the
|
|
4
|
+
* caller's arc baseline, everything else from disk), with in-process reads and a Node child-process environment.
|
|
5
|
+
* Falls back to the plain filesystem outside agent threads or when the daemon view fails.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { ARC_TREE_MANIFEST_ENV, arcTreePathKey, writeArcTreeManifest } from "./arc-tree-manifest.mjs";
|
|
12
|
+
import { daemonGitArcIdentity, requestDaemonGitArc } from "./workbench-daemon.mjs";
|
|
13
|
+
|
|
14
|
+
const TREE_SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u;
|
|
15
|
+
|
|
16
|
+
function nonEmptyString(value) {
|
|
17
|
+
return typeof value === "string" && value.length > 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Keep every well-formed repository and held entry; each malformed one drops only itself. */
|
|
21
|
+
function conformRepositories(body, warn) {
|
|
22
|
+
if (!body || typeof body !== "object" || !Array.isArray(body.repositories)) {
|
|
23
|
+
throw new Error("the daemon's arc tree response lacks repositories");
|
|
24
|
+
}
|
|
25
|
+
let dropped = 0;
|
|
26
|
+
const repositories = body.repositories.flatMap((repository) => {
|
|
27
|
+
if (!repository || typeof repository !== "object" || !nonEmptyString(repository.repoRoot) || !TREE_SHA.test(String(repository.tree))) {
|
|
28
|
+
dropped++;
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const held = (Array.isArray(repository.held) ? repository.held : []).flatMap((entry) => {
|
|
32
|
+
if (!entry || typeof entry !== "object" || !nonEmptyString(entry.threadId) || !Array.isArray(entry.paths)) {
|
|
33
|
+
dropped++;
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
const paths = entry.paths.filter(nonEmptyString);
|
|
37
|
+
dropped += entry.paths.length - paths.length;
|
|
38
|
+
return [{ threadId: entry.threadId, paths }];
|
|
39
|
+
});
|
|
40
|
+
const head = typeof repository.head === "string" && TREE_SHA.test(repository.head) ? repository.head : null;
|
|
41
|
+
return [{ root: path.resolve(repository.repoRoot), head, tree: repository.tree, held }];
|
|
42
|
+
});
|
|
43
|
+
if (dropped) warn(`Workbench arc tree dropped ${dropped} malformed daemon entr${dropped === 1 ? "y" : "ies"}; their paths read from disk.`);
|
|
44
|
+
return repositories;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function git(root, args, { input = "", signal } = {}) {
|
|
48
|
+
const child = spawn("git", args, { cwd: root, signal, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
49
|
+
const stdout = [];
|
|
50
|
+
const stderr = [];
|
|
51
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
52
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
53
|
+
const exit = new Promise((resolve, reject) => {
|
|
54
|
+
child.once("error", reject);
|
|
55
|
+
child.once("close", resolve);
|
|
56
|
+
});
|
|
57
|
+
child.stdin.end(input);
|
|
58
|
+
if (await exit !== 0) throw new Error(`git ${args[0]} failed: ${Buffer.concat(stderr).toString().trim()}`);
|
|
59
|
+
return Buffer.concat(stdout);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Each path's bytes in `tree` as a checkout would write them, or null where the tree lacks it. Batch reads skip
|
|
64
|
+
* checkout filters, so only the existence check is batched.
|
|
65
|
+
*/
|
|
66
|
+
async function readTreeFiles(root, tree, paths, signal) {
|
|
67
|
+
if (paths.some((relative) => relative.includes("\n"))) throw new Error("held paths containing newlines cannot be read from Git");
|
|
68
|
+
const checks = paths.length
|
|
69
|
+
? (await git(root, ["cat-file", "--batch-check"], { input: paths.map((relative) => `${tree}:${relative}\n`).join(""), signal })).toString().split("\n")
|
|
70
|
+
: [];
|
|
71
|
+
return await Promise.all(paths.map(async (relative, index) => {
|
|
72
|
+
const target = path.resolve(root, relative);
|
|
73
|
+
if (checks[index].endsWith(" missing")) return [target, null];
|
|
74
|
+
const [, type] = checks[index].split(" ");
|
|
75
|
+
if (type !== "blob") throw new Error(`${relative} is a ${type} in the arc tree, not a file`);
|
|
76
|
+
return [target, await git(root, ["cat-file", "--filters", `${tree}:${relative}`], { signal })];
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function missing(target) {
|
|
81
|
+
return Object.assign(new Error(`ENOENT: no such file or directory, open '${target}'`), {
|
|
82
|
+
code: "ENOENT", errno: -os.constants.errno.ENOENT, path: target, syscall: "open",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default class WorkbenchArcTree {
|
|
87
|
+
#held;
|
|
88
|
+
#manifest = null;
|
|
89
|
+
|
|
90
|
+
constructor(source, cwd, files, repositories) {
|
|
91
|
+
this.source = source;
|
|
92
|
+
this.cwd = cwd;
|
|
93
|
+
this.files = files;
|
|
94
|
+
this.repositories = repositories;
|
|
95
|
+
this.#held = new Map([...files].map(([target, content]) => [arcTreePathKey(target), content]));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static async load({ cwd = process.cwd(), holdOwn = false, env = process.env, fetch, signal, warn = () => {} } = {}) {
|
|
99
|
+
const root = path.resolve(cwd);
|
|
100
|
+
signal?.throwIfAborted();
|
|
101
|
+
if (!daemonGitArcIdentity(env)) return new WorkbenchArcTree("filesystem", root, new Map(), []);
|
|
102
|
+
try {
|
|
103
|
+
const repositories = conformRepositories(
|
|
104
|
+
await requestDaemonGitArc({ action: "arcTree", cwd: root, holdOwn, paths: [] }, { env, fetch, signal }),
|
|
105
|
+
warn,
|
|
106
|
+
);
|
|
107
|
+
if (!repositories.length) throw new Error(`no Workbench arc covers ${root}`);
|
|
108
|
+
const files = new Map();
|
|
109
|
+
for (const repository of repositories) {
|
|
110
|
+
const held = [...new Set(repository.held.flatMap(({ paths }) => paths))];
|
|
111
|
+
for (const [target, content] of await readTreeFiles(repository.root, repository.tree, held, signal)) files.set(target, content);
|
|
112
|
+
}
|
|
113
|
+
return new WorkbenchArcTree("arc", root, files, repositories);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
signal?.throwIfAborted();
|
|
116
|
+
warn(`Workbench arc tree unavailable (${error instanceof Error ? error.message : String(error)}); reading the filesystem, including other threads' live edits.`);
|
|
117
|
+
return new WorkbenchArcTree("filesystem", root, new Map(), []);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
static fromFiles(cwd, files, repositories = []) {
|
|
122
|
+
const root = path.resolve(cwd);
|
|
123
|
+
return new WorkbenchArcTree("arc", root, new Map([...files].map(([relative, content]) => [path.resolve(root, relative), content])), repositories);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get isEmpty() {
|
|
127
|
+
return !this.files.size;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async readFile(target, encoding) {
|
|
131
|
+
const resolved = path.resolve(this.cwd, target);
|
|
132
|
+
const content = this.#held.get(arcTreePathKey(resolved));
|
|
133
|
+
if (content === undefined) return await fs.promises.readFile(resolved, encoding ? { encoding } : undefined);
|
|
134
|
+
if (content === null) throw missing(resolved);
|
|
135
|
+
return encoding ? content.toString(encoding) : Buffer.from(content);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
exists(target) {
|
|
139
|
+
const resolved = path.resolve(this.cwd, target);
|
|
140
|
+
const content = this.#held.get(arcTreePathKey(resolved));
|
|
141
|
+
return content === undefined ? fs.existsSync(resolved) : content !== null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get deletedOnDisk() {
|
|
145
|
+
return [...this.files].flatMap(([target, content]) => content !== null && !fs.existsSync(target) ? [target] : []);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async nodeEnvironment({ env = process.env, directory } = {}) {
|
|
149
|
+
if (this.isEmpty) return {};
|
|
150
|
+
this.#manifest ??= this.#writeManifest(path.resolve(this.cwd, directory ?? path.join("node_modules", ".cache", "wb-arc-tree")));
|
|
151
|
+
let file;
|
|
152
|
+
try {
|
|
153
|
+
({ file } = await this.#manifest);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
this.#manifest = null;
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
const preload = `--import ${new URL("./arc-tree-preload.mjs", import.meta.url).href}`;
|
|
159
|
+
return { [ARC_TREE_MANIFEST_ENV]: file, NODE_OPTIONS: env.NODE_OPTIONS ? `${env.NODE_OPTIONS} ${preload}` : preload };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async #writeManifest(parent) {
|
|
163
|
+
await fs.promises.mkdir(parent, { recursive: true });
|
|
164
|
+
const directory = await fs.promises.mkdtemp(path.join(parent, "manifest-"));
|
|
165
|
+
const file = path.join(directory, "manifest.json");
|
|
166
|
+
try {
|
|
167
|
+
await writeArcTreeManifest(file, this.files);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
await fs.promises.rm(directory, { force: true, recursive: true });
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
return { directory, file };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async dispose() {
|
|
176
|
+
const manifest = this.#manifest;
|
|
177
|
+
this.#manifest = null;
|
|
178
|
+
const written = await manifest?.catch(() => null);
|
|
179
|
+
if (written) await fs.promises.rm(written.directory, { force: true, maxRetries: 5, recursive: true, retryDelay: 50 });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async [Symbol.asyncDispose]() {
|
|
183
|
+
await this.dispose();
|
|
184
|
+
}
|
|
185
|
+
}
|
package/WorkbenchBootstrap.mjs
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Exports:
|
|
3
|
-
* - WorkbenchBootstrap (default): locates or installs an editable checkout and delegates to it.
|
|
3
|
+
* - WorkbenchBootstrap (default): locates or installs an editable checkout and delegates to it; `run` resolves a delegated exit code.
|
|
4
4
|
*/
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
6
|
import { constants } from "node:fs";
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import { randomUUID } from "node:crypto";
|
|
10
9
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import
|
|
10
|
+
import WorkbenchInstallationRecord from "./WorkbenchInstallationRecord.mjs";
|
|
12
11
|
import WorkbenchInstallPrompt from "./WorkbenchInstallPrompt.mjs";
|
|
13
|
-
import WorkbenchBootstrapCommand from "./WorkbenchBootstrapCommand.mjs";
|
|
12
|
+
import WorkbenchBootstrapCommand, { WorkbenchChildExitError } from "./WorkbenchBootstrapCommand.mjs";
|
|
14
13
|
import WorkbenchToolchain from "./WorkbenchToolchain.mjs";
|
|
15
14
|
import WorkbenchPackageRegistration from "./WorkbenchPackageRegistration.mjs";
|
|
15
|
+
import resolveWorkbenchDataRoot from "./workbench-data-root.mjs";
|
|
16
16
|
|
|
17
17
|
export default class WorkbenchBootstrap {
|
|
18
18
|
constructor({
|
|
@@ -35,7 +35,9 @@ export default class WorkbenchBootstrap {
|
|
|
35
35
|
this.toolchain = toolchain;
|
|
36
36
|
this.registration = registration;
|
|
37
37
|
this.write = write;
|
|
38
|
-
this.
|
|
38
|
+
this.installation = new WorkbenchInstallationRecord({
|
|
39
|
+
dataRoot: resolveWorkbenchDataRoot({ environment, homeDirectory: home, platform }),
|
|
40
|
+
});
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
async checkoutExists(root) {
|
|
@@ -63,26 +65,6 @@ export default class WorkbenchBootstrap {
|
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
async readInstallation() {
|
|
67
|
-
try {
|
|
68
|
-
const record = JSON.parse(await fs.readFile(this.registry, "utf8"));
|
|
69
|
-
if (record.version !== 1 || typeof record.root !== "string" || !path.isAbsolute(record.root)
|
|
70
|
-
|| !["cloning", "setup", "ready"].includes(record.phase)) {
|
|
71
|
-
throw new Error(`Invalid Workbench installation record: ${this.registry}. Existing files were not changed.`);
|
|
72
|
-
}
|
|
73
|
-
return record;
|
|
74
|
-
} catch (error) {
|
|
75
|
-
if (error.code === "ENOENT") return null;
|
|
76
|
-
throw error;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async writeInstallation(record) {
|
|
81
|
-
const candidate = `${this.registry}.${randomUUID()}.tmp`;
|
|
82
|
-
await fs.writeFile(candidate, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
83
|
-
await fs.rename(candidate, this.registry);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
68
|
async requireTool(command, cwd) {
|
|
87
69
|
try {
|
|
88
70
|
await this.commands.run(command, ["--version"], {
|
|
@@ -105,7 +87,7 @@ export default class WorkbenchBootstrap {
|
|
|
105
87
|
if (await this.checkoutExists(linked)) {
|
|
106
88
|
return await this.delegate(linked, args);
|
|
107
89
|
}
|
|
108
|
-
const installed = await this.
|
|
90
|
+
const installed = await this.installation.read();
|
|
109
91
|
if (installed?.phase === "ready") {
|
|
110
92
|
const state = await this.checkoutState(installed.root);
|
|
111
93
|
if (state === "usable") return await this.delegate(installed.root, args);
|
|
@@ -130,21 +112,10 @@ export default class WorkbenchBootstrap {
|
|
|
130
112
|
? path.join(this.environment.LOCALAPPDATA || path.join(this.home, "AppData", "Local"), "Programs", "inthedark", "wb")
|
|
131
113
|
: path.join(this.home, ".local", "lib", "inthedark", "wb"));
|
|
132
114
|
const root = path.resolve(await this.prompt.location("Install location", defaultRoot));
|
|
133
|
-
await
|
|
134
|
-
const lockPath = `${this.registry}.lock.sqlite3`;
|
|
135
|
-
const lock = new DatabaseSync(lockPath);
|
|
136
|
-
try {
|
|
137
|
-
lock.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE");
|
|
138
|
-
} catch (error) {
|
|
139
|
-
lock.close();
|
|
140
|
-
if (error.errcode === 5 || error.errcode === 6) {
|
|
141
|
-
throw new Error("Another Workbench installation is running. Let it finish before trying again.");
|
|
142
|
-
}
|
|
143
|
-
throw error;
|
|
144
|
-
}
|
|
115
|
+
const lock = await this.installation.lock();
|
|
145
116
|
try {
|
|
146
117
|
// Another invocation may have finished while this one was accepting input.
|
|
147
|
-
const latest = await this.
|
|
118
|
+
const latest = await this.installation.read();
|
|
148
119
|
if (latest?.phase === "ready") {
|
|
149
120
|
const state = await this.checkoutState(latest.root);
|
|
150
121
|
if (state === "usable") return await this.delegate(latest.root, args);
|
|
@@ -158,7 +129,7 @@ export default class WorkbenchBootstrap {
|
|
|
158
129
|
const resume = latest?.root === root && await this.checkoutExists(root);
|
|
159
130
|
if (!resume) await this.requireEmptyDestination(root);
|
|
160
131
|
// Recording before provisioning keeps the chosen location as the next default if anything fails.
|
|
161
|
-
await this.
|
|
132
|
+
await this.installation.write({ version: 1, root, phase: resume ? "setup" : "cloning" });
|
|
162
133
|
await this.registration.prepare();
|
|
163
134
|
await this.toolchain.ensure();
|
|
164
135
|
await this.prompt.withHeader(async () => {
|
|
@@ -168,15 +139,13 @@ export default class WorkbenchBootstrap {
|
|
|
168
139
|
const repository = this.environment.WORKBENCH_INSTALL_REPOSITORY || "https://github.com/ErisInTheDark/workbench.git";
|
|
169
140
|
await this.commands.run("git", ["clone", "--progress", "--branch", "main", "--single-branch", repository, root]);
|
|
170
141
|
if (!await this.checkoutExists(root)) throw new Error("Clone did not produce a Workbench checkout.");
|
|
171
|
-
await this.
|
|
142
|
+
await this.installation.write({ version: 1, root, phase: "setup" });
|
|
172
143
|
}
|
|
173
144
|
await this.commands.run(process.execPath, [path.join(root, "scripts", "install.mjs"), "--prepare"], { cwd: root });
|
|
174
145
|
await this.registration.register(root);
|
|
175
146
|
});
|
|
176
|
-
await this.
|
|
147
|
+
await this.installation.write({ version: 1, root, phase: "ready" });
|
|
177
148
|
} finally {
|
|
178
|
-
// Closing the connection also releases the OS lock after normal completion;
|
|
179
|
-
// process death releases it without a stale PID or lock-file recovery path.
|
|
180
149
|
lock.close();
|
|
181
150
|
}
|
|
182
151
|
// Persist readiness before optional platform actions: missing native artifacts
|
|
@@ -185,8 +154,15 @@ export default class WorkbenchBootstrap {
|
|
|
185
154
|
args[0] === "connect" ? "--connect" : "--welcome"], { cwd: root, interactive: true });
|
|
186
155
|
}
|
|
187
156
|
|
|
157
|
+
/** Resolves the checkout CLI's exit code; it already reported its own failure. */
|
|
188
158
|
async delegate(root, args) {
|
|
189
|
-
|
|
159
|
+
try {
|
|
160
|
+
await this.commands.run(process.execPath, [path.join(root, "cli", "dispatch.mjs"), ...args], { interactive: true });
|
|
161
|
+
return 0;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error instanceof WorkbenchChildExitError) return error.exitCode;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
190
166
|
}
|
|
191
167
|
|
|
192
168
|
async requireEmptyDestination(root) {
|
|
@@ -1,11 +1,33 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Exports:
|
|
3
|
-
* - WorkbenchBootstrapCommand (default): owns bootstrap child commands, Windows executable lookup (including Git Bash), and failures.
|
|
3
|
+
* - WorkbenchBootstrapCommand (default): owns bootstrap child commands, Windows executable lookup (including Git Bash), process-tree cancellation, and failures.
|
|
4
|
+
* - WorkbenchChildExitError: a child exited with a non-zero status after reporting its own failure; delegators propagate `exitCode` silently.
|
|
4
5
|
*/
|
|
5
6
|
import { spawn } from "node:child_process";
|
|
6
7
|
import fs from "node:fs/promises";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
|
|
10
|
+
function stopTree(child) {
|
|
11
|
+
if (child.exitCode !== null || child.signalCode !== null || !child.pid) return;
|
|
12
|
+
if (process.platform === "win32") {
|
|
13
|
+
// A denied or failed tree kill still stops the direct child.
|
|
14
|
+
const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
|
|
15
|
+
killer.once("error", () => { child.kill(); });
|
|
16
|
+
killer.once("exit", code => { if (code !== 0) child.kill(); });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
try { process.kill(-child.pid, "SIGTERM"); }
|
|
20
|
+
catch (error) { if (error.code !== "ESRCH") child.kill("SIGTERM"); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class WorkbenchChildExitError extends Error {
|
|
24
|
+
constructor(command, exitCode) {
|
|
25
|
+
super(`${command} failed with status ${exitCode}.`);
|
|
26
|
+
this.name = "WorkbenchChildExitError";
|
|
27
|
+
this.exitCode = exitCode;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
9
31
|
export default class WorkbenchBootstrapCommand {
|
|
10
32
|
constructor({ output = process.stdout, errorOutput = process.stderr, environment = process.env } = {}) {
|
|
11
33
|
this.output = output;
|
|
@@ -115,19 +137,28 @@ export default class WorkbenchBootstrapCommand {
|
|
|
115
137
|
throw new Error(`Workbench needs Git Bash on Windows.${sawLauncher ? " The bash on PATH is WSL's, which cannot run Workbench." : ""} Install Git for Windows, then retry.`);
|
|
116
138
|
}
|
|
117
139
|
|
|
140
|
+
/** Cancelling a non-interactive run stops the child's whole process tree; wrappers like vp otherwise orphan their workers. */
|
|
118
141
|
async run(command, args, { cwd, signal, onOutput, environment = {}, output = this.output, errorOutput = this.errorOutput, interactive = false } = {}) {
|
|
119
142
|
signal?.throwIfAborted();
|
|
120
143
|
const [executable, ...prefix] = await this.resolve(command);
|
|
121
144
|
signal?.throwIfAborted();
|
|
145
|
+
const tree = Boolean(signal) && !interactive;
|
|
122
146
|
return await new Promise((resolve, reject) => {
|
|
123
147
|
const child = spawn(executable, [...prefix, ...args], {
|
|
124
148
|
cwd,
|
|
125
149
|
env: this.environmentFor(environment),
|
|
126
150
|
windowsHide: true,
|
|
127
151
|
stdio: interactive ? "inherit" : ["ignore", "pipe", "pipe"],
|
|
128
|
-
signal
|
|
152
|
+
// A POSIX process group lets one signal reach every descendant.
|
|
153
|
+
...(tree ? { detached: process.platform !== "win32" } : { signal }),
|
|
129
154
|
});
|
|
130
155
|
let failure;
|
|
156
|
+
let cancelled = false;
|
|
157
|
+
const cancel = () => {
|
|
158
|
+
cancelled = true;
|
|
159
|
+
stopTree(child);
|
|
160
|
+
};
|
|
161
|
+
if (tree) signal.addEventListener("abort", cancel, { once: true });
|
|
131
162
|
const relay = signal => { child.kill(signal); };
|
|
132
163
|
const interrupt = () => relay("SIGINT");
|
|
133
164
|
const terminate = () => relay("SIGTERM");
|
|
@@ -157,13 +188,17 @@ export default class WorkbenchBootstrapCommand {
|
|
|
157
188
|
process.off("SIGTERM", terminate);
|
|
158
189
|
process.off("SIGHUP", hangup);
|
|
159
190
|
}
|
|
160
|
-
if (
|
|
191
|
+
if (tree) signal.removeEventListener("abort", cancel);
|
|
192
|
+
if (cancelled) {
|
|
193
|
+
reject(Object.assign(new Error(`${command} was cancelled.`, { cause: signal.reason }), { name: "AbortError", code: "ABORT_ERR" }));
|
|
194
|
+
} else if (failure?.code === "ENOENT") {
|
|
161
195
|
reject(Object.assign(new Error(`Cannot start ${command}: executable not found. Install it and make it available on PATH, then retry.`, { cause: failure }), {
|
|
162
196
|
code: "ENOENT",
|
|
163
197
|
}));
|
|
164
198
|
} else if (failure) reject(failure);
|
|
165
199
|
else if (code === 0) resolve();
|
|
166
|
-
else
|
|
200
|
+
else if (code !== null) reject(new WorkbenchChildExitError(command, code));
|
|
201
|
+
else reject(Object.assign(new Error(`${command} failed with signal ${exitSignal ?? "unknown"}.`), { exitCode: 1 }));
|
|
167
202
|
});
|
|
168
203
|
});
|
|
169
204
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - WorkbenchInstallationPhase/WorkbenchInstallationRecordValue: one recorded installation.
|
|
4
|
+
* - default WorkbenchInstallationRecord: read, write and exclusively lock the data root's installation record.
|
|
5
|
+
*/
|
|
6
|
+
export type WorkbenchInstallationPhase = "cloning" | "setup" | "ready";
|
|
7
|
+
export interface WorkbenchInstallationRecordValue {
|
|
8
|
+
version: 1;
|
|
9
|
+
root: string;
|
|
10
|
+
phase: WorkbenchInstallationPhase;
|
|
11
|
+
}
|
|
12
|
+
export default class WorkbenchInstallationRecord {
|
|
13
|
+
constructor(options: { dataRoot: string });
|
|
14
|
+
readonly path: string;
|
|
15
|
+
read(): Promise<WorkbenchInstallationRecordValue | null>;
|
|
16
|
+
write(record: WorkbenchInstallationRecordValue): Promise<void>;
|
|
17
|
+
lock(): Promise<{ close(): void }>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - default WorkbenchInstallationRecord: read, write and exclusively lock the data root's record of which checkout is installed.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { DatabaseSync } from "node:sqlite";
|
|
9
|
+
|
|
10
|
+
const PHASES = ["cloning", "setup", "ready"];
|
|
11
|
+
|
|
12
|
+
export default class WorkbenchInstallationRecord {
|
|
13
|
+
constructor({ dataRoot }) {
|
|
14
|
+
this.path = path.join(dataRoot, "installation", "installation.json");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The record, or null when none exists; malformed records reject without being changed. */
|
|
18
|
+
async read() {
|
|
19
|
+
try {
|
|
20
|
+
const record = JSON.parse(await fs.readFile(this.path, "utf8"));
|
|
21
|
+
if (record.version !== 1 || typeof record.root !== "string" || !path.isAbsolute(record.root)
|
|
22
|
+
|| !PHASES.includes(record.phase)) {
|
|
23
|
+
throw new Error(`Invalid Workbench installation record: ${this.path}. Existing files were not changed.`);
|
|
24
|
+
}
|
|
25
|
+
return record;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error.code === "ENOENT") return null;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async write(record) {
|
|
33
|
+
await fs.mkdir(path.dirname(this.path), { recursive: true });
|
|
34
|
+
const candidate = `${this.path}.${randomUUID()}.tmp`;
|
|
35
|
+
await fs.writeFile(candidate, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
36
|
+
await fs.rename(candidate, this.path);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Holds the installation lock until `close()`. Closing the connection, or process death, releases the OS lock
|
|
41
|
+
* without a stale PID or lock-file recovery path.
|
|
42
|
+
*/
|
|
43
|
+
async lock() {
|
|
44
|
+
await fs.mkdir(path.dirname(this.path), { recursive: true });
|
|
45
|
+
const lock = new DatabaseSync(`${this.path}.lock.sqlite3`);
|
|
46
|
+
try {
|
|
47
|
+
lock.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE");
|
|
48
|
+
} catch (error) {
|
|
49
|
+
lock.close();
|
|
50
|
+
if (error.errcode === 5 || error.errcode === 6) {
|
|
51
|
+
throw new Error("Another Workbench installation is running. Let it finish before trying again.");
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
return { close: () => lock.close() };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/*
|
|
3
|
+
* Exports:
|
|
4
|
+
* - ARC_TREE_MANIFEST_ENV: environment variable naming the manifest Node processes preload.
|
|
5
|
+
* - arcTreePathKey: comparable key for an absolute path on this platform's file system.
|
|
6
|
+
* - writeArcTreeManifest: write held paths and their arc tree bytes for ./arc-tree-preload.mjs.
|
|
7
|
+
* - readArcTreeManifest: read a manifest into path keys mapped to bytes, or null where the arc tree lacks the path.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
export const ARC_TREE_MANIFEST_ENV = "WORKBENCH_ARC_OVERLAY_MANIFEST";
|
|
13
|
+
const MANIFEST_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
/** @param {string} target */
|
|
16
|
+
export function arcTreePathKey(target) {
|
|
17
|
+
const resolved = path.resolve(target);
|
|
18
|
+
return process.platform === "win32" || process.platform === "darwin" ? resolved.toLowerCase() : resolved;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {string} file @param {ReadonlyMap<string, Buffer | null>} files absolute paths */
|
|
22
|
+
export async function writeArcTreeManifest(file, files) {
|
|
23
|
+
const encoded = Object.fromEntries([...files].map(([target, content]) => [target, content?.toString("base64") ?? null]));
|
|
24
|
+
await fs.promises.writeFile(file, JSON.stringify({ version: MANIFEST_VERSION, files: encoded }), { flag: "wx" });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} file
|
|
29
|
+
* @returns {Map<string, Buffer | null>}
|
|
30
|
+
*/
|
|
31
|
+
export function readArcTreeManifest(file) {
|
|
32
|
+
/** @type {{ files?: unknown; version?: unknown }} */
|
|
33
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
34
|
+
if (parsed.version !== MANIFEST_VERSION || !parsed.files || typeof parsed.files !== "object") {
|
|
35
|
+
throw new Error(`Arc tree manifest ${file} is invalid.`);
|
|
36
|
+
}
|
|
37
|
+
return new Map(Object.entries(parsed.files).map(([target, content]) => {
|
|
38
|
+
if (content !== null && typeof content !== "string") throw new Error(`Arc tree manifest ${file} is invalid.`);
|
|
39
|
+
return [arcTreePathKey(target), content === null ? null : Buffer.from(content, "base64")];
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/*
|
|
3
|
+
* No exports. Node `--import` preload, plain JavaScript so it loads before any TypeScript loader exists: answer reads of
|
|
4
|
+
* other threads' held paths from the arc tree manifest, and make paths the caller's arc tree lacks look absent.
|
|
5
|
+
* Node's module resolver stats files internally, so it still finds hidden files; reading them then fails as missing.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { ARC_TREE_MANIFEST_ENV, arcTreePathKey, readArcTreeManifest } from "./arc-tree-manifest.mjs";
|
|
13
|
+
|
|
14
|
+
/** @typedef {(error: NodeJS.ErrnoException | null, value?: unknown) => void} Callback */
|
|
15
|
+
/** @typedef {(...values: unknown[]) => unknown} LooseFunction */
|
|
16
|
+
|
|
17
|
+
/** @param {Map<string, Buffer | null>} overlay */
|
|
18
|
+
function install(overlay) {
|
|
19
|
+
/** @param {unknown} target */
|
|
20
|
+
const keyOf = (target) => {
|
|
21
|
+
if (typeof target === "string") return arcTreePathKey(target);
|
|
22
|
+
if (target instanceof URL) return target.protocol === "file:" ? arcTreePathKey(fileURLToPath(target)) : null;
|
|
23
|
+
if (Buffer.isBuffer(target)) return arcTreePathKey(target.toString());
|
|
24
|
+
return null;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* @param {unknown} target
|
|
28
|
+
* @returns {Buffer | null | undefined} undefined when no other thread holds the path
|
|
29
|
+
*/
|
|
30
|
+
const held = (target) => {
|
|
31
|
+
const key = keyOf(target);
|
|
32
|
+
return key === null ? undefined : overlay.get(key);
|
|
33
|
+
};
|
|
34
|
+
/** @param {string} syscall @param {unknown} target */
|
|
35
|
+
const missing = (syscall, target) => Object.assign(
|
|
36
|
+
new Error(`ENOENT: no such file or directory, ${syscall} '${String(target)}'`),
|
|
37
|
+
{ code: "ENOENT", errno: -os.constants.errno.ENOENT, path: String(target), syscall },
|
|
38
|
+
);
|
|
39
|
+
/** @param {Buffer} content @param {unknown} options */
|
|
40
|
+
const encoded = (content, options) => {
|
|
41
|
+
const encoding = typeof options === "string"
|
|
42
|
+
? /** @type {BufferEncoding} */ (options)
|
|
43
|
+
: /** @type {{ encoding?: BufferEncoding | null } | undefined} */ (options)?.encoding;
|
|
44
|
+
return encoding ? content.toString(encoding) : Buffer.from(content);
|
|
45
|
+
};
|
|
46
|
+
/** @param {unknown} directory @param {unknown[]} entries */
|
|
47
|
+
const visible = (directory, entries) => entries.filter((entry) => {
|
|
48
|
+
const dirent = entry instanceof fs.Dirent ? entry : null;
|
|
49
|
+
const name = dirent ? dirent.name : entry;
|
|
50
|
+
if (typeof name !== "string") return true;
|
|
51
|
+
const parent = dirent ? dirent.parentPath : directory instanceof URL ? fileURLToPath(directory) : String(directory);
|
|
52
|
+
return overlay.get(arcTreePathKey(path.join(parent, name))) !== null;
|
|
53
|
+
});
|
|
54
|
+
/** @param {unknown[]} args */
|
|
55
|
+
const callbackOf = (args) => /** @type {Callback} */ (args.at(-1));
|
|
56
|
+
/** @param {Callback} callback @param {NodeJS.ErrnoException | null} error @param {unknown} [value] */
|
|
57
|
+
const later = (callback, error, value) => process.nextTick(() => callback(error, value));
|
|
58
|
+
|
|
59
|
+
const { promises } = fs;
|
|
60
|
+
const original = {
|
|
61
|
+
access: /** @type {LooseFunction} */ (fs.access),
|
|
62
|
+
accessSync: fs.accessSync,
|
|
63
|
+
existsSync: fs.existsSync,
|
|
64
|
+
lstat: /** @type {LooseFunction} */ (fs.lstat),
|
|
65
|
+
lstatSync: /** @type {LooseFunction} */ (fs.lstatSync),
|
|
66
|
+
promisesAccess: promises.access,
|
|
67
|
+
promisesLstat: /** @type {LooseFunction} */ (promises.lstat),
|
|
68
|
+
promisesReadFile: /** @type {LooseFunction} */ (promises.readFile),
|
|
69
|
+
promisesReaddir: /** @type {LooseFunction} */ (promises.readdir),
|
|
70
|
+
promisesStat: /** @type {LooseFunction} */ (promises.stat),
|
|
71
|
+
readFile: /** @type {LooseFunction} */ (fs.readFile),
|
|
72
|
+
readFileSync: /** @type {LooseFunction} */ (fs.readFileSync),
|
|
73
|
+
readdir: /** @type {LooseFunction} */ (fs.readdir),
|
|
74
|
+
readdirSync: /** @type {LooseFunction} */ (fs.readdirSync),
|
|
75
|
+
stat: /** @type {LooseFunction} */ (fs.stat),
|
|
76
|
+
statSync: /** @type {LooseFunction} */ (fs.statSync),
|
|
77
|
+
};
|
|
78
|
+
/** @param {string} syscall @param {LooseFunction} sync */
|
|
79
|
+
const statSync = (syscall, sync) => (/** @type {unknown} */ target, /** @type {fs.StatSyncOptions | undefined} */ options) => {
|
|
80
|
+
if (held(target) !== null) return sync.call(fs, target, options);
|
|
81
|
+
if (options?.throwIfNoEntry === false) return undefined;
|
|
82
|
+
throw missing(syscall, target);
|
|
83
|
+
};
|
|
84
|
+
/** @param {string} syscall @param {LooseFunction} callbackStat */
|
|
85
|
+
const statCallback = (syscall, callbackStat) => (/** @type {unknown} */ target, /** @type {unknown[]} */ ...args) => {
|
|
86
|
+
if (held(target) !== null) return callbackStat.call(fs, target, ...args);
|
|
87
|
+
later(callbackOf(args), missing(syscall, target));
|
|
88
|
+
};
|
|
89
|
+
/** @param {string} syscall @param {LooseFunction} promiseStat */
|
|
90
|
+
const statPromise = (syscall, promiseStat) => async (/** @type {unknown} */ target, /** @type {unknown} */ options) => {
|
|
91
|
+
if (held(target) === null) throw missing(syscall, target);
|
|
92
|
+
return await promiseStat.call(promises, target, options);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
Object.assign(fs, {
|
|
96
|
+
/** @param {unknown} target @param {unknown} [options] */
|
|
97
|
+
readFileSync(target, options) {
|
|
98
|
+
const content = held(target);
|
|
99
|
+
if (content === undefined) return original.readFileSync.call(fs, target, options);
|
|
100
|
+
if (content === null) throw missing("open", target);
|
|
101
|
+
return encoded(content, options);
|
|
102
|
+
},
|
|
103
|
+
/** @param {unknown} target @param {unknown[]} args */
|
|
104
|
+
readFile(target, ...args) {
|
|
105
|
+
const content = held(target);
|
|
106
|
+
if (content === undefined) return original.readFile.call(fs, target, ...args);
|
|
107
|
+
if (content === null) later(callbackOf(args), missing("open", target));
|
|
108
|
+
else later(callbackOf(args), null, encoded(content, args.length > 1 ? args[0] : undefined));
|
|
109
|
+
},
|
|
110
|
+
/** @param {fs.PathLike} target */
|
|
111
|
+
existsSync(target) {
|
|
112
|
+
return held(target) === null ? false : original.existsSync(target);
|
|
113
|
+
},
|
|
114
|
+
/** @param {fs.PathLike} target @param {number} [mode] */
|
|
115
|
+
accessSync(target, mode) {
|
|
116
|
+
if (held(target) === null) throw missing("access", target);
|
|
117
|
+
original.accessSync(target, mode);
|
|
118
|
+
},
|
|
119
|
+
access: statCallback("access", original.access),
|
|
120
|
+
statSync: statSync("stat", original.statSync),
|
|
121
|
+
lstatSync: statSync("lstat", original.lstatSync),
|
|
122
|
+
stat: statCallback("stat", original.stat),
|
|
123
|
+
lstat: statCallback("lstat", original.lstat),
|
|
124
|
+
/** @param {unknown} directory @param {unknown} [options] */
|
|
125
|
+
readdirSync(directory, options) {
|
|
126
|
+
return visible(directory, /** @type {unknown[]} */ (original.readdirSync.call(fs, directory, options)));
|
|
127
|
+
},
|
|
128
|
+
/** @param {unknown} directory @param {unknown[]} args */
|
|
129
|
+
readdir(directory, ...args) {
|
|
130
|
+
const callback = callbackOf(args);
|
|
131
|
+
original.readdir.call(fs, directory, ...args.slice(0, -1), /** @type {Callback} */ ((error, entries) => {
|
|
132
|
+
callback(error, error ? undefined : visible(directory, /** @type {unknown[]} */ (entries ?? [])));
|
|
133
|
+
}));
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
Object.assign(promises, {
|
|
137
|
+
/** @param {unknown} target @param {unknown} [options] */
|
|
138
|
+
async readFile(target, options) {
|
|
139
|
+
const content = held(target);
|
|
140
|
+
if (content === undefined) return await original.promisesReadFile.call(promises, target, options);
|
|
141
|
+
if (content === null) throw missing("open", target);
|
|
142
|
+
return encoded(content, options);
|
|
143
|
+
},
|
|
144
|
+
/** @param {fs.PathLike} target @param {number} [mode] */
|
|
145
|
+
async access(target, mode) {
|
|
146
|
+
if (held(target) === null) throw missing("access", target);
|
|
147
|
+
await original.promisesAccess(target, mode);
|
|
148
|
+
},
|
|
149
|
+
stat: statPromise("stat", original.promisesStat),
|
|
150
|
+
lstat: statPromise("lstat", original.promisesLstat),
|
|
151
|
+
/** @param {unknown} directory @param {unknown} [options] */
|
|
152
|
+
async readdir(directory, options) {
|
|
153
|
+
return visible(directory, /** @type {unknown[]} */ (await original.promisesReaddir.call(promises, directory, options)));
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
syncBuiltinESMExports();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const manifest = process.env[ARC_TREE_MANIFEST_ENV];
|
|
160
|
+
if (manifest) install(readArcTreeManifest(manifest));
|
package/index.d.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Exports:
|
|
3
|
-
* - WorkbenchConfigOptions: dotenv
|
|
4
|
-
* - WorkbenchConfigOutput:
|
|
5
|
-
* - config: load
|
|
3
|
+
* - WorkbenchConfigOptions: dotenv mapping, missing-value policies, cancellation and warning hooks.
|
|
4
|
+
* - WorkbenchConfigOutput: mapped values after store replacement and policy checks, with the first file read error.
|
|
5
|
+
* - config: load and map dotenv values; resolve stores and check required names before environment writes.
|
|
6
|
+
* - WorkbenchArcTree/WorkbenchArcTreeOptions/WorkbenchArcTreeRepository/WorkbenchArcTreeNodeEnvironmentOptions: arc tree types.
|
|
7
|
+
* - arcTree: the worktree as the calling agent thread builds it, or the plain filesystem outside agent threads.
|
|
6
8
|
*/
|
|
9
|
+
import type WorkbenchArcTree from "./WorkbenchArcTree.mjs";
|
|
10
|
+
import type { WorkbenchArcTreeOptions } from "./WorkbenchArcTree.mjs";
|
|
11
|
+
|
|
12
|
+
export type { WorkbenchArcTree };
|
|
13
|
+
export type {
|
|
14
|
+
WorkbenchArcTreeNodeEnvironmentOptions, WorkbenchArcTreeOptions, WorkbenchArcTreeRepository,
|
|
15
|
+
} from "./WorkbenchArcTree.mjs";
|
|
7
16
|
export type WorkbenchConfigOptions = {
|
|
8
17
|
/** One file or several; earlier files win unless `override` is set. Defaults to `.env`. */
|
|
9
18
|
path?: string | readonly string[];
|
|
@@ -11,6 +20,8 @@ export type WorkbenchConfigOptions = {
|
|
|
11
20
|
/** Replace variables already present in `processEnv`, as dotenv does. */
|
|
12
21
|
override?: boolean;
|
|
13
22
|
processEnv?: Record<string, string | undefined>;
|
|
23
|
+
/** Map merged parsed values before store resolution and policy checks. Rejection prevents environment writes. */
|
|
24
|
+
map?: (env: Record<string, string>) => Record<string, string> | Promise<Record<string, string>>;
|
|
14
25
|
/** Aborting kills running store commands and rejects. */
|
|
15
26
|
signal?: AbortSignal;
|
|
16
27
|
/** Opt into bounded lookup warnings that never contain command output. Silent by default. */
|
|
@@ -36,3 +47,9 @@ export interface WorkbenchConfigOutput {
|
|
|
36
47
|
* before any environment writes. Existing environment values win unless override is set.
|
|
37
48
|
*/
|
|
38
49
|
export function config(options?: WorkbenchConfigOptions): Promise<WorkbenchConfigOutput>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Other threads' held paths read at the caller's arc baseline; everything else reads from disk. Outside agent
|
|
53
|
+
* threads, or when the daemon view fails (reported through `warn`), the plain filesystem. Only `signal` aborts reject.
|
|
54
|
+
*/
|
|
55
|
+
export function arcTree(options?: WorkbenchArcTreeOptions): Promise<WorkbenchArcTree>;
|
package/index.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Exports:
|
|
3
|
-
* - config: load dotenv
|
|
3
|
+
* - config: load and map dotenv values, resolve store references, and enforce an optional missing-variable policy.
|
|
4
|
+
* - arcTree: load the worktree as the calling agent thread builds it, or the plain filesystem without one.
|
|
4
5
|
*/
|
|
5
6
|
import fs from "node:fs/promises";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import dotenv from "dotenv";
|
|
8
9
|
import StoreCommandRunner from "./StoreCommandRunner.mjs";
|
|
10
|
+
import WorkbenchArcTree from "./WorkbenchArcTree.mjs";
|
|
9
11
|
import {
|
|
10
12
|
DEFAULT_STORE_DEFINITIONS,
|
|
11
13
|
expandStoreCommand,
|
|
@@ -109,10 +111,13 @@ export async function config({
|
|
|
109
111
|
runner = new StoreCommandRunner(),
|
|
110
112
|
required,
|
|
111
113
|
optional,
|
|
114
|
+
map,
|
|
112
115
|
} = {}) {
|
|
113
116
|
signal?.throwIfAborted();
|
|
114
117
|
const policy = missingPolicy(required, optional);
|
|
115
|
-
const { parsed:
|
|
118
|
+
const { parsed: input, error } = await readParsed(Array.isArray(paths) ? paths : [paths], encoding, override);
|
|
119
|
+
const raw = map ? await map(input) : input;
|
|
120
|
+
signal?.throwIfAborted();
|
|
116
121
|
const definitions = raw.WB_STORES?.trim() || processEnv.WB_STORES?.trim() || DEFAULT_STORE_DEFINITIONS;
|
|
117
122
|
const { parsed, unresolved } = await resolveReferences(raw, definitions, { runner, signal, warn, policy });
|
|
118
123
|
applyMissingPolicy(parsed, unresolved, policy, processEnv, override);
|
|
@@ -122,3 +127,7 @@ export async function config({
|
|
|
122
127
|
dotenv.populate(processEnv, values, { override: policy ? true : override });
|
|
123
128
|
return error ? { parsed, error } : { parsed };
|
|
124
129
|
}
|
|
130
|
+
|
|
131
|
+
export async function arcTree(options) {
|
|
132
|
+
return await WorkbenchArcTree.load(options);
|
|
133
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inthedark/wb",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
"wb",
|
|
27
27
|
"WorkbenchBootstrap.mjs",
|
|
28
|
+
"WorkbenchInstallationRecord.mjs",
|
|
29
|
+
"WorkbenchInstallationRecord.d.mts",
|
|
28
30
|
"WorkbenchBootstrapCommand.mjs",
|
|
29
31
|
"WorkbenchToolchain.mjs",
|
|
30
32
|
"WorkbenchPackageRegistration.mjs",
|
|
@@ -37,10 +39,16 @@
|
|
|
37
39
|
"index.d.ts",
|
|
38
40
|
"store-definitions.mjs",
|
|
39
41
|
"StoreCommandRunner.mjs",
|
|
42
|
+
"WorkbenchArcTree.mjs",
|
|
43
|
+
"WorkbenchArcTree.d.mts",
|
|
44
|
+
"arc-tree-manifest.mjs",
|
|
45
|
+
"arc-tree-preload.mjs",
|
|
46
|
+
"workbench-daemon.mjs",
|
|
47
|
+
"workbench-daemon.d.mts",
|
|
40
48
|
"wb.schema.json",
|
|
41
49
|
"README.md"
|
|
42
50
|
],
|
|
43
51
|
"dependencies": {
|
|
44
|
-
"dotenv": "
|
|
52
|
+
"dotenv": "18.0.4"
|
|
45
53
|
}
|
|
46
54
|
}
|
package/wb
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import WorkbenchBootstrap from "./WorkbenchBootstrap.mjs";
|
|
6
6
|
|
|
7
|
-
try {
|
|
7
|
+
try {
|
|
8
|
+
const exitCode = await new WorkbenchBootstrap().run(process.argv.slice(2));
|
|
9
|
+
if (Number.isInteger(exitCode)) process.exitCode = exitCode;
|
|
10
|
+
}
|
|
8
11
|
catch (error) {
|
|
9
12
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
10
13
|
process.exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - WorkbenchDaemonEndpoint: validated loopback publication of the running local daemon.
|
|
4
|
+
* - DaemonGitArcIdentity: harness and thread a Git arc request acts as.
|
|
5
|
+
* - DaemonGitArcRequest: one Git arc action body, without identity.
|
|
6
|
+
* - DaemonGitArcRequestOptions: environment, fetch and cancellation boundaries.
|
|
7
|
+
* - daemonGitArcIdentity: the invoking agent thread's Git arc identity, or null outside agent threads.
|
|
8
|
+
* - readDaemonEndpoint: read the bounded, loopback-only local daemon publication, or null when none is published.
|
|
9
|
+
* - requestDaemonGitArc: send one Git arc request as the invoking thread; resolves the raw JSON body for the caller to conform.
|
|
10
|
+
*/
|
|
11
|
+
export interface WorkbenchDaemonEndpoint {
|
|
12
|
+
version: 1;
|
|
13
|
+
instanceId: string;
|
|
14
|
+
pid: number;
|
|
15
|
+
origin: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DaemonGitArcIdentity {
|
|
19
|
+
harness: string;
|
|
20
|
+
threadId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DaemonGitArcRequest {
|
|
24
|
+
action: string;
|
|
25
|
+
cwd: string;
|
|
26
|
+
[field: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DaemonGitArcRequestOptions {
|
|
30
|
+
env?: NodeJS.ProcessEnv;
|
|
31
|
+
fetch?: typeof globalThis.fetch;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function daemonGitArcIdentity(env?: NodeJS.ProcessEnv): DaemonGitArcIdentity | null;
|
|
36
|
+
/** Rejects oversized, malformed or non-loopback publications so they never become a daemon address. */
|
|
37
|
+
export function readDaemonEndpoint(file: string): Promise<WorkbenchDaemonEndpoint | null>;
|
|
38
|
+
export function requestDaemonGitArc(request: DaemonGitArcRequest, options?: DaemonGitArcRequestOptions): Promise<unknown>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - daemonGitArcIdentity: the invoking agent thread's Git arc identity, or null outside agent threads.
|
|
4
|
+
* - readDaemonEndpoint: read the bounded, loopback-only local daemon publication, or null when none is published.
|
|
5
|
+
* - requestDaemonGitArc: send one Git arc request as the invoking thread to the published local daemon.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import resolveWorkbenchDataRoot from "./workbench-data-root.mjs";
|
|
10
|
+
|
|
11
|
+
const ENDPOINT_SIZE_LIMIT = 16_384;
|
|
12
|
+
const ENDPOINT_KEYS = ["instanceId", "origin", "pid", "version"];
|
|
13
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
14
|
+
|
|
15
|
+
export function daemonGitArcIdentity(env = process.env) {
|
|
16
|
+
const threadId = env.WORKBENCH_THREAD_ID || env.CODEX_THREAD_ID;
|
|
17
|
+
return threadId ? { harness: env.WORKBENCH_HARNESS || "codex", threadId } : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isLoopbackOrigin(value) {
|
|
21
|
+
if (typeof value !== "string") return false;
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(value);
|
|
24
|
+
return url.origin === value && url.protocol === "http:" && url.hostname === "127.0.0.1" && Number(url.port) > 0;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isEndpoint(value) {
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
32
|
+
return Object.keys(value).sort().join() === ENDPOINT_KEYS.join()
|
|
33
|
+
&& value.version === 1
|
|
34
|
+
&& typeof value.instanceId === "string" && UUID.test(value.instanceId)
|
|
35
|
+
&& Number.isInteger(value.pid) && value.pid >= 1
|
|
36
|
+
&& isLoopbackOrigin(value.origin);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function readDaemonEndpoint(file) {
|
|
40
|
+
let handle;
|
|
41
|
+
try {
|
|
42
|
+
handle = await fs.open(file, "r");
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error?.code === "ENOENT") return null;
|
|
45
|
+
throw new Error("Local daemon endpoint could not be opened.", { cause: error });
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const buffer = Buffer.alloc(ENDPOINT_SIZE_LIMIT + 1);
|
|
49
|
+
let size = 0;
|
|
50
|
+
while (size < buffer.length) {
|
|
51
|
+
const { bytesRead } = await handle.read(buffer, size, buffer.length - size, null);
|
|
52
|
+
if (!bytesRead) break;
|
|
53
|
+
size += bytesRead;
|
|
54
|
+
}
|
|
55
|
+
if (size === buffer.length) throw new Error("Local daemon endpoint exceeds its size limit.");
|
|
56
|
+
const parsed = JSON.parse(buffer.toString("utf8", 0, size));
|
|
57
|
+
if (!isEndpoint(parsed)) throw new Error("Local daemon endpoint is invalid.");
|
|
58
|
+
return parsed;
|
|
59
|
+
} finally {
|
|
60
|
+
await handle.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function requestDaemonGitArc(request, { env = process.env, fetch = globalThis.fetch, signal } = {}) {
|
|
65
|
+
const endpoint = await readDaemonEndpoint(path.join(resolveWorkbenchDataRoot({ environment: env }), "daemon", "runtime.json"));
|
|
66
|
+
if (!endpoint) throw new Error("The Workbench daemon has not published an endpoint.");
|
|
67
|
+
const response = await fetch(new URL("/daemon/git-arc", endpoint.origin), {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "Content-Type": "application/json" },
|
|
70
|
+
body: JSON.stringify({ ...request, ...daemonGitArcIdentity(env) }),
|
|
71
|
+
redirect: "error",
|
|
72
|
+
signal,
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok) throw new Error(`Git arc ${request.action} request failed (HTTP ${response.status}).`);
|
|
75
|
+
return await response.json();
|
|
76
|
+
}
|