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,548 @@
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 credential resolution. The primary source is the WorkBuddy
6
+ * desktop app's own auth file, read-only; a plugin-owned copy under
7
+ * `$DSH_HOME` holds token refreshes so the desktop file is never written.
8
+ * The effective credential is whichever of the two expires later, so a
9
+ * refresh by either side wins.
10
+ *
11
+ * @module dsh-workbuddy-connect/auth
12
+ */
13
+ import { readFile, rm } from 'node:fs/promises';
14
+ import { homedir, release } from 'node:os';
15
+ import { basename, dirname, join } from 'node:path';
16
+ import { withFileLock, writeFileAtomic } from './_compat.js';
17
+ import { resolveDshHome } from './_compat.js';
18
+ import { regionOf } from './upstream.js';
19
+ import { WorkBuddyAtRestKeyProvider, WorkBuddyElectronPathError, classifyDesktopAuthDocument, keyIdsOf, openAuthField, reasonCodeOf, unwrapDesktopAuthDocument, } from './desktop-credential-protection.js';
20
+ import { electronProfileFor } from './variants.js';
21
+ /** Basename of the plugin-owned credential copy inside the Harness home. */
22
+ export const WORKBUDDY_AUTH_FILENAME = '.workbuddy-auth.json';
23
+ /** Env variable that overrides the desktop auth-file location. */
24
+ export const WORKBUDDY_AUTH_FILE_ENV = 'WORKBUDDY_AUTH_FILE';
25
+ /** Current on-disk format of the plugin-owned copy; readers reject others. */
26
+ const OWN_FORMAT_VERSION = 1;
27
+ /** Plugin-owned copy path inside the Harness home. */
28
+ export function workbuddyOwnAuthPath() {
29
+ return join(resolveDshHome(), WORKBUDDY_AUTH_FILENAME);
30
+ }
31
+ const DESKTOP_AUTH_RELATIVE_PATH = ['CodeBuddyExtension', 'Data', 'Public', 'auth', 'workbuddy-desktop.info'];
32
+ /** Whether this Linux process is running inside Windows Subsystem for Linux. */
33
+ function isWsl() {
34
+ if (process.platform !== 'linux')
35
+ return false;
36
+ if (process.env['WSL_DISTRO_NAME'] !== undefined || process.env['WSL_INTEROP'] !== undefined)
37
+ return true;
38
+ return release().toLowerCase().includes('microsoft');
39
+ }
40
+ /** Convert a Windows drive path to WSL's conventional `/mnt/<drive>` form. */
41
+ function windowsPathForWsl(value) {
42
+ const path = value?.trim();
43
+ if (!path)
44
+ return undefined;
45
+ if (path.startsWith('/'))
46
+ return path;
47
+ const drivePath = /^([a-z]):[\\/](.*)$/iu.exec(path);
48
+ if (drivePath === null)
49
+ return undefined;
50
+ return join('/mnt', drivePath[1].toLowerCase(), ...drivePath[2].split(/[\\/]+/u));
51
+ }
52
+ /** Windows desktop credential candidates visible from a WSL process. */
53
+ function wslDesktopAuthCandidates(home) {
54
+ const profile = windowsPathForWsl(process.env['USERPROFILE'])
55
+ ?? join('/mnt/c/Users', basename(home));
56
+ const localAppData = windowsPathForWsl(process.env['LOCALAPPDATA'])
57
+ ?? join(profile, 'AppData', 'Local');
58
+ const roamingAppData = windowsPathForWsl(process.env['APPDATA'])
59
+ ?? join(profile, 'AppData', 'Roaming');
60
+ return [
61
+ join(localAppData, ...DESKTOP_AUTH_RELATIVE_PATH),
62
+ join(roamingAppData, ...DESKTOP_AUTH_RELATIVE_PATH),
63
+ ];
64
+ }
65
+ /**
66
+ * Platform-default candidates for the WorkBuddy desktop app's auth file, in
67
+ * probe order. Windows probes both AppData roots: current builds write under
68
+ * `%LOCALAPPDATA%` (Local), older ones under `%APPDATA%` (Roaming). Linux
69
+ * probes both XDG bases — most distributions write under the config home,
70
+ * but UOS/deepin builds write under the data home (issue #43), and probing
71
+ * only one silently reads a signed-in app as signed out. WSL probes those
72
+ * same Windows locations through its mounted Windows profile before the
73
+ * native Linux locations.
74
+ */
75
+ export function defaultDesktopAuthCandidates() {
76
+ const home = homedir();
77
+ if (process.platform === 'darwin') {
78
+ return [join(home, 'Library', 'Application Support', 'CodeBuddyExtension', 'Data', 'Public', 'auth', 'workbuddy-desktop.info')];
79
+ }
80
+ if (process.platform === 'win32') {
81
+ return [
82
+ join(home, 'AppData', 'Local', 'CodeBuddyExtension', 'Data', 'Public', 'auth', 'workbuddy-desktop.info'),
83
+ join(home, 'AppData', 'Roaming', 'CodeBuddyExtension', 'Data', 'Public', 'auth', 'workbuddy-desktop.info'),
84
+ ];
85
+ }
86
+ if (process.platform === 'linux') {
87
+ // An XDG override is adopted only as a non-empty absolute path; an
88
+ // invalid value falls back to the platform default rather than joining a
89
+ // relative path onto it.
90
+ const configHome = xdgBase('XDG_CONFIG_HOME', join(home, '.config'));
91
+ const dataHome = xdgBase('XDG_DATA_HOME', join(home, '.local', 'share'));
92
+ // Config home first, keeping the probe order existing installs hit.
93
+ const linux = dedupeCandidates([
94
+ join(configHome, ...DESKTOP_AUTH_RELATIVE_PATH),
95
+ join(dataHome, ...DESKTOP_AUTH_RELATIVE_PATH),
96
+ ]);
97
+ return isWsl() ? dedupeCandidates([...wslDesktopAuthCandidates(home), ...linux]) : linux;
98
+ }
99
+ return [];
100
+ }
101
+ /** The XDG base directory for one env variable, or its platform default. */
102
+ function xdgBase(envName, fallback) {
103
+ const value = process.env[envName]?.trim();
104
+ if (value !== undefined && value !== '' && value.startsWith('/'))
105
+ return value;
106
+ return fallback;
107
+ }
108
+ /** Drop duplicate candidates while keeping probe order. */
109
+ function dedupeCandidates(candidates) {
110
+ return [...new Set(candidates)];
111
+ }
112
+ /**
113
+ * The platform-default candidates for one variant, in probe order.
114
+ *
115
+ * Both apps write into the *same* shared `CodeBuddyExtension` auth directory
116
+ * and differ only in the file's basename, so the per-platform ordering above
117
+ * is reused verbatim and just the filename is swapped.
118
+ */
119
+ export function desktopAuthCandidatesFor(variant) {
120
+ return dedupeCandidates(defaultDesktopAuthCandidates().map(path => join(dirname(path), variant.desktopFilename)));
121
+ }
122
+ /** First platform-default candidate; see {@link defaultDesktopAuthCandidates}. */
123
+ export function defaultDesktopAuthPath(variant) {
124
+ const candidates = variant === undefined ? defaultDesktopAuthCandidates() : desktopAuthCandidatesFor(variant);
125
+ return candidates[0];
126
+ }
127
+ /** Normalize an expiry that may arrive in seconds or milliseconds. */
128
+ function expiryToMs(value) {
129
+ if (value <= 0)
130
+ return 0;
131
+ return value > 1e12 ? value : value * 1000;
132
+ }
133
+ function optionalString(value) {
134
+ return typeof value === 'string' && value !== '' ? value : undefined;
135
+ }
136
+ /**
137
+ * Parse a WorkBuddy auth document in either on-disk shape: the plugin OAuth
138
+ * nested form `{"auth":{...},"account":{...}}` and the flat panel form.
139
+ * Returns undefined when the document carries no access token.
140
+ */
141
+ export function parseWorkBuddyAuth(text) {
142
+ let parsed;
143
+ try {
144
+ parsed = JSON.parse(text);
145
+ }
146
+ catch {
147
+ return undefined;
148
+ }
149
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
150
+ return undefined;
151
+ const document = parsed;
152
+ let auth;
153
+ let identity;
154
+ if (typeof document['auth'] === 'object' && document['auth'] !== null) {
155
+ auth = document['auth'];
156
+ identity = typeof document['account'] === 'object' && document['account'] !== null
157
+ ? document['account']
158
+ : {};
159
+ }
160
+ else {
161
+ auth = document;
162
+ identity = document;
163
+ }
164
+ const accessToken = typeof auth['accessToken'] === 'string' ? auth['accessToken'] : '';
165
+ if (accessToken === '')
166
+ return undefined;
167
+ const expiresAtMs = typeof auth['expiresAt'] === 'number' ? expiryToMs(auth['expiresAt']) : 0;
168
+ const refreshExpiresAtMs = typeof auth['refreshExpiresAt'] === 'number' ? expiryToMs(auth['refreshExpiresAt']) : undefined;
169
+ const enterpriseId = optionalString(identity['enterpriseId']);
170
+ const nickname = optionalString(identity['nickname']);
171
+ const credential = {
172
+ accessToken,
173
+ refreshToken: typeof auth['refreshToken'] === 'string' ? auth['refreshToken'] : '',
174
+ expiresAtMs,
175
+ ...refreshExpiresAtMs === undefined ? {} : { refreshExpiresAtMs },
176
+ domain: optionalString(auth['domain']) ?? '',
177
+ uid: optionalString(identity['uid']) ?? '',
178
+ ...enterpriseId === undefined ? {} : { enterpriseId },
179
+ ...nickname === undefined ? {} : { nickname },
180
+ source: 'desktop',
181
+ };
182
+ return credential;
183
+ }
184
+ /** Serialize the plugin-owned copy. */
185
+ function ownDocument(credential) {
186
+ return { version: OWN_FORMAT_VERSION, credential };
187
+ }
188
+ /** Parse the plugin-owned copy; other versions and shapes are rejected. */
189
+ function parseOwnDocument(text) {
190
+ let parsed;
191
+ try {
192
+ parsed = JSON.parse(text);
193
+ }
194
+ catch {
195
+ return undefined;
196
+ }
197
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
198
+ return undefined;
199
+ const document = parsed;
200
+ if (document['version'] !== OWN_FORMAT_VERSION)
201
+ return undefined;
202
+ if (typeof document['credential'] !== 'object' || document['credential'] === null)
203
+ return undefined;
204
+ // The owned copy stores the normalized credential itself (camelCase
205
+ // `expiresAtMs`, identity fields at the top level), not the desktop
206
+ // document shape. Round-tripping through parseWorkBuddyAuth reads
207
+ // `expiresAt` and an `account` object, finds neither, zeroes the expiry,
208
+ // and drops uid/enterprise/nickname — so a surviving copy refreshed on
209
+ // every request and lost its identity headers.
210
+ const stored = document['credential'];
211
+ const accessToken = typeof stored['accessToken'] === 'string' ? stored['accessToken'] : '';
212
+ if (accessToken === '')
213
+ return undefined;
214
+ const refreshExpiresAtMs = typeof stored['refreshExpiresAtMs'] === 'number' ? stored['refreshExpiresAtMs'] : undefined;
215
+ const enterpriseId = optionalString(stored['enterpriseId']);
216
+ const nickname = optionalString(stored['nickname']);
217
+ return {
218
+ accessToken,
219
+ refreshToken: typeof stored['refreshToken'] === 'string' ? stored['refreshToken'] : '',
220
+ expiresAtMs: typeof stored['expiresAtMs'] === 'number' ? stored['expiresAtMs'] : 0,
221
+ ...refreshExpiresAtMs === undefined ? {} : { refreshExpiresAtMs },
222
+ domain: optionalString(stored['domain']) ?? '',
223
+ uid: optionalString(stored['uid']) ?? '',
224
+ ...enterpriseId === undefined ? {} : { enterpriseId },
225
+ ...nickname === undefined ? {} : { nickname },
226
+ source: 'dsh',
227
+ };
228
+ }
229
+ /** Whether a filesystem error reports an absent path. */
230
+ function isENOENT(error) {
231
+ return error?.code === 'ENOENT';
232
+ }
233
+ /**
234
+ * Read-only credential store with demand-driven refresh.
235
+ *
236
+ * Refresh policy: refresh only when the access token is inside the margin
237
+ * (or already expired), keep the refreshed credential in the plugin-owned
238
+ * copy, and never write the desktop app's file. A failed refresh still
239
+ * returns a not-yet-expired token so an unreachable refresh endpoint does
240
+ * not take down a working session.
241
+ */
242
+ export class WorkBuddyCredentialStore {
243
+ variant;
244
+ refresh;
245
+ refreshMarginMs;
246
+ ownPath;
247
+ keyProvider;
248
+ desktopPathOverride;
249
+ inflight;
250
+ constructor(options) {
251
+ this.variant = options.variant;
252
+ this.refresh = options.refresh;
253
+ this.refreshMarginMs = options.refreshMarginMs ?? 5 * 60 * 1000;
254
+ this.ownPath = options.ownPath ?? (options.variant ? join(resolveDshHome(), options.variant.ownFilename) : workbuddyOwnAuthPath());
255
+ // Legacy stores without a variant stay on the CN product the store's other
256
+ // defaults already assume (own-copy filename, auth env var); a variant
257
+ // without an electron profile falls back by id, keeping externally
258
+ // assembled descriptors source-compatible.
259
+ this.keyProvider = options.keyProvider
260
+ ?? new WorkBuddyAtRestKeyProvider({ product: electronProfileFor(options.variant) });
261
+ this.desktopPathOverride = options.desktopPath;
262
+ }
263
+ /**
264
+ * Configuration precedence for the desktop file: the plugin's configured
265
+ * path, then the environment variable, then the platform defaults. An
266
+ * explicit path is used verbatim; the defaults are a probe order.
267
+ */
268
+ resolveDesktopCandidates() {
269
+ const fromEnv = process.env[this.variant?.env ?? WORKBUDDY_AUTH_FILE_ENV];
270
+ const explicit = this.desktopPathOverride
271
+ ?? (fromEnv !== undefined && fromEnv.trim() !== '' ? fromEnv : undefined);
272
+ if (explicit !== undefined)
273
+ return [explicit];
274
+ return this.variant === undefined
275
+ ? defaultDesktopAuthCandidates()
276
+ : desktopAuthCandidatesFor(this.variant);
277
+ }
278
+ resolveDesktopPath() {
279
+ return this.resolveDesktopCandidates()[0];
280
+ }
281
+ /**
282
+ * Repoint the desktop file; a settings change applies on the next read.
283
+ */
284
+ setDesktopPath(path) {
285
+ this.desktopPathOverride = path;
286
+ }
287
+ /** The resolved desktop auth-file path, for diagnostics. */
288
+ desktopAuthPath() {
289
+ return this.resolveDesktopPath();
290
+ }
291
+ /** The plugin-owned copy path, for diagnostics. */
292
+ ownAuthPath() {
293
+ return this.ownPath;
294
+ }
295
+ /** Read the freshest stored credential without refreshing anything. */
296
+ async current() {
297
+ const [desktop, own] = await Promise.all([this.readDesktop(), this.readOwn()]);
298
+ // A credential belonging to the other product is refused rather than used:
299
+ // the two apps share one auth directory and differ only by filename, so a
300
+ // misconfigured `authFile` / env var is a realistic mistake, and sending one
301
+ // region's token to the other's endpoint would leak it across products.
302
+ // Naming the file and the expected region is what makes it fixable.
303
+ if (this.variant !== undefined) {
304
+ for (const [label, credential] of [['desktop file', desktop], ['plugin copy', own]]) {
305
+ if (credential === undefined)
306
+ continue;
307
+ const region = regionOf(credential.domain);
308
+ if (region !== this.variant.region) {
309
+ throw new WorkBuddyElectronPathError('credential-region-mismatch', `${this.variant.displayName} received a ${region === 'cn' ? 'WorkBuddy (CN)' : 'WorkBuddy AI'} credential`
310
+ + ` in its ${label} (domain ${JSON.stringify(credential.domain)});`
311
+ + ` point ${this.variant.env} at the ${this.variant.appName} sign-in, or remove the mismatched file`);
312
+ }
313
+ }
314
+ }
315
+ if (desktop === undefined)
316
+ return own;
317
+ if (own === undefined)
318
+ return desktop;
319
+ // Identity beats expiry. The plugin's own copy is written by its own
320
+ // refreshes, so after the user switches accounts in the desktop app the copy
321
+ // still belongs to the *previous* account — and may well expire later,
322
+ // because the plugin refreshed it. Preferring it by expiry would send the old
323
+ // account's uid in `X-User-Id` and answer as the wrong user. The desktop
324
+ // file is the authority on who is signed in now; a differing identity means
325
+ // the copy is stale regardless of its timestamp.
326
+ if (desktop.uid !== own.uid || desktop.enterpriseId !== own.enterpriseId)
327
+ return desktop;
328
+ return own.expiresAtMs > desktop.expiresAtMs ? own : desktop;
329
+ }
330
+ /**
331
+ * The credential to send upstream: {@link current}, refreshed on demand.
332
+ * Single-flight, so parallel requests share one refresh.
333
+ */
334
+ async resolve() {
335
+ const credential = await this.current();
336
+ if (credential === undefined) {
337
+ const candidates = this.resolveDesktopCandidates();
338
+ const desktop = candidates.length > 0 ? candidates.join(' or ') : '(no desktop path on this platform)';
339
+ const app = this.variant?.appName ?? 'WorkBuddy';
340
+ throw new Error(`workbuddy: no signed-in ${app} account found; sign in once in the ${app} desktop app`
341
+ + ` (expected ${desktop} or ${this.variant?.env ?? WORKBUDDY_AUTH_FILE_ENV}), or refresh an existing session`);
342
+ }
343
+ if (!this.needsRefresh(credential))
344
+ return credential;
345
+ this.inflight ??= this.refreshNow(credential)
346
+ .finally(() => {
347
+ this.inflight = undefined;
348
+ });
349
+ return this.inflight;
350
+ }
351
+ /** Read-only sign-in summary; never refreshes and never throws. */
352
+ async status() {
353
+ try {
354
+ const credential = await this.current();
355
+ if (credential === undefined)
356
+ return { state: 'signed-out', reasonCode: 'no-credential' };
357
+ return {
358
+ state: 'signed-in',
359
+ expiresAtMs: credential.expiresAtMs,
360
+ ...credential.refreshExpiresAtMs === undefined ? {} : { refreshExpiresAtMs: credential.refreshExpiresAtMs },
361
+ ...credential.nickname === undefined ? {} : { nickname: credential.nickname },
362
+ ...credential.domain === '' ? {} : { domain: credential.domain },
363
+ source: credential.source,
364
+ };
365
+ }
366
+ catch (error) {
367
+ // A region mismatch (or an unreadable file, or an unusable key helper) is
368
+ // a *diagnosable* signed-out state, not a silent one: the user needs the
369
+ // path to the file that is wrong, and which provider it actually belongs
370
+ // to. Reported as a status rather than thrown, because `status()` is
371
+ // documented never to throw and the card renders `reason` verbatim.
372
+ //
373
+ // The code travels beside the prose so the card can branch on the cause
374
+ // without ever matching the message text.
375
+ return {
376
+ state: 'signed-out',
377
+ reason: error instanceof Error ? error.message : String(error),
378
+ ...reasonCodeOf(error) === undefined
379
+ ? {}
380
+ : { reasonCode: reasonCodeOf(error) },
381
+ };
382
+ }
383
+ }
384
+ /** Remove the plugin-owned copy; the desktop file is untouched. */
385
+ async logout() {
386
+ await rm(this.ownPath, { force: true });
387
+ await rm(`${this.ownPath}.lock`, { force: true });
388
+ }
389
+ needsRefresh(credential) {
390
+ if (credential.expiresAtMs <= 0)
391
+ return true;
392
+ return Date.now() + this.refreshMarginMs >= credential.expiresAtMs;
393
+ }
394
+ async refreshNow(credential) {
395
+ if (credential.refreshToken === '') {
396
+ if (credential.expiresAtMs > Date.now() + 30_000)
397
+ return credential;
398
+ throw new Error('workbuddy: access token expired and no refresh token is stored; sign in again in the WorkBuddy desktop app');
399
+ }
400
+ try {
401
+ const outcome = await this.refresh(credential);
402
+ const refreshed = {
403
+ ...credential,
404
+ accessToken: outcome.accessToken,
405
+ ...outcome.refreshToken === undefined ? {} : { refreshToken: outcome.refreshToken },
406
+ expiresAtMs: outcome.expiresInSec !== undefined
407
+ ? Date.now() + outcome.expiresInSec * 1000
408
+ : credential.expiresAtMs,
409
+ ...outcome.domain === undefined || outcome.domain === '' ? {} : { domain: outcome.domain },
410
+ source: 'dsh',
411
+ };
412
+ await this.saveOwn(refreshed);
413
+ return refreshed;
414
+ }
415
+ catch (error) {
416
+ if (credential.expiresAtMs > Date.now() + 30_000)
417
+ return credential;
418
+ throw new Error(`workbuddy: token refresh failed and the access token is expired (${String(error)});`
419
+ + ' open the WorkBuddy desktop app once to sign in again');
420
+ }
421
+ }
422
+ async saveOwn(credential) {
423
+ await withFileLock(this.ownPath, async () => {
424
+ await writeFileAtomic(this.ownPath, `${JSON.stringify(ownDocument(credential), null, 2)}\n`, {
425
+ mode: 0o600,
426
+ dirMode: 0o700,
427
+ });
428
+ });
429
+ }
430
+ /**
431
+ * Read the first desktop candidate that exists. Only an absent file
432
+ * (ENOENT) falls through to the next candidate; a file that is present
433
+ * but unparsable is authoritative for its slot, so a stale older-version
434
+ * file never silently wins over a broken newer one.
435
+ *
436
+ * Since WorkBuddy 5.6 the token fields may arrive in at-rest envelopes, so
437
+ * the text is classified before the regular parser sees it. An encrypted
438
+ * document must be *opened*, never skipped; an unrecognized one must fail
439
+ * loudly. The desktop file, as long as it exists, is the identity
440
+ * authority — a document this plugin cannot read must surface as a
441
+ * diagnosis rather than be papered over by the plugin-owned copy, which
442
+ * belongs to whatever account was signed in when it was last refreshed.
443
+ * Only an absent (or empty) file lets the probe continue.
444
+ */
445
+ async readDesktop() {
446
+ for (const desktopPath of this.resolveDesktopCandidates()) {
447
+ let text;
448
+ try {
449
+ text = await readFile(desktopPath, 'utf8');
450
+ }
451
+ catch (error) {
452
+ if (!isENOENT(error))
453
+ throw error;
454
+ continue;
455
+ }
456
+ const classification = classifyDesktopAuthDocument(text);
457
+ if (classification.format === 'plaintext')
458
+ return parseWorkBuddyAuth(text);
459
+ if (classification.format === 'absent')
460
+ continue;
461
+ if (classification.format === 'unrecognized') {
462
+ throw new Error(`the desktop auth file at ${desktopPath} exists but is unreadable`
463
+ + ' (neither a plaintext credential nor a decodable WorkBuddy 5.6 envelope);'
464
+ + ' fix or remove the file — it outranks the plugin-owned credential copy');
465
+ }
466
+ return await this.openEncryptedDesktop(classification);
467
+ }
468
+ return undefined;
469
+ }
470
+ /** Open a 5.6 encrypted desktop document into the regular credential shape. */
471
+ async openEncryptedDesktop(classification) {
472
+ const wrapped = classification.wrapped;
473
+ const key = await this.keyProvider.protectorKeyFor(keyIdsOf(wrapped.fields));
474
+ const text = unwrapDesktopAuthDocument(classification, field => {
475
+ const plaintext = openAuthField(key, field.envelope);
476
+ if (plaintext === undefined) {
477
+ throw new WorkBuddyElectronPathError('encrypted-credential-unreadable', `the encrypted desktop credential's ${field.field} could not be decrypted`
478
+ + ` (envelope key id ${field.envelope.keyId});`
479
+ + ' the WorkBuddy app may hold a different at-rest key — open it once to reseal the sign-in');
480
+ }
481
+ return plaintext;
482
+ });
483
+ return parseWorkBuddyAuth(text);
484
+ }
485
+ /**
486
+ * Classify the first desktop candidate that exists and carries content;
487
+ * `absent` when none does. An empty first file is skipped so it cannot mask
488
+ * a real document on the next candidate. Diagnostics only — it never spawns
489
+ * the key helper and never decrypts, so doctor can describe the file
490
+ * without attempting the unlock.
491
+ */
492
+ async desktopAuthFormat() {
493
+ for (const desktopPath of this.resolveDesktopCandidates()) {
494
+ let text;
495
+ try {
496
+ text = await readFile(desktopPath, 'utf8');
497
+ }
498
+ catch (error) {
499
+ if (!isENOENT(error))
500
+ throw error;
501
+ continue;
502
+ }
503
+ const format = classifyDesktopAuthDocument(text).format;
504
+ if (format !== 'absent')
505
+ return format;
506
+ }
507
+ return 'absent';
508
+ }
509
+ async readOwn() {
510
+ try {
511
+ return parseOwnDocument(await readFile(this.ownPath, 'utf8'));
512
+ }
513
+ catch (error) {
514
+ if (isENOENT(error))
515
+ return undefined;
516
+ return undefined;
517
+ }
518
+ }
519
+ /**
520
+ * The first desktop candidate the probe would actually read from; `undefined`
521
+ * when none qualifies. Semantics deliberately match the probe: empty files
522
+ * are skipped (the probe classifies them as absent and moves on), so on an
523
+ * XDG layout where the config-home file is empty but the data-home file
524
+ * holds the credential, diagnostics name the *data-home* file — the one
525
+ * authentication really uses. Like the probe it never parses or decrypts.
526
+ */
527
+ async resolvedDesktopAuthPath() {
528
+ for (const desktopPath of this.resolveDesktopCandidates()) {
529
+ let text;
530
+ try {
531
+ text = await readFile(desktopPath, 'utf8');
532
+ }
533
+ catch (error) {
534
+ if (!isENOENT(error))
535
+ throw error;
536
+ continue;
537
+ }
538
+ if (text.trim() === '')
539
+ continue;
540
+ return desktopPath;
541
+ }
542
+ return undefined;
543
+ }
544
+ /** Whether any desktop-file candidate exists as a regular file; diagnostics only. */
545
+ async desktopFilePresent() {
546
+ return await this.resolvedDesktopAuthPath() !== undefined;
547
+ }
548
+ }