dsh-model 0.0.1 → 0.15.0

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.
Files changed (83) hide show
  1. package/README.en.md +127 -0
  2. package/README.md +96 -43
  3. package/bin/dsh-model.js +8 -0
  4. package/dist/bridge/aggregate.js +91 -0
  5. package/dist/bridge/login.js +127 -0
  6. package/dist/bridge/runtime.js +136 -0
  7. package/dist/bridge/server.js +183 -0
  8. package/dist/bridge/workbuddy/_compat.js +52 -0
  9. package/dist/bridge/workbuddy/app-version.js +141 -0
  10. package/dist/bridge/workbuddy/auth.js +548 -0
  11. package/dist/bridge/workbuddy/catalog.js +154 -0
  12. package/dist/bridge/workbuddy/client-identity.js +234 -0
  13. package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
  14. package/dist/bridge/workbuddy/loopback.js +52 -0
  15. package/dist/bridge/workbuddy/probe.js +153 -0
  16. package/dist/bridge/workbuddy/status-paths.js +49 -0
  17. package/dist/bridge/workbuddy/upstream.js +1180 -0
  18. package/dist/bridge/workbuddy/variants.js +101 -0
  19. package/dist/cli.js +215 -0
  20. package/dist/commands/bridge.js +68 -0
  21. package/dist/commands/connect.js +67 -0
  22. package/dist/commands/engine.js +122 -0
  23. package/dist/commands/key.js +75 -0
  24. package/dist/commands/login.js +113 -0
  25. package/dist/commands/logs.js +19 -0
  26. package/dist/commands/opencode.js +83 -0
  27. package/dist/commands/remote.js +129 -0
  28. package/dist/commands/self.js +116 -0
  29. package/dist/commands/service.js +72 -0
  30. package/dist/commands/setup.js +219 -0
  31. package/dist/commands/sources.js +196 -0
  32. package/dist/commands/status.js +216 -0
  33. package/dist/commands/uninstall.js +97 -0
  34. package/dist/commands/workbuddy.js +117 -0
  35. package/dist/context.js +86 -0
  36. package/dist/daemon/client.js +27 -0
  37. package/dist/daemon/core.js +850 -0
  38. package/dist/daemon/service.js +33 -0
  39. package/dist/daemon/stats.js +126 -0
  40. package/dist/daemon/usage.js +213 -0
  41. package/dist/dsh/cli.js +90 -0
  42. package/dist/dsh/connect.js +257 -0
  43. package/dist/dsh/credentials.js +55 -0
  44. package/dist/dsh/locate.js +129 -0
  45. package/dist/dsh/patch.js +106 -0
  46. package/dist/dsh/pick.js +59 -0
  47. package/dist/engine/auth.js +40 -0
  48. package/dist/engine/client.js +104 -0
  49. package/dist/engine/compat.js +94 -0
  50. package/dist/engine/config.js +47 -0
  51. package/dist/engine/install.js +118 -0
  52. package/dist/engine/mgmt.js +99 -0
  53. package/dist/engine/sourcemap.js +48 -0
  54. package/dist/errors.js +14 -0
  55. package/dist/i18n.js +39 -0
  56. package/dist/integrations/dshplugin.js +89 -0
  57. package/dist/integrations/opencode.js +101 -0
  58. package/dist/integrations/workbuddy.js +114 -0
  59. package/dist/keys.js +54 -0
  60. package/dist/ops.js +178 -0
  61. package/dist/remote/public.js +109 -0
  62. package/dist/secrets.js +13 -0
  63. package/dist/service/index.js +48 -0
  64. package/dist/service/launchd.js +89 -0
  65. package/dist/service/systemd.js +83 -0
  66. package/dist/sources.js +36 -0
  67. package/dist/state.js +90 -0
  68. package/dist/upstreams.js +23 -0
  69. package/dist/util/diff.js +30 -0
  70. package/dist/util/exec.js +42 -0
  71. package/dist/util/fs.js +107 -0
  72. package/dist/util/output.js +50 -0
  73. package/dist/util/pkg.js +13 -0
  74. package/dist/util/port.js +16 -0
  75. package/dist/util/prompt.js +40 -0
  76. package/dist/util/proxy.js +73 -0
  77. package/dist/util/redact.js +7 -0
  78. package/engine-manifest.json +28 -0
  79. package/package.json +77 -7
  80. package/plugin/client.js +688 -0
  81. package/plugin/cordis.patch.yml +4 -0
  82. package/plugin/index.js +231 -0
  83. package/docs/DESIGN.md +0 -610
@@ -0,0 +1,992 @@
1
+ // Ported from dsh-workbuddy-connect v0.7.1 (commit 390582e04da4e8e8bf746d71997440e4b716ec47),
2
+ // https://github.com/corrinehu/dsh-workbuddy-connect — MIT License, Copyright (c) 2026 Corrine Hu.
3
+ // See LICENSE.dsh-workbuddy-connect in this directory. Changes: imports adapted to dsh-model (./_compat.js).
4
+ /**
5
+ * WorkBuddy 5.6.x at-rest credential protection: classification, key
6
+ * resolution, and field decryption for the desktop apps' encrypted auth files.
7
+ *
8
+ * Since WorkBuddy 5.6 both desktop apps (CN and international, which followed
9
+ * in 5.6.2) encrypt `auth.accessToken` and `auth.refreshToken` at rest
10
+ * (`buildPolicy: "fields"`, on by default), so the plugin reads
11
+ * `{$wbEncrypted:1, envelope}` wrappers instead of token strings (issues
12
+ * #39/#40, #59/#60). Everything needed to open them lives on the same machine:
13
+ *
14
+ * - the sealed payload (`{version:1, atRestSecretKey}`) comes from the
15
+ * WorkBuddy-modified Electron's private `workbuddyStorage` binding, reached
16
+ * by running *its own* binary once with `ELECTRON_RUN_AS_NODE=1`;
17
+ * - `protectorKey = sha256(atRestSecretKey, utf8)` opens the envelopes with
18
+ * AES-256-GCM; the AAD builder below is transcribed from the app's own
19
+ * `buildAuthenticatedContextAad` (verified live against 5.6.2, see
20
+ * `docs/r3-final.js` in the working copy — not committed).
21
+ *
22
+ * The plugin process itself can never call `_linkedBinding` (it runs in DSH's
23
+ * Node, not the forked Electron), so the helper is spawned. The key is cached
24
+ * in memory only, single-flight, and re-resolved when an envelope names a
25
+ * different key id. Neither the payload, the key, nor any token is ever
26
+ * logged; error messages carry sizes, ids, and exit codes only.
27
+ *
28
+ * @module dsh-workbuddy-connect/desktop-credential-protection
29
+ */
30
+ import { execFile } from 'node:child_process';
31
+ import { accessSync, constants, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
32
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
33
+ import { basename, dirname, join } from 'node:path';
34
+ import { electronProfileFor } from './variants.js';
35
+ /**
36
+ * Env variable naming an explicit Electron binary for the CN product. The
37
+ * international product has its own ({@link WorkBuddyElectronProduct.envVar}
38
+ * on each variant); a shared variable is a single point of failure across two
39
+ * independently installed apps (issue #60).
40
+ */
41
+ export const WORKBUDDY_ELECTRON_BIN_ENV = 'WORKBUDDY_ELECTRON_BIN';
42
+ /**
43
+ * The platform-default Electron binary for one product, or `undefined` where
44
+ * none is verified. macOS defaults come from each product's measured layout;
45
+ * the Windows default needs `LOCALAPPDATA`, and the international app has no
46
+ * verified default location at all — registry discovery only, never a guess.
47
+ */
48
+ export function defaultWorkBuddyElectronPath(product, platform = process.platform) {
49
+ if (platform === 'darwin')
50
+ return product.macOS.defaultPath;
51
+ if (platform !== 'win32' || product.windows.defaultPathSegments === undefined)
52
+ return undefined;
53
+ const localAppData = process.env.LOCALAPPDATA?.trim();
54
+ return localAppData === undefined || localAppData === ''
55
+ ? undefined
56
+ : join(localAppData, ...product.windows.defaultPathSegments);
57
+ }
58
+ /** Distinct key ids across the wrapped fields, in field order. */
59
+ export function keyIdsOf(fields) {
60
+ return [...new Set(fields.map(wrapped => wrapped.envelope.keyId))];
61
+ }
62
+ /**
63
+ * Whether a raw value is the 5.6 field wrapper, with its inner envelope
64
+ * decodable. The wrapper is `{$wbEncrypted:1, envelope:<base64 of a JSON
65
+ * {suite,keyId,nonce,authTag,ciphertext>}}`; anything claiming the flag whose
66
+ * envelope cannot be decoded makes the whole document unrecognized rather
67
+ * than encrypted, because no key could ever open it.
68
+ */
69
+ function parseWrappedField(field, value) {
70
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
71
+ return undefined;
72
+ const wrapped = value;
73
+ if (wrapped['$wbEncrypted'] !== 1 || typeof wrapped['envelope'] !== 'string')
74
+ return undefined;
75
+ let inner;
76
+ try {
77
+ inner = JSON.parse(Buffer.from(wrapped['envelope'], 'base64').toString('utf8'));
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ if (typeof inner !== 'object' || inner === null || Array.isArray(inner))
83
+ return undefined;
84
+ const parts = inner;
85
+ const nonce = parseBase64(parts['nonce'], 12);
86
+ const authTag = parseBase64(parts['authTag'], 16);
87
+ const ciphertext = parseBase64(parts['ciphertext']);
88
+ if (nonce === undefined || authTag === undefined || ciphertext === undefined)
89
+ return undefined;
90
+ if (typeof parts['suite'] !== 'number' || !Number.isInteger(parts['suite']))
91
+ return undefined;
92
+ // Suite 1 is the only scheme WorkBuddy 5.6.x defines for credential fields.
93
+ // Anything else is a format this plugin has not seen, so the wrapper is not
94
+ // claimed as encrypted — the document then reads as unrecognized and the
95
+ // store reports a diagnosis instead of attempting a blind open.
96
+ if (parts['suite'] !== 1)
97
+ return undefined;
98
+ if (typeof parts['keyId'] !== 'string' || !/^[0-9a-f]{16}$/u.test(parts['keyId']))
99
+ return undefined;
100
+ return {
101
+ field,
102
+ envelope: {
103
+ suite: parts['suite'],
104
+ keyId: parts['keyId'],
105
+ nonce,
106
+ authTag,
107
+ ciphertext,
108
+ },
109
+ };
110
+ }
111
+ /** Decode a base64 value and check its exact byte length when given. */
112
+ function parseBase64(value, length) {
113
+ if (typeof value !== 'string' || value === '')
114
+ return undefined;
115
+ let decoded;
116
+ try {
117
+ decoded = Buffer.from(value, 'base64');
118
+ }
119
+ catch {
120
+ return undefined;
121
+ }
122
+ // Buffer.from is lenient about stray characters; require the round-trip so a
123
+ // tampered envelope is rejected before any key material is involved.
124
+ if (decoded.length === 0 || decoded.toString('base64').replace(/=+$/u, '') !== value.replace(/=+$/u, ''))
125
+ return undefined;
126
+ return length === undefined || decoded.length === length ? decoded : undefined;
127
+ }
128
+ const AUTH_FIELDS = ['accessToken', 'refreshToken'];
129
+ /**
130
+ * Read a desktop auth document's format. `absent` is an empty file; `plaintext`
131
+ * is any document the regular parser could read (even one without a token);
132
+ * `encrypted` has at least one field in a decodable wrapper; everything else —
133
+ * unparsable JSON, non-objects, wrappers whose envelope will not decode — is
134
+ * `unrecognized`.
135
+ */
136
+ export function classifyDesktopAuthDocument(text) {
137
+ if (text.trim() === '')
138
+ return { format: 'absent' };
139
+ let parsed;
140
+ try {
141
+ parsed = JSON.parse(text);
142
+ }
143
+ catch {
144
+ return { format: 'unrecognized' };
145
+ }
146
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
147
+ return { format: 'unrecognized' };
148
+ const document = parsed;
149
+ const auth = typeof document['auth'] === 'object' && document['auth'] !== null
150
+ ? document['auth']
151
+ : document;
152
+ const fields = [];
153
+ for (const field of AUTH_FIELDS) {
154
+ const value = auth[field];
155
+ if (typeof value === 'string')
156
+ continue;
157
+ const wrapped = parseWrappedField(field, value);
158
+ // A field in *some* object that is not a decodable wrapper: not plaintext,
159
+ // not usable. Treated as unrecognized below unless another field wrapped.
160
+ if (wrapped === undefined && value !== undefined)
161
+ return { format: 'unrecognized' };
162
+ if (wrapped !== undefined)
163
+ fields.push(wrapped);
164
+ }
165
+ if (fields.length === 0)
166
+ return { format: 'plaintext' };
167
+ return { format: 'encrypted', wrapped: { document, fields } };
168
+ }
169
+ /**
170
+ * Decrypt a wrapped document into the plaintext text the regular parser reads.
171
+ * Throws a diagnosable error naming the field and key ids — never envelope or
172
+ * token content — when any wrapped field cannot be opened.
173
+ */
174
+ export function unwrapDesktopAuthDocument(classification, openField) {
175
+ const wrapped = classification.wrapped;
176
+ const rebuilt = structuredClone(wrapped.document);
177
+ const auth = typeof rebuilt['auth'] === 'object' && rebuilt['auth'] !== null
178
+ ? rebuilt['auth']
179
+ : rebuilt;
180
+ for (const field of wrapped.fields) {
181
+ auth[field.field] = openField(field);
182
+ }
183
+ return JSON.stringify(rebuilt);
184
+ }
185
+ /**
186
+ * The authenticated-context AAD for one field envelope, transcribed from the
187
+ * app bundle's `buildAuthenticatedContextAad` and verified live against 5.6.2
188
+ * (`docs/r3-final.js` in the working copy holds the original reference).
189
+ * Credential fields are always suite 1 under the `field` framing (WBEV1);
190
+ * the framing family's other members (WBEF1/WBER1/WBES1) belong to other
191
+ * document kinds and are deliberately not implemented — opening a field is
192
+ * not a place to guess at future formats.
193
+ */
194
+ export function buildAuthenticatedContextAad(keyId, suite) {
195
+ const prefix = Buffer.from('WB-AAD\0', 'ascii');
196
+ const lengthPrefixed = (value) => {
197
+ const bytes = Buffer.from(value, 'utf8');
198
+ const header = Buffer.allocUnsafe(4);
199
+ header.writeUInt32BE(bytes.length);
200
+ return Buffer.concat([header, bytes]);
201
+ };
202
+ const suiteBytes = Buffer.allocUnsafe(4);
203
+ suiteBytes.writeUInt32BE(suite);
204
+ // No sequence numbers on credential fields; the final byte 0 mirrors the
205
+ // reference script's default context.
206
+ return Buffer.concat([
207
+ prefix, Buffer.from([1]),
208
+ lengthPrefixed('WBEV1'),
209
+ lengthPrefixed('sym-v1'),
210
+ suiteBytes,
211
+ lengthPrefixed(keyId),
212
+ Buffer.from([2]),
213
+ Buffer.from([0]),
214
+ Buffer.from([0]),
215
+ ]);
216
+ }
217
+ /**
218
+ * Open one envelope with a protector key; `undefined` when it will not open.
219
+ * The accepted format is exactly what WorkBuddy 5.6.2 writes — suite 1 under
220
+ * the `field` framing — so a failure means "not this format / wrong key",
221
+ * and is reported as such rather than retried against other framings.
222
+ */
223
+ export function openAuthField(key, envelope) {
224
+ try {
225
+ const decipher = createDecipheriv('aes-256-gcm', key, envelope.nonce, { authTagLength: 16 });
226
+ decipher.setAAD(buildAuthenticatedContextAad(envelope.keyId, envelope.suite));
227
+ decipher.setAuthTag(envelope.authTag);
228
+ return Buffer.concat([decipher.update(envelope.ciphertext), decipher.final()]).toString('utf8');
229
+ }
230
+ catch {
231
+ return undefined;
232
+ }
233
+ }
234
+ /** Seal one field with the exact format `openAuthField` reads. Test helper. */
235
+ export function sealAuthFieldForTest(key, plaintext, suite = 1) {
236
+ const keyId = createHash('sha256').update(key).digest('hex').slice(0, 16);
237
+ const nonce = randomBytes(12);
238
+ const cipher = createCipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 });
239
+ cipher.setAAD(buildAuthenticatedContextAad(keyId, suite));
240
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(plaintext, 'utf8')), cipher.final()]);
241
+ const inner = {
242
+ suite,
243
+ keyId,
244
+ nonce: nonce.toString('base64'),
245
+ authTag: cipher.getAuthTag().toString('base64'),
246
+ ciphertext: ciphertext.toString('base64'),
247
+ };
248
+ return { '$wbEncrypted': 1, envelope: Buffer.from(JSON.stringify(inner), 'utf8').toString('base64') };
249
+ }
250
+ /**
251
+ * Validate the helper's payload against the app's own rules: `version:1` and
252
+ * a canonical-base64 32-byte, non-all-zero secret. `undefined` otherwise.
253
+ */
254
+ export function parseAtRestPayload(text) {
255
+ let parsed;
256
+ try {
257
+ parsed = JSON.parse(text);
258
+ }
259
+ catch {
260
+ return undefined;
261
+ }
262
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
263
+ return undefined;
264
+ const payload = parsed;
265
+ if (payload['version'] !== 1)
266
+ return undefined;
267
+ const secret = payload['atRestSecretKey'];
268
+ if (typeof secret !== 'string' || secret === '')
269
+ return undefined;
270
+ let decoded;
271
+ try {
272
+ decoded = Buffer.from(secret, 'base64');
273
+ }
274
+ catch {
275
+ return undefined;
276
+ }
277
+ if (decoded.length !== 32)
278
+ return undefined;
279
+ if (decoded.toString('base64') !== secret)
280
+ return undefined;
281
+ if (decoded.every(byte => byte === 0))
282
+ return undefined;
283
+ return { atRestSecretKey: secret };
284
+ }
285
+ /** Derive the protector key from the payload's secret (sha256 over its UTF-8 string). */
286
+ export function deriveProtectorKey(secret) {
287
+ return createHash('sha256').update(secret, 'utf8').digest();
288
+ }
289
+ /**
290
+ * Select discovery by platform. Both products have verified layouts on macOS
291
+ * and Windows (the international app is registry-only there), so discovery no
292
+ * longer gates on region; Linux and others stay `none`.
293
+ */
294
+ export function electronDiscoveryFor(platform = process.platform) {
295
+ if (platform === 'darwin')
296
+ return 'macos-workbuddy';
297
+ if (platform === 'win32')
298
+ return 'windows-workbuddy';
299
+ return 'none';
300
+ }
301
+ /**
302
+ * The at-rest key provider one variant's store should use. Shared by the
303
+ * plugin host and the CLI entry so the browser card and `doctor`/`status` can
304
+ * never disagree about which binary a variant resolves.
305
+ */
306
+ export function atRestKeyProviderFor(variant) {
307
+ return new WorkBuddyAtRestKeyProvider({
308
+ product: electronProfileFor(variant),
309
+ discovery: electronDiscoveryFor(),
310
+ });
311
+ }
312
+ /** Absolute tool paths: never resolved through PATH, which a user can change. */
313
+ const MDFIND_BIN = '/usr/bin/mdfind';
314
+ const PLUTIL_BIN = '/usr/bin/plutil';
315
+ const WINDOWS_REGISTRY_ROOTS = [
316
+ 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
317
+ 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
318
+ 'HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
319
+ ];
320
+ const WINDOWS_REGISTRY_OUTPUT_MAX_BYTES = 1024 * 1024;
321
+ const WINDOWS_ELECTRON_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u;
322
+ /** One discovery subprocess's own limits; see {@link WorkBuddyAtRestKeyProviderOptions}. */
323
+ export const WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS = 3_000;
324
+ /** Whole-discovery budget, independent of the helper's own timeout. */
325
+ export const WORKBUDDY_DISCOVERY_BUDGET_MS = 10_000;
326
+ const MDFIND_MAX_OUTPUT_BYTES = 1024 * 1024;
327
+ const PLUTIL_MAX_OUTPUT_BYTES = 64 * 1024;
328
+ /**
329
+ * Why a discovery step could not produce an answer. Every one of these means
330
+ * "we do not know", explicitly *not* "the candidate does not exist" — the
331
+ * distinction is what keeps a half-finished check from being mistaken for a
332
+ * unique candidate.
333
+ */
334
+ class DiscoveryIncompleteError extends Error {
335
+ }
336
+ /**
337
+ * The default discovery tools: Spotlight for the bundle, `/usr/bin/plutil` for
338
+ * identity. Every failure that means "we could not tell" — a missing tool, a
339
+ * timeout, an oversized answer — is raised as {@link DiscoveryIncompleteError}
340
+ * so it can never be silently read as "no such app".
341
+ *
342
+ * `bundleId` is the product being searched for; the Spotlight query and the
343
+ * caller's identity comparison both use it, so the two can never disagree
344
+ * about which app they are looking for.
345
+ */
346
+ export function workBuddyDiscoveryTools(bundleId) {
347
+ const runTool = (bin, args, maxBytes, signal) => new Promise((resolve, reject) => {
348
+ if (signal.aborted) {
349
+ reject(new DiscoveryIncompleteError(`${bin} was not started: the discovery budget was already spent`));
350
+ return;
351
+ }
352
+ let settled = false;
353
+ const child = execFile(bin, [...args], { maxBuffer: maxBytes, timeout: WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS }, (error, stdout) => {
354
+ if (settled)
355
+ return;
356
+ settled = true;
357
+ if (error !== null && error !== undefined) {
358
+ reject(new DiscoveryIncompleteError(`${bin} could not complete (${error.killed === true ? 'timed out' : String(error.code ?? 'unavailable')})`));
359
+ return;
360
+ }
361
+ resolve(stdout);
362
+ });
363
+ const abort = () => {
364
+ if (settled)
365
+ return;
366
+ settled = true;
367
+ child.kill();
368
+ reject(new DiscoveryIncompleteError(`${bin} was abandoned: the discovery budget was spent`));
369
+ };
370
+ signal.addEventListener('abort', abort, { once: true });
371
+ child.on('close', () => { signal.removeEventListener('abort', abort); });
372
+ });
373
+ return {
374
+ findApps: async (signal) => {
375
+ const out = await runTool(MDFIND_BIN, [`kMDItemCFBundleIdentifier == '${bundleId}'`], MDFIND_MAX_OUTPUT_BYTES, signal);
376
+ return out.split('\n').map(line => line.trim()).filter(line => line.endsWith('.app'));
377
+ },
378
+ bundleIdentifier: async (bundlePath, signal) => {
379
+ try {
380
+ const out = await runTool(PLUTIL_BIN, ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', join(bundlePath, 'Contents', 'Info.plist')], PLUTIL_MAX_OUTPUT_BYTES, signal);
381
+ return out.trim();
382
+ }
383
+ catch {
384
+ // An unreadable plist is "we could not check this one", not "this one
385
+ // does not match" — the candidate stays unresolved and the whole
386
+ // discovery reports incomplete rather than quietly dropping it.
387
+ return undefined;
388
+ }
389
+ },
390
+ bundleVersion: async (bundlePath, signal) => {
391
+ try {
392
+ const out = await runTool(PLUTIL_BIN, ['-extract', 'CFBundleShortVersionString', 'raw', '-o', '-', join(bundlePath, 'Contents', 'Info.plist')], PLUTIL_MAX_OUTPUT_BYTES, signal);
393
+ const version = out.trim();
394
+ return version === '' ? undefined : version;
395
+ }
396
+ catch {
397
+ return undefined;
398
+ }
399
+ },
400
+ };
401
+ }
402
+ /**
403
+ * The Windows registry discovery tool. It is deliberately separate from the
404
+ * macOS Spotlight/plutil seam: the two platforms have different identity and
405
+ * candidate rules, and neither tool should accidentally become cross-platform.
406
+ */
407
+ export function workBuddyWindowsDiscoveryTools() {
408
+ const systemRoot = process.env.SystemRoot?.trim();
409
+ const regPath = systemRoot === undefined || systemRoot === ''
410
+ ? undefined
411
+ : join(systemRoot, 'System32', 'reg.exe');
412
+ const runTool = (root, signal) => new Promise((resolve, reject) => {
413
+ if (regPath === undefined) {
414
+ reject(new DiscoveryIncompleteError('SystemRoot is not configured'));
415
+ return;
416
+ }
417
+ if (signal.aborted) {
418
+ reject(new DiscoveryIncompleteError('reg.exe was not started: the discovery budget was already spent'));
419
+ return;
420
+ }
421
+ let settled = false;
422
+ const child = execFile(regPath, ['query', root, '/s'], {
423
+ maxBuffer: WINDOWS_REGISTRY_OUTPUT_MAX_BYTES,
424
+ timeout: WORKBUDDY_DISCOVERY_STEP_TIMEOUT_MS,
425
+ windowsHide: true,
426
+ }, (error, stdout, stderr) => {
427
+ if (settled)
428
+ return;
429
+ settled = true;
430
+ // `reg query` uses exit code 1 for more than a missing key. Only a
431
+ // confirmed missing-key diagnostic is an empty result; every other
432
+ // failure stays incomplete rather than becoming "not installed".
433
+ if (error !== null && error !== undefined) {
434
+ if (error.killed !== true && (error.code === 1 || error.code === '1')
435
+ && windowsRegistryKeyMissing(stderr)) {
436
+ resolve('');
437
+ return;
438
+ }
439
+ reject(new DiscoveryIncompleteError(`reg.exe could not complete (${error.killed === true ? 'timed out' : String(error.code ?? 'unavailable')})`));
440
+ return;
441
+ }
442
+ resolve(stdout);
443
+ });
444
+ const abort = () => {
445
+ if (settled)
446
+ return;
447
+ settled = true;
448
+ child.kill();
449
+ reject(new DiscoveryIncompleteError('reg.exe was abandoned: the discovery budget was spent'));
450
+ };
451
+ signal.addEventListener('abort', abort, { once: true });
452
+ child.on('close', () => { signal.removeEventListener('abort', abort); });
453
+ });
454
+ return { queryUninstallRoot: runTool };
455
+ }
456
+ /**
457
+ * In-memory protector-key resolver: one spawn per key id, single-flight, never
458
+ * persisted. The cache is keyed by the id envelopes ask for, so an envelope
459
+ * sealed under a rotated key triggers exactly one fresh resolution.
460
+ */ export class WorkBuddyAtRestKeyProvider {
461
+ /**
462
+ * The explicit binary, when one was configured. `undefined` here means "the
463
+ * caller did not name one", which is what lets discovery run — an explicit
464
+ * path that turns out to be unusable is an error, never a reason to look for
465
+ * a different app.
466
+ */
467
+ explicitPath;
468
+ product;
469
+ defaultPath;
470
+ discovery;
471
+ tools;
472
+ windowsTools;
473
+ platform;
474
+ discoveryBudgetMs;
475
+ timeoutMs;
476
+ source;
477
+ spawnHelper;
478
+ /**
479
+ * The path discovery settled on, cached only on success. A failure leaves
480
+ * this unset so the next attempt tries again — the user may install or move
481
+ * the app without restarting DSH.
482
+ */
483
+ discoveredPath;
484
+ cache;
485
+ inflight;
486
+ constructor(options) {
487
+ this.product = options.product;
488
+ const fromEnv = process.env[options.product.envVar]?.trim();
489
+ const envPath = fromEnv === undefined || fromEnv === '' ? undefined : fromEnv;
490
+ // Explicit sources are authoritative and mutually exclusive with
491
+ // discovery: naming a binary means "use this one", so an unusable one is
492
+ // an error, not an invitation to go looking for another app.
493
+ this.explicitPath = options.electronPath ?? envPath;
494
+ this.discovery = options.discovery ?? 'none';
495
+ this.platform = options.platform ?? process.platform;
496
+ this.defaultPath = this.discovery === 'none'
497
+ ? undefined
498
+ : options.defaultElectronPath === undefined
499
+ ? defaultWorkBuddyElectronPath(options.product, this.platform)
500
+ : options.defaultElectronPath ?? undefined;
501
+ this.tools = options.tools ?? workBuddyDiscoveryTools(options.product.macOS.bundleId);
502
+ this.windowsTools = options.windowsTools ?? workBuddyWindowsDiscoveryTools();
503
+ this.discoveryBudgetMs = options.discoveryBudgetMs ?? WORKBUDDY_DISCOVERY_BUDGET_MS;
504
+ this.timeoutMs = options.timeoutMs ?? 10_000;
505
+ this.spawnHelper = options.spawnHelper ?? (path => this.spawnAt(path));
506
+ this.source = options.source ?? (() => this.spawnPayload());
507
+ }
508
+ /**
509
+ * The binary the default helper would use, for diagnostics.
510
+ *
511
+ * Reports a *discovery result* once one exists, so diagnostics describe what
512
+ * would actually run rather than the default that was bypassed. Discovery
513
+ * itself stays in {@link resolveElectronPath}: this accessor never triggers a
514
+ * search (the constructor must remain I/O-free, and callers may ask before
515
+ * any resolution has happened).
516
+ */
517
+ helperPath() {
518
+ if (this.explicitPath !== undefined)
519
+ return this.explicitPath;
520
+ if (this.discovery === 'none')
521
+ return undefined;
522
+ return this.discoveredPath ?? this.defaultPath;
523
+ }
524
+ /**
525
+ * A protector key matching one of the requested envelope key ids. The first
526
+ * id the cache answers wins; otherwise one spawn resolves the current key,
527
+ * which must match a request — a mismatch means the envelopes were sealed by
528
+ * a different install than the one this machine now runs, and no key we can
529
+ * reach will open them.
530
+ */
531
+ async protectorKeyFor(requested) {
532
+ if (requested.length === 0) {
533
+ throw new WorkBuddyElectronPathError('encrypted-credential-unreadable', 'encrypted desktop credential carries no key ids');
534
+ }
535
+ const cached = this.cache;
536
+ if (cached !== undefined && requested.includes(cached.keyId))
537
+ return cached.key;
538
+ this.inflight ??= this.source().then(text => this.ingest(text))
539
+ .finally(() => {
540
+ this.inflight = undefined;
541
+ });
542
+ const resolved = await this.inflight;
543
+ if (!requested.includes(resolved.keyId)) {
544
+ throw new WorkBuddyElectronPathError('encrypted-credential-unreadable', `WorkBuddy's current at-rest key (id ${resolved.keyId}) does not match the credential's envelope (id ${requested.join(' or ')});`
545
+ + ' the desktop credential was sealed by a different WorkBuddy installation');
546
+ }
547
+ return resolved.key;
548
+ }
549
+ ingest(text) {
550
+ const payload = parseAtRestPayload(text);
551
+ if (payload === undefined) {
552
+ throw new WorkBuddyElectronPathError('encrypted-credential-unreadable', 'WorkBuddy key helper returned an unusable at-rest payload (expected {version:1, atRestSecretKey})');
553
+ }
554
+ const key = deriveProtectorKey(payload.atRestSecretKey);
555
+ const resolved = {
556
+ key,
557
+ keyId: createHash('sha256').update(key).digest('hex').slice(0, 16),
558
+ };
559
+ this.cache = resolved;
560
+ return resolved;
561
+ }
562
+ /**
563
+ * The binary to spawn, or a diagnosable error saying why there is none.
564
+ *
565
+ * Order is the contract: an explicit path is used as-is and never falls back;
566
+ * discovery runs only for a provider that was configured for it, and only
567
+ * after the platform default has been tried and found unusable.
568
+ */
569
+ async resolveElectronPath() {
570
+ if (this.explicitPath !== undefined) {
571
+ if (!isExecutable(this.explicitPath)) {
572
+ throw new WorkBuddyElectronPathError('electron-path-invalid', `the configured ${this.product.productName} Electron binary is not available at ${this.explicitPath};`
573
+ + ` check ${this.product.envVar} or unset it to let the plugin look for the app itself`);
574
+ }
575
+ return this.explicitPath;
576
+ }
577
+ if (this.discovery === 'none') {
578
+ // A platform without a verified layout: "not configured", never "we
579
+ // searched and failed".
580
+ throw new WorkBuddyElectronPathError('electron-binary-unavailable', `no ${this.product.productName} Electron binary is configured for this platform;`
581
+ + ` set ${this.product.envVar} to the app's Electron binary`);
582
+ }
583
+ // The default path is the ordinary case and costs one stat; discovery is
584
+ // reserved for the installations the default misses.
585
+ if (this.defaultPath !== undefined && isExecutable(this.defaultPath))
586
+ return this.defaultPath;
587
+ // A previously discovered path is re-checked rather than trusted: the app
588
+ // may have been moved or removed since, and a stale path must not win.
589
+ if (this.discoveredPath !== undefined) {
590
+ if (isExecutable(this.discoveredPath))
591
+ return this.discoveredPath;
592
+ this.discoveredPath = undefined;
593
+ }
594
+ const found = this.discovery === 'macos-workbuddy'
595
+ ? await this.discoverMacosApp()
596
+ : await this.discoverWindowsApp();
597
+ this.discoveredPath = found;
598
+ return found;
599
+ }
600
+ /**
601
+ * Resolve this product's app through Spotlight, then prove each candidate's
602
+ * identity before it can be executed.
603
+ *
604
+ * The whole flow shares one budget: a hang in one candidate must not extend
605
+ * the wait for the others, and running out of budget is reported as an
606
+ * unfinished check rather than an absent app.
607
+ */
608
+ async discoverMacosApp() {
609
+ if (this.platform !== 'darwin') {
610
+ throw new WorkBuddyElectronPathError('electron-binary-unavailable', `no ${this.product.productName} Electron binary is configured for this platform;`
611
+ + ` set ${this.product.envVar} to the app's Electron binary`);
612
+ }
613
+ const controller = new AbortController();
614
+ const timer = setTimeout(() => controller.abort(), this.discoveryBudgetMs);
615
+ try {
616
+ let candidates;
617
+ try {
618
+ candidates = await this.tools.findApps(controller.signal);
619
+ }
620
+ catch {
621
+ throw discoveryIncomplete(this.product.productName, 'the app search did not complete');
622
+ }
623
+ // Several Spotlight rows can name one bundle (path aliases, the
624
+ // /System/Volumes/Data view). Identity + realpath collapse those into
625
+ // one candidate; only genuinely distinct apps may count as "more than
626
+ // one", or a single app would look ambiguous.
627
+ const seen = new Map();
628
+ let unresolved = false;
629
+ for (const candidate of candidates) {
630
+ // A Spotlight index keeps rows for apps deleted since the last sweep,
631
+ // and a stale row is a *decidable* exclusion: the candidate is gone,
632
+ // which is not the same as "we could not check it". Skipping it here
633
+ // is what stops one dead row from sinking a live app beside it — the
634
+ // plan's "明确不可用 → 排除该候选" case (issue #48 §3.7).
635
+ //
636
+ // Only ENOENT qualifies. `existsSync` is not usable here: it answers
637
+ // `false` for *any* error, so an EACCES/EPERM parent (or an
638
+ // ENAMETOOLONG path) would be read as "this app was deleted" and a
639
+ // live sibling would be chosen over a candidate we merely could not
640
+ // inspect. `statSync` distinguishes them, and anything other than a
641
+ // confirmed absence stays unresolved.
642
+ try {
643
+ statSync(candidate);
644
+ }
645
+ catch (error) {
646
+ if (isENOENT(error))
647
+ continue;
648
+ unresolved = true;
649
+ continue;
650
+ }
651
+ let bundleIdentifier;
652
+ try {
653
+ bundleIdentifier = await this.tools.bundleIdentifier(candidate, controller.signal);
654
+ }
655
+ catch {
656
+ unresolved = true;
657
+ continue;
658
+ }
659
+ if (bundleIdentifier === undefined) {
660
+ unresolved = true;
661
+ continue;
662
+ }
663
+ if (bundleIdentifier !== this.product.macOS.bundleId)
664
+ continue;
665
+ const electronPath = join(candidate, 'Contents', 'MacOS', 'Electron');
666
+ if (!isExecutable(electronPath))
667
+ continue;
668
+ let identity;
669
+ try {
670
+ identity = realpathSync(candidate);
671
+ }
672
+ catch {
673
+ identity = candidate;
674
+ }
675
+ if (seen.has(identity))
676
+ continue;
677
+ let version;
678
+ try {
679
+ version = await this.tools.bundleVersion(candidate, controller.signal);
680
+ }
681
+ catch {
682
+ // Version is display-only; an unreadable one must not sink an
683
+ // otherwise identified candidate.
684
+ version = undefined;
685
+ }
686
+ seen.set(identity, { bundlePath: candidate, electronPath, ...version === undefined ? {} : { version } });
687
+ }
688
+ if (seen.size > 1) {
689
+ const listed = [...seen.values()]
690
+ .map(app => ` - ${app.bundlePath}${app.version === undefined ? '' : ` (${app.version})`}`)
691
+ .join('\n');
692
+ throw new WorkBuddyElectronPathError('electron-binary-ambiguous', `more than one ${this.product.productName} application was found, so none was chosen:\n${listed}\n`
693
+ + ` set ${this.product.envVar} to the one to use`);
694
+ }
695
+ // A candidate nobody could check might have been a second copy, so its
696
+ // existence forbids claiming the rest are unique. This is the difference
697
+ // between "we know there is exactly one" and "we only found one of the
698
+ // ones we could read" (§3.4).
699
+ if (unresolved)
700
+ throw discoveryIncomplete(this.product.productName, 'some candidates could not be checked');
701
+ if (seen.size === 0) {
702
+ throw new WorkBuddyElectronPathError('electron-binary-not-found', `no ${this.product.productName} application was found in the default location or the system index;`
703
+ + ' if it is installed elsewhere, it may not be indexed yet;'
704
+ + ` set ${this.product.envVar} to the app's Electron binary`);
705
+ }
706
+ return [...seen.values()][0].electronPath;
707
+ }
708
+ finally {
709
+ clearTimeout(timer);
710
+ controller.abort();
711
+ }
712
+ }
713
+ /**
714
+ * Resolve this product's app through Windows uninstall records. Registry
715
+ * entries provide hints, not trust: every DisplayIcon candidate must still
716
+ * be the product's Electron binary with the known Electron layout before
717
+ * execution.
718
+ */
719
+ async discoverWindowsApp() {
720
+ if (this.platform !== 'win32') {
721
+ throw new WorkBuddyElectronPathError('electron-binary-unavailable', `no ${this.product.productName} Electron binary is configured for this platform;`
722
+ + ` set ${this.product.envVar} to the app's Electron binary`);
723
+ }
724
+ const controller = new AbortController();
725
+ const timer = setTimeout(() => controller.abort(), this.discoveryBudgetMs);
726
+ try {
727
+ const candidates = [];
728
+ let unresolved = false;
729
+ for (const root of WINDOWS_REGISTRY_ROOTS) {
730
+ let output;
731
+ try {
732
+ output = await this.windowsTools.queryUninstallRoot(root, controller.signal);
733
+ }
734
+ catch {
735
+ unresolved = true;
736
+ continue;
737
+ }
738
+ const parsed = parseWindowsRegistryOutput(output, this.product.windows.displayNamePattern);
739
+ candidates.push(...parsed.candidates);
740
+ unresolved ||= parsed.incomplete;
741
+ }
742
+ const seen = new Map();
743
+ const rejected = [];
744
+ for (const candidate of new Set(candidates)) {
745
+ const inspection = inspectWindowsElectronCandidate(candidate, this.platform, this.product.windows.exeBasename);
746
+ if (inspection === 'unresolved') {
747
+ unresolved = true;
748
+ continue;
749
+ }
750
+ if (inspection === undefined) {
751
+ rejected.push(candidate);
752
+ continue;
753
+ }
754
+ seen.set(inspection.identity, inspection.electronPath);
755
+ }
756
+ if (unresolved)
757
+ throw discoveryIncomplete(this.product.productName, 'some registry entries or candidates could not be checked');
758
+ if (seen.size > 1) {
759
+ const listed = [...seen.values()].map(path => ` - ${path}`).join('\n');
760
+ throw new WorkBuddyElectronPathError('electron-binary-ambiguous', `more than one ${this.product.productName} application was found, so none was chosen:\n${listed}\n`
761
+ + ` set ${this.product.envVar} to the one to use`);
762
+ }
763
+ if (seen.size === 0) {
764
+ // #66: "nothing was found" and "candidates were found but each failed
765
+ // the layout check" are different diagnoses; the old single wording
766
+ // claimed the former even when the latter was true, sending users
767
+ // hunting for an install that was right there. The counts stay, the
768
+ // candidate paths deliberately do not — this text reaches /status and
769
+ // doctor, and local install paths are not for every loopback reader.
770
+ const message = rejected.length === 0
771
+ ? `no usable ${this.product.productName} Electron binary was found in the default location or Windows uninstall records;`
772
+ + ` set ${this.product.envVar} to the app's Electron binary`
773
+ : `Windows uninstall records found ${rejected.length} ${this.product.productName} candidate${rejected.length > 1 ? 's' : ''},`
774
+ + ` but ${rejected.length > 1 ? 'none' : 'it'} did not match the expected app layout (the app's exe beside a version file and resources\\app.asar);`
775
+ + ` set ${this.product.envVar} to the installed app's executable to use it`;
776
+ throw new WorkBuddyElectronPathError('electron-binary-not-found', message);
777
+ }
778
+ return [...seen.values()][0];
779
+ }
780
+ finally {
781
+ clearTimeout(timer);
782
+ controller.abort();
783
+ }
784
+ }
785
+ async spawnPayload() {
786
+ return await this.spawnHelper(await this.resolveElectronPath());
787
+ }
788
+ async spawnAt(electronPath) {
789
+ return await new Promise((resolve, reject) => {
790
+ execFile(electronPath, [HELPER_SCRIPT_ARGUMENT_FLAG, HELPER_SCRIPT], {
791
+ timeout: this.timeoutMs,
792
+ maxBuffer: 1024 * 1024,
793
+ windowsHide: true,
794
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
795
+ }, (error, stdout) => {
796
+ if (error !== null && error !== undefined) {
797
+ // Code and reason only: stdout/stderr can carry paths or crash dumps,
798
+ // and the payload must never appear in a message.
799
+ const reason = error.killed === true
800
+ ? `timed out or was killed after ${String(this.timeoutMs)}ms`
801
+ : error.code !== undefined
802
+ ? `exited with code ${String(error.code)}`
803
+ : 'could not be started';
804
+ reject(new WorkBuddyElectronPathError('encrypted-credential-unreadable', `the WorkBuddy key helper (${electronPath}) ${reason}`));
805
+ return;
806
+ }
807
+ const output = stdout.trim();
808
+ if (output === '') {
809
+ reject(new WorkBuddyElectronPathError('encrypted-credential-unreadable', `the WorkBuddy key helper (${electronPath}) produced no payload`));
810
+ return;
811
+ }
812
+ resolve(output);
813
+ });
814
+ });
815
+ }
816
+ }
817
+ /** Only known missing-key diagnostics can safely make a failed query empty. */
818
+ function windowsRegistryKeyMissing(stderr) {
819
+ const detail = stderr.trim();
820
+ return /^ERROR:\s*The system was unable to find the specified registry key or value\.?$/iu.test(detail)
821
+ || /^错误[::]\s*系统找不到指定的注册表项或值[。.]?$/u.test(detail);
822
+ }
823
+ /**
824
+ * Parse the value columns emitted by `reg query ... /s`.
825
+ *
826
+ * Entries are filtered by the product's DisplayName pattern *before* the
827
+ * DisplayIcon is judged, so another product's records — however broken — are
828
+ * excluded as decisively not ours and can never mark this product's search
829
+ * incomplete.
830
+ */
831
+ function parseWindowsRegistryOutput(output, displayNamePattern) {
832
+ const entries = new Map();
833
+ let currentKey;
834
+ for (const line of output.split(/\r?\n/u)) {
835
+ const keyMatch = /^\s*(HKEY_[^\r\n]+?)\s*$/iu.exec(line);
836
+ if (keyMatch !== null) {
837
+ currentKey = keyMatch[1];
838
+ entries.set(currentKey, {});
839
+ continue;
840
+ }
841
+ if (currentKey === undefined)
842
+ continue;
843
+ const valueMatch = /^\s+(DisplayName|DisplayIcon)\s+REG_[A-Z0-9_]+\s*(.*?)\s*$/iu.exec(line);
844
+ if (valueMatch === null)
845
+ continue;
846
+ const entry = entries.get(currentKey);
847
+ if (entry === undefined)
848
+ continue;
849
+ const value = valueMatch[2] ?? '';
850
+ if (valueMatch[1].toLowerCase() === 'displayname')
851
+ entry.displayName = value;
852
+ else
853
+ entry.displayIcon = value;
854
+ }
855
+ const candidates = [];
856
+ let incomplete = false;
857
+ for (const entry of entries.values()) {
858
+ if (entry.displayName === undefined)
859
+ continue;
860
+ if (!displayNamePattern.test(entry.displayName.trim()))
861
+ continue;
862
+ const displayIcon = entry.displayIcon === undefined ? undefined : parseWindowsDisplayIcon(entry.displayIcon);
863
+ if (displayIcon === undefined)
864
+ incomplete = true;
865
+ else
866
+ candidates.push(displayIcon);
867
+ }
868
+ return { candidates, incomplete };
869
+ }
870
+ /** Read a quoted DisplayIcon path and remove the Windows icon-index suffix. */
871
+ function parseWindowsDisplayIcon(value) {
872
+ const raw = value.trim();
873
+ let path;
874
+ if (raw.startsWith('"')) {
875
+ const closingQuote = raw.indexOf('"', 1);
876
+ if (closingQuote < 0)
877
+ return undefined;
878
+ const suffix = raw.slice(closingQuote + 1).trim();
879
+ if (suffix !== '' && !/^,\d+$/u.test(suffix))
880
+ return undefined;
881
+ path = raw.slice(1, closingQuote).replace(/,\d+$/u, '');
882
+ }
883
+ else {
884
+ const match = /^(.+?\.exe)(?:,\d+)?$/iu.exec(raw);
885
+ if (match === null)
886
+ return undefined;
887
+ path = match[1];
888
+ }
889
+ path = path.trim();
890
+ return /\.exe$/iu.test(path) ? path : undefined;
891
+ }
892
+ /**
893
+ * Validate the known Windows layout for the product's exe. `undefined` is a
894
+ * decidable exclusion; `unresolved` is reserved for errors that prevent
895
+ * checking.
896
+ */
897
+ function inspectWindowsElectronCandidate(electronPath, platform, exeBasename) {
898
+ if (platform !== 'win32' || basename(electronPath).toLowerCase() !== exeBasename)
899
+ return undefined;
900
+ let binaryStat;
901
+ try {
902
+ binaryStat = statSync(electronPath);
903
+ }
904
+ catch (error) {
905
+ return isENOENT(error) ? undefined : 'unresolved';
906
+ }
907
+ if (!binaryStat.isFile())
908
+ return undefined;
909
+ try {
910
+ accessSync(electronPath, constants.X_OK);
911
+ }
912
+ catch (error) {
913
+ return isENOENT(error) ? undefined : 'unresolved';
914
+ }
915
+ const installRoot = dirname(electronPath);
916
+ let version;
917
+ try {
918
+ version = readFileSync(join(installRoot, 'version'), 'utf8').trim();
919
+ }
920
+ catch (error) {
921
+ return isENOENT(error) ? undefined : 'unresolved';
922
+ }
923
+ if (!WINDOWS_ELECTRON_VERSION_PATTERN.test(version))
924
+ return undefined;
925
+ try {
926
+ // The archive path must not go through fs.stat: under an Electron host,
927
+ // asar interception stats app.asar as a directory (isFile() false, size
928
+ // 0), which deterministically excluded every registry candidate (#66).
929
+ // Listing the real resources directory is asar-independent — identical
930
+ // on plain Node and inside Electron.
931
+ if (!readdirSync(join(installRoot, 'resources')).includes('app.asar'))
932
+ return undefined;
933
+ }
934
+ catch (error) {
935
+ return isENOENT(error) ? undefined : 'unresolved';
936
+ }
937
+ let identity;
938
+ try {
939
+ identity = realpathSync(electronPath);
940
+ }
941
+ catch (error) {
942
+ return isENOENT(error) ? undefined : 'unresolved';
943
+ }
944
+ return {
945
+ electronPath,
946
+ // Windows paths are case-insensitive even when a registry entry preserved
947
+ // a different casing from the filesystem spelling.
948
+ identity: platform === 'win32' ? identity.toLowerCase() : identity,
949
+ };
950
+ }
951
+ /** Whether a path exists and is executable; never throws. */
952
+ function isExecutable(path) {
953
+ try {
954
+ accessSync(path, constants.X_OK);
955
+ return true;
956
+ }
957
+ catch {
958
+ return false;
959
+ }
960
+ }
961
+ /**
962
+ * A failure the card must be able to classify. The code travels with the error
963
+ * so the store can promote it to `reasonCode` without re-deriving the cause
964
+ * from prose.
965
+ */
966
+ export class WorkBuddyElectronPathError extends Error {
967
+ reasonCode;
968
+ constructor(reasonCode, message) {
969
+ super(message);
970
+ this.name = 'WorkBuddyElectronPathError';
971
+ this.reasonCode = reasonCode;
972
+ }
973
+ }
974
+ /** Read the reason code off an arbitrary thrown value, when it carries one. */
975
+ export function reasonCodeOf(error) {
976
+ return error instanceof WorkBuddyElectronPathError ? error.reasonCode : undefined;
977
+ }
978
+ /** Whether a filesystem error reports an absent path (`existsSync` cannot tell). */
979
+ function isENOENT(error) {
980
+ return error?.code === 'ENOENT';
981
+ }
982
+ function discoveryIncomplete(productName, detail) {
983
+ return new WorkBuddyElectronPathError('electron-discovery-incomplete', `the ${productName} application search did not finish (${detail});`
984
+ + ' this is not proof that the app is missing');
985
+ }
986
+ /**
987
+ * The helper: run inside WorkBuddy's Electron as plain Node, where the
988
+ * private `workbuddyStorage` binding exists, and print only the payload. It
989
+ * writes nothing else, so whatever reaches stdout is the payload.
990
+ */
991
+ const HELPER_SCRIPT = 'process.stdout.write(String(process._linkedBinding("electron_browser_workbuddy_storage").loggerGet()))';
992
+ const HELPER_SCRIPT_ARGUMENT_FLAG = '-e';