dsh-connect-workbuddy 4.2.0 → 4.3.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.
- package/CHANGELOG.md +38 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/docs/WINDOWS.md +38 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +472 -172
- package/lib/{host-heartbeat-C4TXu8ew.js → host-heartbeat-jNlsUMZO.js} +256 -14
- package/lib/index.d.ts +95 -10
- package/lib/index.js +8 -3
- package/package.json +1 -1
|
@@ -1706,6 +1706,107 @@ function parseVaultDocumentWithWriteTime(text, filePath) {
|
|
|
1706
1706
|
...refreshedFrom === void 0 ? {} : { refreshedFrom }
|
|
1707
1707
|
};
|
|
1708
1708
|
}
|
|
1709
|
+
/**
|
|
1710
|
+
* File name of the vault's DISMISSAL list, stored beside the vault entries.
|
|
1711
|
+
*
|
|
1712
|
+
* It exists because deleting a vault entry is not the same as deleting an
|
|
1713
|
+
* account. `syncVault` writes every desktop sign-in it scans back into the
|
|
1714
|
+
* vault — that is how a desktop account becomes selectable — so removing the
|
|
1715
|
+
* vault file alone was undone by the very next scan: the row came back and the
|
|
1716
|
+
* button read as broken (measured: `removeAccount` returns true, `accounts()`
|
|
1717
|
+
* still lists it).
|
|
1718
|
+
*
|
|
1719
|
+
* Dismissal means HIDDEN UNTIL THE USER SCANS AGAIN, not deleted forever. A
|
|
1720
|
+
* desktop sign-in is still there in the app, and the manual 「re-scan」 action is
|
|
1721
|
+
* the user asking to see what the machine currently has — so
|
|
1722
|
+
* {@link WorkBuddyCredentialStore.clearDismissals} empties this list on every
|
|
1723
|
+
* manual scan and the accounts return. Without that, a removed account could
|
|
1724
|
+
* never be recovered except by reinstalling or re-signing in.
|
|
1725
|
+
*
|
|
1726
|
+
* The list must survive the automatic refreshes in between: the card polls the
|
|
1727
|
+
* usage route every 60 seconds, and an in-memory-only dismissal would let the
|
|
1728
|
+
* account reappear a minute after the user removed it. Hence a file.
|
|
1729
|
+
*
|
|
1730
|
+
* Dismissal is per ACCOUNT, not per source: hiding only the vault copy would
|
|
1731
|
+
* leave the same account listed from the desktop file, which is the
|
|
1732
|
+
* half-measure that produced the report.
|
|
1733
|
+
*/
|
|
1734
|
+
const VAULT_DISMISSED_FILENAME = ".dismissed.json";
|
|
1735
|
+
/** Current on-disk format of the dismissal list; readers reject others. */
|
|
1736
|
+
const DISMISSED_FORMAT_VERSION = 1;
|
|
1737
|
+
/**
|
|
1738
|
+
* How long one scan may be shared across callers, in milliseconds.
|
|
1739
|
+
*
|
|
1740
|
+
* One page open asks the store for the same account list six times (usage
|
|
1741
|
+
* route: accounts, status, resolve; the credits panel: once PER ACCOUNT through
|
|
1742
|
+
* `credentialFor`; and both region tabs poll together). Each read re-listed the
|
|
1743
|
+
* auth directory, re-parsed every file and re-ran the vault merge — measured at
|
|
1744
|
+
* 6 `readdir` and (6N+12) `readFile` for N accounts.
|
|
1745
|
+
*
|
|
1746
|
+
* Why a WINDOW and not a plain in-flight share: the six reads are SEQUENTIAL
|
|
1747
|
+
* (`await` one after another), so by the time the second starts the first has
|
|
1748
|
+
* resolved and there is nothing in flight to join. Sharing only concurrent reads
|
|
1749
|
+
* optimised nothing at all.
|
|
1750
|
+
*
|
|
1751
|
+
* Why so short: this is not a cache of the account list, it is an admission that
|
|
1752
|
+
* one page open IS one logical read. Anything longer would delay noticing a
|
|
1753
|
+
* sign-in the desktop app made on its own — discovery IS polling, so this window
|
|
1754
|
+
* is how long such a change may stay unseen. The card polls every 60 s, so 1 s is
|
|
1755
|
+
* invisible to the user while collapsing one page open from six scans to one. A
|
|
1756
|
+
* write through this store invalidates the window outright, so the actions the
|
|
1757
|
+
* user takes here (OAuth add, import, remove, rescan) are never delayed by it.
|
|
1758
|
+
*/
|
|
1759
|
+
const SCAN_SHARE_WINDOW_MS = 1e3;
|
|
1760
|
+
/** Read the dismissal list; an absent or unreadable file reads as empty. */
|
|
1761
|
+
async function readDismissed(dir) {
|
|
1762
|
+
const dismissed = /* @__PURE__ */ new Set();
|
|
1763
|
+
try {
|
|
1764
|
+
const parsed = JSON.parse(await readFile(join(dir, VAULT_DISMISSED_FILENAME), "utf8"));
|
|
1765
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return dismissed;
|
|
1766
|
+
const document = parsed;
|
|
1767
|
+
if (document["version"] !== DISMISSED_FORMAT_VERSION) return dismissed;
|
|
1768
|
+
const ids = document["accountIds"];
|
|
1769
|
+
if (!Array.isArray(ids)) return dismissed;
|
|
1770
|
+
for (const id of ids) if (typeof id === "string" && VAULT_ACCOUNT_ID_RE.test(id)) dismissed.add(id);
|
|
1771
|
+
} catch {}
|
|
1772
|
+
return dismissed;
|
|
1773
|
+
}
|
|
1774
|
+
/** Write the dismissal list atomically under its own lock. */
|
|
1775
|
+
async function writeDismissed(dir, dismissed) {
|
|
1776
|
+
const path = join(dir, VAULT_DISMISSED_FILENAME);
|
|
1777
|
+
const ids = [...dismissed].slice(-1e3);
|
|
1778
|
+
const document = {
|
|
1779
|
+
version: DISMISSED_FORMAT_VERSION,
|
|
1780
|
+
accountIds: ids
|
|
1781
|
+
};
|
|
1782
|
+
await withFileLock(path, async () => {
|
|
1783
|
+
await writeFileAtomic(path, `${JSON.stringify(document, null, 2)}\n`, {
|
|
1784
|
+
mode: 384,
|
|
1785
|
+
dirMode: 448
|
|
1786
|
+
});
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* Whether a directory entry is ONE ACCOUNT's vault credential file.
|
|
1791
|
+
*
|
|
1792
|
+
* The vault directory holds more than credentials: `writeDismissed` puts
|
|
1793
|
+
* `.dismissed.json` here, and atomic writes transiently leave
|
|
1794
|
+
* `<id>.json.<hex>.tmp` siblings behind. Both used to be swept up by a bare
|
|
1795
|
+
* `endsWith('.json')` check, and the DISMISSAL file in particular is
|
|
1796
|
+
* well-formed JSON that is not a credential — so `diagnose` reported it at the
|
|
1797
|
+
* user as 「not a readable vault credential」 on any machine where an account
|
|
1798
|
+
* had been removed, turning a healthy sign-in into an unreadable-looking one.
|
|
1799
|
+
*
|
|
1800
|
+
* The rule is the account id shape the store itself writes
|
|
1801
|
+
* ({@link VAULT_ACCOUNT_ID_RE}), not a naming convention: a file qualifies only
|
|
1802
|
+
* when its stem is a real id, which no bookkeeping or temp file can be. A
|
|
1803
|
+
* `.tmp` sibling therefore fails on the dot of its extension, and a hidden file
|
|
1804
|
+
* fails on its leading dot even before that.
|
|
1805
|
+
*/
|
|
1806
|
+
function isVaultCredentialFileName(name) {
|
|
1807
|
+
if (!name.endsWith(".json")) return false;
|
|
1808
|
+
return VAULT_ACCOUNT_ID_RE.test(name.slice(0, -5));
|
|
1809
|
+
}
|
|
1709
1810
|
/** Current on-disk format of the vault; readers reject others. */
|
|
1710
1811
|
const VAULT_FORMAT_VERSION = 1;
|
|
1711
1812
|
/**
|
|
@@ -2027,7 +2128,7 @@ async function readVaultEntries(dir) {
|
|
|
2027
2128
|
throw error;
|
|
2028
2129
|
}
|
|
2029
2130
|
for (const name of names) {
|
|
2030
|
-
if (!name
|
|
2131
|
+
if (!isVaultCredentialFileName(name)) continue;
|
|
2031
2132
|
const path = join(dir, name);
|
|
2032
2133
|
try {
|
|
2033
2134
|
const parsed = parseVaultDocumentWithWriteTime(await readFile(path, "utf8"), path);
|
|
@@ -2140,6 +2241,28 @@ var WorkBuddyCredentialStore = class {
|
|
|
2140
2241
|
accountId;
|
|
2141
2242
|
inflight;
|
|
2142
2243
|
/**
|
|
2244
|
+
* In-flight/completed scan for the CURRENT `readAll` generation.
|
|
2245
|
+
*
|
|
2246
|
+
* One page open calls `readAll` six times: the usage route asks for the
|
|
2247
|
+
* account list, the sign-in state and the resolved credential, then the
|
|
2248
|
+
* per-account credits panel asks once PER ACCOUNT (`credentialFor`), and both
|
|
2249
|
+
* region tabs poll on the same timer. Each call re-read every auth file,
|
|
2250
|
+
* re-parsed it, and re-ran the vault merge — measured at 6 `readdir` + (6N+12)
|
|
2251
|
+
* `readFile` for N accounts, i.e. the cost grew with the account count on a
|
|
2252
|
+
* path whose answer cannot change within one tick.
|
|
2253
|
+
*
|
|
2254
|
+
* This is deliberately NOT a time-based cache: a TTL would make the card show
|
|
2255
|
+
* a stale account list after an OAuth add or an import, which is the very
|
|
2256
|
+
* class of bug this card keeps chasing. Instead the promise is shared while it
|
|
2257
|
+
* is in flight, and every WRITE clears it (see `invalidateScan`) — so a read
|
|
2258
|
+
* after a write always scans again, and a read during a scan joins it.
|
|
2259
|
+
*
|
|
2260
|
+
* Undefined means "no scan in flight and nothing cached".
|
|
2261
|
+
*/
|
|
2262
|
+
scanInflight;
|
|
2263
|
+
/** When the cached scan was STARTED, epoch ms; only meaningful with it set. */
|
|
2264
|
+
scanAtMs = 0;
|
|
2265
|
+
/**
|
|
2143
2266
|
* A RUNTIME account override from the pool's rotation, or undefined.
|
|
2144
2267
|
*
|
|
2145
2268
|
* Deliberately separate from {@link accountId}, which is the user's persisted
|
|
@@ -2202,6 +2325,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
2202
2325
|
setDesktopPath(path) {
|
|
2203
2326
|
this.desktopPathOverride = path;
|
|
2204
2327
|
this.inflight = void 0;
|
|
2328
|
+
this.invalidateScan();
|
|
2205
2329
|
}
|
|
2206
2330
|
/**
|
|
2207
2331
|
* Select an account by id; tokens stay outside settings.
|
|
@@ -2215,6 +2339,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
2215
2339
|
selectAccount(accountId) {
|
|
2216
2340
|
this.accountId = accountId === "" ? void 0 : accountId;
|
|
2217
2341
|
this.inflight = void 0;
|
|
2342
|
+
this.invalidateScan();
|
|
2218
2343
|
}
|
|
2219
2344
|
/** Selected account id, for diagnostics and route assembly. */
|
|
2220
2345
|
selectedAccountId() {
|
|
@@ -2290,13 +2415,42 @@ var WorkBuddyCredentialStore = class {
|
|
|
2290
2415
|
* region's accounts are invisible to selection, refresh, and status alike,
|
|
2291
2416
|
* which is what keeps the two regions' providers from cross-billing.
|
|
2292
2417
|
*/
|
|
2418
|
+
/**
|
|
2419
|
+
* The account list, sharing one scan across concurrent and repeated reads.
|
|
2420
|
+
*
|
|
2421
|
+
* See {@link scanInflight}: the whole performance fix lives in these four
|
|
2422
|
+
* lines. Every caller keeps its OWN semantics (`current`, `accounts`,
|
|
2423
|
+
* `status`, `selectionLost`), because this returns the same array they used
|
|
2424
|
+
* to build themselves — only the filesystem work is shared.
|
|
2425
|
+
*
|
|
2426
|
+
* A failed scan is NOT cached: the rejection clears the slot so the next read
|
|
2427
|
+
* retries, rather than pinning every later caller to one transient failure.
|
|
2428
|
+
*/
|
|
2293
2429
|
async readAll() {
|
|
2430
|
+
const now = Date.now();
|
|
2431
|
+
if (this.scanInflight !== void 0 && now - this.scanAtMs < SCAN_SHARE_WINDOW_MS) return this.scanInflight;
|
|
2432
|
+
this.scanAtMs = now;
|
|
2433
|
+
this.scanInflight = this.scanAll().catch((error) => {
|
|
2434
|
+
this.scanInflight = void 0;
|
|
2435
|
+
throw error;
|
|
2436
|
+
});
|
|
2437
|
+
return this.scanInflight;
|
|
2438
|
+
}
|
|
2439
|
+
/** Drop the shared scan so the next read re-reads the filesystem. */
|
|
2440
|
+
invalidateScan() {
|
|
2441
|
+
this.scanInflight = void 0;
|
|
2442
|
+
this.scanAtMs = 0;
|
|
2443
|
+
}
|
|
2444
|
+
/** The one real scan: read every candidate file, then merge the vault. */
|
|
2445
|
+
async scanAll() {
|
|
2294
2446
|
const files = await this.candidateFiles();
|
|
2447
|
+
const dismissed = await readDismissed(this.vaultDir());
|
|
2295
2448
|
const byId = /* @__PURE__ */ new Map();
|
|
2296
2449
|
for (const file of files) {
|
|
2297
2450
|
const credential = await readAuthFile(file, this.resolveAtRestKey);
|
|
2298
2451
|
if (credential === void 0 || !this.matchesRegion(credential.domain)) continue;
|
|
2299
2452
|
const id = workbuddyAccountId(credential);
|
|
2453
|
+
if (dismissed.has(id)) continue;
|
|
2300
2454
|
const existing = byId.get(id);
|
|
2301
2455
|
if (existing === void 0) {
|
|
2302
2456
|
byId.set(id, credential);
|
|
@@ -2307,12 +2461,13 @@ var WorkBuddyCredentialStore = class {
|
|
|
2307
2461
|
for (const own of await this.readOwns()) {
|
|
2308
2462
|
if (!this.matchesRegion(own.domain)) continue;
|
|
2309
2463
|
const id = workbuddyAccountId(own);
|
|
2464
|
+
if (dismissed.has(id)) continue;
|
|
2310
2465
|
const existing = byId.get(id);
|
|
2311
2466
|
if (existing === void 0) byId.set(id, own);
|
|
2312
2467
|
else if (fileRank(existing.filePath) !== 0 && own.expiresAtMs > existing.expiresAtMs) byId.set(id, own);
|
|
2313
2468
|
}
|
|
2314
|
-
await this.syncVault(byId);
|
|
2315
|
-
return [...byId.values()];
|
|
2469
|
+
await this.syncVault(byId, dismissed);
|
|
2470
|
+
return Object.freeze([...byId.values()]);
|
|
2316
2471
|
}
|
|
2317
2472
|
/**
|
|
2318
2473
|
* Merge the scan's credentials into the vault and read the vault back.
|
|
@@ -2323,7 +2478,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
2323
2478
|
* mtime-stable and every sync is O(accounts) reads with at most one write
|
|
2324
2479
|
* per CHANGED account.
|
|
2325
2480
|
*/
|
|
2326
|
-
async syncVault(scan) {
|
|
2481
|
+
async syncVault(scan, dismissed) {
|
|
2327
2482
|
const dir = this.vaultDir();
|
|
2328
2483
|
let entries;
|
|
2329
2484
|
try {
|
|
@@ -2333,6 +2488,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
2333
2488
|
}
|
|
2334
2489
|
let dirReady = false;
|
|
2335
2490
|
for (const [id, credential] of scan) {
|
|
2491
|
+
if (dismissed.has(id)) continue;
|
|
2336
2492
|
const incumbent = entries.get(id);
|
|
2337
2493
|
if (incumbent !== void 0 && !shouldReplaceVaultEntry(credential, incumbent)) continue;
|
|
2338
2494
|
if (!dirReady) try {
|
|
@@ -2353,6 +2509,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
2353
2509
|
for (const [id, entry] of entries) {
|
|
2354
2510
|
if (!this.matchesRegion(entry.credential.domain)) continue;
|
|
2355
2511
|
const existing = scan.get(id);
|
|
2512
|
+
if (dismissed.has(id)) continue;
|
|
2356
2513
|
if (existing === void 0) scan.set(id, entry.credential);
|
|
2357
2514
|
else if (entry.refreshedFrom !== void 0 && entry.refreshedFrom === authFileName(existing.filePath)) scan.set(id, entry.credential);
|
|
2358
2515
|
else if (fileRank(existing.filePath) !== 0 && entry.credential.expiresAtMs > existing.expiresAtMs) scan.set(id, entry.credential);
|
|
@@ -2526,20 +2683,92 @@ var WorkBuddyCredentialStore = class {
|
|
|
2526
2683
|
source: "dsh",
|
|
2527
2684
|
filePath: join(dir, `${id}.json`)
|
|
2528
2685
|
});
|
|
2686
|
+
await this.forgetDismissal(dir, id);
|
|
2529
2687
|
this.inflight = void 0;
|
|
2688
|
+
this.invalidateScan();
|
|
2530
2689
|
return id;
|
|
2531
2690
|
}
|
|
2532
2691
|
/**
|
|
2533
|
-
*
|
|
2692
|
+
* Drop one id from the dismissal list, if present.
|
|
2534
2693
|
*
|
|
2535
|
-
*
|
|
2536
|
-
*
|
|
2537
|
-
*
|
|
2538
|
-
*
|
|
2694
|
+
* Silent by design: called on the ADD paths, where a list that cannot be
|
|
2695
|
+
* updated must not fail the action the user asked for. The consequence is the
|
|
2696
|
+
* same as before the fix — the account stays hidden until the list is
|
|
2697
|
+
* repaired — while throwing here would report a failed sign-in that succeeded.
|
|
2698
|
+
*/
|
|
2699
|
+
async forgetDismissal(dir, accountId) {
|
|
2700
|
+
try {
|
|
2701
|
+
const dismissed = await readDismissed(dir);
|
|
2702
|
+
if (!dismissed.has(accountId)) return;
|
|
2703
|
+
dismissed.delete(accountId);
|
|
2704
|
+
await writeDismissed(dir, dismissed);
|
|
2705
|
+
} catch {}
|
|
2706
|
+
}
|
|
2707
|
+
/**
|
|
2708
|
+
* Forget EVERY dismissal, so the next read lists the accounts again.
|
|
2709
|
+
*
|
|
2710
|
+
* Called by the manual 「re-scan」 action. Dismissal is "hide until I ask to
|
|
2711
|
+
* scan again", and this is that ask: the user is requesting what the machine
|
|
2712
|
+
* currently has, which includes accounts they hid earlier. Leaving the list
|
|
2713
|
+
* intact would make a removed desktop account unrecoverable from the UI — the
|
|
2714
|
+
* opposite of what the button promises.
|
|
2715
|
+
*
|
|
2716
|
+
* PREFERS deleting the file over writing an empty list: an absent file is the
|
|
2717
|
+
* normal state and keeps the vault directory free of a file that says nothing.
|
|
2718
|
+
* The unlink is not the only way, though, because on Windows removing a file
|
|
2719
|
+
* another process holds open fails with `EPERM`/`EBUSY`/`EACCES` — an antivirus
|
|
2720
|
+
* scanner, OneDrive, or an editor with it open, the same contention the card's
|
|
2721
|
+
* `isFileContentionWriteError` classifies for profile writes and the reason
|
|
2722
|
+
* `docs/WINDOWS.md` keeps a register. POSIX unlinks open files happily, so
|
|
2723
|
+
* without this the promise 「点重新检测账号即可找回」 would be false, silently,
|
|
2724
|
+
* on exactly the platform it matters most on.
|
|
2725
|
+
*
|
|
2726
|
+
* So the unlink falls back to OVERWRITING the list with an empty one through
|
|
2727
|
+
* the same locked atomic write `writeDismissed` uses. For every reader the two
|
|
2728
|
+
* outcomes are identical (`readDismissed` yields no ids either way), so the
|
|
2729
|
+
* user's ask is honoured even when the file itself cannot be removed.
|
|
2730
|
+
*
|
|
2731
|
+
* Returns whether the dismissals are now genuinely gone, VERIFIED by reading
|
|
2732
|
+
* the list back rather than by trusting the write: the caller renders this, so
|
|
2733
|
+
* "it worked" has to mean it. A `false` is the honest answer for a vault that
|
|
2734
|
+
* could neither be unlinked nor rewritten, and the rescan still runs — the
|
|
2735
|
+
* accounts simply stay hidden, which the UI can now say out loud instead of
|
|
2736
|
+
* showing a button that appears to have done nothing.
|
|
2737
|
+
*/
|
|
2738
|
+
async clearDismissals() {
|
|
2739
|
+
this.inflight = void 0;
|
|
2740
|
+
this.invalidateScan();
|
|
2741
|
+
const dir = this.vaultDir();
|
|
2742
|
+
const path = join(dir, VAULT_DISMISSED_FILENAME);
|
|
2743
|
+
try {
|
|
2744
|
+
await rm(path, { force: true });
|
|
2745
|
+
} catch {
|
|
2746
|
+
try {
|
|
2747
|
+
await writeDismissed(dir, /* @__PURE__ */ new Set());
|
|
2748
|
+
} catch {
|
|
2749
|
+
return (await readDismissed(dir)).size === 0;
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
return (await readDismissed(dir)).size === 0;
|
|
2753
|
+
}
|
|
2754
|
+
/**
|
|
2755
|
+
* Remove ONE account from this region's list, by id.
|
|
2539
2756
|
*
|
|
2540
|
-
*
|
|
2541
|
-
*
|
|
2542
|
-
*
|
|
2757
|
+
* The account is DISMISSED, not merely deleted. Deleting the vault entry alone
|
|
2758
|
+
* was undone by the next scan: `syncVault` writes every desktop sign-in it
|
|
2759
|
+
* finds back into the vault (that is how a desktop account becomes selectable)
|
|
2760
|
+
* and `readAll` merges the vault in afterwards, so a removed desktop account
|
|
2761
|
+
* returned on the very next read while `removeAccount` had reported `true`.
|
|
2762
|
+
* Measured before the fix: remove → `true`, then `accounts()` still lists it.
|
|
2763
|
+
*
|
|
2764
|
+
* The desktop app's auth files stay read-only: dismissing hides the account
|
|
2765
|
+
* from this plugin without signing the app out, and that is exactly what the
|
|
2766
|
+
* card says. Re-adding it (a fresh OAuth sign-in or an import) clears the
|
|
2767
|
+
* dismissal, so the account is not lost to a future login.
|
|
2768
|
+
*
|
|
2769
|
+
* Returns whether a stored vault entry was also removed, so the caller can
|
|
2770
|
+
* still tell "removed" from "only a desktop sign-in" — the dismissal happens
|
|
2771
|
+
* either way, because only the user gets to decide an account is dismissed.
|
|
2543
2772
|
*/
|
|
2544
2773
|
async removeAccount(accountId) {
|
|
2545
2774
|
if (!VAULT_ACCOUNT_ID_RE.test(accountId)) throw new Error(`workbuddy: refusing to remove '${accountId}': not a vault account id`);
|
|
@@ -2557,6 +2786,17 @@ var WorkBuddyCredentialStore = class {
|
|
|
2557
2786
|
});
|
|
2558
2787
|
if (this.rotatedAccountId === accountId) this.rotatedAccountId = void 0;
|
|
2559
2788
|
if (this.accountId === accountId) this.accountId = void 0;
|
|
2789
|
+
const dir = this.vaultDir();
|
|
2790
|
+
try {
|
|
2791
|
+
await mkdir(dir, { recursive: true });
|
|
2792
|
+
const dismissed = await readDismissed(dir);
|
|
2793
|
+
if (!dismissed.has(accountId)) {
|
|
2794
|
+
dismissed.add(accountId);
|
|
2795
|
+
await writeDismissed(dir, dismissed);
|
|
2796
|
+
}
|
|
2797
|
+
} catch {}
|
|
2798
|
+
this.inflight = void 0;
|
|
2799
|
+
this.invalidateScan();
|
|
2560
2800
|
return removed;
|
|
2561
2801
|
}
|
|
2562
2802
|
/**
|
|
@@ -2575,6 +2815,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
2575
2815
|
recursive: true,
|
|
2576
2816
|
force: true
|
|
2577
2817
|
});
|
|
2818
|
+
this.invalidateScan();
|
|
2578
2819
|
}
|
|
2579
2820
|
needsRefresh(credential) {
|
|
2580
2821
|
if (credential.expiresAtMs <= 0) return true;
|
|
@@ -2595,6 +2836,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
2595
2836
|
...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain },
|
|
2596
2837
|
source: "dsh"
|
|
2597
2838
|
};
|
|
2839
|
+
this.invalidateScan();
|
|
2598
2840
|
await writeVaultEntry(this.vaultDir(), workbuddyAccountId(credential), refreshed, credential.filePath).catch(() => {});
|
|
2599
2841
|
return refreshed;
|
|
2600
2842
|
} catch (error) {
|
|
@@ -2679,7 +2921,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
2679
2921
|
const tried = [...await this.candidateFiles(), ...this.ownCandidates()];
|
|
2680
2922
|
try {
|
|
2681
2923
|
const names = await readdir(this.vaultDir());
|
|
2682
|
-
for (const name of names.filter(
|
|
2924
|
+
for (const name of names.filter(isVaultCredentialFileName)) {
|
|
2683
2925
|
const path = join(this.vaultDir(), name);
|
|
2684
2926
|
tried.push(path);
|
|
2685
2927
|
let text;
|
|
@@ -3197,7 +3439,7 @@ function generationOf(version) {
|
|
|
3197
3439
|
//#endregion
|
|
3198
3440
|
//#region src/version.ts
|
|
3199
3441
|
/** The npm package version this build was produced from. */
|
|
3200
|
-
const WORKBUDDY_CONNECT_VERSION = "4.
|
|
3442
|
+
const WORKBUDDY_CONNECT_VERSION = "4.3.0";
|
|
3201
3443
|
//#endregion
|
|
3202
3444
|
//#region src/host-heartbeat.ts
|
|
3203
3445
|
/**
|
package/lib/index.d.ts
CHANGED
|
@@ -247,6 +247,28 @@ declare class WorkBuddyCredentialStore {
|
|
|
247
247
|
private desktopPathOverride;
|
|
248
248
|
private accountId;
|
|
249
249
|
private inflight;
|
|
250
|
+
/**
|
|
251
|
+
* In-flight/completed scan for the CURRENT `readAll` generation.
|
|
252
|
+
*
|
|
253
|
+
* One page open calls `readAll` six times: the usage route asks for the
|
|
254
|
+
* account list, the sign-in state and the resolved credential, then the
|
|
255
|
+
* per-account credits panel asks once PER ACCOUNT (`credentialFor`), and both
|
|
256
|
+
* region tabs poll on the same timer. Each call re-read every auth file,
|
|
257
|
+
* re-parsed it, and re-ran the vault merge — measured at 6 `readdir` + (6N+12)
|
|
258
|
+
* `readFile` for N accounts, i.e. the cost grew with the account count on a
|
|
259
|
+
* path whose answer cannot change within one tick.
|
|
260
|
+
*
|
|
261
|
+
* This is deliberately NOT a time-based cache: a TTL would make the card show
|
|
262
|
+
* a stale account list after an OAuth add or an import, which is the very
|
|
263
|
+
* class of bug this card keeps chasing. Instead the promise is shared while it
|
|
264
|
+
* is in flight, and every WRITE clears it (see `invalidateScan`) — so a read
|
|
265
|
+
* after a write always scans again, and a read during a scan joins it.
|
|
266
|
+
*
|
|
267
|
+
* Undefined means "no scan in flight and nothing cached".
|
|
268
|
+
*/
|
|
269
|
+
private scanInflight;
|
|
270
|
+
/** When the cached scan was STARTED, epoch ms; only meaningful with it set. */
|
|
271
|
+
private scanAtMs;
|
|
250
272
|
/**
|
|
251
273
|
* A RUNTIME account override from the pool's rotation, or undefined.
|
|
252
274
|
*
|
|
@@ -331,7 +353,22 @@ declare class WorkBuddyCredentialStore {
|
|
|
331
353
|
* region's accounts are invisible to selection, refresh, and status alike,
|
|
332
354
|
* which is what keeps the two regions' providers from cross-billing.
|
|
333
355
|
*/
|
|
356
|
+
/**
|
|
357
|
+
* The account list, sharing one scan across concurrent and repeated reads.
|
|
358
|
+
*
|
|
359
|
+
* See {@link scanInflight}: the whole performance fix lives in these four
|
|
360
|
+
* lines. Every caller keeps its OWN semantics (`current`, `accounts`,
|
|
361
|
+
* `status`, `selectionLost`), because this returns the same array they used
|
|
362
|
+
* to build themselves — only the filesystem work is shared.
|
|
363
|
+
*
|
|
364
|
+
* A failed scan is NOT cached: the rejection clears the slot so the next read
|
|
365
|
+
* retries, rather than pinning every later caller to one transient failure.
|
|
366
|
+
*/
|
|
334
367
|
private readAll;
|
|
368
|
+
/** Drop the shared scan so the next read re-reads the filesystem. */
|
|
369
|
+
private invalidateScan;
|
|
370
|
+
/** The one real scan: read every candidate file, then merge the vault. */
|
|
371
|
+
private scanAll;
|
|
335
372
|
/**
|
|
336
373
|
* Merge the scan's credentials into the vault and read the vault back.
|
|
337
374
|
*
|
|
@@ -430,16 +467,64 @@ declare class WorkBuddyCredentialStore {
|
|
|
430
467
|
*/
|
|
431
468
|
addOAuthAccount(credential: Omit<WorkBuddyCredential, 'source' | 'filePath'>): Promise<string>;
|
|
432
469
|
/**
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
*
|
|
470
|
+
* Drop one id from the dismissal list, if present.
|
|
471
|
+
*
|
|
472
|
+
* Silent by design: called on the ADD paths, where a list that cannot be
|
|
473
|
+
* updated must not fail the action the user asked for. The consequence is the
|
|
474
|
+
* same as before the fix — the account stays hidden until the list is
|
|
475
|
+
* repaired — while throwing here would report a failed sign-in that succeeded.
|
|
476
|
+
*/
|
|
477
|
+
private forgetDismissal;
|
|
478
|
+
/**
|
|
479
|
+
* Forget EVERY dismissal, so the next read lists the accounts again.
|
|
480
|
+
*
|
|
481
|
+
* Called by the manual 「re-scan」 action. Dismissal is "hide until I ask to
|
|
482
|
+
* scan again", and this is that ask: the user is requesting what the machine
|
|
483
|
+
* currently has, which includes accounts they hid earlier. Leaving the list
|
|
484
|
+
* intact would make a removed desktop account unrecoverable from the UI — the
|
|
485
|
+
* opposite of what the button promises.
|
|
486
|
+
*
|
|
487
|
+
* PREFERS deleting the file over writing an empty list: an absent file is the
|
|
488
|
+
* normal state and keeps the vault directory free of a file that says nothing.
|
|
489
|
+
* The unlink is not the only way, though, because on Windows removing a file
|
|
490
|
+
* another process holds open fails with `EPERM`/`EBUSY`/`EACCES` — an antivirus
|
|
491
|
+
* scanner, OneDrive, or an editor with it open, the same contention the card's
|
|
492
|
+
* `isFileContentionWriteError` classifies for profile writes and the reason
|
|
493
|
+
* `docs/WINDOWS.md` keeps a register. POSIX unlinks open files happily, so
|
|
494
|
+
* without this the promise 「点重新检测账号即可找回」 would be false, silently,
|
|
495
|
+
* on exactly the platform it matters most on.
|
|
496
|
+
*
|
|
497
|
+
* So the unlink falls back to OVERWRITING the list with an empty one through
|
|
498
|
+
* the same locked atomic write `writeDismissed` uses. For every reader the two
|
|
499
|
+
* outcomes are identical (`readDismissed` yields no ids either way), so the
|
|
500
|
+
* user's ask is honoured even when the file itself cannot be removed.
|
|
501
|
+
*
|
|
502
|
+
* Returns whether the dismissals are now genuinely gone, VERIFIED by reading
|
|
503
|
+
* the list back rather than by trusting the write: the caller renders this, so
|
|
504
|
+
* "it worked" has to mean it. A `false` is the honest answer for a vault that
|
|
505
|
+
* could neither be unlinked nor rewritten, and the rescan still runs — the
|
|
506
|
+
* accounts simply stay hidden, which the UI can now say out loud instead of
|
|
507
|
+
* showing a button that appears to have done nothing.
|
|
508
|
+
*/
|
|
509
|
+
clearDismissals(): Promise<boolean>;
|
|
510
|
+
/**
|
|
511
|
+
* Remove ONE account from this region's list, by id.
|
|
512
|
+
*
|
|
513
|
+
* The account is DISMISSED, not merely deleted. Deleting the vault entry alone
|
|
514
|
+
* was undone by the next scan: `syncVault` writes every desktop sign-in it
|
|
515
|
+
* finds back into the vault (that is how a desktop account becomes selectable)
|
|
516
|
+
* and `readAll` merges the vault in afterwards, so a removed desktop account
|
|
517
|
+
* returned on the very next read while `removeAccount` had reported `true`.
|
|
518
|
+
* Measured before the fix: remove → `true`, then `accounts()` still lists it.
|
|
519
|
+
*
|
|
520
|
+
* The desktop app's auth files stay read-only: dismissing hides the account
|
|
521
|
+
* from this plugin without signing the app out, and that is exactly what the
|
|
522
|
+
* card says. Re-adding it (a fresh OAuth sign-in or an import) clears the
|
|
523
|
+
* dismissal, so the account is not lost to a future login.
|
|
524
|
+
*
|
|
525
|
+
* Returns whether a stored vault entry was also removed, so the caller can
|
|
526
|
+
* still tell "removed" from "only a desktop sign-in" — the dismissal happens
|
|
527
|
+
* either way, because only the user gets to decide an account is dismissed.
|
|
443
528
|
*/
|
|
444
529
|
removeAccount(accountId: string): Promise<boolean>;
|
|
445
530
|
/**
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-
|
|
1
|
+
import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-jNlsUMZO.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -3952,9 +3952,14 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
3952
3952
|
const region = requestRegion(req, res);
|
|
3953
3953
|
if (region === void 0) return;
|
|
3954
3954
|
try {
|
|
3955
|
-
const
|
|
3955
|
+
const store = deps.store(region);
|
|
3956
|
+
const dismissalsCleared = await store.clearDismissals();
|
|
3957
|
+
const accounts = await store.accounts();
|
|
3956
3958
|
deps.regionUsable?.(region, accounts.length > 0);
|
|
3957
|
-
json(res, 200, {
|
|
3959
|
+
json(res, 200, {
|
|
3960
|
+
accounts: accounts.map(toWebAccount),
|
|
3961
|
+
dismissalsCleared
|
|
3962
|
+
});
|
|
3958
3963
|
} catch (error) {
|
|
3959
3964
|
json(res, 500, { error: safeMessage(error) });
|
|
3960
3965
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-connect-workbuddy",
|
|
3
3
|
"displayName": "DSH Connect WorkBuddy",
|
|
4
4
|
"description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.3.0",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|