@inthedark/wb 0.0.4 → 0.0.6

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 CHANGED
@@ -4,6 +4,15 @@
4
4
  an agentic ide about the human
5
5
 
6
6
  ## how 2 use
7
+
8
+ you need to install these yourself first:
9
+ 1. [git](https://git-scm.com/)
10
+ 2. node, through whatever you like. [vite+](https://viteplus.dev/) recommended
11
+
12
+ optional:
13
+ 1. [tailscale](https://tailscale.com/), if you want to connect to workbench agents on other devices
14
+
15
+ and then workbench itself is just:
7
16
  ```bash
8
17
  # install & open
9
18
  npx @inthedark/wb
@@ -48,7 +57,9 @@ note: have tried to keep dependencies minimal for the most part
48
57
  `/package` — the entrypoint/installer, what actually gets published to npm
49
58
  `/daemon/host` — wake service
50
59
  `/daemon/server` — workbench harness
51
- `/daemon/voice` - vtt service (rust)
60
+ `/daemon/voice` — vtt service (rust)
61
+ `/daemon/repo` — vfs service (go)
62
+ `/integration` — integrations in third party software
52
63
  `/instructions` — instructions for agents, copied into the user's `~/.workbench` folder whenever read
53
64
  `/app/server` — server for frontend app
54
65
  `/app/client` — frontend SPA
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
 
9
9
  const MAX_OUTPUT_BYTES = 1024 * 1024;
10
10
  const PACKAGED_WB = path.join(path.dirname(fileURLToPath(import.meta.url)), "wb");
11
+ const DANGEROUS_ACCESS = "DANGEROUSLY_ACCESS_ENV_VARS";
11
12
 
12
13
  export default class StoreCommandRunner {
13
14
  constructor({ maxOutputBytes = MAX_OUTPUT_BYTES, packagedWb = PACKAGED_WB } = {}) {
@@ -19,9 +20,16 @@ export default class StoreCommandRunner {
19
20
  async run(argv, { signal } = {}) {
20
21
  signal?.throwIfAborted();
21
22
  // `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
+ const packaged = argv[0] === "wb";
24
+ const [executable, ...args] = packaged ? [process.execPath, this.packagedWb, ...argv.slice(1)] : argv;
23
25
  return await new Promise((resolve, reject) => {
24
- const child = spawn(executable, args, { shell: false, signal, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
26
+ const child = spawn(executable, args, {
27
+ env: packaged ? { ...process.env, [DANGEROUS_ACCESS]: "1" } : process.env,
28
+ shell: false,
29
+ signal,
30
+ stdio: ["ignore", "pipe", "ignore"],
31
+ windowsHide: true,
32
+ });
25
33
  const chunks = [];
26
34
  let length = 0;
27
35
  let overflow = false;
@@ -11,21 +11,29 @@ import { fileURLToPath } from "node:url";
11
11
  import { DatabaseSync } from "node:sqlite";
12
12
  import WorkbenchInstallPrompt from "./WorkbenchInstallPrompt.mjs";
13
13
  import WorkbenchBootstrapCommand from "./WorkbenchBootstrapCommand.mjs";
14
+ import WorkbenchToolchain from "./WorkbenchToolchain.mjs";
15
+ import WorkbenchPackageRegistration from "./WorkbenchPackageRegistration.mjs";
14
16
 
15
17
  export default class WorkbenchBootstrap {
16
18
  constructor({
17
19
  home = os.homedir(),
18
20
  packageRoot = path.dirname(fileURLToPath(import.meta.url)),
19
21
  environment = process.env,
22
+ platform = process.platform,
20
23
  prompt = new WorkbenchInstallPrompt(),
21
- commands = new WorkbenchBootstrapCommand(),
24
+ commands = new WorkbenchBootstrapCommand({ environment }),
25
+ toolchain = new WorkbenchToolchain({ home, environment, commands }),
26
+ registration = new WorkbenchPackageRegistration({ home, environment, packageRoot }),
22
27
  write = text => process.stdout.write(text),
23
28
  } = {}) {
24
29
  this.home = home;
25
30
  this.packageRoot = packageRoot;
26
31
  this.environment = environment;
32
+ this.platform = platform;
27
33
  this.prompt = prompt;
28
34
  this.commands = commands;
35
+ this.toolchain = toolchain;
36
+ this.registration = registration;
29
37
  this.write = write;
30
38
  this.registry = path.join(home, ".workbench", "installation.json");
31
39
  }
@@ -35,7 +43,7 @@ export default class WorkbenchBootstrap {
35
43
  const manifest = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
36
44
  if (manifest.name !== "workbench-root") return false;
37
45
  await fs.access(path.join(root, "wb"));
38
- await fs.access(path.join(root, "installation", "install.mjs"));
46
+ await fs.access(path.join(root, "scripts", "install.mjs"));
39
47
  await fs.access(path.join(root, "cli", "dispatch.mjs"));
40
48
  return true;
41
49
  } catch (error) {
@@ -88,47 +96,6 @@ export default class WorkbenchBootstrap {
88
96
  }
89
97
  }
90
98
 
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
99
  async run(args) {
133
100
  const managed = this.environment.WORKBENCH_THREAD_ID || this.environment.CODEX_THREAD_ID;
134
101
  const view = args[0] === "view" && (args.length === 1 || args.length === 2 && ["daemon", "app"].includes(args[1]));
@@ -151,12 +118,11 @@ export default class WorkbenchBootstrap {
151
118
  }
152
119
  await this.requireTool("git");
153
120
  await this.requireTool("bash");
154
- await this.requireTool("vp");
155
121
  const warning = installed?.phase === "ready"
156
122
  ? `\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.` : "";
123
+ : installed ? `\nResuming incomplete install at ${installed.root}.` : "";
158
124
  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}`,
125
+ `Workbench is not installed. This command will provision a private Vite+ toolchain if needed, clone the repository, run build commands, and register wb through your package manager. Continue?${warning}`,
160
126
  ["Let's go!", "Cancel"],
161
127
  );
162
128
  if (choice !== "Let's go!") throw new DOMException("Setup cancelled.", "AbortError");
@@ -189,30 +155,25 @@ export default class WorkbenchBootstrap {
189
155
  if (latest?.root !== installed?.root || latest?.phase !== installed?.phase) {
190
156
  throw new Error("The selected Workbench installation changed while setup was open. Run wb again.");
191
157
  }
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);
158
+ const resume = latest?.root === root && await this.checkoutExists(root);
159
+ if (!resume) await this.requireEmptyDestination(root);
160
+ // Recording before provisioning keeps the chosen location as the next default if anything fails.
161
+ await this.writeInstallation({ version: 1, root, phase: resume ? "setup" : "cloning" });
162
+ await this.registration.prepare();
163
+ await this.toolchain.ensure();
196
164
  await this.prompt.withHeader(async () => {
197
- this.write(`Installing Workbench at ${root}\n`);
198
- try {
165
+ this.write(`${resume ? "Resuming" : "Installing"} Workbench at ${root}\n`);
166
+ if (!resume) {
199
167
  // Install sandboxes substitute a local snapshot; ordinary installs clone the public repository.
200
168
  const repository = this.environment.WORKBENCH_INSTALL_REPOSITORY || "https://github.com/ErisInTheDark/workbench.git";
201
169
  await this.commands.run("git", ["clone", "--progress", "--branch", "main", "--single-branch", repository, root]);
202
170
  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;
171
+ await this.writeInstallation({ version: 1, root, phase: "setup" });
212
172
  }
173
+ await this.commands.run(process.execPath, [path.join(root, "scripts", "install.mjs"), "--prepare"], { cwd: root });
174
+ await this.registration.register(root);
213
175
  });
214
- record = { ...record, phase: "ready" };
215
- await this.writeInstallation(record);
176
+ await this.writeInstallation({ version: 1, root, phase: "ready" });
216
177
  } finally {
217
178
  // Closing the connection also releases the OS lock after normal completion;
218
179
  // process death releases it without a stale PID or lock-file recovery path.
@@ -220,7 +181,7 @@ export default class WorkbenchBootstrap {
220
181
  }
221
182
  // Persist readiness before optional platform actions: missing native artifacts
222
183
  // 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"),
184
+ await this.commands.run(process.execPath, [path.join(root, "scripts", "install.mjs"),
224
185
  args[0] === "connect" ? "--connect" : "--welcome"], { cwd: root, interactive: true });
225
186
  }
226
187
 
@@ -20,9 +20,39 @@ export default class WorkbenchBootstrapCommand {
20
20
  return key === undefined ? undefined : this.environment[key];
21
21
  }
22
22
 
23
+ environmentFor(overrides = {}) {
24
+ const environment = { ...this.environment };
25
+ for (const [name, value] of Object.entries(overrides)) {
26
+ for (const key of Object.keys(environment)) {
27
+ if (key === name || process.platform === "win32" && key.toLowerCase() === name.toLowerCase()) delete environment[key];
28
+ }
29
+ if (value !== undefined && value !== null) environment[name] = value;
30
+ }
31
+ return environment;
32
+ }
33
+
34
+ async findExecutable(command) {
35
+ const names = process.platform === "win32" ? [`${command}.exe`] : [command];
36
+ for (const directory of (this.variable("PATH") || "").split(path.delimiter).filter(Boolean)) {
37
+ for (const name of names) {
38
+ const candidate = path.resolve(directory, name);
39
+ try {
40
+ const stat = await fs.stat(candidate);
41
+ if (stat.isFile()) return candidate;
42
+ } catch (error) {
43
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
44
+ }
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+
23
50
  async resolve(command) {
24
51
  if (process.platform === "win32" && command === "bash") return [await this.resolveBash()];
25
- if (process.platform !== "win32" || !["npm", "pnpm"].includes(command)) return [command];
52
+ if (!["npm", "pnpm"].includes(command)) return [command];
53
+ const direct = await this.findExecutable(command);
54
+ if (direct) return [direct];
55
+ if (process.platform !== "win32") throw Object.assign(new Error(`${command} is unavailable on PATH.`), { code: "ENOENT" });
26
56
  const directories = [path.dirname(process.execPath), ...(this.variable("PATH") || "").split(path.delimiter)];
27
57
  for (const directory of directories) {
28
58
  if (!directory) continue;
@@ -92,7 +122,7 @@ export default class WorkbenchBootstrapCommand {
92
122
  return await new Promise((resolve, reject) => {
93
123
  const child = spawn(executable, [...prefix, ...args], {
94
124
  cwd,
95
- env: { ...this.environment, ...environment },
125
+ env: this.environmentFor(environment),
96
126
  windowsHide: true,
97
127
  stdio: interactive ? "inherit" : ["ignore", "pipe", "pipe"],
98
128
  signal,
@@ -0,0 +1,184 @@
1
+ /*
2
+ * Exports:
3
+ * - WorkbenchPackageRegistration (default): capture the invoking package manager and register wb in its public global bin.
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 { fileURLToPath } from "node:url";
10
+ import WorkbenchBootstrapCommand from "./WorkbenchBootstrapCommand.mjs";
11
+
12
+ const managers = {
13
+ npm: {
14
+ binArgs: ["prefix", "--global"],
15
+ packageArgs: ["root", "--global"],
16
+ packagePaths: roots => roots.map(root => path.join(root, "@inthedark", "wb")),
17
+ bin: prefix => process.platform === "win32" ? prefix : path.join(prefix, "bin"),
18
+ install: (root, prefix) => ["install", "--global", "--prefix", prefix, path.join(root, "package")],
19
+ },
20
+ pnpm: {
21
+ binArgs: ["bin", "--global"],
22
+ packageArgs: ["list", "--global", "--parseable", "@inthedark/wb"],
23
+ packagePaths: roots => roots.slice(1),
24
+ bin: directory => directory,
25
+ install: root => ["add", "--global", path.join(root, "package")],
26
+ },
27
+ };
28
+
29
+ export default class WorkbenchPackageRegistration {
30
+ constructor({
31
+ environment = process.env, home = os.homedir(), nodePath = process.execPath,
32
+ packageRoot = path.dirname(fileURLToPath(import.meta.url)),
33
+ commands = new WorkbenchBootstrapCommand({ environment: { ...environment } }),
34
+ } = {}) {
35
+ this.commands = commands;
36
+ this.home = home;
37
+ this.nodePath = nodePath;
38
+ this.packageRoot = packageRoot;
39
+ }
40
+
41
+ async capture() {
42
+ const userAgent = this.commands.variable("npm_config_user_agent");
43
+ const identity = userAgent?.match(/^([a-z]+)(?:\/|\s|$)/iu)?.[1]?.toLowerCase();
44
+ const entry = this.commands.variable("npm_execpath");
45
+ const entryIdentity = entry && /(?:^|[/\\])(npm-cli\.js|npx-cli\.js|pnpm(?:\.cjs|\.js|\.exe)?)$/iu.exec(entry)?.[1];
46
+ const identified = entryIdentity && (entryIdentity.toLowerCase().startsWith("pnpm") ? "pnpm" : "npm");
47
+ if (identity && !Object.hasOwn(managers, identity)) {
48
+ throw new Error(`Automatic wb registration supports npm and pnpm, not ${identity}. Run npx @inthedark/wb or pnpm dlx @inthedark/wb.`);
49
+ }
50
+ if (identity && identified && identity !== identified) {
51
+ throw new Error("Invoking package-manager metadata conflicts. Run Workbench directly with npx or pnpm dlx.");
52
+ }
53
+ const name = identity ?? identified ?? "npm";
54
+ let command;
55
+ if (entry && identified === name) {
56
+ if (!path.isAbsolute(entry)) throw new Error("Invoking package-manager executable must be an absolute path.");
57
+ await fs.access(entry);
58
+ command = /\.(?:cjs|mjs|js)$/iu.test(entry) ? [this.nodePath, entry] : [entry];
59
+ } else {
60
+ command = await this.commands.resolve(name);
61
+ }
62
+ return { name, command };
63
+ }
64
+
65
+ async execute(selection, args, options = {}) {
66
+ const [command, ...prefix] = selection.command;
67
+ return this.commands.run(command, [...prefix, ...args], { cwd: this.home, ...options });
68
+ }
69
+
70
+ async reportPaths(selection, args) {
71
+ let output = "";
72
+ await this.execute(selection, args, { output: { write: bytes => { output += bytes.toString(); } } });
73
+ const locations = output.trim().split(/\r?\n/u);
74
+ if (locations.some(location => !location || !path.isAbsolute(location))) {
75
+ throw new Error(`${selection.name} did not report absolute global installation paths.`);
76
+ }
77
+ return locations;
78
+ }
79
+
80
+ async destination(selection) {
81
+ const locations = await this.reportPaths(selection, managers[selection.name].binArgs);
82
+ if (locations.length !== 1) throw new Error(`${selection.name} reported more than one global bin directory.`);
83
+ const [location] = locations;
84
+ return { prefix: location, bin: managers[selection.name].bin(location) };
85
+ }
86
+
87
+ async checkWritable(directory) {
88
+ let current = directory;
89
+ while (true) {
90
+ try {
91
+ await fs.access(current, constants.W_OK);
92
+ return;
93
+ } catch (error) {
94
+ if (error.code !== "ENOENT") throw new Error(`The package manager's global bin is not writable: ${directory}. Fix its permissions or configure a user-owned global prefix.`, { cause: error });
95
+ const parent = path.dirname(current);
96
+ if (parent === current) throw error;
97
+ current = parent;
98
+ }
99
+ }
100
+ }
101
+
102
+ samePath(left, right) {
103
+ const normalise = value => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value);
104
+ return normalise(left) === normalise(right);
105
+ }
106
+
107
+ async publicCommand(bin) {
108
+ for (const name of process.platform === "win32" ? ["wb.exe", "wb.cmd", "wb.bat", "wb"] : ["wb"]) {
109
+ const candidate = path.join(bin, name);
110
+ try {
111
+ if ((await fs.stat(candidate)).isFile()) return candidate;
112
+ } catch (error) {
113
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ async checkExistingCommand(bin) {
120
+ const command = await this.publicCommand(bin);
121
+ if (!command) return;
122
+ const manager = managers[this.selection.name];
123
+ const roots = await this.reportPaths(this.selection, manager.packageArgs);
124
+ for (const packageRoot of manager.packagePaths(roots)) {
125
+ try {
126
+ const manifest = JSON.parse(await fs.readFile(path.join(packageRoot, "package.json"), "utf8"));
127
+ if (manifest.name === "@inthedark/wb" && manifest.bin?.wb === "wb") {
128
+ await fs.access(path.join(packageRoot, "wb"));
129
+ return;
130
+ }
131
+ } catch (error) {
132
+ if (error.code !== "ENOENT") throw error;
133
+ }
134
+ }
135
+ throw new Error(`The global bin already contains ${command}, but ${this.selection.name} does not own a Workbench package there. Resolve that conflict before installing.`);
136
+ }
137
+
138
+ async checkPath(bin) {
139
+ const directories = (this.commands.variable("PATH") || "").split(path.delimiter).filter(Boolean);
140
+ if (!directories.some(directory => this.samePath(directory, bin))) {
141
+ throw new Error(`${bin} is ${this.selection.name}'s global bin but is not on PATH. Add that directory to your PATH, open a new terminal, and retry.`);
142
+ }
143
+ const names = process.platform === "win32" ? ["wb.exe", "wb.cmd", "wb.bat", "wb"] : ["wb"];
144
+ const ownEntry = await fs.realpath(path.join(this.packageRoot, "wb"));
145
+ for (const directory of directories) {
146
+ if (this.samePath(directory, bin)) {
147
+ await this.checkExistingCommand(bin);
148
+ return;
149
+ }
150
+ for (const name of names) {
151
+ const candidate = path.join(directory, name);
152
+ let target;
153
+ try { target = await fs.realpath(candidate); }
154
+ catch (error) { if (error.code === "ENOENT" || error.code === "ENOTDIR") continue; throw error; }
155
+ if (this.samePath(target, ownEntry)) continue;
156
+ // npm's npx Windows wrappers sit beside the package's node_modules entry.
157
+ const injected = path.join(directory, "..", "@inthedark", "wb", "wb");
158
+ try {
159
+ if (this.samePath(await fs.realpath(injected), ownEntry)) continue;
160
+ } catch (error) { if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error; }
161
+ throw new Error(`Another wb command at ${candidate} precedes ${bin} on PATH. Resolve that conflict before installing; it was not changed.`);
162
+ }
163
+ }
164
+ }
165
+
166
+ async prepare() {
167
+ const selection = await this.capture();
168
+ const destination = await this.destination(selection);
169
+ this.selection = { ...selection, ...destination };
170
+ await this.checkPath(destination.bin);
171
+ await this.checkWritable(destination.bin);
172
+ }
173
+
174
+ async register(root) {
175
+ if (!this.selection) throw new Error("Package registration must be checked before installing Workbench.");
176
+ const current = await this.destination(this.selection);
177
+ if (!this.samePath(current.bin, this.selection.bin) || !this.samePath(current.prefix, this.selection.prefix)) {
178
+ throw new Error("The package manager's global destination changed during installation. Run Workbench again.");
179
+ }
180
+ await this.execute(this.selection, managers[this.selection.name].install(root, this.selection.prefix));
181
+ if (!await this.publicCommand(this.selection.bin)) throw new Error(`${this.selection.name} did not register the public wb command in ${this.selection.bin}.`);
182
+ await this.checkPath(this.selection.bin);
183
+ }
184
+ }
@@ -0,0 +1,185 @@
1
+ /*
2
+ * Exports:
3
+ * - WorkbenchToolchain (default): persist Vite+ selection and own isolated provisioning and pinned child environments.
4
+ */
5
+ import fs from "node:fs/promises";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { randomUUID } from "node:crypto";
9
+ import { DatabaseSync } from "node:sqlite";
10
+ import WorkbenchBootstrapCommand from "./WorkbenchBootstrapCommand.mjs";
11
+ import resolveWorkbenchDataRoot from "./workbench-data-root.mjs";
12
+ import installVitePlus from "./vite-plus-installer.mjs";
13
+
14
+ const directoryVariables = ["VP_HOME", "VP_BIN_DIR", "VP_DATA_DIR", "VP_CACHE_DIR",
15
+ "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME"];
16
+ const versionVariables = ["VP_NODE_VERSION", "VP_PACKAGE_MANAGER", "VP_NPM_VERSION",
17
+ "VP_PNPM_VERSION", "VP_YARN_VERSION", "VP_BUN_VERSION", "VP_BYPASS"];
18
+
19
+ export default class WorkbenchToolchain {
20
+ constructor({
21
+ environment = process.env, home = os.homedir(),
22
+ dataRoot = resolveWorkbenchDataRoot({ environment, homeDirectory: home }),
23
+ commands = new WorkbenchBootstrapCommand({ environment }), install = installVitePlus,
24
+ } = {}) {
25
+ this.commands = commands;
26
+ this.environment = environment;
27
+ this.dataRoot = path.resolve(dataRoot);
28
+ this.directory = path.join(this.dataRoot, "installation");
29
+ this.receipt = path.join(this.directory, "toolchain.json");
30
+ this.managedHome = path.join(this.dataRoot, "toolchain");
31
+ this.install = install;
32
+ }
33
+
34
+ async read() {
35
+ let text;
36
+ try { text = await fs.readFile(this.receipt, "utf8"); }
37
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
38
+ let record;
39
+ try { record = JSON.parse(text); }
40
+ catch (error) { throw new Error(`Invalid toolchain receipt: ${this.receipt}. It was preserved.`, { cause: error }); }
41
+ if (record?.version !== 1 || typeof record.ready !== "boolean" || !["managed", "external"].includes(record.kind)
42
+ || record.kind === "external" && !record.ready
43
+ || typeof record.executable !== "string" || !path.isAbsolute(record.executable)
44
+ || !record.environment || typeof record.environment !== "object" || Array.isArray(record.environment)
45
+ || Object.keys(record.environment).some(name => !directoryVariables.includes(name))
46
+ || directoryVariables.some(name => !(record.environment[name] === null
47
+ || typeof record.environment[name] === "string" && path.isAbsolute(record.environment[name])))) {
48
+ throw new Error(`Invalid toolchain receipt: ${this.receipt}. It was preserved.`);
49
+ }
50
+ if (record.kind === "managed" && (record.environment.VP_HOME !== this.managedHome
51
+ || record.executable !== this.managedExecutable())) {
52
+ throw new Error(`Managed toolchain receipt points outside its owned location: ${this.receipt}.`);
53
+ }
54
+ return record;
55
+ }
56
+
57
+ managedExecutable() {
58
+ return path.join(this.managedHome, "bin", process.platform === "win32" ? "vp.exe" : "vp");
59
+ }
60
+
61
+ selection(executable, kind) {
62
+ const environment = Object.fromEntries(directoryVariables.map(name => {
63
+ const value = this.commands.variable(name)?.trim();
64
+ if (value && !path.isAbsolute(value) && name.startsWith("VP_")) {
65
+ throw new Error(`${name} must be an absolute path to persist the Vite+ environment.`);
66
+ }
67
+ return [name, value && path.isAbsolute(value) ? value : null];
68
+ }));
69
+ if (kind === "managed") {
70
+ for (const name of directoryVariables.filter(name => name.startsWith("VP_"))) environment[name] = null;
71
+ environment.VP_HOME = this.managedHome;
72
+ }
73
+ return { version: 1, kind, ready: kind === "external", executable, environment };
74
+ }
75
+
76
+ childEnvironment(record, pins) {
77
+ return {
78
+ ...Object.fromEntries(versionVariables.map(name => [name, undefined])),
79
+ ...record.environment,
80
+ WORKBENCH_DATA_ROOT: this.dataRoot,
81
+ ...(pins ? {
82
+ VP_NODE_VERSION: pins.node,
83
+ VP_PACKAGE_MANAGER: `${pins.manager}@${pins.packageManager}`,
84
+ VP_PNPM_VERSION: pins.packageManager,
85
+ } : {}),
86
+ };
87
+ }
88
+
89
+ async verify(record) {
90
+ try {
91
+ await this.commands.run(record.executable, ["--version"], {
92
+ environment: this.childEnvironment(record), output: { write() {} }, errorOutput: { write() {} },
93
+ });
94
+ } catch (error) {
95
+ throw new Error(`Recorded Vite+ is unavailable at ${record.executable}. ${record.kind === "external"
96
+ ? "Restore that installation; Workbench will not replace a user-owned toolchain."
97
+ : "Run wb repair to restore the managed toolchain."}`, { cause: error });
98
+ }
99
+ }
100
+
101
+ async discover() {
102
+ const executable = await this.commands.findExecutable("vp");
103
+ if (!executable) return null;
104
+ const record = this.selection(executable, "external");
105
+ await this.verify(record);
106
+ return record;
107
+ }
108
+
109
+ async resolve() {
110
+ const record = await this.read() ?? await this.discover();
111
+ if (!record) throw new Error("Workbench's Vite+ toolchain is unavailable. Run wb repair.");
112
+ if (!record.ready) throw new Error("Workbench toolchain setup is incomplete. Run wb repair.");
113
+ return record;
114
+ }
115
+
116
+ async ensure({ onOutput } = {}) {
117
+ if (this.environment.WORKBENCH_THREAD_ID || this.environment.CODEX_THREAD_ID) {
118
+ throw new Error("Managed threads cannot provision Workbench's toolchain.");
119
+ }
120
+ await fs.mkdir(this.directory, { recursive: true });
121
+ const lock = new DatabaseSync(path.join(this.directory, "toolchain.lock.sqlite3"));
122
+ try {
123
+ try { lock.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE"); }
124
+ catch (error) {
125
+ if (error.errcode === 5 || error.errcode === 6) throw new Error("Another Workbench toolchain setup is running. Retry after it finishes.");
126
+ throw error;
127
+ }
128
+ let record = await this.read();
129
+ if (record?.ready) {
130
+ try { await this.verify(record); return record; }
131
+ catch (error) {
132
+ if (record.kind !== "managed") throw error;
133
+ if (error.cause?.code !== "ENOENT") throw error;
134
+ }
135
+ } else if (!record) {
136
+ record = await this.discover();
137
+ if (record) { await this.write(record); return record; }
138
+ record = this.selection(this.managedExecutable(), "managed");
139
+ await this.write(record);
140
+ }
141
+ if (record.ready) {
142
+ record = { ...record, ready: false };
143
+ await this.write(record);
144
+ }
145
+ onOutput?.(`Provisioning Workbench's managed Vite+ at ${this.managedHome}\n`);
146
+ await this.install({ home: this.managedHome, directory: this.directory, commands: this.commands, onOutput });
147
+ await this.verify(record);
148
+ record = { ...record, ready: true };
149
+ await this.write(record);
150
+ return record;
151
+ } finally { lock.close(); }
152
+ }
153
+
154
+ async write(record) {
155
+ const temporary = `${this.receipt}.${randomUUID()}.tmp`;
156
+ await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { flag: "wx", mode: 0o600 });
157
+ await fs.rename(temporary, this.receipt);
158
+ }
159
+
160
+ async pins(root) {
161
+ const manifest = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
162
+ const runtime = manifest.devEngines?.runtime;
163
+ const manager = manifest.devEngines?.packageManager;
164
+ const exact = value => typeof value === "string" && /^\d+\.\d+\.\d+$/u.test(value);
165
+ if (runtime?.name !== "node" || manager?.name !== "pnpm" || !exact(runtime.version) || !exact(manager.version)) {
166
+ throw new Error(`Workbench must declare exact Node and pnpm devEngines pins: ${root}`);
167
+ }
168
+ return { node: runtime.version, manager: manager.name, packageManager: manager.version };
169
+ }
170
+
171
+ async run(args, { cwd, ...options }) {
172
+ const [record, pins] = await Promise.all([this.resolve(), this.pins(cwd)]);
173
+ return this.commands.run(record.executable, args, {
174
+ ...options, cwd, environment: { ...options.environment, ...this.childEnvironment(record, pins) },
175
+ });
176
+ }
177
+
178
+ async runNode(root, entry, args = [], { nodeOptions = [], ...options } = {}) {
179
+ const [record, pins] = await Promise.all([this.resolve(), this.pins(root)]);
180
+ return this.commands.run(record.executable, [
181
+ "env", "exec", "--node", pins.node, "--package-manager", `${pins.manager}@${pins.packageManager}`,
182
+ "node", ...nodeOptions, entry, ...args,
183
+ ], { ...options, environment: { ...options.environment, ...this.childEnvironment(record, pins) } });
184
+ }
185
+ }
package/index.mjs CHANGED
@@ -17,10 +17,6 @@ function warnDefault(message) {
17
17
  process.emitWarning(message, "WorkbenchStoreWarning");
18
18
  }
19
19
 
20
- function isManagedAgentProcess() {
21
- return Boolean(process.env.WORKBENCH_THREAD_ID?.trim() || process.env.CODEX_THREAD_ID?.trim());
22
- }
23
-
24
20
  function stripTerminalLineEnding(value) {
25
21
  return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value;
26
22
  }
@@ -58,10 +54,6 @@ async function resolveReferences(parsed, definitions, { runner, signal, warn })
58
54
  }
59
55
  }
60
56
  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
57
  const results = new Map(await Promise.all(Array.from(lookups, async ([id, lookup]) => {
66
58
  const result = await runner.run(lookup.argv, { signal });
67
59
  if (!result.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inthedark/wb",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,6 +26,11 @@
26
26
  "wb",
27
27
  "WorkbenchBootstrap.mjs",
28
28
  "WorkbenchBootstrapCommand.mjs",
29
+ "WorkbenchToolchain.mjs",
30
+ "WorkbenchPackageRegistration.mjs",
31
+ "vite-plus-installer.mjs",
32
+ "workbench-data-root.mjs",
33
+ "workbench-data-root.d.mts",
29
34
  "WorkbenchInstallPrompt.mjs",
30
35
  "installer-cube.mjs",
31
36
  "index.mjs",
@@ -0,0 +1,52 @@
1
+ /*
2
+ * Exports:
3
+ * - default installVitePlus: execute the pinned official installer without user shell integration.
4
+ */
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { randomUUID } from "node:crypto";
8
+
9
+ const VITE_PLUS_VERSION = "1.1.0";
10
+
11
+ export default async function installVitePlus({
12
+ home, directory, commands, onOutput, fetchInstaller = fetch, platform = process.platform, files = fs,
13
+ }) {
14
+ const windows = platform === "win32";
15
+ const extension = windows ? "ps1" : "sh";
16
+ const url = `https://raw.githubusercontent.com/voidzero-dev/vite-plus/v${VITE_PLUS_VERSION}/packages/cli/install.${extension}`;
17
+ const response = await fetchInstaller(url);
18
+ if (!response.ok) {
19
+ await response.body?.cancel();
20
+ throw new Error(`Vite+ installer download failed (HTTP ${response.status}).`);
21
+ }
22
+ const script = await response.text();
23
+ if (!script.trim()) throw new Error("Vite+ installer download was empty.");
24
+ await files.mkdir(directory, { recursive: true });
25
+ const filename = path.join(directory, `vite-plus-${randomUUID()}.${extension}`);
26
+ await files.writeFile(filename, script, { flag: "wx", mode: 0o600 });
27
+ let failure;
28
+ try {
29
+ const environment = Object.fromEntries(Object.keys(commands.environment)
30
+ .filter(name => /^VP_/iu.test(name)).map(name => [name, undefined]));
31
+ Object.assign(environment, {
32
+ VP_HOME: home,
33
+ VP_VERSION: VITE_PLUS_VERSION,
34
+ VP_NODE_MANAGER: "yes",
35
+ VP_PM_MANAGER: "yes",
36
+ // Release-pinned self-setup honours this on Unix and Windows; CI alone still edits profiles.
37
+ VP_SELF_SETUP_NO_MODIFY_PATH: "1",
38
+ CI: "true",
39
+ });
40
+ await commands.run(windows ? "powershell.exe" : "bash",
41
+ windows ? ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", filename] : [filename],
42
+ { cwd: directory, environment, onOutput });
43
+ } catch (error) {
44
+ failure = error;
45
+ }
46
+ try { await files.unlink(filename); }
47
+ catch (error) {
48
+ if (failure) throw new AggregateError([failure, error], `Vite+ setup failed and its downloaded script could not be removed: ${filename}`, { cause: failure });
49
+ throw error;
50
+ }
51
+ if (failure) throw failure;
52
+ }
@@ -0,0 +1,11 @@
1
+ /*
2
+ * Exports:
3
+ * - WorkbenchDataRootOptions: environment, home and platform inputs for data storage.
4
+ * - default resolveWorkbenchDataRoot: dependency-free per-user data location.
5
+ */
6
+ export interface WorkbenchDataRootOptions {
7
+ environment?: NodeJS.ProcessEnv;
8
+ homeDirectory?: string;
9
+ platform?: NodeJS.Platform;
10
+ }
11
+ export default function resolveWorkbenchDataRoot(options?: WorkbenchDataRootOptions): string;
@@ -0,0 +1,20 @@
1
+ /*
2
+ * Exports:
3
+ * - default resolveWorkbenchDataRoot: resolve per-user OS data storage without installed dependencies.
4
+ */
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+
8
+ export default function resolveWorkbenchDataRoot({
9
+ environment = process.env, homeDirectory = os.homedir(), platform = process.platform,
10
+ } = {}) {
11
+ const paths = platform === "win32" ? path.win32 : path.posix;
12
+ const override = environment.WORKBENCH_DATA_ROOT?.trim();
13
+ if (override) return paths.resolve(override);
14
+ if (platform === "win32") {
15
+ return paths.resolve(environment.LOCALAPPDATA?.trim() || paths.join(homeDirectory, "AppData", "Local"), "inthedark", "wb");
16
+ }
17
+ if (platform === "darwin") return paths.resolve(homeDirectory, "Library", "Application Support", "inthedark", "wb");
18
+ const xdg = environment.XDG_DATA_HOME?.trim();
19
+ return paths.resolve(xdg && paths.isAbsolute(xdg) ? xdg : paths.join(homeDirectory, ".local", "share"), "inthedark", "wb");
20
+ }