@sejuneoh/aitop 0.0.0-stage → 0.21.1
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 -0
- package/README.md +235 -2
- package/bin/aitop.mjs +360 -0
- package/package.json +36 -4
- package/src/attention.mjs +70 -0
- package/src/brand.mjs +30 -0
- package/src/budget.mjs +29 -0
- package/src/cache.mjs +78 -0
- package/src/collect.mjs +51 -0
- package/src/config.mjs +169 -0
- package/src/doctor.mjs +218 -0
- package/src/frames.mjs +24 -0
- package/src/help.mjs +111 -0
- package/src/json.mjs +57 -0
- package/src/keys.mjs +154 -0
- package/src/log.mjs +130 -0
- package/src/notify.mjs +251 -0
- package/src/order.mjs +69 -0
- package/src/pricing.mjs +130 -0
- package/src/procs.mjs +205 -0
- package/src/sources/agy-ledger.mjs +153 -0
- package/src/sources/antigravity.mjs +763 -0
- package/src/sources/claude-ledger.mjs +181 -0
- package/src/sources/claude-tracker.mjs +183 -0
- package/src/sources/claude.mjs +352 -0
- package/src/sources/codex-ledger.mjs +141 -0
- package/src/sources/codex.mjs +374 -0
- package/src/sources/common.mjs +228 -0
- package/src/sources/files.mjs +116 -0
- package/src/sources/gemini.mjs +26 -0
- package/src/sources/patch.mjs +78 -0
- package/src/sqlite.mjs +50 -0
- package/src/tail.mjs +109 -0
- package/src/text.mjs +256 -0
- package/src/turns.mjs +79 -0
- package/src/ui/chrome.mjs +163 -0
- package/src/ui/detail.mjs +360 -0
- package/src/ui/draw.mjs +152 -0
- package/src/ui/glyphs.mjs +58 -0
- package/src/ui/table.mjs +425 -0
- package/src/ui/theme.mjs +137 -0
- package/src/ui.mjs +99 -0
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sejuneoh/aitop",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.21.1",
|
|
4
|
+
"description": "터미널에서 Claude Code · Codex · Antigravity · Gemini 세션과 토큰을 실시간으로 보는 읽기 전용 모니터",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"aitop": "bin/aitop.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"os": [
|
|
20
|
+
"linux",
|
|
21
|
+
"darwin"
|
|
22
|
+
],
|
|
23
|
+
"homepage": "https://github.com/SejuneOh/aitop#readme",
|
|
24
|
+
"bugs": "https://github.com/SejuneOh/aitop/issues",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/SejuneOh/aitop.git"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"registry": "https://registry.npmjs.org",
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"start": "node bin/aitop.mjs",
|
|
35
|
+
"test": "node --test test/*.test.mjs",
|
|
36
|
+
"test:e2e": "node --test test/e2e/*.e2e.mjs"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// "끝났는데 아직 안 본" 세션 추적. aitop 메모리에만 두고 파일에는 쓰지 않는다.
|
|
2
|
+
// 작업 중(busy·shell)이던 세션이 대기(idle)로 바뀌었는데 그때 선택돼 있지 않았으면 확인 전으로 본다.
|
|
3
|
+
|
|
4
|
+
export function createAttention() {
|
|
5
|
+
return { prev: new Map(), unseen: new Set() };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function updateAttention(state, sessions, selectedId) {
|
|
9
|
+
const ids = new Set();
|
|
10
|
+
for (const s of sessions) {
|
|
11
|
+
ids.add(s.id);
|
|
12
|
+
const before = state.prev.get(s.id);
|
|
13
|
+
// 상태를 기록 수정 시각으로 추측한 세션(statusGuessed)은, 작업이 아닌 줄이 붙어도 잠깐 작업 중으로 보이므로 확인 전으로 치지 않는다.
|
|
14
|
+
if (s.id === selectedId || s.status === 'busy' || s.status === 'shell' || !s.live) state.unseen.delete(s.id);
|
|
15
|
+
else if ((before === 'busy' || before === 'shell') && s.status === 'idle' && !s.statusGuessed) state.unseen.add(s.id);
|
|
16
|
+
state.prev.set(s.id, s.status);
|
|
17
|
+
}
|
|
18
|
+
for (const id of state.prev.keys()) if (!ids.has(id)) state.prev.delete(id);
|
|
19
|
+
for (const id of state.unseen) if (!ids.has(id)) state.unseen.delete(id);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function markSeen(state, id) {
|
|
23
|
+
state.unseen.delete(id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 다음에 봐야 할 세션: 지금 선택한 세션은 빼고, 그 다음 것부터 한 바퀴 돌며 응답 대기 → 확인 전 순서로 찾는다.
|
|
27
|
+
// 선택이 목록에 없으면(검색으로 가려짐 등) 맨 위부터 찾는다.
|
|
28
|
+
export function nextAttention(state, sessions, selectedId) {
|
|
29
|
+
const at = sessions.findIndex((s) => s.id === selectedId);
|
|
30
|
+
const ordered = at < 0 ? sessions : [...sessions.slice(at + 1), ...sessions.slice(0, at)];
|
|
31
|
+
return ordered.find((s) => s.status === 'waiting') ?? ordered.find((s) => state.unseen.has(s.id)) ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 응답 대기(◆)와 확인 전(●) 수.
|
|
35
|
+
export function attentionCounts(sessions) {
|
|
36
|
+
return { waiting: sessions.filter((s) => s.live && s.status === 'waiting').length, unseen: sessions.filter((s) => s.unseen).length };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 창 제목: 맨 앞 아이콘(탭 사이에서 aitop 을 찾게) + 응답 대기·확인 전 수. 예: (2◆ 1●) aitop
|
|
40
|
+
export function windowTitle(sessions, icon = '') {
|
|
41
|
+
const { waiting, unseen } = attentionCounts(sessions);
|
|
42
|
+
const marks = [waiting && `${waiting}◆`, unseen && `${unseen}●`].filter(Boolean);
|
|
43
|
+
return (icon ? icon + ' ' : '') + (marks.length ? `(${marks.join(' ')}) aitop` : 'aitop');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 새로 응답 대기가 된 세션 목록. seen 은 지난번 응답 대기 세션 id 집합이며 여기서 갱신한다.
|
|
47
|
+
export function freshWaiting(seen, sessions) {
|
|
48
|
+
const now = sessions.filter((s) => s.live && s.status === 'waiting');
|
|
49
|
+
const fresh = now.filter((s) => !seen.has(s.id));
|
|
50
|
+
seen.clear();
|
|
51
|
+
for (const s of now) seen.add(s.id);
|
|
52
|
+
return fresh;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 벨: 새로 응답 대기가 된 세션이 있을 때만 true.
|
|
56
|
+
export function newlyWaiting(seen, sessions) {
|
|
57
|
+
return freshWaiting(seen, sessions).length > 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --bell · --notify · --on-waiting 이 함께 쓰는 감시. 첫 갱신에서 이미 응답 대기인 세션은 알리지 않는다.
|
|
61
|
+
export function createWaitWatch() {
|
|
62
|
+
return { seen: new Set(), ready: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function takeNewlyWaiting(watch, sessions) {
|
|
66
|
+
const fresh = freshWaiting(watch.seen, sessions);
|
|
67
|
+
const ready = watch.ready;
|
|
68
|
+
watch.ready = true;
|
|
69
|
+
return ready ? fresh : [];
|
|
70
|
+
}
|
package/src/brand.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// aitop 표식: 로고, 창·탭 아이콘, WezTerm 탭용 user var.
|
|
2
|
+
// AITOP_ICONS 로 고른다.
|
|
3
|
+
// (없음) 로고 ⣠⣴⣾, 탭 아이콘은 Nerd Font 로봇(U+F06A9), WezTerm user var 를 내보낸다
|
|
4
|
+
// plain Nerd 아이콘과 user var 를 끈다. 탭 아이콘은 로고의 마지막 막대 ⣾
|
|
5
|
+
// ascii 로고를 >_ai 로, 탭 아이콘 없음, user var 없음 (점자를 못 그리는 환경용)
|
|
6
|
+
// 로고의 점자 글자는 동아시아 폭이 N 이라 한글 터미널에서도 늘 1칸이다.
|
|
7
|
+
|
|
8
|
+
export function iconMode(env = process.env) {
|
|
9
|
+
return env.AITOP_ICONS === 'plain' || env.AITOP_ICONS === 'ascii' ? env.AITOP_ICONS : 'full';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function brand(mode = iconMode()) {
|
|
13
|
+
if (mode === 'ascii') return { mode, logo: '>_ai', tab: '', userVars: false };
|
|
14
|
+
return {
|
|
15
|
+
mode,
|
|
16
|
+
logo: '⣠⣴⣾',
|
|
17
|
+
tab: mode === 'plain' ? '⣾' : '\u{F06A9}',
|
|
18
|
+
userVars: mode === 'full',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const BRAND = brand();
|
|
23
|
+
|
|
24
|
+
// WezTerm 탭 제목(format-tab-title)에서 pane.user_vars 로 읽는 값. 다른 터미널은 모르는 OSC 를 무시한다.
|
|
25
|
+
// counts 가 null 이면 값을 지운다 (aitop 을 끝낼 때).
|
|
26
|
+
export function userVarsSeq(counts) {
|
|
27
|
+
const set = (name, value) => `\x1b]1337;SetUserVar=${name}=${Buffer.from(value).toString('base64')}\x07`;
|
|
28
|
+
if (!counts) return set('aitop', '') + set('aitop_waiting', '') + set('aitop_unseen', '');
|
|
29
|
+
return set('aitop', '1') + set('aitop_waiting', String(counts.waiting)) + set('aitop_unseen', String(counts.unseen));
|
|
30
|
+
}
|
package/src/budget.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// 하루 예산(budgetDay): 오늘 비용에 최근 지출 속도 × 로컬 자정까지 남은 시간을 더해 오늘 끝의 비용을 어림한다.
|
|
2
|
+
// 예산은 화면의 비용 기준(c 키)과 상관없이 늘 API 정가 환산으로 본다. 그래서 화면과 --json 의 단계가 같다.
|
|
3
|
+
// 예상이 예산의 CAUTION 배 이상이면 'caution', 예산 이상이면 'warn'.
|
|
4
|
+
|
|
5
|
+
export const CAUTION = 0.8;
|
|
6
|
+
// 자정 뒤 이만큼이 지나기 전에는 속도를 믿지 않고 예상 = 오늘 비용.
|
|
7
|
+
export const MIN_TODAY_MS = 15 * 60_000;
|
|
8
|
+
|
|
9
|
+
// 지금부터 로컬 자정까지 남은 시간 (시간 단위).
|
|
10
|
+
export function hoursToMidnight(now = Date.now()) {
|
|
11
|
+
const d = new Date(now);
|
|
12
|
+
const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
|
|
13
|
+
return Math.max(0, (midnight - now) / 3600_000);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const levelOf = (projected, day) => (projected >= day ? 'warn' : projected >= day * CAUTION ? 'caution' : 'ok');
|
|
17
|
+
|
|
18
|
+
// usage: collect 의 사용량. recentToday 는 최근 1시간 중 오늘(로컬 자정 이후) 몫과 그 구간 길이(ms)다 (recentTodayOf).
|
|
19
|
+
// 속도 = 그 몫을 시간당으로 늘린 값. 값이 없거나, 아직 집계 중이거나, 자정 뒤 15분이 안 됐으면 예상 = 오늘 비용.
|
|
20
|
+
// 예산이 없거나 사용량이 없으면 null.
|
|
21
|
+
export function projectBudget(usage, day, { now = Date.now() } = {}) {
|
|
22
|
+
if (!(typeof day === 'number' && day > 0) || !usage?.today) return null;
|
|
23
|
+
const r = usage.loading == null && usage.recentToday?.ms >= MIN_TODAY_MS ? usage.recentToday : null;
|
|
24
|
+
const spent = usage.today.cost ?? 0;
|
|
25
|
+
const perHour = r ? (r.cost ?? 0) / (r.ms / 3600_000) : 0;
|
|
26
|
+
const projected = spent + perHour * hoursToMidnight(now);
|
|
27
|
+
const unknown = Boolean(usage.today.unknown || r?.unknown);
|
|
28
|
+
return { day, spent, projected, unknown, level: levelOf(projected, day) };
|
|
29
|
+
}
|
package/src/cache.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// 큰 세션 기록을 매번 처음부터 다시 읽지 않도록, 파일별로 "어디까지 읽었고 그때 합계가 얼마였는지"를
|
|
2
|
+
// ~/.cache/aitop 에 저장한다. 이 프로그램이 디스크에 쓰는 곳은 여기와 --debug 때의 디버그 로그(log.mjs)뿐이다.
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, statSync, readdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { PRICE_TAG } from './pricing.mjs';
|
|
8
|
+
import { debug, debugIo } from './log.mjs';
|
|
9
|
+
|
|
10
|
+
// 캐시 형식이 바뀌면 VERSION 을, 가격표가 바뀌면 PRICE_TAG 가 폴더 이름을 바꿔서 예전 캐시를 버린다.
|
|
11
|
+
const VERSION = 13;
|
|
12
|
+
const ROOT = join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'aitop');
|
|
13
|
+
const NAME = `v${VERSION}-${PRICE_TAG}`;
|
|
14
|
+
const DIR = join(ROOT, NAME);
|
|
15
|
+
export const CACHE_DIR = DIR;
|
|
16
|
+
const dirty = new Map();
|
|
17
|
+
let cleaned = false;
|
|
18
|
+
|
|
19
|
+
// 다른 버전의 aitop 이 같이 쓰고 있을 수 있으니, 같은 형식의 다른 폴더는 일주일 넘게 손대지 않았을 때만 지운다.
|
|
20
|
+
const STALE_DIR_MS = 7 * 24 * 3600 * 1000;
|
|
21
|
+
|
|
22
|
+
function removeOldDirs() {
|
|
23
|
+
cleaned = true;
|
|
24
|
+
try {
|
|
25
|
+
for (const d of readdirSync(ROOT)) {
|
|
26
|
+
if (d === NAME) continue;
|
|
27
|
+
const p = join(ROOT, d);
|
|
28
|
+
// 이 버전보다 낮은 형식(v2, v6-… 등)은 더 이상 어떤 버전도 쓰지 않으므로 바로 지운다.
|
|
29
|
+
const ver = Number(/^v(\d+)(?:-|$)/.exec(d)?.[1]);
|
|
30
|
+
if (ver < VERSION || Date.now() - statSync(p).mtimeMs > STALE_DIR_MS) rmSync(p, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
} catch (e) {
|
|
33
|
+
debugIo('cache', 'old cache cleanup failed', e);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// kind: 같은 기록 파일을 다른 용도로 읽는 tracker 가 각자 캐시를 갖도록 구분한다 (예: 세션 화면용, 날짜별 사용량용).
|
|
38
|
+
const keyOf = (path, kind) => createHash('sha1').update(`${kind}\0${path}`).digest('hex');
|
|
39
|
+
|
|
40
|
+
// 파일이 바뀌지 않고 뒤에만 붙었을 때(크기가 같거나 커졌고 inode 가 같을 때)만 캐시를 쓴다.
|
|
41
|
+
export function loadCache(path, kind = '') {
|
|
42
|
+
try {
|
|
43
|
+
const c = JSON.parse(readFileSync(join(DIR, keyOf(path, kind) + '.json'), 'utf8'));
|
|
44
|
+
const st = statSync(path);
|
|
45
|
+
if (c.path !== path || c.ino !== st.ino || st.size < c.offset) return null;
|
|
46
|
+
return c;
|
|
47
|
+
} catch (e) {
|
|
48
|
+
debugIo('cache', 'cache load failed', e);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function markDirty(path, snapshot, kind = '') {
|
|
54
|
+
dirty.set(keyOf(path, kind), { path, build: snapshot });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function flushCache() {
|
|
58
|
+
if (!dirty.size) return;
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
61
|
+
} catch (e) {
|
|
62
|
+
debug('cache', 'cache folder create failed', e);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!cleaned) removeOldDirs();
|
|
66
|
+
for (const [key, { path, build }] of dirty) {
|
|
67
|
+
try {
|
|
68
|
+
const data = build();
|
|
69
|
+
if (!data) continue;
|
|
70
|
+
const file = join(DIR, key + '.json');
|
|
71
|
+
writeFileSync(file + '.tmp', JSON.stringify({ ...data, path, ino: statSync(path).ino }), { mode: 0o600 });
|
|
72
|
+
renameSync(file + '.tmp', file);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
debugIo('cache', 'cache write failed', e);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
dirty.clear();
|
|
78
|
+
}
|
package/src/collect.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { collectClaude } from './sources/claude.mjs';
|
|
2
|
+
import { collectCodex } from './sources/codex.mjs';
|
|
3
|
+
import { collectGemini } from './sources/gemini.mjs';
|
|
4
|
+
import { collectAgy } from './sources/antigravity.mjs';
|
|
5
|
+
import { collectLedger } from './sources/claude-ledger.mjs';
|
|
6
|
+
import { createBudget } from './tail.mjs';
|
|
7
|
+
|
|
8
|
+
const RANK = { waiting: 0, busy: 1, shell: 2, live: 3, idle: 4, ended: 5 };
|
|
9
|
+
|
|
10
|
+
// 상태 순서가 먼저다. 같은 상태 안에서는 갱신마다 자리가 바뀌지 않도록 바뀌지 않는 값으로 줄 세운다:
|
|
11
|
+
// 늦게 시작한 세션이 위, 시작 시각이 없으면 아래, 그다음 도구·id 순.
|
|
12
|
+
// 종료 세션은 더 바뀌지 않으므로 최근에 끝난 세션이 위로 오게 마지막 활동 시각으로 줄 세운다.
|
|
13
|
+
export function bySession(a, b) {
|
|
14
|
+
const at = (s) => (s.status === 'ended' ? s.updatedAt : s.startedAt) ?? -Infinity;
|
|
15
|
+
return (
|
|
16
|
+
(RANK[a.status] ?? 9) - (RANK[b.status] ?? 9) ||
|
|
17
|
+
at(b) - at(a) ||
|
|
18
|
+
String(a.tool ?? '').localeCompare(String(b.tool ?? '')) ||
|
|
19
|
+
String(a.id ?? '').localeCompare(String(b.id ?? ''))
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// budgetBytes·budgetMs: 이번 갱신에서 세션 기록을 읽을 최대 바이트와 시간. 실시간 화면은 작게 잡아 큰 기록을 여러 번에 나눠 읽는다.
|
|
24
|
+
// 돌려주는 more 가 true 면 한도 때문에 덜 읽은 기록이 있다.
|
|
25
|
+
// 도구마다, 그리고 날짜별 사용량 장부에 한도를 따로 줘서 서로의 읽기를 막지 않게 한다.
|
|
26
|
+
export function collect({ budgetBytes = Infinity, budgetMs = Infinity, ...opts } = {}) {
|
|
27
|
+
const budgets = [];
|
|
28
|
+
const budgetOf = (bytes, ms) => {
|
|
29
|
+
const b = createBudget(bytes, ms);
|
|
30
|
+
budgets.push(b);
|
|
31
|
+
return b;
|
|
32
|
+
};
|
|
33
|
+
const sessions = [];
|
|
34
|
+
const errors = [];
|
|
35
|
+
for (const [tool, fn] of [['claude', collectClaude], ['codex', collectCodex], ['gemini', collectGemini], ['agy', collectAgy]]) {
|
|
36
|
+
try {
|
|
37
|
+
sessions.push(...fn({ ...opts, budget: budgetOf(budgetBytes, budgetMs) }));
|
|
38
|
+
} catch (err) {
|
|
39
|
+
errors.push(`${tool}: ${err.message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// 장부는 세션 화면보다 덜 급하니 한 번 갱신에 세션 한도의 1/3 만 읽는다 (처음 한 번 다 읽은 뒤에는 캐시와 새 줄만 읽는다).
|
|
43
|
+
let usage = null;
|
|
44
|
+
try {
|
|
45
|
+
usage = collectLedger({ budget: budgetOf(budgetBytes / 3, budgetMs / 2) });
|
|
46
|
+
} catch (err) {
|
|
47
|
+
errors.push(`usage: ${err.message}`);
|
|
48
|
+
}
|
|
49
|
+
sessions.sort(bySession);
|
|
50
|
+
return { sessions, usage, errors, at: Date.now(), more: budgets.some((b) => b.exhausted) };
|
|
51
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// 설정: 옵션마다 명령줄 옵션(flag) > 환경 변수(env) > 설정 파일(file) > 기본값(default) 순서로 고른다.
|
|
2
|
+
// 설정 파일은 $XDG_CONFIG_HOME/aitop/config.json (없으면 ~/.config/aitop/config.json). aitop 은 이 파일을 읽기만 한다.
|
|
3
|
+
// 잘못된 JSON, 모르는 키, 잘못된 값은 그 키만 무시하고 problems 에 남긴다 (--doctor 와 --print-config 가 보인다).
|
|
4
|
+
// 화면 모듈은 불러올 때 테마·아이콘을 고르므로, 이 모듈은 다른 aitop 모듈을 불러오지 않는다.
|
|
5
|
+
import { closeSync, constants, fstatSync, openSync, readSync, statSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { isAbsolute, join } from 'node:path';
|
|
8
|
+
import { stripControl } from './text.mjs';
|
|
9
|
+
|
|
10
|
+
const bool = (v) => typeof v === 'boolean';
|
|
11
|
+
const oneOf = (...xs) => (v) => xs.includes(v);
|
|
12
|
+
// 달러 금액 글자("50", "12.5", "$50")를 0 보다 큰 수로 바꾼다. 아니면 null.
|
|
13
|
+
const usd = (v) => {
|
|
14
|
+
const t = String(v).trim().replace(/^\$/, '');
|
|
15
|
+
const n = /^\d+(\.\d+)?$|^\.\d+$/.test(t) ? Number(t) : NaN;
|
|
16
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// argv 에서 --budget-day <usd>, --budget-day=<usd>, --no-budget-day 를 꺼낸다. 뒤에 준 쪽을 따른다.
|
|
20
|
+
// value: 금액, null(이번 실행만 끔), undefined(주지 않음). 나머지 인자는 rest 로 돌려준다.
|
|
21
|
+
export function budgetDayOption(argv) {
|
|
22
|
+
let value;
|
|
23
|
+
let error = null;
|
|
24
|
+
const rest = [];
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
const a = argv[i];
|
|
27
|
+
if (a === '--no-budget-day') {
|
|
28
|
+
value = null;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const m = /^--budget-day(?:=(.*))?$/.exec(a);
|
|
32
|
+
if (!m) {
|
|
33
|
+
rest.push(a);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// 다음 인자가 옵션이면 값으로 먹지 않는다. -5 처럼 수로 보이면 값으로 받아 잘못된 값으로 알린다.
|
|
37
|
+
const next = argv[i + 1];
|
|
38
|
+
const raw = m[1] ?? (next == null || (next.startsWith('-') && !/^-[\d.]/.test(next)) ? '' : argv[++i]);
|
|
39
|
+
const n = usd(raw);
|
|
40
|
+
if (n == null) error ??= raw === '' ? '--budget-day 값이 없습니다 (하루 예산, 달러)' : `--budget-day 값 ${JSON.stringify(raw)} 이 잘못되었습니다 (0 보다 큰 수)`;
|
|
41
|
+
else value = n;
|
|
42
|
+
}
|
|
43
|
+
return { value, rest, error };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// env: 환경 변수 이름과 그 값을 옵션 값으로 바꾸는 함수 (undefined 면 설정하지 않은 것으로 본다).
|
|
47
|
+
// envProblem: 환경 변수 값이 잘못되면 무시하고 problems 에 남긴다 (--doctor, --print-config 가 보인다).
|
|
48
|
+
// notify, onWaiting 은 응답 대기 알림(notify.mjs)이 쓴다. debug 는 디버그 로그(log.mjs)를 켠다. budgetDay 는 하루 예산(달러)으로, 헤더와 --json 의 예상 비용 경고(budget.mjs)가 쓴다.
|
|
49
|
+
export const KEYS = {
|
|
50
|
+
theme: { ok: oneOf('light', 'dark'), expect: 'light 또는 dark', def: null, env: ['AITOP_THEME', (v) => (v === 'light' || v === 'dark' ? v : undefined)] },
|
|
51
|
+
icons: { ok: oneOf('plain', 'ascii'), expect: 'plain 또는 ascii', def: 'full', env: ['AITOP_ICONS', (v) => (v === 'plain' || v === 'ascii' ? v : undefined)] },
|
|
52
|
+
bell: { ok: bool, expect: 'true 또는 false', def: false },
|
|
53
|
+
showRequests: { ok: bool, expect: 'true 또는 false', def: false, env: ['AITOP_SHOW_REQUESTS', (v) => (v === '1' ? true : v === '0' ? false : undefined)] },
|
|
54
|
+
all: { ok: bool, expect: 'true 또는 false', def: false },
|
|
55
|
+
notify: { ok: bool, expect: 'true 또는 false', def: false },
|
|
56
|
+
onWaiting: { ok: (v) => typeof v === 'string' && v.trim() !== '', expect: '실행할 명령 문자열', def: null },
|
|
57
|
+
debug: { ok: bool, expect: 'true 또는 false', def: false, env: ['AITOP_DEBUG', (v) => (v === '1' ? true : v === '0' ? false : undefined)], envProblem: true, envExpect: '1 또는 0' },
|
|
58
|
+
budgetDay: { ok: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0, expect: '0 보다 큰 수', def: null, env: ['AITOP_BUDGET_DAY', (v) => usd(v) ?? undefined], envProblem: true },
|
|
59
|
+
// colorBlind 는 적록 색약 팔레트(ui/theme.mjs)를 고른다. theme(밝기)과 따로 정한다.
|
|
60
|
+
colorBlind: { ok: bool, expect: 'true 또는 false', def: false, env: ['AITOP_COLOR_BLIND', (v) => (v === '1' ? true : v === '0' ? false : undefined)], envProblem: true, envExpect: '1 또는 0' },
|
|
61
|
+
// 실행 중 세션이 없고 7일 동안 쓰지 않은 도구를 도구 줄에서 숨긴다. false 면 늘 셋 다 보인다.
|
|
62
|
+
hideIdleTools: { ok: bool, expect: 'true 또는 false', def: true },
|
|
63
|
+
// 폭이 모호한 기호(·, →, ─, ● 등)를 2칸으로 그리는 터미널이면 wide. 표 칸이 밀리지 않게 1칸 기호로 바꾸고 폭을 2칸으로 센다.
|
|
64
|
+
ambiguous: { ok: oneOf('narrow', 'wide'), expect: 'narrow 또는 wide', def: 'narrow', env: ['AITOP_AMBIGUOUS', (v) => (v === 'narrow' || v === 'wide' ? v : undefined)], envProblem: true },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function configPath(env = process.env, home = homedir()) {
|
|
68
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
69
|
+
return join(xdg && isAbsolute(xdg) ? xdg : join(home, '.config'), 'aitop', 'config.json');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 설정 파일은 작다. 이보다 크면 읽지 않는다.
|
|
73
|
+
export const MAX_BYTES = 64 * 1024;
|
|
74
|
+
|
|
75
|
+
const show = (v) => JSON.stringify(v)?.slice(0, 60) ?? String(v);
|
|
76
|
+
|
|
77
|
+
// 파일을 읽어 올바른 키만 values 에 담는다. uid: 현재 사용자 (테스트에서 바꿔 끼운다).
|
|
78
|
+
export function readConfigFile(path, { uid = process.getuid?.() } = {}) {
|
|
79
|
+
const res = { path, exists: false, values: {}, problems: [] };
|
|
80
|
+
let fd;
|
|
81
|
+
let text;
|
|
82
|
+
let st;
|
|
83
|
+
const notFile = () => res.problems.push('설정 파일이 일반 파일이 아니라 읽지 않습니다');
|
|
84
|
+
const tooBig = () => res.problems.push(`설정 파일이 ${MAX_BYTES / 1024}KB 를 넘어 읽지 않습니다`);
|
|
85
|
+
try {
|
|
86
|
+
// FIFO 나 장치 파일을 열면 멈출 수 있으므로 열기 전에 확인하고, 열 때도 기다리지 않게 연다.
|
|
87
|
+
st = statSync(path);
|
|
88
|
+
res.exists = true;
|
|
89
|
+
if (!st.isFile()) return (notFile(), res);
|
|
90
|
+
if (st.size > MAX_BYTES) return (tooBig(), res);
|
|
91
|
+
fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
92
|
+
st = fstatSync(fd);
|
|
93
|
+
if (!st.isFile()) return (notFile(), res);
|
|
94
|
+
if (st.size > MAX_BYTES) return (tooBig(), res);
|
|
95
|
+
// 확인한 뒤에 파일이 커져도 한도 + 1 바이트까지만 읽는다.
|
|
96
|
+
const buf = Buffer.alloc(MAX_BYTES + 1);
|
|
97
|
+
let n = 0;
|
|
98
|
+
for (let got; n < buf.length && (got = readSync(fd, buf, n, buf.length - n, n)) > 0; ) n += got;
|
|
99
|
+
if (n > MAX_BYTES) return (tooBig(), res);
|
|
100
|
+
text = buf.subarray(0, n).toString('utf8');
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err.code !== 'ENOENT') {
|
|
103
|
+
res.exists = err.code !== 'ENOTDIR';
|
|
104
|
+
res.problems.push(`설정 파일을 읽을 수 없습니다 (${err.code ?? err.message})`);
|
|
105
|
+
}
|
|
106
|
+
return res;
|
|
107
|
+
} finally {
|
|
108
|
+
if (fd != null) closeSync(fd);
|
|
109
|
+
}
|
|
110
|
+
let obj;
|
|
111
|
+
try {
|
|
112
|
+
obj = JSON.parse(text);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
// 오류 문구에 파일 내용 일부가 들어가므로 제어문자를 지우고 줄인다.
|
|
115
|
+
res.problems.push(`설정 파일이 올바른 JSON 이 아니라 무시합니다 (${stripControl(err.message).slice(0, 120)})`);
|
|
116
|
+
return res;
|
|
117
|
+
}
|
|
118
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
119
|
+
res.problems.push('설정 파일의 맨 위는 JSON 객체({ ... })여야 합니다');
|
|
120
|
+
return res;
|
|
121
|
+
}
|
|
122
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
123
|
+
const spec = Object.hasOwn(KEYS, key) ? KEYS[key] : null;
|
|
124
|
+
if (!spec) res.problems.push(`모르는 키 ${show(key)} 는 무시합니다`);
|
|
125
|
+
else if (!spec.ok(value)) res.problems.push(`${key} 값 ${show(value)} 이 잘못되어 무시합니다 (${spec.expect})`);
|
|
126
|
+
else res.values[key] = value;
|
|
127
|
+
}
|
|
128
|
+
// 응답 대기 때 실행할 명령은 다른 사용자가 바꿀 수 없는 파일에서만 받는다.
|
|
129
|
+
if ('onWaiting' in res.values) {
|
|
130
|
+
const mine = uid == null || st.uid === uid;
|
|
131
|
+
const shared = (st.mode & 0o022) !== 0;
|
|
132
|
+
if (!mine || shared) {
|
|
133
|
+
delete res.values.onWaiting;
|
|
134
|
+
const why = [!mine && '파일 소유자가 현재 사용자가 아님', shared && `그룹·다른 사용자가 쓸 수 있음 (mode ${(st.mode & 0o777).toString(8).padStart(3, '0')})`].filter(Boolean).join(', ');
|
|
135
|
+
res.problems.push(`onWaiting 은 무시합니다: ${why}. chmod 600 으로 고치세요`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return res;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// flags: 명령줄에서 정한 값만 담는다 (주지 않은 옵션은 넣지 않는다).
|
|
142
|
+
export function resolveConfig({ flags = {}, env = process.env, file } = {}) {
|
|
143
|
+
file ??= readConfigFile(configPath(env));
|
|
144
|
+
const values = {};
|
|
145
|
+
const problems = [...file.problems];
|
|
146
|
+
for (const [key, spec] of Object.entries(KEYS)) {
|
|
147
|
+
const fromEnv = spec.env && env[spec.env[0]] != null ? spec.env[1](env[spec.env[0]]) : undefined;
|
|
148
|
+
if (spec.envProblem && env[spec.env[0]] != null && fromEnv === undefined) problems.push(`환경 변수 ${spec.env[0]} 값 ${show(env[spec.env[0]])} 이 잘못되어 무시합니다 (${spec.envExpect ?? spec.expect})`);
|
|
149
|
+
if (flags[key] !== undefined) values[key] = { value: flags[key], source: 'flag' };
|
|
150
|
+
else if (fromEnv !== undefined) values[key] = { value: fromEnv, source: 'env' };
|
|
151
|
+
else if (key in file.values) values[key] = { value: file.values[key], source: 'file' };
|
|
152
|
+
else values[key] = { value: spec.def, source: 'default' };
|
|
153
|
+
}
|
|
154
|
+
return { path: file.path, exists: file.exists, values, problems };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --print-config 의 사람용 출력.
|
|
158
|
+
export function configText(cfg) {
|
|
159
|
+
const out = [`설정 파일: ${stripControl(cfg.path)} (${cfg.exists ? '있음' : '없음'})`, ''];
|
|
160
|
+
for (const [key, { value, source }] of Object.entries(cfg.values)) {
|
|
161
|
+
const v = value == null ? (key === 'theme' ? 'auto' : '-') : typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
162
|
+
out.push(`${key.padEnd(14)}${v.padEnd(12)} ${source}`);
|
|
163
|
+
}
|
|
164
|
+
if (cfg.problems.length) out.push('', ...cfg.problems.map((p) => `! ${stripControl(p)}`));
|
|
165
|
+
return out.join('\n');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --print-config --json, --doctor --json 용.
|
|
169
|
+
export const configJson = (cfg) => ({ path: cfg.path, exists: cfg.exists, values: cfg.values, problems: cfg.problems });
|