thepopebot 1.2.75-beta.6 → 1.2.75-beta.8

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/bin/cli.js CHANGED
@@ -248,6 +248,7 @@ async function init() {
248
248
  const pkg = {
249
249
  name: dirName,
250
250
  private: true,
251
+ type: 'module',
251
252
  scripts: {
252
253
  setup: 'thepopebot setup',
253
254
  'setup-telegram': 'thepopebot setup-telegram',
@@ -260,7 +261,15 @@ async function init() {
260
261
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
261
262
  console.log(' Created package.json');
262
263
  } else {
263
- console.log(' Skipped package.json (already exists)');
264
+ // Ensure "type": "module" is set for ESM support
265
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
266
+ if (!pkg.type) {
267
+ pkg.type = 'module';
268
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
269
+ console.log(' Added "type": "module" to package.json');
270
+ } else {
271
+ console.log(' Skipped package.json (already exists)');
272
+ }
264
273
  }
265
274
 
266
275
  // Create default skill activation symlinks
@@ -70,7 +70,7 @@ export async function register() {
70
70
  const { startClusterRuntime } = await import('../lib/cluster/runtime.js');
71
71
  startClusterRuntime();
72
72
 
73
- // Start internal maintenance cron (cleanup expired agent job keys, etc.)
73
+ // Start internal maintenance cron (cleanup orphaned agent job keys, etc.)
74
74
  const { startMaintenanceCron } = await import('../lib/maintenance.js');
75
75
  startMaintenanceCron();
76
76
 
package/lib/ai/tools.js CHANGED
@@ -54,9 +54,6 @@ const agentChatCodingTool = tool(
54
54
  const codingAgent = getConfig('CODING_AGENT') || 'claude-code';
55
55
  const containerName = `${codingAgent}-headless-${randomUUID().slice(0, 8)}`;
56
56
 
57
- const { createAgentJobApiKey } = await import('../db/api-keys.js');
58
- const { key: agentJobToken } = createAgentJobApiKey(randomUUID());
59
-
60
57
  const { runHeadlessContainer, tailContainerLogs, waitForContainer, removeContainer } = await import('../tools/docker.js');
61
58
  await runHeadlessContainer({
62
59
  containerName,
@@ -67,7 +64,6 @@ const agentChatCodingTool = tool(
67
64
  taskPrompt: prompt,
68
65
  mode,
69
66
  injectSecrets: true,
70
- agentJobToken,
71
67
  });
72
68
 
73
69
  const streamCallback = runtime.configurable.streamCallback;
@@ -138,12 +138,6 @@ export async function ensureCodeWorkspaceContainer(id) {
138
138
  const chat = getChatByWorkspaceId(id);
139
139
  const injectSecrets = chat?.chatMode === 'agent';
140
140
 
141
- let agentJobToken;
142
- if (injectSecrets) {
143
- const { createAgentJobApiKey } = await import('../db/api-keys.js');
144
- ({ key: agentJobToken } = createAgentJobApiKey(id));
145
- }
146
-
147
141
  try {
148
142
  const { inspectContainer, startContainer, removeContainer, runInteractiveContainer } =
149
143
  await import('../tools/docker.js');
@@ -159,7 +153,6 @@ export async function ensureCodeWorkspaceContainer(id) {
159
153
  featureBranch: workspace.featureBranch,
160
154
  workspaceId: id,
161
155
  injectSecrets,
162
- agentJobToken,
163
156
  });
164
157
  return { status: 'created' };
165
158
  }
@@ -188,7 +181,6 @@ export async function ensureCodeWorkspaceContainer(id) {
188
181
  featureBranch: workspace.featureBranch,
189
182
  workspaceId: id,
190
183
  injectSecrets,
191
- agentJobToken,
192
184
  });
193
185
  return { status: 'created' };
194
186
  } catch (err) {
@@ -217,12 +209,6 @@ export async function startInteractiveMode(id) {
217
209
  const chat = getChatByWorkspaceId(id);
218
210
  const injectSecrets = chat?.chatMode === 'agent';
219
211
 
220
- let agentJobToken;
221
- if (injectSecrets) {
222
- const { createAgentJobApiKey } = await import('../db/api-keys.js');
223
- ({ key: agentJobToken } = createAgentJobApiKey(id));
224
- }
225
-
226
212
  try {
227
213
  const { getConfig } = await import('../config.js');
228
214
  const agent = getConfig('CODING_AGENT') || 'claude-code';
@@ -237,7 +223,6 @@ export async function startInteractiveMode(id) {
237
223
  featureBranch: workspace.featureBranch,
238
224
  workspaceId: id,
239
225
  injectSecrets,
240
- agentJobToken,
241
226
  });
242
227
 
243
228
  updateContainerName(id, containerName);
@@ -5,9 +5,6 @@ import { settings } from './schema.js';
5
5
 
6
6
  const KEY_PREFIX = 'tpb_';
7
7
 
8
- // In-memory cache: array of { id, keyHash } or null (not loaded)
9
- let _cache = null;
10
-
11
8
  /**
12
9
  * Generate a new API key: tpb_ + 64 hex chars (32 random bytes).
13
10
  * @returns {string}
@@ -25,44 +22,6 @@ export function hashApiKey(key) {
25
22
  return createHash('sha256').update(key).digest('hex');
26
23
  }
27
24
 
28
- /**
29
- * Lazy-load all API key hashes into the in-memory cache.
30
- */
31
- function _ensureCache() {
32
- if (_cache !== null) return _cache;
33
-
34
- const db = getDb();
35
- const ONE_HOUR = 60 * 60 * 1000;
36
- const cutoff = Date.now() - ONE_HOUR;
37
-
38
- const rows = db
39
- .select()
40
- .from(settings)
41
- .where(eq(settings.type, 'api_key'))
42
- .all();
43
-
44
- const jobKeyRows = db
45
- .select()
46
- .from(settings)
47
- .where(eq(settings.type, 'agent_job_api_key'))
48
- .all()
49
- .filter(r => r.lastUsedAt !== null ? r.lastUsedAt > cutoff : r.createdAt > cutoff);
50
-
51
- _cache = [...rows, ...jobKeyRows].map((row) => {
52
- const parsed = JSON.parse(row.value);
53
- return { id: row.id, keyHash: parsed.key_hash, type: row.type };
54
- });
55
-
56
- return _cache;
57
- }
58
-
59
- /**
60
- * Clear the in-memory cache (call after create/delete).
61
- */
62
- export function invalidateApiKeyCache() {
63
- _cache = null;
64
- }
65
-
66
25
  /**
67
26
  * Create a new named API key.
68
27
  * @param {string} name - Human-readable name for the key
@@ -89,7 +48,6 @@ export function createApiKeyRecord(name, createdBy) {
89
48
  };
90
49
 
91
50
  db.insert(settings).values(record).run();
92
- invalidateApiKeyCache();
93
51
 
94
52
  return {
95
53
  key,
@@ -143,7 +101,6 @@ export function getApiKey() {
143
101
  export function deleteApiKeyById(id) {
144
102
  const db = getDb();
145
103
  db.delete(settings).where(eq(settings.id, id)).run();
146
- invalidateApiKeyCache();
147
104
  }
148
105
 
149
106
  /**
@@ -152,11 +109,11 @@ export function deleteApiKeyById(id) {
152
109
  export function deleteApiKey() {
153
110
  const db = getDb();
154
111
  db.delete(settings).where(eq(settings.type, 'api_key')).run();
155
- invalidateApiKeyCache();
156
112
  }
157
113
 
158
114
  /**
159
- * Verify a raw API key against all cached hashes.
115
+ * Verify a raw API key against stored hashes.
116
+ * Queries the database directly on each call (SQLite is in-process, no caching needed).
160
117
  * @param {string} rawKey - Raw API key from request header
161
118
  * @returns {object|null} Record if valid, null otherwise
162
119
  */
@@ -164,27 +121,32 @@ export function verifyApiKey(rawKey) {
164
121
  if (!rawKey || !rawKey.startsWith(KEY_PREFIX)) return null;
165
122
 
166
123
  const keyHash = hashApiKey(rawKey);
167
- const cached = _ensureCache();
124
+ const db = getDb();
168
125
 
169
- if (!cached || cached.length === 0) return null;
126
+ const rows = [
127
+ ...db.select().from(settings).where(eq(settings.type, 'api_key')).all(),
128
+ ...db.select().from(settings).where(eq(settings.type, 'agent_job_api_key')).all(),
129
+ ];
130
+
131
+ if (rows.length === 0) return null;
170
132
 
171
133
  const b = Buffer.from(keyHash, 'hex');
172
134
 
173
- for (const entry of cached) {
174
- const a = Buffer.from(entry.keyHash, 'hex');
135
+ for (const row of rows) {
136
+ const parsed = JSON.parse(row.value);
137
+ const a = Buffer.from(parsed.key_hash, 'hex');
175
138
  if (a.length === b.length && timingSafeEqual(a, b)) {
176
- // Update last_used_at column directly (non-blocking)
139
+ // Update last_used_at
177
140
  try {
178
- const db = getDb();
179
141
  const now = Date.now();
180
142
  db.update(settings)
181
143
  .set({ lastUsedAt: now, updatedAt: now })
182
- .where(eq(settings.id, entry.id))
144
+ .where(eq(settings.id, row.id))
183
145
  .run();
184
- } catch {
185
- // Non-fatal: last_used_at is informational
146
+ } catch (err) {
147
+ console.error('[api-keys] Failed to update last_used_at:', err.message);
186
148
  }
187
- return entry;
149
+ return { id: row.id, keyHash: parsed.key_hash, type: row.type };
188
150
  }
189
151
  }
190
152
 
@@ -192,12 +154,12 @@ export function verifyApiKey(rawKey) {
192
154
  }
193
155
 
194
156
  /**
195
- * Create a per-job API key for an agent job container.
196
- * Stored as type 'agent_job_api_key'. Valid for 1 hour since last use.
197
- * @param {string} jobId - Agent job ID (stored as key column for traceability)
157
+ * Create a per-container API key for agent secret access.
158
+ * Stored as type 'agent_job_api_key' with the container name in the key column.
159
+ * @param {string} containerName - Docker container name (used for cleanup)
198
160
  * @returns {{ key: string }} The raw API key to inject into the container
199
161
  */
200
- export function createAgentJobApiKey(jobId) {
162
+ export function createAgentJobApiKey(containerName) {
201
163
  const db = getDb();
202
164
  const key = generateApiKey();
203
165
  const keyHash = hashApiKey(key);
@@ -205,12 +167,11 @@ export function createAgentJobApiKey(jobId) {
205
167
  db.insert(settings).values({
206
168
  id: randomUUID(),
207
169
  type: 'agent_job_api_key',
208
- key: jobId,
170
+ key: containerName,
209
171
  value: JSON.stringify({ key_hash: keyHash }),
210
172
  createdAt: now,
211
173
  updatedAt: now,
212
174
  }).run();
213
- invalidateApiKeyCache();
214
175
  return { key };
215
176
  }
216
177
 
@@ -2,39 +2,55 @@ import cron from 'node-cron';
2
2
  import { eq } from 'drizzle-orm';
3
3
  import { getDb } from './db/index.js';
4
4
  import { settings } from './db/schema.js';
5
- import { invalidateApiKeyCache } from './db/api-keys.js';
6
5
 
7
- const ONE_HOUR = 60 * 60 * 1000;
6
+ const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
8
7
 
9
- function cleanExpiredAgentJobKeys() {
8
+ async function cleanExpiredAgentJobKeys() {
10
9
  try {
11
10
  const db = getDb();
12
- const cutoff = Date.now() - ONE_HOUR;
11
+ const cutoff = Date.now() - TWENTY_FOUR_HOURS;
13
12
  const rows = db
14
- .select({ id: settings.id, lastUsedAt: settings.lastUsedAt, createdAt: settings.createdAt })
13
+ .select({ id: settings.id, key: settings.key, lastUsedAt: settings.lastUsedAt, createdAt: settings.createdAt })
15
14
  .from(settings)
16
15
  .where(eq(settings.type, 'agent_job_api_key'))
17
16
  .all();
18
- const expiredIds = rows
19
- .filter(r => r.lastUsedAt !== null ? r.lastUsedAt < cutoff : r.createdAt < cutoff)
20
- .map(r => r.id);
17
+
18
+ // Filter to candidates not used in the last 24 hours
19
+ const candidates = rows.filter(r =>
20
+ (r.lastUsedAt !== null ? r.lastUsedAt : r.createdAt) < cutoff
21
+ );
22
+
23
+ if (candidates.length === 0) {
24
+ console.log(`[maintenance] No expired agent job keys (${rows.length} active)`);
25
+ return;
26
+ }
27
+
28
+ // Check if the container still exists for each candidate
29
+ const { inspectContainer } = await import('./tools/docker.js');
30
+ const expiredIds = [];
31
+ for (const r of candidates) {
32
+ const info = await inspectContainer(r.key);
33
+ if (!info) {
34
+ expiredIds.push(r.id);
35
+ }
36
+ }
37
+
21
38
  if (expiredIds.length > 0) {
22
39
  for (const id of expiredIds) {
23
40
  db.delete(settings).where(eq(settings.id, id)).run();
24
41
  }
25
- invalidateApiKeyCache();
26
- console.log(`[maintenance] Deleted ${expiredIds.length} expired agent job key(s)`);
42
+ console.log(`[maintenance] Deleted ${expiredIds.length} orphaned agent job key(s)`);
27
43
  } else {
28
- console.log(`[maintenance] No expired agent job keys (${rows.length} active)`);
44
+ console.log(`[maintenance] ${candidates.length} candidate(s) checked, all containers still running`);
29
45
  }
30
46
  } catch (err) {
31
47
  console.error('[maintenance] cleanExpiredAgentJobKeys failed:', err);
32
48
  }
33
49
  }
34
50
 
35
- function runMaintenance() {
51
+ async function runMaintenance() {
36
52
  console.log('[maintenance] Running maintenance...');
37
- cleanExpiredAgentJobKeys();
53
+ await cleanExpiredAgentJobKeys();
38
54
  }
39
55
 
40
56
  export function startMaintenanceCron() {
@@ -3,8 +3,6 @@ import { z } from 'zod';
3
3
  import { githubApi } from './github.js';
4
4
  import { createModel } from '../ai/model.js';
5
5
  import { getConfig } from '../config.js';
6
- import { createAgentJobApiKey } from '../db/api-keys.js';
7
-
8
6
  /**
9
7
  * Generate a short descriptive title for an agent job using the LLM.
10
8
  * Uses structured output to avoid thinking-token leaks with extended-thinking models.
@@ -93,7 +91,6 @@ async function createAgentJob(agentJobDescription, options = {}) {
93
91
 
94
92
  // 6. Launch Docker container locally (fire-and-forget with async cleanup)
95
93
  const repoSlug = `${GH_OWNER}/${GH_REPO}`;
96
- const { key: agentJobToken } = createAgentJobApiKey(agentJobId);
97
94
  launchAgentJobContainer({
98
95
  agentJobId,
99
96
  repo: repoSlug,
@@ -102,7 +99,6 @@ async function createAgentJob(agentJobDescription, options = {}) {
102
99
  description: agentJobDescription,
103
100
  codingAgent: options.agentBackend,
104
101
  llmModel: options.llmModel,
105
- agentJobToken,
106
102
  }).catch(err => {
107
103
  console.error(`[agent-job] Failed to launch container for ${agentJobId}:`, err.message);
108
104
  });
@@ -196,7 +196,7 @@ async function runContainer({ containerName, image, env = [], workingDir, hostCo
196
196
  * @param {boolean} [options.injectSecrets] - Inject agent job secrets into container env
197
197
  * @returns {Promise<{containerId: string, containerName: string}>}
198
198
  */
199
- async function runInteractiveContainer({ containerName, repo, branch, codingAgent, featureBranch, workspaceId, injectSecrets, agentJobToken, continueSession = true }) {
199
+ async function runInteractiveContainer({ containerName, repo, branch, codingAgent, featureBranch, workspaceId, injectSecrets, continueSession = true }) {
200
200
  const agent = codingAgent || getConfig('CODING_AGENT') || 'claude-code';
201
201
  const version = process.env.THEPOPEBOT_VERSION;
202
202
  const image = `stephengpope/thepopebot:coding-agent-${agent}-${version}`;
@@ -234,11 +234,12 @@ async function runInteractiveContainer({ containerName, repo, branch, codingAgen
234
234
  if (jobSecrets.length > 0) {
235
235
  env.push(`AGENT_JOB_SECRETS=${JSON.stringify(Object.fromEntries(jobSecrets.map(s => [s.key, s.value])))}`);
236
236
  }
237
- if (agentJobToken) {
238
- env.push(`AGENT_JOB_TOKEN=${agentJobToken}`);
239
- const appUrl = getConfig('APP_URL');
240
- if (appUrl) env.push(`APP_URL=${appUrl}`);
241
- }
237
+ // Create per-container API key for agent-secrets access
238
+ const { createAgentJobApiKey } = await import('../db/api-keys.js');
239
+ const { key: agentJobToken } = createAgentJobApiKey(containerName);
240
+ env.push(`AGENT_JOB_TOKEN=${agentJobToken}`);
241
+ const appUrl = getConfig('APP_URL');
242
+ if (appUrl) env.push(`APP_URL=${appUrl}`);
242
243
  }
243
244
 
244
245
  const hostConfig = {};
@@ -399,7 +400,7 @@ function buildAgentAuthEnv(agent) {
399
400
  * @param {boolean} [options.injectSecrets] - Inject agent job secrets into container env
400
401
  * @returns {Promise<{containerId: string, containerName: string}>}
401
402
  */
402
- async function runHeadlessContainer({ containerName, repo, branch, featureBranch, workspaceId, taskPrompt, mode = 'plan', codingAgent, systemPrompt, continueSession = true, injectSecrets, agentJobToken }) {
403
+ async function runHeadlessContainer({ containerName, repo, branch, featureBranch, workspaceId, taskPrompt, mode = 'plan', codingAgent, systemPrompt, continueSession = true, injectSecrets }) {
403
404
  const agent = codingAgent || getConfig('CODING_AGENT') || 'claude-code';
404
405
  const version = process.env.THEPOPEBOT_VERSION;
405
406
  const image = `stephengpope/thepopebot:coding-agent-${agent}-${version}`;
@@ -446,11 +447,12 @@ async function runHeadlessContainer({ containerName, repo, branch, featureBranch
446
447
  if (jobSecrets.length > 0) {
447
448
  env.push(`AGENT_JOB_SECRETS=${JSON.stringify(Object.fromEntries(jobSecrets.map(s => [s.key, s.value])))}`);
448
449
  }
449
- if (agentJobToken) {
450
- env.push(`AGENT_JOB_TOKEN=${agentJobToken}`);
451
- const appUrl = getConfig('APP_URL');
452
- if (appUrl) env.push(`APP_URL=${appUrl}`);
453
- }
450
+ // Create per-container API key for agent-secrets access
451
+ const { createAgentJobApiKey } = await import('../db/api-keys.js');
452
+ const { key: agentJobToken } = createAgentJobApiKey(containerName);
453
+ env.push(`AGENT_JOB_TOKEN=${agentJobToken}`);
454
+ const appUrl = getConfig('APP_URL');
455
+ if (appUrl) env.push(`APP_URL=${appUrl}`);
454
456
  }
455
457
 
456
458
  const hostConfig = {};
@@ -861,7 +863,7 @@ async function removeVolume(name) {
861
863
  * @param {string} [options.llmModel] - Model override
862
864
  * @returns {Promise<{containerId: string, containerName: string, volumeName: string}>}
863
865
  */
864
- async function runAgentJobContainer({ agentJobId, repo, branch, title, description, codingAgent, llmModel, agentJobToken }) {
866
+ async function runAgentJobContainer({ agentJobId, repo, branch, title, description, codingAgent, llmModel }) {
865
867
  const agent = codingAgent || getConfig('CODING_AGENT') || 'claude-code';
866
868
  const version = process.env.THEPOPEBOT_VERSION;
867
869
  const image = `stephengpope/thepopebot:coding-agent-${agent}-${version}`;
@@ -899,8 +901,10 @@ async function runAgentJobContainer({ agentJobId, repo, branch, title, descripti
899
901
  const appUrl = getConfig('APP_URL');
900
902
  if (appUrl) env.push(`APP_URL=${appUrl}`);
901
903
 
902
- // Inject per-job API token for agent-secrets skill
903
- if (agentJobToken) env.push(`AGENT_JOB_TOKEN=${agentJobToken}`);
904
+ // Create per-container API key for agent-secrets access
905
+ const { createAgentJobApiKey } = await import('../db/api-keys.js');
906
+ const { key: agentJobToken } = createAgentJobApiKey(containerName);
907
+ env.push(`AGENT_JOB_TOKEN=${agentJobToken}`);
904
908
 
905
909
  // Inject agent job secrets (plain secrets as env vars; oauth types are null — agent must fetch via get)
906
910
  const { getAllAgentJobSecrets } = await import('../db/config.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thepopebot",
3
- "version": "1.2.75-beta.6",
3
+ "version": "1.2.75-beta.8",
4
4
  "type": "module",
5
5
  "description": "Create autonomous AI agents with a two-layer architecture: Next.js Event Handler + Docker Agent.",
6
6
  "bin": {
@@ -18,9 +18,8 @@ if (!cmd || cmd === 'list') {
18
18
  console.log('Available secrets:');
19
19
  keys.forEach(k => {
20
20
  const fetchRequired = secrets[k] === null;
21
- console.log(` - ${k}${fetchRequired ? ' (fetch required: use get)' : ''}`);
21
+ console.log(` - ${k}${fetchRequired ? ' (use agent-job-secrets skill to fetch)' : ''}`);
22
22
  });
23
- console.log('\nTo get a value: agent-job-secrets.js get KEY_NAME');
24
23
  }
25
24
  process.exit(0);
26
25
  }
@@ -31,33 +30,43 @@ if (!apiKey) { console.error('AGENT_JOB_TOKEN not available'); process.exit(1);
31
30
  if (!appUrl) { console.error('APP_URL not available'); process.exit(1); }
32
31
 
33
32
  if (cmd === 'get') {
34
- if (!key) { console.error('Usage: agent-job-secrets.js get KEY_NAME'); process.exit(1); }
35
- const res = await fetch(`${appUrl}/api/get-agent-job-secret?key=${encodeURIComponent(key)}`, {
33
+ if (!key) { console.error('Usage: agent-job-secrets get KEY_NAME'); process.exit(1); }
34
+ const url = `${appUrl}/api/get-agent-job-secret?key=${encodeURIComponent(key)}`;
35
+ const res = await fetch(url, {
36
36
  headers: { 'x-api-key': apiKey },
37
37
  });
38
+ if (!res.ok) {
39
+ const body = await res.text();
40
+ console.error(`GET ${url} → ${res.status} ${body}`);
41
+ process.exit(1);
42
+ }
38
43
  const json = await res.json();
39
- if (!res.ok || json.error) { console.error('Failed:', json.error || res.status); process.exit(1); }
40
44
  console.log(json.value);
41
45
  process.exit(0);
42
46
  }
43
47
 
44
48
  if (cmd === 'set') {
45
49
  if (!key) {
46
- console.error('Usage: agent-job-secrets.js set KEY_NAME [value]');
47
- console.error(' echo "value" | agent-job-secrets.js set KEY_NAME');
50
+ console.error('Usage: agent-job-secrets set KEY_NAME [value]');
51
+ console.error(' echo "value" | agent-job-secrets set KEY_NAME');
48
52
  process.exit(1);
49
53
  }
50
54
  let value = inlineValue;
51
55
  if (value === undefined) {
52
56
  value = readFileSync('/dev/stdin', 'utf8').trim();
53
57
  }
54
- const res = await fetch(`${appUrl}/api/set-agent-job-secret`, {
58
+ const url = `${appUrl}/api/set-agent-job-secret`;
59
+ const res = await fetch(url, {
55
60
  method: 'POST',
56
61
  headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
57
62
  body: JSON.stringify({ key, value }),
58
63
  });
64
+ if (!res.ok) {
65
+ const body = await res.text();
66
+ console.error(`POST ${url} → ${res.status} ${body}`);
67
+ process.exit(1);
68
+ }
59
69
  const json = await res.json();
60
- if (!res.ok || json.error) { console.error('Failed:', json.error || res.status); process.exit(1); }
61
70
  console.log(`Secret "${key}" updated.`);
62
71
  process.exit(0);
63
72
  }