@inthedark/wb 0.0.5 → 0.0.7
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 +42 -0
- package/StoreCommandRunner.mjs +10 -2
- package/WorkbenchBootstrap.mjs +13 -60
- package/index.d.ts +18 -5
- package/index.mjs +47 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,3 +69,45 @@ note: have tried to keep dependencies minimal for the most part
|
|
|
69
69
|
`/test` — test runner
|
|
70
70
|
`/scripts` — stuff used by package scripts plus anything i didn't notice an agent left behind :3
|
|
71
71
|
`/docs` — help agents find things and not break them without eating all their tokens
|
|
72
|
+
|
|
73
|
+
## .env secrets
|
|
74
|
+
|
|
75
|
+
workbench's npm package `@inthedark/wb` can be used as a dotenv replacement. its `config` is async, so await it before reading your env vars:
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
import { config } from "@inthedark/wb";
|
|
79
|
+
|
|
80
|
+
await config();
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
the following syntax in your .env file can be used to load data from workbench's private store:
|
|
84
|
+
|
|
85
|
+
```dotenv
|
|
86
|
+
API_KEY=${wb:API_KEY}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
configure secrets in workbench at settings > projects & folders > environment > store. select the folder you want to configure.
|
|
90
|
+
|
|
91
|
+
the env loader by default silently acts like dotenv would for loading the .env if workbench is not installed, so it's safe to use in teams where some devs are using other stuff. references it can't resolve stay as literal text. existing env vars still take precedence. pass a `warn` callback to opt into lookup warnings.
|
|
92
|
+
|
|
93
|
+
you can choose what happens when values are missing:
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
await config({ required: ["DATABASE_URL"] }); // all others optional
|
|
97
|
+
await config({ optional: ["ANALYTICS_KEY"] }); // all others required
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
use one list or the other. missing optional vars become empty strings, including the whole value if a store reference fails. missing required vars throw one error listing all their names before changing the environment. explicitly empty values are valid. "all others" means vars in your .env files.
|
|
101
|
+
|
|
102
|
+
you can use wb's env loader to load from other clis too, not just wb store:
|
|
103
|
+
|
|
104
|
+
```dotenv
|
|
105
|
+
WB_STORES="wb: wb store get {key}; other: my-secret-cli get {key}"
|
|
106
|
+
API_KEY=${other:API_KEY}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
replace `my-secret-cli get {key}` with your cli's command. `{key}` is substituted into its arguments, and stdout becomes the value. commands run without a shell.
|
|
110
|
+
|
|
111
|
+
### security note
|
|
112
|
+
|
|
113
|
+
wb's env secret storage is not built for maximum security. it's built to prevent agents accidentally obtaining secrets. values use aes-256-gcm with a key derived from the device identity, username and project location, not a separate password, so this isn't protection against hostile code running as you.
|
package/StoreCommandRunner.mjs
CHANGED
|
@@ -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
|
|
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, {
|
|
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;
|
package/WorkbenchBootstrap.mjs
CHANGED
|
@@ -19,6 +19,7 @@ export default class WorkbenchBootstrap {
|
|
|
19
19
|
home = os.homedir(),
|
|
20
20
|
packageRoot = path.dirname(fileURLToPath(import.meta.url)),
|
|
21
21
|
environment = process.env,
|
|
22
|
+
platform = process.platform,
|
|
22
23
|
prompt = new WorkbenchInstallPrompt(),
|
|
23
24
|
commands = new WorkbenchBootstrapCommand({ environment }),
|
|
24
25
|
toolchain = new WorkbenchToolchain({ home, environment, commands }),
|
|
@@ -28,6 +29,7 @@ export default class WorkbenchBootstrap {
|
|
|
28
29
|
this.home = home;
|
|
29
30
|
this.packageRoot = packageRoot;
|
|
30
31
|
this.environment = environment;
|
|
32
|
+
this.platform = platform;
|
|
31
33
|
this.prompt = prompt;
|
|
32
34
|
this.commands = commands;
|
|
33
35
|
this.toolchain = toolchain;
|
|
@@ -94,47 +96,6 @@ export default class WorkbenchBootstrap {
|
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
async discardIncomplete(record) {
|
|
98
|
-
const root = path.resolve(record.root);
|
|
99
|
-
if (root !== record.root || !["wb", "workbench"].includes(path.basename(root).toLowerCase())) {
|
|
100
|
-
throw new Error(`Cannot verify failed install destination ${record.root}; it was preserved.`);
|
|
101
|
-
}
|
|
102
|
-
let stat;
|
|
103
|
-
try { stat = await fs.lstat(root); }
|
|
104
|
-
catch (error) {
|
|
105
|
-
if (error.code !== "ENOENT") throw error;
|
|
106
|
-
await fs.unlink(this.registry);
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
110
|
-
throw new Error(`Failed install destination ${root} is not an ordinary directory; it was preserved.`);
|
|
111
|
-
}
|
|
112
|
-
if (await fs.realpath(root) !== root) {
|
|
113
|
-
throw new Error(`Failed install destination ${root} resolves elsewhere; it was preserved.`);
|
|
114
|
-
}
|
|
115
|
-
if ((await fs.readdir(root)).length) {
|
|
116
|
-
if (!await this.checkoutExists(root)) {
|
|
117
|
-
throw new Error(`Failed install destination ${root} is not a complete Workbench checkout; it was preserved.`);
|
|
118
|
-
}
|
|
119
|
-
const git = await fs.lstat(path.join(root, ".git")).catch(error => {
|
|
120
|
-
if (error.code === "ENOENT") return null;
|
|
121
|
-
throw error;
|
|
122
|
-
});
|
|
123
|
-
if (!git?.isDirectory() || git.isSymbolicLink()) {
|
|
124
|
-
throw new Error(`Failed install destination ${root} is not a regular Git clone; it was preserved.`);
|
|
125
|
-
}
|
|
126
|
-
let changes = "";
|
|
127
|
-
await this.commands.run("git", ["status", "--porcelain=v1", "--untracked-files=all"], {
|
|
128
|
-
cwd: root,
|
|
129
|
-
output: { write: bytes => { changes += bytes.toString(); } },
|
|
130
|
-
errorOutput: { write() {} },
|
|
131
|
-
});
|
|
132
|
-
if (changes.trim()) throw new Error(`Failed install destination ${root} has changes; it was preserved.`);
|
|
133
|
-
}
|
|
134
|
-
await fs.rm(root, { recursive: true });
|
|
135
|
-
await fs.unlink(this.registry);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
99
|
async run(args) {
|
|
139
100
|
const managed = this.environment.WORKBENCH_THREAD_ID || this.environment.CODEX_THREAD_ID;
|
|
140
101
|
const view = args[0] === "view" && (args.length === 1 || args.length === 2 && ["daemon", "app"].includes(args[1]));
|
|
@@ -159,7 +120,7 @@ export default class WorkbenchBootstrap {
|
|
|
159
120
|
await this.requireTool("bash");
|
|
160
121
|
const warning = installed?.phase === "ready"
|
|
161
122
|
? `\nNote: Previous installation at ${installed.root} no longer exists and will be recreated.`
|
|
162
|
-
: installed ? `\
|
|
123
|
+
: installed ? `\nResuming incomplete install at ${installed.root}.` : "";
|
|
163
124
|
const choice = await this.prompt.choose(
|
|
164
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}`,
|
|
165
126
|
["Let's go!", "Cancel"],
|
|
@@ -194,33 +155,25 @@ export default class WorkbenchBootstrap {
|
|
|
194
155
|
if (latest?.root !== installed?.root || latest?.phase !== installed?.phase) {
|
|
195
156
|
throw new Error("The selected Workbench installation changed while setup was open. Run wb again.");
|
|
196
157
|
}
|
|
197
|
-
|
|
198
|
-
await this.requireEmptyDestination(root);
|
|
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" });
|
|
199
162
|
await this.registration.prepare();
|
|
200
163
|
await this.toolchain.ensure();
|
|
201
|
-
let record = { version: 1, root, phase: "cloning" };
|
|
202
|
-
await this.writeInstallation(record);
|
|
203
164
|
await this.prompt.withHeader(async () => {
|
|
204
|
-
this.write(
|
|
205
|
-
|
|
165
|
+
this.write(`${resume ? "Resuming" : "Installing"} Workbench at ${root}\n`);
|
|
166
|
+
if (!resume) {
|
|
206
167
|
// Install sandboxes substitute a local snapshot; ordinary installs clone the public repository.
|
|
207
168
|
const repository = this.environment.WORKBENCH_INSTALL_REPOSITORY || "https://github.com/ErisInTheDark/workbench.git";
|
|
208
169
|
await this.commands.run("git", ["clone", "--progress", "--branch", "main", "--single-branch", repository, root]);
|
|
209
170
|
if (!await this.checkoutExists(root)) throw new Error("Clone did not produce a Workbench checkout.");
|
|
210
|
-
|
|
211
|
-
await this.writeInstallation(record);
|
|
212
|
-
await this.commands.run(process.execPath, [path.join(root, "scripts", "install.mjs"), "--prepare"], { cwd: root });
|
|
213
|
-
await this.registration.register(root);
|
|
214
|
-
} catch (error) {
|
|
215
|
-
try { await this.discardIncomplete(record); }
|
|
216
|
-
catch (cleanupError) {
|
|
217
|
-
throw new Error(`${error.message} Incomplete checkout was preserved at ${root}: ${cleanupError.message}`, { cause: error });
|
|
218
|
-
}
|
|
219
|
-
throw error;
|
|
171
|
+
await this.writeInstallation({ version: 1, root, phase: "setup" });
|
|
220
172
|
}
|
|
173
|
+
await this.commands.run(process.execPath, [path.join(root, "scripts", "install.mjs"), "--prepare"], { cwd: root });
|
|
174
|
+
await this.registration.register(root);
|
|
221
175
|
});
|
|
222
|
-
|
|
223
|
-
await this.writeInstallation(record);
|
|
176
|
+
await this.writeInstallation({ version: 1, root, phase: "ready" });
|
|
224
177
|
} finally {
|
|
225
178
|
// Closing the connection also releases the OS lock after normal completion;
|
|
226
179
|
// process death releases it without a stale PID or lock-file recovery path.
|
package/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Exports:
|
|
3
|
-
* - WorkbenchConfigOptions: dotenv
|
|
3
|
+
* - WorkbenchConfigOptions: dotenv loading, mutually exclusive missing-value policies, cancellation and warning hooks.
|
|
4
4
|
* - WorkbenchConfigOutput: parsed values after store replacement, with the first file read error.
|
|
5
|
-
* - config: load dotenv
|
|
5
|
+
* - config: load dotenv and store values; reject missing required names before writing the environment.
|
|
6
6
|
*/
|
|
7
|
-
export
|
|
7
|
+
export type WorkbenchConfigOptions = {
|
|
8
8
|
/** One file or several; earlier files win unless `override` is set. Defaults to `.env`. */
|
|
9
9
|
path?: string | readonly string[];
|
|
10
10
|
encoding?: BufferEncoding;
|
|
@@ -13,13 +13,26 @@ export interface WorkbenchConfigOptions {
|
|
|
13
13
|
processEnv?: Record<string, string | undefined>;
|
|
14
14
|
/** Aborting kills running store commands and rejects. */
|
|
15
15
|
signal?: AbortSignal;
|
|
16
|
-
/**
|
|
16
|
+
/** Opt into bounded lookup warnings that never contain command output. Silent by default. */
|
|
17
17
|
warn?: (message: string) => void;
|
|
18
|
-
}
|
|
18
|
+
} & ({
|
|
19
|
+
/** Only these names are required; all other dotenv variables are optional. Empty values count as supplied. */
|
|
20
|
+
required?: readonly string[];
|
|
21
|
+
optional?: never;
|
|
22
|
+
} | {
|
|
23
|
+
required?: never;
|
|
24
|
+
/** These names are optional; all other dotenv variables are required. Empty values count as supplied. */
|
|
25
|
+
optional?: readonly string[];
|
|
26
|
+
});
|
|
19
27
|
|
|
20
28
|
export interface WorkbenchConfigOutput {
|
|
21
29
|
parsed: Record<string, string>;
|
|
22
30
|
error?: Error;
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Without a policy, unresolved references remain literal. With a policy, missing optional
|
|
35
|
+
* variables become empty strings in their entirety. Missing required names reject together
|
|
36
|
+
* before any environment writes. Existing environment values win unless override is set.
|
|
37
|
+
*/
|
|
25
38
|
export function config(options?: WorkbenchConfigOptions): Promise<WorkbenchConfigOutput>;
|
package/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Exports:
|
|
3
|
-
* - config: load dotenv files,
|
|
3
|
+
* - config: load dotenv files, resolve named store references, and enforce an optional missing-variable policy.
|
|
4
4
|
*/
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
@@ -13,14 +13,6 @@ import {
|
|
|
13
13
|
parseStoreDefinitions,
|
|
14
14
|
} from "./store-definitions.mjs";
|
|
15
15
|
|
|
16
|
-
function warnDefault(message) {
|
|
17
|
-
process.emitWarning(message, "WorkbenchStoreWarning");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function isManagedAgentProcess() {
|
|
21
|
-
return Boolean(process.env.WORKBENCH_THREAD_ID?.trim() || process.env.CODEX_THREAD_ID?.trim());
|
|
22
|
-
}
|
|
23
|
-
|
|
24
16
|
function stripTerminalLineEnding(value) {
|
|
25
17
|
return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value;
|
|
26
18
|
}
|
|
@@ -41,15 +33,17 @@ async function readParsed(paths, encoding, override) {
|
|
|
41
33
|
return { parsed, error };
|
|
42
34
|
}
|
|
43
35
|
|
|
44
|
-
async function resolveReferences(parsed, definitions, { runner, signal, warn }) {
|
|
36
|
+
async function resolveReferences(parsed, definitions, { runner, signal, warn, policy }) {
|
|
37
|
+
const references = new Map(Object.entries(parsed).map(([name, value]) => [name, findStoreReferences(value)]));
|
|
38
|
+
const unresolved = new Set();
|
|
45
39
|
let stores;
|
|
46
40
|
try {
|
|
47
41
|
stores = parseStoreDefinitions(definitions);
|
|
48
42
|
} catch (failure) {
|
|
49
|
-
warn(`${failure.message} Store references were left unchanged.`);
|
|
50
|
-
|
|
43
|
+
warn(policy ? failure.message : `${failure.message} Store references were left unchanged.`);
|
|
44
|
+
for (const [name, found] of references) if (found.length) unresolved.add(name);
|
|
45
|
+
return { parsed, unresolved };
|
|
51
46
|
}
|
|
52
|
-
const references = new Map(Object.entries(parsed).map(([name, value]) => [name, findStoreReferences(value)]));
|
|
53
47
|
const lookups = new Map();
|
|
54
48
|
for (const [name, found] of references) {
|
|
55
49
|
for (const { key, store } of found) {
|
|
@@ -57,29 +51,52 @@ async function resolveReferences(parsed, definitions, { runner, signal, warn })
|
|
|
57
51
|
if (stores.has(store) && !lookups.has(id)) lookups.set(id, { argv: expandStoreCommand(stores.get(store), key), owner: name, store });
|
|
58
52
|
}
|
|
59
53
|
}
|
|
60
|
-
if (!lookups.size) return parsed;
|
|
61
|
-
if (isManagedAgentProcess()) {
|
|
62
|
-
warn("Store lookups are disabled in Workbench-managed agent processes. Store references were left unchanged.");
|
|
63
|
-
return parsed;
|
|
64
|
-
}
|
|
65
54
|
const results = new Map(await Promise.all(Array.from(lookups, async ([id, lookup]) => {
|
|
66
55
|
const result = await runner.run(lookup.argv, { signal });
|
|
67
56
|
if (!result.ok) {
|
|
68
57
|
const detail = result.reason === "exit" ? `exited with ${result.exitCode}` : `failed (${result.reason})`;
|
|
69
|
-
warn(`The ${lookup.store} store lookup for ${lookup.owner} ${detail}
|
|
58
|
+
warn(`The ${lookup.store} store lookup for ${lookup.owner} ${detail}.${policy ? "" : " The reference was left unchanged."}`);
|
|
70
59
|
}
|
|
71
60
|
return [id, result];
|
|
72
61
|
})));
|
|
73
|
-
|
|
62
|
+
const resolved = Object.fromEntries(Object.entries(parsed).map(([name, value]) => {
|
|
74
63
|
let next = "";
|
|
75
64
|
let cursor = 0;
|
|
76
65
|
for (const reference of references.get(name)) {
|
|
77
66
|
const result = results.get(JSON.stringify([reference.store, reference.key]));
|
|
67
|
+
if (!result?.ok) unresolved.add(name);
|
|
78
68
|
next += value.slice(cursor, reference.start) + (result?.ok ? stripTerminalLineEnding(result.stdout) : reference.text);
|
|
79
69
|
cursor = reference.end;
|
|
80
70
|
}
|
|
81
71
|
return [name, next + value.slice(cursor)];
|
|
82
72
|
}));
|
|
73
|
+
return { parsed: resolved, unresolved };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function missingPolicy(required, optional) {
|
|
77
|
+
if (required !== undefined && optional !== undefined) {
|
|
78
|
+
throw new TypeError("Specify either required or optional, not both.");
|
|
79
|
+
}
|
|
80
|
+
const names = required ?? optional;
|
|
81
|
+
if (required === undefined && optional === undefined) return null;
|
|
82
|
+
if (!Array.isArray(names) || names.some(name => typeof name !== "string" || !name.length)) {
|
|
83
|
+
throw new TypeError("required and optional must be arrays of nonempty environment variable names.");
|
|
84
|
+
}
|
|
85
|
+
return { names: new Set(names), listedRequired: required !== undefined };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function applyMissingPolicy(parsed, unresolved, policy, processEnv, override) {
|
|
89
|
+
if (!policy) return;
|
|
90
|
+
const missing = [];
|
|
91
|
+
for (const name of new Set([...Object.keys(parsed), ...policy.names])) {
|
|
92
|
+
const existing = Object.hasOwn(processEnv, name) && processEnv[name] !== undefined;
|
|
93
|
+
const fromFile = Object.hasOwn(parsed, name);
|
|
94
|
+
if (existing && (!override || !fromFile)) continue;
|
|
95
|
+
if (fromFile && !unresolved.has(name)) continue;
|
|
96
|
+
if (policy.names.has(name) === policy.listedRequired) missing.push(name);
|
|
97
|
+
else Object.defineProperty(parsed, name, { value: "", writable: true, enumerable: true, configurable: true });
|
|
98
|
+
}
|
|
99
|
+
if (missing.length) throw new Error(`Missing required environment variables: ${missing.join(", ")}`);
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
export async function config({
|
|
@@ -88,13 +105,20 @@ export async function config({
|
|
|
88
105
|
override = false,
|
|
89
106
|
processEnv = process.env,
|
|
90
107
|
signal,
|
|
91
|
-
warn =
|
|
108
|
+
warn = () => {},
|
|
92
109
|
runner = new StoreCommandRunner(),
|
|
110
|
+
required,
|
|
111
|
+
optional,
|
|
93
112
|
} = {}) {
|
|
94
113
|
signal?.throwIfAborted();
|
|
114
|
+
const policy = missingPolicy(required, optional);
|
|
95
115
|
const { parsed: raw, error } = await readParsed(Array.isArray(paths) ? paths : [paths], encoding, override);
|
|
96
116
|
const definitions = raw.WB_STORES?.trim() || processEnv.WB_STORES?.trim() || DEFAULT_STORE_DEFINITIONS;
|
|
97
|
-
const parsed = await resolveReferences(raw, definitions, { runner, signal, warn });
|
|
98
|
-
|
|
117
|
+
const { parsed, unresolved } = await resolveReferences(raw, definitions, { runner, signal, warn, policy });
|
|
118
|
+
applyMissingPolicy(parsed, unresolved, policy, processEnv, override);
|
|
119
|
+
const values = policy && !override
|
|
120
|
+
? Object.fromEntries(Object.entries(parsed).filter(([name]) => !Object.hasOwn(processEnv, name) || processEnv[name] === undefined))
|
|
121
|
+
: parsed;
|
|
122
|
+
dotenv.populate(processEnv, values, { override: policy ? true : override });
|
|
99
123
|
return error ? { parsed, error } : { parsed };
|
|
100
124
|
}
|