@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/lib/clock.js CHANGED
@@ -20,5 +20,3 @@ export function nowMono() {
20
20
  return performance.timeOrigin + performance.now();
21
21
  }
22
22
 
23
- /** Default clock injectable into pure helpers. */
24
- export const defaultClock = { nowWall, nowMono };
@@ -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 };
@@ -61,6 +61,3 @@ export function classifyFailure(failureOrPayload) {
61
61
  }
62
62
 
63
63
  /** True when classification says switch to next key. */
64
- export function shouldSwitch(failureOrPayload) {
65
- return classifyFailure(failureOrPayload).action === 'switch';
66
- }
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
+ }
@@ -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 timer = setInterval(() => { try { schedulePersist(); } catch (_) {} }, 15000);
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, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds, expectedClones,
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 list = pool.weightedRefs ?? pool.refs;
688
- const start = pool.state.pointer ?? 0;
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
+ }