@venturewild/workspace 0.1.2 → 0.1.4

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.
@@ -1,302 +1,314 @@
1
- // Central config + role definitions.
2
- // One UI permission-flagged per AR-19.
3
-
4
- import path from 'node:path';
5
- import os from 'node:os';
6
- import fs from 'node:fs';
7
- import crypto from 'node:crypto';
8
- import { fileURLToPath } from 'node:url';
9
-
10
- import { loadAccount } from './account.mjs';
11
- import { loadOperatorToken } from './operator.mjs';
12
-
13
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
-
15
- // Secrets that shipped as scaffold defaults. Forgeable — never allowed on a
16
- // non-localhost bind. Kept only so the startup guard can recognise them.
17
- export const WEAK_SECRETS = Object.freeze(
18
- new Set(['dev-partner-token', 'dev-share-secret-change-me']),
19
- );
20
-
21
- const LOCAL_HOSTS = Object.freeze(new Set(['127.0.0.1', 'localhost', '::1']));
22
-
23
- export function isLocalhost(host) {
24
- return LOCAL_HOSTS.has(host);
25
- }
26
-
27
- // The bmo-sync daemon's HTTP origin, derived from its WebSocket event-feed
28
- // URL so the two stay consistent when WILD_WORKSPACE_DAEMON_URL is overridden.
29
- function daemonHttpBase(wsUrl) {
30
- try {
31
- const u = new URL(wsUrl);
32
- return `${u.protocol === 'wss:' ? 'https:' : 'http:'}//${u.host}`;
33
- } catch {
34
- return 'http://127.0.0.1:8320';
35
- }
36
- }
37
-
38
- // Per-install secrets. Generated once, persisted to <dataDir>/secrets.json
39
- // (the .wild-workspace dir is gitignored). Replaces the weak scaffold defaults
40
- // so share tokens can't be forged. (Concern C2.)
41
- function loadOrCreateSecrets(dataDir) {
42
- const secretsPath = path.join(dataDir, 'secrets.json');
43
- try {
44
- const parsed = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
45
- if (parsed.partnerToken && parsed.shareSecret) return parsed;
46
- } catch {
47
- // missing / unreadable / malformed — fall through and regenerate
48
- }
49
- const generated = {
50
- partnerToken: crypto.randomBytes(24).toString('base64url'),
51
- shareSecret: crypto.randomBytes(32).toString('base64url'),
52
- };
53
- try {
54
- fs.mkdirSync(dataDir, { recursive: true });
55
- fs.writeFileSync(secretsPath, JSON.stringify(generated, null, 2), { mode: 0o600 });
56
- } catch {
57
- // can't persist (read-only fs?) — still use the generated pair for this run
58
- }
59
- return generated;
60
- }
61
-
62
- // Refuse to start in public mode with a forgeable secret. (Concerns C1/C2.)
63
- export function assertSecureBinding(config) {
64
- if (!config.publicMode) return;
65
- if (WEAK_SECRETS.has(config.partnerToken) || WEAK_SECRETS.has(config.shareSecret)) {
66
- throw new Error(
67
- `Refusing to run in public mode with a default secret. ` +
68
- `Set WILD_WORKSPACE_PARTNER_TOKEN and WILD_WORKSPACE_SHARE_SECRET, ` +
69
- `or remove them so per-install secrets are generated.`,
70
- );
71
- }
72
- }
73
-
74
- export const ROLES = Object.freeze({
75
- PARTNER: 'partner',
76
- VIEWER: 'viewer',
77
- CLIENT: 'client',
78
- // The consented support/operator channel (off by default — see operator.mjs).
79
- OPERATOR: 'operator',
80
- });
81
-
82
- export const ROLE_CAPABILITIES = Object.freeze({
83
- partner: {
84
- chat: true,
85
- chatWrite: true,
86
- preview: true,
87
- fileTree: true,
88
- terminal: true,
89
- inbox: true,
90
- share: true,
91
- sync: true,
92
- deploy: true,
93
- requestChanges: false,
94
- operate: true, // the owner can also drive the operator allowlist locally
95
- },
96
- viewer: {
97
- chat: true,
98
- chatWrite: false,
99
- preview: true,
100
- fileTree: false,
101
- terminal: false,
102
- inbox: false,
103
- share: false,
104
- sync: false,
105
- deploy: false,
106
- requestChanges: false,
107
- operate: false,
108
- },
109
- client: {
110
- chat: true,
111
- chatWrite: true,
112
- preview: true,
113
- fileTree: false,
114
- terminal: false,
115
- inbox: false,
116
- share: false,
117
- sync: false,
118
- deploy: false,
119
- requestChanges: true,
120
- operate: false,
121
- },
122
- // Operator: remote diagnose + a curated remediation allowlist. Read-only on
123
- // chat (can SEE the conversation to help, cannot drive the agent — chatWrite
124
- // stays false), plus the `operate` capability the /api/operator/* routes gate
125
- // on. Reachable only with the dedicated operator token (operator.mjs).
126
- operator: {
127
- chat: true,
128
- chatWrite: false,
129
- preview: true,
130
- fileTree: true,
131
- terminal: false,
132
- inbox: false,
133
- share: false,
134
- sync: false,
135
- deploy: false,
136
- requestChanges: false,
137
- operate: true,
138
- },
139
- });
140
-
141
- export const DEFAULT_AGENTS = Object.freeze([
142
- {
143
- id: 'claude',
144
- binary: 'claude',
145
- label: 'Claude Code',
146
- description: 'Anthropic Claude Code',
147
- args: ['-p', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'],
148
- streamFormat: 'claude-stream-json',
149
- },
150
- {
151
- id: 'gemini',
152
- binary: 'gemini',
153
- label: 'Gemini CLI',
154
- description: 'Google Gemini',
155
- args: ['-p'],
156
- streamFormat: 'text',
157
- },
158
- {
159
- id: 'glm',
160
- binary: 'glm',
161
- label: 'GLM (Z.AI)',
162
- description: 'GLM-4.6 via Z.AI',
163
- args: ['-p', '--permission-mode', 'bypassPermissions'],
164
- streamFormat: 'text',
165
- },
166
- {
167
- id: 'codex',
168
- binary: 'codex',
169
- label: 'Codex (OpenAI)',
170
- description: 'GPT-5 / o3 via Codex CLI',
171
- args: ['exec', '--skip-git-repo-check'],
172
- streamFormat: 'text',
173
- },
174
- ]);
175
-
176
- export function buildConfig(overrides = {}) {
177
- const env = process.env;
178
- const portOverride = overrides.port;
179
- const resolvedPort =
180
- typeof portOverride === 'number'
181
- ? portOverride
182
- : Number(env.WILD_WORKSPACE_PORT || 5173);
183
- const workspaceDir = path.resolve(
184
- overrides.workspaceDir || env.WILD_WORKSPACE_DIR || process.cwd(),
185
- );
186
- const dataDir = path.resolve(
187
- overrides.dataDir ||
188
- env.WILD_WORKSPACE_DATA_DIR ||
189
- path.join(workspaceDir, '.wild-workspace'),
190
- );
191
- // Lazy: only load/generate persisted secrets if neither an override nor an
192
- // env var supplies one — keeps tests that pass both from touching the fs.
193
- let _secrets = null;
194
- const secrets = () => (_secrets ??= loadOrCreateSecrets(dataDir));
195
- const host = overrides.host || env.WILD_WORKSPACE_HOST || '127.0.0.1';
196
- // publicMode = "treat every request as untrusted". True for a non-localhost
197
- // bind, OR when WILD_WORKSPACE_PUBLIC=1 — needed when a tunnel (Cloudflare
198
- // etc.) forwards public traffic to a localhost-bound server, since the bind
199
- // address alone would otherwise look local. Drives the C1 auth posture.
200
- const publicMode =
201
- overrides.publicMode ??
202
- (env.WILD_WORKSPACE_PUBLIC === '1' || !isLocalhost(host));
203
- // bmo-sync: the local daemon's event feed (a WebSocket URL).
204
- const daemonUrl =
205
- overrides.daemonUrl ||
206
- env.WILD_WORKSPACE_DAEMON_URL ||
207
- 'ws://127.0.0.1:8320/api/events';
208
- // Per-install bmo-sync account — null until the user runs `wild-workspace
209
- // login` with the payload from `workspace.venturewild.llc`. Its presence
210
- // upgrades the install to a real slug (shareBaseUrl flips to the user's
211
- // subdomain) and lights up the /api/session.account field for the UI.
212
- const account =
213
- overrides.account === undefined ? loadAccount(dataDir) : overrides.account;
214
- const accountShareBase = account?.slug
215
- ? `https://${account.slug}.venturewild.llc`
216
- : null;
217
- return {
218
- port: resolvedPort,
219
- host,
220
- publicMode,
221
- workspaceDir,
222
- dataDir,
223
- webDir:
224
- overrides.webDir ||
225
- env.WILD_WORKSPACE_WEB_DIR ||
226
- path.resolve(__dirname, '..', '..', 'web', 'dist'),
227
- openBrowser: overrides.openBrowser ?? env.WILD_WORKSPACE_NO_OPEN !== '1',
228
- shareBaseUrl:
229
- overrides.shareBaseUrl ||
230
- env.WILD_WORKSPACE_SHARE_BASE_URL ||
231
- accountShareBase ||
232
- `http://${host}:${resolvedPort}`,
233
- // The signed-in account (if any). Kept on the server-side config so
234
- // /api/session can expose the public bits (slug, email, accountId, displayName)
235
- // to the UI while accountToken stays here only.
236
- account: account
237
- ? {
238
- slug: account.slug,
239
- email: account.email,
240
- accountId: account.accountId,
241
- displayName: account.displayName,
242
- loggedInAt: account.loggedInAt,
243
- // accountToken is intentionally kept out of the broadcasted config
244
- // shape — it's read separately by code that needs to authenticate
245
- // against bmo-sync.
246
- }
247
- : null,
248
- accountToken: account?.accountToken || null,
249
- partnerToken:
250
- overrides.partnerToken ||
251
- env.WILD_WORKSPACE_PARTNER_TOKEN ||
252
- secrets().partnerToken,
253
- shareSecret:
254
- overrides.shareSecret ||
255
- env.WILD_WORKSPACE_SHARE_SECRET ||
256
- secrets().shareSecret,
257
- // The operator-channel token — null unless the user explicitly enabled the
258
- // channel (`wild-workspace operator enable`). Off by default. Server-side
259
- // only; never broadcast to the browser.
260
- operatorToken:
261
- overrides.operatorToken ??
262
- env.WILD_WORKSPACE_OPERATOR_TOKEN ??
263
- loadOperatorToken(dataDir),
264
- workspaceId:
265
- overrides.workspaceId ||
266
- env.WILD_WORKSPACE_ID ||
267
- path.basename(workspaceDir) ||
268
- 'workspace',
269
- role: overrides.role || env.WILD_WORKSPACE_ROLE || ROLES.PARTNER,
270
- // bmo-sync daemon — a separate local process; the bridge retries quietly
271
- // when it is absent. daemonUrl is the WebSocket event feed; daemonHttpUrl
272
- // is the same origin's HTTP API (pair / detach / list).
273
- daemonUrl,
274
- daemonHttpUrl: daemonHttpBase(daemonUrl),
275
- // Auto-start the bmo-sync daemon when the server boots. On by default;
276
- // WILD_WORKSPACE_DAEMON_AUTOSTART=0 disables it. Forced off under the test
277
- // runner (VITEST / NODE_ENV=test) so the suite never spawns a real daemon.
278
- daemonAutostart:
279
- overrides.daemonAutostart ??
280
- (env.WILD_WORKSPACE_DAEMON_AUTOSTART !== '0' &&
281
- !env.VITEST &&
282
- env.NODE_ENV !== 'test'),
283
- // Central bmo-sync server (Fly.io). Used to redeem invites (via the
284
- // daemon) and — only when an admin key is set — to mint them.
285
- bmoSyncServerUrl:
286
- overrides.bmoSyncServerUrl ||
287
- env.WILD_WORKSPACE_BMO_SYNC_URL ||
288
- env.BMO_SYNC_URL ||
289
- 'https://sync.venturewild.llc',
290
- // Optional. Present only on an install that mints invites (the folder
291
- // owner). Absent installs can still redeem. Never sent to the browser.
292
- bmoSyncAdminKey:
293
- overrides.bmoSyncAdminKey ||
294
- env.BMO_SYNC_ADMIN_KEY ||
295
- env.WILD_WORKSPACE_BMO_ADMIN_KEY ||
296
- null,
297
- home: os.homedir(),
298
- nodeEnv: env.NODE_ENV || 'production',
299
- };
300
- }
301
-
302
- export const APP_VERSION = '0.1.0';
1
+ // Central config + role definitions.
2
+ // One UI permission-flagged per AR-19.
3
+
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import fs from 'node:fs';
7
+ import crypto from 'node:crypto';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ import { loadAccount } from './account.mjs';
11
+ import { loadOperatorToken } from './operator.mjs';
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ // Secrets that shipped as scaffold defaults. Forgeable — never allowed on a
16
+ // non-localhost bind. Kept only so the startup guard can recognise them.
17
+ export const WEAK_SECRETS = Object.freeze(
18
+ new Set(['dev-partner-token', 'dev-share-secret-change-me']),
19
+ );
20
+
21
+ const LOCAL_HOSTS = Object.freeze(new Set(['127.0.0.1', 'localhost', '::1']));
22
+
23
+ export function isLocalhost(host) {
24
+ return LOCAL_HOSTS.has(host);
25
+ }
26
+
27
+ // The bmo-sync daemon's HTTP origin, derived from its WebSocket event-feed
28
+ // URL so the two stay consistent when WILD_WORKSPACE_DAEMON_URL is overridden.
29
+ function daemonHttpBase(wsUrl) {
30
+ try {
31
+ const u = new URL(wsUrl);
32
+ return `${u.protocol === 'wss:' ? 'https:' : 'http:'}//${u.host}`;
33
+ } catch {
34
+ return 'http://127.0.0.1:8320';
35
+ }
36
+ }
37
+
38
+ // Per-install secrets. Generated once, persisted to <dataDir>/secrets.json
39
+ // (the .wild-workspace dir is gitignored). Replaces the weak scaffold defaults
40
+ // so share tokens can't be forged. (Concern C2.)
41
+ function loadOrCreateSecrets(dataDir) {
42
+ const secretsPath = path.join(dataDir, 'secrets.json');
43
+ try {
44
+ const parsed = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
45
+ if (parsed.partnerToken && parsed.shareSecret) return parsed;
46
+ } catch {
47
+ // missing / unreadable / malformed — fall through and regenerate
48
+ }
49
+ const generated = {
50
+ partnerToken: crypto.randomBytes(24).toString('base64url'),
51
+ shareSecret: crypto.randomBytes(32).toString('base64url'),
52
+ };
53
+ try {
54
+ fs.mkdirSync(dataDir, { recursive: true });
55
+ fs.writeFileSync(secretsPath, JSON.stringify(generated, null, 2), { mode: 0o600 });
56
+ } catch {
57
+ // can't persist (read-only fs?) — still use the generated pair for this run
58
+ }
59
+ return generated;
60
+ }
61
+
62
+ // Refuse to start in public mode with a forgeable secret. (Concerns C1/C2.)
63
+ export function assertSecureBinding(config) {
64
+ if (!config.publicMode) return;
65
+ if (WEAK_SECRETS.has(config.partnerToken) || WEAK_SECRETS.has(config.shareSecret)) {
66
+ throw new Error(
67
+ `Refusing to run in public mode with a default secret. ` +
68
+ `Set WILD_WORKSPACE_PARTNER_TOKEN and WILD_WORKSPACE_SHARE_SECRET, ` +
69
+ `or remove them so per-install secrets are generated.`,
70
+ );
71
+ }
72
+ }
73
+
74
+ export const ROLES = Object.freeze({
75
+ PARTNER: 'partner',
76
+ VIEWER: 'viewer',
77
+ CLIENT: 'client',
78
+ // The consented support/operator channel (off by default — see operator.mjs).
79
+ OPERATOR: 'operator',
80
+ });
81
+
82
+ export const ROLE_CAPABILITIES = Object.freeze({
83
+ partner: {
84
+ chat: true,
85
+ chatWrite: true,
86
+ preview: true,
87
+ fileTree: true,
88
+ terminal: true,
89
+ inbox: true,
90
+ share: true,
91
+ sync: true,
92
+ deploy: true,
93
+ requestChanges: false,
94
+ operate: true, // the owner can also drive the operator allowlist locally
95
+ },
96
+ viewer: {
97
+ chat: true,
98
+ chatWrite: false,
99
+ preview: true,
100
+ fileTree: false,
101
+ terminal: false,
102
+ inbox: false,
103
+ share: false,
104
+ sync: false,
105
+ deploy: false,
106
+ requestChanges: false,
107
+ operate: false,
108
+ },
109
+ client: {
110
+ chat: true,
111
+ chatWrite: true,
112
+ preview: true,
113
+ fileTree: false,
114
+ terminal: false,
115
+ inbox: false,
116
+ share: false,
117
+ sync: false,
118
+ deploy: false,
119
+ requestChanges: true,
120
+ operate: false,
121
+ },
122
+ // Operator: remote diagnose + a curated remediation allowlist. Read-only on
123
+ // chat (can SEE the conversation to help, cannot drive the agent — chatWrite
124
+ // stays false), plus the `operate` capability the /api/operator/* routes gate
125
+ // on. Reachable only with the dedicated operator token (operator.mjs).
126
+ operator: {
127
+ chat: true,
128
+ chatWrite: false,
129
+ preview: true,
130
+ fileTree: true,
131
+ terminal: false,
132
+ inbox: false,
133
+ share: false,
134
+ sync: false,
135
+ deploy: false,
136
+ requestChanges: false,
137
+ operate: true,
138
+ },
139
+ });
140
+
141
+ export const DEFAULT_AGENTS = Object.freeze([
142
+ {
143
+ id: 'claude',
144
+ binary: 'claude',
145
+ label: 'Claude Code',
146
+ description: 'Anthropic Claude Code',
147
+ args: ['-p', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'],
148
+ streamFormat: 'claude-stream-json',
149
+ },
150
+ {
151
+ id: 'gemini',
152
+ binary: 'gemini',
153
+ label: 'Gemini CLI',
154
+ description: 'Google Gemini',
155
+ args: ['-p'],
156
+ streamFormat: 'text',
157
+ },
158
+ {
159
+ id: 'glm',
160
+ binary: 'glm',
161
+ label: 'GLM (Z.AI)',
162
+ description: 'GLM-4.6 via Z.AI',
163
+ args: ['-p', '--permission-mode', 'bypassPermissions'],
164
+ streamFormat: 'text',
165
+ },
166
+ {
167
+ id: 'codex',
168
+ binary: 'codex',
169
+ label: 'Codex (OpenAI)',
170
+ description: 'GPT-5 / o3 via Codex CLI',
171
+ args: ['exec', '--skip-git-repo-check'],
172
+ streamFormat: 'text',
173
+ },
174
+ ]);
175
+
176
+ export function buildConfig(overrides = {}) {
177
+ const env = process.env;
178
+ const portOverride = overrides.port;
179
+ const resolvedPort =
180
+ typeof portOverride === 'number'
181
+ ? portOverride
182
+ : Number(env.WILD_WORKSPACE_PORT || 5173);
183
+ const workspaceDir = path.resolve(
184
+ overrides.workspaceDir || env.WILD_WORKSPACE_DIR || process.cwd(),
185
+ );
186
+ const dataDir = path.resolve(
187
+ overrides.dataDir ||
188
+ env.WILD_WORKSPACE_DATA_DIR ||
189
+ path.join(workspaceDir, '.wild-workspace'),
190
+ );
191
+ // Lazy: only load/generate persisted secrets if neither an override nor an
192
+ // env var supplies one — keeps tests that pass both from touching the fs.
193
+ let _secrets = null;
194
+ const secrets = () => (_secrets ??= loadOrCreateSecrets(dataDir));
195
+ const host = overrides.host || env.WILD_WORKSPACE_HOST || '127.0.0.1';
196
+ // publicMode = "treat every request as untrusted". True for a non-localhost
197
+ // bind, OR when WILD_WORKSPACE_PUBLIC=1 — needed when a tunnel (Cloudflare
198
+ // etc.) forwards public traffic to a localhost-bound server, since the bind
199
+ // address alone would otherwise look local. Drives the C1 auth posture.
200
+ const publicMode =
201
+ overrides.publicMode ??
202
+ (env.WILD_WORKSPACE_PUBLIC === '1' || !isLocalhost(host));
203
+ // bmo-sync: the local daemon's event feed (a WebSocket URL).
204
+ const daemonUrl =
205
+ overrides.daemonUrl ||
206
+ env.WILD_WORKSPACE_DAEMON_URL ||
207
+ 'ws://127.0.0.1:8320/api/events';
208
+ // Per-install bmo-sync account — null until the user runs `wild-workspace
209
+ // login` with the payload from `workspace.venturewild.llc`. Its presence
210
+ // upgrades the install to a real slug (shareBaseUrl flips to the user's
211
+ // subdomain) and lights up the /api/session.account field for the UI.
212
+ const account =
213
+ overrides.account === undefined ? loadAccount(dataDir) : overrides.account;
214
+ const accountShareBase = account?.slug
215
+ ? `https://${account.slug}.venturewild.llc`
216
+ : null;
217
+ return {
218
+ port: resolvedPort,
219
+ host,
220
+ publicMode,
221
+ workspaceDir,
222
+ dataDir,
223
+ webDir:
224
+ overrides.webDir ||
225
+ env.WILD_WORKSPACE_WEB_DIR ||
226
+ path.resolve(__dirname, '..', '..', 'web', 'dist'),
227
+ openBrowser: overrides.openBrowser ?? env.WILD_WORKSPACE_NO_OPEN !== '1',
228
+ shareBaseUrl:
229
+ overrides.shareBaseUrl ||
230
+ env.WILD_WORKSPACE_SHARE_BASE_URL ||
231
+ accountShareBase ||
232
+ `http://${host}:${resolvedPort}`,
233
+ // The signed-in account (if any). Kept on the server-side config so
234
+ // /api/session can expose the public bits (slug, email, accountId, displayName)
235
+ // to the UI while accountToken stays here only.
236
+ account: account
237
+ ? {
238
+ slug: account.slug,
239
+ email: account.email,
240
+ accountId: account.accountId,
241
+ displayName: account.displayName,
242
+ loggedInAt: account.loggedInAt,
243
+ // accountToken is intentionally kept out of the broadcasted config
244
+ // shape — it's read separately by code that needs to authenticate
245
+ // against bmo-sync.
246
+ }
247
+ : null,
248
+ accountToken: account?.accountToken || null,
249
+ partnerToken:
250
+ overrides.partnerToken ||
251
+ env.WILD_WORKSPACE_PARTNER_TOKEN ||
252
+ secrets().partnerToken,
253
+ shareSecret:
254
+ overrides.shareSecret ||
255
+ env.WILD_WORKSPACE_SHARE_SECRET ||
256
+ secrets().shareSecret,
257
+ // The operator-channel token — null unless the user explicitly enabled the
258
+ // channel (`wild-workspace operator enable`). Off by default. Server-side
259
+ // only; never broadcast to the browser.
260
+ operatorToken:
261
+ overrides.operatorToken ??
262
+ env.WILD_WORKSPACE_OPERATOR_TOKEN ??
263
+ loadOperatorToken(dataDir),
264
+ workspaceId:
265
+ overrides.workspaceId ||
266
+ env.WILD_WORKSPACE_ID ||
267
+ path.basename(workspaceDir) ||
268
+ 'workspace',
269
+ role: overrides.role || env.WILD_WORKSPACE_ROLE || ROLES.PARTNER,
270
+ // bmo-sync daemon — a separate local process; the bridge retries quietly
271
+ // when it is absent. daemonUrl is the WebSocket event feed; daemonHttpUrl
272
+ // is the same origin's HTTP API (pair / detach / list).
273
+ daemonUrl,
274
+ daemonHttpUrl: daemonHttpBase(daemonUrl),
275
+ // Auto-start the bmo-sync daemon when the server boots. On by default;
276
+ // WILD_WORKSPACE_DAEMON_AUTOSTART=0 disables it. Forced off under the test
277
+ // runner (VITEST / NODE_ENV=test) so the suite never spawns a real daemon.
278
+ daemonAutostart:
279
+ overrides.daemonAutostart ??
280
+ (env.WILD_WORKSPACE_DAEMON_AUTOSTART !== '0' &&
281
+ !env.VITEST &&
282
+ env.NODE_ENV !== 'test'),
283
+ // Central bmo-sync server (Fly.io). Used to redeem invites (via the
284
+ // daemon) and — only when an admin key is set — to mint them.
285
+ bmoSyncServerUrl:
286
+ overrides.bmoSyncServerUrl ||
287
+ env.WILD_WORKSPACE_BMO_SYNC_URL ||
288
+ env.BMO_SYNC_URL ||
289
+ 'https://sync.venturewild.llc',
290
+ // Optional. Present only on an install that mints invites (the folder
291
+ // owner). Absent installs can still redeem. Never sent to the browser.
292
+ bmoSyncAdminKey:
293
+ overrides.bmoSyncAdminKey ||
294
+ env.BMO_SYNC_ADMIN_KEY ||
295
+ env.WILD_WORKSPACE_BMO_ADMIN_KEY ||
296
+ null,
297
+ home: os.homedir(),
298
+ nodeEnv: env.NODE_ENV || 'production',
299
+ };
300
+ }
301
+
302
+ // Read from package.json (the single source of truth) so the reported version
303
+ // can never drift from the published release — `npm version` bumps it for free.
304
+ // This previously hardcoded '0.1.0', so every release (0.1.1/0.1.2/0.1.3) shipped
305
+ // a stale version to `--version`, /api/health, doctor, and all telemetry.
306
+ function readAppVersion() {
307
+ try {
308
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8'));
309
+ return pkg.version || '0.0.0';
310
+ } catch {
311
+ return '0.0.0';
312
+ }
313
+ }
314
+ export const APP_VERSION = readAppVersion();