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/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# reverbo
|
|
2
|
+
|
|
3
|
+
터미널에 치는 프롬프트를 번역해 상태줄에 보여줘요. 이미 하고 있는 일이
|
|
4
|
+
읽기 연습이 돼요.
|
|
5
|
+
|
|
6
|
+
한국어로 치면 1~3초 뒤 상태줄에 원문 행과 영어 번역 행이 함께 떠요.
|
|
7
|
+
영어로 이미 쓴 프롬프트에는 아무것도 안 떠요 — 배울 게 없으니까요.
|
|
8
|
+
|
|
9
|
+
## 설치
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npm i -g reverbo
|
|
13
|
+
reverbo init
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`init` 이 키를 물어보고(화면에 안 보여요), 보이는 방식을 고르게 하고,
|
|
17
|
+
Claude Code 에 직접 붙여요. Claude Code 를 새로 열면 상태줄에 떠요.
|
|
18
|
+
|
|
19
|
+
보이는 방식은 나중에 `reverbo config` 로 다시 고를 수 있어요 —
|
|
20
|
+
원문을 전부 띄울지 첫 행만 띄울지, 색으로 가를지, 어느 쪽으로 번역할지.
|
|
21
|
+
고르는 동안 실제로 어떻게 보이는지 그려줘요.
|
|
22
|
+
|
|
23
|
+
키를 `OPENAI_API_KEY` 로 이미 내보내 뒀다면 묻지 않고 그대로 써요 —
|
|
24
|
+
설정 파일에 사본을 만들지 않아요.
|
|
25
|
+
|
|
26
|
+
떼려면 `reverbo uninstall` 이에요. 우리가 붙인 것만 떼고 나머지
|
|
27
|
+
설정은 그대로 둬요.
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
"statusLine": {
|
|
31
|
+
"type": "command",
|
|
32
|
+
"command": "reverbo statusline",
|
|
33
|
+
"refreshInterval": 5
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**이걸 넣으면 Claude Code 가 원래 보여주던 상태줄(현재 폴더·브랜치·모델
|
|
38
|
+
행)이 사라져요.** 그 자리를 reverbo 이 대신 채우는데, 대부분의
|
|
39
|
+
경우엔 번역할 게 없어서 완전히 빈 줄이 돼요 — 설치하고 나서 상태줄이
|
|
40
|
+
안 보인다면 고장이 아니라 원래 그래요.
|
|
41
|
+
|
|
42
|
+
키를 아직 안 넣었다면 `init` 을 다시 실행해서 넣으면 돼요. 키는
|
|
43
|
+
`~/.reverbo/config.json` 에 저장되고, 이미 있으면 `OPENAI_API_KEY`
|
|
44
|
+
환경변수로도 대신할 수 있어요 — 둘 중 어디에 있든 같은 곳(status·상태줄·
|
|
45
|
+
번역)에서 봐요.
|
|
46
|
+
|
|
47
|
+
번역은 Claude Code 가 프롬프트마다 주는 식별자(`prompt_id`)로 찾아요.
|
|
48
|
+
이건 Claude Code v2.1.196 이상에만 있어요 — 그보다 낮은 버전이면 상태줄은
|
|
49
|
+
계속 빈 줄이고, `status` 가 그걸 알려줘요.
|
|
50
|
+
|
|
51
|
+
## 명령
|
|
52
|
+
|
|
53
|
+
| 명령 | 무엇 |
|
|
54
|
+
| -- | -- |
|
|
55
|
+
| `reverbo init` | 설정 파일을 만들고 Claude Code 에 붙이는 방법을 알려줘요 |
|
|
56
|
+
| `reverbo config` | 보이는 방식을 다시 골라요 — 미리보기를 그려줘요 |
|
|
57
|
+
| `reverbo login` | 웹에서 코드를 승인해 로그인해요 — 이후 원문과 번역이 서버에도 쌓여요 |
|
|
58
|
+
| `reverbo logout` | 로그인을 끊어요 — 그 뒤로는 서버에 안 쌓여요 |
|
|
59
|
+
| `reverbo sync` | 아직 못 보낸 것을 마저 보내요. `--dry-run` 을 붙이면 보내지 않고 무엇이 남았는지만 봐요 |
|
|
60
|
+
| `reverbo status` | 키가 있는지·어디서 왔는지, 어느 언어로 번역하는지, 로그인했는지, 캐시가 얼마나 쌓였는지 보여줘요 |
|
|
61
|
+
| `reverbo statusline` | Claude Code 가 상태줄을 그릴 때 부르는 명령이에요 — 직접 실행할 일은 없어요 |
|
|
62
|
+
|
|
63
|
+
인자 없이 실행하면 쓸 수 있는 명령을 보여줘요.
|
|
64
|
+
|
|
65
|
+
## 어디로 가나요
|
|
66
|
+
|
|
67
|
+
**로그인 안 하면** 번역할 문장만 OpenAI 로 나가요. 코드나 파일 경로가
|
|
68
|
+
섞인 프롬프트면 그것도 함께 가요. 기기 밖으로 나가는 건 그게 전부예요.
|
|
69
|
+
기본은 OpenAI 예요. 설정 파일에서 `provider` 를 `anthropic` 으로 바꾸면
|
|
70
|
+
그쪽으로 나가요 — 지금은 설정 마법사가 안 물어보고 파일을 직접 고쳐야 해요.
|
|
71
|
+
|
|
72
|
+
**로그인하면** 원문과 번역이 우리 서버에도 쌓여요. 쌓인 건 대시보드에서
|
|
73
|
+
지울 수 있어요. 터미널에는 아직 지우는 명령이 없어요.
|
|
74
|
+
|
|
75
|
+
**비밀값이 든 프롬프트는 다르게 움직여요.** API 키·개인 키·접속 문자열처럼
|
|
76
|
+
정해진 모양에 걸리면 그 프롬프트는 번역도 안 하고 어디로도 안 나가요 —
|
|
77
|
+
로그인 여부와 상관없이 OpenAI 에도요.
|
|
78
|
+
|
|
79
|
+
**설정 파일(`~/.reverbo/config.json`)의 `exclude` 에 적은 디렉터리에서는
|
|
80
|
+
아무것도 안 나가요.** 그 안에서 친 프롬프트는 번역도, 서버로 쌓는 것도
|
|
81
|
+
건너뛰어요.
|
|
82
|
+
|
|
83
|
+
설정과 캐시된 번역은 기기 안 `~/.reverbo` 에 있어요. 이 디렉터리와 그 안
|
|
84
|
+
파일은 본인 계정만 읽을 수 있게 만들어져요(디렉터리 0700, 파일 0600) —
|
|
85
|
+
프롬프트 원문이 그대로 들어 있어서예요.
|
|
86
|
+
|
|
87
|
+
## 안 죽는 것
|
|
88
|
+
|
|
89
|
+
키가 없거나, 오프라인이거나, 번역이 늦어도 상태줄은 빈 줄로 떨어질 뿐
|
|
90
|
+
작업 화면을 막지 않아요. 종료 코드는 항상 0이에요.
|
|
91
|
+
|
|
92
|
+
## 설계
|
|
93
|
+
|
|
94
|
+
전체 배경과 검증된 사실, 결정 근거는 저장소의
|
|
95
|
+
`docs/superpowers/specs/2026-09-06-terminal-prompt-translation-design.md`
|
|
96
|
+
문서에 있어요. 이 문서는 npm 패키지에는 안 들어가요 — 저장소에서 봐야 해요.
|
|
97
|
+
|
|
98
|
+
## 이용 조건
|
|
99
|
+
|
|
100
|
+
Aimgonna 의 것이에요. 설치해 쓰는 건 자유지만 복제·재배포는 안 돼요
|
|
101
|
+
(`UNLICENSED`). 서버에 쌓는 기능은 구독이 필요하고, 번역은 자기 키로
|
|
102
|
+
언제나 쓸 수 있어요.
|
package/bin/reverbo.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reverbo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "UNLICENSED",
|
|
5
|
+
"homepage": "https://reverbo.aimgonna.com",
|
|
6
|
+
"description": "터미널에 치는 프롬프트를 번역해 상태줄에 보여줍니다",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=20"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"reverbo": "bin/reverbo.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"prepublishOnly": "vitest run"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"vitest": "^2.1.0"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"claude-code",
|
|
29
|
+
"statusline",
|
|
30
|
+
"translation",
|
|
31
|
+
"korean",
|
|
32
|
+
"language-learning",
|
|
33
|
+
"cli"
|
|
34
|
+
]
|
|
35
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// 서버 호출. 이 파일이 서버 계약을 코드로 고정한다 — CLI 가 서버와 말을
|
|
2
|
+
// 섞는 유일한 층이다.
|
|
3
|
+
//
|
|
4
|
+
// 어느 함수도 던지지 않는다. 실패는 값으로 돌려주고, outbox 가 그 값을
|
|
5
|
+
// 보고 다시 보낼지 판단한다. `fetch` 는 주입받는다 — 이 파일이
|
|
6
|
+
// globalThis.fetch 를 직접 부르지 않는다.
|
|
7
|
+
|
|
8
|
+
export const BASE = 'https://reverbo.aimgonna.com'
|
|
9
|
+
|
|
10
|
+
const TIMEOUT = 10000
|
|
11
|
+
|
|
12
|
+
// 끝 슬래시를 지운다. `REVERBO_BASE=https://x.com/` 처럼 사람이 슬래시를
|
|
13
|
+
// 붙여 넣는 건 자연스러운데, 그대로 이어 붙이면 `//api/...` 가 된다.
|
|
14
|
+
function normalizeBase(base) {
|
|
15
|
+
return String(base || '').replace(/\/+$/, '')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function call(url, init, fetchImpl) {
|
|
19
|
+
const ac = new AbortController()
|
|
20
|
+
const timer = setTimeout(() => ac.abort(), TIMEOUT)
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetchImpl(url, {
|
|
23
|
+
...init,
|
|
24
|
+
headers: { 'content-type': 'application/json', ...(init.headers || {}) },
|
|
25
|
+
signal: ac.signal,
|
|
26
|
+
})
|
|
27
|
+
return { ok: !!(res && res.ok), status: (res && res.status) ?? null, res }
|
|
28
|
+
} catch {
|
|
29
|
+
return { ok: false, status: null, res: null } // 끊김·시간 초과
|
|
30
|
+
} finally {
|
|
31
|
+
clearTimeout(timer)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 응답 본문을 JSON 으로 읽는다. 서버가 죽거나 프록시가 HTML 오류 페이지를
|
|
36
|
+
// 주면 res.json() 자체가 던진다 — 그 경우 null 로 삼킨다.
|
|
37
|
+
async function readJson(res) {
|
|
38
|
+
try {
|
|
39
|
+
return await res.json()
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function startPairing({ fetchImpl, base = BASE }) {
|
|
46
|
+
const { ok, res } = await call(normalizeBase(base) + '/api/cli/pair', { method: 'POST', body: '{}' }, fetchImpl)
|
|
47
|
+
if (!ok) return null
|
|
48
|
+
const b = await readJson(res)
|
|
49
|
+
if (!b || !b.deviceCode || !b.userCode) return null
|
|
50
|
+
return { deviceCode: b.deviceCode, userCode: b.userCode, expiresIn: b.expiresIn, interval: b.interval }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function pollPairing({ deviceCode, fetchImpl, base = BASE }) {
|
|
54
|
+
// 기기코드는 비밀이라 주소가 아니라 본문에 싣는다 — 주소는 서버 기록과
|
|
55
|
+
// 중간 장비에 그대로 남는다.
|
|
56
|
+
const { ok, res } = await call(
|
|
57
|
+
normalizeBase(base) + '/api/cli/pair/poll',
|
|
58
|
+
{ method: 'POST', body: JSON.stringify({ deviceCode }) },
|
|
59
|
+
fetchImpl,
|
|
60
|
+
)
|
|
61
|
+
if (!ok) return { status: 'error' }
|
|
62
|
+
const b = await readJson(res)
|
|
63
|
+
if (!b) return { status: 'error' }
|
|
64
|
+
if (b.status === 'approved') {
|
|
65
|
+
// token 없는 approved 는 반쪽 성공이다 — 로그인 흐름이 그대로 믿으면
|
|
66
|
+
// `undefined` 를 설정에 저장하고, 이후 모든 업로드가
|
|
67
|
+
// `Bearer undefined` → 401 → outbox 정지로 조용히 죽는다. 서버와
|
|
68
|
+
// 말이 안 통한 것으로 본다.
|
|
69
|
+
if (!b.token) return { status: 'error' }
|
|
70
|
+
const out = { status: 'approved', token: b.token }
|
|
71
|
+
// 서버는 승인 응답에 email 을 함께 싣는다 — 사람이 보는 짧은 코드를
|
|
72
|
+
// 남이 먼저 승인해버리는 경쟁을 CLI 가 화면에서 잡아내는 방어다.
|
|
73
|
+
// 옛 서버는 안 줄 수 있으니 있을 때만 담는다.
|
|
74
|
+
if (b.email) out.email = b.email
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
// 모르는 status 는 기다리는 쪽으로 본다 — 서버가 앞으로 상태를 늘릴 수
|
|
78
|
+
// 있고, 여기서 잘못 판단해 영원히 기다리는 것보다는 pending 으로 보고
|
|
79
|
+
// 로그인 흐름의 마감(타임아웃)이 결국 끊게 두는 편이 안전하다.
|
|
80
|
+
return { status: b.status === 'expired' ? 'expired' : 'pending' }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function uploadPrompt({ entry, token, fetchImpl, base = BASE }) {
|
|
84
|
+
if (!token) return { ok: false, status: 401 }
|
|
85
|
+
const { ok, status } = await call(
|
|
86
|
+
normalizeBase(base) + '/api/cli/prompts',
|
|
87
|
+
{ method: 'POST', headers: { authorization: 'Bearer ' + token }, body: JSON.stringify(entry) },
|
|
88
|
+
fetchImpl,
|
|
89
|
+
)
|
|
90
|
+
return { ok, status }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 「이 로그인 값으로 쌓을 수 있나」만 묻는다.
|
|
94
|
+
//
|
|
95
|
+
// 물어보려고 새 문을 내지 않는다 — 목록 라우트가 첫 페이지에 그 답을 함께
|
|
96
|
+
// 실어 준다. limit=0 은 「줄은 필요 없고 상태만」이라, 답만 필요한 이 요청에
|
|
97
|
+
// 프롬프트 원문이 한 줄도 안 실린다. 서버는 그때 목록 조회 자체를 건너뛴다.
|
|
98
|
+
//
|
|
99
|
+
// 모르면 null 이다. 못 닿았거나, 옛 서버라 그 칸이 없거나, 본문을 못 읽으면
|
|
100
|
+
// 아무 주장도 하지 않는다 — 모르는 것을 안다고 하면 멀쩡한 구독자에게
|
|
101
|
+
// 구독하라고 말하게 된다.
|
|
102
|
+
export async function fetchStoring({ token, fetchImpl, base = BASE }) {
|
|
103
|
+
if (!token) return null
|
|
104
|
+
const { ok, res } = await call(
|
|
105
|
+
normalizeBase(base) + '/api/cli/prompts?limit=0',
|
|
106
|
+
{ method: 'GET', headers: { authorization: 'Bearer ' + token } },
|
|
107
|
+
fetchImpl,
|
|
108
|
+
)
|
|
109
|
+
if (!ok) return null
|
|
110
|
+
const b = await readJson(res)
|
|
111
|
+
return b && typeof b.storing === 'boolean' ? b.storing : null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 이 함수를 부르는 명령은 없다 — 의도적이다. 터미널 삭제는 AIM-2259 로
|
|
115
|
+
// 미뤘다. 웹에 이미 있고 거기선 한 줄씩 고를 수 있는데, 여기서 `reverbo
|
|
116
|
+
// delete` 를 만들면 전부 지우는 것만 되니 오히려 못한 도구가 된다. 계약은
|
|
117
|
+
// 서버가 이미 받고 있어서 함수는 남겨둔다 — 다음 사람이 「왜 죽어 있지」로
|
|
118
|
+
// 헤매지 않게 이 주석을 남긴다.
|
|
119
|
+
export async function deleteAll({ token, fetchImpl, base = BASE }) {
|
|
120
|
+
if (!token) return { ok: false, status: 401 }
|
|
121
|
+
// ?all=1 은 장식이 아니다 — 빈 값이 「전부」로 읽히던 사고를 막으려고
|
|
122
|
+
// 서버가 일부러 명시하게 만들었다. 빠지면 서버가 400 을 준다.
|
|
123
|
+
const { ok, status } = await call(
|
|
124
|
+
normalizeBase(base) + '/api/cli/prompts?all=1',
|
|
125
|
+
{ method: 'DELETE', headers: { authorization: 'Bearer ' + token } },
|
|
126
|
+
fetchImpl,
|
|
127
|
+
)
|
|
128
|
+
return { ok, status }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 로그인 값을 서버에서 폐기한다. cli_tokens 는 자체 만료가 없어서, 이
|
|
132
|
+
// 문이 없으면 새어 나간 로그인 값(백업·화면 공유)을 되돌릴 방법이 없다.
|
|
133
|
+
// logout 이 로컬 설정만 지우고 이걸 안 부르면 서버 쪽 창은 계속 열려 있다.
|
|
134
|
+
export async function revokeToken({ token, fetchImpl, base = BASE }) {
|
|
135
|
+
if (!token) return { ok: false, status: 401 }
|
|
136
|
+
const { ok, status } = await call(
|
|
137
|
+
normalizeBase(base) + '/api/cli/token',
|
|
138
|
+
{ method: 'DELETE', headers: { authorization: 'Bearer ' + token } },
|
|
139
|
+
fetchImpl,
|
|
140
|
+
)
|
|
141
|
+
return { ok, status }
|
|
142
|
+
}
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// prompt_id 우편함 — 스펙 D4.
|
|
2
|
+
//
|
|
3
|
+
// 읽는 쪽(statusline)과 쓰는 쪽(worker)을 잇는 유일한 통로다. 캐시는
|
|
4
|
+
// 성능이 아니라 작동 조건이다: 사실 ⑤ 때문에 번역을 동기로 부르면
|
|
5
|
+
// 상태줄 프로세스가 취소당해 결과가 도착할 곳이 없다.
|
|
6
|
+
|
|
7
|
+
const SAFE_ID = /^[A-Za-z0-9._-]+$/
|
|
8
|
+
const DEFAULT_TTL = 60_000
|
|
9
|
+
const DEFAULT_KEEP = 200
|
|
10
|
+
|
|
11
|
+
// I5 — 이 디렉터리와 그 안의 파일은 사람이 친 프롬프트 원문을 담는다.
|
|
12
|
+
// 공용 기기에서 다른 계정이 못 읽도록 디렉터리는 0700, 파일은 0600.
|
|
13
|
+
// node:fs 는 여기서 import 하지 않는다 — 값만 주입된 mkdir/writeFile 에
|
|
14
|
+
// 얹어 보낸다(config.js 의 saveConfig 가 이미 이렇게 한다).
|
|
15
|
+
const DIR_MODE = 0o700
|
|
16
|
+
const FILE_MODE = 0o600
|
|
17
|
+
|
|
18
|
+
function entryPath(dir, promptId) {
|
|
19
|
+
return SAFE_ID.test(promptId) ? `${dir}/${promptId}.json` : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function readEntry({ dir, promptId, readFile }) {
|
|
23
|
+
const file = entryPath(dir, promptId)
|
|
24
|
+
if (!file) return null
|
|
25
|
+
try {
|
|
26
|
+
const o = JSON.parse(await readFile(file, 'utf8'))
|
|
27
|
+
return o && typeof o === 'object' ? o : null
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function writeEntry({ dir, promptId, entry, mkdir, writeFile, rename }) {
|
|
34
|
+
const file = entryPath(dir, promptId)
|
|
35
|
+
if (!file) return
|
|
36
|
+
try {
|
|
37
|
+
await mkdir(dir, { recursive: true, mode: DIR_MODE })
|
|
38
|
+
// 임시 파일 → rename. 읽는 쪽이 반쯤 쓰인 파일을 만나지 않는다.
|
|
39
|
+
// rename 은 모드를 바꾸지 않으니 임시 파일에 준 0600 이 그대로 넘어간다.
|
|
40
|
+
await writeFile(`${file}.tmp`, JSON.stringify(entry), { mode: FILE_MODE })
|
|
41
|
+
await rename(`${file}.tmp`, file)
|
|
42
|
+
} catch {
|
|
43
|
+
// 캐시를 못 써도 상태줄은 빈 줄로 살아 있다.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function acquireLock({ dir, promptId, now, readFile, writeFile, mkdir, unlink, ttlMs = DEFAULT_TTL }) {
|
|
48
|
+
const file = entryPath(dir, promptId)
|
|
49
|
+
if (!file) return false
|
|
50
|
+
const lock = `${file}.lock`
|
|
51
|
+
try {
|
|
52
|
+
const at = Number(await readFile(lock, 'utf8'))
|
|
53
|
+
// 워커가 죽으면 잠금이 남는다. 시각을 넣어 오래된 것은 무시한다.
|
|
54
|
+
if (Number.isFinite(at) && now - at < ttlMs) return false
|
|
55
|
+
// M1 — 죽은 잠금이다. wx 로 새로 잡으려면 먼저 지워야 한다(있는 채로
|
|
56
|
+
// wx 를 쓰면 항상 실패한다).
|
|
57
|
+
await unlink(lock)
|
|
58
|
+
} catch {
|
|
59
|
+
// 잠금이 없거나 못 읽는다 — 잡으러 간다
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
await mkdir(dir, { recursive: true, mode: DIR_MODE })
|
|
63
|
+
// M1 — 읽고 나서 쓰는 방식은 두 워커가 동시에 「없다」를 보고 둘 다
|
|
64
|
+
// 잡을 수 있는 틈을 남긴다. wx 는 파일이 이미 있으면 실패하는 원자적
|
|
65
|
+
// 생성이라 그 틈이 없다 — 진 쪽은 여기서 예외를 맞고 false 로 떨어진다.
|
|
66
|
+
await writeFile(lock, String(now), { flag: 'wx', mode: FILE_MODE })
|
|
67
|
+
return true
|
|
68
|
+
} catch {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function prune({
|
|
74
|
+
dir, keep = DEFAULT_KEEP, ttlMs = DEFAULT_TTL, now = Date.now(),
|
|
75
|
+
readdir, stat, unlink,
|
|
76
|
+
}) {
|
|
77
|
+
try {
|
|
78
|
+
const all = await readdir(dir)
|
|
79
|
+
|
|
80
|
+
const jsonFiles = all.filter((f) => f.endsWith('.json'))
|
|
81
|
+
if (jsonFiles.length > keep) {
|
|
82
|
+
const withTime = []
|
|
83
|
+
for (const f of jsonFiles) {
|
|
84
|
+
try { withTime.push({ f: `${dir}/${f}`, t: (await stat(`${dir}/${f}`)).mtimeMs }) } catch {}
|
|
85
|
+
}
|
|
86
|
+
withTime.sort((a, b) => a.t - b.t)
|
|
87
|
+
for (const { f } of withTime.slice(0, withTime.length - keep)) {
|
|
88
|
+
try { await unlink(f) } catch {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// I4 — acquireLock 은 잠금을 잡을 뿐 해제하지 않고, rename 이 실패하면
|
|
93
|
+
// writeEntry 가 .tmp 를 남긴다. 개수 상한이 아니라 나이로 지운다 — 갓
|
|
94
|
+
// 생긴 잠금은 지금 도는 워커를 보호하는 중이라 지우면 재작업이 겹친다.
|
|
95
|
+
const staleCandidates = all.filter((f) => f.endsWith('.lock') || f.endsWith('.tmp'))
|
|
96
|
+
for (const f of staleCandidates) {
|
|
97
|
+
try {
|
|
98
|
+
const { mtimeMs } = await stat(`${dir}/${f}`)
|
|
99
|
+
if (now - mtimeMs >= ttlMs) await unlink(`${dir}/${f}`)
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// 정리는 최선 노력이다. 실패해도 제품이 멈추지 않는다.
|
|
104
|
+
}
|
|
105
|
+
}
|