@goodandready/dsh-key-rotation 0.8.8 → 0.8.11
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/README.md +40 -3
- package/README.ru.md +40 -3
- package/README.zh.md +29 -4
- package/lib/best-effort.js +26 -0
- package/lib/bucket.js +1 -87
- package/lib/cascade.js +27 -39
- package/lib/client.js +360 -55
- package/lib/clock.js +0 -2
- package/lib/concurrency.js +9 -0
- package/lib/error-taxonomy.js +0 -3
- package/lib/heal.js +47 -0
- package/lib/http-bridge.js +0 -7
- package/lib/index.js +65 -10
- package/lib/plugin-updater.js +277 -0
- package/lib/pool.js +109 -29
- package/lib/rotate.js +127 -107
- package/lib/routes-ops.js +5 -2
- package/lib/sandbox.js +9 -1
- package/lib/webhook.js +2 -1
- package/package.json +5 -2
package/lib/clock.js
CHANGED
package/lib/concurrency.js
CHANGED
|
@@ -40,6 +40,15 @@ export class ConcurrencyTracker {
|
|
|
40
40
|
e.lastAcquired = now;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
getActive(ref, now) {
|
|
44
|
+
now = now || Date.now();
|
|
45
|
+
if (!ref) return 0;
|
|
46
|
+
const e = this._inFlight.get(ref);
|
|
47
|
+
if (!e) return 0;
|
|
48
|
+
if (now - e.lastAcquired > this._staleMs) return 0;
|
|
49
|
+
return e.count;
|
|
50
|
+
}
|
|
51
|
+
|
|
43
52
|
snapshot() {
|
|
44
53
|
const out = {};
|
|
45
54
|
for (const [k, v] of this._inFlight) out[k] = { count: v.count };
|
package/lib/error-taxonomy.js
CHANGED
package/lib/heal.js
CHANGED
|
@@ -33,3 +33,50 @@ export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
|
|
|
33
33
|
}
|
|
34
34
|
return healed;
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Background auto-recovery for keys in brokenUntil status.
|
|
39
|
+
* Tests broken keys with a lightweight free probe (e.g. probeModels).
|
|
40
|
+
* If the probe succeeds, clears broken and failure states and records a 'heal' event.
|
|
41
|
+
*
|
|
42
|
+
* @param {Array} pools
|
|
43
|
+
* @param {Function} probeFn async (ref) => { ok: boolean, code?: string, status?: number }
|
|
44
|
+
* @param {number} [now=Date.now()]
|
|
45
|
+
* @returns {Promise<Array<{ ref: string, poolBase: string, ok: boolean }>>}
|
|
46
|
+
*/
|
|
47
|
+
export async function autoUnbreakBrokenKeys(pools, probeFn, now = Date.now()) {
|
|
48
|
+
if (!Array.isArray(pools) || pools.length === 0 || typeof probeFn !== 'function') return [];
|
|
49
|
+
const results = [];
|
|
50
|
+
for (const pool of pools) {
|
|
51
|
+
if (!pool || !pool.state || !pool.base) continue;
|
|
52
|
+
const bu = pool.state.brokenUntil;
|
|
53
|
+
if (!bu || bu.size === 0) continue;
|
|
54
|
+
const brokenRefs = [];
|
|
55
|
+
for (const [ref, until] of bu.entries()) {
|
|
56
|
+
if (Number.isFinite(until) && until > now) {
|
|
57
|
+
brokenRefs.push(ref);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const ref of brokenRefs) {
|
|
61
|
+
try {
|
|
62
|
+
const res = await probeFn(ref);
|
|
63
|
+
if (res && res.ok) {
|
|
64
|
+
bu.delete(ref);
|
|
65
|
+
pool.state.failedUntil?.delete(ref);
|
|
66
|
+
pool.state.failCounts?.delete(ref);
|
|
67
|
+
pool.state.authFailCounts?.delete(ref);
|
|
68
|
+
if (Array.isArray(pool.state.events)) {
|
|
69
|
+
pool.state.events.push({ at: now, ref, reason: 'auto-unbreak', cooldownMs: 0, type: 'heal' });
|
|
70
|
+
if (pool.state.events.length > 50) pool.state.events.shift();
|
|
71
|
+
}
|
|
72
|
+
results.push({ ref, poolBase: pool.base, ok: true });
|
|
73
|
+
} else {
|
|
74
|
+
results.push({ ref, poolBase: pool.base, ok: false });
|
|
75
|
+
}
|
|
76
|
+
} catch (_) {
|
|
77
|
+
results.push({ ref, poolBase: pool.base, ok: false });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return results;
|
|
82
|
+
}
|
package/lib/http-bridge.js
CHANGED
|
@@ -78,13 +78,6 @@ export function providerCatalog(ctx, cloneIds) {
|
|
|
78
78
|
return out;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export function guardLocal(req, res, label) {
|
|
82
|
-
if (!isTrustedBridgeRequest(req)) {
|
|
83
|
-
json(res, 403, { error: { code: 'forbidden', message: `dsh-key-rotation: ${label} is local-only` } });
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
88
81
|
|
|
89
82
|
export function scanForLiveSecrets(section) {
|
|
90
83
|
const masked = structuredClone(section);
|
package/lib/index.js
CHANGED
|
@@ -27,8 +27,9 @@ const HEALTH_PATH = '/dsh-key-rotation/health';
|
|
|
27
27
|
const USAGE_PATH = '/dsh-key-rotation/usage';
|
|
28
28
|
const TEST_PATH = '/dsh-key-rotation/test';
|
|
29
29
|
const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
|
|
30
|
+
import { sortAttemptList } from './pool.js';
|
|
30
31
|
import { LastTestCache, SandboxRunner } from './sandbox.js';
|
|
31
|
-
import { healIdleCooldowns } from './heal.js';
|
|
32
|
+
import { healIdleCooldowns, autoUnbreakBrokenKeys } from './heal.js';
|
|
32
33
|
import { LatencyHistogram } from './histogram.js';
|
|
33
34
|
import { pickCascadeFallback } from './cascade.js';
|
|
34
35
|
import { ConcurrencyTracker } from './concurrency.js';
|
|
@@ -51,6 +52,7 @@ import { safeParseJson } from './atomic-io.js';
|
|
|
51
52
|
import { registerOpsRoutes } from './routes-ops.js';
|
|
52
53
|
import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
53
54
|
import path from 'node:path';
|
|
55
|
+
import { registerPluginUpdater } from './plugin-updater.js'
|
|
54
56
|
|
|
55
57
|
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
56
58
|
const PIAI_NS = 'llm-pi-ai';
|
|
@@ -199,6 +201,9 @@ export const Config = Schema.object({
|
|
|
199
201
|
hour: Schema.number().default(0),
|
|
200
202
|
}),
|
|
201
203
|
rateLimitThreshold: Schema.number().default(0.1),
|
|
204
|
+
proactiveRateLimitGuard: Schema.boolean().default(true),
|
|
205
|
+
selfHealingIntervalMinutes: Schema.number().default(30),
|
|
206
|
+
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency']).default('round-robin'),
|
|
202
207
|
rpmLimit: Schema.number().default(0),
|
|
203
208
|
webhookActionToken: Schema.string().role('secret').default(''),
|
|
204
209
|
expiryWarnDays: Schema.number().default(7),
|
|
@@ -230,6 +235,8 @@ export const Config = Schema.object({
|
|
|
230
235
|
})).default({}),
|
|
231
236
|
cooldownMs: Schema.number(),
|
|
232
237
|
maxCooldownMs: Schema.number(),
|
|
238
|
+
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency']),
|
|
239
|
+
proactiveRateLimitGuard: Schema.boolean(),
|
|
233
240
|
})).default([...DEFAULT_PROVIDERS]),
|
|
234
241
|
});
|
|
235
242
|
|
|
@@ -339,8 +346,34 @@ export function apply(ctx, config = {}) {
|
|
|
339
346
|
if (snap) statePersistence.save(snap);
|
|
340
347
|
}
|
|
341
348
|
|
|
349
|
+
// Auto-healing / auto-unbreak for broken keys (#303)
|
|
342
350
|
ctx.effect(() => {
|
|
343
|
-
const
|
|
351
|
+
const cfg = getConfig();
|
|
352
|
+
const intervalMin = cfg?.selfHealingIntervalMinutes ?? 30;
|
|
353
|
+
if (!intervalMin || intervalMin <= 0) return () => {};
|
|
354
|
+
const intervalMs = intervalMin * 60 * 1000;
|
|
355
|
+
const timer = setInterval(async () => {
|
|
356
|
+
try {
|
|
357
|
+
const c = getConfig();
|
|
358
|
+
if (!c || !c.selfHealingIntervalMinutes || c.selfHealingIntervalMinutes <= 0) return;
|
|
359
|
+
const { pools } = buildRuntime();
|
|
360
|
+
const runner = ensureSandboxRunner(ctx);
|
|
361
|
+
await autoUnbreakBrokenKeys(pools, async (ref) => {
|
|
362
|
+
let val = (await ctx.credentials?.resolve?.(ref))?.value;
|
|
363
|
+
if (!val) return { ok: false };
|
|
364
|
+
return runner.probeModels(ref, val);
|
|
365
|
+
});
|
|
366
|
+
} catch (e) { ctx.logger?.warn?.('[dsh-key-rotation] auto-unbreak failed', e); }
|
|
367
|
+
}, intervalMs);
|
|
368
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
369
|
+
return () => clearInterval(timer);
|
|
370
|
+
}, 'dsh-key-rotation: auto-unbreak');
|
|
371
|
+
|
|
372
|
+
ctx.effect(() => {
|
|
373
|
+
const timer = setInterval(() => {
|
|
374
|
+
try { schedulePersist(); }
|
|
375
|
+
catch (e) { ctx.logger?.warn?.('[dsh-key-rotation] periodic persist failed', e); }
|
|
376
|
+
}, 15000);
|
|
344
377
|
if (typeof timer.unref === 'function') timer.unref();
|
|
345
378
|
return () => {
|
|
346
379
|
clearInterval(timer);
|
|
@@ -354,7 +387,9 @@ export function apply(ctx, config = {}) {
|
|
|
354
387
|
}
|
|
355
388
|
statePersistence.dispose();
|
|
356
389
|
}
|
|
357
|
-
} catch (
|
|
390
|
+
} catch (e) {
|
|
391
|
+
ctx.logger?.warn?.('[dsh-key-rotation] dispose persist failed', e);
|
|
392
|
+
}
|
|
358
393
|
};
|
|
359
394
|
}, 'dsh-key-rotation: state persistence');
|
|
360
395
|
// Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
|
|
@@ -372,9 +407,10 @@ export function apply(ctx, config = {}) {
|
|
|
372
407
|
}
|
|
373
408
|
}
|
|
374
409
|
}
|
|
375
|
-
const n = sweepExpired(poolState, now);
|
|
376
|
-
if (n > 0) console.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
|
|
377
410
|
const runtime = buildRuntime();
|
|
411
|
+
const allActiveRefs = Array.from(runtime.poolByRef.keys());
|
|
412
|
+
const n = sweepExpired(poolState, now, allActiveRefs);
|
|
413
|
+
if (n > 0) console.warn(`[dsh-key-rotation] sweep: cleared ${n} expired cooldown(s)`);
|
|
378
414
|
for (const pool of runtime.poolByRef.values()) {
|
|
379
415
|
compactUsage(pool, 30, now);
|
|
380
416
|
}
|
|
@@ -564,7 +600,7 @@ export function apply(ctx, config = {}) {
|
|
|
564
600
|
if (typeof v === 'string' && v.length > 0) { const ts = Date.parse(v); return Number.isNaN(ts) ? undefined : ts; }
|
|
565
601
|
return undefined;
|
|
566
602
|
};
|
|
567
|
-
const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt) => {
|
|
603
|
+
const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) => {
|
|
568
604
|
const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && ref.length > 0);
|
|
569
605
|
if (refs.length === 0) return null;
|
|
570
606
|
const w = Array.isArray(weights) ? weights : [];
|
|
@@ -582,7 +618,7 @@ export function apply(ctx, config = {}) {
|
|
|
582
618
|
}
|
|
583
619
|
return { base, refs, weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
|
|
584
620
|
weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
|
|
585
|
-
state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax, expiresAt: parsedExpiry, rpmLimit };
|
|
621
|
+
state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax, expiresAt: parsedExpiry, rpmLimit, routingStrategy: poolStrategy, proactiveRateLimitGuard: poolGuard };
|
|
586
622
|
};
|
|
587
623
|
for (const p of cfg.providers ?? []) {
|
|
588
624
|
const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
|
|
@@ -656,7 +692,10 @@ export function apply(ctx, config = {}) {
|
|
|
656
692
|
}
|
|
657
693
|
}
|
|
658
694
|
}
|
|
659
|
-
cachedRuntime = { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, verboseLogging: cfg.verboseLogging ?? false,
|
|
695
|
+
cachedRuntime = { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, concurrencyLimit, cascade, quotaResetWindow, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, verboseLogging: cfg.verboseLogging ?? false,
|
|
696
|
+
proactiveRateLimitGuard: cfg.proactiveRateLimitGuard ?? true,
|
|
697
|
+
selfHealingIntervalMinutes: cfg.selfHealingIntervalMinutes ?? 30,
|
|
698
|
+
routingStrategy: cfg.routingStrategy ?? 'round-robin', switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds, expectedClones,
|
|
660
699
|
circuitBreakerEnabled: cfg.circuitBreakerEnabled ?? true,
|
|
661
700
|
circuitBreakerThreshold: cfg.circuitBreakerThreshold ?? 5,
|
|
662
701
|
circuitBreakerOpenMs: cfg.circuitBreakerOpenMs ?? 30000,
|
|
@@ -684,8 +723,12 @@ export function apply(ctx, config = {}) {
|
|
|
684
723
|
const pool = poolByRef.get(ref);
|
|
685
724
|
if (!pool) return original(ref);
|
|
686
725
|
const now = Date.now();
|
|
687
|
-
const
|
|
688
|
-
|
|
726
|
+
const strat = pool.routingStrategy ?? buildRuntime().routingStrategy ?? 'round-robin';
|
|
727
|
+
let list = pool.weightedRefs ?? pool.refs;
|
|
728
|
+
if (strat === 'lowest-latency' || strat === 'least-loaded') {
|
|
729
|
+
list = sortAttemptList(list, strat, { latencyHistogram, concurrencyTracker });
|
|
730
|
+
}
|
|
731
|
+
const start = (strat === 'round-robin') ? (pool.state.pointer ?? 0) : 0;
|
|
689
732
|
for (let i = 0; i < list.length; i++) {
|
|
690
733
|
const index = (start + i) % list.length;
|
|
691
734
|
const candidate = list[index];
|
|
@@ -805,6 +848,7 @@ export function apply(ctx, config = {}) {
|
|
|
805
848
|
notifySwitch: (runtime, pool, info) => notifySwitch(runtime, pool, info, { webhookSender, notifyQueue: moduleNotifyQueue, now: () => Date.now() }),
|
|
806
849
|
notifyExhaustion,
|
|
807
850
|
recordLatency,
|
|
851
|
+
latencyHistogram,
|
|
808
852
|
concurrencyTracker,
|
|
809
853
|
MARKER,
|
|
810
854
|
finishError,
|
|
@@ -819,6 +863,7 @@ export function apply(ctx, config = {}) {
|
|
|
819
863
|
// the resolve patch hands out the next key on each dispatch.
|
|
820
864
|
// Operational routes (status/usage/snapshot/key/import/test/health/webhook) (#253)
|
|
821
865
|
registerOpsRoutes(ctx, {
|
|
866
|
+
|
|
822
867
|
buildRuntime,
|
|
823
868
|
latencyHistogram,
|
|
824
869
|
lastTestCache,
|
|
@@ -830,6 +875,16 @@ export function apply(ctx, config = {}) {
|
|
|
830
875
|
quotaStore,
|
|
831
876
|
});
|
|
832
877
|
|
|
878
|
+
// One-click plugin updater (#307). GET status / POST install exact registry version.
|
|
879
|
+
ctx.effect(() => {
|
|
880
|
+
if (typeof ctx.webServer?.register !== 'function') return;
|
|
881
|
+
registerPluginUpdater(ctx, {
|
|
882
|
+
endpoint: '/api/dsh-key-rotation/update',
|
|
883
|
+
packageName: '@goodandready/dsh-key-rotation',
|
|
884
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
885
|
+
});
|
|
886
|
+
}, 'dsh-key-rotation: plugin updater');
|
|
887
|
+
|
|
833
888
|
ctx.effect(() => ctx.on('llm/stream', (options, next) => {
|
|
834
889
|
if (options[MARKER]) return next();
|
|
835
890
|
if (rotationDisabled) return next(); // #199: disabled via webhook action
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Host-side one-click updater for a DSH Web plugin.
|
|
10
|
+
*
|
|
11
|
+
* Mount from apply():
|
|
12
|
+
*
|
|
13
|
+
* registerPluginUpdater(ctx, {
|
|
14
|
+
* endpoint: '/dsh-key-rotation/update',
|
|
15
|
+
* packageName: '@goodandready/dsh-key-rotation',
|
|
16
|
+
* manifestUrl: new URL('../package.json', import.meta.url),
|
|
17
|
+
* })
|
|
18
|
+
*
|
|
19
|
+
* GET returns status. POST is accepted only from the local same-origin UI and
|
|
20
|
+
* installs the exact registry version through the normal DSH CLI.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const UPDATE_HEADER = 'x-dsh-plugin-update'
|
|
24
|
+
const UPDATE_TIMEOUT_MS = 10 * 60_000
|
|
25
|
+
const VERSION_CACHE_MS = 5 * 60_000
|
|
26
|
+
let latestCache
|
|
27
|
+
|
|
28
|
+
function header(request, name) {
|
|
29
|
+
const value = request.headers?.[name]
|
|
30
|
+
return Array.isArray(value) ? value[0] : value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isLoopback(value) {
|
|
34
|
+
const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
|
|
35
|
+
return address === 'localhost' || address === 'localhost.' || address === '::1'
|
|
36
|
+
|| address?.startsWith('127.') === true
|
|
37
|
+
|| address?.startsWith('::ffff:127.') === true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isTrustedUpdateRequest(request) {
|
|
41
|
+
if (header(request, UPDATE_HEADER) !== '1') return false
|
|
42
|
+
if (!isLoopback(request.socket?.remoteAddress)) return false
|
|
43
|
+
const site = header(request, 'sec-fetch-site')
|
|
44
|
+
if (site !== undefined && site !== 'same-origin') return false
|
|
45
|
+
const origin = header(request, 'origin')
|
|
46
|
+
const host = header(request, 'host')
|
|
47
|
+
if (origin === undefined || host === undefined) return false
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(origin)
|
|
50
|
+
return (url.protocol === 'http:' || url.protocol === 'https:')
|
|
51
|
+
&& isLoopback(url.hostname) && url.host === host
|
|
52
|
+
} catch {
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validProfileName(value) {
|
|
58
|
+
return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
|
|
59
|
+
&& !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function profileNameFromArgv(argv) {
|
|
63
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
64
|
+
if (argv[index] === '--profile') return argv[index + 1]
|
|
65
|
+
if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length)
|
|
66
|
+
}
|
|
67
|
+
return argv[2] === 'web' ? 'web' : undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function findDshCliEntry() {
|
|
71
|
+
const value = process.argv[1]
|
|
72
|
+
if (value === undefined || value === '') return undefined
|
|
73
|
+
const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value)
|
|
74
|
+
if (!existsSync(entry)) return undefined
|
|
75
|
+
for (let directory = dirname(entry); ; directory = dirname(directory)) {
|
|
76
|
+
const manifestPath = resolve(directory, 'package.json')
|
|
77
|
+
if (existsSync(manifestPath)) {
|
|
78
|
+
try {
|
|
79
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
80
|
+
const bin = typeof manifest.bin === 'string'
|
|
81
|
+
? manifest.bin
|
|
82
|
+
: typeof manifest.bin === 'object' && manifest.bin !== null
|
|
83
|
+
? manifest.bin.dsh
|
|
84
|
+
: undefined
|
|
85
|
+
if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
|
|
86
|
+
&& !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry
|
|
87
|
+
} catch {
|
|
88
|
+
// Continue searching parent package directories.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const parent = dirname(directory)
|
|
92
|
+
if (parent === directory) return undefined
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function runtime() {
|
|
97
|
+
const profileDir = resolve(process.env.DSH_PROFILE_DIR
|
|
98
|
+
?? resolve(homedir(), '.dsh', 'profiles', 'web'))
|
|
99
|
+
const selected = profileNameFromArgv(process.argv)
|
|
100
|
+
const profileName = validProfileName(selected)
|
|
101
|
+
? selected
|
|
102
|
+
: validProfileName(basename(profileDir)) ? basename(profileDir) : 'web'
|
|
103
|
+
const cliEntry = findDshCliEntry()
|
|
104
|
+
return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseSemver(value) {
|
|
108
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value)
|
|
109
|
+
if (match === null) return undefined
|
|
110
|
+
return {
|
|
111
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
112
|
+
prerelease: match[4]?.split('.') ?? [],
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function comparePrerelease(left, right) {
|
|
117
|
+
if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1
|
|
118
|
+
const length = Math.max(left.length, right.length)
|
|
119
|
+
for (let index = 0; index < length; index += 1) {
|
|
120
|
+
const a = left[index]
|
|
121
|
+
const b = right[index]
|
|
122
|
+
if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1
|
|
123
|
+
if (a === b) continue
|
|
124
|
+
const aNumeric = /^\d+$/.test(a)
|
|
125
|
+
const bNumeric = /^\d+$/.test(b)
|
|
126
|
+
if (aNumeric && bNumeric) {
|
|
127
|
+
const aNumber = BigInt(a)
|
|
128
|
+
const bNumber = BigInt(b)
|
|
129
|
+
if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
|
|
133
|
+
return a > b ? 1 : -1
|
|
134
|
+
}
|
|
135
|
+
return 0
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function isNewerVersion(currentValue, candidateValue) {
|
|
139
|
+
const current = parseSemver(currentValue)
|
|
140
|
+
const candidate = parseSemver(candidateValue)
|
|
141
|
+
if (current === undefined || candidate === undefined) return false
|
|
142
|
+
for (let index = 0; index < 3; index += 1) {
|
|
143
|
+
if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
|
|
144
|
+
}
|
|
145
|
+
return comparePrerelease(candidate.prerelease, current.prerelease) > 0
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function latestVersion(packageName, registry) {
|
|
149
|
+
if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) return latestCache.version
|
|
150
|
+
try {
|
|
151
|
+
const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
|
|
152
|
+
signal: AbortSignal.timeout(8_000),
|
|
153
|
+
})
|
|
154
|
+
if (!response.ok) return undefined
|
|
155
|
+
const value = await response.json()
|
|
156
|
+
if (typeof value.version !== 'string' || value.version === '') return undefined
|
|
157
|
+
latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
|
|
158
|
+
return value.version
|
|
159
|
+
} catch {
|
|
160
|
+
return undefined
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function currentVersion(manifestUrl) {
|
|
165
|
+
const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
|
|
166
|
+
if (typeof value.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.')
|
|
167
|
+
return value.version
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function status(options, target) {
|
|
171
|
+
const current = await currentVersion(options.manifestUrl)
|
|
172
|
+
const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
|
|
173
|
+
return {
|
|
174
|
+
packageName: options.packageName,
|
|
175
|
+
currentVersion: current,
|
|
176
|
+
...(latest === undefined ? {} : { latestVersion: latest }),
|
|
177
|
+
latestCheckFailed: latest === undefined,
|
|
178
|
+
updateAvailable: latest !== undefined && isNewerVersion(current, latest),
|
|
179
|
+
profileName: target.profileName,
|
|
180
|
+
canAutoUpdate: target.cliEntry !== undefined,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function installExact(target, packageSpec, options) {
|
|
185
|
+
if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
|
|
186
|
+
await new Promise((resolvePromise, reject) => {
|
|
187
|
+
const child = spawn(process.execPath, [
|
|
188
|
+
target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
|
|
189
|
+
'--config.minimumReleaseAge=0', packageSpec,
|
|
190
|
+
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
191
|
+
], {
|
|
192
|
+
cwd: target.profileDir,
|
|
193
|
+
windowsHide: true,
|
|
194
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
195
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
196
|
+
})
|
|
197
|
+
let detail = ''
|
|
198
|
+
child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
|
|
199
|
+
child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
|
|
200
|
+
const timer = setTimeout(() => {
|
|
201
|
+
child.kill()
|
|
202
|
+
reject(new Error('Update timed out; use the normal DSH update flow.'))
|
|
203
|
+
}, UPDATE_TIMEOUT_MS)
|
|
204
|
+
child.once('error', error => { clearTimeout(timer); reject(error) })
|
|
205
|
+
child.once('exit', code => {
|
|
206
|
+
clearTimeout(timer)
|
|
207
|
+
if (code === 0) resolvePromise()
|
|
208
|
+
else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function json(response, statusCode, value) {
|
|
214
|
+
response.writeHead(statusCode, {
|
|
215
|
+
'content-type': 'application/json; charset=utf-8',
|
|
216
|
+
'cache-control': 'no-store',
|
|
217
|
+
})
|
|
218
|
+
response.end(JSON.stringify(value))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function registerPluginUpdater(ctx, options) {
|
|
222
|
+
let installing = false
|
|
223
|
+
return ctx.webServer.register({
|
|
224
|
+
kind: 'exact',
|
|
225
|
+
path: options.endpoint,
|
|
226
|
+
handler: async (request, response) => {
|
|
227
|
+
try {
|
|
228
|
+
const target = runtime()
|
|
229
|
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
230
|
+
const payload = await status(options, target)
|
|
231
|
+
response.writeHead(200, {
|
|
232
|
+
'content-type': 'application/json; charset=utf-8',
|
|
233
|
+
'cache-control': 'no-store',
|
|
234
|
+
})
|
|
235
|
+
response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload))
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
if (request.method !== 'POST') {
|
|
239
|
+
response.writeHead(405, { allow: 'GET, HEAD, POST' })
|
|
240
|
+
response.end()
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
if (!isTrustedUpdateRequest(request)) {
|
|
244
|
+
json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
if (installing) {
|
|
248
|
+
json(response, 409, { error: 'This plugin is already updating.' })
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
installing = true
|
|
252
|
+
try {
|
|
253
|
+
const before = await status(options, target)
|
|
254
|
+
if (before.latestVersion === undefined) {
|
|
255
|
+
json(response, 503, { error: 'The latest version is temporarily unavailable.' })
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
if (!before.updateAvailable) {
|
|
259
|
+
json(response, 200, before)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
await installExact(target, `${options.packageName}@${before.latestVersion}`, options)
|
|
263
|
+
json(response, 200, {
|
|
264
|
+
...before,
|
|
265
|
+
updatedVersion: before.latestVersion,
|
|
266
|
+
restartRequired: true,
|
|
267
|
+
})
|
|
268
|
+
} finally {
|
|
269
|
+
installing = false
|
|
270
|
+
}
|
|
271
|
+
} catch (error) {
|
|
272
|
+
ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`)
|
|
273
|
+
json(response, 503, { error: 'Plugin update failed; see server logs.' })
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
})
|
|
277
|
+
}
|