oc-codex-multi-auth 6.23.0 → 6.25.0
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/LICENSE +21 -21
- package/README.md +795 -729
- package/assets/icon.svg +7 -7
- package/assets/opencode-logo-ornate-dark.svg +18 -18
- package/assets/readme-hero.svg +31 -31
- package/config/README.md +197 -197
- package/config/minimal-opencode.json +14 -14
- package/config/opencode-legacy.json +1346 -1346
- package/config/opencode-modern.json +466 -466
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +61 -57
- package/dist/index.js.map +1 -1
- package/dist/lib/accounts/persistence.d.ts +6 -6
- package/dist/lib/accounts/persistence.d.ts.map +1 -1
- package/dist/lib/accounts/persistence.js +60 -6
- package/dist/lib/accounts/persistence.js.map +1 -1
- package/dist/lib/accounts/recovery.d.ts +5 -1
- package/dist/lib/accounts/recovery.d.ts.map +1 -1
- package/dist/lib/accounts/recovery.js +26 -1
- package/dist/lib/accounts/recovery.js.map +1 -1
- package/dist/lib/accounts/state.d.ts +18 -0
- package/dist/lib/accounts/state.d.ts.map +1 -1
- package/dist/lib/accounts/state.js +26 -2
- package/dist/lib/accounts/state.js.map +1 -1
- package/dist/lib/accounts.d.ts +2 -0
- package/dist/lib/accounts.d.ts.map +1 -1
- package/dist/lib/accounts.js +7 -1
- package/dist/lib/accounts.js.map +1 -1
- package/dist/lib/auth/login-runner.d.ts.map +1 -1
- package/dist/lib/auth/login-runner.js +8 -0
- package/dist/lib/auth/login-runner.js.map +1 -1
- package/dist/lib/auth/plan-tier.d.ts.map +1 -1
- package/dist/lib/auth/plan-tier.js +2 -1
- package/dist/lib/auth/plan-tier.js.map +1 -1
- package/dist/lib/codex-usage.d.ts +84 -0
- package/dist/lib/codex-usage.d.ts.map +1 -1
- package/dist/lib/codex-usage.js +186 -5
- package/dist/lib/codex-usage.js.map +1 -1
- package/dist/lib/config.d.ts +16 -3
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +14 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/context-overflow.js +10 -10
- package/dist/lib/desktop-notifications.js +6 -6
- package/dist/lib/oauth-success.js +202 -202
- package/dist/lib/opencode-v2-provider.d.ts +2 -0
- package/dist/lib/opencode-v2-provider.d.ts.map +1 -0
- package/dist/lib/opencode-v2-provider.js +4 -0
- package/dist/lib/opencode-v2-provider.js.map +1 -0
- package/dist/lib/opencode-v2-rpc.d.ts +31 -0
- package/dist/lib/opencode-v2-rpc.d.ts.map +1 -0
- package/dist/lib/opencode-v2-rpc.js +17 -0
- package/dist/lib/opencode-v2-rpc.js.map +1 -0
- package/dist/lib/opencode-v2-status.d.ts +18 -0
- package/dist/lib/opencode-v2-status.d.ts.map +1 -0
- package/dist/lib/opencode-v2-status.js +98 -0
- package/dist/lib/opencode-v2-status.js.map +1 -0
- package/dist/lib/opencode-v2-tui.d.ts +4 -0
- package/dist/lib/opencode-v2-tui.d.ts.map +1 -0
- package/dist/lib/opencode-v2-tui.js +112 -0
- package/dist/lib/opencode-v2-tui.js.map +1 -0
- package/dist/lib/opencode-v2.d.ts +11 -0
- package/dist/lib/opencode-v2.d.ts.map +1 -0
- package/dist/lib/opencode-v2.js +235 -0
- package/dist/lib/opencode-v2.js.map +1 -0
- package/dist/lib/prompts/codex-opencode-bridge.js +67 -67
- package/dist/lib/prompts/codex.d.ts.map +1 -1
- package/dist/lib/prompts/codex.js +79 -75
- package/dist/lib/prompts/codex.js.map +1 -1
- package/dist/lib/quota-capacity.d.ts +4 -0
- package/dist/lib/quota-capacity.d.ts.map +1 -0
- package/dist/lib/quota-capacity.js +45 -0
- package/dist/lib/quota-capacity.js.map +1 -0
- package/dist/lib/quota-overview.d.ts +13 -27
- package/dist/lib/quota-overview.d.ts.map +1 -1
- package/dist/lib/quota-overview.js +59 -99
- package/dist/lib/quota-overview.js.map +1 -1
- package/dist/lib/quota-recovery.d.ts +5 -0
- package/dist/lib/quota-recovery.d.ts.map +1 -0
- package/dist/lib/quota-recovery.js +66 -0
- package/dist/lib/quota-recovery.js.map +1 -0
- package/dist/lib/request/fetch-helpers.d.ts +1 -1
- package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
- package/dist/lib/request/fetch-helpers.js +1 -1
- package/dist/lib/request/fetch-helpers.js.map +1 -1
- package/dist/lib/schemas.d.ts +14 -1
- package/dist/lib/schemas.d.ts.map +1 -1
- package/dist/lib/schemas.js +7 -2
- package/dist/lib/schemas.js.map +1 -1
- package/dist/lib/storage/load-save.d.ts.map +1 -1
- package/dist/lib/storage/load-save.js +22 -20
- package/dist/lib/storage/load-save.js.map +1 -1
- package/dist/lib/storage/state.d.ts +8 -0
- package/dist/lib/storage/state.d.ts.map +1 -1
- package/dist/lib/storage/state.js +47 -20
- package/dist/lib/storage/state.js.map +1 -1
- package/dist/lib/tools/codex-limits.d.ts.map +1 -1
- package/dist/lib/tools/codex-limits.js +49 -3
- package/dist/lib/tools/codex-limits.js.map +1 -1
- package/dist/lib/tui-quota-cache.d.ts +8 -0
- package/dist/lib/tui-quota-cache.d.ts.map +1 -1
- package/dist/lib/tui-quota-cache.js +4 -1
- package/dist/lib/tui-quota-cache.js.map +1 -1
- package/dist/lib/tui-quota-overview.d.ts.map +1 -1
- package/dist/lib/tui-quota-overview.js +15 -2
- package/dist/lib/tui-quota-overview.js.map +1 -1
- package/dist/lib/tui-status-slot.d.ts +3 -0
- package/dist/lib/tui-status-slot.d.ts.map +1 -0
- package/dist/lib/tui-status-slot.js +27 -0
- package/dist/lib/tui-status-slot.js.map +1 -0
- package/dist/lib/tui-status.d.ts +1 -0
- package/dist/lib/tui-status.d.ts.map +1 -1
- package/dist/lib/tui-status.js +1 -0
- package/dist/lib/tui-status.js.map +1 -1
- package/dist/tui.d.ts +10 -3
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +22 -1
- package/dist/tui.js.map +1 -1
- package/package.json +157 -155
- package/scripts/audit-dev-allowlist.js +114 -114
- package/scripts/clean-dist.js +27 -27
- package/scripts/copy-oauth-success.js +47 -47
- package/scripts/install-oc-codex-multi-auth-core.js +2661 -2016
- package/scripts/install-oc-codex-multi-auth.js +37 -37
- package/scripts/test-all-models.sh +260 -260
- package/scripts/validate-model-map.sh +97 -97
|
@@ -1,2016 +1,2661 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
-
import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
-
|
|
8
|
-
const PACKAGE_NAME = "oc-codex-multi-auth";
|
|
9
|
-
const LEGACY_PACKAGE_NAMES = ["oc-chatgpt-multi-auth"];
|
|
10
|
-
const ORIGIN_HISTORY_FILE_NAME = "oc-codex-multi-auth-origin.json";
|
|
11
|
-
const WINDOWS_RENAME_RETRY_ATTEMPTS = 5;
|
|
12
|
-
const WINDOWS_RENAME_RETRY_BASE_DELAY_MS = 10;
|
|
13
|
-
const STALE_MANAGED_MODEL_KEYS = new Set([
|
|
14
|
-
"gpt-5.2",
|
|
15
|
-
"gpt-5.3-codex",
|
|
16
|
-
"gpt-5.4",
|
|
17
|
-
// Retired per OpenAI's docs and dropped from the templates: gpt-5.4-mini left
|
|
18
|
-
// Codex (ChatGPT sign-in) on 2026-08-31; gpt-5-codex and the gpt-5.1-codex
|
|
19
|
-
// family were shut down on 2026-07-23 (developers.openai.com/api/docs/deprecations).
|
|
20
|
-
"gpt-5.4-mini",
|
|
21
|
-
"gpt-5-codex",
|
|
22
|
-
"gpt-5.1-codex",
|
|
23
|
-
"gpt-5.1-codex-max",
|
|
24
|
-
"gpt-5.1-codex-mini",
|
|
25
|
-
...["none", "low", "medium", "high", "xhigh"].map((e) => `gpt-5.4-mini-${e}`),
|
|
26
|
-
...["low", "medium", "high"].map((e) => `gpt-5-codex-${e}`),
|
|
27
|
-
...["low", "medium", "high"].map((e) => `gpt-5.1-codex-${e}`),
|
|
28
|
-
...["low", "medium", "high", "xhigh"].map((e) => `gpt-5.1-codex-max-${e}`),
|
|
29
|
-
...["medium", "high"].map((e) => `gpt-5.1-codex-mini-${e}`),
|
|
30
|
-
]);
|
|
31
|
-
const STANDALONE_COMMANDS = new Set(["doctor", "status", "list", "limits", "dashboard", "health", "diag", "warm"]);
|
|
32
|
-
const INSTALLER_COMMANDS = new Set(["install"]);
|
|
33
|
-
const UPDATE_COMMANDS = new Set(["update"]);
|
|
34
|
-
|
|
35
|
-
function splitCommandArgv(argv) {
|
|
36
|
-
const [first, ...rest] = argv;
|
|
37
|
-
if (!first) return { kind: "install", argv };
|
|
38
|
-
if (INSTALLER_COMMANDS.has(first)) return { kind: "install", argv: rest };
|
|
39
|
-
if (UPDATE_COMMANDS.has(first)) return { kind: "update", argv: rest };
|
|
40
|
-
if (STANDALONE_COMMANDS.has(first)) return { kind: "standalone", command: first, argv: rest };
|
|
41
|
-
if (first.startsWith("-")) return { kind: "install", argv };
|
|
42
|
-
return { kind: "unknown", command: first, argv: rest };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function parseStandaloneArgs(argv) {
|
|
46
|
-
const options = {
|
|
47
|
-
json: false,
|
|
48
|
-
includeSensitive: false,
|
|
49
|
-
deep: false,
|
|
50
|
-
fix: false,
|
|
51
|
-
tag: undefined,
|
|
52
|
-
configPath: undefined,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
else if (arg === "--
|
|
62
|
-
else if (arg
|
|
63
|
-
else if (arg
|
|
64
|
-
else if (arg
|
|
65
|
-
else if (arg === "--
|
|
66
|
-
else
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
"
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
);
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
)
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
function
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
function
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
};
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
const
|
|
639
|
-
if (
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
if (
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
const
|
|
719
|
-
|
|
720
|
-
function
|
|
721
|
-
if (
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
function
|
|
736
|
-
|
|
737
|
-
if (
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
const
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
})
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
account
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
command: "warm",
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
return;
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
};
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
(
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
//
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
//
|
|
1419
|
-
//
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
//
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
const
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
.
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
if (
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
const
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
if (
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
const
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
if (
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
const PACKAGE_NAME = "oc-codex-multi-auth";
|
|
9
|
+
const LEGACY_PACKAGE_NAMES = ["oc-chatgpt-multi-auth"];
|
|
10
|
+
const ORIGIN_HISTORY_FILE_NAME = "oc-codex-multi-auth-origin.json";
|
|
11
|
+
const WINDOWS_RENAME_RETRY_ATTEMPTS = 5;
|
|
12
|
+
const WINDOWS_RENAME_RETRY_BASE_DELAY_MS = 10;
|
|
13
|
+
const STALE_MANAGED_MODEL_KEYS = new Set([
|
|
14
|
+
"gpt-5.2",
|
|
15
|
+
"gpt-5.3-codex",
|
|
16
|
+
"gpt-5.4",
|
|
17
|
+
// Retired per OpenAI's docs and dropped from the templates: gpt-5.4-mini left
|
|
18
|
+
// Codex (ChatGPT sign-in) on 2026-08-31; gpt-5-codex and the gpt-5.1-codex
|
|
19
|
+
// family were shut down on 2026-07-23 (developers.openai.com/api/docs/deprecations).
|
|
20
|
+
"gpt-5.4-mini",
|
|
21
|
+
"gpt-5-codex",
|
|
22
|
+
"gpt-5.1-codex",
|
|
23
|
+
"gpt-5.1-codex-max",
|
|
24
|
+
"gpt-5.1-codex-mini",
|
|
25
|
+
...["none", "low", "medium", "high", "xhigh"].map((e) => `gpt-5.4-mini-${e}`),
|
|
26
|
+
...["low", "medium", "high"].map((e) => `gpt-5-codex-${e}`),
|
|
27
|
+
...["low", "medium", "high"].map((e) => `gpt-5.1-codex-${e}`),
|
|
28
|
+
...["low", "medium", "high", "xhigh"].map((e) => `gpt-5.1-codex-max-${e}`),
|
|
29
|
+
...["medium", "high"].map((e) => `gpt-5.1-codex-mini-${e}`),
|
|
30
|
+
]);
|
|
31
|
+
const STANDALONE_COMMANDS = new Set(["doctor", "status", "list", "limits", "dashboard", "health", "diag", "warm"]);
|
|
32
|
+
const INSTALLER_COMMANDS = new Set(["install"]);
|
|
33
|
+
const UPDATE_COMMANDS = new Set(["update"]);
|
|
34
|
+
|
|
35
|
+
function splitCommandArgv(argv) {
|
|
36
|
+
const [first, ...rest] = argv;
|
|
37
|
+
if (!first) return { kind: "install", argv };
|
|
38
|
+
if (INSTALLER_COMMANDS.has(first)) return { kind: "install", argv: rest };
|
|
39
|
+
if (UPDATE_COMMANDS.has(first)) return { kind: "update", argv: rest };
|
|
40
|
+
if (STANDALONE_COMMANDS.has(first)) return { kind: "standalone", command: first, argv: rest };
|
|
41
|
+
if (first.startsWith("-")) return { kind: "install", argv };
|
|
42
|
+
return { kind: "unknown", command: first, argv: rest };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseStandaloneArgs(argv) {
|
|
46
|
+
const options = {
|
|
47
|
+
json: false,
|
|
48
|
+
includeSensitive: false,
|
|
49
|
+
deep: false,
|
|
50
|
+
fix: false,
|
|
51
|
+
tag: undefined,
|
|
52
|
+
configPath: undefined,
|
|
53
|
+
sort: undefined,
|
|
54
|
+
direction: undefined,
|
|
55
|
+
refresh: false,
|
|
56
|
+
help: false,
|
|
57
|
+
};
|
|
58
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
59
|
+
const arg = argv[index];
|
|
60
|
+
if (arg === "--json") options.json = true;
|
|
61
|
+
else if (arg === "--refresh") options.refresh = true;
|
|
62
|
+
else if (arg === "--sort") options.sort = parseLimitsSortField(argv[++index]);
|
|
63
|
+
else if (arg.startsWith("--sort=")) options.sort = parseLimitsSortField(arg.slice("--sort=".length));
|
|
64
|
+
else if (arg === "--asc") options.direction = "asc";
|
|
65
|
+
else if (arg === "--desc") options.direction = "desc";
|
|
66
|
+
else if (arg === "--include-sensitive") options.includeSensitive = true;
|
|
67
|
+
else if (arg === "--deep") options.deep = true;
|
|
68
|
+
else if (arg === "--fix") options.fix = true;
|
|
69
|
+
else if (arg === "--tag") options.tag = argv[++index];
|
|
70
|
+
else if (arg.startsWith("--tag=")) options.tag = arg.slice("--tag=".length);
|
|
71
|
+
else if (arg === "--config-path") options.configPath = argv[++index];
|
|
72
|
+
else if (arg.startsWith("--config-path=")) options.configPath = arg.slice("--config-path=".length);
|
|
73
|
+
else if (arg === "--help" || arg === "-h") options.help = true;
|
|
74
|
+
else throw new Error(`Unknown option for standalone command: ${arg}`);
|
|
75
|
+
}
|
|
76
|
+
return options;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const LIMITS_SORT_ALIASES = new Map([
|
|
80
|
+
["account", "account"],
|
|
81
|
+
["number", "account"],
|
|
82
|
+
["usage", "usage"],
|
|
83
|
+
["used", "usage"],
|
|
84
|
+
["reset", "reset"],
|
|
85
|
+
["renewal", "reset"],
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
function parseLimitsSortField(value) {
|
|
89
|
+
const field = LIMITS_SORT_ALIASES.get(String(value ?? "").trim().toLowerCase());
|
|
90
|
+
if (!field) {
|
|
91
|
+
throw new Error(`Unknown --sort value: ${value ?? "(missing)"} (expected account, usage, or reset)`);
|
|
92
|
+
}
|
|
93
|
+
return field;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getManagedPackageNames() {
|
|
97
|
+
return [PACKAGE_NAME, ...LEGACY_PACKAGE_NAMES];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function normalizePathForCompare(path, resolveRealPath = realpathSync) {
|
|
101
|
+
const resolved = resolve(path);
|
|
102
|
+
try {
|
|
103
|
+
const realPath = resolveRealPath(resolved);
|
|
104
|
+
return process.platform === "win32" ? realPath.toLowerCase() : realPath;
|
|
105
|
+
} catch {
|
|
106
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function isDirectRunPath(argvPath, modulePath, resolveRealPath = realpathSync) {
|
|
111
|
+
if (!argvPath || !modulePath) return false;
|
|
112
|
+
return (
|
|
113
|
+
normalizePathForCompare(argvPath, resolveRealPath) ===
|
|
114
|
+
normalizePathForCompare(modulePath, resolveRealPath)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function printHelp() {
|
|
119
|
+
console.log(`Usage: ${PACKAGE_NAME} [command] [options]\n\n` +
|
|
120
|
+
"Commands:\n" +
|
|
121
|
+
" install Register plugin entries (default with no command)\n" +
|
|
122
|
+
" update Refresh the cached package without changing OpenCode config\n" +
|
|
123
|
+
" doctor Run local account/config diagnostics\n" +
|
|
124
|
+
" status Show account/config status\n" +
|
|
125
|
+
" list List configured accounts\n" +
|
|
126
|
+
" limits Show 5-hour and weekly usage for each account\n" +
|
|
127
|
+
" dashboard Print dashboard guidance\n" +
|
|
128
|
+
" health Check local token/account health\n" +
|
|
129
|
+
" diag Alias for doctor --deep\n" +
|
|
130
|
+
" warm Open every enabled account's usage window now (one request each)\n\n" +
|
|
131
|
+
"Limits options:\n" +
|
|
132
|
+
" --sort account|usage|reset Order accounts by number, by usage, or by next reset\n" +
|
|
133
|
+
" --asc, --desc Direction (default --asc: lowest number, least used, earliest reset)\n" +
|
|
134
|
+
" --refresh Read every account live instead of the plugin's last readings\n\n" +
|
|
135
|
+
`Installer usage: ${PACKAGE_NAME} install [--plugin-only|--modern|--full|--legacy] [--dry-run] [--no-cache-clear]\n` +
|
|
136
|
+
`Updater usage: ${PACKAGE_NAME} update [--dry-run]\n\n` +
|
|
137
|
+
"Default behavior:\n" +
|
|
138
|
+
" - Registers plugin entries without changing provider.openai\n" +
|
|
139
|
+
" - Enables the prompt status bar TUI plugin at ~/.config/opencode/tui.json\n" +
|
|
140
|
+
" - Installs model catalogs only with --modern, --full, or --legacy\n" +
|
|
141
|
+
" - Ensures plugin is unpinned (latest)\n" +
|
|
142
|
+
" - Clears OpenCode plugin cache\n\n" +
|
|
143
|
+
"Options:\n" +
|
|
144
|
+
" --plugin-only Register plugins without changing provider.openai\n" +
|
|
145
|
+
" --v2 Register for OpenCode V2 (includes automatic quota UI loading)\n" +
|
|
146
|
+
" --modern Force compact modern config (10 base OAuth models + --variant presets)\n" +
|
|
147
|
+
" --full Install compact base models plus 53 explicit selector entries\n" +
|
|
148
|
+
" --legacy Force explicit legacy config (53 preset model entries)\n" +
|
|
149
|
+
" --dry-run Show actions without writing\n" +
|
|
150
|
+
" --no-cache-clear Skip clearing OpenCode cache\n"
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
155
|
+
const repoRoot = resolve(scriptDir, "..");
|
|
156
|
+
const modernTemplatePath = join(repoRoot, "config", "opencode-modern.json");
|
|
157
|
+
const legacyTemplatePath = join(repoRoot, "config", "opencode-legacy.json");
|
|
158
|
+
|
|
159
|
+
function log(message) {
|
|
160
|
+
console.log(message);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function delay(ms) {
|
|
164
|
+
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isWindowsLockError(error) {
|
|
168
|
+
const code = error?.code;
|
|
169
|
+
return code === "EPERM" || code === "EBUSY";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function formatErrorForLog(error) {
|
|
173
|
+
if (error instanceof Error) {
|
|
174
|
+
return error.message;
|
|
175
|
+
}
|
|
176
|
+
return String(error);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function resolveHomeDirectory(env = process.env) {
|
|
180
|
+
return env.HOME || env.USERPROFILE || homedir();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Resolve both JSON and JSONC config locations before changing V2 registration. */
|
|
184
|
+
function buildPaths(homeDir) {
|
|
185
|
+
const configDir = join(homeDir, ".config", "opencode");
|
|
186
|
+
const cacheDir = join(homeDir, ".cache", "opencode");
|
|
187
|
+
return {
|
|
188
|
+
configDir,
|
|
189
|
+
configPath: join(configDir, "opencode.json"),
|
|
190
|
+
jsoncConfigPath: join(configDir, "opencode.jsonc"),
|
|
191
|
+
tuiConfigPath: join(configDir, "tui.json"),
|
|
192
|
+
cacheDir,
|
|
193
|
+
cacheNodeModulesPaths: getManagedPackageNames().map((name) => join(cacheDir, "node_modules", name)),
|
|
194
|
+
cachePackagePaths: getManagedPackageNames().flatMap((name) => [
|
|
195
|
+
join(cacheDir, "packages", name),
|
|
196
|
+
join(cacheDir, "packages", `${name}@latest`),
|
|
197
|
+
]),
|
|
198
|
+
cacheBunLock: join(cacheDir, "bun.lock"),
|
|
199
|
+
cachePackageJson: join(cacheDir, "package.json"),
|
|
200
|
+
originHistoryPath: join(homeDir, ".opencode", ORIGIN_HISTORY_FILE_NAME),
|
|
201
|
+
modernTemplatePath,
|
|
202
|
+
legacyTemplatePath,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Keep V2 plugin-only installation separate from V1 model catalog modes. */
|
|
207
|
+
function parseCliArgs(argv = process.argv.slice(2)) {
|
|
208
|
+
const args = new Set(argv);
|
|
209
|
+
if (args.has("--help") || args.has("-h")) {
|
|
210
|
+
return {
|
|
211
|
+
wantsHelp: true,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const requestedModern = args.has("--modern");
|
|
216
|
+
const requestedFull = args.has("--full");
|
|
217
|
+
const requestedLegacy = args.has("--legacy");
|
|
218
|
+
const explicitPluginOnly = args.has("--plugin-only");
|
|
219
|
+
|
|
220
|
+
const requestedModes = [requestedModern, requestedFull, requestedLegacy]
|
|
221
|
+
.filter(Boolean).length;
|
|
222
|
+
if (requestedModes > 1) {
|
|
223
|
+
throw new Error("Choose only one of --modern, --full, or --legacy.");
|
|
224
|
+
}
|
|
225
|
+
if (explicitPluginOnly && requestedModes > 0) {
|
|
226
|
+
throw new Error("--plugin-only cannot be combined with --modern, --full, or --legacy.");
|
|
227
|
+
}
|
|
228
|
+
const pluginOnly = explicitPluginOnly || requestedModes === 0;
|
|
229
|
+
if (args.has("--v2") && !pluginOnly) {
|
|
230
|
+
throw new Error("--v2 registers the plugin only; omit --modern, --full, and --legacy.");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
wantsHelp: false,
|
|
235
|
+
dryRun: args.has("--dry-run"),
|
|
236
|
+
skipCacheClear: args.has("--no-cache-clear"),
|
|
237
|
+
pluginOnly,
|
|
238
|
+
v2: args.has("--v2"),
|
|
239
|
+
configMode: requestedFull ? "full" : requestedLegacy ? "legacy" : "modern",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parseUpdateArgs(argv) {
|
|
244
|
+
const args = new Set(argv);
|
|
245
|
+
const supported = new Set(["--dry-run", "--help", "-h"]);
|
|
246
|
+
const unknown = argv.find((arg) => !supported.has(arg));
|
|
247
|
+
if (unknown) {
|
|
248
|
+
throw new Error(`Unknown option for update command: ${unknown}`);
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
wantsHelp: args.has("--help") || args.has("-h"),
|
|
252
|
+
dryRun: args.has("--dry-run"),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const MANAGED_PACKAGE_ENTRY = "managed-package";
|
|
257
|
+
const LOCAL_CHECKOUT_ENTRY = "local-checkout";
|
|
258
|
+
const UNRELATED_ENTRY = "unrelated";
|
|
259
|
+
const DECLARED_NAME_LOOKUP_DEPTH = 3;
|
|
260
|
+
|
|
261
|
+
/** Extract a package/path from V1 tuples or native V2 plugin objects. */
|
|
262
|
+
function pluginEntrySpecifier(entry) {
|
|
263
|
+
if (typeof entry === "string") return entry;
|
|
264
|
+
if (isPlainObject(entry) && typeof entry.package === "string") return entry.package;
|
|
265
|
+
// `[specifier, options]` configures a plugin without changing where it loads from.
|
|
266
|
+
if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Spellings that can only mean a location on disk: absolute paths, `~`,
|
|
272
|
+
* `./`/`../`, Windows drive letters, and UNC shares. Anything else that merely
|
|
273
|
+
* contains a separator (`@scope/name`, git URLs, `npm:` aliases) is ambiguous
|
|
274
|
+
* and counts as a path only when it resolves on this machine.
|
|
275
|
+
*/
|
|
276
|
+
function isExplicitPathSpecifier(specifier) {
|
|
277
|
+
return (
|
|
278
|
+
/^[a-zA-Z]:[\\/]/.test(specifier) ||
|
|
279
|
+
/^[\\/]/.test(specifier) ||
|
|
280
|
+
/^~[\\/]/.test(specifier) ||
|
|
281
|
+
/^\.\.?[\\/]/.test(specifier)
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The path an entry names, exactly as the config spells it - and only when the
|
|
287
|
+
* specifier actually is a path. A `/` alone does not make one: registry and
|
|
288
|
+
* URL spellings can end in `oc-codex-multi-auth` without naming this package's
|
|
289
|
+
* checkout, so an ambiguous specifier counts only when it resolves on disk.
|
|
290
|
+
*/
|
|
291
|
+
function pluginEntryPath(specifier, baseDirectory) {
|
|
292
|
+
const trimmed = specifier.trim();
|
|
293
|
+
if (!trimmed) return null;
|
|
294
|
+
if (/^file:\/\//i.test(trimmed)) {
|
|
295
|
+
try {
|
|
296
|
+
return fileURLToPath(trimmed);
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (isExplicitPathSpecifier(trimmed)) return trimmed;
|
|
302
|
+
if (!trimmed.includes("/") && !trimmed.includes("\\")) return null;
|
|
303
|
+
const inspectionPath = resolveInspectionPath(trimmed, baseDirectory);
|
|
304
|
+
return inspectionPath && existsSync(inspectionPath) ? trimmed : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function pluginPathSegments(entryPath) {
|
|
308
|
+
return entryPath.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter(Boolean);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Compared as written, without resolving symlinks: this asks whether an entry
|
|
313
|
+
* names something `clearCache` removes, and `clearCache` removes the paths
|
|
314
|
+
* exactly as it spells them - `rm` unlinks a symlink rather than descending
|
|
315
|
+
* into it. Cache eviction resolves symlinks because it decides the opposite
|
|
316
|
+
* question, whether a recursive delete is safe.
|
|
317
|
+
*/
|
|
318
|
+
function isInsideDirectory(candidate, directory, platform) {
|
|
319
|
+
const fold = (value) => (platform === "win32" ? value.toLowerCase() : value);
|
|
320
|
+
const relativePath = relative(fold(resolve(directory)), fold(resolve(candidate)));
|
|
321
|
+
return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isPackageManagerPath(entryPath, options = {}) {
|
|
325
|
+
const { platform = process.platform, cacheDirectory, inspectionPath } = options;
|
|
326
|
+
// Windows reaches one directory under many spellings, so `NODE_MODULES`
|
|
327
|
+
// there is the same package-manager output as `node_modules`. Elsewhere the
|
|
328
|
+
// two are different directories and must stay so.
|
|
329
|
+
const segments = pluginPathSegments(entryPath).map((segment) =>
|
|
330
|
+
platform === "win32" ? segment.toLowerCase() : segment,
|
|
331
|
+
);
|
|
332
|
+
if (
|
|
333
|
+
segments.some(
|
|
334
|
+
(segment, index) =>
|
|
335
|
+
segment === "node_modules" ||
|
|
336
|
+
// OpenCode's plugin cache spells the version into the directory name.
|
|
337
|
+
// A `packages/` directory without one is an ordinary monorepo.
|
|
338
|
+
(segments[index - 1] === "packages" && segment.includes("@")),
|
|
339
|
+
)
|
|
340
|
+
) {
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
// The cache is where this installer puts its own copies, and `clearCache`
|
|
344
|
+
// empties it on the same run. Reading spelling alone leaves the cache's
|
|
345
|
+
// unversioned `packages/<name>` looking like somebody's monorepo, so the
|
|
346
|
+
// entry is kept while the directory under it is deleted - a config left
|
|
347
|
+
// pointing at nothing. Whose directory it is settles that; the spelling
|
|
348
|
+
// cannot.
|
|
349
|
+
return Boolean(
|
|
350
|
+
cacheDirectory &&
|
|
351
|
+
inspectionPath &&
|
|
352
|
+
isInsideDirectory(inspectionPath, cacheDirectory, platform),
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Where an entry points, for reading metadata about it only. OpenCode resolves
|
|
358
|
+
* a relative entry against the config file that declares it, so that directory
|
|
359
|
+
* is what makes such a path mean anything; the installer's working directory
|
|
360
|
+
* would name somewhere else entirely. Null when a relative entry arrives with
|
|
361
|
+
* no declaring directory to resolve it against.
|
|
362
|
+
*/
|
|
363
|
+
function resolveInspectionPath(entryPath, baseDirectory) {
|
|
364
|
+
if (isAbsolute(entryPath)) return entryPath;
|
|
365
|
+
return baseDirectory ? resolve(baseDirectory, entryPath) : null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Last-resort identification for a path that is not present on this machine.
|
|
370
|
+
* Spelling alone never authorizes deleting an entry; it only names the package a
|
|
371
|
+
* missing path was probably meant to point at.
|
|
372
|
+
*/
|
|
373
|
+
function managedNameFromPathSpelling(entryPath) {
|
|
374
|
+
const segments = pluginPathSegments(entryPath);
|
|
375
|
+
const last = segments.at(-1) === "dist" ? segments.at(-2) : segments.at(-1);
|
|
376
|
+
if (!last) return null;
|
|
377
|
+
let candidate = last.toLowerCase();
|
|
378
|
+
try {
|
|
379
|
+
candidate = decodeURIComponent(candidate);
|
|
380
|
+
} catch {
|
|
381
|
+
// Keep the raw segment when it carries a malformed escape.
|
|
382
|
+
}
|
|
383
|
+
const versionSuffix = candidate.indexOf("@");
|
|
384
|
+
if (versionSuffix > 0) candidate = candidate.slice(0, versionSuffix);
|
|
385
|
+
return getManagedPackageNames().find((name) => name.toLowerCase() === candidate) ?? null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function readDeclaredPackageName(directoryPath) {
|
|
389
|
+
try {
|
|
390
|
+
const parsed = JSON.parse(readFileSync(join(directoryPath, "package.json"), "utf8"));
|
|
391
|
+
const name = parsed?.name;
|
|
392
|
+
return typeof name === "string" && name.trim() ? name.trim() : null;
|
|
393
|
+
} catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** An entry may point at a build output inside the package, so walk upwards. */
|
|
399
|
+
function resolveDeclaredPackageName(entryPath) {
|
|
400
|
+
if (!isAbsolute(entryPath)) return null;
|
|
401
|
+
let current = resolve(entryPath);
|
|
402
|
+
for (let depth = 0; depth <= DECLARED_NAME_LOOKUP_DEPTH; depth += 1) {
|
|
403
|
+
const name = readDeclaredPackageName(current);
|
|
404
|
+
if (name) return name;
|
|
405
|
+
const parent = dirname(current);
|
|
406
|
+
if (parent === current) return null;
|
|
407
|
+
current = parent;
|
|
408
|
+
}
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Decides what a plugin entry is, by identity rather than by spelling.
|
|
414
|
+
*
|
|
415
|
+
* The distinction that matters is not which package an entry names but who
|
|
416
|
+
* chose the location. A bare specifier or a path inside `node_modules` is a
|
|
417
|
+
* reference the installer itself produced and may retire. Any other path is
|
|
418
|
+
* somewhere a human deliberately pointed OpenCode - a checkout of this package
|
|
419
|
+
* being developed on, most often - and is never the installer's to remove.
|
|
420
|
+
*/
|
|
421
|
+
function classifyPluginEntry(entry, options = {}) {
|
|
422
|
+
const {
|
|
423
|
+
resolveDeclaredName = resolveDeclaredPackageName,
|
|
424
|
+
baseDirectory,
|
|
425
|
+
cacheDirectory,
|
|
426
|
+
platform = process.platform,
|
|
427
|
+
} = options;
|
|
428
|
+
const specifier = pluginEntrySpecifier(entry);
|
|
429
|
+
if (specifier === null) return { kind: UNRELATED_ENTRY, name: null };
|
|
430
|
+
|
|
431
|
+
const entryPath = pluginEntryPath(specifier, baseDirectory);
|
|
432
|
+
if (entryPath === null) {
|
|
433
|
+
const bare = specifier.trim().toLowerCase();
|
|
434
|
+
const name = getManagedPackageNames().find(
|
|
435
|
+
(managed) =>
|
|
436
|
+
bare === managed.toLowerCase() || bare.startsWith(`${managed.toLowerCase()}@`),
|
|
437
|
+
);
|
|
438
|
+
return name
|
|
439
|
+
? { kind: MANAGED_PACKAGE_ENTRY, name }
|
|
440
|
+
: { kind: UNRELATED_ENTRY, name: null };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const inspectionPath = resolveInspectionPath(entryPath, baseDirectory);
|
|
444
|
+
const declaredName = inspectionPath ? resolveDeclaredName(inspectionPath) : null;
|
|
445
|
+
const managedName = declaredName
|
|
446
|
+
? getManagedPackageNames().find(
|
|
447
|
+
(managed) => managed.toLowerCase() === declaredName.toLowerCase(),
|
|
448
|
+
) ?? null
|
|
449
|
+
: managedNameFromPathSpelling(entryPath);
|
|
450
|
+
|
|
451
|
+
if (!managedName) return { kind: UNRELATED_ENTRY, name: null };
|
|
452
|
+
|
|
453
|
+
return isPackageManagerPath(entryPath, { platform, cacheDirectory, inspectionPath })
|
|
454
|
+
? { kind: MANAGED_PACKAGE_ENTRY, name: managedName }
|
|
455
|
+
: {
|
|
456
|
+
kind: LOCAL_CHECKOUT_ENTRY,
|
|
457
|
+
name: managedName,
|
|
458
|
+
path: inspectionPath ?? entryPath,
|
|
459
|
+
resolvesOnDisk: Boolean(inspectionPath && existsSync(inspectionPath)),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Ensures this plugin is registered exactly once, without changing how an
|
|
465
|
+
* existing registration is spelled. Appending the published package name is the
|
|
466
|
+
* fallback for a config that does not reference the plugin at all, not the
|
|
467
|
+
* canonical form every config is rewritten into.
|
|
468
|
+
*/
|
|
469
|
+
function normalizePluginList(list, onNotice, options = {}) {
|
|
470
|
+
const entries = Array.isArray(list)
|
|
471
|
+
? list.filter((entry) => entry !== null && entry !== undefined && entry !== "")
|
|
472
|
+
: [];
|
|
473
|
+
const classifications = entries.map((entry) => classifyPluginEntry(entry, options));
|
|
474
|
+
// A checkout of this package already IS the registration, so a published
|
|
475
|
+
// entry beside it is a second copy of the same plugin for OpenCode to load.
|
|
476
|
+
// `options.checkoutRegistered` carries the same fact across config files: a
|
|
477
|
+
// checkout registered only in opencode.json still suppresses the published
|
|
478
|
+
// name in tui.json, and vice versa. Only a checkout of the CURRENT package
|
|
479
|
+
// counts: the former name is valid for cleanup, never as the registration
|
|
480
|
+
// the installer exists to ensure.
|
|
481
|
+
const checkoutRegistered = options.checkoutRegistered === true || classifications.some(
|
|
482
|
+
(classification) =>
|
|
483
|
+
classification.kind === LOCAL_CHECKOUT_ENTRY && classification.name === PACKAGE_NAME,
|
|
484
|
+
);
|
|
485
|
+
const kept = [];
|
|
486
|
+
let keptPublishedName = false;
|
|
487
|
+
|
|
488
|
+
entries.forEach((entry, index) => {
|
|
489
|
+
const classification = classifications[index];
|
|
490
|
+
|
|
491
|
+
if (classification.kind === LOCAL_CHECKOUT_ENTRY) {
|
|
492
|
+
kept.push(entry);
|
|
493
|
+
if (classification.resolvesOnDisk === false) {
|
|
494
|
+
onNotice?.(
|
|
495
|
+
`Warning: keeping ${classification.path} registered, but it does not resolve on disk; ` +
|
|
496
|
+
"the plugin may not load until the path exists again.",
|
|
497
|
+
);
|
|
498
|
+
} else {
|
|
499
|
+
onNotice?.(
|
|
500
|
+
`Keeping the local ${classification.name} checkout registered at ${classification.path}`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (classification.kind === MANAGED_PACKAGE_ENTRY) {
|
|
507
|
+
// Retire stale duplicates, version pins, renamed packages, and paths
|
|
508
|
+
// into package-manager output; keep one published-name entry in place
|
|
509
|
+
// unless a checkout already covers it.
|
|
510
|
+
const isPublishedName = pluginEntrySpecifier(entry) === PACKAGE_NAME;
|
|
511
|
+
if (isPublishedName && !checkoutRegistered && !keptPublishedName) {
|
|
512
|
+
keptPublishedName = true;
|
|
513
|
+
kept.push(entry);
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
kept.push(entry);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
return checkoutRegistered || keptPublishedName ? kept : [...kept, PACKAGE_NAME];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function readLocalCheckoutSightings(historyPath) {
|
|
525
|
+
try {
|
|
526
|
+
const parsed = JSON.parse(readFileSync(historyPath, "utf8"));
|
|
527
|
+
const sightings = parsed?.sightings;
|
|
528
|
+
if (!Array.isArray(sightings)) return [];
|
|
529
|
+
return sightings.filter(
|
|
530
|
+
(sighting) =>
|
|
531
|
+
sighting &&
|
|
532
|
+
typeof sighting === "object" &&
|
|
533
|
+
sighting.isLocalCheckout === true &&
|
|
534
|
+
typeof sighting.root === "string" &&
|
|
535
|
+
typeof sighting.lastSeen === "string" &&
|
|
536
|
+
getManagedPackageNames().includes(sighting.name),
|
|
537
|
+
);
|
|
538
|
+
} catch {
|
|
539
|
+
return [];
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* A checkout the plugin has run from that the finished config does not
|
|
545
|
+
* register. Reported rather than restored: config history is evidence of what
|
|
546
|
+
* happened, not authority over what the user wants registered now.
|
|
547
|
+
*/
|
|
548
|
+
function findUnregisteredLocalCheckout(pluginList, historyPath, options = {}) {
|
|
549
|
+
const entries = Array.isArray(pluginList) ? pluginList : [];
|
|
550
|
+
if (entries.some((entry) => classifyPluginEntry(entry, options).kind === LOCAL_CHECKOUT_ENTRY)) {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
const latest = readLocalCheckoutSightings(historyPath)
|
|
554
|
+
.sort((left, right) => (Date.parse(left.lastSeen) || 0) - (Date.parse(right.lastSeen) || 0))
|
|
555
|
+
.at(-1);
|
|
556
|
+
if (!latest) return null;
|
|
557
|
+
// The directory has to still hold the package that was recorded there. A
|
|
558
|
+
// path gets reused - a checkout deleted and something else cloned into its
|
|
559
|
+
// place - and a recorded path that now declares another project would
|
|
560
|
+
// otherwise be offered as somewhere to point OpenCode back at.
|
|
561
|
+
const declaredName = resolveDeclaredPackageName(latest.root);
|
|
562
|
+
if (!declaredName || declaredName.toLowerCase() !== String(latest.name).toLowerCase()) {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
return latest;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function mergeTuiConfig(existingConfig, onNotice, options = {}) {
|
|
569
|
+
const existing = isPlainObject(existingConfig) ? { ...existingConfig } : {};
|
|
570
|
+
const next = { ...existing };
|
|
571
|
+
if (typeof next.$schema !== "string" || !next.$schema.trim()) {
|
|
572
|
+
next.$schema = "https://opencode.ai/tui.json";
|
|
573
|
+
}
|
|
574
|
+
next.plugin = normalizePluginList(existing.plugin, onNotice, options);
|
|
575
|
+
return next;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function formatJson(obj) {
|
|
579
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function getStandaloneStoragePath(options, env = process.env) {
|
|
583
|
+
if (options.configPath) return resolve(options.configPath);
|
|
584
|
+
return join(resolveHomeDirectory(env), ".opencode", "oc-codex-multi-auth-accounts.json");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function readStandaloneStorage(path) {
|
|
588
|
+
try {
|
|
589
|
+
const raw = await readFile(path, "utf-8");
|
|
590
|
+
const parsed = JSON.parse(raw);
|
|
591
|
+
// Shape validation, not just parse validation: a JSON array, scalar, or
|
|
592
|
+
// object without an `accounts` array is unreadable by the plugin runtime
|
|
593
|
+
// too (normalizeAccountStorage rejects it), so reporting it as a healthy
|
|
594
|
+
// empty pool (exit 0, "No accounts configured") hides the corruption
|
|
595
|
+
// from scripted callers that key on exit codes.
|
|
596
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
597
|
+
return { storage: null, error: "Storage file must be a JSON object with an accounts array." };
|
|
598
|
+
}
|
|
599
|
+
// Forward-compat mirror of the runtime guard: a newer schema version
|
|
600
|
+
// must not be shown as readable accounts by this build.
|
|
601
|
+
const version = parsed.version;
|
|
602
|
+
if (typeof version === "number" && Number.isFinite(version) && version > 3) {
|
|
603
|
+
return {
|
|
604
|
+
storage: null,
|
|
605
|
+
error: `Unsupported account storage schema version ${version}; this build supports up to version 3.`,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
if (!Array.isArray(parsed.accounts)) {
|
|
609
|
+
return { storage: null, error: "Storage file must be a JSON object with an accounts array." };
|
|
610
|
+
}
|
|
611
|
+
return {
|
|
612
|
+
storage: normalizeStandaloneStorage(parsed),
|
|
613
|
+
error: null,
|
|
614
|
+
};
|
|
615
|
+
} catch (error) {
|
|
616
|
+
if (error?.code === "ENOENT") return { storage: null, error: null };
|
|
617
|
+
return { storage: null, error: formatErrorForLog(error) };
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function normalizeStandaloneIdentityPart(value) {
|
|
622
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function sameStandaloneIdentity(left, right) {
|
|
626
|
+
const normalizedLeft = normalizeStandaloneIdentityPart(left);
|
|
627
|
+
const normalizedRight = normalizeStandaloneIdentityPart(right);
|
|
628
|
+
return !!normalizedLeft && !!normalizedRight && normalizedLeft === normalizedRight;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function isStandaloneOrgTokenDuplicate(left, right) {
|
|
632
|
+
const leftOrganizationId = normalizeStandaloneIdentityPart(left?.organizationId);
|
|
633
|
+
const rightOrganizationId = normalizeStandaloneIdentityPart(right?.organizationId);
|
|
634
|
+
if (leftOrganizationId && rightOrganizationId && leftOrganizationId !== rightOrganizationId) return false;
|
|
635
|
+
const leftOrgLike = !!leftOrganizationId || left?.accountIdSource === "org";
|
|
636
|
+
const rightOrgLike = !!rightOrganizationId || right?.accountIdSource === "org";
|
|
637
|
+
const leftTokenLike = !leftOrganizationId && left?.accountIdSource === "token";
|
|
638
|
+
const rightTokenLike = !rightOrganizationId && right?.accountIdSource === "token";
|
|
639
|
+
if (!((leftOrgLike && rightTokenLike) || (rightOrgLike && leftTokenLike))) return false;
|
|
640
|
+
return sameStandaloneIdentity(left?.email, right?.email) ||
|
|
641
|
+
sameStandaloneIdentity(left?.refreshToken, right?.refreshToken);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function mergeStandaloneAccounts(target, source) {
|
|
645
|
+
const targetOrgLike = !!normalizeStandaloneIdentityPart(target?.organizationId) || target?.accountIdSource === "org";
|
|
646
|
+
const sourceOrgLike = !!normalizeStandaloneIdentityPart(source?.organizationId) || source?.accountIdSource === "org";
|
|
647
|
+
if (targetOrgLike || !sourceOrgLike) {
|
|
648
|
+
return {
|
|
649
|
+
...source,
|
|
650
|
+
...target,
|
|
651
|
+
organizationId: target.organizationId ?? source.organizationId,
|
|
652
|
+
accountId: target.accountId ?? source.accountId,
|
|
653
|
+
accountIdSource: target.accountIdSource ?? source.accountIdSource,
|
|
654
|
+
accountLabel: target.accountLabel ?? source.accountLabel,
|
|
655
|
+
email: target.email ?? source.email,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
return mergeStandaloneAccounts(source, target);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Mirror of `isStaleGeneratedAccountLabel` / `dropStaleGeneratedLabel` in
|
|
662
|
+
// lib/auth/token-utils.ts and lib/storage/normalize.ts. The standalone CLI
|
|
663
|
+
// reads the pool through this normalizer and never through the compiled
|
|
664
|
+
// `normalizeAccountStorage`, so without the mirror `status`, `list`, `health`,
|
|
665
|
+
// `doctor` and `dashboard` keep printing the org-derived label the plugin
|
|
666
|
+
// itself now drops - next to the account id, which is the identity the label
|
|
667
|
+
// was misnaming. The marker must hold this account's own id suffix so a name
|
|
668
|
+
// set with `codex-label` survives.
|
|
669
|
+
const GENERATED_LABEL_PATTERN = /\s\[id:[^\]]*\]$/;
|
|
670
|
+
|
|
671
|
+
function dropStaleStandaloneLabel(account) {
|
|
672
|
+
const label = typeof account?.accountLabel === "string" ? account.accountLabel.trim() : "";
|
|
673
|
+
const accountId = typeof account?.accountId === "string" ? account.accountId.trim() : "";
|
|
674
|
+
if (!label || !accountId) return account;
|
|
675
|
+
const marker = label.match(GENERATED_LABEL_PATTERN)?.[0];
|
|
676
|
+
if (!marker) return account;
|
|
677
|
+
const suffix = accountId.length > 6 ? accountId.slice(-6) : accountId;
|
|
678
|
+
if (marker !== ` [id:${suffix}]`) return account;
|
|
679
|
+
const next = { ...account };
|
|
680
|
+
delete next.accountLabel;
|
|
681
|
+
return next;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function normalizeStandaloneStorage(storage) {
|
|
685
|
+
if (!Array.isArray(storage.accounts)) return storage;
|
|
686
|
+
const accounts = [...storage.accounts];
|
|
687
|
+
const removed = new Set();
|
|
688
|
+
for (let i = 0; i < accounts.length; i += 1) {
|
|
689
|
+
if (removed.has(i)) continue;
|
|
690
|
+
for (let j = i + 1; j < accounts.length; j += 1) {
|
|
691
|
+
if (removed.has(j) || !isStandaloneOrgTokenDuplicate(accounts[i], accounts[j])) continue;
|
|
692
|
+
const leftOrgLike = !!normalizeStandaloneIdentityPart(accounts[i]?.organizationId) ||
|
|
693
|
+
accounts[i]?.accountIdSource === "org";
|
|
694
|
+
const targetIndex = leftOrgLike ? i : j;
|
|
695
|
+
const sourceIndex = targetIndex === i ? j : i;
|
|
696
|
+
accounts[targetIndex] = mergeStandaloneAccounts(accounts[targetIndex], accounts[sourceIndex]);
|
|
697
|
+
removed.add(sourceIndex);
|
|
698
|
+
if (sourceIndex === i) break;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
const normalizedAccounts = accounts
|
|
702
|
+
.filter((_, index) => !removed.has(index))
|
|
703
|
+
.map(dropStaleStandaloneLabel);
|
|
704
|
+
return {
|
|
705
|
+
...storage,
|
|
706
|
+
accounts: normalizedAccounts,
|
|
707
|
+
activeIndex: Math.max(0, Math.min(storage.activeIndex ?? 0, Math.max(0, normalizedAccounts.length - 1))),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const MASKED_VALUE = "*****";
|
|
712
|
+
// The head/tail mask keeps eight characters, so it conceals nothing worth
|
|
713
|
+
// concealing below thirteen: `me@x.io` would print in full and `me@x.io12`
|
|
714
|
+
// all but its middle character. `doctor` output is what users paste into
|
|
715
|
+
// issues, so anything shorter is replaced outright instead. Input is trimmed
|
|
716
|
+
// first, or `" me@x.io "` clears the cutoff on padding alone and drops back
|
|
717
|
+
// into the partial mask.
|
|
718
|
+
const MASK_MIN_LENGTH = 13;
|
|
719
|
+
|
|
720
|
+
function maskValue(value, includeSensitive) {
|
|
721
|
+
if (includeSensitive || typeof value !== "string") return value;
|
|
722
|
+
const trimmed = value.trim();
|
|
723
|
+
if (!trimmed) return trimmed;
|
|
724
|
+
if (trimmed.length < MASK_MIN_LENGTH) return MASKED_VALUE;
|
|
725
|
+
return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Six characters when the id is shown in full, matching what the
|
|
729
|
+
// in-conversation surfaces print as `id:`. Four when it is head/tail masked,
|
|
730
|
+
// which is the tail `maskValue` already discloses as `accountId` in the same
|
|
731
|
+
// payload, so the printed identity never reveals more of an id than the field
|
|
732
|
+
// beside it. Nothing at all when the id was too short for that mask: the
|
|
733
|
+
// `accountId` next to it is then `*****`, and four raw characters of a short
|
|
734
|
+
// id can be the whole id.
|
|
735
|
+
function accountIdSuffix(accountId, includeSensitive) {
|
|
736
|
+
if (!accountId) return undefined;
|
|
737
|
+
if (includeSensitive) {
|
|
738
|
+
return accountId.length > 6 ? accountId.slice(-6) : accountId;
|
|
739
|
+
}
|
|
740
|
+
if (accountId.length < MASK_MIN_LENGTH) return undefined;
|
|
741
|
+
return accountId.slice(-4);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// A member id is what tells two seats of one Business workspace apart, and no
|
|
745
|
+
// fixed-length tail always does it: member ids sharing a six-character tail
|
|
746
|
+
// were observed, and in a real nine-seat pool the ids are 67 characters with
|
|
747
|
+
// no shared tail at all, so growing a tail until it separates them prints most
|
|
748
|
+
// of the id in every row. The renderer below mirrors `resolveSeatRenderer` in
|
|
749
|
+
// lib/account-display.ts - a tail, else one window anchored where the ids first
|
|
750
|
+
// diverge, else short windows at each position where a pair first differs
|
|
751
|
+
// joined by `..`, else a hash prefix, each capped - and the id whole only if
|
|
752
|
+
// none of those separate them, which needs a 128-bit SHA-256 collision. The
|
|
753
|
+
// hash outcome is reachable, and it prints a value that cannot be matched
|
|
754
|
+
// against the id by eye.
|
|
755
|
+
const STANDALONE_SEAT_MAX_LENGTH = 12;
|
|
756
|
+
const STANDALONE_SEAT_HASH_LENGTHS = [8, 12, 16, 24, 32];
|
|
757
|
+
const STANDALONE_SEAT_WINDOW_SEPARATOR = "..";
|
|
758
|
+
|
|
759
|
+
function seatIsDisclosable(accountUserId, includeSensitive) {
|
|
760
|
+
if (!accountUserId) return false;
|
|
761
|
+
return includeSensitive || accountUserId.length >= MASK_MIN_LENGTH;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function seatTail(accountUserId, length) {
|
|
765
|
+
return accountUserId.length > length ? accountUserId.slice(-length) : accountUserId;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function seatWindow(accountUserId, start, length) {
|
|
769
|
+
if (accountUserId.length <= length) return accountUserId;
|
|
770
|
+
const begin = Math.max(0, Math.min(start, accountUserId.length - length));
|
|
771
|
+
return accountUserId.slice(begin, begin + length);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function seatCommonPrefixLength(values) {
|
|
775
|
+
const [first] = values;
|
|
776
|
+
if (first === undefined) return 0;
|
|
777
|
+
let shared = first.length;
|
|
778
|
+
for (const value of values) {
|
|
779
|
+
let index = 0;
|
|
780
|
+
while (index < shared && index < value.length && first[index] === value[index]) {
|
|
781
|
+
index += 1;
|
|
782
|
+
}
|
|
783
|
+
shared = index;
|
|
784
|
+
if (shared === 0) break;
|
|
785
|
+
}
|
|
786
|
+
return shared;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function seatFirstDivergence(left, right) {
|
|
790
|
+
const limit = Math.min(left.length, right.length);
|
|
791
|
+
let index = 0;
|
|
792
|
+
while (index < limit && left[index] === right[index]) index += 1;
|
|
793
|
+
return index;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// For every pair, the first index at which that pair differs - not every index
|
|
797
|
+
// where the ids disagree, which across a handful of random-looking ids is
|
|
798
|
+
// nearly all of them and localizes nothing.
|
|
799
|
+
function seatDivergenceAnchors(values) {
|
|
800
|
+
const anchors = new Set();
|
|
801
|
+
for (let left = 0; left < values.length; left += 1) {
|
|
802
|
+
for (let right = left + 1; right < values.length; right += 1) {
|
|
803
|
+
anchors.add(seatFirstDivergence(values[left], values[right]));
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return [...anchors].sort((left, right) => left - right);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function seatAnchorWindowStarts(anchors, width) {
|
|
810
|
+
const starts = [];
|
|
811
|
+
for (const anchor of anchors) {
|
|
812
|
+
const last = starts[starts.length - 1];
|
|
813
|
+
if (last !== undefined && anchor < last + width) continue;
|
|
814
|
+
starts.push(anchor);
|
|
815
|
+
}
|
|
816
|
+
return starts;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function resolveStandaloneSeatRenderer(accountUserIds, includeSensitive) {
|
|
820
|
+
// Starts at the length the mask above allows, so masked output widens only
|
|
821
|
+
// when leaving it short would print a lie.
|
|
822
|
+
const base = includeSensitive ? 6 : 4;
|
|
823
|
+
const distinct = [];
|
|
824
|
+
const seen = new Set();
|
|
825
|
+
for (const accountUserId of accountUserIds) {
|
|
826
|
+
if (!seatIsDisclosable(accountUserId, includeSensitive)) continue;
|
|
827
|
+
if (seen.has(accountUserId)) continue;
|
|
828
|
+
seen.add(accountUserId);
|
|
829
|
+
distinct.push(accountUserId);
|
|
830
|
+
}
|
|
831
|
+
const atBase = (accountUserId) => seatTail(accountUserId, base);
|
|
832
|
+
if (distinct.length <= 1) return atBase;
|
|
833
|
+
|
|
834
|
+
const separates = (render) => new Set(distinct.map(render)).size === distinct.length;
|
|
835
|
+
|
|
836
|
+
for (let length = base; length <= STANDALONE_SEAT_MAX_LENGTH; length += 1) {
|
|
837
|
+
const render = (accountUserId) => seatTail(accountUserId, length);
|
|
838
|
+
if (separates(render)) return render;
|
|
839
|
+
}
|
|
840
|
+
const start = seatCommonPrefixLength(distinct);
|
|
841
|
+
for (let length = base; length <= STANDALONE_SEAT_MAX_LENGTH; length += 1) {
|
|
842
|
+
const render = (accountUserId) => seatWindow(accountUserId, start, length);
|
|
843
|
+
if (separates(render)) return render;
|
|
844
|
+
}
|
|
845
|
+
const anchors = seatDivergenceAnchors(distinct);
|
|
846
|
+
for (let width = 2; width <= STANDALONE_SEAT_MAX_LENGTH; width += 1) {
|
|
847
|
+
const starts = seatAnchorWindowStarts(anchors, width);
|
|
848
|
+
const rendered =
|
|
849
|
+
starts.length * width + (starts.length - 1) * STANDALONE_SEAT_WINDOW_SEPARATOR.length;
|
|
850
|
+
// Skipped, not abandoned: a wider window can span two nearby anchors
|
|
851
|
+
// that needed one window each, so the cost falls as the window count
|
|
852
|
+
// does. Mirrors `resolveSeatRenderer` in lib/account-display.ts, where
|
|
853
|
+
// the measured counter-example is written out.
|
|
854
|
+
if (rendered > STANDALONE_SEAT_MAX_LENGTH) continue;
|
|
855
|
+
const render = (accountUserId) =>
|
|
856
|
+
starts
|
|
857
|
+
.map((windowStart) => accountUserId.slice(windowStart, windowStart + width))
|
|
858
|
+
.join(STANDALONE_SEAT_WINDOW_SEPARATOR);
|
|
859
|
+
if (separates(render)) return render;
|
|
860
|
+
}
|
|
861
|
+
for (const length of STANDALONE_SEAT_HASH_LENGTHS) {
|
|
862
|
+
const render = (accountUserId) => createHash("sha256").update(accountUserId).digest("hex").slice(0, length);
|
|
863
|
+
if (separates(render)) return render;
|
|
864
|
+
}
|
|
865
|
+
return (accountUserId) => accountUserId;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function summarizeStandaloneAccounts(storage, includeSensitive, tag) {
|
|
869
|
+
const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
|
|
870
|
+
const normalizedTag = typeof tag === "string" ? tag.trim().toLowerCase() : "";
|
|
871
|
+
const entries = accounts
|
|
872
|
+
.map((account, index) => ({ account, index }))
|
|
873
|
+
.filter(({ account }) => !normalizedTag ||
|
|
874
|
+
(Array.isArray(account?.accountTags) &&
|
|
875
|
+
account.accountTags.some((entry) => String(entry).toLowerCase() === normalizedTag)));
|
|
876
|
+
const renderSeat = resolveStandaloneSeatRenderer(
|
|
877
|
+
entries.map(({ account }) =>
|
|
878
|
+
(typeof account?.accountUserId === "string" ? account.accountUserId.trim() : "") || undefined,
|
|
879
|
+
),
|
|
880
|
+
includeSensitive,
|
|
881
|
+
);
|
|
882
|
+
return entries
|
|
883
|
+
.map(({ account, index }) => {
|
|
884
|
+
const trimmedId =
|
|
885
|
+
typeof account?.accountId === "string" ? account.accountId.trim() : "";
|
|
886
|
+
const accountId = trimmedId || undefined;
|
|
887
|
+
// Members of one Business workspace share `accountId`, so the seat is
|
|
888
|
+
// what tells them apart. It is carried masked next to its suffix for
|
|
889
|
+
// the same reason `accountId` is: so the printed `seat:` discloses no
|
|
890
|
+
// more of an id than the field beside it unless telling two seats
|
|
891
|
+
// apart requires it.
|
|
892
|
+
const trimmedUserId =
|
|
893
|
+
typeof account?.accountUserId === "string" ? account.accountUserId.trim() : "";
|
|
894
|
+
const accountUserId = trimmedUserId || undefined;
|
|
895
|
+
return {
|
|
896
|
+
index,
|
|
897
|
+
label: account?.accountLabel ?? `Account ${index + 1}`,
|
|
898
|
+
email: maskValue(account?.email, includeSensitive),
|
|
899
|
+
accountId: maskValue(accountId, includeSensitive),
|
|
900
|
+
idSuffix: accountIdSuffix(accountId, includeSensitive),
|
|
901
|
+
accountUserId: maskValue(accountUserId, includeSensitive),
|
|
902
|
+
seatSuffix: seatIsDisclosable(accountUserId, includeSensitive)
|
|
903
|
+
? renderSeat(accountUserId)
|
|
904
|
+
: undefined,
|
|
905
|
+
accountIdSource: account?.accountIdSource,
|
|
906
|
+
enabled: account?.enabled !== false,
|
|
907
|
+
hasRefreshToken: typeof account?.refreshToken === "string" && account.refreshToken.length > 0,
|
|
908
|
+
hasAccessToken: typeof account?.accessToken === "string" && account.accessToken.length > 0,
|
|
909
|
+
expiresAt: account?.expiresAt,
|
|
910
|
+
expired: typeof account?.expiresAt === "number" ? account.expiresAt <= Date.now() : undefined,
|
|
911
|
+
tags: Array.isArray(account?.accountTags) ? account.accountTags : [],
|
|
912
|
+
note: account?.accountNote,
|
|
913
|
+
rateLimitResetTimes: account?.rateLimitResetTimes ?? {},
|
|
914
|
+
quotaExhaustedUntil: account?.quotaExhaustedUntil,
|
|
915
|
+
};
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function printStandaloneResult(command, payload, json) {
|
|
920
|
+
if (json) {
|
|
921
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
console.log(`oc-codex-multi-auth ${command}`);
|
|
925
|
+
if (payload.message) console.log(payload.message);
|
|
926
|
+
console.log(`Storage: ${payload.storagePath}`);
|
|
927
|
+
console.log(`Accounts: ${payload.totalAccounts}`);
|
|
928
|
+
if (Array.isArray(payload.accounts)) {
|
|
929
|
+
for (const account of payload.accounts) {
|
|
930
|
+
const identity = [
|
|
931
|
+
account.email,
|
|
932
|
+
account.idSuffix ? `id:${account.idSuffix}` : undefined,
|
|
933
|
+
account.seatSuffix ? `seat:${account.seatSuffix}` : undefined,
|
|
934
|
+
]
|
|
935
|
+
.filter(Boolean)
|
|
936
|
+
.join(", ");
|
|
937
|
+
const name = identity ? `${account.label} (${identity})` : account.label;
|
|
938
|
+
console.log(`- [${account.index}] ${name} enabled=${account.enabled} refresh=${account.hasRefreshToken} access=${account.hasAccessToken}`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
if (payload.error) console.log(`Error: ${payload.error}`);
|
|
942
|
+
for (const fix of payload.appliedFixes ?? []) console.log(`Fixed: ${fix}`);
|
|
943
|
+
for (const error of payload.fixErrors ?? []) console.log(`Repair failed: ${error}`);
|
|
944
|
+
if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Import compiled modules from dist/ so the standalone CLI behaves identically
|
|
949
|
+
* to the in-conversation tools. dist/ ships in the npm package (files
|
|
950
|
+
* allowlist) and none of these modules import the OpenCode plugin runtime, so
|
|
951
|
+
* they load cleanly in plain Node.
|
|
952
|
+
*/
|
|
953
|
+
async function loadDistModules(relativePaths, label) {
|
|
954
|
+
const distRoot = join(repoRoot, "dist", "lib");
|
|
955
|
+
const toUrl = (rel) => pathToFileURL(join(distRoot, rel)).href;
|
|
956
|
+
try {
|
|
957
|
+
return await Promise.all(relativePaths.map((rel) => import(toUrl(rel))));
|
|
958
|
+
} catch (error) {
|
|
959
|
+
throw new Error(
|
|
960
|
+
`Could not load ${label} runtime from dist/. Build the package first (npm run build). Cause: ${formatErrorForLog(error)}`,
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async function loadWarmRuntime(env) {
|
|
966
|
+
const [storageMod, usageMod, warmReqMod, warmMod, shutdownMod, recoveryMod] = await loadDistModules(
|
|
967
|
+
[
|
|
968
|
+
"storage.js",
|
|
969
|
+
"codex-usage.js",
|
|
970
|
+
"accounts/warm-request.js",
|
|
971
|
+
"accounts/warm.js",
|
|
972
|
+
"shutdown.js",
|
|
973
|
+
"accounts/warm-recovery.js",
|
|
974
|
+
],
|
|
975
|
+
"warm",
|
|
976
|
+
);
|
|
977
|
+
// Unlike the plugin, this CLI *is* the process, so it owns termination:
|
|
978
|
+
// Ctrl+C must abort the warm run rather than wait for it to drain.
|
|
979
|
+
// Refreshing a token here persists credentials, which registers the
|
|
980
|
+
// shutdown handler via the storage lock.
|
|
981
|
+
shutdownMod.setShutdownOwnsProcess(true);
|
|
982
|
+
return { storageMod, usageMod, warmReqMod, warmMod, shutdownMod, recoveryMod };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function loadLimitsRuntime(env) {
|
|
986
|
+
const [
|
|
987
|
+
storageMod,
|
|
988
|
+
usageMod,
|
|
989
|
+
shutdownMod,
|
|
990
|
+
loggerMod,
|
|
991
|
+
configMod,
|
|
992
|
+
planMod,
|
|
993
|
+
planTierMod,
|
|
994
|
+
quotaCacheMod,
|
|
995
|
+
quotaOverviewMod,
|
|
996
|
+
themeMod,
|
|
997
|
+
] = await loadDistModules(
|
|
998
|
+
[
|
|
999
|
+
"storage.js",
|
|
1000
|
+
"codex-usage.js",
|
|
1001
|
+
"shutdown.js",
|
|
1002
|
+
"logger.js",
|
|
1003
|
+
"config.js",
|
|
1004
|
+
"plan-allotment.js",
|
|
1005
|
+
"auth/plan-tier.js",
|
|
1006
|
+
"tui-quota-cache.js",
|
|
1007
|
+
"tui-quota-overview.js",
|
|
1008
|
+
"ui/theme.js",
|
|
1009
|
+
],
|
|
1010
|
+
"limits",
|
|
1011
|
+
);
|
|
1012
|
+
// Fetching usage can refresh (and therefore persist) a token, so the same
|
|
1013
|
+
// process-owns-termination rule as `warm` applies.
|
|
1014
|
+
shutdownMod.setShutdownOwnsProcess(true);
|
|
1015
|
+
return {
|
|
1016
|
+
storageMod,
|
|
1017
|
+
usageMod,
|
|
1018
|
+
shutdownMod,
|
|
1019
|
+
loggerMod,
|
|
1020
|
+
configMod,
|
|
1021
|
+
planMod,
|
|
1022
|
+
planTierMod,
|
|
1023
|
+
quotaCacheMod,
|
|
1024
|
+
quotaOverviewMod,
|
|
1025
|
+
themeMod,
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
export async function runWarmCommand(parsed, options = {}) {
|
|
1030
|
+
const { env = process.env } = options;
|
|
1031
|
+
const storagePath = getStandaloneStoragePath(parsed, env);
|
|
1032
|
+
|
|
1033
|
+
let runtime;
|
|
1034
|
+
try {
|
|
1035
|
+
runtime = await (options.loadWarmRuntime ?? loadWarmRuntime)(env);
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
const payload = { command: "warm", storagePath, error: formatErrorForLog(error) };
|
|
1038
|
+
printWarmResult(payload, parsed.json);
|
|
1039
|
+
return { exitCode: 1, action: "warm", storagePath };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const { storageMod, usageMod, warmReqMod, warmMod, recoveryMod } = runtime;
|
|
1043
|
+
// Point dist storage at the resolved accounts file so a refreshed token is
|
|
1044
|
+
// persisted to the SAME file the rest of the toolchain reads.
|
|
1045
|
+
storageMod.setStoragePathDirect(storagePath);
|
|
1046
|
+
|
|
1047
|
+
let storage = null;
|
|
1048
|
+
try {
|
|
1049
|
+
storage = await storageMod.loadAccounts();
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
// Typed storage errors (e.g. UNSUPPORTED_SCHEMA_VERSION) carry the
|
|
1052
|
+
// upgrade hint; surface them rather than crashing the CLI.
|
|
1053
|
+
const hint = error && typeof error.hint === "string" ? ` ${error.hint}` : "";
|
|
1054
|
+
const payload = { command: "warm", storagePath, error: `${formatErrorForLog(error)}${hint}` };
|
|
1055
|
+
printWarmResult(payload, parsed.json);
|
|
1056
|
+
return { exitCode: 1, action: "warm", storagePath };
|
|
1057
|
+
}
|
|
1058
|
+
const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
|
|
1059
|
+
if (accounts.length === 0) {
|
|
1060
|
+
// `loadAccounts` swallows parse/IO errors and returns null. Probe the
|
|
1061
|
+
// file so a corrupt storage fails like `status`/`doctor` do (exit 1)
|
|
1062
|
+
// instead of reporting a healthy empty pool. ENOENT stays a silent
|
|
1063
|
+
// empty pool: a missing file legitimately means no accounts yet.
|
|
1064
|
+
const probe = await readStandaloneStorage(storagePath);
|
|
1065
|
+
if (probe.error) {
|
|
1066
|
+
const payload = { command: "warm", storagePath, error: probe.error };
|
|
1067
|
+
printWarmResult(payload, parsed.json);
|
|
1068
|
+
return { exitCode: 1, action: "warm", storagePath };
|
|
1069
|
+
}
|
|
1070
|
+
const payload = {
|
|
1071
|
+
command: "warm",
|
|
1072
|
+
storagePath,
|
|
1073
|
+
totalAccounts: 0,
|
|
1074
|
+
warmed: 0,
|
|
1075
|
+
blocksCleared: 0,
|
|
1076
|
+
failed: 0,
|
|
1077
|
+
skipped: 0,
|
|
1078
|
+
results: [],
|
|
1079
|
+
message: "No accounts configured.",
|
|
1080
|
+
nextAction: "Run opencode auth login.",
|
|
1081
|
+
};
|
|
1082
|
+
printWarmResult(payload, parsed.json);
|
|
1083
|
+
return { exitCode: 0, action: "warm", storagePath };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Same adapter as lib/tools/codex-warm.ts createWarmOne: refresh → resolve
|
|
1087
|
+
// account id → open the usage window; map an exhausted (quota-429) account
|
|
1088
|
+
// to a failure so it is not reported as warmed.
|
|
1089
|
+
const succeeded = [];
|
|
1090
|
+
const warmOne = async (account) => {
|
|
1091
|
+
const snapshot = { ...account, rateLimitResetTimes: { ...account.rateLimitResetTimes } };
|
|
1092
|
+
const { accessToken } = await usageMod.ensureCodexUsageAccessToken({ storage, account });
|
|
1093
|
+
const accountId = usageMod.resolveCodexUsageAccountId({ account, accessToken });
|
|
1094
|
+
if (!accountId) {
|
|
1095
|
+
return { status: "failed", detail: "could not resolve account id (re-login may be required)" };
|
|
1096
|
+
}
|
|
1097
|
+
const result = await warmReqMod.warmAccountWindow({
|
|
1098
|
+
accountId,
|
|
1099
|
+
accessToken,
|
|
1100
|
+
organizationId: account.organizationId,
|
|
1101
|
+
});
|
|
1102
|
+
if (result.status === "exhausted") {
|
|
1103
|
+
return { status: "failed", detail: result.detail ?? "quota/usage limit reached" };
|
|
1104
|
+
}
|
|
1105
|
+
if (!result.rateLimited && result.model) succeeded.push({
|
|
1106
|
+
account: { ...snapshot, refreshToken: account.refreshToken }, model: result.model, accessToken,
|
|
1107
|
+
});
|
|
1108
|
+
return { status: "warmed" };
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
const summary = await warmMod.warmAccounts(accounts, warmOne);
|
|
1112
|
+
let blocksCleared = 0;
|
|
1113
|
+
let blockClearError;
|
|
1114
|
+
for (const observation of succeeded) {
|
|
1115
|
+
let changed = false;
|
|
1116
|
+
try {
|
|
1117
|
+
const completed = await recoveryMod.recoverWarmedAccount(observation, () => { changed = true; });
|
|
1118
|
+
changed = completed || changed;
|
|
1119
|
+
} catch {
|
|
1120
|
+
blockClearError = "Failed to clear local blocks; warm results are unchanged.";
|
|
1121
|
+
}
|
|
1122
|
+
if (changed) blocksCleared++;
|
|
1123
|
+
}
|
|
1124
|
+
const payload = {
|
|
1125
|
+
command: "warm",
|
|
1126
|
+
blocksCleared,
|
|
1127
|
+
blockClearError,
|
|
1128
|
+
storagePath,
|
|
1129
|
+
totalAccounts: summary.total,
|
|
1130
|
+
warmed: summary.warmedCount,
|
|
1131
|
+
failed: summary.failedCount,
|
|
1132
|
+
skipped: summary.skippedCount,
|
|
1133
|
+
results: summary.results.map((r) => ({
|
|
1134
|
+
index: r.index,
|
|
1135
|
+
email: maskValue(accounts[r.index]?.email, parsed.includeSensitive),
|
|
1136
|
+
status: r.status,
|
|
1137
|
+
detail: r.detail,
|
|
1138
|
+
})),
|
|
1139
|
+
};
|
|
1140
|
+
printWarmResult(payload, parsed.json);
|
|
1141
|
+
return { exitCode: summary.failedCount > 0 ? 1 : 0, action: "warm", storagePath };
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function printWarmResult(payload, json) {
|
|
1145
|
+
if (json) {
|
|
1146
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
console.log(`oc-codex-multi-auth warm`);
|
|
1150
|
+
if (payload.message) console.log(payload.message);
|
|
1151
|
+
console.log(`Storage: ${payload.storagePath}`);
|
|
1152
|
+
if (payload.error) {
|
|
1153
|
+
console.log(`Error: ${payload.error}`);
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
console.log(`Accounts: ${payload.totalAccounts}`);
|
|
1157
|
+
for (const r of payload.results ?? []) {
|
|
1158
|
+
const label = r.email ? `[${r.index}] ${r.email}` : `[${r.index}]`;
|
|
1159
|
+
const detail = r.detail ? ` — ${r.detail}` : "";
|
|
1160
|
+
console.log(`- ${label}: ${r.status}${detail}`);
|
|
1161
|
+
}
|
|
1162
|
+
console.log(`Summary: ${payload.warmed} warmed, ${payload.failed} failed, ${payload.skipped} skipped`);
|
|
1163
|
+
console.log(`Blocks cleared: ${payload.blocksCleared ?? 0}`);
|
|
1164
|
+
if (payload.blockClearError) console.log(payload.blockClearError);
|
|
1165
|
+
if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* `limits` — show 5-hour and weekly Codex usage per account (#209).
|
|
1170
|
+
*
|
|
1171
|
+
* Reports the plugin's own last reading of each account by default, and reads
|
|
1172
|
+
* an account live only when the plugin holds none for it or under
|
|
1173
|
+
* `--refresh`. A live read goes through the compiled `codex-usage` runtime, so
|
|
1174
|
+
* the CLI reports the same windows as the in-conversation `codex-limits`
|
|
1175
|
+
* tool. The locally persisted
|
|
1176
|
+
* `rateLimitResetTimes` is carried in the payload as well: it is the only
|
|
1177
|
+
* rate-limit state available for an account whose live fetch fails, and
|
|
1178
|
+
* `--json` consumers of the previous behavior still find the field.
|
|
1179
|
+
*/
|
|
1180
|
+
export async function runLimitsCommand(parsed, options = {}) {
|
|
1181
|
+
const { env = process.env } = options;
|
|
1182
|
+
const storagePath = getStandaloneStoragePath(parsed, env);
|
|
1183
|
+
|
|
1184
|
+
let runtime;
|
|
1185
|
+
try {
|
|
1186
|
+
runtime = await (options.loadLimitsRuntime ?? loadLimitsRuntime)(env);
|
|
1187
|
+
} catch (error) {
|
|
1188
|
+
const payload = { command: "limits", storagePath, error: formatErrorForLog(error) };
|
|
1189
|
+
printLimitsResult(payload, parsed.json);
|
|
1190
|
+
return { exitCode: 1, action: "limits", storagePath };
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const {
|
|
1194
|
+
storageMod,
|
|
1195
|
+
usageMod,
|
|
1196
|
+
loggerMod,
|
|
1197
|
+
configMod,
|
|
1198
|
+
planMod,
|
|
1199
|
+
planTierMod,
|
|
1200
|
+
quotaCacheMod,
|
|
1201
|
+
quotaOverviewMod,
|
|
1202
|
+
themeMod,
|
|
1203
|
+
} = runtime;
|
|
1204
|
+
const pluginConfig = configMod.loadPluginConfig();
|
|
1205
|
+
const quotaDisplay = configMod.getQuotaDisplay(pluginConfig);
|
|
1206
|
+
const configuredSort = configMod.getLimitsSort?.(pluginConfig) ?? { by: "account", direction: "asc" };
|
|
1207
|
+
const sort = {
|
|
1208
|
+
by: parsed.sort ?? configuredSort.by,
|
|
1209
|
+
direction: parsed.direction ?? configuredSort.direction,
|
|
1210
|
+
};
|
|
1211
|
+
const render = {
|
|
1212
|
+
usageMod,
|
|
1213
|
+
quotaDisplay,
|
|
1214
|
+
color: !parsed.json && (themeMod?.shouldUseColor?.(process.stdout, env) ?? false),
|
|
1215
|
+
};
|
|
1216
|
+
const planNameOf = (planType) =>
|
|
1217
|
+
planTierMod?.formatPlanType?.(planType) ?? (typeof planType === "string" ? planType : null);
|
|
1218
|
+
// The badge is decoration; the report is the point. A runtime that arrived
|
|
1219
|
+
// without the plan module drops the `(5x)` rather than failing the account
|
|
1220
|
+
// it was attached to - the per-account catch below would otherwise turn one
|
|
1221
|
+
// missing module into an "Error:" line against every account in the pool.
|
|
1222
|
+
const planMultiplierOf = (planType) =>
|
|
1223
|
+
planMod?.formatPlanMultiplier?.(planType) ?? null;
|
|
1224
|
+
// Point dist storage at the resolved accounts file so a refreshed token is
|
|
1225
|
+
// persisted to the SAME file the rest of the toolchain reads.
|
|
1226
|
+
storageMod.setStoragePathDirect(storagePath);
|
|
1227
|
+
|
|
1228
|
+
let storage = null;
|
|
1229
|
+
try {
|
|
1230
|
+
storage = await storageMod.loadAccounts();
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
// Typed storage errors (e.g. UNSUPPORTED_SCHEMA_VERSION) carry the
|
|
1233
|
+
// upgrade hint; surface them rather than crashing the CLI.
|
|
1234
|
+
const hint = error && typeof error.hint === "string" ? ` ${error.hint}` : "";
|
|
1235
|
+
const payload = { command: "limits", storagePath, error: `${formatErrorForLog(error)}${hint}` };
|
|
1236
|
+
printLimitsResult(payload, parsed.json);
|
|
1237
|
+
return { exitCode: 1, action: "limits", storagePath };
|
|
1238
|
+
}
|
|
1239
|
+
const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
|
|
1240
|
+
if (accounts.length === 0) {
|
|
1241
|
+
// Same probe contract as `warm`: a corrupt file exits 1 like
|
|
1242
|
+
// `status`/`doctor`; a missing file stays a silent empty pool.
|
|
1243
|
+
const probe = await readStandaloneStorage(storagePath);
|
|
1244
|
+
if (probe.error) {
|
|
1245
|
+
const payload = { command: "limits", storagePath, error: probe.error };
|
|
1246
|
+
printLimitsResult(payload, parsed.json);
|
|
1247
|
+
return { exitCode: 1, action: "limits", storagePath };
|
|
1248
|
+
}
|
|
1249
|
+
const payload = {
|
|
1250
|
+
command: "limits",
|
|
1251
|
+
storagePath,
|
|
1252
|
+
totalAccounts: 0,
|
|
1253
|
+
// Same shape as a populated pool: `null` when nothing is readable.
|
|
1254
|
+
pool: null,
|
|
1255
|
+
poolSummary: null,
|
|
1256
|
+
readings: null,
|
|
1257
|
+
sort,
|
|
1258
|
+
accounts: [],
|
|
1259
|
+
message: "No accounts configured.",
|
|
1260
|
+
nextAction: "Run opencode auth login.",
|
|
1261
|
+
};
|
|
1262
|
+
printLimitsResult(payload, parsed.json);
|
|
1263
|
+
return { exitCode: 0, action: "limits", storagePath };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Same workspace dedupe the codex-limits tool applies, so two entries for one
|
|
1267
|
+
// workspace are not billed and printed twice.
|
|
1268
|
+
const indices = usageMod.deduplicateUsageAccountIndices(storage);
|
|
1269
|
+
// `--tag` must gate which accounts are contacted at all, not just which are
|
|
1270
|
+
// printed: an untagged account would otherwise be billed a usage fetch and
|
|
1271
|
+
// could have its refreshed credentials persisted.
|
|
1272
|
+
const normalizedTag =
|
|
1273
|
+
typeof parsed.tag === "string" ? parsed.tag.trim().toLowerCase() : "";
|
|
1274
|
+
const stateDir = resolveOpenCodeStateDir(env);
|
|
1275
|
+
const now = Date.now();
|
|
1276
|
+
// The plugin already polls every account's usage for the pool status line
|
|
1277
|
+
// and keeps the result on disk. Reading that instead of asking upstream
|
|
1278
|
+
// again is what makes this report instant, and it spares a large pool a
|
|
1279
|
+
// burst of usage requests. `--refresh` still reads every account live.
|
|
1280
|
+
const previous = await readPluginQuotaReadings(quotaCacheMod, quotaOverviewMod, stateDir);
|
|
1281
|
+
const cached = parsed.refresh ? undefined : previous;
|
|
1282
|
+
const results = [];
|
|
1283
|
+
// Only accounts that answered contribute to the pool total. An account that
|
|
1284
|
+
// failed to report is left out entirely rather than counted as full or as
|
|
1285
|
+
// empty, since either would state capacity nobody measured.
|
|
1286
|
+
const poolMembers = [];
|
|
1287
|
+
const sortKeys = new Map();
|
|
1288
|
+
const entryAccounts = new Map();
|
|
1289
|
+
const entryWorkspaceIds = new Map();
|
|
1290
|
+
const liveOverviewAccounts = [];
|
|
1291
|
+
let failedCount = 0;
|
|
1292
|
+
|
|
1293
|
+
const readLive = async (account, index, entry) => {
|
|
1294
|
+
const { accessToken } = await usageMod.ensureCodexUsageAccessToken({ storage, account });
|
|
1295
|
+
const accountId = usageMod.resolveCodexUsageAccountId({ account, accessToken });
|
|
1296
|
+
if (!accountId) {
|
|
1297
|
+
throw new Error("could not resolve account id (re-login may be required)");
|
|
1298
|
+
}
|
|
1299
|
+
entryWorkspaceIds.set(entry, accountId);
|
|
1300
|
+
const usage = usageMod.parseCodexUsagePayload(
|
|
1301
|
+
await usageMod.fetchCodexUsage({
|
|
1302
|
+
accountId,
|
|
1303
|
+
accessToken,
|
|
1304
|
+
organizationId: account.organizationId,
|
|
1305
|
+
}),
|
|
1306
|
+
quotaDisplay,
|
|
1307
|
+
);
|
|
1308
|
+
const quotaExhaustedResetAtMs = usageMod.getUsageQuotaExhaustedResetAtMs([
|
|
1309
|
+
usage.primary,
|
|
1310
|
+
usage.secondary,
|
|
1311
|
+
]);
|
|
1312
|
+
if (quotaExhaustedResetAtMs !== undefined) {
|
|
1313
|
+
try {
|
|
1314
|
+
await usageMod.persistUsageQuotaExhaustion(account, quotaExhaustedResetAtMs);
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
loggerMod.logWarn(
|
|
1317
|
+
`[${PACKAGE_NAME}] Failed to persist exhausted usage quota: ${formatErrorForLog(error)}`,
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
if (usageMod.isUsageQuotaRecovered([usage.primary, usage.secondary])) {
|
|
1322
|
+
try {
|
|
1323
|
+
await usageMod.persistUsageQuotaRecovery(account);
|
|
1324
|
+
} catch {
|
|
1325
|
+
loggerMod.logWarn("Failed to persist recovered usage quota");
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
const overviewAccount = quotaOverviewMod?.toOverviewAccount?.({
|
|
1329
|
+
// Taken after the fetch: a refresh above rotates the token the
|
|
1330
|
+
// fingerprint is derived from.
|
|
1331
|
+
fingerprint: usageMod.createUsageAccountFingerprint(account),
|
|
1332
|
+
index: index + 1,
|
|
1333
|
+
usage,
|
|
1334
|
+
email: account.email,
|
|
1335
|
+
label: account.accountLabel,
|
|
1336
|
+
});
|
|
1337
|
+
if (overviewAccount) liveOverviewAccounts.push({ ...overviewAccount, fetchedAt: now });
|
|
1338
|
+
return {
|
|
1339
|
+
source: "live",
|
|
1340
|
+
readAt: now,
|
|
1341
|
+
planType: usage.planType,
|
|
1342
|
+
windows: [usage.primary, usage.secondary],
|
|
1343
|
+
limits: usage.limits,
|
|
1344
|
+
credits: usage.credits,
|
|
1345
|
+
// Raw counts stay in `resetCredits` and the rendered line lives in
|
|
1346
|
+
// its own field: embedding the English summary inside the counts
|
|
1347
|
+
// object would make `--json` consumers parse presentation text to
|
|
1348
|
+
// reach a number that is already beside it.
|
|
1349
|
+
resetCredits: usage.resetCredits,
|
|
1350
|
+
resetCreditsSummary: usage.resetCredits
|
|
1351
|
+
? usageMod.formatResetCredits(usage.resetCredits)
|
|
1352
|
+
: null,
|
|
1353
|
+
};
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
for (const index of indices) {
|
|
1357
|
+
const account = accounts[index];
|
|
1358
|
+
if (!account) continue;
|
|
1359
|
+
if (
|
|
1360
|
+
normalizedTag &&
|
|
1361
|
+
!(
|
|
1362
|
+
Array.isArray(account.accountTags) &&
|
|
1363
|
+
account.accountTags.some((entry) => String(entry).toLowerCase() === normalizedTag)
|
|
1364
|
+
)
|
|
1365
|
+
) {
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
const entry = {
|
|
1369
|
+
index,
|
|
1370
|
+
label: account.accountLabel ?? `Account ${index + 1}`,
|
|
1371
|
+
email: maskValue(account.email, parsed.includeSensitive),
|
|
1372
|
+
rateLimitResetTimes: account.rateLimitResetTimes ?? {},
|
|
1373
|
+
quotaExhaustedUntil: account.quotaExhaustedUntil,
|
|
1374
|
+
};
|
|
1375
|
+
entryAccounts.set(entry, account);
|
|
1376
|
+
entryWorkspaceIds.set(entry, account.accountId);
|
|
1377
|
+
try {
|
|
1378
|
+
// An account the plugin has no reading for (added since its last
|
|
1379
|
+
// poll, or a different pool than the one it polled) is read live
|
|
1380
|
+
// rather than left blank.
|
|
1381
|
+
const cachedAccount = cached && findPluginQuotaReading(cached, account, usageMod);
|
|
1382
|
+
const reading = cachedAccount
|
|
1383
|
+
? toCachedLimitsReading(cachedAccount, usageMod, quotaDisplay)
|
|
1384
|
+
: await readLive(account, index, entry);
|
|
1385
|
+
poolMembers.push({
|
|
1386
|
+
planType: reading.planType,
|
|
1387
|
+
primary: reading.windows[0] ?? {},
|
|
1388
|
+
secondary: reading.windows[1] ?? {},
|
|
1389
|
+
});
|
|
1390
|
+
sortKeys.set(entry, readLimitsSortKeys(usageMod, reading.windows));
|
|
1391
|
+
entry.source = reading.source;
|
|
1392
|
+
entry.readAt = reading.readAt;
|
|
1393
|
+
entry.planType = reading.planType;
|
|
1394
|
+
entry.planName = planNameOf(reading.planType);
|
|
1395
|
+
entry.planMultiplier = planMultiplierOf(reading.planType);
|
|
1396
|
+
entry.credits = reading.credits;
|
|
1397
|
+
entry.resetCredits = reading.resetCredits;
|
|
1398
|
+
entry.resetCreditsSummary = reading.resetCreditsSummary;
|
|
1399
|
+
entry.limits = reading.limits;
|
|
1400
|
+
} catch (error) {
|
|
1401
|
+
// `ensureCodexUsageAccessToken` can surface a raw OAuth refresh
|
|
1402
|
+
// response, so the message is redacted through the logger's token
|
|
1403
|
+
// patterns before it reaches stdout, JSON output, or CI logs.
|
|
1404
|
+
// Truncation alone does not protect bearer/JWT/refresh-token material.
|
|
1405
|
+
entry.error = loggerMod.maskString(formatErrorForLog(error)).slice(0, 160);
|
|
1406
|
+
failedCount += 1;
|
|
1407
|
+
}
|
|
1408
|
+
results.push(entry);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
await attachWorkspaceNames({
|
|
1412
|
+
results,
|
|
1413
|
+
entryAccounts,
|
|
1414
|
+
entryWorkspaceIds,
|
|
1415
|
+
stateDir,
|
|
1416
|
+
refresh: parsed.refresh,
|
|
1417
|
+
lookup: async (account, readLive) => {
|
|
1418
|
+
// An account reported from the plugin's readings is not worth a
|
|
1419
|
+
// token refresh just to name it: only a stored token still valid
|
|
1420
|
+
// is used, and the name waits for a run that reads it live.
|
|
1421
|
+
const accessToken = readLive
|
|
1422
|
+
? (await usageMod.ensureCodexUsageAccessToken({ storage, account })).accessToken
|
|
1423
|
+
: typeof account.accessToken === "string" && account.expiresAt > Date.now()
|
|
1424
|
+
? account.accessToken
|
|
1425
|
+
: undefined;
|
|
1426
|
+
if (!accessToken) return undefined;
|
|
1427
|
+
const accountId = usageMod.resolveCodexUsageAccountId({ account, accessToken });
|
|
1428
|
+
if (!accountId) throw new Error("could not resolve account id");
|
|
1429
|
+
return usageMod.fetchCodexWorkspaceNames({
|
|
1430
|
+
accountId,
|
|
1431
|
+
accessToken,
|
|
1432
|
+
organizationId: account.organizationId,
|
|
1433
|
+
timeoutMs: WORKSPACE_NAME_LOOKUP_TIMEOUT_MS,
|
|
1434
|
+
});
|
|
1435
|
+
},
|
|
1436
|
+
warn: (message) => loggerMod.logWarn(`[${PACKAGE_NAME}] ${loggerMod.maskString(message)}`),
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
// Hand a full live read back to the plugin, so its status line and the next
|
|
1440
|
+
// `limits` see it too. Only a read of the plugin's own pool qualifies: a
|
|
1441
|
+
// `--tag` subset would drop every other account from the snapshot, and a
|
|
1442
|
+
// `--config-path` store is not the pool the status line describes.
|
|
1443
|
+
const allLive = results.every((entry) => entry.source !== "cache");
|
|
1444
|
+
if (!normalizedTag && !parsed.configPath && allLive && liveOverviewAccounts.length > 0) {
|
|
1445
|
+
try {
|
|
1446
|
+
await writePluginQuotaReadings({
|
|
1447
|
+
quotaCacheMod,
|
|
1448
|
+
stateDir,
|
|
1449
|
+
now,
|
|
1450
|
+
live: liveOverviewAccounts,
|
|
1451
|
+
previous,
|
|
1452
|
+
pool: indices.map((index) => ({ index, account: accounts[index] })).filter(({ account }) => account),
|
|
1453
|
+
usageMod,
|
|
1454
|
+
});
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
loggerMod.logWarn(
|
|
1457
|
+
`[${PACKAGE_NAME}] Failed to cache the pool quota snapshot: ${formatErrorForLog(error)}`,
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
const cachedEntries = results.filter((entry) => entry.source === "cache");
|
|
1463
|
+
const readings = results.some((entry) => entry.source)
|
|
1464
|
+
? {
|
|
1465
|
+
source: cachedEntries.length === 0
|
|
1466
|
+
? "live"
|
|
1467
|
+
: cachedEntries.length === results.filter((entry) => entry.source).length
|
|
1468
|
+
? "cache"
|
|
1469
|
+
: "mixed",
|
|
1470
|
+
// The newest reading names the report; any account read at another
|
|
1471
|
+
// moment carries its own time.
|
|
1472
|
+
readAt: cachedEntries.length === 0
|
|
1473
|
+
? now
|
|
1474
|
+
: Math.max(...cachedEntries.map((entry) => entry.readAt)),
|
|
1475
|
+
}
|
|
1476
|
+
: null;
|
|
1477
|
+
const pool = usageMod.summarizeUsagePool(poolMembers);
|
|
1478
|
+
const payload = {
|
|
1479
|
+
command: "limits",
|
|
1480
|
+
storagePath,
|
|
1481
|
+
totalAccounts: accounts.length,
|
|
1482
|
+
shownAccounts: results.length,
|
|
1483
|
+
// Both percentages are stated so a consumer never has to know which way
|
|
1484
|
+
// `quotaDisplay` was pointing to read them.
|
|
1485
|
+
pool: pool
|
|
1486
|
+
? {
|
|
1487
|
+
leftPercent: pool.leftPercent,
|
|
1488
|
+
usedPercent: 100 - pool.leftPercent,
|
|
1489
|
+
allotment: pool.allotment,
|
|
1490
|
+
countedAccounts: pool.countedAccounts,
|
|
1491
|
+
}
|
|
1492
|
+
: null,
|
|
1493
|
+
poolSummary: pool
|
|
1494
|
+
? usageMod.formatUsagePoolSummary(pool, quotaDisplay)
|
|
1495
|
+
: null,
|
|
1496
|
+
readings,
|
|
1497
|
+
sort,
|
|
1498
|
+
accounts: sortLimitsEntries(results, sortKeys, sort),
|
|
1499
|
+
};
|
|
1500
|
+
printLimitsResult(payload, parsed.json, render);
|
|
1501
|
+
return { exitCode: failedCount > 0 ? 1 : 0, action: "limits", storagePath };
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/** Where OpenCode keeps its state, and so where the plugin's quota caches live. */
|
|
1505
|
+
function resolveOpenCodeStateDir(env) {
|
|
1506
|
+
const explicit = env.OPENCODE_STATE_DIR?.trim();
|
|
1507
|
+
if (explicit) return explicit;
|
|
1508
|
+
const stateHome = env.XDG_STATE_HOME?.trim() || join(resolveHomeDirectory(env), ".local", "state");
|
|
1509
|
+
return join(stateHome, "opencode");
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* The plugin's last reading of the pool: the snapshot its status line polls,
|
|
1514
|
+
* with the request path's newer reading of the serving account folded in.
|
|
1515
|
+
*/
|
|
1516
|
+
async function readPluginQuotaReadings(quotaCacheMod, quotaOverviewMod, stateDir) {
|
|
1517
|
+
if (!quotaCacheMod?.readTuiQuotaOverviewSnapshot) return undefined;
|
|
1518
|
+
const raw = await quotaCacheMod.readTuiQuotaOverviewSnapshot(
|
|
1519
|
+
quotaCacheMod.getTuiQuotaOverviewCachePath(stateDir),
|
|
1520
|
+
);
|
|
1521
|
+
if (!raw) return undefined;
|
|
1522
|
+
const latest = await quotaCacheMod.readTuiQuotaSnapshot(
|
|
1523
|
+
quotaCacheMod.getTuiQuotaCachePath(stateDir),
|
|
1524
|
+
);
|
|
1525
|
+
const snapshot = quotaOverviewMod?.mergeOverviewWithLatestAccount?.(raw, latest) ?? raw;
|
|
1526
|
+
return { raw, snapshot };
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Pair an account with its entry in a plugin snapshot, by the credential
|
|
1531
|
+
* fingerprint alone. Pool position and email do not identify an account: one
|
|
1532
|
+
* email can hold a personal account and several workspace seats, so a looser
|
|
1533
|
+
* match could report one seat's quota as another's. An account whose token
|
|
1534
|
+
* has rotated since the plugin's poll is simply read live.
|
|
1535
|
+
*/
|
|
1536
|
+
function findPluginQuotaEntry(snapshot, account, usageMod) {
|
|
1537
|
+
const fingerprint = usageMod.createUsageAccountFingerprint(account);
|
|
1538
|
+
return snapshot.accounts.find((candidate) => candidate.fingerprint === fingerprint);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function findPluginQuotaReading(readings, account, usageMod) {
|
|
1542
|
+
const found = findPluginQuotaEntry(readings.snapshot, account, usageMod);
|
|
1543
|
+
if (!found) return undefined;
|
|
1544
|
+
return { account: found, readAt: found.fetchedAt ?? readings.snapshot.fetchedAt };
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* A window nobody has drawn from reports "now plus the window" as its reset.
|
|
1549
|
+
* The plugin now drops that reset, but a snapshot an older build wrote still
|
|
1550
|
+
* carries it, so it is recognized by lying a whole window after the reading.
|
|
1551
|
+
*/
|
|
1552
|
+
function isCachedWindowNotStarted(limit, readAt) {
|
|
1553
|
+
if (limit.usedPercent !== 0) return false;
|
|
1554
|
+
if (limit.resetAtMs === undefined) return true;
|
|
1555
|
+
const windowMs = (limit.windowMinutes ?? 0) * 60_000;
|
|
1556
|
+
return windowMs > 0 && limit.resetAtMs - readAt >= windowMs - 60_000;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
function toCachedLimitsReading(reading, usageMod, quotaDisplay) {
|
|
1560
|
+
const windows = reading.account.limits.map((limit) => {
|
|
1561
|
+
const usedPercent =
|
|
1562
|
+
typeof limit.usedPercent === "number"
|
|
1563
|
+
? limit.usedPercent
|
|
1564
|
+
: typeof limit.leftPercent === "number"
|
|
1565
|
+
? 100 - limit.leftPercent
|
|
1566
|
+
: undefined;
|
|
1567
|
+
const window = { usedPercent, windowMinutes: limit.windowMinutes };
|
|
1568
|
+
if (isCachedWindowNotStarted({ ...limit, usedPercent }, reading.readAt)) {
|
|
1569
|
+
return { ...window, notStarted: true };
|
|
1570
|
+
}
|
|
1571
|
+
return { ...window, resetAtMs: limit.resetAtMs };
|
|
1572
|
+
});
|
|
1573
|
+
const count = reading.account.resetCredits;
|
|
1574
|
+
const applicable = reading.account.resetCreditsApplicable;
|
|
1575
|
+
return {
|
|
1576
|
+
source: "cache",
|
|
1577
|
+
readAt: reading.readAt,
|
|
1578
|
+
planType: reading.account.planType ?? null,
|
|
1579
|
+
windows,
|
|
1580
|
+
limits: windows.map((window) =>
|
|
1581
|
+
usageMod.toUsageLimitPayload(
|
|
1582
|
+
usageMod.formatUsageLimitTitle(window.windowMinutes),
|
|
1583
|
+
window,
|
|
1584
|
+
quotaDisplay,
|
|
1585
|
+
),
|
|
1586
|
+
),
|
|
1587
|
+
// The snapshot keeps neither the credit balance nor the banked total
|
|
1588
|
+
// beside the redeemable count, so only what it does keep is reported.
|
|
1589
|
+
credits: null,
|
|
1590
|
+
resetCredits: null,
|
|
1591
|
+
resetCreditsSummary:
|
|
1592
|
+
typeof count === "number" && count > 0
|
|
1593
|
+
? `${count} ${typeof applicable === "number" ? "applicable now" : "banked"}`
|
|
1594
|
+
: null,
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/**
|
|
1599
|
+
* Write a live read of the whole pool as the plugin's snapshot, in the shape
|
|
1600
|
+
* its poller writes. The file is shared by every OpenCode window on the
|
|
1601
|
+
* machine, so it is written only when the result is at least as complete and
|
|
1602
|
+
* as current as what it replaces:
|
|
1603
|
+
*
|
|
1604
|
+
* - an account this run failed to read keeps its previous entry, and the
|
|
1605
|
+
* snapshot then keeps the older time, exactly as the poller does; an account
|
|
1606
|
+
* with no previous entry to keep means no write, because a snapshot missing
|
|
1607
|
+
* an account would judge the pool on a subset;
|
|
1608
|
+
* - a previous snapshot that describes a different pool is left alone, judged
|
|
1609
|
+
* by fingerprint or by pool position and email, since a rotated token
|
|
1610
|
+
* changes a fingerprint without changing the pool;
|
|
1611
|
+
* - a snapshot another process wrote while this run was reading is left
|
|
1612
|
+
* alone, since it is as new as this one or newer.
|
|
1613
|
+
*/
|
|
1614
|
+
async function writePluginQuotaReadings({ quotaCacheMod, stateDir, now, live, previous, pool, usageMod }) {
|
|
1615
|
+
if (!quotaCacheMod?.writeTuiQuotaOverviewSnapshot) return;
|
|
1616
|
+
const samePoolAs = (entry) =>
|
|
1617
|
+
pool.some(({ index, account }) =>
|
|
1618
|
+
entry.fingerprint === usageMod.createUsageAccountFingerprint(account) ||
|
|
1619
|
+
(entry.index === index + 1 &&
|
|
1620
|
+
typeof account.email === "string" &&
|
|
1621
|
+
entry.email?.trim().toLowerCase() === account.email.trim().toLowerCase()),
|
|
1622
|
+
);
|
|
1623
|
+
if (previous && !previous.raw.accounts.every(samePoolAs)) return;
|
|
1624
|
+
const accounts = [...live];
|
|
1625
|
+
let carriedOver = false;
|
|
1626
|
+
for (const { index, account } of pool) {
|
|
1627
|
+
if (accounts.some((entry) => entry.index === index + 1)) continue;
|
|
1628
|
+
const kept = previous && findPluginQuotaEntry(previous.raw, account, usageMod);
|
|
1629
|
+
if (!kept) return;
|
|
1630
|
+
accounts.push({ ...kept, fetchedAt: kept.fetchedAt ?? previous.raw.fetchedAt });
|
|
1631
|
+
carriedOver = true;
|
|
1632
|
+
}
|
|
1633
|
+
accounts.sort((left, right) => left.index - right.index);
|
|
1634
|
+
const path = quotaCacheMod.getTuiQuotaOverviewCachePath(stateDir);
|
|
1635
|
+
const current = await quotaCacheMod.readTuiQuotaOverviewSnapshot(path);
|
|
1636
|
+
if (JSON.stringify(current) !== JSON.stringify(previous?.raw)) return;
|
|
1637
|
+
await quotaCacheMod.writeTuiQuotaOverviewSnapshot(
|
|
1638
|
+
{
|
|
1639
|
+
version: quotaCacheMod.TUI_QUOTA_CACHE_VERSION,
|
|
1640
|
+
fetchedAt: carriedOver && previous ? Math.min(previous.raw.fetchedAt, now) : now,
|
|
1641
|
+
accounts,
|
|
1642
|
+
},
|
|
1643
|
+
path,
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
const WORKSPACE_NAME_CACHE_FILE = "oc-codex-multi-auth-workspace-names.json";
|
|
1648
|
+
const WORKSPACE_NAME_CACHE_VERSION = 1;
|
|
1649
|
+
// The name is decoration, so a slow lookup gives up long before a usage fetch
|
|
1650
|
+
// would.
|
|
1651
|
+
const WORKSPACE_NAME_LOOKUP_TIMEOUT_MS = 5_000;
|
|
1652
|
+
|
|
1653
|
+
async function readWorkspaceNameCache(stateDir) {
|
|
1654
|
+
const names = new Map();
|
|
1655
|
+
try {
|
|
1656
|
+
const parsed = JSON.parse(await readFile(join(stateDir, WORKSPACE_NAME_CACHE_FILE), "utf-8"));
|
|
1657
|
+
if (parsed?.version !== WORKSPACE_NAME_CACHE_VERSION || typeof parsed.accounts !== "object") {
|
|
1658
|
+
return names;
|
|
1659
|
+
}
|
|
1660
|
+
for (const [id, record] of Object.entries(parsed.accounts ?? {})) {
|
|
1661
|
+
const name = record?.name;
|
|
1662
|
+
if (name === null) names.set(id, null);
|
|
1663
|
+
else if (typeof name === "string") {
|
|
1664
|
+
names.set(id, name.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim() || null);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
} catch {
|
|
1668
|
+
// A missing or unreadable cache is an empty one.
|
|
1669
|
+
}
|
|
1670
|
+
return names;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
async function writeWorkspaceNameCache(stateDir, names, now) {
|
|
1674
|
+
const accounts = Object.fromEntries(
|
|
1675
|
+
[...names].map(([id, name]) => [id, { name, checkedAt: now }]),
|
|
1676
|
+
);
|
|
1677
|
+
await writeFileAtomic(
|
|
1678
|
+
join(stateDir, WORKSPACE_NAME_CACHE_FILE),
|
|
1679
|
+
`${JSON.stringify({ version: WORKSPACE_NAME_CACHE_VERSION, accounts }, null, 2)}\n`,
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* Name the Business workspace each account belongs to.
|
|
1685
|
+
*
|
|
1686
|
+
* Names are remembered on disk, because a workspace is renamed far more
|
|
1687
|
+
* rarely than `limits` is run: an account is asked about only the first time
|
|
1688
|
+
* it is seen, or under `--refresh`. One answer lists every workspace the login
|
|
1689
|
+
* is a member of, so an id another account already named is not asked about
|
|
1690
|
+
* again, and a personal account is remembered as having no name. The lookup
|
|
1691
|
+
* is decoration: a failure only drops the line.
|
|
1692
|
+
*/
|
|
1693
|
+
async function attachWorkspaceNames({ results, entryAccounts, entryWorkspaceIds, stateDir, refresh, lookup, warn }) {
|
|
1694
|
+
const known = refresh ? new Map() : await readWorkspaceNameCache(stateDir);
|
|
1695
|
+
let changed = false;
|
|
1696
|
+
for (const entry of results) {
|
|
1697
|
+
const workspaceId = entryWorkspaceIds.get(entry);
|
|
1698
|
+
if (!workspaceId || known.has(workspaceId) || entry.error) continue;
|
|
1699
|
+
try {
|
|
1700
|
+
const names = await lookup(entryAccounts.get(entry), entry.source === "live");
|
|
1701
|
+
if (!names) continue;
|
|
1702
|
+
for (const [id, name] of names) known.set(id, name);
|
|
1703
|
+
if (!known.has(workspaceId)) known.set(workspaceId, null);
|
|
1704
|
+
changed = true;
|
|
1705
|
+
} catch (error) {
|
|
1706
|
+
warn(`Failed to read workspace names: ${formatErrorForLog(error)}`);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
for (const entry of results) {
|
|
1710
|
+
const workspaceId = entryWorkspaceIds.get(entry);
|
|
1711
|
+
entry.workspaceName = (workspaceId && known.get(workspaceId)) ?? null;
|
|
1712
|
+
}
|
|
1713
|
+
if (!changed) return;
|
|
1714
|
+
try {
|
|
1715
|
+
await writeWorkspaceNameCache(stateDir, known, Date.now());
|
|
1716
|
+
} catch (error) {
|
|
1717
|
+
warn(`Failed to cache workspace names: ${formatErrorForLog(error)}`);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/**
|
|
1722
|
+
* What `--sort usage` and `--sort reset` compare: the account's governing
|
|
1723
|
+
* window, the one with the least headroom, since that is the one that stops a
|
|
1724
|
+
* request. Ties go to the later reset, so an account with both windows spent
|
|
1725
|
+
* is ranked by when it actually becomes usable again. Only the two windows
|
|
1726
|
+
* that govern ordinary requests count, matching the pool total, and a window
|
|
1727
|
+
* that has not started has no renewal to rank by.
|
|
1728
|
+
*/
|
|
1729
|
+
function readLimitsSortKeys(usageMod, windows) {
|
|
1730
|
+
let governing;
|
|
1731
|
+
for (const window of windows) {
|
|
1732
|
+
if (!usageMod.hasUsageWindow(window) || !Number.isFinite(window.usedPercent)) continue;
|
|
1733
|
+
if (
|
|
1734
|
+
!governing ||
|
|
1735
|
+
window.usedPercent > governing.usedPercent ||
|
|
1736
|
+
(window.usedPercent === governing.usedPercent &&
|
|
1737
|
+
(window.resetAtMs ?? -Infinity) > (governing.resetAtMs ?? -Infinity))
|
|
1738
|
+
) {
|
|
1739
|
+
governing = window;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
return {
|
|
1743
|
+
usedPercent: governing?.usedPercent,
|
|
1744
|
+
resetAtMs: governing && !governing.notStarted && Number.isFinite(governing.resetAtMs)
|
|
1745
|
+
? governing.resetAtMs
|
|
1746
|
+
: undefined,
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Accounts whose key is unknown (a failed fetch, a window not yet started)
|
|
1752
|
+
* sort last in either direction, and ties fall back to the account number so
|
|
1753
|
+
* the order is stable between runs.
|
|
1754
|
+
*/
|
|
1755
|
+
function sortLimitsEntries(entries, sortKeys, sort) {
|
|
1756
|
+
const keyOf = (entry) => {
|
|
1757
|
+
if (sort.by === "account") return entry.index;
|
|
1758
|
+
const keys = sortKeys.get(entry);
|
|
1759
|
+
return sort.by === "usage" ? keys?.usedPercent : keys?.resetAtMs;
|
|
1760
|
+
};
|
|
1761
|
+
const direction = sort.direction === "desc" ? -1 : 1;
|
|
1762
|
+
return [...entries].sort((left, right) => {
|
|
1763
|
+
const leftKey = keyOf(left);
|
|
1764
|
+
const rightKey = keyOf(right);
|
|
1765
|
+
if (leftKey === undefined && rightKey !== undefined) return 1;
|
|
1766
|
+
if (rightKey === undefined && leftKey !== undefined) return -1;
|
|
1767
|
+
if (leftKey !== undefined && leftKey !== rightKey) {
|
|
1768
|
+
return direction * (leftKey - rightKey);
|
|
1769
|
+
}
|
|
1770
|
+
return left.index - right.index;
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
const LIMITS_USAGE_COLORS = [
|
|
1775
|
+
[99, "\u001b[31m"],
|
|
1776
|
+
[80, "\u001b[38;5;208m"],
|
|
1777
|
+
[60, "\u001b[33m"],
|
|
1778
|
+
[0, "\u001b[32m"],
|
|
1779
|
+
];
|
|
1780
|
+
|
|
1781
|
+
/**
|
|
1782
|
+
* Colour keyed on consumption whatever `quotaDisplay` words it as. It follows
|
|
1783
|
+
* the rounded figure printed beside it, so `99% used` is never shown orange.
|
|
1784
|
+
*/
|
|
1785
|
+
function colorLimitsPercent(text, usedPercent, render) {
|
|
1786
|
+
if (!render?.color) return text;
|
|
1787
|
+
const code = LIMITS_USAGE_COLORS.find(([floor]) => usedPercent >= floor)?.[1];
|
|
1788
|
+
return code ? `${code}${text}\u001b[0m` : text;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function formatLimitsPercent(limit, render) {
|
|
1792
|
+
if (typeof limit.leftPercent !== "number") return "unavailable";
|
|
1793
|
+
const mode = render?.quotaDisplay ?? "free";
|
|
1794
|
+
const usedPercent = 100 - limit.leftPercent;
|
|
1795
|
+
const text = mode === "used" ? `${usedPercent}% used` : `${limit.leftPercent}% left`;
|
|
1796
|
+
return colorLimitsPercent(text, usedPercent, render);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function formatLimitsRenewal(limit, render, now) {
|
|
1800
|
+
if (limit.notStarted) return "not started (the window opens on first use)";
|
|
1801
|
+
const usageMod = render?.usageMod;
|
|
1802
|
+
if (!Number.isFinite(limit.resetAtMs) || !usageMod?.formatUsageResetTimestamp) return undefined;
|
|
1803
|
+
const at = usageMod.formatUsageResetTimestamp(limit.resetAtMs);
|
|
1804
|
+
if (!at) return undefined;
|
|
1805
|
+
if (limit.resetAtMs <= now) return `${at} (passed since this reading)`;
|
|
1806
|
+
const countdown = usageMod.formatUsageCountdown(limit.resetAtMs - now);
|
|
1807
|
+
return countdown ? `${at} (in ${countdown})` : at;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/** `2026-09-27 13:17:22 (14m ago)`. */
|
|
1811
|
+
function formatLimitsReadTime(readAt, render, now) {
|
|
1812
|
+
const usageMod = render?.usageMod;
|
|
1813
|
+
if (!Number.isFinite(readAt) || !usageMod?.formatUsageResetTimestamp) return undefined;
|
|
1814
|
+
const at = usageMod.formatUsageResetTimestamp(Math.floor(readAt / 1000) * 1000);
|
|
1815
|
+
const age = now - readAt >= 60_000 ? `${usageMod.formatUsageCountdown(now - readAt)} ago` : "just now";
|
|
1816
|
+
return `${at} (${age})`;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
function describeLimitsReadings(readings, render, now) {
|
|
1820
|
+
const at = readings && formatLimitsReadTime(readings.readAt, render, now);
|
|
1821
|
+
if (!at) return undefined;
|
|
1822
|
+
if (readings.source === "live") return `read live at ${at}`;
|
|
1823
|
+
return `the plugin's last readings, taken ${at}; --refresh reads every account live`;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function buildLimitsAccountRows(account, render, now, readings) {
|
|
1827
|
+
const rows = [];
|
|
1828
|
+
if (account.workspaceName) rows.push(["Business account", account.workspaceName]);
|
|
1829
|
+
if (account.error) {
|
|
1830
|
+
rows.push(["Error", account.error]);
|
|
1831
|
+
return rows;
|
|
1832
|
+
}
|
|
1833
|
+
for (const limit of account.limits ?? []) {
|
|
1834
|
+
rows.push([limit.name, formatLimitsPercent(limit, render)]);
|
|
1835
|
+
const renewal = formatLimitsRenewal(limit, render, now);
|
|
1836
|
+
if (renewal) rows.push(["Renews", renewal]);
|
|
1837
|
+
}
|
|
1838
|
+
if ((account.limits ?? []).length === 0) rows.push([null, "No usage windows reported yet."]);
|
|
1839
|
+
const planName = account.planName ?? account.planType;
|
|
1840
|
+
if (planName) {
|
|
1841
|
+
const allotment = account.planMultiplier ? ` (${account.planMultiplier})` : "";
|
|
1842
|
+
rows.push(["Plan", `${planName}${allotment}`]);
|
|
1843
|
+
}
|
|
1844
|
+
if (account.credits) rows.push(["Credits", account.credits]);
|
|
1845
|
+
const hasResets = account.resetCredits
|
|
1846
|
+
? account.resetCredits.available > 0
|
|
1847
|
+
: Boolean(account.resetCreditsSummary);
|
|
1848
|
+
if (hasResets) rows.push(["Resets", account.resetCreditsSummary]);
|
|
1849
|
+
// Only a reading taken at a different moment from the one the header
|
|
1850
|
+
// names gets its own time.
|
|
1851
|
+
if (readings && Number.isFinite(account.readAt) && account.readAt !== readings.readAt) {
|
|
1852
|
+
const at = formatLimitsReadTime(account.readAt, render, now);
|
|
1853
|
+
if (at) rows.push(["Read", account.source === "live" ? `${at}, live` : at]);
|
|
1854
|
+
}
|
|
1855
|
+
return rows;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function limitsKeyWidth(rows) {
|
|
1859
|
+
return Math.max(0, ...rows.filter(([key]) => key !== null).map(([key]) => key.length));
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
function printLimitsRows(rows, indent, width = limitsKeyWidth(rows)) {
|
|
1863
|
+
for (const [key, value] of rows) {
|
|
1864
|
+
console.log(key === null ? `${indent}${value}` : `${indent}${`${key}:`.padEnd(width + 1)} ${value}`);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function printLimitsResult(payload, json, render) {
|
|
1869
|
+
if (json) {
|
|
1870
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
console.log(`oc-codex-multi-auth limits`);
|
|
1874
|
+
if (payload.message) console.log(payload.message);
|
|
1875
|
+
if (payload.error) {
|
|
1876
|
+
printLimitsRows([["Storage", payload.storagePath], ["Error", payload.error]], "");
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
const now = Date.now();
|
|
1880
|
+
const readings = describeLimitsReadings(payload.readings, render, now);
|
|
1881
|
+
const header = [
|
|
1882
|
+
["Storage", payload.storagePath],
|
|
1883
|
+
["Accounts", String(payload.totalAccounts)],
|
|
1884
|
+
...(payload.sort ? [["Sort", `${payload.sort.by} (${payload.sort.direction})`]] : []),
|
|
1885
|
+
...(readings ? [["Readings", readings]] : []),
|
|
1886
|
+
];
|
|
1887
|
+
const footer = [
|
|
1888
|
+
...(payload.poolSummary ? [["Pool", payload.poolSummary]] : []),
|
|
1889
|
+
...(payload.nextAction ? [["Next", payload.nextAction]] : []),
|
|
1890
|
+
];
|
|
1891
|
+
// The header and the pool total share one column, and every account's rows
|
|
1892
|
+
// share another, so the report reads down straight edges rather than each
|
|
1893
|
+
// block lining up only with itself.
|
|
1894
|
+
const topWidth = limitsKeyWidth([...header, ...footer]);
|
|
1895
|
+
printLimitsRows(header, "", topWidth);
|
|
1896
|
+
const accountRows = (payload.accounts ?? []).map((account) => ({
|
|
1897
|
+
account,
|
|
1898
|
+
rows: buildLimitsAccountRows(account, render, now, payload.readings),
|
|
1899
|
+
}));
|
|
1900
|
+
const rowWidth = limitsKeyWidth(accountRows.flatMap(({ rows }) => rows));
|
|
1901
|
+
for (const { account, rows } of accountRows) {
|
|
1902
|
+
console.log("");
|
|
1903
|
+
const label = account.email ? `${account.label} (${account.email})` : account.label;
|
|
1904
|
+
console.log(`- [${account.index}] ${label}`);
|
|
1905
|
+
printLimitsRows(rows, " ", rowWidth);
|
|
1906
|
+
}
|
|
1907
|
+
if (footer.length > 0) {
|
|
1908
|
+
console.log("");
|
|
1909
|
+
printLimitsRows(footer, "", topWidth);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
export async function runStandaloneCommand(command, argv = [], options = {}) {
|
|
1914
|
+
const parsed = parseStandaloneArgs(argv);
|
|
1915
|
+
if (command === "diag") {
|
|
1916
|
+
command = "doctor";
|
|
1917
|
+
parsed.deep = true;
|
|
1918
|
+
}
|
|
1919
|
+
if (parsed.help) {
|
|
1920
|
+
printHelp();
|
|
1921
|
+
return { exitCode: 0, action: "help" };
|
|
1922
|
+
}
|
|
1923
|
+
if (command === "warm") {
|
|
1924
|
+
return runWarmCommand(parsed, options);
|
|
1925
|
+
}
|
|
1926
|
+
if (command === "limits") {
|
|
1927
|
+
return runLimitsCommand(parsed, options);
|
|
1928
|
+
}
|
|
1929
|
+
const { env = process.env } = options;
|
|
1930
|
+
const storagePath = getStandaloneStoragePath(parsed, env);
|
|
1931
|
+
const repairRequested = command === "doctor" && parsed.fix;
|
|
1932
|
+
let storage = null;
|
|
1933
|
+
let error = null;
|
|
1934
|
+
if (parsed.configPath || !repairRequested) {
|
|
1935
|
+
({ storage, error } = await readStandaloneStorage(storagePath));
|
|
1936
|
+
}
|
|
1937
|
+
const appliedFixes = [];
|
|
1938
|
+
const fixErrors = [];
|
|
1939
|
+
if (repairRequested && !error) {
|
|
1940
|
+
const previousKeychain = process.env.CODEX_KEYCHAIN;
|
|
1941
|
+
try {
|
|
1942
|
+
const loadDoctorRuntime = options.loadDoctorRuntime ?? (() => loadDistModules(
|
|
1943
|
+
["storage.js", "tools/doctor-repair.js", "shutdown.js"], "doctor",
|
|
1944
|
+
));
|
|
1945
|
+
const [storageMod, repairMod, shutdownMod] = await loadDoctorRuntime();
|
|
1946
|
+
// A CLI file selection must not read or replace the global keychain pool.
|
|
1947
|
+
if (parsed.configPath) process.env.CODEX_KEYCHAIN = "0";
|
|
1948
|
+
storageMod.setStoragePathDirect(storagePath);
|
|
1949
|
+
shutdownMod.setShutdownOwnsProcess(true);
|
|
1950
|
+
try {
|
|
1951
|
+
storage = await storageMod.loadAccounts();
|
|
1952
|
+
} catch (loadError) {
|
|
1953
|
+
// Typed storage errors (UNSUPPORTED_SCHEMA_VERSION, unknown V2)
|
|
1954
|
+
// carry exact in-tree copy plus an upgrade/recovery hint, and the
|
|
1955
|
+
// load never got far enough to attempt a repair. Surface them on
|
|
1956
|
+
// the error channel (exit 1) instead of the generic catch below,
|
|
1957
|
+
// which is reserved for unknown throws so upstream failure text
|
|
1958
|
+
// never reaches output unredacted.
|
|
1959
|
+
if (loadError && typeof loadError.code === "string") {
|
|
1960
|
+
const hint = typeof loadError.hint === "string" ? ` ${loadError.hint}` : "";
|
|
1961
|
+
error = `${formatErrorForLog(loadError)}${hint}`;
|
|
1962
|
+
} else {
|
|
1963
|
+
throw loadError;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
if (!storage && !error) {
|
|
1967
|
+
// `loadAccounts` swallows JSON parse/IO errors and returns null. In
|
|
1968
|
+
// default-path mode the pre-read above was skipped (keychain routing
|
|
1969
|
+
// may own the pool), so probe the JSON file here: a corrupt file must
|
|
1970
|
+
// surface as a parse error (exit 1) instead of "No accounts
|
|
1971
|
+
// configured" (exit 0). ENOENT stays silent - a missing file with an
|
|
1972
|
+
// empty keychain legitimately means no accounts yet. Skipped when a
|
|
1973
|
+
// typed load error already set `error`; the probe would only
|
|
1974
|
+
// overwrite the precise schema message with its own paraphrase.
|
|
1975
|
+
const probe = await readStandaloneStorage(storagePath);
|
|
1976
|
+
if (probe.error) error = probe.error;
|
|
1977
|
+
}
|
|
1978
|
+
if (!error) {
|
|
1979
|
+
const repair = await repairMod.repairDoctorAccounts(storage?.accounts ?? []);
|
|
1980
|
+
appliedFixes.push(...repair.appliedFixes);
|
|
1981
|
+
fixErrors.push(...repair.fixErrors);
|
|
1982
|
+
storage = (await storageMod.loadAccounts()) ?? storage;
|
|
1983
|
+
}
|
|
1984
|
+
} catch {
|
|
1985
|
+
fixErrors.push("Doctor repair could not complete. Check the selected storage file and installed runtime.");
|
|
1986
|
+
} finally {
|
|
1987
|
+
if (parsed.configPath) {
|
|
1988
|
+
if (previousKeychain === undefined) delete process.env.CODEX_KEYCHAIN;
|
|
1989
|
+
else process.env.CODEX_KEYCHAIN = previousKeychain;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
const accounts = summarizeStandaloneAccounts(storage, parsed.includeSensitive, parsed.tag);
|
|
1994
|
+
const totalAccounts = Array.isArray(storage?.accounts) ? storage.accounts.length : 0;
|
|
1995
|
+
const payload = {
|
|
1996
|
+
command,
|
|
1997
|
+
storagePath,
|
|
1998
|
+
totalAccounts,
|
|
1999
|
+
shownAccounts: accounts.length,
|
|
2000
|
+
activeIndex: typeof storage?.activeIndex === "number" ? storage.activeIndex : 0,
|
|
2001
|
+
activeIndexByFamily: storage?.activeIndexByFamily ?? {},
|
|
2002
|
+
accounts,
|
|
2003
|
+
error,
|
|
2004
|
+
};
|
|
2005
|
+
if (command === "dashboard") {
|
|
2006
|
+
payload.message = "Standalone dashboard server is not launched by this safe CLI; use status/list/limits/health or OpenCode codex-dashboard.";
|
|
2007
|
+
payload.nextAction = "Run oc-codex-multi-auth status or open OpenCode and call codex-dashboard.";
|
|
2008
|
+
} else if (command === "doctor") {
|
|
2009
|
+
payload.message = error ? "Storage could not be parsed." : totalAccounts > 0 ? "Local diagnostics completed." : "No accounts configured.";
|
|
2010
|
+
payload.deep = parsed.deep;
|
|
2011
|
+
payload.fixApplied = parsed.fix ? appliedFixes.length > 0 : undefined;
|
|
2012
|
+
if (parsed.fix) {
|
|
2013
|
+
payload.appliedFixes = appliedFixes;
|
|
2014
|
+
payload.fixErrors = fixErrors;
|
|
2015
|
+
}
|
|
2016
|
+
payload.nextAction = totalAccounts > 0 ? "Run oc-codex-multi-auth health --json for scriptable checks." : "Run opencode auth login.";
|
|
2017
|
+
} else if (command === "health") {
|
|
2018
|
+
payload.healthyCount = accounts.filter((account) => account.enabled && account.hasRefreshToken).length;
|
|
2019
|
+
payload.unhealthyCount = accounts.filter((account) => !account.enabled || !account.hasRefreshToken).length;
|
|
2020
|
+
} else if (command === "status") {
|
|
2021
|
+
payload.message = totalAccounts > 0 ? "Account storage loaded." : "No accounts configured.";
|
|
2022
|
+
}
|
|
2023
|
+
printStandaloneResult(command, payload, parsed.json);
|
|
2024
|
+
return { exitCode: error || fixErrors.length > 0 ? 1 : 0, action: command, storagePath };
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// Top-level keys inside `provider.openai` that the installer owns absolutely.
|
|
2028
|
+
// These are always sourced from the template (overwritten or removed) so the
|
|
2029
|
+
// plugin's required runtime shape is authoritative. Any OTHER key the user has
|
|
2030
|
+
// placed under `provider.openai` is preserved as-is. `models` is handled
|
|
2031
|
+
// separately because it's a map where user-added model ids must survive while
|
|
2032
|
+
// template-shipped ids win on collision.
|
|
2033
|
+
const MANAGED_OPENAI_KEYS = new Set(["baseURL", "apiKey", "options"]);
|
|
2034
|
+
|
|
2035
|
+
function isPlainObject(value) {
|
|
2036
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// Deep-merge `provider.openai` preserving unknown user keys while letting the
|
|
2040
|
+
// installer overwrite the managed shape it ships. This replaces the earlier
|
|
2041
|
+
// wholesale overwrite which clobbered custom user-added keys (see audit top-20
|
|
2042
|
+
// #6).
|
|
2043
|
+
function mergeOpenaiProvider(existingOpenai, templateOpenai, options = {}) {
|
|
2044
|
+
const existingSafe = isPlainObject(existingOpenai) ? existingOpenai : {};
|
|
2045
|
+
const templateSafe = isPlainObject(templateOpenai) ? templateOpenai : {};
|
|
2046
|
+
const modelKeysToRemove = options.modelKeysToRemove instanceof Set
|
|
2047
|
+
? options.modelKeysToRemove
|
|
2048
|
+
: new Set();
|
|
2049
|
+
|
|
2050
|
+
const result = {};
|
|
2051
|
+
|
|
2052
|
+
// 1. Start with the user's non-managed keys (unknown-to-installer settings).
|
|
2053
|
+
for (const [key, value] of Object.entries(existingSafe)) {
|
|
2054
|
+
if (MANAGED_OPENAI_KEYS.has(key)) continue;
|
|
2055
|
+
if (key === "models") continue; // handled explicitly below
|
|
2056
|
+
result[key] = value;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// 2. Apply template-managed keys. Installer is source of truth for these.
|
|
2060
|
+
for (const [key, value] of Object.entries(templateSafe)) {
|
|
2061
|
+
if (key === "models") continue; // handled explicitly below
|
|
2062
|
+
result[key] = value;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// 3. Merge `models` by id: template wins on collision, user-added ids survive.
|
|
2066
|
+
const existingModels = isPlainObject(existingSafe.models) ? existingSafe.models : {};
|
|
2067
|
+
const templateModels = isPlainObject(templateSafe.models) ? templateSafe.models : {};
|
|
2068
|
+
const prunedExistingModels = Object.fromEntries(
|
|
2069
|
+
Object.entries(existingModels).filter(([key]) => !modelKeysToRemove.has(key)),
|
|
2070
|
+
);
|
|
2071
|
+
const mergedModels = { ...prunedExistingModels, ...templateModels };
|
|
2072
|
+
if (Object.keys(mergedModels).length > 0) {
|
|
2073
|
+
result.models = mergedModels;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
return result;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
// Naive line-by-line diff for displaying config changes in dry-run. Good enough
|
|
2080
|
+
// for eyeballing; not intended to be parsed or round-tripped.
|
|
2081
|
+
function formatConfigDiff(existingConfig, nextConfig) {
|
|
2082
|
+
const oldText = existingConfig === undefined ? "" : formatJson(existingConfig);
|
|
2083
|
+
const newText = formatJson(nextConfig);
|
|
2084
|
+
if (oldText === newText) {
|
|
2085
|
+
return "(no changes)";
|
|
2086
|
+
}
|
|
2087
|
+
const lines = [];
|
|
2088
|
+
lines.push("--- existing");
|
|
2089
|
+
lines.push("+++ proposed");
|
|
2090
|
+
if (existingConfig === undefined) {
|
|
2091
|
+
lines.push("- (no existing config)");
|
|
2092
|
+
} else {
|
|
2093
|
+
for (const line of oldText.split("\n")) {
|
|
2094
|
+
lines.push(`- ${line}`);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
for (const line of newText.split("\n")) {
|
|
2098
|
+
lines.push(`+ ${line}`);
|
|
2099
|
+
}
|
|
2100
|
+
return lines.join("\n");
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function formatRedactedConfigDiff(existingConfig, nextConfig) {
|
|
2104
|
+
const missing = Symbol("missing");
|
|
2105
|
+
const changes = [];
|
|
2106
|
+
const visit = (existing, next, path) => {
|
|
2107
|
+
if (existing === missing) {
|
|
2108
|
+
changes.push(`+ ${path}`);
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
if (next === missing) {
|
|
2112
|
+
changes.push(`- ${path}`);
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
if (Object.is(existing, next)) return;
|
|
2116
|
+
|
|
2117
|
+
if (Array.isArray(existing) && Array.isArray(next)) {
|
|
2118
|
+
const length = Math.max(existing.length, next.length);
|
|
2119
|
+
for (let index = 0; index < length; index += 1) {
|
|
2120
|
+
visit(
|
|
2121
|
+
index < existing.length ? existing[index] : missing,
|
|
2122
|
+
index < next.length ? next[index] : missing,
|
|
2123
|
+
`${path}[${index}]`,
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
if (isPlainObject(existing) && isPlainObject(next)) {
|
|
2130
|
+
const keys = new Set([...Object.keys(existing), ...Object.keys(next)]);
|
|
2131
|
+
for (const key of keys) {
|
|
2132
|
+
visit(
|
|
2133
|
+
Object.hasOwn(existing, key) ? existing[key] : missing,
|
|
2134
|
+
Object.hasOwn(next, key) ? next[key] : missing,
|
|
2135
|
+
`${path}.${key}`,
|
|
2136
|
+
);
|
|
2137
|
+
}
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
changes.push(`~ ${path}`);
|
|
2142
|
+
};
|
|
2143
|
+
|
|
2144
|
+
visit(existingConfig === undefined ? missing : existingConfig, nextConfig, "$");
|
|
2145
|
+
return changes.length > 0 ? changes.join("\n") : "(no changes)";
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function mergeFullTemplate(modernTemplate, legacyTemplate) {
|
|
2149
|
+
const modernModels = modernTemplate.provider?.openai?.models ?? {};
|
|
2150
|
+
const legacyModels = legacyTemplate.provider?.openai?.models ?? {};
|
|
2151
|
+
const overlappingKeys = Object.keys(modernModels).filter((key) => Object.hasOwn(legacyModels, key));
|
|
2152
|
+
|
|
2153
|
+
if (overlappingKeys.length > 0) {
|
|
2154
|
+
throw new Error(`Full config template collision for model keys: ${overlappingKeys.join(", ")}`);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
return {
|
|
2158
|
+
...modernTemplate,
|
|
2159
|
+
provider: {
|
|
2160
|
+
...(modernTemplate.provider ?? {}),
|
|
2161
|
+
openai: {
|
|
2162
|
+
...(modernTemplate.provider?.openai ?? {}),
|
|
2163
|
+
models: {
|
|
2164
|
+
...modernModels,
|
|
2165
|
+
...legacyModels,
|
|
2166
|
+
},
|
|
2167
|
+
},
|
|
2168
|
+
},
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
function getTemplateModelKeys(template) {
|
|
2173
|
+
return new Set(Object.keys(template.provider?.openai?.models ?? {}));
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
async function readJson(filePath) {
|
|
2177
|
+
const content = await readFile(filePath, "utf-8");
|
|
2178
|
+
return JSON.parse(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content);
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
async function renameWithWindowsRetry(sourcePath, destinationPath) {
|
|
2182
|
+
let lastError = null;
|
|
2183
|
+
|
|
2184
|
+
for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
|
|
2185
|
+
try {
|
|
2186
|
+
await rename(sourcePath, destinationPath);
|
|
2187
|
+
return;
|
|
2188
|
+
} catch (error) {
|
|
2189
|
+
if (isWindowsLockError(error)) {
|
|
2190
|
+
lastError = error;
|
|
2191
|
+
await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
|
|
2192
|
+
continue;
|
|
2193
|
+
}
|
|
2194
|
+
throw error;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
if (lastError) {
|
|
2199
|
+
throw lastError;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
async function removeWithWindowsRetry(path, options) {
|
|
2204
|
+
let lastError = null;
|
|
2205
|
+
|
|
2206
|
+
for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
|
|
2207
|
+
try {
|
|
2208
|
+
await rm(path, options);
|
|
2209
|
+
return;
|
|
2210
|
+
} catch (error) {
|
|
2211
|
+
if (isWindowsLockError(error)) {
|
|
2212
|
+
lastError = error;
|
|
2213
|
+
await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
|
|
2214
|
+
continue;
|
|
2215
|
+
}
|
|
2216
|
+
throw error;
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
if (lastError) {
|
|
2221
|
+
throw lastError;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
async function writeFileAtomic(filePath, content) {
|
|
2226
|
+
const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
2227
|
+
const tempPath = `${filePath}.${uniqueSuffix}.tmp`;
|
|
2228
|
+
|
|
2229
|
+
try {
|
|
2230
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
2231
|
+
await writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
2232
|
+
await renameWithWindowsRetry(tempPath, filePath);
|
|
2233
|
+
} catch (error) {
|
|
2234
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
2235
|
+
throw error;
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
async function loadTemplate(mode, paths) {
|
|
2240
|
+
if (mode === "modern") {
|
|
2241
|
+
return readJson(paths.modernTemplatePath);
|
|
2242
|
+
}
|
|
2243
|
+
if (mode === "legacy") {
|
|
2244
|
+
return readJson(paths.legacyTemplatePath);
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
const [modernTemplate, legacyTemplate] = await Promise.all([
|
|
2248
|
+
readJson(paths.modernTemplatePath),
|
|
2249
|
+
readJson(paths.legacyTemplatePath),
|
|
2250
|
+
]);
|
|
2251
|
+
|
|
2252
|
+
return mergeFullTemplate(modernTemplate, legacyTemplate);
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
async function copyFileWithWindowsRetry(sourcePath, destinationPath) {
|
|
2256
|
+
let lastError = null;
|
|
2257
|
+
|
|
2258
|
+
for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
|
|
2259
|
+
try {
|
|
2260
|
+
await copyFile(sourcePath, destinationPath);
|
|
2261
|
+
return;
|
|
2262
|
+
} catch (error) {
|
|
2263
|
+
if (isWindowsLockError(error)) {
|
|
2264
|
+
lastError = error;
|
|
2265
|
+
await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
|
|
2266
|
+
continue;
|
|
2267
|
+
}
|
|
2268
|
+
throw error;
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
if (lastError) {
|
|
2273
|
+
throw lastError;
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
async function backupConfig(sourcePath, dryRun) {
|
|
2278
|
+
const timestamp = new Date()
|
|
2279
|
+
.toISOString()
|
|
2280
|
+
.replace(/[:.]/g, "-")
|
|
2281
|
+
.replace("T", "_")
|
|
2282
|
+
.replace("Z", "");
|
|
2283
|
+
const backupPath = `${sourcePath}.bak-${timestamp}`;
|
|
2284
|
+
if (!dryRun) {
|
|
2285
|
+
await copyFileWithWindowsRetry(sourcePath, backupPath);
|
|
2286
|
+
}
|
|
2287
|
+
return backupPath;
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
async function removePluginFromCachePackage(paths, dryRun) {
|
|
2291
|
+
if (!existsSync(paths.cachePackageJson)) {
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
if (!isEvictableCachePath(paths.cachePackageJson, paths.cacheDir)) {
|
|
2295
|
+
log(`Warning: refusing to update ${paths.cachePackageJson}: it does not resolve inside the OpenCode cache.`);
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
let cacheData;
|
|
2300
|
+
try {
|
|
2301
|
+
cacheData = await readJson(paths.cachePackageJson);
|
|
2302
|
+
} catch (error) {
|
|
2303
|
+
log(`Warning: Could not parse ${paths.cachePackageJson} (${formatErrorForLog(error)}). Skipping.`);
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
const sections = [
|
|
2308
|
+
"dependencies",
|
|
2309
|
+
"devDependencies",
|
|
2310
|
+
"peerDependencies",
|
|
2311
|
+
"optionalDependencies",
|
|
2312
|
+
];
|
|
2313
|
+
|
|
2314
|
+
let changed = false;
|
|
2315
|
+
for (const section of sections) {
|
|
2316
|
+
const deps = cacheData?.[section];
|
|
2317
|
+
if (deps && typeof deps === "object") {
|
|
2318
|
+
for (const name of getManagedPackageNames()) {
|
|
2319
|
+
if (name in deps) {
|
|
2320
|
+
delete deps[name];
|
|
2321
|
+
changed = true;
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
if (!changed) {
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
if (dryRun) {
|
|
2332
|
+
log(`[dry-run] Would update ${paths.cachePackageJson} to remove ${getManagedPackageNames().join(", ")}`);
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
await writeFileAtomic(paths.cachePackageJson, formatJson(cacheData));
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* Mirror of `isEvictableCachePath` in lib/auto-update-checker.ts. A recursive
|
|
2341
|
+
* delete must never act on a path that only spells like cache: the cache root
|
|
2342
|
+
* itself must not resolve through a symlink (`~/.cache/opencode -> ~` would
|
|
2343
|
+
* otherwise call the whole home directory "inside the cache"), and the
|
|
2344
|
+
* resolved target must stay inside the resolved root.
|
|
2345
|
+
*/
|
|
2346
|
+
function isEvictableCachePath(cachePath, cacheRoot) {
|
|
2347
|
+
const absolutePath = resolve(cachePath);
|
|
2348
|
+
const absoluteRoot = resolve(cacheRoot);
|
|
2349
|
+
if (!isInsideDirectory(absolutePath, absoluteRoot, process.platform)) return false;
|
|
2350
|
+
try {
|
|
2351
|
+
const realRoot = realpathSync(absoluteRoot);
|
|
2352
|
+
const rootIsSymlinked = process.platform === "win32"
|
|
2353
|
+
? realRoot.toLowerCase() !== absoluteRoot.toLowerCase()
|
|
2354
|
+
: realRoot !== absoluteRoot;
|
|
2355
|
+
if (rootIsSymlinked) return false;
|
|
2356
|
+
return isInsideDirectory(realpathSync(absolutePath), realRoot, process.platform);
|
|
2357
|
+
} catch {
|
|
2358
|
+
return false;
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
async function clearCache(paths, dryRun, skipCacheClear) {
|
|
2363
|
+
if (skipCacheClear) {
|
|
2364
|
+
log("Skipping cache clear (--no-cache-clear).");
|
|
2365
|
+
await removePluginFromCachePackage(paths, dryRun);
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
const cacheTargets = [
|
|
2370
|
+
...paths.cacheNodeModulesPaths,
|
|
2371
|
+
...paths.cachePackagePaths,
|
|
2372
|
+
paths.cacheBunLock,
|
|
2373
|
+
];
|
|
2374
|
+
|
|
2375
|
+
if (dryRun) {
|
|
2376
|
+
for (const cacheNodeModulesPath of paths.cacheNodeModulesPaths) {
|
|
2377
|
+
log(`[dry-run] Would remove ${cacheNodeModulesPath}`);
|
|
2378
|
+
}
|
|
2379
|
+
for (const cachePackagePath of paths.cachePackagePaths) {
|
|
2380
|
+
log(`[dry-run] Would remove ${cachePackagePath}`);
|
|
2381
|
+
}
|
|
2382
|
+
log(`[dry-run] Would remove ${paths.cacheBunLock}`);
|
|
2383
|
+
} else {
|
|
2384
|
+
for (const cacheTarget of cacheTargets) {
|
|
2385
|
+
if (!existsSync(cacheTarget)) continue;
|
|
2386
|
+
if (!isEvictableCachePath(cacheTarget, paths.cacheDir)) {
|
|
2387
|
+
log(`Warning: refusing to remove ${cacheTarget}: it does not resolve inside the OpenCode cache.`);
|
|
2388
|
+
continue;
|
|
2389
|
+
}
|
|
2390
|
+
await removeWithWindowsRetry(cacheTarget, {
|
|
2391
|
+
recursive: cacheTarget !== paths.cacheBunLock,
|
|
2392
|
+
force: true,
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
await removePluginFromCachePackage(paths, dryRun);
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
/** Route V2 installs without rewriting V1 entries or parallel JSONC config. */
|
|
2401
|
+
export async function runInstaller(argv = process.argv.slice(2), options = {}) {
|
|
2402
|
+
const split = splitCommandArgv(argv);
|
|
2403
|
+
if (split.kind === "standalone") {
|
|
2404
|
+
return runStandaloneCommand(split.command, split.argv, options);
|
|
2405
|
+
}
|
|
2406
|
+
if (split.kind === "unknown") {
|
|
2407
|
+
printHelp();
|
|
2408
|
+
throw new Error(`Unknown command: ${split.command}`);
|
|
2409
|
+
}
|
|
2410
|
+
const { env = process.env } = options;
|
|
2411
|
+
const paths = buildPaths(resolveHomeDirectory(env));
|
|
2412
|
+
if (split.kind === "update") {
|
|
2413
|
+
const parsedUpdate = parseUpdateArgs(split.argv);
|
|
2414
|
+
if (parsedUpdate.wantsHelp) {
|
|
2415
|
+
printHelp();
|
|
2416
|
+
return { exitCode: 0, action: "help" };
|
|
2417
|
+
}
|
|
2418
|
+
await clearCache(paths, parsedUpdate.dryRun, false);
|
|
2419
|
+
log(`\n${parsedUpdate.dryRun ? "Dry run complete." : "Cache cleared."} Restart OpenCode to install the latest plugin.`);
|
|
2420
|
+
return {
|
|
2421
|
+
exitCode: 0,
|
|
2422
|
+
action: "update",
|
|
2423
|
+
dryRun: Boolean(parsedUpdate.dryRun),
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
const parsed = parseCliArgs(split.argv);
|
|
2427
|
+
if (parsed.wantsHelp) {
|
|
2428
|
+
printHelp();
|
|
2429
|
+
return { exitCode: 0, action: "help" };
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
const { configMode, dryRun, skipCacheClear, pluginOnly } = parsed;
|
|
2433
|
+
if (parsed.v2) {
|
|
2434
|
+
if (existsSync(paths.jsoncConfigPath)) {
|
|
2435
|
+
throw new Error(`OpenCode config exists at ${paths.jsoncConfigPath}; edit its plugins list directly instead of writing a second config file.`);
|
|
2436
|
+
}
|
|
2437
|
+
const existing = existsSync(paths.configPath) ? await readJson(paths.configPath) : {};
|
|
2438
|
+
if (!isPlainObject(existing)) throw new Error("OpenCode config root must be an object");
|
|
2439
|
+
if (Array.isArray(existing.plugin) && existing.plugin.length > 0) {
|
|
2440
|
+
throw new Error("OpenCode V1 plugin entries are present. Use a separate V2 config or migrate them manually; --v2 will not remove your V1 registration.");
|
|
2441
|
+
}
|
|
2442
|
+
const next = { ...existing, plugins: normalizePluginList(existing.plugins, log, {
|
|
2443
|
+
baseDirectory: paths.configDir, cacheDirectory: paths.cacheDir,
|
|
2444
|
+
}) };
|
|
2445
|
+
next.$schema ??= "https://opencode.ai/config.json";
|
|
2446
|
+
if (dryRun) log(`[dry-run] Would register V2 plugin in ${paths.configPath}`);
|
|
2447
|
+
else if (formatJson(existing) !== formatJson(next)) {
|
|
2448
|
+
if (existsSync(paths.configPath)) await backupConfig(paths.configPath, false);
|
|
2449
|
+
await writeFileAtomic(paths.configPath, formatJson(next));
|
|
2450
|
+
}
|
|
2451
|
+
log(dryRun ? "V2 registration dry run complete." : "V2 plugin registered. Restart the OpenCode service to load it.");
|
|
2452
|
+
return { exitCode: 0, action: "install", dryRun: Boolean(dryRun), configMode: "v2" };
|
|
2453
|
+
}
|
|
2454
|
+
const effectiveConfigMode = pluginOnly ? "plugin-only" : configMode;
|
|
2455
|
+
const requiredTemplatePaths = pluginOnly
|
|
2456
|
+
? []
|
|
2457
|
+
: configMode === "modern"
|
|
2458
|
+
? [paths.modernTemplatePath]
|
|
2459
|
+
: configMode === "legacy"
|
|
2460
|
+
? [paths.legacyTemplatePath]
|
|
2461
|
+
: [paths.modernTemplatePath, paths.legacyTemplatePath];
|
|
2462
|
+
|
|
2463
|
+
for (const templatePath of requiredTemplatePaths) {
|
|
2464
|
+
if (!existsSync(templatePath)) {
|
|
2465
|
+
throw new Error(`Config template not found at ${templatePath}`);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
const template = pluginOnly
|
|
2470
|
+
? { $schema: "https://opencode.ai/config.json", plugin: [PACKAGE_NAME] }
|
|
2471
|
+
: await loadTemplate(configMode, paths);
|
|
2472
|
+
template.plugin = [PACKAGE_NAME];
|
|
2473
|
+
const modelKeysToRemove = new Set(STALE_MANAGED_MODEL_KEYS);
|
|
2474
|
+
if (!pluginOnly && configMode === "modern") {
|
|
2475
|
+
for (const key of getTemplateModelKeys(await readJson(paths.legacyTemplatePath))) {
|
|
2476
|
+
modelKeysToRemove.add(key);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
if (!pluginOnly && configMode === "legacy") {
|
|
2480
|
+
for (const key of getTemplateModelKeys(await readJson(paths.modernTemplatePath))) {
|
|
2481
|
+
modelKeysToRemove.add(key);
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
let existingConfig;
|
|
2486
|
+
if (existsSync(paths.configPath)) {
|
|
2487
|
+
try {
|
|
2488
|
+
const existing = await readJson(paths.configPath);
|
|
2489
|
+
if (!isPlainObject(existing)) {
|
|
2490
|
+
throw new Error("config root must be a JSON object");
|
|
2491
|
+
}
|
|
2492
|
+
existingConfig = existing;
|
|
2493
|
+
} catch (error) {
|
|
2494
|
+
if (pluginOnly) {
|
|
2495
|
+
throw new Error(
|
|
2496
|
+
`Could not parse existing config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`,
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
log(`Warning: Could not parse existing config (${formatErrorForLog(error)}). Replacing with template.`);
|
|
2500
|
+
existingConfig = undefined;
|
|
2501
|
+
}
|
|
2502
|
+
} else {
|
|
2503
|
+
log("No existing config found. Creating new global config.");
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
let existingTuiConfig;
|
|
2507
|
+
if (existsSync(paths.tuiConfigPath)) {
|
|
2508
|
+
try {
|
|
2509
|
+
const existing = await readJson(paths.tuiConfigPath);
|
|
2510
|
+
if (!isPlainObject(existing)) {
|
|
2511
|
+
throw new Error("TUI config root must be a JSON object");
|
|
2512
|
+
}
|
|
2513
|
+
existingTuiConfig = existing;
|
|
2514
|
+
} catch (error) {
|
|
2515
|
+
if (pluginOnly) {
|
|
2516
|
+
throw new Error(
|
|
2517
|
+
`Could not parse existing TUI config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`,
|
|
2518
|
+
);
|
|
2519
|
+
}
|
|
2520
|
+
log(`Warning: Could not parse existing TUI config (${formatErrorForLog(error)}). Replacing with minimal TUI config.`);
|
|
2521
|
+
existingTuiConfig = undefined;
|
|
2522
|
+
}
|
|
2523
|
+
} else {
|
|
2524
|
+
log("No existing TUI config found. Creating new global TUI config.");
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
// A checkout of this package registered in either file already loads the
|
|
2528
|
+
// plugin, so the published name must not be written beside it anywhere -
|
|
2529
|
+
// otherwise the checkout in opencode.json and the published package in
|
|
2530
|
+
// tui.json both load.
|
|
2531
|
+
const pluginListOptions = {
|
|
2532
|
+
baseDirectory: paths.configDir,
|
|
2533
|
+
cacheDirectory: paths.cacheDir,
|
|
2534
|
+
};
|
|
2535
|
+
const checkoutRegistered = [existingConfig?.plugin, existingTuiConfig?.plugin]
|
|
2536
|
+
.flatMap((list) => (Array.isArray(list) ? list : []))
|
|
2537
|
+
.some((entry) => {
|
|
2538
|
+
const classification = classifyPluginEntry(entry, pluginListOptions);
|
|
2539
|
+
return (
|
|
2540
|
+
classification.kind === LOCAL_CHECKOUT_ENTRY &&
|
|
2541
|
+
classification.name === PACKAGE_NAME
|
|
2542
|
+
);
|
|
2543
|
+
});
|
|
2544
|
+
const normalizeOptions = { ...pluginListOptions, checkoutRegistered };
|
|
2545
|
+
|
|
2546
|
+
let nextConfig;
|
|
2547
|
+
if (existingConfig !== undefined) {
|
|
2548
|
+
const merged = { ...existingConfig };
|
|
2549
|
+
merged.plugin = normalizePluginList(existingConfig.plugin, log, normalizeOptions);
|
|
2550
|
+
if (!pluginOnly) {
|
|
2551
|
+
const provider = (existingConfig.provider && typeof existingConfig.provider === "object")
|
|
2552
|
+
? { ...existingConfig.provider }
|
|
2553
|
+
: {};
|
|
2554
|
+
provider.openai = mergeOpenaiProvider(existingConfig.provider?.openai, template.provider?.openai, {
|
|
2555
|
+
modelKeysToRemove,
|
|
2556
|
+
});
|
|
2557
|
+
merged.provider = provider;
|
|
2558
|
+
}
|
|
2559
|
+
nextConfig = merged;
|
|
2560
|
+
} else {
|
|
2561
|
+
nextConfig = pluginOnly
|
|
2562
|
+
? { $schema: template.$schema, plugin: [PACKAGE_NAME] }
|
|
2563
|
+
: template;
|
|
2564
|
+
nextConfig.plugin = normalizePluginList(nextConfig.plugin, log, normalizeOptions);
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
const nextTuiConfig = mergeTuiConfig(existingTuiConfig, log, normalizeOptions);
|
|
2568
|
+
|
|
2569
|
+
const unregisteredCheckout = findUnregisteredLocalCheckout(nextConfig.plugin, paths.originHistoryPath, {
|
|
2570
|
+
baseDirectory: paths.configDir,
|
|
2571
|
+
cacheDirectory: paths.cacheDir,
|
|
2572
|
+
});
|
|
2573
|
+
if (unregisteredCheckout) {
|
|
2574
|
+
log(
|
|
2575
|
+
`Note: this plugin last loaded from a checkout at ${unregisteredCheckout.root} on ${unregisteredCheckout.lastSeen}, ` +
|
|
2576
|
+
`which ${paths.configPath} does not register. Point the plugin entry back at that path if OpenCode should keep loading your own build.`,
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
const configChanged = existingConfig === undefined || formatJson(existingConfig) !== formatJson(nextConfig);
|
|
2581
|
+
const tuiConfigChanged = existingTuiConfig === undefined || formatJson(existingTuiConfig) !== formatJson(nextTuiConfig);
|
|
2582
|
+
let wrote = false;
|
|
2583
|
+
if (dryRun) {
|
|
2584
|
+
log(`[dry-run] ${configChanged ? "Would write" : "Would leave unchanged"} ${paths.configPath} using ${effectiveConfigMode} config`);
|
|
2585
|
+
log(`[dry-run] Diff for ${paths.configPath}:`);
|
|
2586
|
+
log(formatRedactedConfigDiff(existingConfig, nextConfig));
|
|
2587
|
+
log(`[dry-run] ${tuiConfigChanged ? "Would write" : "Would leave unchanged"} ${paths.tuiConfigPath} with the TUI status plugin`);
|
|
2588
|
+
log(`[dry-run] Diff for ${paths.tuiConfigPath}:`);
|
|
2589
|
+
log(formatRedactedConfigDiff(existingTuiConfig, nextTuiConfig));
|
|
2590
|
+
} else {
|
|
2591
|
+
if (configChanged) {
|
|
2592
|
+
if (existsSync(paths.configPath)) {
|
|
2593
|
+
const backupPath = await backupConfig(paths.configPath, false);
|
|
2594
|
+
log(`Backup created: ${backupPath}`);
|
|
2595
|
+
}
|
|
2596
|
+
await writeFileAtomic(paths.configPath, formatJson(nextConfig));
|
|
2597
|
+
wrote = true;
|
|
2598
|
+
log(`Wrote ${paths.configPath} (${effectiveConfigMode} config)`);
|
|
2599
|
+
} else {
|
|
2600
|
+
log(`Left ${paths.configPath} unchanged`);
|
|
2601
|
+
}
|
|
2602
|
+
if (tuiConfigChanged) {
|
|
2603
|
+
if (existsSync(paths.tuiConfigPath)) {
|
|
2604
|
+
const backupPath = await backupConfig(paths.tuiConfigPath, false);
|
|
2605
|
+
log(`Backup created: ${backupPath}`);
|
|
2606
|
+
}
|
|
2607
|
+
await writeFileAtomic(paths.tuiConfigPath, formatJson(nextTuiConfig));
|
|
2608
|
+
wrote = true;
|
|
2609
|
+
log(`Wrote ${paths.tuiConfigPath} (TUI status plugin)`);
|
|
2610
|
+
} else {
|
|
2611
|
+
log(`Left ${paths.tuiConfigPath} unchanged`);
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
await clearCache(paths, dryRun, skipCacheClear);
|
|
2616
|
+
|
|
2617
|
+
log("\nDone. Restart OpenCode to (re)install the plugin.");
|
|
2618
|
+
log("Example: opencode");
|
|
2619
|
+
if (!pluginOnly && configMode === "modern") {
|
|
2620
|
+
log("Note: Modern config intentionally shows 10 base OAuth model entries; use the variant picker for reasoning presets.");
|
|
2621
|
+
}
|
|
2622
|
+
if (!pluginOnly && configMode === "legacy") {
|
|
2623
|
+
log("Note: Legacy config writes 53 explicit preset entries and is also safe for older OpenCode versions.");
|
|
2624
|
+
}
|
|
2625
|
+
if (!pluginOnly && configMode === "full") {
|
|
2626
|
+
log("Note: Full config installs both compact base models and explicit preset entries for direct selector IDs.");
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
return {
|
|
2630
|
+
exitCode: 0,
|
|
2631
|
+
action: "install",
|
|
2632
|
+
configMode: effectiveConfigMode,
|
|
2633
|
+
pluginOnly,
|
|
2634
|
+
configPath: paths.configPath,
|
|
2635
|
+
tuiConfigPath: paths.tuiConfigPath,
|
|
2636
|
+
dryRun: Boolean(dryRun),
|
|
2637
|
+
wrote,
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
export const __test = {
|
|
2642
|
+
ORIGIN_HISTORY_FILE_NAME,
|
|
2643
|
+
buildPaths,
|
|
2644
|
+
backupConfig,
|
|
2645
|
+
classifyPluginEntry,
|
|
2646
|
+
copyFileWithWindowsRetry,
|
|
2647
|
+
findUnregisteredLocalCheckout,
|
|
2648
|
+
formatConfigDiff,
|
|
2649
|
+
formatRedactedConfigDiff,
|
|
2650
|
+
mergeFullTemplate,
|
|
2651
|
+
mergeOpenaiProvider,
|
|
2652
|
+
mergeTuiConfig,
|
|
2653
|
+
normalizePluginList,
|
|
2654
|
+
parseCliArgs,
|
|
2655
|
+
removeWithWindowsRetry,
|
|
2656
|
+
runStandaloneCommand,
|
|
2657
|
+
splitCommandArgv,
|
|
2658
|
+
writeFileAtomic,
|
|
2659
|
+
renameWithWindowsRetry,
|
|
2660
|
+
resolveHomeDirectory,
|
|
2661
|
+
};
|