dsh-workbuddy-xdpool 1.7.0 → 1.7.1
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 +145 -0
- package/lib/bin.js +392 -31
- package/lib/client.js +167 -9
- package/lib/index.d.ts +200 -3
- package/lib/index.js +433 -57
- package/package.json +1 -1
package/lib/bin.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createDecipheriv, createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { copyFile, mkdir, readFile, readdir, rename, writeFile, writeFile as writeFile$1 } from "node:fs/promises";
|
|
3
3
|
import { homedir, platform } from "node:os";
|
|
4
|
-
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { execFile, execFileSync } from "node:child_process";
|
|
6
6
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
7
7
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -427,6 +427,7 @@ function parseUpstreamModel(value) {
|
|
|
427
427
|
const reasoning = parseReasoning(raw["reasoning"]);
|
|
428
428
|
const supportsToolCall = typeof raw["supportsToolCall"] === "boolean" ? raw["supportsToolCall"] : void 0;
|
|
429
429
|
const supportsImages = typeof raw["supportsImages"] === "boolean" ? raw["supportsImages"] : void 0;
|
|
430
|
+
const tags = Array.isArray(raw["tags"]) ? raw["tags"].filter((tag) => typeof tag === "string" && tag !== "") : void 0;
|
|
430
431
|
return {
|
|
431
432
|
id,
|
|
432
433
|
name,
|
|
@@ -437,7 +438,8 @@ function parseUpstreamModel(value) {
|
|
|
437
438
|
...descriptionZh === void 0 ? {} : { descriptionZh },
|
|
438
439
|
...descriptionEn === void 0 ? {} : { descriptionEn },
|
|
439
440
|
...supportsToolCall === void 0 ? {} : { supportsToolCall },
|
|
440
|
-
...supportsImages === void 0 ? {} : { supportsImages }
|
|
441
|
+
...supportsImages === void 0 ? {} : { supportsImages },
|
|
442
|
+
...tags === void 0 || tags.length === 0 ? {} : { tags }
|
|
441
443
|
};
|
|
442
444
|
}
|
|
443
445
|
/**
|
|
@@ -2321,6 +2323,41 @@ async function readCredential(path) {
|
|
|
2321
2323
|
}
|
|
2322
2324
|
}
|
|
2323
2325
|
/**
|
|
2326
|
+
* The pool id a document would receive, WITHOUT decrypting anything.
|
|
2327
|
+
*
|
|
2328
|
+
* Used to honour the ignore list before the at-rest key lookup runs: `uin` and
|
|
2329
|
+
* `uid` are read as plain strings by {@link parseWorkBuddyAuth}, while
|
|
2330
|
+
* `nickname` is commonly encrypted — so this returns undefined for an account
|
|
2331
|
+
* whose identity lives only in the encrypted nickname, and the caller falls
|
|
2332
|
+
* back to the full parse for those.
|
|
2333
|
+
*/
|
|
2334
|
+
function cheapIdentityId(text) {
|
|
2335
|
+
let parsed;
|
|
2336
|
+
try {
|
|
2337
|
+
parsed = JSON.parse(text);
|
|
2338
|
+
} catch {
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
2342
|
+
const document = parsed;
|
|
2343
|
+
const identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : document;
|
|
2344
|
+
const uin = typeof identity["uin"] === "string" && identity["uin"] !== "" ? identity["uin"] : void 0;
|
|
2345
|
+
const uid = typeof identity["uid"] === "string" && identity["uid"] !== "" ? identity["uid"] : void 0;
|
|
2346
|
+
if (uin === void 0 && uid === void 0) return void 0;
|
|
2347
|
+
return workbuddyAccountId({
|
|
2348
|
+
...uin === void 0 ? {} : { uin },
|
|
2349
|
+
...uid === void 0 ? {} : { uid }
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2352
|
+
/** {@link cheapIdentityId} for a file path; undefined when it cannot be read. */
|
|
2353
|
+
async function cheapIdentityIdFromFile(path) {
|
|
2354
|
+
try {
|
|
2355
|
+
return cheapIdentityId(await readFile(path, "utf8"));
|
|
2356
|
+
} catch {
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
/**
|
|
2324
2361
|
* Build the field opener, or undefined when the app cannot supply its key.
|
|
2325
2362
|
*
|
|
2326
2363
|
* Split out so the key lookup is testable without a real desktop install, and so
|
|
@@ -2399,6 +2436,21 @@ var WorkBuddyAccountPool = class {
|
|
|
2399
2436
|
*/
|
|
2400
2437
|
disabledIds = /* @__PURE__ */ new Set();
|
|
2401
2438
|
/**
|
|
2439
|
+
* Account ids the user threw out of the pool for good.
|
|
2440
|
+
*
|
|
2441
|
+
* Enforced BEFORE the credential is parsed: `scan()` skips a file whose
|
|
2442
|
+
* identity is already ignored, so an ignored account costs no at-rest key
|
|
2443
|
+
* lookup (which spawns the desktop app on 5.6.0+) and cannot re-enter the pool
|
|
2444
|
+
* when the app writes a fresh sign-in for it. That is the difference from
|
|
2445
|
+
* {@link disabledIds}, which only filters at pick time and leaves the account
|
|
2446
|
+
* listed, readable and re-discoverable.
|
|
2447
|
+
*
|
|
2448
|
+
* The set is supplied by the host from the plugin's own ignore file, and is
|
|
2449
|
+
* replaced wholesale on every {@link applyIgnored} so removing an entry takes
|
|
2450
|
+
* effect on the next scan without a restart.
|
|
2451
|
+
*/
|
|
2452
|
+
ignoredIds = /* @__PURE__ */ new Set();
|
|
2453
|
+
/**
|
|
2402
2454
|
* Per-account credit floor, keyed by account id. 0 (or absent) means "spend
|
|
2403
2455
|
* it all".
|
|
2404
2456
|
*
|
|
@@ -2450,12 +2502,39 @@ var WorkBuddyAccountPool = class {
|
|
|
2450
2502
|
if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
|
|
2451
2503
|
if (options.creditReserves !== void 0) this.setCreditReserves(options.creditReserves);
|
|
2452
2504
|
}
|
|
2505
|
+
/**
|
|
2506
|
+
* Replace the permanent ignore list.
|
|
2507
|
+
*
|
|
2508
|
+
* Also drops any already-discovered account that is now ignored, so the change
|
|
2509
|
+
* is visible without waiting for the next scan: the card refreshes its status
|
|
2510
|
+
* document right after the write, and an account still sitting in `accounts`
|
|
2511
|
+
* would keep showing up there.
|
|
2512
|
+
*/
|
|
2513
|
+
applyIgnored(ids) {
|
|
2514
|
+
this.ignoredIds = new Set(ids);
|
|
2515
|
+
if (this.ignoredIds.size === 0) return;
|
|
2516
|
+
this.accounts = this.accounts.filter((account) => !this.ignoredIds.has(account.id));
|
|
2517
|
+
}
|
|
2518
|
+
/** Whether this account has been thrown out of the pool for good. */
|
|
2519
|
+
isIgnored(accountId) {
|
|
2520
|
+
return this.ignoredIds.has(accountId);
|
|
2521
|
+
}
|
|
2522
|
+
/** Every ignored id currently in force, in insertion order. */
|
|
2523
|
+
ignoredIdsInOrder() {
|
|
2524
|
+
return [...this.ignoredIds];
|
|
2525
|
+
}
|
|
2453
2526
|
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
2454
2527
|
async scan() {
|
|
2455
2528
|
const found = [];
|
|
2456
2529
|
for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
|
|
2530
|
+
if (this.ignoredIds.size > 0) {
|
|
2531
|
+
const cheapId = await cheapIdentityIdFromFile(file);
|
|
2532
|
+
if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
|
|
2533
|
+
}
|
|
2457
2534
|
const credential = await readCredential(file);
|
|
2458
|
-
if (credential
|
|
2535
|
+
if (credential === void 0) continue;
|
|
2536
|
+
if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
|
|
2537
|
+
found.push(credential);
|
|
2459
2538
|
}
|
|
2460
2539
|
const byId = /* @__PURE__ */ new Map();
|
|
2461
2540
|
for (const account of this.accounts) byId.set(account.id, account);
|
|
@@ -2796,28 +2875,44 @@ ${new Date(until).toISOString()}
|
|
|
2796
2875
|
};
|
|
2797
2876
|
//#endregion
|
|
2798
2877
|
//#region src/catalog.ts
|
|
2799
|
-
/**
|
|
2878
|
+
/**
|
|
2879
|
+
* Static fallback used before the first live catalog fetch, and whenever the
|
|
2880
|
+
* upstream cannot be reached.
|
|
2881
|
+
*
|
|
2882
|
+
* The multipliers are carried on purpose. Without them the provider's model
|
|
2883
|
+
* picker silently loses every rate and every free badge the moment the live
|
|
2884
|
+
* fetch fails — which reads to the user as "the plugin broke my model list"
|
|
2885
|
+
* rather than "the upstream is unreachable". The values are the ones the two
|
|
2886
|
+
* gateways actually advertise for these ids (`credits: "x0.79 credits"` and so
|
|
2887
|
+
* on), so a fallback row looks the same as a live one.
|
|
2888
|
+
*
|
|
2889
|
+
* `multiplier: 0` is the gateways' own spelling of "free" (`credits: "x0.00"`),
|
|
2890
|
+
* which is what turns on the free badge.
|
|
2891
|
+
*/
|
|
2800
2892
|
const FALLBACK_WORKBUDDY_MODELS = [
|
|
2801
2893
|
{
|
|
2802
2894
|
id: "glm-5.3",
|
|
2803
2895
|
name: "GLM-5.3",
|
|
2804
2896
|
contextWindow: 2e5,
|
|
2805
2897
|
maxOutputTokens: 128e3,
|
|
2806
|
-
supportsImages: true
|
|
2898
|
+
supportsImages: true,
|
|
2899
|
+
multiplier: .79
|
|
2807
2900
|
},
|
|
2808
2901
|
{
|
|
2809
2902
|
id: "glm-5.3-flash",
|
|
2810
2903
|
name: "GLM-5.3-Flash",
|
|
2811
2904
|
contextWindow: 2e5,
|
|
2812
2905
|
maxOutputTokens: 128e3,
|
|
2813
|
-
supportsImages: true
|
|
2906
|
+
supportsImages: true,
|
|
2907
|
+
multiplier: .06
|
|
2814
2908
|
},
|
|
2815
2909
|
{
|
|
2816
2910
|
id: "glm-5.2",
|
|
2817
2911
|
name: "GLM-5.2",
|
|
2818
2912
|
contextWindow: 2e5,
|
|
2819
2913
|
maxOutputTokens: 128e3,
|
|
2820
|
-
supportsImages: true
|
|
2914
|
+
supportsImages: true,
|
|
2915
|
+
multiplier: .79
|
|
2821
2916
|
},
|
|
2822
2917
|
{
|
|
2823
2918
|
id: "glm-5.1",
|
|
@@ -2845,7 +2940,8 @@ const FALLBACK_WORKBUDDY_MODELS = [
|
|
|
2845
2940
|
name: "Kimi-K3",
|
|
2846
2941
|
contextWindow: 2e5,
|
|
2847
2942
|
maxOutputTokens: 128e3,
|
|
2848
|
-
supportsImages: true
|
|
2943
|
+
supportsImages: true,
|
|
2944
|
+
multiplier: 1.62
|
|
2849
2945
|
},
|
|
2850
2946
|
{
|
|
2851
2947
|
id: "minimax-m3",
|
|
@@ -2859,14 +2955,16 @@ const FALLBACK_WORKBUDDY_MODELS = [
|
|
|
2859
2955
|
name: "Hy3",
|
|
2860
2956
|
contextWindow: 32e3,
|
|
2861
2957
|
maxOutputTokens: 8e3,
|
|
2862
|
-
supportsImages: true
|
|
2958
|
+
supportsImages: true,
|
|
2959
|
+
multiplier: 0
|
|
2863
2960
|
},
|
|
2864
2961
|
{
|
|
2865
2962
|
id: "hy4-preview",
|
|
2866
2963
|
name: "Hy4-Preview",
|
|
2867
2964
|
contextWindow: 1e6,
|
|
2868
2965
|
maxOutputTokens: 128e3,
|
|
2869
|
-
supportsImages: true
|
|
2966
|
+
supportsImages: true,
|
|
2967
|
+
multiplier: .29
|
|
2870
2968
|
}
|
|
2871
2969
|
];
|
|
2872
2970
|
/** Live catalog with a static fallback behind it. */
|
|
@@ -2944,7 +3042,8 @@ function toModelInfo(model) {
|
|
|
2944
3042
|
maxOutputTokens: model.maxTokens,
|
|
2945
3043
|
supportsImages: model.supportsImages ?? false,
|
|
2946
3044
|
...model.creditMultiplier === void 0 ? {} : { multiplier: model.creditMultiplier },
|
|
2947
|
-
...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts }
|
|
3045
|
+
...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts },
|
|
3046
|
+
...model.tags === void 0 || model.tags.length === 0 ? {} : { tags: model.tags }
|
|
2948
3047
|
};
|
|
2949
3048
|
}
|
|
2950
3049
|
/** Map the live upstream list, falling back to the static list when empty. */
|
|
@@ -3358,6 +3457,30 @@ function expertChatEvents(expert, conversationId, requestId) {
|
|
|
3358
3457
|
expert_industry_id: ""
|
|
3359
3458
|
} : event);
|
|
3360
3459
|
}
|
|
3460
|
+
//#endregion
|
|
3461
|
+
//#region src/status-paths.ts
|
|
3462
|
+
/**
|
|
3463
|
+
* The schedule every automation job falls back to.
|
|
3464
|
+
*
|
|
3465
|
+
* Shared by both halves on purpose. The host uses it when a configured hour
|
|
3466
|
+
* list arrives empty (the settings schema materializes "never configured" into
|
|
3467
|
+
* `[]`), and the card uses it when it writes the `automation` block back, so a
|
|
3468
|
+
* document that already holds an empty list is healed instead of being saved
|
|
3469
|
+
* back as an unrunnable schedule.
|
|
3470
|
+
*
|
|
3471
|
+
* This lives here rather than in `scheduler.ts` because the browser half cannot
|
|
3472
|
+
* import the host module: `scheduler.ts` pulls in `node:crypto` and the whole
|
|
3473
|
+
* upstream client, none of which exists in the browser bundle. Two hand-written
|
|
3474
|
+
* copies would drift, and the drift is invisible — the card would write a
|
|
3475
|
+
* schedule the scheduler does not run.
|
|
3476
|
+
*/
|
|
3477
|
+
const DEFAULT_AUTOMATION_HOURS = {
|
|
3478
|
+
checkin: [9],
|
|
3479
|
+
report: [10],
|
|
3480
|
+
tasks: [11],
|
|
3481
|
+
streak: [12],
|
|
3482
|
+
travel: [9, 21]
|
|
3483
|
+
};
|
|
3361
3484
|
/**
|
|
3362
3485
|
* The fallback record for the 腾讯轻量云 expert, used when the marketplace
|
|
3363
3486
|
* listing cannot be read. The id is the one the task is scored against.
|
|
@@ -3401,6 +3524,23 @@ const JOB_KINDS = [
|
|
|
3401
3524
|
"streak",
|
|
3402
3525
|
"travel"
|
|
3403
3526
|
];
|
|
3527
|
+
/**
|
|
3528
|
+
* Pick the hour list to run on: the configured one, or the default.
|
|
3529
|
+
*
|
|
3530
|
+
* An EMPTY list is treated as "not configured" rather than as "never run".
|
|
3531
|
+
* That distinction is the whole point: the settings schema materializes an
|
|
3532
|
+
* absent list into `[]` (a bare `z.array` with no `.default()`), so a config
|
|
3533
|
+
* that never mentioned the schedule arrives here looking exactly like one
|
|
3534
|
+
* deliberately set to nothing — and honouring the empty reading silently
|
|
3535
|
+
* disabled every job while the card still showed the switch as ON.
|
|
3536
|
+
*
|
|
3537
|
+
* A user who genuinely wants a job skipped can leave it out of the schedule
|
|
3538
|
+
* they save; they cannot express "off" with an empty array through the card
|
|
3539
|
+
* either way, because the card always writes a non-empty list.
|
|
3540
|
+
*/
|
|
3541
|
+
function hoursOrDefault(configured, fallback) {
|
|
3542
|
+
return configured !== void 0 && configured.length > 0 ? configured : fallback;
|
|
3543
|
+
}
|
|
3404
3544
|
const EMPTY_JOB_STATE = {
|
|
3405
3545
|
ok: 0,
|
|
3406
3546
|
failed: 0,
|
|
@@ -3576,11 +3716,11 @@ var WorkBuddyScheduler = class {
|
|
|
3576
3716
|
this.earningsDate = today;
|
|
3577
3717
|
}
|
|
3578
3718
|
this.enabled = options.enabled ?? false;
|
|
3579
|
-
this.checkinHours = options.checkinHours
|
|
3580
|
-
this.reportHours = options.reportHours
|
|
3581
|
-
this.taskHours = options.taskHours
|
|
3582
|
-
this.streakHours = options.streakHours
|
|
3583
|
-
this.travelHours = options.travelHours
|
|
3719
|
+
this.checkinHours = hoursOrDefault(options.checkinHours, DEFAULT_AUTOMATION_HOURS.checkin);
|
|
3720
|
+
this.reportHours = hoursOrDefault(options.reportHours, DEFAULT_AUTOMATION_HOURS.report);
|
|
3721
|
+
this.taskHours = hoursOrDefault(options.taskHours, DEFAULT_AUTOMATION_HOURS.tasks);
|
|
3722
|
+
this.streakHours = hoursOrDefault(options.streakHours, DEFAULT_AUTOMATION_HOURS.streak);
|
|
3723
|
+
this.travelHours = hoursOrDefault(options.travelHours, DEFAULT_AUTOMATION_HOURS.travel);
|
|
3584
3724
|
}
|
|
3585
3725
|
/** Apply a new configuration; safe to call while running. */
|
|
3586
3726
|
/**
|
|
@@ -3608,11 +3748,11 @@ var WorkBuddyScheduler = class {
|
|
|
3608
3748
|
}
|
|
3609
3749
|
applyConfig(options) {
|
|
3610
3750
|
if (options.enabled !== void 0) this.enabled = options.enabled;
|
|
3611
|
-
if (options.checkinHours !== void 0) this.checkinHours = options.checkinHours;
|
|
3612
|
-
if (options.reportHours !== void 0) this.reportHours = options.reportHours;
|
|
3613
|
-
if (options.taskHours !== void 0) this.taskHours = options.taskHours;
|
|
3614
|
-
if (options.streakHours !== void 0) this.streakHours = options.streakHours;
|
|
3615
|
-
if (options.travelHours !== void 0) this.travelHours = options.travelHours;
|
|
3751
|
+
if (options.checkinHours !== void 0) this.checkinHours = hoursOrDefault(options.checkinHours, DEFAULT_AUTOMATION_HOURS.checkin);
|
|
3752
|
+
if (options.reportHours !== void 0) this.reportHours = hoursOrDefault(options.reportHours, DEFAULT_AUTOMATION_HOURS.report);
|
|
3753
|
+
if (options.taskHours !== void 0) this.taskHours = hoursOrDefault(options.taskHours, DEFAULT_AUTOMATION_HOURS.tasks);
|
|
3754
|
+
if (options.streakHours !== void 0) this.streakHours = hoursOrDefault(options.streakHours, DEFAULT_AUTOMATION_HOURS.streak);
|
|
3755
|
+
if (options.travelHours !== void 0) this.travelHours = hoursOrDefault(options.travelHours, DEFAULT_AUTOMATION_HOURS.travel);
|
|
3616
3756
|
}
|
|
3617
3757
|
/** Hours for one job, used by the loop and the status document. */
|
|
3618
3758
|
hoursOf(kind) {
|
|
@@ -4275,6 +4415,137 @@ function formatRates(status) {
|
|
|
4275
4415
|
return lines.join("\n");
|
|
4276
4416
|
}
|
|
4277
4417
|
//#endregion
|
|
4418
|
+
//#region src/ignored.ts
|
|
4419
|
+
/**
|
|
4420
|
+
* The permanent ignore list: accounts the user has thrown out of the pool.
|
|
4421
|
+
*
|
|
4422
|
+
* Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
|
|
4423
|
+
* account stays on the card, can be switched back on, and its credential keeps
|
|
4424
|
+
* being read on every scan. Ignoring is a statement about the ACCOUNT — "this
|
|
4425
|
+
* one is not mine any more" — so an ignored account is skipped BEFORE its
|
|
4426
|
+
* credential is decrypted, never appears among the pool's accounts, and does not
|
|
4427
|
+
* come back when the desktop app writes a fresh `.info` file for it.
|
|
4428
|
+
*
|
|
4429
|
+
* It lives in a plugin-owned file rather than in the settings document for one
|
|
4430
|
+
* practical reason: the CLI has no settings service. `dsh-workbuddy-xdpool
|
|
4431
|
+
* ignore <id>` runs without a host, so a settings-only list could be written by
|
|
4432
|
+
* the card and never by the CLI. One file, read by both halves, keeps the two
|
|
4433
|
+
* views of "which accounts are ignored" identical by construction.
|
|
4434
|
+
*
|
|
4435
|
+
* @module dsh-workbuddy-xdpool/ignored
|
|
4436
|
+
*/
|
|
4437
|
+
/** Directory holding this plugin's own state (imported snapshots, ignore list). */
|
|
4438
|
+
const PLUGIN_DATA_DIR_NAME = ".workbuddy-xdpool";
|
|
4439
|
+
/** File holding the permanent ignore list, inside {@link pluginDataDir}. */
|
|
4440
|
+
const IGNORED_FILE_NAME = "ignored.json";
|
|
4441
|
+
/**
|
|
4442
|
+
* The DSH home directory, honouring the same override the host uses.
|
|
4443
|
+
*
|
|
4444
|
+
* Shared by the CLI and the host so both halves resolve the same file: an
|
|
4445
|
+
* `ignore` written from the terminal has to be visible to the running plugin,
|
|
4446
|
+
* which is only true if they agree on where "home" is.
|
|
4447
|
+
*/
|
|
4448
|
+
function dshHome$1(env = process.env) {
|
|
4449
|
+
const fromEnv = env["DSH_HOME"];
|
|
4450
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
|
|
4451
|
+
return join(homedir(), ".dsh");
|
|
4452
|
+
}
|
|
4453
|
+
/** This plugin's own state directory. */
|
|
4454
|
+
function pluginDataDir(env = process.env) {
|
|
4455
|
+
return join(dshHome$1(env), PLUGIN_DATA_DIR_NAME);
|
|
4456
|
+
}
|
|
4457
|
+
/** Absolute path of the ignore list. */
|
|
4458
|
+
function ignoredIdsPath(env = process.env) {
|
|
4459
|
+
return join(pluginDataDir(env), IGNORED_FILE_NAME);
|
|
4460
|
+
}
|
|
4461
|
+
/**
|
|
4462
|
+
* Normalize whatever the file holds into a clean list.
|
|
4463
|
+
*
|
|
4464
|
+
* Tolerant on purpose: this file is hand-editable and a malformed entry must not
|
|
4465
|
+
* take the pool down. An entry without a usable id is dropped; a missing label
|
|
4466
|
+
* falls back to the id so the card always has something to print.
|
|
4467
|
+
*/
|
|
4468
|
+
function normalize(raw) {
|
|
4469
|
+
if (typeof raw !== "object" || raw === null) return [];
|
|
4470
|
+
const entries = raw.accounts;
|
|
4471
|
+
if (!Array.isArray(entries)) return [];
|
|
4472
|
+
const out = [];
|
|
4473
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4474
|
+
for (const entry of entries) {
|
|
4475
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
4476
|
+
const record = entry;
|
|
4477
|
+
const id = typeof record["id"] === "string" ? record["id"].trim() : "";
|
|
4478
|
+
if (id === "" || seen.has(id)) continue;
|
|
4479
|
+
seen.add(id);
|
|
4480
|
+
const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
|
|
4481
|
+
const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
|
|
4482
|
+
out.push({
|
|
4483
|
+
id,
|
|
4484
|
+
label,
|
|
4485
|
+
ignoredAt
|
|
4486
|
+
});
|
|
4487
|
+
}
|
|
4488
|
+
return out;
|
|
4489
|
+
}
|
|
4490
|
+
/**
|
|
4491
|
+
* Read the ignore list, tolerating every "no list yet" shape.
|
|
4492
|
+
*
|
|
4493
|
+
* A missing file, unreadable file, or invalid JSON all mean the same thing to
|
|
4494
|
+
* the caller — nothing is ignored — so none of them throws. The pool must be
|
|
4495
|
+
* able to start on a machine that has never ignored anything.
|
|
4496
|
+
*/
|
|
4497
|
+
async function readIgnoredAccounts(path = ignoredIdsPath()) {
|
|
4498
|
+
try {
|
|
4499
|
+
return normalize(JSON.parse(await readFile(path, "utf8")));
|
|
4500
|
+
} catch {
|
|
4501
|
+
return [];
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4504
|
+
/**
|
|
4505
|
+
* Replace the ignore list, atomically.
|
|
4506
|
+
*
|
|
4507
|
+
* Written to a sibling temp file and renamed over the target so a crash (or a
|
|
4508
|
+
* concurrent reader) can never observe a half-written document — the ignore
|
|
4509
|
+
* list is the only thing standing between a dead account and the rotation, and
|
|
4510
|
+
* a truncated file reads as "nothing is ignored", which would quietly put every
|
|
4511
|
+
* discarded account back in the pool.
|
|
4512
|
+
*/
|
|
4513
|
+
async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
|
|
4514
|
+
const payload = JSON.stringify({
|
|
4515
|
+
version: 1,
|
|
4516
|
+
accounts
|
|
4517
|
+
}, null, 2);
|
|
4518
|
+
await mkdir(dirname(path), { recursive: true });
|
|
4519
|
+
const temp = `${path}.tmp`;
|
|
4520
|
+
await writeFile$1(temp, `${payload}\n`, "utf8");
|
|
4521
|
+
await rename(temp, path);
|
|
4522
|
+
}
|
|
4523
|
+
/**
|
|
4524
|
+
* Add one account to the ignore list, preserving the rest.
|
|
4525
|
+
*
|
|
4526
|
+
* A read-modify-write rather than a wholesale replace: the card and the CLI can
|
|
4527
|
+
* both be open, and each request names exactly one account, so re-writing the
|
|
4528
|
+
* whole list from a stale view would drop the other side's edits.
|
|
4529
|
+
*/
|
|
4530
|
+
async function ignoreAccount(account, path = ignoredIdsPath()) {
|
|
4531
|
+
const current = await readIgnoredAccounts(path);
|
|
4532
|
+
if (current.some((entry) => entry.id === account.id)) return current;
|
|
4533
|
+
const next = [...current, {
|
|
4534
|
+
id: account.id,
|
|
4535
|
+
label: account.label ?? account.id,
|
|
4536
|
+
ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4537
|
+
}];
|
|
4538
|
+
await writeIgnoredAccounts(next, path);
|
|
4539
|
+
return next;
|
|
4540
|
+
}
|
|
4541
|
+
/** Drop one account from the ignore list. Returns the resulting list. */
|
|
4542
|
+
async function unignoreAccount(accountId, path = ignoredIdsPath()) {
|
|
4543
|
+
const current = await readIgnoredAccounts(path);
|
|
4544
|
+
const next = current.filter((entry) => entry.id !== accountId);
|
|
4545
|
+
if (next.length !== current.length) await writeIgnoredAccounts(next, path);
|
|
4546
|
+
return next;
|
|
4547
|
+
}
|
|
4548
|
+
//#endregion
|
|
4278
4549
|
//#region src/index.ts
|
|
4279
4550
|
/**
|
|
4280
4551
|
* One region's model-selection schema.
|
|
@@ -4376,6 +4647,20 @@ function createCore(logger) {
|
|
|
4376
4647
|
*/
|
|
4377
4648
|
/** Directory holding imported account snapshots. */
|
|
4378
4649
|
const ACCOUNT_DIR_NAME = ".workbuddy-xdpool";
|
|
4650
|
+
/**
|
|
4651
|
+
* Assemble the runtime for a CLI command, with the ignore list applied.
|
|
4652
|
+
*
|
|
4653
|
+
* Every command goes through here rather than calling `createCore()` directly,
|
|
4654
|
+
* so a command can never accidentally act on an account the user has thrown out
|
|
4655
|
+
* — `checkin all` collecting a reward for a discarded account would be exactly
|
|
4656
|
+
* the kind of silent surprise the ignore feature exists to prevent.
|
|
4657
|
+
*/
|
|
4658
|
+
async function cliCore() {
|
|
4659
|
+
const core = createCore();
|
|
4660
|
+
const ignored = await readIgnoredAccounts();
|
|
4661
|
+
core.pool.applyIgnored(ignored.map((entry) => entry.id));
|
|
4662
|
+
return core;
|
|
4663
|
+
}
|
|
4379
4664
|
/** Snapshot files are named by the md5 prefix of their key, so any key is safe. */
|
|
4380
4665
|
function snapshotPath(key, dir) {
|
|
4381
4666
|
return join(dir, `${createHash("md5").update(key).digest("hex").slice(0, 8)}.json`);
|
|
@@ -4400,6 +4685,9 @@ function usage() {
|
|
|
4400
4685
|
" accounts List discovered accounts (add --json)",
|
|
4401
4686
|
" import <key> Snapshot the current desktop login as <key> (add --force)",
|
|
4402
4687
|
" remove <key> Delete one imported snapshot",
|
|
4688
|
+
" ignore <acct> Drop an account from the pool for good (id or label)",
|
|
4689
|
+
" unignore <acct> Put an ignored account back into the pool",
|
|
4690
|
+
" ignored List the accounts dropped from the pool (add --json)",
|
|
4403
4691
|
" login Guide for adding another account (desktop app is single-sign-in)",
|
|
4404
4692
|
" checkin [all|<acct>] Daily check-in: report status, or collect with `all` / a label",
|
|
4405
4693
|
" reset Clear all rate-limit cooldowns immediately",
|
|
@@ -4428,7 +4716,7 @@ async function commandStatus(args) {
|
|
|
4428
4716
|
const asJson = args.includes("--json");
|
|
4429
4717
|
const withCredits = args.includes("--credits");
|
|
4430
4718
|
const withRates = args.includes("--rates");
|
|
4431
|
-
const core =
|
|
4719
|
+
const core = await cliCore();
|
|
4432
4720
|
const accounts = await core.pool.scan();
|
|
4433
4721
|
const status = {
|
|
4434
4722
|
ok: accounts.length > 0,
|
|
@@ -4489,7 +4777,7 @@ async function commandStatus(args) {
|
|
|
4489
4777
|
async function commandCheckin(args) {
|
|
4490
4778
|
const asJson = args.includes("--json");
|
|
4491
4779
|
const target = args.find((arg) => !arg.startsWith("--"));
|
|
4492
|
-
const core =
|
|
4780
|
+
const core = await cliCore();
|
|
4493
4781
|
if ((await core.pool.scan()).length === 0) {
|
|
4494
4782
|
console.error("No WorkBuddy account discovered. Sign in with the WorkBuddy desktop app first.");
|
|
4495
4783
|
return 1;
|
|
@@ -4573,7 +4861,7 @@ async function commandDoctor() {
|
|
|
4573
4861
|
lines.push(` ✓ ${dir} → ${snapshots.length} snapshot(s)`);
|
|
4574
4862
|
}
|
|
4575
4863
|
lines.push("");
|
|
4576
|
-
const accounts = await
|
|
4864
|
+
const accounts = await (await cliCore()).pool.scan();
|
|
4577
4865
|
lines.push(`Accounts discovered: ${accounts.length}`);
|
|
4578
4866
|
if (accounts.length === 0) {
|
|
4579
4867
|
healthy = false;
|
|
@@ -4651,6 +4939,66 @@ async function commandRemove(args) {
|
|
|
4651
4939
|
return 1;
|
|
4652
4940
|
}
|
|
4653
4941
|
}
|
|
4942
|
+
/**
|
|
4943
|
+
* Throw one account out of the pool for good, or take it back.
|
|
4944
|
+
*
|
|
4945
|
+
* The target may be a full account id (as `accounts --json` prints it) or any
|
|
4946
|
+
* unambiguous fragment of the label, so the user does not have to copy a hash.
|
|
4947
|
+
* The id is what gets stored — a label can change when the account is renamed,
|
|
4948
|
+
* and an ignore list keyed by a mutable label would silently stop matching.
|
|
4949
|
+
*/
|
|
4950
|
+
async function commandIgnore(args, ignored) {
|
|
4951
|
+
const verb = ignored ? "ignore" : "unignore";
|
|
4952
|
+
const target = args.filter((arg) => !arg.startsWith("--"))[0];
|
|
4953
|
+
if (target === void 0) {
|
|
4954
|
+
console.error(`usage: dsh-workbuddy-xdpool ${verb} <account-id|label>`);
|
|
4955
|
+
return 2;
|
|
4956
|
+
}
|
|
4957
|
+
const accounts = await (await cliCore()).pool.scan();
|
|
4958
|
+
const current = await readIgnoredAccounts();
|
|
4959
|
+
if (!ignored) {
|
|
4960
|
+
const match = current.find((entry) => entry.id === target) ?? current.find((entry) => entry.label === target) ?? current.find((entry) => entry.id.startsWith(target));
|
|
4961
|
+
if (match === void 0) {
|
|
4962
|
+
console.error(`No ignored account matches "${target}". Run \`ignored\` to list them.`);
|
|
4963
|
+
return 1;
|
|
4964
|
+
}
|
|
4965
|
+
await unignoreAccount(match.id);
|
|
4966
|
+
console.log(`Restored ${match.label} (${match.id}). It rejoins the pool on the next scan.`);
|
|
4967
|
+
return 0;
|
|
4968
|
+
}
|
|
4969
|
+
const matches = accounts.filter((account) => account.id === target || account.label === target || account.id.startsWith(target) || account.label.includes(target));
|
|
4970
|
+
if (matches.length === 0) {
|
|
4971
|
+
console.error(`No account matches "${target}". Run \`accounts\` to list them.`);
|
|
4972
|
+
return 1;
|
|
4973
|
+
}
|
|
4974
|
+
if (matches.length > 1) {
|
|
4975
|
+
console.error(`"${target}" matches ${matches.length} accounts; use the full id:\n` + matches.map((account) => ` ${account.id} ${account.label}`).join("\n"));
|
|
4976
|
+
return 1;
|
|
4977
|
+
}
|
|
4978
|
+
const account = matches[0];
|
|
4979
|
+
await ignoreAccount({
|
|
4980
|
+
id: account.id,
|
|
4981
|
+
label: account.label
|
|
4982
|
+
});
|
|
4983
|
+
console.log(`Ignored ${account.label} (${account.id}).\n Its credential is no longer read and it will not rejoin the pool, even if the
|
|
4984
|
+
desktop app signs it in again. Undo with: dsh-workbuddy-xdpool unignore ${account.id}`);
|
|
4985
|
+
return 0;
|
|
4986
|
+
}
|
|
4987
|
+
/** List the accounts currently thrown out of the pool. */
|
|
4988
|
+
async function commandIgnored(args) {
|
|
4989
|
+
const asJson = args.includes("--json");
|
|
4990
|
+
const ignored = await readIgnoredAccounts();
|
|
4991
|
+
if (asJson) {
|
|
4992
|
+
console.log(JSON.stringify(ignored, null, 2));
|
|
4993
|
+
return 0;
|
|
4994
|
+
}
|
|
4995
|
+
if (ignored.length === 0) {
|
|
4996
|
+
console.log("No accounts are ignored. Use `ignore <account-id|label>` to drop one.");
|
|
4997
|
+
return 0;
|
|
4998
|
+
}
|
|
4999
|
+
console.log(ignored.map((entry) => `⛔ ${entry.label} (${entry.id})`).join("\n"));
|
|
5000
|
+
return 0;
|
|
5001
|
+
}
|
|
4654
5002
|
function commandLogin() {
|
|
4655
5003
|
console.log([
|
|
4656
5004
|
"Adding another WorkBuddy account",
|
|
@@ -4670,7 +5018,7 @@ function commandLogin() {
|
|
|
4670
5018
|
return 0;
|
|
4671
5019
|
}
|
|
4672
5020
|
async function commandReset() {
|
|
4673
|
-
const core =
|
|
5021
|
+
const core = await cliCore();
|
|
4674
5022
|
await core.pool.scan();
|
|
4675
5023
|
core.pool.resetCooldowns();
|
|
4676
5024
|
console.log("Cleared all rate-limit cooldowns.");
|
|
@@ -4678,15 +5026,25 @@ async function commandReset() {
|
|
|
4678
5026
|
}
|
|
4679
5027
|
async function commandAccounts(args) {
|
|
4680
5028
|
const asJson = args.includes("--json");
|
|
4681
|
-
const accounts = await
|
|
4682
|
-
|
|
5029
|
+
const accounts = await (await cliCore()).pool.scan();
|
|
5030
|
+
const ignored = await readIgnoredAccounts();
|
|
5031
|
+
if (asJson) console.log(JSON.stringify([...accounts.map((account) => ({
|
|
4683
5032
|
id: account.id,
|
|
4684
5033
|
label: account.label,
|
|
4685
5034
|
cooling: account.cooldownUntilMs > Date.now(),
|
|
4686
5035
|
rateLimitHits: account.rateLimitHits
|
|
4687
|
-
})),
|
|
4688
|
-
|
|
4689
|
-
|
|
5036
|
+
})), ...ignored.map((entry) => ({
|
|
5037
|
+
id: entry.id,
|
|
5038
|
+
label: entry.label,
|
|
5039
|
+
ignored: true,
|
|
5040
|
+
ignoredAt: entry.ignoredAt
|
|
5041
|
+
}))], null, 2));
|
|
5042
|
+
else if (accounts.length === 0 && ignored.length === 0) console.log("No imported accounts. Run `import <key>` after signing in on the desktop app.");
|
|
5043
|
+
else {
|
|
5044
|
+
const lines = accounts.map((account) => `${account.cooldownUntilMs > Date.now() ? "⏸" : "▶"} ${account.label}`);
|
|
5045
|
+
if (ignored.length > 0) lines.push(...ignored.map((entry) => `⛔ ${entry.label} (removed; \`unignore ${entry.id}\` to restore)`));
|
|
5046
|
+
console.log(lines.join("\n"));
|
|
5047
|
+
}
|
|
4690
5048
|
return 0;
|
|
4691
5049
|
}
|
|
4692
5050
|
/** Entry point; returns the process exit code. */
|
|
@@ -4704,6 +5062,9 @@ async function main(argv) {
|
|
|
4704
5062
|
case "accounts": return commandAccounts(rest);
|
|
4705
5063
|
case "import": return commandImport(rest);
|
|
4706
5064
|
case "remove": return commandRemove(rest);
|
|
5065
|
+
case "ignore": return commandIgnore(rest, true);
|
|
5066
|
+
case "unignore": return commandIgnore(rest, false);
|
|
5067
|
+
case "ignored": return commandIgnored(rest);
|
|
4707
5068
|
case "login": return commandLogin();
|
|
4708
5069
|
case "logout": return commandRemove(["default", ...rest]);
|
|
4709
5070
|
case "reset": return commandReset();
|