@goodandready/dsh-key-rotation 0.8.31 → 0.8.33
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 +16 -1
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/lib/ops-webhook.js +98 -33
- package/lib/plugin-updater.js +107 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,7 +3,22 @@
|
|
|
3
3
|
All notable changes to `@goodandready/dsh-key-rotation` are documented here.
|
|
4
4
|
User-facing feature notes also appear in README (en is source of truth).
|
|
5
5
|
|
|
6
|
-
## 0.8.
|
|
6
|
+
## 0.8.33 - 2026-09-28
|
|
7
|
+
|
|
8
|
+
### Fixed & Hardened
|
|
9
|
+
- **Telegram Webhook Interactive Actions (#388)**:
|
|
10
|
+
- Route `/dsh-key-rotation/webhook-action` now accepts `X-Telegram-Bot-Api-Secret-Token` via constant-time comparison (`crypto.timingSafeEqual`) alongside `Authorization: Bearer`.
|
|
11
|
+
- Added native Telegram `answerCallbackQuery` webhook response (`{ method: 'answerCallbackQuery', callback_query_id, text }`) dismissing the spinner immediately on button presses.
|
|
12
|
+
- Enforced mandatory explicit HTTPS URL requirement in `setWebhook` helper and transmission of `secret_token`.
|
|
13
|
+
- **Plugin Self-Updater Resiliency & Supply Chain Protection (#390)**:
|
|
14
|
+
- Removed `--config.minimumReleaseAge=0` override from `dsh plugin add` arguments, restoring release age policies and package verification.
|
|
15
|
+
- Implemented live PID detection via `process.kill(pid, 0)` in `package.json.lock`, returning 409 Conflict if installation is already in progress.
|
|
16
|
+
- Added automatic cleanup of orphaned/stale lockfiles left by dead processes.
|
|
17
|
+
- Ensured child process timeout immediately cleans up its own lockfile.
|
|
18
|
+
- **Repository & Worktree Hygiene (#389)**:
|
|
19
|
+
- Pruned obsolete worktrees and deleted old merged feature branches.
|
|
20
|
+
|
|
21
|
+
## 0.8.32 - 2026-09-28
|
|
7
22
|
|
|
8
23
|
### Added & Enhanced
|
|
9
24
|
- **Per-Model Per-Key Token Quotas (#380)**:
|
package/README.md
CHANGED
|
@@ -311,7 +311,7 @@ All management routes require loopback authentication (`127.0.0.1` / `::1`) with
|
|
|
311
311
|
| `/dsh-key-rotation/reset` | `POST` | Instantly resets all cooldowns and restores all keys to `ready`. |
|
|
312
312
|
| `/dsh-key-rotation/test-matrix` | `POST` | Triggers parallel health check across all configured keys and models. |
|
|
313
313
|
| `/dsh-key-rotation/usage-report` | `GET` | Returns aggregated usage metrics in JSON or CSV format (`?format=csv`). |
|
|
314
|
-
| `/dsh-key-rotation/webhook-
|
|
314
|
+
| `/dsh-key-rotation/webhook-action` | `POST` | Receives and executes interactive actions from Telegram/Slack callbacks (`Authorization: Bearer` or `X-Telegram-Bot-Api-Secret-Token`). |
|
|
315
315
|
|
|
316
316
|
---
|
|
317
317
|
|
package/README.ru.md
CHANGED
|
@@ -303,7 +303,7 @@ dsh-key-rotation:
|
|
|
303
303
|
| `/dsh-key-rotation/reset` | `POST` | Мгновенный сброс всех кулдаунов и возврат ключей в статус `ready`. |
|
|
304
304
|
| `/dsh-key-rotation/test-matrix` | `POST` | Запуск параллельного тестирования всей матрицы ключей и моделей. |
|
|
305
305
|
| `/dsh-key-rotation/usage-report` | `GET` | Получение сводного отчета использования в формате JSON или CSV (`?format=csv`). |
|
|
306
|
-
| `/dsh-key-rotation/webhook-
|
|
306
|
+
| `/dsh-key-rotation/webhook-action` | `POST` | Обработка интерактивных действий от кнопок в Telegram/Slack (`Authorization: Bearer` или `X-Telegram-Bot-Api-Secret-Token`). |
|
|
307
307
|
|
|
308
308
|
---
|
|
309
309
|
|
package/lib/ops-webhook.js
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
-
// lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js).
|
|
2
|
+
// lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js, #388 telegram secret-token & answerCallbackQuery).
|
|
3
3
|
import {
|
|
4
4
|
json,
|
|
5
5
|
readJson,
|
|
6
6
|
} from './http-bridge.js';
|
|
7
7
|
import { WEBHOOK_ACTION_PATH } from './ops-paths.js';
|
|
8
8
|
|
|
9
|
+
function constantTimeEqual(a, b) {
|
|
10
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
11
|
+
const aBuf = Buffer.from(a);
|
|
12
|
+
const bBuf = Buffer.from(b);
|
|
13
|
+
return aBuf.length === bBuf.length && crypto.timingSafeEqual(aBuf, bBuf);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getHeader(headers, name) {
|
|
17
|
+
if (!headers || typeof headers !== 'object') return '';
|
|
18
|
+
const target = name.toLowerCase();
|
|
19
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
20
|
+
if (k.toLowerCase() === target) return String(v ?? '');
|
|
21
|
+
}
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
/**
|
|
10
26
|
* @param {object} ctx cordis context
|
|
11
27
|
* @param {object} deps live dependencies from apply()
|
|
@@ -26,27 +42,20 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
26
42
|
const runtime = buildRuntime();
|
|
27
43
|
const expected = runtime.webhookActionToken;
|
|
28
44
|
if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
45
|
+
|
|
46
|
+
const authHeader = getHeader(req.headers, 'authorization');
|
|
47
|
+
const secretHeader = getHeader(req.headers, 'x-telegram-bot-api-secret-token');
|
|
48
|
+
const bearerMatch = constantTimeEqual(authHeader, `Bearer ${expected}`);
|
|
49
|
+
const secretMatch = constantTimeEqual(secretHeader, expected);
|
|
50
|
+
if (!bearerMatch && !secretMatch) {
|
|
51
|
+
json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
let body;
|
|
36
56
|
try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (!action && typeof body?.data === 'string') {
|
|
40
|
-
try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
|
|
41
|
-
}
|
|
42
|
-
if (!action && typeof body?.callback_data === 'string') {
|
|
43
|
-
try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
|
|
44
|
-
}
|
|
45
|
-
// #222: Telegram update envelope {update_id, callback_query:{data}}
|
|
46
|
-
if (!action && typeof body?.callback_query?.data === 'string') {
|
|
47
|
-
try { action = String(JSON.parse(body.callback_query.data)?.id ?? ''); } catch { action = ''; }
|
|
48
|
-
}
|
|
49
|
-
// #222: Telegram setWebhook registration helper
|
|
57
|
+
|
|
58
|
+
// #222 / #388: Telegram setWebhook registration helper
|
|
50
59
|
if (typeof body?.setWebhook === 'object' && body.setWebhook) {
|
|
51
60
|
const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
|
|
52
61
|
if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
|
|
@@ -55,38 +64,94 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
55
64
|
json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: invalid telegram bot token format' } });
|
|
56
65
|
return;
|
|
57
66
|
}
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
const rawUrl = typeof body.setWebhook.url === 'string' ? body.setWebhook.url.trim() : '';
|
|
68
|
+
if (!rawUrl || !rawUrl.startsWith('https://')) {
|
|
69
|
+
json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: explicit public https url required in setWebhook.url' } });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
60
72
|
try {
|
|
61
73
|
const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
|
|
62
|
-
method: 'POST',
|
|
63
|
-
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'content-type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
url: rawUrl,
|
|
78
|
+
secret_token: expected,
|
|
79
|
+
allowed_updates: ['callback_query'],
|
|
80
|
+
}),
|
|
64
81
|
signal: AbortSignal.timeout(10000),
|
|
65
82
|
});
|
|
66
83
|
const hookData = await hookRes.json().catch(() => ({}));
|
|
67
|
-
json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
|
|
84
|
+
json(res, 200, { ok: hookRes.ok, url: rawUrl, secret_token_set: true, telegram: hookData });
|
|
68
85
|
} catch (e) {
|
|
69
86
|
json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
|
|
70
87
|
}
|
|
71
88
|
return;
|
|
72
89
|
}
|
|
73
|
-
|
|
90
|
+
|
|
91
|
+
const callbackQueryId = typeof body?.callback_query?.id === 'string'
|
|
92
|
+
? body.callback_query.id
|
|
93
|
+
: (typeof body?.callback_query?.id === 'number' ? String(body.callback_query.id) : '');
|
|
94
|
+
const botToken = typeof body?.botToken === 'string' ? body.botToken : '';
|
|
95
|
+
|
|
96
|
+
const respond = async (statusCode, payload, answerText) => {
|
|
97
|
+
if (callbackQueryId) {
|
|
98
|
+
payload.method = 'answerCallbackQuery';
|
|
99
|
+
payload.callback_query_id = callbackQueryId;
|
|
100
|
+
payload.text = answerText || payload.error?.message || 'OK';
|
|
101
|
+
if (botToken) {
|
|
102
|
+
try {
|
|
103
|
+
await fetch(`https://api.telegram.org/bot${botToken}/answerCallbackQuery`, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'content-type': 'application/json' },
|
|
106
|
+
body: JSON.stringify({ callback_query_id: callbackQueryId, text: payload.text }),
|
|
107
|
+
signal: AbortSignal.timeout(5000),
|
|
108
|
+
});
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
json(res, statusCode, payload);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
|
|
116
|
+
let action = typeof body?.action === 'string' ? body.action : '';
|
|
117
|
+
if (!action && typeof body?.data === 'string') {
|
|
118
|
+
try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
|
|
119
|
+
}
|
|
120
|
+
if (!action && typeof body?.callback_data === 'string') {
|
|
121
|
+
try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
|
|
122
|
+
}
|
|
123
|
+
// #222 / #388: Telegram update envelope {update_id, callback_query:{id, data}}
|
|
124
|
+
if (!action && typeof body?.callback_query?.data === 'string') {
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(body.callback_query.data);
|
|
127
|
+
action = typeof parsed?.id === 'string' ? parsed.id : String(parsed ?? '');
|
|
128
|
+
} catch {
|
|
129
|
+
action = body.callback_query.data;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!action) {
|
|
133
|
+
await respond(400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }, 'No action in payload');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
74
136
|
const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
|
|
75
137
|
try {
|
|
76
138
|
if (action === 'disable-rotation') {
|
|
77
139
|
setRotationDisabled(true);
|
|
78
140
|
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.('[dsh-key-rotation] rotation DISABLED via webhook action');
|
|
79
|
-
|
|
141
|
+
await respond(200, { ok: true, action }, 'Rotation disabled');
|
|
80
142
|
return;
|
|
81
143
|
}
|
|
82
144
|
if (action === 'enable-rotation') {
|
|
83
145
|
setRotationDisabled(false);
|
|
84
|
-
|
|
146
|
+
await respond(200, { ok: true, action }, 'Rotation enabled');
|
|
85
147
|
return;
|
|
86
148
|
}
|
|
87
149
|
if (action.startsWith('pause-') || action.startsWith('reset-')) {
|
|
88
150
|
const st = poolState.get(provider);
|
|
89
|
-
if (!st) {
|
|
151
|
+
if (!st) {
|
|
152
|
+
await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
90
155
|
if (action.startsWith('pause-')) {
|
|
91
156
|
const until = Date.now() + 3600000; // 1h pause
|
|
92
157
|
for (const ref of (st.failedUntil ? [...st.failedUntil.keys()] : [])) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
|
|
@@ -96,7 +161,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
96
161
|
for (const ref of p.refs) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
|
|
97
162
|
}
|
|
98
163
|
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
|
|
99
|
-
|
|
164
|
+
await respond(200, { ok: true, action, provider, until }, `Pool ${provider} paused 1h`);
|
|
100
165
|
return;
|
|
101
166
|
}
|
|
102
167
|
const cleared = st.failedUntil.size;
|
|
@@ -114,12 +179,12 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
114
179
|
else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
|
|
115
180
|
}
|
|
116
181
|
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} RESET via webhook action (circuitReset=${circuitReset})`);
|
|
117
|
-
|
|
182
|
+
await respond(200, { ok: true, action, provider, cleared, circuitReset }, `Pool ${provider} reset`);
|
|
118
183
|
return;
|
|
119
184
|
}
|
|
120
|
-
|
|
185
|
+
await respond(400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } }, `Unknown action: ${action}`);
|
|
121
186
|
} catch (e) {
|
|
122
|
-
|
|
187
|
+
await respond(500, { error: { code: 'action-failed', message: String(e?.message ?? e) } }, 'Action failed');
|
|
123
188
|
}
|
|
124
189
|
},
|
|
125
190
|
}), 'dsh-key-rotation: webhook-action');
|
package/lib/plugin-updater.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
3
3
|
import { readFile } from 'node:fs/promises'
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import { basename, dirname, isAbsolute, resolve } from 'node:path'
|
|
@@ -60,17 +60,17 @@ function validProfileName(value) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function profileNameFromArgv(argv) {
|
|
63
|
-
for (let index =
|
|
64
|
-
|
|
65
|
-
if (
|
|
63
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
64
|
+
const item = argv[index]
|
|
65
|
+
if (item === '--profile' || item === '-p') return argv[index + 1]
|
|
66
|
+
if (typeof item === 'string' && item.startsWith('--profile=')) return item.slice('--profile='.length)
|
|
66
67
|
}
|
|
67
|
-
return
|
|
68
|
+
return undefined
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
function findDshCliEntry() {
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
73
|
-
const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value)
|
|
72
|
+
const entry = process.argv[1]
|
|
73
|
+
if (typeof entry !== 'string' || entry === '') return undefined
|
|
74
74
|
if (!existsSync(entry)) return undefined
|
|
75
75
|
for (let directory = dirname(entry); ; directory = dirname(directory)) {
|
|
76
76
|
const manifestPath = resolve(directory, 'package.json')
|
|
@@ -187,13 +187,87 @@ export function isValidPackageSpec(packageSpec) {
|
|
|
187
187
|
return typeof packageSpec === 'string' && PACKAGE_SPEC_PATTERN.test(packageSpec)
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
|
|
190
|
+
export function readLockPid(lockPath) {
|
|
191
|
+
try {
|
|
192
|
+
if (!existsSync(lockPath)) return undefined
|
|
193
|
+
const stat = statSync(lockPath)
|
|
194
|
+
if (stat.isDirectory()) return undefined
|
|
195
|
+
const content = readFileSync(lockPath, 'utf8').trim()
|
|
196
|
+
if (!content) return undefined
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(content)
|
|
199
|
+
if (parsed && typeof parsed.pid === 'number') return parsed.pid
|
|
200
|
+
} catch {}
|
|
201
|
+
const num = parseInt(content, 10)
|
|
202
|
+
if (!isNaN(num) && num > 0) return num
|
|
203
|
+
const match = /\b(\d{1,8})\b/.exec(content)
|
|
204
|
+
if (match) return Number(match[1])
|
|
205
|
+
return undefined
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function isPidAlive(pid) {
|
|
212
|
+
if (typeof pid !== 'number' || isNaN(pid) || pid <= 0) return false
|
|
213
|
+
try {
|
|
214
|
+
process.kill(pid, 0)
|
|
215
|
+
return true
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return err?.code === 'EPERM'
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function checkAndCleanLock(profileDir, childPid) {
|
|
222
|
+
const lockPath = resolve(profileDir, 'package.json.lock')
|
|
223
|
+
if (!existsSync(lockPath)) return { locked: false }
|
|
224
|
+
const pid = readLockPid(lockPath)
|
|
225
|
+
if (pid !== undefined) {
|
|
226
|
+
if (isPidAlive(pid)) {
|
|
227
|
+
if (childPid !== undefined && pid === childPid) {
|
|
228
|
+
try {
|
|
229
|
+
rmSync(lockPath, { recursive: true, force: true })
|
|
230
|
+
return { locked: false, cleaned: true }
|
|
231
|
+
} catch {
|
|
232
|
+
return { locked: true, pid }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { locked: true, pid }
|
|
236
|
+
}
|
|
237
|
+
// PID is dead, remove stale lock
|
|
238
|
+
try {
|
|
239
|
+
rmSync(lockPath, { recursive: true, force: true })
|
|
240
|
+
return { locked: false, cleaned: true }
|
|
241
|
+
} catch {
|
|
242
|
+
return { locked: true, pid }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Unknown or empty lockfile format
|
|
246
|
+
if (childPid !== undefined) {
|
|
247
|
+
try {
|
|
248
|
+
rmSync(lockPath, { recursive: true, force: true })
|
|
249
|
+
return { locked: false, cleaned: true }
|
|
250
|
+
} catch {}
|
|
251
|
+
}
|
|
252
|
+
return { locked: true }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function installExact(target, packageSpec, options = {}) {
|
|
191
256
|
if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
|
|
192
257
|
if (!isValidPackageSpec(packageSpec)) throw new Error(`Invalid package spec: ${packageSpec}`)
|
|
258
|
+
|
|
259
|
+
const lockStatus = checkAndCleanLock(target.profileDir)
|
|
260
|
+
if (lockStatus.locked) {
|
|
261
|
+
const error = new Error('Another plugin installation is currently in progress.')
|
|
262
|
+
error.status = 409
|
|
263
|
+
error.code = 'ELOCKED'
|
|
264
|
+
throw error
|
|
265
|
+
}
|
|
266
|
+
|
|
193
267
|
await new Promise((resolvePromise, reject) => {
|
|
194
268
|
const child = spawn(process.execPath, [
|
|
195
269
|
target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
|
|
196
|
-
|
|
270
|
+
packageSpec,
|
|
197
271
|
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
198
272
|
], {
|
|
199
273
|
cwd: target.profileDir,
|
|
@@ -204,13 +278,24 @@ async function installExact(target, packageSpec, options) {
|
|
|
204
278
|
let detail = ''
|
|
205
279
|
child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
|
|
206
280
|
child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
|
|
281
|
+
let timedOut = false
|
|
282
|
+
const timeoutMs = options.timeoutMs ?? UPDATE_TIMEOUT_MS
|
|
207
283
|
const timer = setTimeout(() => {
|
|
208
|
-
|
|
284
|
+
timedOut = true
|
|
285
|
+
try { child.kill('SIGTERM') } catch {}
|
|
286
|
+
setTimeout(() => {
|
|
287
|
+
try { child.kill('SIGKILL') } catch {}
|
|
288
|
+
checkAndCleanLock(target.profileDir, child.pid)
|
|
289
|
+
}, 500).unref?.()
|
|
209
290
|
reject(new Error('Update timed out; use the normal DSH update flow.'))
|
|
210
|
-
},
|
|
291
|
+
}, timeoutMs)
|
|
211
292
|
child.once('error', error => { clearTimeout(timer); reject(error) })
|
|
212
|
-
child.once('exit', code => {
|
|
293
|
+
child.once('exit', (code, signal) => {
|
|
213
294
|
clearTimeout(timer)
|
|
295
|
+
if (timedOut) {
|
|
296
|
+
checkAndCleanLock(target.profileDir, child.pid)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
214
299
|
if (code === 0) resolvePromise()
|
|
215
300
|
else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
|
|
216
301
|
})
|
|
@@ -255,6 +340,11 @@ export function registerPluginUpdater(ctx, options) {
|
|
|
255
340
|
json(response, 409, { error: 'This plugin is already updating.' })
|
|
256
341
|
return
|
|
257
342
|
}
|
|
343
|
+
const profileLock = checkAndCleanLock(target.profileDir)
|
|
344
|
+
if (profileLock.locked) {
|
|
345
|
+
json(response, 409, { error: 'Another plugin installation is currently in progress in this profile.' })
|
|
346
|
+
return
|
|
347
|
+
}
|
|
258
348
|
installing = true
|
|
259
349
|
try {
|
|
260
350
|
const before = await status(options, target)
|
|
@@ -276,6 +366,10 @@ export function registerPluginUpdater(ctx, options) {
|
|
|
276
366
|
installing = false
|
|
277
367
|
}
|
|
278
368
|
} catch (error) {
|
|
369
|
+
if (error?.status === 409 || error?.code === 'ELOCKED') {
|
|
370
|
+
json(response, 409, { error: error.message || 'Another plugin installation is currently in progress.' })
|
|
371
|
+
return
|
|
372
|
+
}
|
|
279
373
|
ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`)
|
|
280
374
|
json(response, 503, { error: 'Plugin update failed; see server logs.' })
|
|
281
375
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.33",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
6
6
|
"keywords": [
|