@inthedark/wb 0.0.0-stage → 0.0.1
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 +60 -3
- package/StoreCommandRunner.mjs +50 -0
- package/WorkbenchBootstrap.mjs +240 -0
- package/WorkbenchBootstrapCommand.mjs +140 -0
- package/WorkbenchBootstrapPrompt.mjs +71 -0
- package/index.d.ts +25 -0
- package/index.mjs +100 -0
- package/package.json +37 -4
- package/store-definitions.mjs +91 -0
- package/wb +11 -0
package/README.md
CHANGED
|
@@ -1,3 +1,60 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
|
|
2
|
+
# workbench
|
|
3
|
+
|
|
4
|
+
an agentic ide about the human
|
|
5
|
+
|
|
6
|
+
## how 2 use
|
|
7
|
+
```bash
|
|
8
|
+
# install & open
|
|
9
|
+
npx @inthedark/wb
|
|
10
|
+
|
|
11
|
+
# set up daemon on another device
|
|
12
|
+
npx @inthedark/wb connect
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## features
|
|
16
|
+
|
|
17
|
+
key features:
|
|
18
|
+
- fully customisable agent instructions with support for comments & imports
|
|
19
|
+
- strong thread transcript UX for when you need to keep an eye on a thread (command matching + html elements for agents)
|
|
20
|
+
- strong thread management UX for when you're managing LOTS of threads (thread status-sorted sidebar)
|
|
21
|
+
- better shared workspace support than any other agentic ide, because a lot of work is not worktree->pr shaped; agents claim the files they need and other agents don't freak out about unclaimed dirt
|
|
22
|
+
- near-identical agent behaviour across providers (codex, opencode 2, claude code) as if they're the same harness
|
|
23
|
+
- agents can orchestrate subagents working in threads across providers and projects
|
|
24
|
+
- customisable voice to text ("what if voice to text was like working in a rich text editor")
|
|
25
|
+
- git UI for doing your own work at the same time as other agents, allowing easy commits while agents have other files claimed
|
|
26
|
+
- quickly switch between saved profiles for harness/model/effort/window/fast mode/agent identity
|
|
27
|
+
- automatic compaction on turn start past configurable time & token thresholds (reduce cache write costs)
|
|
28
|
+
- agents can search repos with shell tools like they're on disk via vfs
|
|
29
|
+
|
|
30
|
+
setup/meta:
|
|
31
|
+
- built on top of other harnesses (codex, opencode 2, claude code) so you can use subscriptions
|
|
32
|
+
- tailscale integration
|
|
33
|
+
- daemon sleeps when idle, leaving only a light http wake service up
|
|
34
|
+
|
|
35
|
+
unpolished/unfinished/broken stuff (coming soon tm!!!!!):
|
|
36
|
+
- soon, autocomplete/suggestions extension for vscode & similar (highly configurable, able to be FAST)
|
|
37
|
+
- 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)
|
|
38
|
+
- browse AX & UX
|
|
39
|
+
- search UX
|
|
40
|
+
- i'd like more git ui
|
|
41
|
+
- many places around the app could use a UI polish pass
|
|
42
|
+
- the codebase itself could use a polish pass, esp. the react & tailwind
|
|
43
|
+
|
|
44
|
+
## tech
|
|
45
|
+
|
|
46
|
+
note: have tried to keep dependencies minimal for the most part
|
|
47
|
+
|
|
48
|
+
`/package` — the entrypoint/installer, what actually gets published to npm
|
|
49
|
+
`/daemon/host` — wake service
|
|
50
|
+
`/daemon/server` — workbench harness
|
|
51
|
+
`/daemon/voice` - vtt service (rust)
|
|
52
|
+
`/instructions` — instructions for agents, copied into the user's `~/.workbench` folder whenever read
|
|
53
|
+
`/app/server` — server for frontend app
|
|
54
|
+
`/app/client` — frontend SPA
|
|
55
|
+
`/app/tray` — system tray application for the frontend app (rust/tauri)
|
|
56
|
+
`/shared` — shared stuff between the projects
|
|
57
|
+
`/shared/network/native` — tailscale integration (go)
|
|
58
|
+
`/test` — test runner
|
|
59
|
+
`/scripts` — stuff used by package scripts plus anything i didn't notice an agent left behind :3
|
|
60
|
+
`/docs` — help agents find things and not break them without eating all their tokens
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - default StoreCommandRunner: run one store lookup argv without a shell and report bounded, sanitised outcomes.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
10
|
+
const PACKAGED_WB = path.join(path.dirname(fileURLToPath(import.meta.url)), "wb");
|
|
11
|
+
|
|
12
|
+
export default class StoreCommandRunner {
|
|
13
|
+
constructor({ maxOutputBytes = MAX_OUTPUT_BYTES, packagedWb = PACKAGED_WB } = {}) {
|
|
14
|
+
this.maxOutputBytes = maxOutputBytes;
|
|
15
|
+
this.packagedWb = packagedWb;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolves `{ ok: true, stdout }` or `{ ok: false, reason, exitCode? }`; rejects only when `signal` aborts. */
|
|
19
|
+
async run(argv, { signal } = {}) {
|
|
20
|
+
signal?.throwIfAborted();
|
|
21
|
+
// `wb` means this package's own bootstrap so lookups work without PATH setup.
|
|
22
|
+
const [executable, ...args] = argv[0] === "wb" ? [process.execPath, this.packagedWb, ...argv.slice(1)] : argv;
|
|
23
|
+
return await new Promise((resolve, reject) => {
|
|
24
|
+
const child = spawn(executable, args, { shell: false, signal, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
25
|
+
const chunks = [];
|
|
26
|
+
let length = 0;
|
|
27
|
+
let overflow = false;
|
|
28
|
+
child.stdout.on("data", chunk => {
|
|
29
|
+
if (overflow) return;
|
|
30
|
+
length += chunk.length;
|
|
31
|
+
if (length > this.maxOutputBytes) {
|
|
32
|
+
overflow = true;
|
|
33
|
+
child.kill();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
chunks.push(chunk);
|
|
37
|
+
});
|
|
38
|
+
child.once("error", error => {
|
|
39
|
+
if (signal?.aborted) reject(signal.reason);
|
|
40
|
+
else resolve({ ok: false, reason: error.code === "ENOENT" ? "missing-command" : "spawn-failed" });
|
|
41
|
+
});
|
|
42
|
+
child.once("close", exitCode => {
|
|
43
|
+
if (signal?.aborted) return reject(signal.reason);
|
|
44
|
+
if (overflow) return resolve({ ok: false, reason: "output-limit" });
|
|
45
|
+
if (exitCode !== 0) return resolve({ ok: false, reason: "exit", exitCode });
|
|
46
|
+
resolve({ ok: true, stdout: Buffer.concat(chunks).toString("utf8") });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - WorkbenchBootstrap (default): locates or installs an editable checkout and delegates to it.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import { constants } from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { DatabaseSync } from "node:sqlite";
|
|
12
|
+
import WorkbenchBootstrapPrompt from "./WorkbenchBootstrapPrompt.mjs";
|
|
13
|
+
import WorkbenchBootstrapCommand from "./WorkbenchBootstrapCommand.mjs";
|
|
14
|
+
|
|
15
|
+
export default class WorkbenchBootstrap {
|
|
16
|
+
constructor({
|
|
17
|
+
home = os.homedir(),
|
|
18
|
+
packageRoot = path.dirname(fileURLToPath(import.meta.url)),
|
|
19
|
+
environment = process.env,
|
|
20
|
+
prompt = new WorkbenchBootstrapPrompt(),
|
|
21
|
+
commands = new WorkbenchBootstrapCommand(),
|
|
22
|
+
write = text => process.stdout.write(text),
|
|
23
|
+
} = {}) {
|
|
24
|
+
this.home = home;
|
|
25
|
+
this.packageRoot = packageRoot;
|
|
26
|
+
this.environment = environment;
|
|
27
|
+
this.prompt = prompt;
|
|
28
|
+
this.commands = commands;
|
|
29
|
+
this.write = write;
|
|
30
|
+
this.registry = path.join(home, ".workbench", "installation.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async checkoutExists(root) {
|
|
34
|
+
try {
|
|
35
|
+
const manifest = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
|
|
36
|
+
if (manifest.name !== "workbench-root") return false;
|
|
37
|
+
await fs.access(path.join(root, "wb"));
|
|
38
|
+
await fs.access(path.join(root, "installation", "install.mjs"));
|
|
39
|
+
await fs.access(path.join(root, "cli", "dispatch.mjs"));
|
|
40
|
+
return true;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code === "ENOENT") return false;
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async checkoutState(root) {
|
|
48
|
+
if (await this.checkoutExists(root)) return "usable";
|
|
49
|
+
try {
|
|
50
|
+
await fs.lstat(root);
|
|
51
|
+
return "invalid";
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code === "ENOENT") return "missing";
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async readInstallation() {
|
|
59
|
+
try {
|
|
60
|
+
const record = JSON.parse(await fs.readFile(this.registry, "utf8"));
|
|
61
|
+
if (record.version !== 1 || typeof record.root !== "string" || !path.isAbsolute(record.root)
|
|
62
|
+
|| !["cloning", "setup", "ready"].includes(record.phase)) {
|
|
63
|
+
throw new Error(`Invalid Workbench installation record: ${this.registry}. Existing files were not changed.`);
|
|
64
|
+
}
|
|
65
|
+
return record;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.code === "ENOENT") return null;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async writeInstallation(record) {
|
|
73
|
+
const candidate = `${this.registry}.${randomUUID()}.tmp`;
|
|
74
|
+
await fs.writeFile(candidate, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
75
|
+
await fs.rename(candidate, this.registry);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async requireTool(command, cwd) {
|
|
79
|
+
try {
|
|
80
|
+
await this.commands.run(command, ["--version"], {
|
|
81
|
+
cwd,
|
|
82
|
+
output: { write() {} },
|
|
83
|
+
errorOutput: { write() {} },
|
|
84
|
+
});
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error.code !== "ENOENT") throw error;
|
|
87
|
+
throw new Error(`${command} is required to install Workbench. Install it and make it available on PATH, then retry.`, { cause: error });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async discardIncomplete(record) {
|
|
92
|
+
const root = path.resolve(record.root);
|
|
93
|
+
if (root !== record.root || !["wb", "workbench"].includes(path.basename(root).toLowerCase())) {
|
|
94
|
+
throw new Error(`Cannot verify failed install destination ${record.root}; it was preserved.`);
|
|
95
|
+
}
|
|
96
|
+
let stat;
|
|
97
|
+
try { stat = await fs.lstat(root); }
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error.code !== "ENOENT") throw error;
|
|
100
|
+
await fs.unlink(this.registry);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
104
|
+
throw new Error(`Failed install destination ${root} is not an ordinary directory; it was preserved.`);
|
|
105
|
+
}
|
|
106
|
+
if (await fs.realpath(root) !== root) {
|
|
107
|
+
throw new Error(`Failed install destination ${root} resolves elsewhere; it was preserved.`);
|
|
108
|
+
}
|
|
109
|
+
if ((await fs.readdir(root)).length) {
|
|
110
|
+
if (!await this.checkoutExists(root)) {
|
|
111
|
+
throw new Error(`Failed install destination ${root} is not a complete Workbench checkout; it was preserved.`);
|
|
112
|
+
}
|
|
113
|
+
const git = await fs.lstat(path.join(root, ".git")).catch(error => {
|
|
114
|
+
if (error.code === "ENOENT") return null;
|
|
115
|
+
throw error;
|
|
116
|
+
});
|
|
117
|
+
if (!git?.isDirectory() || git.isSymbolicLink()) {
|
|
118
|
+
throw new Error(`Failed install destination ${root} is not a regular Git clone; it was preserved.`);
|
|
119
|
+
}
|
|
120
|
+
let changes = "";
|
|
121
|
+
await this.commands.run("git", ["status", "--porcelain=v1", "--untracked-files=all"], {
|
|
122
|
+
cwd: root,
|
|
123
|
+
output: { write: bytes => { changes += bytes.toString(); } },
|
|
124
|
+
errorOutput: { write() {} },
|
|
125
|
+
});
|
|
126
|
+
if (changes.trim()) throw new Error(`Failed install destination ${root} has changes; it was preserved.`);
|
|
127
|
+
}
|
|
128
|
+
await fs.rm(root, { recursive: true });
|
|
129
|
+
await fs.unlink(this.registry);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async run(args) {
|
|
133
|
+
const managed = this.environment.WORKBENCH_THREAD_ID || this.environment.CODEX_THREAD_ID;
|
|
134
|
+
const view = args[0] === "view" && (args.length === 1 || args.length === 2 && ["daemon", "app"].includes(args[1]));
|
|
135
|
+
const humanCommand = view || args.length === 0 || (args.length === 1 && ["connect", "disconnect", "shortcut"].includes(args[0]));
|
|
136
|
+
if (managed && humanCommand) throw new Error("Managed threads cannot install or launch Workbench services.");
|
|
137
|
+
const linked = path.resolve(this.packageRoot, "..");
|
|
138
|
+
if (await this.checkoutExists(linked)) {
|
|
139
|
+
return await this.delegate(linked, args);
|
|
140
|
+
}
|
|
141
|
+
const installed = await this.readInstallation();
|
|
142
|
+
if (installed?.phase === "ready") {
|
|
143
|
+
const state = await this.checkoutState(installed.root);
|
|
144
|
+
if (state === "usable") return await this.delegate(installed.root, args);
|
|
145
|
+
if (state === "invalid") {
|
|
146
|
+
throw new Error(`Workbench checkout is unavailable at ${installed.root}. Restore or repair that installation; no replacement was created.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (managed || !(args.length === 0 || (args.length === 1 && args[0] === "connect"))) {
|
|
150
|
+
throw new Error("Workbench is not installed. Run wb or wb connect in an interactive terminal first.");
|
|
151
|
+
}
|
|
152
|
+
await this.requireTool("git");
|
|
153
|
+
await this.requireTool("bash");
|
|
154
|
+
await this.requireTool("vp");
|
|
155
|
+
const warning = installed?.phase === "ready"
|
|
156
|
+
? `\nNote: Previous installation at ${installed.root} no longer exists and will be recreated.`
|
|
157
|
+
: installed ? `\nNote: Incomplete install attempt at ${installed.root} will be removed.` : "";
|
|
158
|
+
const choice = await this.prompt.choose(
|
|
159
|
+
`Workbench is not installed. This command will clone the repository and run build commands. It may take 1-2 minutes. Continue?${warning}`,
|
|
160
|
+
["Let's go!", "Cancel"],
|
|
161
|
+
);
|
|
162
|
+
if (choice !== "Let's go!") throw new DOMException("Setup cancelled.", "AbortError");
|
|
163
|
+
const defaultRoot = installed?.root ?? (process.platform === "win32"
|
|
164
|
+
? path.join(this.environment.LOCALAPPDATA || path.join(this.home, "AppData", "Local"), "Programs", "inthedark", "wb")
|
|
165
|
+
: path.join(this.home, ".local", "lib", "inthedark", "wb"));
|
|
166
|
+
const root = path.resolve(await this.prompt.location("Install location", defaultRoot));
|
|
167
|
+
await fs.mkdir(path.dirname(this.registry), { recursive: true });
|
|
168
|
+
const lockPath = `${this.registry}.lock.sqlite3`;
|
|
169
|
+
const lock = new DatabaseSync(lockPath);
|
|
170
|
+
try {
|
|
171
|
+
lock.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE");
|
|
172
|
+
} catch (error) {
|
|
173
|
+
lock.close();
|
|
174
|
+
if (error.errcode === 5 || error.errcode === 6) {
|
|
175
|
+
throw new Error("Another Workbench installation is running. Let it finish before trying again.");
|
|
176
|
+
}
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
// Another invocation may have finished while this one was accepting input.
|
|
181
|
+
const latest = await this.readInstallation();
|
|
182
|
+
if (latest?.phase === "ready") {
|
|
183
|
+
const state = await this.checkoutState(latest.root);
|
|
184
|
+
if (state === "usable") return await this.delegate(latest.root, args);
|
|
185
|
+
if (state === "invalid") {
|
|
186
|
+
throw new Error(`Workbench checkout is unavailable at ${latest.root}. Restore or repair that installation; no replacement was created.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (latest?.root !== installed?.root || latest?.phase !== installed?.phase) {
|
|
190
|
+
throw new Error("The selected Workbench installation changed while setup was open. Run wb again.");
|
|
191
|
+
}
|
|
192
|
+
if (latest && latest.phase !== "ready") await this.discardIncomplete(latest);
|
|
193
|
+
await this.requireEmptyDestination(root);
|
|
194
|
+
let record = { version: 1, root, phase: "cloning" };
|
|
195
|
+
await this.writeInstallation(record);
|
|
196
|
+
await this.prompt.withHeader(async () => {
|
|
197
|
+
this.write(`Installing Workbench at ${root}\n`);
|
|
198
|
+
try {
|
|
199
|
+
// Install sandboxes substitute a local snapshot; ordinary installs clone the public repository.
|
|
200
|
+
const repository = this.environment.WORKBENCH_INSTALL_REPOSITORY || "https://github.com/ErisInTheDark/workbench.git";
|
|
201
|
+
await this.commands.run("git", ["clone", "--progress", "--branch", "main", "--single-branch", repository, root]);
|
|
202
|
+
if (!await this.checkoutExists(root)) throw new Error("Clone did not produce a Workbench checkout.");
|
|
203
|
+
record = { ...record, phase: "setup" };
|
|
204
|
+
await this.writeInstallation(record);
|
|
205
|
+
await this.commands.run(process.execPath, [path.join(root, "installation", "install.mjs"), "--prepare"], { cwd: root });
|
|
206
|
+
} catch (error) {
|
|
207
|
+
try { await this.discardIncomplete(record); }
|
|
208
|
+
catch (cleanupError) {
|
|
209
|
+
throw new Error(`${error.message} Incomplete checkout was preserved at ${root}: ${cleanupError.message}`, { cause: error });
|
|
210
|
+
}
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
record = { ...record, phase: "ready" };
|
|
215
|
+
await this.writeInstallation(record);
|
|
216
|
+
} finally {
|
|
217
|
+
// Closing the connection also releases the OS lock after normal completion;
|
|
218
|
+
// process death releases it without a stale PID or lock-file recovery path.
|
|
219
|
+
lock.close();
|
|
220
|
+
}
|
|
221
|
+
// Persist readiness before optional platform actions: missing native artifacts
|
|
222
|
+
// must not turn a usable source installation into an endless setup loop.
|
|
223
|
+
await this.commands.run(process.execPath, [path.join(root, "installation", "install.mjs"),
|
|
224
|
+
args[0] === "connect" ? "--connect" : "--welcome"], { cwd: root, interactive: true });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async delegate(root, args) {
|
|
228
|
+
return await this.commands.run(process.execPath, [path.join(root, "cli", "dispatch.mjs"), ...args], { interactive: true });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async requireEmptyDestination(root) {
|
|
232
|
+
try {
|
|
233
|
+
if ((await fs.readdir(root)).length) throw new Error(`Installation destination must be empty: ${root}. Existing files were preserved.`);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error.code !== "ENOENT") throw error;
|
|
236
|
+
}
|
|
237
|
+
await fs.mkdir(path.dirname(root), { recursive: true });
|
|
238
|
+
await fs.access(path.dirname(root), constants.W_OK);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - WorkbenchBootstrapCommand (default): owns bootstrap child commands, Windows executable lookup (including Git Bash), and failures.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
export default class WorkbenchBootstrapCommand {
|
|
10
|
+
constructor({ output = process.stdout, errorOutput = process.stderr, environment = process.env } = {}) {
|
|
11
|
+
this.output = output;
|
|
12
|
+
this.errorOutput = errorOutput;
|
|
13
|
+
this.environment = environment;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Environment variable by name; Windows names are case-insensitive, but copied env objects are not. */
|
|
17
|
+
variable(name) {
|
|
18
|
+
if (process.platform !== "win32") return this.environment[name];
|
|
19
|
+
const key = Object.keys(this.environment).find(candidate => candidate.toLowerCase() === name.toLowerCase());
|
|
20
|
+
return key === undefined ? undefined : this.environment[key];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async resolve(command) {
|
|
24
|
+
if (process.platform === "win32" && command === "bash") return [await this.resolveBash()];
|
|
25
|
+
if (process.platform !== "win32" || !["npm", "pnpm"].includes(command)) return [command];
|
|
26
|
+
const directories = [path.dirname(process.execPath), ...(this.variable("PATH") || "").split(path.delimiter)];
|
|
27
|
+
for (const directory of directories) {
|
|
28
|
+
if (!directory) continue;
|
|
29
|
+
const executable = path.join(directory, `${command}.exe`);
|
|
30
|
+
try {
|
|
31
|
+
await fs.access(executable);
|
|
32
|
+
return [executable];
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error.code !== "ENOENT") throw error;
|
|
35
|
+
}
|
|
36
|
+
// npm/pnpm's Windows cmd wrappers cannot safely forward arbitrary paths.
|
|
37
|
+
// Invoke their installed Node entry instead of adding a command-shell layer.
|
|
38
|
+
const entry = command === "npm" ? "npm/bin/npm-cli.js" : "pnpm/bin/pnpm.cjs";
|
|
39
|
+
const script = path.join(directory, "node_modules", entry);
|
|
40
|
+
try {
|
|
41
|
+
await fs.access(script);
|
|
42
|
+
return [process.execPath, script];
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code !== "ENOENT") throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`Cannot locate ${command}'s executable or Node entry. Install ${command} and ensure it is on PATH.`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Windows bash that can run Workbench. Git for Windows usually puts only git on PATH, and the
|
|
52
|
+
* bash.exe Windows does find is often WSL's launcher, which runs scripts inside Linux.
|
|
53
|
+
*/
|
|
54
|
+
async resolveBash() {
|
|
55
|
+
const exists = async file => {
|
|
56
|
+
try { await fs.access(file); return true; }
|
|
57
|
+
catch (error) { if (error.code === "ENOENT") return false; throw error; }
|
|
58
|
+
};
|
|
59
|
+
const directories = (this.variable("PATH") || "").split(path.delimiter).filter(Boolean);
|
|
60
|
+
const local = this.variable("LOCALAPPDATA");
|
|
61
|
+
const launchers = [
|
|
62
|
+
path.join(this.variable("SystemRoot") || "C:\\Windows", "System32"),
|
|
63
|
+
...(local ? [path.join(local, "Microsoft", "WindowsApps")] : []),
|
|
64
|
+
].map(directory => path.resolve(directory).toLowerCase());
|
|
65
|
+
let sawLauncher = false;
|
|
66
|
+
for (const directory of directories) {
|
|
67
|
+
const bash = path.join(directory, "bash.exe");
|
|
68
|
+
if (!await exists(bash)) continue;
|
|
69
|
+
if (launchers.includes(path.resolve(directory).toLowerCase())) sawLauncher = true;
|
|
70
|
+
else return bash;
|
|
71
|
+
}
|
|
72
|
+
// git.exe lives in Git\cmd, Git\bin or Git\mingw64\bin; Git Bash is always Git\bin\bash.exe.
|
|
73
|
+
for (const directory of directories) {
|
|
74
|
+
if (!await exists(path.join(directory, "git.exe"))) continue;
|
|
75
|
+
const parent = path.dirname(directory);
|
|
76
|
+
const root = path.basename(parent).toLowerCase() === "mingw64" ? path.dirname(parent) : parent;
|
|
77
|
+
const bash = path.join(root, "bin", "bash.exe");
|
|
78
|
+
if (await exists(bash)) return bash;
|
|
79
|
+
}
|
|
80
|
+
for (const base of [this.variable("ProgramFiles"), local && path.join(local, "Programs")]) {
|
|
81
|
+
if (!base) continue;
|
|
82
|
+
const bash = path.join(base, "Git", "bin", "bash.exe");
|
|
83
|
+
if (await exists(bash)) return bash;
|
|
84
|
+
}
|
|
85
|
+
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.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async run(command, args, { cwd, signal, onOutput, environment = {}, output = this.output, errorOutput = this.errorOutput, interactive = false } = {}) {
|
|
89
|
+
signal?.throwIfAborted();
|
|
90
|
+
const [executable, ...prefix] = await this.resolve(command);
|
|
91
|
+
signal?.throwIfAborted();
|
|
92
|
+
return await new Promise((resolve, reject) => {
|
|
93
|
+
const child = spawn(executable, [...prefix, ...args], {
|
|
94
|
+
cwd,
|
|
95
|
+
env: { ...this.environment, ...environment },
|
|
96
|
+
windowsHide: true,
|
|
97
|
+
stdio: interactive ? "inherit" : ["ignore", "pipe", "pipe"],
|
|
98
|
+
signal,
|
|
99
|
+
});
|
|
100
|
+
let failure;
|
|
101
|
+
const relay = signal => { child.kill(signal); };
|
|
102
|
+
const interrupt = () => relay("SIGINT");
|
|
103
|
+
const terminate = () => relay("SIGTERM");
|
|
104
|
+
const hangup = () => relay("SIGHUP");
|
|
105
|
+
if (interactive) {
|
|
106
|
+
process.on("SIGINT", interrupt);
|
|
107
|
+
process.on("SIGTERM", terminate);
|
|
108
|
+
process.on("SIGHUP", hangup);
|
|
109
|
+
}
|
|
110
|
+
child.on("error", error => { failure = error; });
|
|
111
|
+
const forward = (stream, output) => {
|
|
112
|
+
stream.on("data", bytes => {
|
|
113
|
+
output.write(bytes);
|
|
114
|
+
if (onOutput) {
|
|
115
|
+
try { onOutput(bytes.toString()); }
|
|
116
|
+
catch (error) { failure = error; child.kill(); }
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
if (!interactive) {
|
|
121
|
+
forward(child.stdout, output);
|
|
122
|
+
forward(child.stderr, errorOutput);
|
|
123
|
+
}
|
|
124
|
+
child.once("close", (code, exitSignal) => {
|
|
125
|
+
if (interactive) {
|
|
126
|
+
process.off("SIGINT", interrupt);
|
|
127
|
+
process.off("SIGTERM", terminate);
|
|
128
|
+
process.off("SIGHUP", hangup);
|
|
129
|
+
}
|
|
130
|
+
if (failure?.code === "ENOENT") {
|
|
131
|
+
reject(Object.assign(new Error(`Cannot start ${command}: executable not found. Install it and make it available on PATH, then retry.`, { cause: failure }), {
|
|
132
|
+
code: "ENOENT",
|
|
133
|
+
}));
|
|
134
|
+
} else if (failure) reject(failure);
|
|
135
|
+
else if (code === 0) resolve();
|
|
136
|
+
else reject(Object.assign(new Error(`${command} failed with ${exitSignal ? `signal ${exitSignal}` : `status ${code ?? "unknown"}`}.`), { exitCode: code ?? 1 }));
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - WorkbenchBootstrapPrompt (default): owns the stable dependency-free consent and destination prompts used before cloning.
|
|
4
|
+
*/
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
|
|
9
|
+
export default class WorkbenchBootstrapPrompt {
|
|
10
|
+
constructor({
|
|
11
|
+
input = process.stdin,
|
|
12
|
+
output = process.stdout,
|
|
13
|
+
platform = process.platform,
|
|
14
|
+
cwd = process.cwd(),
|
|
15
|
+
home = os.homedir(),
|
|
16
|
+
ask,
|
|
17
|
+
} = {}) {
|
|
18
|
+
this.input = input;
|
|
19
|
+
this.output = output;
|
|
20
|
+
this.paths = platform === "win32" ? path.win32 : path.posix;
|
|
21
|
+
this.windows = platform === "win32";
|
|
22
|
+
this.cwd = cwd;
|
|
23
|
+
this.home = home;
|
|
24
|
+
this.ask = ask ?? (question => this.question(question));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
suffix(value) {
|
|
28
|
+
const name = this.paths.basename(value);
|
|
29
|
+
const comparable = this.windows ? name.toLowerCase() : name;
|
|
30
|
+
if (comparable === "wb" || comparable === "workbench") return "";
|
|
31
|
+
return `${value.endsWith(this.paths.sep) || (this.windows && value.endsWith("/")) ? "" : this.paths.sep}wb`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
destination(value) {
|
|
35
|
+
if (!value.trim()) throw new Error("Choose an installation directory.");
|
|
36
|
+
if (/[\u0000-\u001f\u007f]/u.test(value)) throw new Error("Installation paths cannot contain control characters.");
|
|
37
|
+
const expanded = value === "~" ? this.home
|
|
38
|
+
: value.startsWith("~/") || (this.windows && value.startsWith("~\\"))
|
|
39
|
+
? this.paths.join(this.home, value.slice(2)) : value;
|
|
40
|
+
return this.paths.resolve(this.cwd, `${expanded}${this.suffix(expanded)}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async choose(label, choices) {
|
|
44
|
+
if (!choices.length) throw new Error("A setup choice needs options.");
|
|
45
|
+
const options = choices.map((choice, index) => `${index + 1}) ${choice}`).join(" ");
|
|
46
|
+
while (true) {
|
|
47
|
+
const answer = (await this.ask(`${label}\n${options}\nChoose [1]: `)).trim();
|
|
48
|
+
const selected = answer === "" ? 0 : Number(answer) - 1;
|
|
49
|
+
if (Number.isInteger(selected) && selected >= 0 && selected < choices.length) return choices[selected];
|
|
50
|
+
this.output.write(`Choose a number from 1 to ${choices.length}.\n`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async location(label, initialValue) {
|
|
55
|
+
const answer = await this.ask(`${label} [${initialValue}]: `);
|
|
56
|
+
return this.destination(answer.trim() || initialValue);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
withHeader(task) {
|
|
60
|
+
return task();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async question(text) {
|
|
64
|
+
if (!this.input.isTTY || !this.output.isTTY) {
|
|
65
|
+
throw new Error("Workbench setup needs an interactive terminal; no changes were accepted.");
|
|
66
|
+
}
|
|
67
|
+
const terminal = createInterface({ input: this.input, output: this.output });
|
|
68
|
+
try { return await terminal.question(text); }
|
|
69
|
+
finally { terminal.close(); }
|
|
70
|
+
}
|
|
71
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - WorkbenchConfigOptions: dotenv-compatible loading options plus cancellation and warning hooks.
|
|
4
|
+
* - WorkbenchConfigOutput: parsed values after store replacement, with the first file read error.
|
|
5
|
+
* - config: load dotenv files, then replace resolvable `${store:key}` references through named WB_STORES commands.
|
|
6
|
+
*/
|
|
7
|
+
export interface WorkbenchConfigOptions {
|
|
8
|
+
/** One file or several; earlier files win unless `override` is set. Defaults to `.env`. */
|
|
9
|
+
path?: string | readonly string[];
|
|
10
|
+
encoding?: BufferEncoding;
|
|
11
|
+
/** Replace variables already present in `processEnv`, as dotenv does. */
|
|
12
|
+
override?: boolean;
|
|
13
|
+
processEnv?: Record<string, string | undefined>;
|
|
14
|
+
/** Aborting kills running store commands and rejects. */
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
/** Receives bounded messages that never contain command output. Defaults to `process.emitWarning`. */
|
|
17
|
+
warn?: (message: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorkbenchConfigOutput {
|
|
21
|
+
parsed: Record<string, string>;
|
|
22
|
+
error?: Error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function config(options?: WorkbenchConfigOptions): Promise<WorkbenchConfigOutput>;
|
package/index.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - config: load dotenv files, then replace resolvable `${store:key}` references through named WB_STORES commands.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import dotenv from "dotenv";
|
|
8
|
+
import StoreCommandRunner from "./StoreCommandRunner.mjs";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_STORE_DEFINITIONS,
|
|
11
|
+
expandStoreCommand,
|
|
12
|
+
findStoreReferences,
|
|
13
|
+
parseStoreDefinitions,
|
|
14
|
+
} from "./store-definitions.mjs";
|
|
15
|
+
|
|
16
|
+
function warnDefault(message) {
|
|
17
|
+
process.emitWarning(message, "WorkbenchStoreWarning");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isManagedAgentProcess() {
|
|
21
|
+
return Boolean(process.env.WORKBENCH_THREAD_ID?.trim() || process.env.CODEX_THREAD_ID?.trim());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stripTerminalLineEnding(value) {
|
|
25
|
+
return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function readParsed(paths, encoding, override) {
|
|
29
|
+
const parsed = {};
|
|
30
|
+
let error;
|
|
31
|
+
for (const file of paths) {
|
|
32
|
+
try {
|
|
33
|
+
const values = dotenv.parse(await fs.readFile(path.resolve(file), { encoding }));
|
|
34
|
+
for (const [name, value] of Object.entries(values)) {
|
|
35
|
+
if (override || !Object.hasOwn(parsed, name)) parsed[name] = value;
|
|
36
|
+
}
|
|
37
|
+
} catch (failure) {
|
|
38
|
+
error ??= failure;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { parsed, error };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function resolveReferences(parsed, definitions, { runner, signal, warn }) {
|
|
45
|
+
let stores;
|
|
46
|
+
try {
|
|
47
|
+
stores = parseStoreDefinitions(definitions);
|
|
48
|
+
} catch (failure) {
|
|
49
|
+
warn(`${failure.message} Store references were left unchanged.`);
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
const references = new Map(Object.entries(parsed).map(([name, value]) => [name, findStoreReferences(value)]));
|
|
53
|
+
const lookups = new Map();
|
|
54
|
+
for (const [name, found] of references) {
|
|
55
|
+
for (const { key, store } of found) {
|
|
56
|
+
const id = JSON.stringify([store, key]);
|
|
57
|
+
if (stores.has(store) && !lookups.has(id)) lookups.set(id, { argv: expandStoreCommand(stores.get(store), key), owner: name, store });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!lookups.size) return parsed;
|
|
61
|
+
if (isManagedAgentProcess()) {
|
|
62
|
+
warn("Store lookups are disabled in Workbench-managed agent processes. Store references were left unchanged.");
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
const results = new Map(await Promise.all(Array.from(lookups, async ([id, lookup]) => {
|
|
66
|
+
const result = await runner.run(lookup.argv, { signal });
|
|
67
|
+
if (!result.ok) {
|
|
68
|
+
const detail = result.reason === "exit" ? `exited with ${result.exitCode}` : `failed (${result.reason})`;
|
|
69
|
+
warn(`The ${lookup.store} store lookup for ${lookup.owner} ${detail}. The reference was left unchanged.`);
|
|
70
|
+
}
|
|
71
|
+
return [id, result];
|
|
72
|
+
})));
|
|
73
|
+
return Object.fromEntries(Object.entries(parsed).map(([name, value]) => {
|
|
74
|
+
let next = "";
|
|
75
|
+
let cursor = 0;
|
|
76
|
+
for (const reference of references.get(name)) {
|
|
77
|
+
const result = results.get(JSON.stringify([reference.store, reference.key]));
|
|
78
|
+
next += value.slice(cursor, reference.start) + (result?.ok ? stripTerminalLineEnding(result.stdout) : reference.text);
|
|
79
|
+
cursor = reference.end;
|
|
80
|
+
}
|
|
81
|
+
return [name, next + value.slice(cursor)];
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function config({
|
|
86
|
+
path: paths = ".env",
|
|
87
|
+
encoding = "utf8",
|
|
88
|
+
override = false,
|
|
89
|
+
processEnv = process.env,
|
|
90
|
+
signal,
|
|
91
|
+
warn = warnDefault,
|
|
92
|
+
runner = new StoreCommandRunner(),
|
|
93
|
+
} = {}) {
|
|
94
|
+
signal?.throwIfAborted();
|
|
95
|
+
const { parsed: raw, error } = await readParsed(Array.isArray(paths) ? paths : [paths], encoding, override);
|
|
96
|
+
const definitions = raw.WB_STORES?.trim() || processEnv.WB_STORES?.trim() || DEFAULT_STORE_DEFINITIONS;
|
|
97
|
+
const parsed = await resolveReferences(raw, definitions, { runner, signal, warn });
|
|
98
|
+
dotenv.populate(processEnv, parsed, { override });
|
|
99
|
+
return error ? { parsed, error } : { parsed };
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inthedark/wb",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/ErisInTheDark/workbench.git",
|
|
8
|
+
"directory": "package"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.13.0"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"wb": "./wb"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./index.d.ts",
|
|
22
|
+
"default": "./index.mjs"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"wb",
|
|
27
|
+
"WorkbenchBootstrap.mjs",
|
|
28
|
+
"WorkbenchBootstrapCommand.mjs",
|
|
29
|
+
"WorkbenchBootstrapPrompt.mjs",
|
|
30
|
+
"index.mjs",
|
|
31
|
+
"index.d.ts",
|
|
32
|
+
"store-definitions.mjs",
|
|
33
|
+
"StoreCommandRunner.mjs",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"dotenv": "^18.0.4"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - DEFAULT_STORE_DEFINITIONS: fallback WB_STORES text used when a file defines none.
|
|
4
|
+
* - parseStoreDefinitions: tokenise WB_STORES text into named argv templates without shell semantics.
|
|
5
|
+
* - expandStoreCommand: substitute one key into a template's arguments after tokenisation.
|
|
6
|
+
* - findStoreReferences: list `${store:key}` references in one value.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_STORE_DEFINITIONS = "wb: wb store get {key}";
|
|
10
|
+
|
|
11
|
+
const STORE_NAME = /^[A-Za-z0-9_-]+$/u;
|
|
12
|
+
const REFERENCE = /\$\{([A-Za-z0-9_-]+):([^}\r\n]+)\}/gu;
|
|
13
|
+
|
|
14
|
+
function tokenise(text) {
|
|
15
|
+
const definitions = [];
|
|
16
|
+
let tokens = [];
|
|
17
|
+
let token = "";
|
|
18
|
+
let started = false;
|
|
19
|
+
let quoted = false;
|
|
20
|
+
let quotedToken = false;
|
|
21
|
+
const endToken = () => {
|
|
22
|
+
if (started) tokens.push({ text: token, quoted: quotedToken });
|
|
23
|
+
token = "";
|
|
24
|
+
started = false;
|
|
25
|
+
quotedToken = false;
|
|
26
|
+
};
|
|
27
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
28
|
+
const character = text[index];
|
|
29
|
+
if (quoted) {
|
|
30
|
+
if (character === "\\" && (text[index + 1] === "\"" || text[index + 1] === "\\")) {
|
|
31
|
+
token += text[index + 1];
|
|
32
|
+
index += 1;
|
|
33
|
+
} else if (character === "\"") {
|
|
34
|
+
quoted = false;
|
|
35
|
+
} else {
|
|
36
|
+
token += character;
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (character === "\"") {
|
|
41
|
+
quoted = true;
|
|
42
|
+
started = true;
|
|
43
|
+
quotedToken = true;
|
|
44
|
+
} else if (character === ";") {
|
|
45
|
+
endToken();
|
|
46
|
+
definitions.push(tokens);
|
|
47
|
+
tokens = [];
|
|
48
|
+
} else if (/\s/u.test(character)) {
|
|
49
|
+
endToken();
|
|
50
|
+
} else {
|
|
51
|
+
token += character;
|
|
52
|
+
started = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (quoted) throw new Error("WB_STORES has an unterminated double quote.");
|
|
56
|
+
endToken();
|
|
57
|
+
definitions.push(tokens);
|
|
58
|
+
return definitions.filter(definition => definition.length);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parseStoreDefinitions(text) {
|
|
62
|
+
const stores = new Map();
|
|
63
|
+
for (const tokens of tokenise(text)) {
|
|
64
|
+
const [first, ...rest] = tokens;
|
|
65
|
+
const separator = first.quoted ? -1 : first.text.indexOf(":");
|
|
66
|
+
const name = separator > 0 ? first.text.slice(0, separator) : "";
|
|
67
|
+
if (!STORE_NAME.test(name)) throw new Error("Each WB_STORES entry must start with a store name followed by a colon.");
|
|
68
|
+
if (stores.has(name)) throw new Error(`WB_STORES defines the ${name} store more than once.`);
|
|
69
|
+
const remainder = first.text.slice(separator + 1);
|
|
70
|
+
const argv = [...(remainder ? [remainder] : []), ...rest.map(token => token.text)];
|
|
71
|
+
if (!argv.length) throw new Error(`WB_STORES gives the ${name} store no command.`);
|
|
72
|
+
if (argv[0].includes("{key}")) throw new Error(`WB_STORES cannot place {key} in the ${name} store's executable.`);
|
|
73
|
+
stores.set(name, argv);
|
|
74
|
+
}
|
|
75
|
+
return stores;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function expandStoreCommand(template, key) {
|
|
79
|
+
const [executable, ...args] = template;
|
|
80
|
+
return [executable, ...args.map(arg => arg.replaceAll("{key}", key))];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function findStoreReferences(value) {
|
|
84
|
+
return Array.from(value.matchAll(REFERENCE), match => ({
|
|
85
|
+
end: match.index + match[0].length,
|
|
86
|
+
key: match[2],
|
|
87
|
+
start: match.index,
|
|
88
|
+
store: match[1],
|
|
89
|
+
text: match[0],
|
|
90
|
+
}));
|
|
91
|
+
}
|
package/wb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* No exports. Dependency-free npm entry locates an editable Workbench installation.
|
|
4
|
+
*/
|
|
5
|
+
import WorkbenchBootstrap from "./WorkbenchBootstrap.mjs";
|
|
6
|
+
|
|
7
|
+
try { await new WorkbenchBootstrap().run(process.argv.slice(2)); }
|
|
8
|
+
catch (error) {
|
|
9
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
10
|
+
process.exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
|
|
11
|
+
}
|