reverbo 0.1.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/README.md +102 -0
- package/bin/reverbo.js +5 -0
- package/package.json +35 -0
- package/src/api.js +142 -0
- package/src/cache.js +105 -0
- package/src/cli.js +641 -0
- package/src/config.js +81 -0
- package/src/login.js +80 -0
- package/src/outbox.js +264 -0
- package/src/paths.js +15 -0
- package/src/prompt.js +117 -0
- package/src/redact.js +108 -0
- package/src/render.js +164 -0
- package/src/settings.js +56 -0
- package/src/statusline.js +62 -0
- package/src/transcript.js +88 -0
- package/src/translate.js +144 -0
- package/src/wizard.js +93 -0
- package/src/worker.js +58 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
// 실제 I/O 는 여기서만 안다. 앞의 모듈은 전부 주입을 받는다.
|
|
2
|
+
|
|
3
|
+
import { readFile, writeFile, mkdir, rename, readdir, stat, unlink, chmod, open } from 'node:fs/promises'
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { paths } from './paths.js'
|
|
7
|
+
import { loadConfig, saveConfig, modelFor, keyEnvFor, MODELS } from './config.js'
|
|
8
|
+
import { readEntry, writeEntry, acquireLock, prune } from './cache.js'
|
|
9
|
+
import { findPrompt } from './transcript.js'
|
|
10
|
+
import { translate } from './translate.js'
|
|
11
|
+
import { shouldSkip } from './redact.js'
|
|
12
|
+
import { statusline } from './statusline.js'
|
|
13
|
+
import { stringWidth } from './render.js'
|
|
14
|
+
import { planInstall, planUninstall, COMMAND } from './settings.js'
|
|
15
|
+
import { askSecret, askChoice } from './prompt.js'
|
|
16
|
+
import { QUESTIONS, previewOf, applyAnswer, nextValue, currentValue } from './wizard.js'
|
|
17
|
+
import { work } from './worker.js'
|
|
18
|
+
import { login } from './login.js'
|
|
19
|
+
import { startPairing, pollPairing, uploadPrompt, revokeToken, fetchStoring, BASE } from './api.js'
|
|
20
|
+
import { append, appendRaw, drain, lockOutbox, unlockOutbox } from './outbox.js'
|
|
21
|
+
|
|
22
|
+
const BIN = fileURLToPath(new URL('../bin/reverbo.js', import.meta.url))
|
|
23
|
+
|
|
24
|
+
const sleep = (ms) => new Promise((res) => setTimeout(res, ms))
|
|
25
|
+
|
|
26
|
+
// 서버 주소. 시험이 프로덕션을 두들기면 안 돼서 둔 문이다.
|
|
27
|
+
//
|
|
28
|
+
// **루프백만 받는다.** 이 주소로는 프롬프트 원문과 로그인 값이 함께
|
|
29
|
+
// 나가는데(uploadPrompt 가 Authorization 을 싣는다), 열어두면 저장소의
|
|
30
|
+
// `.claude/settings.json` 이 env 한 줄을 심는 것만으로 사람이 친 문장이
|
|
31
|
+
// 남의 서버로 샌다 — 화면엔 아무 표시도 없이. 루프백이 경계인 이유는
|
|
32
|
+
// 거기 서버를 띄우려면 이미 그 기계에서 코드를 돌릴 수 있어야 하고,
|
|
33
|
+
// 그럴 수 있는 상대는 이 문이 없어도 다 할 수 있어서다.
|
|
34
|
+
//
|
|
35
|
+
// 포트 뒤에 `/` 나 문자열 끝을 요구하는 이유 — `http://127.0.0.1:80@evil.example`
|
|
36
|
+
// 처럼 루프백을 사용자 정보 자리에 넣어 진짜 호스트를 뒤에 숨기는 모양과
|
|
37
|
+
// `http://127.0.0.1.evil.example` 처럼 앞부분만 흉내 낸 이름을 막는다.
|
|
38
|
+
const LOOPBACK = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?(\/|$)/
|
|
39
|
+
export const baseOf = () => {
|
|
40
|
+
const b = process.env.REVERBO_BASE
|
|
41
|
+
return b && LOOPBACK.test(b) ? b : BASE
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// outbox 파일 하나를 임계구역으로 보는 잠금. 워커는 프롬프트마다 새
|
|
45
|
+
// 프로세스로 뜨므로, 이 배선이 없으면 둘이 서로의 쌓기를 덮어쓴다.
|
|
46
|
+
//
|
|
47
|
+
// 주인 표시(id)를 함께 적는다 — TTL 이 지나 남이 잠금을 치우고 새로 잡은
|
|
48
|
+
// 뒤에 내가 늦게 끝나면서 그 잠금을 지워버리면 안 된다.
|
|
49
|
+
const BUSY = Symbol('busy')
|
|
50
|
+
|
|
51
|
+
async function withOutbox({ file, tries, run }) {
|
|
52
|
+
const id = `${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
53
|
+
const got = await lockOutbox({
|
|
54
|
+
file, id, tries, now: () => Date.now(), sleep, readFile, writeFile, mkdir, unlink,
|
|
55
|
+
})
|
|
56
|
+
if (!got) return BUSY
|
|
57
|
+
try {
|
|
58
|
+
return await run()
|
|
59
|
+
} finally {
|
|
60
|
+
await unlockOutbox({ file, id, readFile, unlink })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// stdout/stderr 쓰기 실패(예: 파이프 상대가 먼저 닫힘, EPIPE)는 await 체인 위의
|
|
65
|
+
// 예외가 아니라 기반 소켓의 비동기 'error' 이벤트로 온다. main() 의 try/catch 는
|
|
66
|
+
// 그걸 못 잡는다 — 리스너를 안 달면 여기서 스택 트레이스가 뜨고 종료 코드가
|
|
67
|
+
// 1이 된다. 상태줄 명령에서는 그 자체로 사용자 작업 화면이 깨지는 것이다.
|
|
68
|
+
process.stdout.on('error', () => {})
|
|
69
|
+
process.stderr.on('error', () => {})
|
|
70
|
+
|
|
71
|
+
async function readStdin() {
|
|
72
|
+
// 사람이 터미널에서 직접 `reverbo statusline` 을 치면 stdin 이
|
|
73
|
+
// TTY 라 아무도 안 닫는다 — for-await 가 영원히 멈춘다. Claude Code 는
|
|
74
|
+
// 항상 파이프로 붙이니 실사용 경로엔 영향이 없다.
|
|
75
|
+
if (process.stdin.isTTY) return ''
|
|
76
|
+
const chunks = []
|
|
77
|
+
for await (const c of process.stdin) chunks.push(c)
|
|
78
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parse(raw) {
|
|
82
|
+
try {
|
|
83
|
+
const o = JSON.parse(raw)
|
|
84
|
+
return o && typeof o === 'object' ? o : {}
|
|
85
|
+
} catch {
|
|
86
|
+
return {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 사실 ⑤ — Claude Code 가 상태줄 프로세스를 죽일 때 그룹째 죽여도 워커가
|
|
91
|
+
// 살아남아야 한다. detached 로 새 프로세스 그룹에 둔다.
|
|
92
|
+
function spawnWorker(job) {
|
|
93
|
+
try {
|
|
94
|
+
const child = spawn(process.execPath, [BIN, '__worker'], {
|
|
95
|
+
detached: true,
|
|
96
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
97
|
+
})
|
|
98
|
+
// spawn 이 비동기로 실패하면(예: EMFILE) 'error' 이벤트가 뜬다. 리스너가
|
|
99
|
+
// 없으면 이걸 부른 statusline 까지 죽는다 — 절대 안 된다.
|
|
100
|
+
child.on('error', () => {})
|
|
101
|
+
child.stdin.on('error', () => {})
|
|
102
|
+
child.stdin.end(JSON.stringify(job))
|
|
103
|
+
child.unref()
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// I3 — statusline·worker·status·init 이 전부 이 함수 하나를 거쳐야
|
|
108
|
+
// ANTHROPIC_API_KEY 환경변수를 같은 방식으로 본다. 예전엔 status 만 여기서
|
|
109
|
+
// 환경변수를 봐서, 키가 환경변수에만 있는 사용자는 status 가 "있어요"를
|
|
110
|
+
// 찍는데 번역은 하나도 안 뜨는 상황이 됐다(statusline·worker 는 못 봤다).
|
|
111
|
+
// 제공처마다 보는 환경변수와 기본 모델이 다르다. 그 해석도 여기 한 곳에서
|
|
112
|
+
// 끝내야 statusline·worker·status 가 같은 값을 본다.
|
|
113
|
+
const cfgOf = async (file) => {
|
|
114
|
+
const cfg = await loadConfig({ file, readFile })
|
|
115
|
+
return {
|
|
116
|
+
...cfg,
|
|
117
|
+
model: modelFor(cfg.provider, cfg.model),
|
|
118
|
+
apiKey: cfg.apiKey || process.env[keyEnvFor(cfg.provider)] || '',
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 화면에 쓰는 이름. 설정 파일의 값은 소문자라 그대로 찍으면 읽기 나쁘다.
|
|
123
|
+
const LABELS = { openai: 'OpenAI', anthropic: 'Anthropic' }
|
|
124
|
+
const labelOf = (provider) => LABELS[provider] || provider
|
|
125
|
+
|
|
126
|
+
async function cmdStatusline() {
|
|
127
|
+
const out = await statusline({
|
|
128
|
+
input: parse(await readStdin()),
|
|
129
|
+
env: process.env,
|
|
130
|
+
deps: {
|
|
131
|
+
config: cfgOf,
|
|
132
|
+
readEntry: ({ dir, promptId }) => readEntry({ dir, promptId, readFile }),
|
|
133
|
+
acquireLock: ({ dir, promptId, now }) => acquireLock({ dir, promptId, now, readFile, writeFile, mkdir, unlink }),
|
|
134
|
+
spawnWorker,
|
|
135
|
+
now: () => Date.now(),
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
if (out) process.stdout.write(out)
|
|
139
|
+
|
|
140
|
+
// 진단용. REVERBO_TERM_DEBUG 가 켜져 있을 때만, 렌더러가 실제로 받은
|
|
141
|
+
// 폭과 우리가 낸 행들의 실제 폭을 기록한다. 화면에서 잘리는 자리와
|
|
142
|
+
// 대조해 여백을 「재서」 정하려는 것이다 — 추측으로 빼면 또 어긋난다.
|
|
143
|
+
if (process.env.REVERBO_TERM_DEBUG) {
|
|
144
|
+
try {
|
|
145
|
+
const widths = out ? out.split('\n').map((l) => stringWidth(l.replace(/\x1b\[[0-9;]*m/g, ''))) : []
|
|
146
|
+
await writeFile(`${paths(process.env).base}/debug.log`,
|
|
147
|
+
`${new Date().toISOString()} COLUMNS=${process.env.COLUMNS} LINES=${process.env.LINES} 행폭=[${widths.join(',')}]\n`,
|
|
148
|
+
{ flag: 'a' })
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 워커가 부르는 전송 훅. worker.js 가 넘기는 키를 그대로 받는다 —
|
|
154
|
+
// cfg 통째가 아니다(D2): exclude 는 사람의 디렉터리 경로고 apiKey 는 번역
|
|
155
|
+
// 맡기는 곳의 키라, 여기까지 안 오는 것이 맞다.
|
|
156
|
+
//
|
|
157
|
+
// 순서가 「먼저 쌓고 그다음 비운다」인 이유 — 잠금을 못 잡았을 때 잃는 게
|
|
158
|
+
// 없어야 한다. 보내보고 실패할 때만 쌓으면, 그 쌓는 자리에서 잠금을 못
|
|
159
|
+
// 잡는 순간 프롬프트가 사라진다(캐시는 최신 200개만 남고 다시 올리는
|
|
160
|
+
// 경로가 없다). 먼저 쌓아두면 비우기는 실패해도 다음 워커가 이어서 보낸다.
|
|
161
|
+
//
|
|
162
|
+
// 로그인이 만료된 동안에도 같은 순서가 맞다 — drain 이 첫 줄에서 멈추므로
|
|
163
|
+
// 뒤에 붙은 새 것도 안 나가고, 그 사이 친 것은 파일에 그대로 남는다.
|
|
164
|
+
//
|
|
165
|
+
// `tries` 는 쌓는 자리에서 잠금을 몇 번까지 기다릴지다 — 기본값을 쓰면
|
|
166
|
+
// 되고, 시험에서 「끝내 못 잡았을 때」를 빨리 만들려고만 바꾼다.
|
|
167
|
+
// 구독이 없어서 못 쌓을 때 하는 말. 두 가지를 한 번에 말해야 한다 —
|
|
168
|
+
// 무엇이 막혔는지, 그리고 **무엇은 그대로 되는지**. 「구독이 필요해요」만
|
|
169
|
+
// 던지면 아무것도 못 쓰게 된 것처럼 읽히는데, 번역은 계속 된다.
|
|
170
|
+
export const blockedSay = () => [
|
|
171
|
+
'구독하면 쌓여요. 쌓아 둔 것은 그대로 있다가 구독하면 한 번에 올라가요.',
|
|
172
|
+
`구독은 ${baseOf()}/#pricing 에서 해요.`,
|
|
173
|
+
'번역은 그대로 돼요 — 상태줄은 지금처럼 나와요.',
|
|
174
|
+
].join('\n')
|
|
175
|
+
|
|
176
|
+
// 마지막 비우기가 구독 때문에 멈췄는지를 파일 하나로 남긴다. `reverbo status`
|
|
177
|
+
// 가 이걸 읽는다 — 상태를 물으려고 서버를 왕복하면, 비행기에서 친 사람이
|
|
178
|
+
// 진단조차 못 한다.
|
|
179
|
+
//
|
|
180
|
+
// 재시도·로그인 문제일 때는 표시를 **그대로 둔다.** 그 둘은 구독에 대해
|
|
181
|
+
// 아무것도 말해주지 않는데, 지워버리면 「모른다」가 「구독 있다」로 보인다.
|
|
182
|
+
async function noteBlocked(file, result) {
|
|
183
|
+
if (!result || result === BUSY) return
|
|
184
|
+
try {
|
|
185
|
+
if (result.stopped === 'blocked') {
|
|
186
|
+
await writeFile(file, new Date().toISOString() + '\n', { mode: 0o600 })
|
|
187
|
+
} else if (result.stopped === null || result.sent > 0) {
|
|
188
|
+
await unlink(file)
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
// 못 쓰거나 이미 없다. 표시 하나 때문에 보내기가 죽으면 안 된다.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 비우기를 안 돌리고도 알게 된 경우(로그인 직후 한 번 물어본 답)를 같은
|
|
196
|
+
// 표시에 적는다. 모르면(null) 손대지 않는다 — 「모른다」로 옛 표시를 지우면
|
|
197
|
+
// 그게 「구독 있다」로 둔갑한다.
|
|
198
|
+
function markStoring(file, storing) {
|
|
199
|
+
if (storing === true) return noteBlocked(file, { stopped: null, sent: 0 })
|
|
200
|
+
if (storing === false) return noteBlocked(file, { stopped: 'blocked', sent: 0 })
|
|
201
|
+
return Promise.resolve()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function uploadEntry({ promptId, source, translated, repo, from, to, token }, { tries } = {}) {
|
|
205
|
+
// 로그인이 곧 동의다. 안 했으면 아무것도 안 올린다.
|
|
206
|
+
if (!token) return
|
|
207
|
+
const p = paths(process.env)
|
|
208
|
+
const entry = { promptId, source, translated, from, to, repo }
|
|
209
|
+
|
|
210
|
+
// ① 쌓는다 — 파일만 만지는 짧은 구간이다.
|
|
211
|
+
const stacked = await withOutbox({
|
|
212
|
+
file: p.outbox, tries,
|
|
213
|
+
run: () => append({ file: p.outbox, entry, readFile, writeFile, rename, unlink }),
|
|
214
|
+
})
|
|
215
|
+
if (stacked === BUSY) await appendRaw({ file: p.outbox, entry, writeFile })
|
|
216
|
+
|
|
217
|
+
// ② 비운다 — 여기서 못 잡으면 그냥 미룬다. 이미 쌓여 있으니 다음 워커나
|
|
218
|
+
// `reverbo sync` 가 이어서 보낸다. 기다릴 이유가 없어 한 번만 시도한다.
|
|
219
|
+
const drained = await withOutbox({
|
|
220
|
+
file: p.outbox, tries: 1,
|
|
221
|
+
run: () => drain({
|
|
222
|
+
file: p.outbox,
|
|
223
|
+
send: (e) => uploadPrompt({ entry: e, token, fetchImpl: globalThis.fetch, base: baseOf() }),
|
|
224
|
+
readFile, writeFile, rename, unlink, now: () => Date.now(),
|
|
225
|
+
}),
|
|
226
|
+
})
|
|
227
|
+
// 워커는 아무 말도 안 한다(상태줄이 사람의 화면이다). 대신 여기서 본 것을
|
|
228
|
+
// 적어 둬야 `reverbo status` 가 왜 안 올라가는지 말할 수 있다.
|
|
229
|
+
await noteBlocked(p.blocked, drained)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function cmdWorker() {
|
|
233
|
+
await work({
|
|
234
|
+
input: parse(await readStdin()),
|
|
235
|
+
env: process.env,
|
|
236
|
+
deps: {
|
|
237
|
+
config: cfgOf,
|
|
238
|
+
findPrompt: ({ path, promptId }) => findPrompt({ path, promptId, open }),
|
|
239
|
+
// 관문 G2 의 실제 배선. home 을 여기서 채운다 — worker.js 는 어느
|
|
240
|
+
// 계정의 홈인지 몰라도 되고, redact.js 는 process.env 를 직접 안 본다.
|
|
241
|
+
shouldSkip: (a) => shouldSkip({ ...a, home: process.env.HOME || process.env.USERPROFILE || '' }),
|
|
242
|
+
translate: (a) => translate({ ...a, fetchImpl: globalThis.fetch }),
|
|
243
|
+
writeEntry: ({ dir, promptId, entry }) => writeEntry({ dir, promptId, entry, mkdir, writeFile, rename }),
|
|
244
|
+
prune: ({ dir }) => prune({ dir, now: Date.now(), readdir, stat, unlink }),
|
|
245
|
+
upload: uploadEntry,
|
|
246
|
+
},
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function cmdStatus() {
|
|
251
|
+
const p = paths(process.env)
|
|
252
|
+
const raw = await loadConfig({ file: p.configFile, readFile }) // 출처를 가리려면 환경변수 병합 전 값이 필요하다
|
|
253
|
+
const cfg = await cfgOf(p.configFile)
|
|
254
|
+
const key = cfg.apiKey
|
|
255
|
+
? `있어요 (${raw.apiKey ? '설정 파일' : keyEnvFor(cfg.provider)})`
|
|
256
|
+
: `없어요 — ${keyEnvFor(cfg.provider)} 를 넣으면 돼요`
|
|
257
|
+
let cached = 0
|
|
258
|
+
try { cached = (await readdir(p.cacheDir)).filter((f) => f.endsWith('.json')).length } catch {}
|
|
259
|
+
|
|
260
|
+
// 못 보낸 것과 그것이 왜 안 가는지. 서버에는 안 묻는다 — 마지막 비우기가
|
|
261
|
+
// 남긴 표시를 읽는다.
|
|
262
|
+
const waiting = String((await readTextOrNull(p.outbox)) || '').split('\n').filter(Boolean).length
|
|
263
|
+
const blocked = (await readTextOrNull(p.blocked)) !== null
|
|
264
|
+
// 「로그인했어요」만 있으면, 구독이 끊긴 사람은 다 맞는데 왜 안 쌓이는지
|
|
265
|
+
// 알 길이 없다. 막힌 이유가 있으면 그 자리에서 말한다.
|
|
266
|
+
const storing = !cfg.token
|
|
267
|
+
? '로그인 안 했어요 — reverbo login'
|
|
268
|
+
: blocked
|
|
269
|
+
? `구독하면 쌓여요 — ${baseOf()}/#pricing`
|
|
270
|
+
: '로그인했어요'
|
|
271
|
+
|
|
272
|
+
// 키 값은 절대 찍지 않는다. 있는지와 출처만 말한다.
|
|
273
|
+
process.stdout.write([
|
|
274
|
+
`설정 파일: ${p.configFile}`,
|
|
275
|
+
`번역을 맡기는 곳: ${labelOf(cfg.provider)}`,
|
|
276
|
+
`키: ${key}`,
|
|
277
|
+
`번역: ${cfg.from} → ${cfg.to}`,
|
|
278
|
+
`모델: ${cfg.model}`,
|
|
279
|
+
// 있는지만 말한다. 로그인 값 자체는 어느 화면에도 안 찍는다.
|
|
280
|
+
`쌓기: ${storing}`,
|
|
281
|
+
`아직 못 보낸 것: ${waiting}개`,
|
|
282
|
+
`쌓인 번역: ${cached}개`,
|
|
283
|
+
'Claude Code v2.1.196 이상이 필요해요 — 그보다 낮으면 상태줄이 계속 비어 있어요.',
|
|
284
|
+
'',
|
|
285
|
+
].join('\n'))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Claude Code 의 설정 파일. 우리 것이 아니라 조심해서 만진다.
|
|
289
|
+
function claudeSettingsFile(env = process.env) {
|
|
290
|
+
return `${env.HOME || env.USERPROFILE || '.'}/.claude/settings.json`
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function readTextOrNull(file) {
|
|
294
|
+
try { return await readFile(file, 'utf8') } catch { return null }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 붙이기·떼기의 실제 쓰기. 판정은 settings.js 가 하고 여기선 그 결과대로
|
|
298
|
+
// 움직이기만 한다. 쓰기 전에 반드시 백업한다 — 남의 설정 파일이다.
|
|
299
|
+
async function applySettings(file, plan) {
|
|
300
|
+
const existed = plan.status !== 'create'
|
|
301
|
+
if (existed) {
|
|
302
|
+
const before = await readTextOrNull(file)
|
|
303
|
+
if (before !== null) await writeFile(`${file}.bak`, before)
|
|
304
|
+
}
|
|
305
|
+
await mkdir(file.slice(0, file.lastIndexOf('/')), { recursive: true })
|
|
306
|
+
await writeFile(file, JSON.stringify(plan.json, null, 2) + '\n')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const BLOCK = [
|
|
310
|
+
' "statusLine": {',
|
|
311
|
+
' "type": "command",',
|
|
312
|
+
` "command": "${COMMAND}",`,
|
|
313
|
+
' "refreshInterval": 5',
|
|
314
|
+
' }',
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
async function cmdInit() {
|
|
318
|
+
const out = []
|
|
319
|
+
const p = paths(process.env)
|
|
320
|
+
const cur = await loadConfig({ file: p.configFile, readFile })
|
|
321
|
+
|
|
322
|
+
// ① 키 — 환경 변수에 이미 있으면 사본을 늘리지 않는다.
|
|
323
|
+
const envName = keyEnvFor(cur.provider)
|
|
324
|
+
const fromEnv = process.env[envName]
|
|
325
|
+
let apiKey = cur.apiKey
|
|
326
|
+
if (fromEnv) {
|
|
327
|
+
out.push(`키는 ${envName} 에 있어서 그대로 쓸게요 — 설정 파일에 옮겨 적지 않아요.`)
|
|
328
|
+
} else if (!apiKey) {
|
|
329
|
+
// 파이프로 부르거나 다른 도구 안에서 부르면 물어볼 상대가 없다.
|
|
330
|
+
// 「다시 실행해요」는 이때 거짓말이 된다 — 같은 자리에서 다시 해도
|
|
331
|
+
// 똑같다. 무엇이 막고 있는지 정확히 말한다.
|
|
332
|
+
if (!process.stdin.isTTY) {
|
|
333
|
+
out.push(`키가 아직 없어요. 여기서는 물어볼 수 없어서(터미널이 아니에요) 둘 중 하나로 넣어요.`,
|
|
334
|
+
` · 터미널 창에서 직접 \`reverbo init\` 을 실행해요`,
|
|
335
|
+
` · 또는 셸 설정에 \`export ${envName}=...\` 를 넣어요`)
|
|
336
|
+
} else {
|
|
337
|
+
apiKey = await askSecret({
|
|
338
|
+
input: process.stdin,
|
|
339
|
+
output: process.stdout,
|
|
340
|
+
question: `${envName} 값을 붙여넣어요 (화면에 안 보여요): `,
|
|
341
|
+
})
|
|
342
|
+
out.push(apiKey ? '키를 설정 파일에 저장했어요.' : `키를 안 넣었어요. ${envName} 를 내보내두거나 init 을 다시 실행해요.`)
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
out.push('키는 이미 설정 파일에 있어요.')
|
|
346
|
+
}
|
|
347
|
+
await saveConfig({ file: p.configFile, config: { ...cur, apiKey }, mkdir, writeFile, chmod })
|
|
348
|
+
|
|
349
|
+
// ②-a 보이는 방식을 고르게 한다. 지난 턴에 「원문을 띄울까」를 내가
|
|
350
|
+
// 정하려다 만 자리다 — 보여주고 고르게 하는 편이 맞다.
|
|
351
|
+
const picked = await runWizard({ ...cur, apiKey })
|
|
352
|
+
if (picked.asked) {
|
|
353
|
+
await saveConfig({ file: p.configFile, config: picked.cfg, mkdir, writeFile, chmod })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ② 어디로 나가는지 — 고르는 게 없어졌어도 이 사실은 남는다.
|
|
357
|
+
if (cur.provider !== 'anthropic') {
|
|
358
|
+
out.push('', `─ 번역할 문장은 ${labelOf(cur.provider)} 로 나가요.`,
|
|
359
|
+
' 코드나 파일 경로가 섞인 프롬프트면 그것도 함께 가요.')
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ③ Claude Code 에 붙이기 — 사람이 복사해 넣지 않아도 되게.
|
|
363
|
+
const sf = claudeSettingsFile(process.env)
|
|
364
|
+
const plan = planInstall(await readTextOrNull(sf))
|
|
365
|
+
out.push('')
|
|
366
|
+
if (plan.status === 'already') {
|
|
367
|
+
out.push('Claude Code 에는 이미 붙어 있어요.')
|
|
368
|
+
} else if (plan.status === 'conflict') {
|
|
369
|
+
out.push(`${sf} 에 다른 상태줄이 이미 있어요 — 건드리지 않았어요.`,
|
|
370
|
+
` 지금 붙어 있는 것: ${plan.existing}`,
|
|
371
|
+
' 바꾸려면 그 줄을 지우고 init 을 다시 실행해요.')
|
|
372
|
+
} else if (plan.status === 'unparsable') {
|
|
373
|
+
out.push(`${sf} 를 읽을 수 없어서 건드리지 않았어요.`,
|
|
374
|
+
' 아래를 직접 넣어요.', '', ...BLOCK)
|
|
375
|
+
} else {
|
|
376
|
+
await applySettings(sf, plan)
|
|
377
|
+
out.push(`Claude Code 에 붙였어요 — ${sf}`)
|
|
378
|
+
if (plan.status === 'add') out.push(` 고치기 전 파일은 ${sf}.bak 에 두었어요.`)
|
|
379
|
+
out.push('Claude Code 를 새로 열면 상태줄에 떠요.')
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// 설정 마법사. 말로 설명하지 않고 고른 값이 어떻게 보이는지 그려준다.
|
|
386
|
+
// TTY 가 아니면(파이프·다른 도구 안) 조용히 건너뛰고 지금 설정을 그대로 쓴다.
|
|
387
|
+
async function runWizard(cfg) {
|
|
388
|
+
if (!process.stdin.isTTY) return { cfg, asked: false }
|
|
389
|
+
const cols = Number(process.env.COLUMNS) || process.stdout.columns || 80
|
|
390
|
+
let out = { ...cfg }
|
|
391
|
+
for (const q of QUESTIONS) {
|
|
392
|
+
const picked = await askChoice({
|
|
393
|
+
input: process.stdin,
|
|
394
|
+
output: process.stdout,
|
|
395
|
+
title: q.title,
|
|
396
|
+
note: q.note,
|
|
397
|
+
options: q.options,
|
|
398
|
+
current: currentValue(q, out),
|
|
399
|
+
next: (v) => nextValue(q, v),
|
|
400
|
+
preview: (v) => previewOf(q, v, out, Math.min(cols, 100)),
|
|
401
|
+
})
|
|
402
|
+
if (picked === undefined) return { cfg: out, asked: false } // Ctrl-C
|
|
403
|
+
out = applyAnswer(out, q.key, picked)
|
|
404
|
+
}
|
|
405
|
+
return { cfg: out, asked: true }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function cmdConfig() {
|
|
409
|
+
const p = paths(process.env)
|
|
410
|
+
const cur = await loadConfig({ file: p.configFile, readFile })
|
|
411
|
+
const { cfg, asked } = await runWizard(cur)
|
|
412
|
+
if (!asked) {
|
|
413
|
+
process.stdout.write('바뀐 것 없어요.\n')
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
await saveConfig({ file: p.configFile, config: cfg, mkdir, writeFile, chmod })
|
|
417
|
+
process.stdout.write(`설정을 ${p.configFile} 에 저장했어요. Claude Code 를 새로 열면 적용돼요.\n`)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function cmdUninstall() {
|
|
421
|
+
const sf = claudeSettingsFile(process.env)
|
|
422
|
+
const plan = planUninstall(await readTextOrNull(sf))
|
|
423
|
+
const say = {
|
|
424
|
+
nothing: 'Claude Code 에 붙어 있지 않아요.',
|
|
425
|
+
foreign: `우리가 붙인 게 아니라 건드리지 않았어요 — 지금 붙어 있는 것: ${plan.existing}`,
|
|
426
|
+
unparsable: `${sf} 를 읽을 수 없어서 건드리지 않았어요.`,
|
|
427
|
+
}[plan.status]
|
|
428
|
+
if (say) { process.stdout.write(say + '\n'); return }
|
|
429
|
+
await applySettings(sf, plan)
|
|
430
|
+
process.stdout.write([
|
|
431
|
+
`Claude Code 에서 뗐어요 — ${sf}`,
|
|
432
|
+
` 고치기 전 파일은 ${sf}.bak 에 두었어요.`,
|
|
433
|
+
'설정과 쌓인 번역은 그대로 있어요 — 아직 못 보낸 것도 함께요.',
|
|
434
|
+
// 폴더만 지우면 서버 쪽 연결이 끊을 방법 없이 남는다. 순서를 먼저 말한다.
|
|
435
|
+
'전부 지울 때는 reverbo logout 을 먼저 해요 — 그래야 서버 쪽 연결도 같이 끊겨요.',
|
|
436
|
+
`그다음 ${paths(process.env).base} 를 지워요.`,
|
|
437
|
+
'',
|
|
438
|
+
].join('\n'))
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
const say = (s) => process.stdout.write(s + '\n')
|
|
443
|
+
|
|
444
|
+
async function cmdLogin() {
|
|
445
|
+
const p = paths(process.env)
|
|
446
|
+
const cur = await loadConfig({ file: p.configFile, readFile })
|
|
447
|
+
const r = await login({
|
|
448
|
+
start: () => startPairing({ fetchImpl: globalThis.fetch, base: baseOf() }),
|
|
449
|
+
poll: (a) => pollPairing({ ...a, fetchImpl: globalThis.fetch, base: baseOf() }),
|
|
450
|
+
show: (m) => {
|
|
451
|
+
// 사람에게는 사용자코드만 보여준다. 기기코드가 화면에 찍히면 둘로
|
|
452
|
+
// 가른 의미가 없어진다.
|
|
453
|
+
if (m.kind === 'code') {
|
|
454
|
+
say(['', `코드: ${m.userCode}`, '', `${baseOf()}/cli/pair 에서 이 코드를 승인하면 이어서 진행돼요.`, ''].join('\n'))
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
// 사람이 보는 코드는 짧아서 남이 먼저 승인해버리는 경쟁이 가능하다.
|
|
458
|
+
// 어느 계정에 이어졌는지 그 자리에서 보여줘야 알아챈다 — 이 줄이
|
|
459
|
+
// 없으면 서버가 이메일을 함께 실어 보내는 이유가 사라진다.
|
|
460
|
+
if (m.kind === 'linked') say(`${m.email} 계정에 이어졌어요. 내 계정이 아니면 reverbo logout 으로 끊어요.`)
|
|
461
|
+
},
|
|
462
|
+
sleep,
|
|
463
|
+
now: () => Date.now(),
|
|
464
|
+
})
|
|
465
|
+
if (!r.ok) {
|
|
466
|
+
say({
|
|
467
|
+
expired: '코드가 만료됐어요. 다시 실행하면 새 코드가 나와요.',
|
|
468
|
+
unreachable: '서버에 닿지 못했어요. 잠시 뒤 다시 해봐요.',
|
|
469
|
+
timeout: '기다리다 그만뒀어요. 다시 실행하면 새 코드가 나와요.',
|
|
470
|
+
}[r.reason] || '로그인하지 못했어요. 다시 실행하면 새 코드가 나와요.')
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
await saveConfig({ file: p.configFile, config: { ...cur, token: r.token }, mkdir, writeFile, chmod })
|
|
474
|
+
|
|
475
|
+
// 이은 것과 쌓이는 것은 다른 사실이다. 승인은 구독을 안 보므로 여기서 한 번
|
|
476
|
+
// 물어야 「이제 쌓여요」가 거짓말이 되지 않는다. 못 닿았거나 옛 서버라
|
|
477
|
+
// 모르겠으면(null) 아무 주장도 안 하고 평소 문구만 낸다.
|
|
478
|
+
const storing = await fetchStoring({ token: r.token, fetchImpl: globalThis.fetch, base: baseOf() })
|
|
479
|
+
await markStoring(p.blocked, storing)
|
|
480
|
+
const said = storing === false
|
|
481
|
+
say(said ? '로그인했어요.' : '로그인했어요. 이제 번역한 문장이 쌓여요.')
|
|
482
|
+
if (said) say(blockedSay())
|
|
483
|
+
|
|
484
|
+
// 로그인이 죽어 있는 동안 401 로 멈춰 있던 줄이 있으면, 지금이 정확히
|
|
485
|
+
// 그걸 보낼 순간이다. 안 하면 다음 프롬프트를 칠 때까지 그대로 기다린다.
|
|
486
|
+
const waiting = String((await readTextOrNull(p.outbox)) || '').split('\n').filter(Boolean)
|
|
487
|
+
if (!waiting.length) return
|
|
488
|
+
const d = await withOutbox({
|
|
489
|
+
file: p.outbox, tries: 4,
|
|
490
|
+
run: () => drain({
|
|
491
|
+
file: p.outbox,
|
|
492
|
+
send: (e) => uploadPrompt({ entry: e, token: r.token, fetchImpl: globalThis.fetch, base: baseOf() }),
|
|
493
|
+
readFile, writeFile, rename, unlink, now: () => Date.now(),
|
|
494
|
+
}),
|
|
495
|
+
})
|
|
496
|
+
await noteBlocked(p.blocked, d)
|
|
497
|
+
if (d !== BUSY && d.sent) say(`밀려 있던 ${d.sent}개를 올렸어요.`)
|
|
498
|
+
// 위에서 이미 말했으면 두 번 말하지 않는다. 안 물어봤거나 못 물어본
|
|
499
|
+
// 경우(모름)에도 비우기가 402 를 만나면 여기서 알게 된다.
|
|
500
|
+
if (!said && d !== BUSY && d.stopped === 'blocked') say(blockedSay())
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function cmdLogout() {
|
|
504
|
+
const p = paths(process.env)
|
|
505
|
+
const cur = await loadConfig({ file: p.configFile, readFile })
|
|
506
|
+
const pending = String((await readTextOrNull(p.outbox)) || '').split('\n').filter(Boolean)
|
|
507
|
+
// 로그인도 안 했고 남은 것도 없으면 할 일이 없다. 남은 것이 있으면
|
|
508
|
+
// 로그인을 안 했어도 치우고 간다 — 앞선 logout 이 워커와 겹쳐 못 치우고
|
|
509
|
+
// 「잠시 뒤 다시 해요」라고 말한 뒤라면 여기가 그 「다시」다.
|
|
510
|
+
if (!cur.token && !pending.length) { say('로그인 안 했어요.'); return }
|
|
511
|
+
|
|
512
|
+
// 못 보낸 줄은 **떠나는 계정의 것**이다. 남겨두면 다음에 다른 계정으로
|
|
513
|
+
// 로그인한 워커가 그 줄들을 새 계정으로 올린다 — 로그인 화면의
|
|
514
|
+
// 「내 계정이 아니면 logout」 안내가 정확히 이 상황을 상정하고 있어서,
|
|
515
|
+
// 그 경로가 데이터를 남의 계정으로 보내면 안내가 함정이 된다.
|
|
516
|
+
//
|
|
517
|
+
// 그래서 지우기 전에 한 번 올려본다. 로그인 값은 아직 살아 있고(폐기는
|
|
518
|
+
// 아래다), 성공하면 잃는 것이 0 이 된다.
|
|
519
|
+
let cleaned = null
|
|
520
|
+
if (pending.length) {
|
|
521
|
+
cleaned = await withOutbox({
|
|
522
|
+
file: p.outbox, tries: 4,
|
|
523
|
+
run: async () => {
|
|
524
|
+
const d = cur.token
|
|
525
|
+
? await drain({
|
|
526
|
+
file: p.outbox,
|
|
527
|
+
send: (e) => uploadPrompt({ entry: e, token: cur.token, fetchImpl: globalThis.fetch, base: baseOf() }),
|
|
528
|
+
readFile, writeFile, rename, unlink, now: () => Date.now(),
|
|
529
|
+
})
|
|
530
|
+
: { sent: 0 }
|
|
531
|
+
// 반드시 잠금 **안에서** 지운다. 밖에서 지우면, 이미 읽어둔
|
|
532
|
+
// 나머지를 쥐고 있던 워커의 drain 이 왕복을 마치며 그걸 다시 써서
|
|
533
|
+
// 지운 파일이 되살아난다.
|
|
534
|
+
try { await unlink(p.outbox) } catch {}
|
|
535
|
+
return d.sent
|
|
536
|
+
},
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// 막혔다는 표시는 **떠나는 계정의 것**이다. 남겨두면 다음 계정으로
|
|
541
|
+
// 로그인한 사람의 status 가 남의 사정을 말한다.
|
|
542
|
+
try { await unlink(p.blocked) } catch {}
|
|
543
|
+
|
|
544
|
+
const out = []
|
|
545
|
+
let revoked = true
|
|
546
|
+
if (cur.token) {
|
|
547
|
+
// 서버 쪽에는 만료가 없어서 이 문이 유일한 폐기 수단이다. 여기서 안
|
|
548
|
+
// 부르고 로컬만 지우면, 설정 파일이 새는 순간(백업·화면 공유) 창이
|
|
549
|
+
// 열린 채로 영영 남는다.
|
|
550
|
+
revoked = (await revokeToken({ token: cur.token, fetchImpl: globalThis.fetch, base: baseOf() })).ok
|
|
551
|
+
// 서버가 안 닿아도 로컬은 지운다 — 나가려는 사람을 붙잡지 않는다.
|
|
552
|
+
// 번역 키는 남긴다: 로그아웃은 쌓기를 멈추는 것이지 번역을 끄는 게 아니다.
|
|
553
|
+
await saveConfig({ file: p.configFile, config: { ...cur, token: '' }, mkdir, writeFile, chmod })
|
|
554
|
+
out.push('로그아웃했어요. 더는 안 쌓여요. 이미 쌓인 것은 대시보드에서 지울 수 있어요.')
|
|
555
|
+
} else {
|
|
556
|
+
out.push('로그인 안 했어요.')
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const n = pending.length
|
|
560
|
+
if (cleaned === BUSY) {
|
|
561
|
+
// 못 치웠으면 지웠다고 말하지 않는다. 다시 부르면 위의 「남은 것이
|
|
562
|
+
// 있으면」 갈래로 들어와 마저 치운다.
|
|
563
|
+
out.push(`아직 못 보낸 ${n}개는 지금 정리할 수 없어요. 잠시 뒤 reverbo logout 을 한 번 더 실행해요.`)
|
|
564
|
+
} else if (cleaned === n && n > 0) {
|
|
565
|
+
out.push(`못 보낸 것 ${n}개는 마저 올렸어요.`)
|
|
566
|
+
} else if (cleaned > 0) {
|
|
567
|
+
out.push(`못 보낸 것 ${n}개 중 ${cleaned}개는 올리고 나머지는 지웠어요 — 떠나는 계정의 것이라 다음 계정으로 올라가면 안 돼요.`)
|
|
568
|
+
} else if (n > 0) {
|
|
569
|
+
out.push(`못 보낸 ${n}개는 지웠어요 — 떠나는 계정의 것이라 다음 계정으로 올라가면 안 돼요.`)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// 지웠다는 말만 하고 끝내면 서버 쪽이 아직 열려 있는 걸 아무도 모른다.
|
|
573
|
+
// 여기서는 「다시 실행해요」가 거짓말이 된다 — 이 기계엔 이미 값이 없다.
|
|
574
|
+
if (cur.token && !revoked) out.push('서버에는 알리지 못했어요 — 이 기계에서는 지웠어요.')
|
|
575
|
+
say(out.join('\n'))
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function cmdSync(argv) {
|
|
579
|
+
const p = paths(process.env)
|
|
580
|
+
const pending = String((await readTextOrNull(p.outbox)) || '').split('\n').filter(Boolean)
|
|
581
|
+
|
|
582
|
+
if (argv.includes('--dry-run')) {
|
|
583
|
+
say(`보낼 것 ${pending.length}개`)
|
|
584
|
+
for (const line of pending) {
|
|
585
|
+
try {
|
|
586
|
+
const first = String(JSON.parse(line).source || '').split('\n')[0]
|
|
587
|
+
say(' ' + (first.length > 60 ? first.slice(0, 60) + '…' : first))
|
|
588
|
+
} catch {
|
|
589
|
+
// 깨진 줄이다 — 개수에는 세되 못 읽는 것을 그리지는 않는다
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const cfg = await cfgOf(p.configFile)
|
|
596
|
+
if (!cfg.token) { say('로그인하면 보낼 수 있어요.'); return }
|
|
597
|
+
if (pending.length === 0) { say('보낼 게 없어요.'); return }
|
|
598
|
+
|
|
599
|
+
// 워커가 같은 파일을 비우는 중일 수 있다. 겹치면 서로의 쓰기를 덮어쓴다.
|
|
600
|
+
const r = await withOutbox({
|
|
601
|
+
file: p.outbox, tries: 4,
|
|
602
|
+
run: () => drain({
|
|
603
|
+
file: p.outbox,
|
|
604
|
+
send: (e) => uploadPrompt({ entry: e, token: cfg.token, fetchImpl: globalThis.fetch, base: baseOf() }),
|
|
605
|
+
readFile, writeFile, rename, unlink,
|
|
606
|
+
}),
|
|
607
|
+
})
|
|
608
|
+
if (r === BUSY) {
|
|
609
|
+
say('지금 다른 데서 보내는 중이에요. 잠시 뒤 다시 해요.')
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
await noteBlocked(p.blocked, r)
|
|
613
|
+
say(`${r.sent}개 보냈어요. ${r.kept}개 남았어요.`)
|
|
614
|
+
// 왜 멈췄는지 말하지 않으면 「0개 보냈어요. 0개 남았어요.」만 보고 끝난다.
|
|
615
|
+
if (r.stopped === 'auth') say('로그인이 만료됐어요. reverbo login 하면 이어서 올라가요.')
|
|
616
|
+
if (r.stopped === 'retry') say('지금은 다 못 보냈어요. 잠시 뒤 다시 해요.')
|
|
617
|
+
// 구독은 재로그인으로 안 풀린다 — 'auth' 와 같은 말을 하면 될 리 없는
|
|
618
|
+
// 일을 반복하게 만든다.
|
|
619
|
+
if (r.stopped === 'blocked') say(blockedSay())
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export async function main(argv) {
|
|
623
|
+
try {
|
|
624
|
+
switch (argv[0]) {
|
|
625
|
+
case 'statusline': await cmdStatusline(); break
|
|
626
|
+
case '__worker': await cmdWorker(); break
|
|
627
|
+
case 'status': await cmdStatus(); break
|
|
628
|
+
case 'init': await cmdInit(); break
|
|
629
|
+
case 'config': await cmdConfig(); break
|
|
630
|
+
case 'login': await cmdLogin(); break
|
|
631
|
+
case 'logout': await cmdLogout(); break
|
|
632
|
+
case 'sync': await cmdSync(argv); break
|
|
633
|
+
case 'uninstall': await cmdUninstall(); break
|
|
634
|
+
default:
|
|
635
|
+
process.stdout.write('쓸 수 있는 명령: init · config · login · logout · sync · status · uninstall\n')
|
|
636
|
+
}
|
|
637
|
+
} catch {
|
|
638
|
+
// 스펙 D8 — 어떤 경우에도 0 으로 끝난다
|
|
639
|
+
}
|
|
640
|
+
process.exitCode = 0
|
|
641
|
+
}
|