@carjms/codexswitch 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +3 -1
- package/README.md +3 -1
- package/bin/codex-switch.js +2 -1
- package/package.json +1 -1
- package/src/cli.js +94 -29
- package/src/ui.js +35 -0
- package/src/util.js +6 -3
package/README.ko.md
CHANGED
|
@@ -203,7 +203,8 @@ cxs list # 계정별 5h/week 사용량 % 확인
|
|
|
203
203
|
| `cxs login [이름]` | 새 계정 로그인 후 저장 (기존 로그인 유지, 이름 생략 시 이메일 사용) |
|
|
204
204
|
| `cxs import [이름]` | 현재 `~/.codex`에 로그인된 계정을 저장소로 가져오기 |
|
|
205
205
|
| `cxs add-key <이름> [키]` | OpenAI API 키 계정 등록 (키 생략 시 `$OPENAI_API_KEY` 사용) |
|
|
206
|
-
| `cxs list` | 계정 목록: 활성
|
|
206
|
+
| `cxs list` | 계정 목록: 활성 표시, 이메일, 플랜, 우선순위, 한도 상태, 사용량 % |
|
|
207
|
+
| `cxs usage [이름]` | 계정별 사용량 대시보드: 5시간/주간 게이지 바, 리셋 카운트다운, 다음 로테이션 계정 (별칭: `status`) |
|
|
207
208
|
| `cxs use <이름>` | 활성 계정 전환 |
|
|
208
209
|
| `cxs current` | 현재 활성 계정 확인 |
|
|
209
210
|
| `cxs next` | 설정된 순서의 다음 계정으로 전환 (끝에 오면 처음으로 순환) |
|
|
@@ -217,6 +218,7 @@ cxs list # 계정별 5h/week 사용량 % 확인
|
|
|
217
218
|
| `cxs export <파일>` | 전체 계정·설정 백업 (⚠️ 토큰 포함 — 비밀번호처럼 취급) |
|
|
218
219
|
| `cxs restore <파일>` | 백업 파일에서 계정 복원 (다른 PC 이전용) |
|
|
219
220
|
| `cxs completion <bash\|zsh>` | 셸 자동완성 스크립트 출력 |
|
|
221
|
+
| `cxs <그 외 명령>` | codex로 그대로 전달 — `cxs resume`, `cxs goal ...`, `cxs apply` 등 codex의 모든 명령을 관리 계정으로 바로 사용 가능 |
|
|
220
222
|
| `cxs remove <이름>` | 계정 삭제 |
|
|
221
223
|
| `cxs rename <옛이름> <새이름>` | 계정 이름 변경 |
|
|
222
224
|
| `cxs disable / enable <이름>` | 로테이션에서 임시 제외 / 복귀 |
|
package/README.md
CHANGED
|
@@ -205,7 +205,8 @@ cxs list # per-account 5h/week usage columns
|
|
|
205
205
|
| `cxs login [name]` | Log in to a new account and store it (existing login untouched; defaults to the email as the name) |
|
|
206
206
|
| `cxs import [name]` | Import the account currently in `~/.codex` |
|
|
207
207
|
| `cxs add-key <name> [key]` | Register an OpenAI API-key account (falls back to `$OPENAI_API_KEY`) |
|
|
208
|
-
| `cxs list` | List accounts: active marker
|
|
208
|
+
| `cxs list` | List accounts: active marker, email, plan, priority, limit status, usage % |
|
|
209
|
+
| `cxs usage [name]` | Per-account usage dashboard: 5h/weekly gauge bars, reset countdowns, next rotation pick (alias: `status`) |
|
|
209
210
|
| `cxs use <name>` | Switch the active account |
|
|
210
211
|
| `cxs current` | Show the active account |
|
|
211
212
|
| `cxs next` | Switch to the next account in rotation order (wraps around) |
|
|
@@ -219,6 +220,7 @@ cxs list # per-account 5h/week usage columns
|
|
|
219
220
|
| `cxs export <file>` | Back up all accounts + settings (⚠️ contains tokens — treat like a password) |
|
|
220
221
|
| `cxs restore <file>` | Restore accounts from a backup (for moving machines) |
|
|
221
222
|
| `cxs completion <bash\|zsh>` | Print a shell completion script |
|
|
223
|
+
| `cxs <anything else>` | Forwarded to codex under the managed account — `cxs resume`, `cxs goal ...`, `cxs apply` all work like their codex counterparts |
|
|
222
224
|
| `cxs remove <name>` | Delete an account |
|
|
223
225
|
| `cxs rename <old> <new>` | Rename an account |
|
|
224
226
|
| `cxs disable / enable <name>` | Temporarily exclude from / restore to rotation |
|
package/bin/codex-switch.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
require('../src/cli.js').main(process.argv.slice(2)).then(
|
|
5
5
|
(code) => process.exit(code || 0),
|
|
6
6
|
(err) => {
|
|
7
|
-
|
|
7
|
+
const ui = require('../src/ui.js');
|
|
8
|
+
console.error(ui.fail(`${ui.red('error:')} ${err && err.message ? err.message : err}`));
|
|
8
9
|
process.exit(1);
|
|
9
10
|
}
|
|
10
11
|
);
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('path');
|
|
|
6
6
|
const { readJSONSafe, writeJSONAtomic, authInfo, ensureDir, fmtDate, fmtRemaining, table } = require('./util.js');
|
|
7
7
|
const store = require('./store.js');
|
|
8
8
|
const runner = require('./runner.js');
|
|
9
|
+
const ui = require('./ui.js');
|
|
9
10
|
|
|
10
11
|
const HELP = `codexswitch — multi-account manager for the OpenAI Codex CLI
|
|
11
12
|
|
|
@@ -13,7 +14,9 @@ Accounts
|
|
|
13
14
|
login [name] log in to a new account (isolated "codex login") and store it
|
|
14
15
|
import [name] import the account currently in ~/.codex/auth.json
|
|
15
16
|
add-key <name> [key] register an OpenAI API-key account (or read $OPENAI_API_KEY)
|
|
16
|
-
list list stored accounts (alias: accounts
|
|
17
|
+
list list stored accounts (alias: accounts)
|
|
18
|
+
usage [name] per-account 5h/weekly usage gauges with reset times
|
|
19
|
+
(alias: status)
|
|
17
20
|
use <name> make <name> the active account in ~/.codex
|
|
18
21
|
current show the active account
|
|
19
22
|
next switch to the next account in rotation order (wraps around)
|
|
@@ -48,6 +51,10 @@ Maintenance
|
|
|
48
51
|
completion <bash|zsh> print a shell completion script
|
|
49
52
|
help show this help
|
|
50
53
|
|
|
54
|
+
Anything else is forwarded to codex under the managed account, so commands
|
|
55
|
+
like "codexswitch resume", "codexswitch goal ..." or "codexswitch apply"
|
|
56
|
+
work exactly like their codex counterparts.
|
|
57
|
+
|
|
51
58
|
Environment
|
|
52
59
|
CODEX_SWITCH_HOME data dir (default ~/.codex-switch)
|
|
53
60
|
CODEX_HOME codex config dir codexswitch manages (default ~/.codex)
|
|
@@ -84,7 +91,7 @@ function storeAccount(name, auth) {
|
|
|
84
91
|
delete meta.accounts[name].disabled;
|
|
85
92
|
delete meta.accounts[name].limitedUntil;
|
|
86
93
|
store.saveMeta(meta);
|
|
87
|
-
out(`${existed ? 'updated' : 'added'} account "${name}"${info.email ? ` (${info.email}, ${info.plan || 'unknown plan'})` : ''}`);
|
|
94
|
+
out(ui.ok(`${existed ? 'updated' : 'added'} account ${ui.bold(`"${name}"`)}${info.email ? ui.dim(` (${info.email}, ${info.plan || 'unknown plan'})`) : ''}`));
|
|
88
95
|
return name;
|
|
89
96
|
}
|
|
90
97
|
|
|
@@ -107,7 +114,7 @@ function cmdLogin(args) {
|
|
|
107
114
|
store.ensureDirs();
|
|
108
115
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codexswitch-login-'));
|
|
109
116
|
try {
|
|
110
|
-
out('opening codex login in an isolated profile (your current account is untouched)...');
|
|
117
|
+
out(ui.info('opening codex login in an isolated profile (your current account is untouched)...'));
|
|
111
118
|
const r = runner.spawnCodexSync(['login'], {
|
|
112
119
|
env: { ...process.env, CODEX_HOME: tmp },
|
|
113
120
|
stdio: 'inherit',
|
|
@@ -136,30 +143,34 @@ function cmdList() {
|
|
|
136
143
|
}
|
|
137
144
|
const now = Date.now();
|
|
138
145
|
const meta = store.loadMeta();
|
|
139
|
-
const pct = (win) =>
|
|
146
|
+
const pct = (win, threshold) => {
|
|
147
|
+
if (!win || typeof win.pct !== 'number') return ui.dim('-');
|
|
148
|
+
const v = `${Math.round(win.pct)}%`;
|
|
149
|
+
return win.pct >= threshold ? ui.red(v) : win.pct >= 70 ? ui.yellow(v) : ui.green(v);
|
|
150
|
+
};
|
|
140
151
|
const rows = accounts.map((a) => {
|
|
141
152
|
const over = store.overThreshold(a, meta, now);
|
|
142
153
|
const status = a.disabled
|
|
143
|
-
? 'disabled'
|
|
154
|
+
? ui.red('disabled')
|
|
144
155
|
: a.limitedUntil && a.limitedUntil > now
|
|
145
|
-
? `limited ${fmtRemaining(a.limitedUntil)}`
|
|
156
|
+
? ui.yellow(`limited ${fmtRemaining(a.limitedUntil)}`)
|
|
146
157
|
: over
|
|
147
|
-
? `over-${over}`
|
|
148
|
-
: 'ok';
|
|
158
|
+
? ui.yellow(`over-${over}`)
|
|
159
|
+
: ui.green('ok');
|
|
149
160
|
return [
|
|
150
|
-
a.active ? '
|
|
151
|
-
a.name,
|
|
152
|
-
a.email,
|
|
161
|
+
a.active ? ui.green('●') : '',
|
|
162
|
+
a.active ? ui.bold(a.name) : a.name,
|
|
163
|
+
ui.dim(a.email || '-'),
|
|
153
164
|
a.plan,
|
|
154
165
|
a.priority,
|
|
155
166
|
status,
|
|
156
|
-
pct(a.usage && a.usage.p5h),
|
|
157
|
-
pct(a.usage && a.usage.weekly),
|
|
158
|
-
fmtDate(a.lastRefresh),
|
|
167
|
+
pct(a.usage && a.usage.p5h, meta.threshold5h),
|
|
168
|
+
pct(a.usage && a.usage.weekly, meta.thresholdWeekly),
|
|
169
|
+
ui.dim(fmtDate(a.lastRefresh)),
|
|
159
170
|
];
|
|
160
171
|
});
|
|
161
172
|
out(table(rows, ['', 'name', 'email', 'plan', 'prio', 'status', '5h', 'week', 'token refreshed']));
|
|
162
|
-
out(`(rotate threshold: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}% — change with "codexswitch threshold")`);
|
|
173
|
+
out(ui.dim(`(rotate threshold: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}% — change with "codexswitch threshold")`));
|
|
163
174
|
}
|
|
164
175
|
|
|
165
176
|
function cmdUse(args) {
|
|
@@ -175,7 +186,7 @@ function cmdUse(args) {
|
|
|
175
186
|
meta.accounts[name].lastUsed = Date.now();
|
|
176
187
|
store.saveMeta(meta);
|
|
177
188
|
const info = authInfo(auth);
|
|
178
|
-
out(`now using "${name}"${info.email ? ` (${info.email}, ${info.plan || 'unknown plan'})` : ''}`);
|
|
189
|
+
out(ui.ok(`now using ${ui.bold(`"${name}"`)}${info.email ? ui.dim(` (${info.email}, ${info.plan || 'unknown plan'})`) : ''}`));
|
|
179
190
|
}
|
|
180
191
|
|
|
181
192
|
function cmdCurrent() {
|
|
@@ -192,9 +203,9 @@ function cmdCurrent() {
|
|
|
192
203
|
(info.email && accounts.find((a) => a.email === info.email)) ||
|
|
193
204
|
accounts.find((a) => a.accountId === info.accountId);
|
|
194
205
|
const name = match ? match.name : '(not stored — run "codexswitch import")';
|
|
195
|
-
const note = meta.active && match && meta.active !== match.name ? ` (meta says "${meta.active}" — out of sync)` : '';
|
|
196
|
-
out(`active: ${name}${note}`);
|
|
197
|
-
out(` email: ${info.email || '-'}\n plan: ${info.plan || '-'}\n token refreshed: ${fmtDate(info.lastRefresh)}`);
|
|
206
|
+
const note = meta.active && match && meta.active !== match.name ? ui.yellow(` (meta says "${meta.active}" — out of sync)`) : '';
|
|
207
|
+
out(`active: ${ui.bold(name)}${note}`);
|
|
208
|
+
out(ui.dim(` email: ${info.email || '-'}\n plan: ${info.plan || '-'}\n token refreshed: ${fmtDate(info.lastRefresh)}`));
|
|
198
209
|
}
|
|
199
210
|
|
|
200
211
|
function cmdNext() {
|
|
@@ -332,6 +343,49 @@ function cmdModel(args) {
|
|
|
332
343
|
out(`default model set to "${meta.model}" (applied to run/exec)`);
|
|
333
344
|
}
|
|
334
345
|
|
|
346
|
+
// Per-account usage dashboard: gauge bars for the 5h / weekly windows with
|
|
347
|
+
// reset countdowns, based on what codex recorded in its session files.
|
|
348
|
+
function cmdUsage(args) {
|
|
349
|
+
const meta = store.loadMeta();
|
|
350
|
+
let accounts = store.listAccounts();
|
|
351
|
+
if (args[0]) {
|
|
352
|
+
accounts = accounts.filter((a) => a.name === args[0]);
|
|
353
|
+
if (accounts.length === 0) throw new Error(`no such account: ${args[0]}`);
|
|
354
|
+
}
|
|
355
|
+
if (accounts.length === 0) {
|
|
356
|
+
out('no accounts yet — add one with "codexswitch login" or "codexswitch import"');
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const now = Date.now();
|
|
360
|
+
const bar = (pct, threshold) => {
|
|
361
|
+
const width = 20;
|
|
362
|
+
const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)));
|
|
363
|
+
const paint = pct >= threshold ? ui.red : pct >= 70 ? ui.yellow : ui.green;
|
|
364
|
+
return `[${paint('█'.repeat(filled))}${ui.dim('░'.repeat(width - filled))}]`;
|
|
365
|
+
};
|
|
366
|
+
const line = (label, win, threshold) => {
|
|
367
|
+
if (!win || typeof win.pct !== 'number') return ` ${label} ${ui.dim('no data')}`;
|
|
368
|
+
const pct = Math.round(win.pct);
|
|
369
|
+
const reset = win.resetAt && win.resetAt > now ? ` resets in ${fmtRemaining(win.resetAt)}` : '';
|
|
370
|
+
const overMark = win.pct >= threshold ? ` ${ui.red(`≥ threshold ${threshold}%`)}` : '';
|
|
371
|
+
return ` ${label} ${bar(win.pct, threshold)} ${String(pct).padStart(3)}%${ui.dim(reset)}${overMark}`;
|
|
372
|
+
};
|
|
373
|
+
for (const a of accounts) {
|
|
374
|
+
const mark = a.active ? ui.green('●') : ' ';
|
|
375
|
+
const state = a.disabled ? ui.red(' [disabled]') : a.limitedUntil && a.limitedUntil > now ? ui.yellow(` [limited ${fmtRemaining(a.limitedUntil)}]`) : '';
|
|
376
|
+
out(`${mark} ${ui.bold(a.name)}${ui.dim(` (${a.plan || a.email || '-'})`)}${state}`);
|
|
377
|
+
if (!a.usage) {
|
|
378
|
+
out(ui.dim(' no usage data yet — codex records it during runs (try "codexswitch run" once)'));
|
|
379
|
+
} else {
|
|
380
|
+
out(line('5h ', a.usage.p5h, meta.threshold5h));
|
|
381
|
+
out(line('weekly', a.usage.weekly, meta.thresholdWeekly));
|
|
382
|
+
if (a.usage.at) out(ui.dim(` measured ${fmtDate(a.usage.at)}`));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const next = store.pickAccount();
|
|
386
|
+
out(ui.dim(`\nrotation would pick: ${next ? next.name : '(none usable)'} · threshold 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`));
|
|
387
|
+
}
|
|
388
|
+
|
|
335
389
|
// Rotate-early thresholds: how full the 5h / weekly window may get before
|
|
336
390
|
// the account is skipped in rotation.
|
|
337
391
|
function cmdThreshold(args) {
|
|
@@ -415,7 +469,7 @@ function cmdNames() {
|
|
|
415
469
|
}
|
|
416
470
|
|
|
417
471
|
const COMMANDS =
|
|
418
|
-
'login import add-key list use current next run exec order model remove rename ' +
|
|
472
|
+
'login import add-key list usage use current next run exec order model remove rename ' +
|
|
419
473
|
'enable disable priority clear-limit cooldown threshold patterns export restore sync completion help';
|
|
420
474
|
|
|
421
475
|
function cmdCompletion(args) {
|
|
@@ -468,7 +522,7 @@ async function cmdRun(args) {
|
|
|
468
522
|
} else if (meta.model && rest.length === 0) {
|
|
469
523
|
rest = ['-m', meta.model];
|
|
470
524
|
}
|
|
471
|
-
out(`
|
|
525
|
+
out(ui.info(`running codex as ${ui.bold(`"${name}"`)}`));
|
|
472
526
|
const startTs = Date.now();
|
|
473
527
|
const res = await runner.runCodex(name, rest);
|
|
474
528
|
runner.recordUsage(name, res.profile, startTs);
|
|
@@ -511,7 +565,7 @@ async function cmdExec(args) {
|
|
|
511
565
|
}
|
|
512
566
|
tried.push(name);
|
|
513
567
|
console.error(
|
|
514
|
-
`
|
|
568
|
+
ui.info(`exec as ${ui.bold(`"${name}"`)}${ui.dim(attempt > 0 ? ` (attempt ${attempt + 1}${useResume ? ', resuming session' : ''})` : '')}`)
|
|
515
569
|
);
|
|
516
570
|
// On rotation, continue the same session with the next account instead
|
|
517
571
|
// of restarting the whole prompt — the session files are shared.
|
|
@@ -523,6 +577,7 @@ async function cmdExec(args) {
|
|
|
523
577
|
runner.recordUsage(name, res.profile, startTs);
|
|
524
578
|
if (res.code === 0) {
|
|
525
579
|
store.clearLimited(name);
|
|
580
|
+
console.error(ui.ok(`done as ${ui.bold(`"${name}"`)}`));
|
|
526
581
|
warnIfOverThreshold(name);
|
|
527
582
|
return 0;
|
|
528
583
|
}
|
|
@@ -533,7 +588,7 @@ async function cmdExec(args) {
|
|
|
533
588
|
useResume = true;
|
|
534
589
|
}
|
|
535
590
|
console.error(
|
|
536
|
-
`
|
|
591
|
+
ui.warn(`"${name}" hit a usage/rate limit (paused until ${fmtDate(until)}) — rotating${useResume ? ' and resuming the session' : ''}`)
|
|
537
592
|
);
|
|
538
593
|
continue;
|
|
539
594
|
}
|
|
@@ -542,13 +597,13 @@ async function cmdExec(args) {
|
|
|
542
597
|
// rotation and keep the task going on the next one.
|
|
543
598
|
setFlag(name, { disabled: true });
|
|
544
599
|
console.error(
|
|
545
|
-
`
|
|
600
|
+
ui.fail(`"${name}" has a revoked/invalid login — disabled. Fix it with: ${ui.bold(`codexswitch login ${name}`)}`)
|
|
546
601
|
);
|
|
547
602
|
continue;
|
|
548
603
|
}
|
|
549
604
|
return res.code; // real failure, don't burn other accounts on it
|
|
550
605
|
}
|
|
551
|
-
console.error('
|
|
606
|
+
console.error(ui.fail('all accounts are rate-limited, over threshold, or disabled'));
|
|
552
607
|
return 2;
|
|
553
608
|
}
|
|
554
609
|
|
|
@@ -560,7 +615,7 @@ function warnIfOverThreshold(name) {
|
|
|
560
615
|
if (blocked) {
|
|
561
616
|
const pct = blocked === '5h' ? account.usage.p5h.pct : account.usage.weekly.pct;
|
|
562
617
|
console.error(
|
|
563
|
-
`
|
|
618
|
+
ui.warn(`"${name}" is at ${Math.round(pct)}% of its ${blocked} limit (threshold ${blocked === '5h' ? meta.threshold5h : meta.thresholdWeekly}%) — the next exec will rotate to another account`)
|
|
564
619
|
);
|
|
565
620
|
}
|
|
566
621
|
}
|
|
@@ -572,7 +627,12 @@ async function main(argv) {
|
|
|
572
627
|
case 'help':
|
|
573
628
|
case '--help':
|
|
574
629
|
case '-h':
|
|
575
|
-
out(
|
|
630
|
+
out(
|
|
631
|
+
HELP.replace(/^(codexswitch)(?= —)/, ui.bold('$1')).replace(
|
|
632
|
+
/^(Accounts|Running codex|Settings|Maintenance|Environment)$/gm,
|
|
633
|
+
(s) => ui.bold(ui.cyan(s))
|
|
634
|
+
)
|
|
635
|
+
);
|
|
576
636
|
return 0;
|
|
577
637
|
case 'login':
|
|
578
638
|
return cmdLogin(args), 0;
|
|
@@ -580,8 +640,10 @@ async function main(argv) {
|
|
|
580
640
|
return cmdImport(args), 0;
|
|
581
641
|
case 'list':
|
|
582
642
|
case 'accounts':
|
|
583
|
-
case 'status':
|
|
584
643
|
return cmdList(), 0;
|
|
644
|
+
case 'usage':
|
|
645
|
+
case 'status':
|
|
646
|
+
return cmdUsage(args), 0;
|
|
585
647
|
case 'use':
|
|
586
648
|
return cmdUse(args), 0;
|
|
587
649
|
case 'current':
|
|
@@ -638,7 +700,10 @@ async function main(argv) {
|
|
|
638
700
|
case 'exec':
|
|
639
701
|
return cmdExec(args);
|
|
640
702
|
default:
|
|
641
|
-
|
|
703
|
+
// Forward everything else to codex under the managed account, so
|
|
704
|
+
// codexswitch is a drop-in replacement: "cxs goal", "cxs resume", ...
|
|
705
|
+
console.error(ui.info(ui.dim(`forwarding to codex: codex ${argv.join(' ')}`)));
|
|
706
|
+
return cmdRun(argv);
|
|
642
707
|
}
|
|
643
708
|
}
|
|
644
709
|
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tiny ANSI color layer (zero deps). Colors turn off automatically when the
|
|
4
|
+
// output is piped, or when NO_COLOR is set — so scripts and tests always see
|
|
5
|
+
// plain text (https://no-color.org).
|
|
6
|
+
const enabled =
|
|
7
|
+
process.env.NO_COLOR == null &&
|
|
8
|
+
process.env.FORCE_COLOR !== '0' &&
|
|
9
|
+
(Boolean(process.stdout.isTTY) || Boolean(process.env.FORCE_COLOR));
|
|
10
|
+
|
|
11
|
+
const wrap = (open, close) => (s) => (enabled ? `[${open}m${s}[${close}m` : String(s));
|
|
12
|
+
|
|
13
|
+
const green = wrap(32, 39);
|
|
14
|
+
const red = wrap(31, 39);
|
|
15
|
+
const yellow = wrap(33, 39);
|
|
16
|
+
const cyan = wrap(36, 39);
|
|
17
|
+
const dim = wrap(2, 22);
|
|
18
|
+
const bold = wrap(1, 22);
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
enabled,
|
|
22
|
+
green,
|
|
23
|
+
red,
|
|
24
|
+
yellow,
|
|
25
|
+
cyan,
|
|
26
|
+
dim,
|
|
27
|
+
bold,
|
|
28
|
+
// status line prefixes — one glyph per kind keeps progress scannable
|
|
29
|
+
ok: (s) => `${green('✓')} ${s}`,
|
|
30
|
+
info: (s) => `${cyan('›')} ${s}`,
|
|
31
|
+
warn: (s) => `${yellow('⚠')} ${s}`,
|
|
32
|
+
fail: (s) => `${red('✗')} ${s}`,
|
|
33
|
+
// strip ANSI codes (for width calculations)
|
|
34
|
+
visible: (s) => String(s).replace(/\[[0-9;]*m/g, ''),
|
|
35
|
+
};
|
package/src/util.js
CHANGED
|
@@ -85,14 +85,17 @@ function fmtRemaining(untilTs) {
|
|
|
85
85
|
if (ms <= 0) return 'now';
|
|
86
86
|
const h = Math.floor(ms / 3600000);
|
|
87
87
|
const m = Math.round((ms % 3600000) / 60000);
|
|
88
|
+
if (h >= 48) return `${Math.floor(h / 24)}d${h % 24}h`;
|
|
88
89
|
return h > 0 ? `${h}h${m}m` : `${m}m`;
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
function table(rows, headers) {
|
|
93
|
+
const { visible, dim } = require('./ui.js');
|
|
92
94
|
const all = [headers, ...rows].map((r) => r.map((c) => String(c == null ? '-' : c)));
|
|
93
|
-
const widths = headers.map((_, i) => Math.max(...all.map((r) => r[i].length)));
|
|
94
|
-
const
|
|
95
|
-
|
|
95
|
+
const widths = headers.map((_, i) => Math.max(...all.map((r) => visible(r[i]).length)));
|
|
96
|
+
const pad = (c, w) => c + ' '.repeat(Math.max(0, w - visible(c).length));
|
|
97
|
+
const line = (r) => r.map((c, i) => pad(c, widths[i])).join(' ').trimEnd();
|
|
98
|
+
return [dim(line(all[0])), dim(line(widths.map((w) => '-'.repeat(w)))), ...all.slice(1).map(line)].join('\n');
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
module.exports = {
|