@inthedark/wb 0.0.1 → 0.0.3
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 -60
- package/WorkbenchBootstrap.mjs +170 -170
- package/WorkbenchBootstrapCommand.mjs +138 -138
- package/WorkbenchInstallPrompt.mjs +256 -0
- package/installer-cube.mjs +113 -0
- package/package.json +4 -2
- package/wb.schema.json +71 -0
- package/WorkbenchBootstrapPrompt.mjs +0 -71
package/README.md
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
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
|
|
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
|
package/WorkbenchBootstrap.mjs
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
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
|
|
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 WorkbenchInstallPrompt from "./WorkbenchInstallPrompt.mjs";
|
|
13
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
|
|
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 WorkbenchInstallPrompt(),
|
|
21
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
|
-
|
|
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
33
|
async checkoutExists(root) {
|
|
34
34
|
try {
|
|
35
35
|
const manifest = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
|
|
36
36
|
if (manifest.name !== "workbench-root") return false;
|
|
37
|
-
await fs.access(path.join(root, "wb"));
|
|
37
|
+
await fs.access(path.join(root, "wb"));
|
|
38
38
|
await fs.access(path.join(root, "installation", "install.mjs"));
|
|
39
39
|
await fs.access(path.join(root, "cli", "dispatch.mjs"));
|
|
40
|
-
return true;
|
|
41
|
-
} catch (error) {
|
|
42
|
-
if (error.code === "ENOENT") return false;
|
|
40
|
+
return true;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code === "ENOENT") return false;
|
|
43
43
|
throw error;
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -58,84 +58,84 @@ export default class WorkbenchBootstrap {
|
|
|
58
58
|
async readInstallation() {
|
|
59
59
|
try {
|
|
60
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)) {
|
|
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
139
|
return await this.delegate(linked, args);
|
|
140
140
|
}
|
|
141
141
|
const installed = await this.readInstallation();
|
|
@@ -148,7 +148,7 @@ export default class WorkbenchBootstrap {
|
|
|
148
148
|
}
|
|
149
149
|
if (managed || !(args.length === 0 || (args.length === 1 && args[0] === "connect"))) {
|
|
150
150
|
throw new Error("Workbench is not installed. Run wb or wb connect in an interactive terminal first.");
|
|
151
|
-
}
|
|
151
|
+
}
|
|
152
152
|
await this.requireTool("git");
|
|
153
153
|
await this.requireTool("bash");
|
|
154
154
|
await this.requireTool("vp");
|
|
@@ -158,24 +158,24 @@ export default class WorkbenchBootstrap {
|
|
|
158
158
|
const choice = await this.prompt.choose(
|
|
159
159
|
`Workbench is not installed. This command will clone the repository and run build commands. It may take 1-2 minutes. Continue?${warning}`,
|
|
160
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
|
-
}
|
|
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
179
|
try {
|
|
180
180
|
// Another invocation may have finished while this one was accepting input.
|
|
181
181
|
const latest = await this.readInstallation();
|
|
@@ -192,49 +192,49 @@ export default class WorkbenchBootstrap {
|
|
|
192
192
|
if (latest && latest.phase !== "ready") await this.discardIncomplete(latest);
|
|
193
193
|
await this.requireEmptyDestination(root);
|
|
194
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);
|
|
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
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.
|
|
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
223
|
await this.commands.run(process.execPath, [path.join(root, "installation", "install.mjs"),
|
|
224
224
|
args[0] === "connect" ? "--connect" : "--welcome"], { cwd: root, interactive: true });
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
async delegate(root, args) {
|
|
228
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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,140 +1,140 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Exports:
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
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
|
-
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
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
|
-
}
|
|
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,256 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - WorkbenchInstallPrompt (default): owns dependency-free installer terminal interaction and the pinned cube header.
|
|
4
|
+
*/
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { emitKeypressEvents } from "node:readline";
|
|
8
|
+
import { INSTALLER_CUBE_ROWS, installerCubeWidth, renderInstallerCube } from "./installer-cube.mjs";
|
|
9
|
+
|
|
10
|
+
const WORDMARK = "w o r k b e n c h";
|
|
11
|
+
const FRAME_INTERVAL_MS = 80;
|
|
12
|
+
// Install log rows kept below a pinned header; shorter terminals skip pinning.
|
|
13
|
+
const LOG_ROWS = 8;
|
|
14
|
+
|
|
15
|
+
function scheduleFrames(tick) {
|
|
16
|
+
const timer = setInterval(tick, FRAME_INTERVAL_MS);
|
|
17
|
+
timer.unref?.();
|
|
18
|
+
return () => clearInterval(timer);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default class WorkbenchInstallPrompt {
|
|
22
|
+
constructor({
|
|
23
|
+
input = process.stdin,
|
|
24
|
+
output = process.stdout,
|
|
25
|
+
platform = process.platform,
|
|
26
|
+
cwd = process.cwd(),
|
|
27
|
+
home = os.homedir(),
|
|
28
|
+
schedule = scheduleFrames,
|
|
29
|
+
now = () => performance.now(),
|
|
30
|
+
} = {}) {
|
|
31
|
+
this.input = input;
|
|
32
|
+
this.output = output;
|
|
33
|
+
this.schedule = schedule;
|
|
34
|
+
this.now = now;
|
|
35
|
+
this.paths = platform === "win32" ? path.win32 : path.posix;
|
|
36
|
+
this.windows = platform === "win32";
|
|
37
|
+
this.cwd = cwd;
|
|
38
|
+
this.home = home;
|
|
39
|
+
this.active = false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
suffix(value) {
|
|
43
|
+
const name = this.paths.basename(value);
|
|
44
|
+
const comparable = this.windows ? name.toLowerCase() : name;
|
|
45
|
+
if (comparable === "wb" || comparable === "workbench") return "";
|
|
46
|
+
return `${value.endsWith(this.paths.sep) || (this.windows && value.endsWith("/")) ? "" : this.paths.sep}wb`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
destination(value) {
|
|
50
|
+
if (!value.trim()) throw new Error("Choose an installation directory.");
|
|
51
|
+
if (/[\u0000-\u001f\u007f]/u.test(value)) throw new Error("Installation paths cannot contain control characters.");
|
|
52
|
+
const expanded = value === "~" ? this.home
|
|
53
|
+
: value.startsWith("~/") || (this.windows && value.startsWith("~\\"))
|
|
54
|
+
? this.paths.join(this.home, value.slice(2)) : value;
|
|
55
|
+
return this.paths.resolve(this.cwd, `${expanded}${this.suffix(expanded)}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
location(label, initialValue) {
|
|
59
|
+
let characters = Array.from(initialValue);
|
|
60
|
+
let cursor = characters.length;
|
|
61
|
+
return this.interact(
|
|
62
|
+
() => {
|
|
63
|
+
const value = characters.join("");
|
|
64
|
+
const suffix = this.suffix(value);
|
|
65
|
+
// Keep a bounded one-line viewport so redraw never overwrites earlier setup output.
|
|
66
|
+
const available = Math.max(8, (this.output.columns || 80) - label.length - suffix.length - 4);
|
|
67
|
+
const start = Math.max(0, cursor - available + 1);
|
|
68
|
+
const visible = characters.slice(start, start + available).join("");
|
|
69
|
+
const prefix = `${label}: `;
|
|
70
|
+
this.output.write(`\r\u001b[2K${prefix}${visible}\u001b[2m${suffix}\u001b[22m`);
|
|
71
|
+
this.output.write(`\r\u001b[${prefix.length + characters.slice(start, cursor).join("").length + 1}G`);
|
|
72
|
+
},
|
|
73
|
+
(text, key, accept) => {
|
|
74
|
+
if (key.name === "return") {
|
|
75
|
+
accept(this.destination(characters.join("")));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (key.name === "left") cursor = Math.max(0, cursor - 1);
|
|
79
|
+
else if (key.name === "right") cursor = Math.min(characters.length, cursor + 1);
|
|
80
|
+
else if (key.name === "home" || (key.ctrl && key.name === "a")) cursor = 0;
|
|
81
|
+
else if (key.name === "end" || (key.ctrl && key.name === "e")) cursor = characters.length;
|
|
82
|
+
else if (key.name === "backspace" && cursor > 0) characters.splice(--cursor, 1);
|
|
83
|
+
else if (key.name === "delete") characters.splice(cursor, 1);
|
|
84
|
+
else if (text && !key.ctrl && !key.meta && !/[\u0000-\u001f\u007f]/u.test(text)) {
|
|
85
|
+
const added = Array.from(text);
|
|
86
|
+
characters.splice(cursor, 0, ...added);
|
|
87
|
+
cursor += added.length;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
choose(label, choices) {
|
|
94
|
+
if (!choices.length) return Promise.reject(new Error("A setup choice needs options."));
|
|
95
|
+
let selected = 0;
|
|
96
|
+
let rendered = false;
|
|
97
|
+
return this.interact(
|
|
98
|
+
() => {
|
|
99
|
+
if (rendered && choices.length > 1) this.output.write(`\r\u001b[${choices.length - 1}A`);
|
|
100
|
+
this.output.write(choices.map((choice, index) =>
|
|
101
|
+
`\r\u001b[2K${index === selected ? `> \u001b[7m${choice}\u001b[27m` : ` ${choice}`}`,
|
|
102
|
+
).join("\n"));
|
|
103
|
+
rendered = true;
|
|
104
|
+
},
|
|
105
|
+
(_text, key, accept) => {
|
|
106
|
+
if (key.name === "up") selected = (selected + choices.length - 1) % choices.length;
|
|
107
|
+
else if (key.name === "down" || key.name === "tab") selected = (selected + 1) % choices.length;
|
|
108
|
+
else if (key.name === "return") accept(choices[selected]);
|
|
109
|
+
},
|
|
110
|
+
label,
|
|
111
|
+
choices.length,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Cube height that fits above the wordmark, label and prompt without scrolling the screen; 0 for none. */
|
|
116
|
+
cubeRows(label, promptLines) {
|
|
117
|
+
const columns = this.output.columns || 80;
|
|
118
|
+
const rows = this.output.rows || 24;
|
|
119
|
+
const labelLines = label
|
|
120
|
+
? label.split("\n").reduce((total, line) => total + Math.max(1, Math.ceil(line.length / columns)), 0)
|
|
121
|
+
: 0;
|
|
122
|
+
// Wordmark, blank line, label, prompt. Scrolling would misplace every home-anchored redraw.
|
|
123
|
+
let cube = Math.min(INSTALLER_CUBE_ROWS.max, rows - 2 - labelLines - promptLines);
|
|
124
|
+
while (cube >= INSTALLER_CUBE_ROWS.min && installerCubeWidth(cube) > columns) cube--;
|
|
125
|
+
return cube >= INSTALLER_CUBE_ROWS.min ? cube : 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Animated cube frames, an in-place redraw and the centred wordmark for a header of `cubeRows` (0 for wordmark only). */
|
|
129
|
+
header(cubeRows) {
|
|
130
|
+
const started = this.now();
|
|
131
|
+
const frame = () => renderInstallerCube((this.now() - started) / 1000, cubeRows).join("\r\n");
|
|
132
|
+
return {
|
|
133
|
+
frame,
|
|
134
|
+
// Save and restore the cursor around each frame so prompts and output below never move.
|
|
135
|
+
draw: () => this.output.write(`\u001b7\u001b[H${frame()}\u001b8`),
|
|
136
|
+
wordmark: cubeRows
|
|
137
|
+
? WORDMARK.padStart(Math.floor((installerCubeWidth(cubeRows) + WORDMARK.length) / 2))
|
|
138
|
+
: WORDMARK,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Runs `task` with the animated header pinned above a scroll region holding its output. */
|
|
143
|
+
async withHeader(task) {
|
|
144
|
+
const cubeRows = this.output.isTTY && !this.active ? this.cubeRows("", LOG_ROWS) : 0;
|
|
145
|
+
if (!cubeRows) return await task();
|
|
146
|
+
const rows = this.output.rows || 24;
|
|
147
|
+
const top = cubeRows + 3;
|
|
148
|
+
const { frame, draw, wordmark } = this.header(cubeRows);
|
|
149
|
+
let pinned = true;
|
|
150
|
+
// Resetting the region homes the cursor; save/restore keeps it where the output left off.
|
|
151
|
+
const release = () => {
|
|
152
|
+
if (!pinned) return;
|
|
153
|
+
pinned = false;
|
|
154
|
+
this.output.write("\u001b7\u001b[r\u001b8");
|
|
155
|
+
};
|
|
156
|
+
// Default SIGINT exits without cleanup and would trap the shell inside the region.
|
|
157
|
+
const interrupt = () => {
|
|
158
|
+
release();
|
|
159
|
+
process.exit(130);
|
|
160
|
+
};
|
|
161
|
+
process.once("exit", release);
|
|
162
|
+
process.once("SIGINT", interrupt);
|
|
163
|
+
let stopAnimation = () => {};
|
|
164
|
+
try {
|
|
165
|
+
// Blank lines push earlier shell output into scrollback instead of erasing it.
|
|
166
|
+
this.output.write(`${"\r\n".repeat(rows)}\u001b[H${frame()}\r\n${wordmark}\r\n\u001b[${top};${rows}r\u001b[${top};1H`);
|
|
167
|
+
stopAnimation = this.schedule(() => { if (pinned) draw(); });
|
|
168
|
+
return await task();
|
|
169
|
+
} finally {
|
|
170
|
+
stopAnimation();
|
|
171
|
+
process.removeListener("exit", release);
|
|
172
|
+
process.removeListener("SIGINT", interrupt);
|
|
173
|
+
release();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interact(render, handle, label = "", promptLines = 1) {
|
|
178
|
+
if (!this.input.isTTY || !this.output.isTTY) {
|
|
179
|
+
return Promise.reject(new Error("Workbench setup needs an interactive terminal; no changes were accepted."));
|
|
180
|
+
}
|
|
181
|
+
if (this.active) return Promise.reject(new Error("Another setup prompt is active."));
|
|
182
|
+
this.active = true;
|
|
183
|
+
const wasRaw = this.input.isRaw;
|
|
184
|
+
// A fresh stdin is neither flowing nor paused; leaving it flowing would let this
|
|
185
|
+
// process keep reading the console and steal keys from later child prompts.
|
|
186
|
+
const wasFlowing = this.input.readableFlowing === true;
|
|
187
|
+
emitKeypressEvents(this.input);
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
let settled = false;
|
|
190
|
+
let screenActive = false;
|
|
191
|
+
let stopAnimation = null;
|
|
192
|
+
const restoreScreen = () => {
|
|
193
|
+
if (!screenActive) return;
|
|
194
|
+
screenActive = false;
|
|
195
|
+
this.output.write("\u001b[0m\u001b[?1049l");
|
|
196
|
+
};
|
|
197
|
+
const finish = (error, value) => {
|
|
198
|
+
if (settled) return;
|
|
199
|
+
settled = true;
|
|
200
|
+
stopAnimation?.();
|
|
201
|
+
stopAnimation = null;
|
|
202
|
+
this.input.removeListener("keypress", onKey);
|
|
203
|
+
this.input.removeListener("end", onEnd);
|
|
204
|
+
this.input.removeListener("error", onError);
|
|
205
|
+
process.removeListener("exit", restoreScreen);
|
|
206
|
+
try {
|
|
207
|
+
this.input.setRawMode(Boolean(wasRaw));
|
|
208
|
+
if (!wasFlowing) this.input.pause();
|
|
209
|
+
} catch (cleanupError) {
|
|
210
|
+
error ??= cleanupError;
|
|
211
|
+
}
|
|
212
|
+
try { restoreScreen(); }
|
|
213
|
+
catch (cleanupError) { error ??= cleanupError; }
|
|
214
|
+
this.active = false;
|
|
215
|
+
if (error) reject(error);
|
|
216
|
+
else resolve(value);
|
|
217
|
+
};
|
|
218
|
+
const onEnd = () => finish(new DOMException("Setup cancelled.", "AbortError"));
|
|
219
|
+
const onError = (error) => finish(error);
|
|
220
|
+
const onKey = (text, key = {}) => {
|
|
221
|
+
if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") {
|
|
222
|
+
onEnd();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
handle(text, key, (value) => finish(null, value));
|
|
227
|
+
if (!settled) render();
|
|
228
|
+
} catch (error) {
|
|
229
|
+
finish(error);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
this.input.on("keypress", onKey);
|
|
233
|
+
this.input.once("end", onEnd);
|
|
234
|
+
this.input.once("error", onError);
|
|
235
|
+
try {
|
|
236
|
+
this.input.setRawMode(true);
|
|
237
|
+
this.input.resume();
|
|
238
|
+
screenActive = true;
|
|
239
|
+
process.once("exit", restoreScreen);
|
|
240
|
+
const cubeRows = this.cubeRows(label, promptLines);
|
|
241
|
+
const { frame, draw, wordmark } = this.header(cubeRows);
|
|
242
|
+
this.output.write(`\u001b[?1049h\u001b[2J\u001b[H${cubeRows ? `${frame()}\r\n` : ""}${wordmark}\r\n\r\n${label ? `${label}\r\n` : ""}`);
|
|
243
|
+
render();
|
|
244
|
+
if (cubeRows) {
|
|
245
|
+
stopAnimation = this.schedule(() => {
|
|
246
|
+
if (settled) return;
|
|
247
|
+
try { draw(); }
|
|
248
|
+
catch (error) { finish(error); }
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
finish(error);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Exports:
|
|
3
|
+
* - INSTALLER_CUBE_ROWS: largest and smallest cube heights worth drawing, in terminal rows.
|
|
4
|
+
* - installerCubeWidth: terminal columns a cube frame of the given height occupies.
|
|
5
|
+
* - renderInstallerCube: raycast one antialiased ASCII frame of the spinning installer cube.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const INSTALLER_CUBE_ROWS = Object.freeze({ max: 21, min: 11 });
|
|
9
|
+
|
|
10
|
+
// Terminal cells are roughly twice as tall as they are wide.
|
|
11
|
+
const CELL_ASPECT = 2.05;
|
|
12
|
+
const RAMP = " .:-=+*#%@";
|
|
13
|
+
const SAMPLES = 4;
|
|
14
|
+
const TILT = 0.35;
|
|
15
|
+
const SPIN = 0.8;
|
|
16
|
+
const START = 0.35;
|
|
17
|
+
const CAMERA = 5.2;
|
|
18
|
+
const FOCAL = 2.9;
|
|
19
|
+
const BORDER = 0.1;
|
|
20
|
+
const STROKE = 0.09;
|
|
21
|
+
const DOT = 0.12;
|
|
22
|
+
const TOP_FILL = 0.4;
|
|
23
|
+
|
|
24
|
+
// Face glyphs live in face space: u to the right and v up when the face is viewed from outside.
|
|
25
|
+
const GLYPHS = {
|
|
26
|
+
check: { strokes: [[[-0.45, 0], [-0.12, -0.38], [0.48, 0.42]]], dots: [] },
|
|
27
|
+
x: { strokes: [[[-0.42, -0.42], [0.42, 0.42]], [[-0.42, 0.42], [0.42, -0.42]]], dots: [] },
|
|
28
|
+
question: {
|
|
29
|
+
strokes: [[[-0.32, 0.22], [-0.22, 0.42], [0, 0.5], [0.22, 0.42], [0.32, 0.22], [0.22, 0.06], [0, -0.04], [0, -0.18]]],
|
|
30
|
+
dots: [[0, -0.42]],
|
|
31
|
+
},
|
|
32
|
+
dots: { strokes: [], dots: [[-0.42, 0], [0, 0], [0.42, 0]] },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Indexed by axis then sign: [x-, x+], [y-, y+], [z-, z+]. Each basis satisfies u x v = normal.
|
|
36
|
+
const FACES = [
|
|
37
|
+
[{ u: [0, 0, 1], v: [0, 1, 0], glyph: GLYPHS.dots }, { u: [0, 0, -1], v: [0, 1, 0], glyph: GLYPHS.question }],
|
|
38
|
+
[{ u: [1, 0, 0], v: [0, 0, 1], glyph: null }, { u: [1, 0, 0], v: [0, 0, -1], glyph: null, fill: TOP_FILL }],
|
|
39
|
+
[{ u: [-1, 0, 0], v: [0, 1, 0], glyph: GLYPHS.x }, { u: [1, 0, 0], v: [0, 1, 0], glyph: GLYPHS.check }],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export function installerCubeWidth(rows) {
|
|
43
|
+
return Math.round(rows * 2.2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function segmentDistance(px, py, [ax, ay], [bx, by]) {
|
|
47
|
+
const dx = bx - ax, dy = by - ay;
|
|
48
|
+
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)));
|
|
49
|
+
return Math.hypot(px - ax - t * dx, py - ay - t * dy);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function glyphCovers(glyph, u, v) {
|
|
53
|
+
for (const stroke of glyph.strokes) {
|
|
54
|
+
for (let index = 1; index < stroke.length; index++) {
|
|
55
|
+
if (segmentDistance(u, v, stroke[index - 1], stroke[index]) < STROKE) return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return glyph.dots.some(([x, y]) => Math.hypot(u - x, v - y) < DOT);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Brightness 0..1 where a camera ray through (px, py) meets the cube, given world-to-object rotation. */
|
|
62
|
+
function shade(toObject, origin, px, py) {
|
|
63
|
+
const direction = toObject(px / FOCAL, py / FOCAL, -1);
|
|
64
|
+
let near = -Infinity, far = Infinity, axis = -1, side = 0;
|
|
65
|
+
for (let index = 0; index < 3; index++) {
|
|
66
|
+
let enter = (-1 - origin[index]) / direction[index];
|
|
67
|
+
let leave = (1 - origin[index]) / direction[index];
|
|
68
|
+
let entered = 0;
|
|
69
|
+
if (enter > leave) { [enter, leave] = [leave, enter]; entered = 1; }
|
|
70
|
+
if (enter > near) { near = enter; axis = index; side = entered; }
|
|
71
|
+
if (leave < far) far = leave;
|
|
72
|
+
}
|
|
73
|
+
if (near > far || far < 0) return 0;
|
|
74
|
+
const face = FACES[axis][side];
|
|
75
|
+
const x = origin[0] + direction[0] * near, y = origin[1] + direction[1] * near, z = origin[2] + direction[2] * near;
|
|
76
|
+
const u = x * face.u[0] + y * face.u[1] + z * face.u[2];
|
|
77
|
+
const v = x * face.v[0] + y * face.v[1] + z * face.v[2];
|
|
78
|
+
if (1 - Math.max(Math.abs(u), Math.abs(v)) < BORDER) return 1;
|
|
79
|
+
if (face.fill) return face.fill;
|
|
80
|
+
return face.glyph && glyphCovers(face.glyph, u, v) ? 0.95 : 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One frame: exactly `rows` strings of exactly installerCubeWidth(rows) printable ASCII characters. */
|
|
84
|
+
export function renderInstallerCube(seconds, rows = INSTALLER_CUBE_ROWS.max) {
|
|
85
|
+
const width = installerCubeWidth(rows);
|
|
86
|
+
const spin = START + seconds * SPIN;
|
|
87
|
+
const cs = Math.cos(spin), ss = Math.sin(spin), ct = Math.cos(TILT), st = Math.sin(TILT);
|
|
88
|
+
// Undo the tilt about X, then the spin about Y.
|
|
89
|
+
const toObject = (x, y, z) => {
|
|
90
|
+
const ty = y * ct + z * st, tz = -y * st + z * ct;
|
|
91
|
+
return [x * cs - tz * ss, ty, x * ss + tz * cs];
|
|
92
|
+
};
|
|
93
|
+
const origin = toObject(0, 0, CAMERA);
|
|
94
|
+
const lines = [];
|
|
95
|
+
for (let row = 0; row < rows; row++) {
|
|
96
|
+
let line = "";
|
|
97
|
+
for (let column = 0; column < width; column++) {
|
|
98
|
+
let coverage = 0;
|
|
99
|
+
for (let j = 0; j < SAMPLES; j++) {
|
|
100
|
+
// Nudge the view up slightly so the cube sits centred rather than low in its box.
|
|
101
|
+
const py = -(row + (j + 0.5) / SAMPLES - rows / 2) / (rows / 2) - 0.12;
|
|
102
|
+
for (let i = 0; i < SAMPLES; i++) {
|
|
103
|
+
const px = (column + (i + 0.5) / SAMPLES - width / 2) / (rows * CELL_ASPECT / 2);
|
|
104
|
+
coverage += shade(toObject, origin, px, py);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const level = Math.pow(coverage / (SAMPLES * SAMPLES), 0.75);
|
|
108
|
+
line += RAMP[Math.min(RAMP.length - 1, Math.round(level * (RAMP.length - 1)))];
|
|
109
|
+
}
|
|
110
|
+
lines.push(line);
|
|
111
|
+
}
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inthedark/wb",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,11 +26,13 @@
|
|
|
26
26
|
"wb",
|
|
27
27
|
"WorkbenchBootstrap.mjs",
|
|
28
28
|
"WorkbenchBootstrapCommand.mjs",
|
|
29
|
-
"
|
|
29
|
+
"WorkbenchInstallPrompt.mjs",
|
|
30
|
+
"installer-cube.mjs",
|
|
30
31
|
"index.mjs",
|
|
31
32
|
"index.d.ts",
|
|
32
33
|
"store-definitions.mjs",
|
|
33
34
|
"StoreCommandRunner.mjs",
|
|
35
|
+
"wb.schema.json",
|
|
34
36
|
"README.md"
|
|
35
37
|
],
|
|
36
38
|
"dependencies": {
|
package/wb.schema.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"$schema": {
|
|
6
|
+
"description": "Editor schema reference; ignored by Workbench.",
|
|
7
|
+
"type": "string"
|
|
8
|
+
},
|
|
9
|
+
"vis": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"properties": {
|
|
12
|
+
"css": {
|
|
13
|
+
"description": "Compiles CSS for .html and .svg vis files containing <link rel=\"workbench-css\">.",
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {
|
|
16
|
+
"command": {
|
|
17
|
+
"minItems": 1,
|
|
18
|
+
"maxItems": 64,
|
|
19
|
+
"type": "array",
|
|
20
|
+
"items": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"minLength": 1,
|
|
23
|
+
"maxLength": 4000
|
|
24
|
+
},
|
|
25
|
+
"description": "Argument vector run from the project root, inside the calling thread's sandbox. Its stdout is the result. {file}, {root} and {input} are substituted."
|
|
26
|
+
},
|
|
27
|
+
"input": {
|
|
28
|
+
"description": "Written to a temporary file named by {input}, for tools that read an entry file rather than arguments.",
|
|
29
|
+
"type": "string",
|
|
30
|
+
"maxLength": 16000
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"required": [
|
|
34
|
+
"command"
|
|
35
|
+
],
|
|
36
|
+
"additionalProperties": false
|
|
37
|
+
},
|
|
38
|
+
"build": {
|
|
39
|
+
"description": "Builds .tsx and .jsx vis files; prints JSON { document, inputs }.",
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {
|
|
42
|
+
"command": {
|
|
43
|
+
"minItems": 1,
|
|
44
|
+
"maxItems": 64,
|
|
45
|
+
"type": "array",
|
|
46
|
+
"items": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"minLength": 1,
|
|
49
|
+
"maxLength": 4000
|
|
50
|
+
},
|
|
51
|
+
"description": "Argument vector run from the project root, inside the calling thread's sandbox. Its stdout is the result. {file}, {root} and {input} are substituted."
|
|
52
|
+
},
|
|
53
|
+
"input": {
|
|
54
|
+
"description": "Written to a temporary file named by {input}, for tools that read an entry file rather than arguments.",
|
|
55
|
+
"type": "string",
|
|
56
|
+
"maxLength": 16000
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"required": [
|
|
60
|
+
"command"
|
|
61
|
+
],
|
|
62
|
+
"additionalProperties": false
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"additionalProperties": false,
|
|
66
|
+
"description": "Live visual drafts agents show with wb vis start."
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"additionalProperties": false,
|
|
70
|
+
"description": "Project-owned Workbench settings, read from .wb.json at the project root."
|
|
71
|
+
}
|
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
}
|