@inthedark/wb 0.0.6 → 0.0.8

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
@@ -58,7 +58,8 @@ note: have tried to keep dependencies minimal for the most part
58
58
  `/daemon/host` — wake service
59
59
  `/daemon/server` — workbench harness
60
60
  `/daemon/voice` — vtt service (rust)
61
- `/daemon/repo` — vfs service (go)
61
+ `/daemon/repo` — vfs service (go)
62
+ `/daemon/relay` — linux sandbox loopback relay (go)
62
63
  `/integration` — integrations in third party software
63
64
  `/instructions` — instructions for agents, copied into the user's `~/.workbench` folder whenever read
64
65
  `/app/server` — server for frontend app
@@ -69,3 +70,45 @@ note: have tried to keep dependencies minimal for the most part
69
70
  `/test` — test runner
70
71
  `/scripts` — stuff used by package scripts plus anything i didn't notice an agent left behind :3
71
72
  `/docs` — help agents find things and not break them without eating all their tokens
73
+
74
+ ## .env secrets
75
+
76
+ 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:
77
+
78
+ ```js
79
+ import { config } from "@inthedark/wb";
80
+
81
+ await config();
82
+ ```
83
+
84
+ the following syntax in your .env file can be used to load data from workbench's private store:
85
+
86
+ ```dotenv
87
+ API_KEY=${wb:API_KEY}
88
+ ```
89
+
90
+ configure secrets in workbench at settings > projects & folders > environment > store. select the folder you want to configure.
91
+
92
+ 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.
93
+
94
+ you can choose what happens when values are missing:
95
+
96
+ ```js
97
+ await config({ required: ["DATABASE_URL"] }); // all others optional
98
+ await config({ optional: ["ANALYTICS_KEY"] }); // all others required
99
+ ```
100
+
101
+ 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.
102
+
103
+ you can use wb's env loader to load from other clis too, not just wb store:
104
+
105
+ ```dotenv
106
+ WB_STORES="wb: wb store get {key}; other: my-secret-cli get {key}"
107
+ API_KEY=${other:API_KEY}
108
+ ```
109
+
110
+ 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.
111
+
112
+ ### security note
113
+
114
+ 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.
@@ -13,6 +13,7 @@ import WorkbenchInstallPrompt from "./WorkbenchInstallPrompt.mjs";
13
13
  import WorkbenchBootstrapCommand from "./WorkbenchBootstrapCommand.mjs";
14
14
  import WorkbenchToolchain from "./WorkbenchToolchain.mjs";
15
15
  import WorkbenchPackageRegistration from "./WorkbenchPackageRegistration.mjs";
16
+ import resolveWorkbenchDataRoot from "./workbench-data-root.mjs";
16
17
 
17
18
  export default class WorkbenchBootstrap {
18
19
  constructor({
@@ -35,7 +36,10 @@ export default class WorkbenchBootstrap {
35
36
  this.toolchain = toolchain;
36
37
  this.registration = registration;
37
38
  this.write = write;
38
- this.registry = path.join(home, ".workbench", "installation.json");
39
+ this.registry = path.join(
40
+ resolveWorkbenchDataRoot({ environment, homeDirectory: home, platform }),
41
+ "installation", "installation.json",
42
+ );
39
43
  }
40
44
 
41
45
  async checkoutExists(root) {
package/index.d.ts CHANGED
@@ -1,25 +1,40 @@
1
1
  /*
2
2
  * Exports:
3
- * - WorkbenchConfigOptions: dotenv-compatible loading options plus cancellation and warning hooks.
4
- * - WorkbenchConfigOutput: parsed values after store replacement, with the first file read error.
5
- * - config: load dotenv files, then replace resolvable `${store:key}` references through named WB_STORES commands.
3
+ * - WorkbenchConfigOptions: dotenv mapping, missing-value policies, cancellation and warning hooks.
4
+ * - WorkbenchConfigOutput: mapped values after store replacement and policy checks, with the first file read error.
5
+ * - config: load and map dotenv values; resolve stores and check required names before environment writes.
6
6
  */
7
- export interface WorkbenchConfigOptions {
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;
11
11
  /** Replace variables already present in `processEnv`, as dotenv does. */
12
12
  override?: boolean;
13
13
  processEnv?: Record<string, string | undefined>;
14
+ /** Map merged parsed values before store resolution and policy checks. Rejection prevents environment writes. */
15
+ map?: (env: Record<string, string>) => Record<string, string> | Promise<Record<string, string>>;
14
16
  /** Aborting kills running store commands and rejects. */
15
17
  signal?: AbortSignal;
16
- /** Receives bounded messages that never contain command output. Defaults to `process.emitWarning`. */
18
+ /** Opt into bounded lookup warnings that never contain command output. Silent by default. */
17
19
  warn?: (message: string) => void;
18
- }
20
+ } & ({
21
+ /** Only these names are required; all other dotenv variables are optional. Empty values count as supplied. */
22
+ required?: readonly string[];
23
+ optional?: never;
24
+ } | {
25
+ required?: never;
26
+ /** These names are optional; all other dotenv variables are required. Empty values count as supplied. */
27
+ optional?: readonly string[];
28
+ });
19
29
 
20
30
  export interface WorkbenchConfigOutput {
21
31
  parsed: Record<string, string>;
22
32
  error?: Error;
23
33
  }
24
34
 
35
+ /**
36
+ * Without a policy, unresolved references remain literal. With a policy, missing optional
37
+ * variables become empty strings in their entirety. Missing required names reject together
38
+ * before any environment writes. Existing environment values win unless override is set.
39
+ */
25
40
  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, then replace resolvable `${store:key}` references through named WB_STORES commands.
3
+ * - config: load and map dotenv values, resolve 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,10 +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
16
  function stripTerminalLineEnding(value) {
21
17
  return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value;
22
18
  }
@@ -37,15 +33,17 @@ async function readParsed(paths, encoding, override) {
37
33
  return { parsed, error };
38
34
  }
39
35
 
40
- 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();
41
39
  let stores;
42
40
  try {
43
41
  stores = parseStoreDefinitions(definitions);
44
42
  } catch (failure) {
45
- warn(`${failure.message} Store references were left unchanged.`);
46
- return parsed;
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 };
47
46
  }
48
- const references = new Map(Object.entries(parsed).map(([name, value]) => [name, findStoreReferences(value)]));
49
47
  const lookups = new Map();
50
48
  for (const [name, found] of references) {
51
49
  for (const { key, store } of found) {
@@ -53,25 +51,52 @@ async function resolveReferences(parsed, definitions, { runner, signal, warn })
53
51
  if (stores.has(store) && !lookups.has(id)) lookups.set(id, { argv: expandStoreCommand(stores.get(store), key), owner: name, store });
54
52
  }
55
53
  }
56
- if (!lookups.size) return parsed;
57
54
  const results = new Map(await Promise.all(Array.from(lookups, async ([id, lookup]) => {
58
55
  const result = await runner.run(lookup.argv, { signal });
59
56
  if (!result.ok) {
60
57
  const detail = result.reason === "exit" ? `exited with ${result.exitCode}` : `failed (${result.reason})`;
61
- warn(`The ${lookup.store} store lookup for ${lookup.owner} ${detail}. The reference was left unchanged.`);
58
+ warn(`The ${lookup.store} store lookup for ${lookup.owner} ${detail}.${policy ? "" : " The reference was left unchanged."}`);
62
59
  }
63
60
  return [id, result];
64
61
  })));
65
- return Object.fromEntries(Object.entries(parsed).map(([name, value]) => {
62
+ const resolved = Object.fromEntries(Object.entries(parsed).map(([name, value]) => {
66
63
  let next = "";
67
64
  let cursor = 0;
68
65
  for (const reference of references.get(name)) {
69
66
  const result = results.get(JSON.stringify([reference.store, reference.key]));
67
+ if (!result?.ok) unresolved.add(name);
70
68
  next += value.slice(cursor, reference.start) + (result?.ok ? stripTerminalLineEnding(result.stdout) : reference.text);
71
69
  cursor = reference.end;
72
70
  }
73
71
  return [name, next + value.slice(cursor)];
74
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(", ")}`);
75
100
  }
76
101
 
77
102
  export async function config({
@@ -80,13 +105,23 @@ export async function config({
80
105
  override = false,
81
106
  processEnv = process.env,
82
107
  signal,
83
- warn = warnDefault,
108
+ warn = () => {},
84
109
  runner = new StoreCommandRunner(),
110
+ required,
111
+ optional,
112
+ map,
85
113
  } = {}) {
86
114
  signal?.throwIfAborted();
87
- const { parsed: raw, error } = await readParsed(Array.isArray(paths) ? paths : [paths], encoding, override);
115
+ const policy = missingPolicy(required, optional);
116
+ const { parsed: input, error } = await readParsed(Array.isArray(paths) ? paths : [paths], encoding, override);
117
+ const raw = map ? await map(input) : input;
118
+ signal?.throwIfAborted();
88
119
  const definitions = raw.WB_STORES?.trim() || processEnv.WB_STORES?.trim() || DEFAULT_STORE_DEFINITIONS;
89
- const parsed = await resolveReferences(raw, definitions, { runner, signal, warn });
90
- dotenv.populate(processEnv, parsed, { override });
120
+ const { parsed, unresolved } = await resolveReferences(raw, definitions, { runner, signal, warn, policy });
121
+ applyMissingPolicy(parsed, unresolved, policy, processEnv, override);
122
+ const values = policy && !override
123
+ ? Object.fromEntries(Object.entries(parsed).filter(([name]) => !Object.hasOwn(processEnv, name) || processEnv[name] === undefined))
124
+ : parsed;
125
+ dotenv.populate(processEnv, values, { override: policy ? true : override });
91
126
  return error ? { parsed, error } : { parsed };
92
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inthedark/wb",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",