axmap-cli 0.0.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/.claude/commands/ax-done.md +10 -0
- package/.claude/commands/ax-setup.md +31 -0
- package/.claude/commands/ax-start.md +15 -0
- package/.claude/commands/ax-tell.md +14 -0
- package/.claude/commands/ax-update.md +19 -0
- package/.claude/commands/ax.md +13 -0
- package/CLAUDE.md +309 -0
- package/LICENSE +20 -0
- package/README.md +207 -0
- package/app/README.md +366 -0
- package/app/eval/edges.mjs +242 -0
- package/app/lib/adjacent.mjs +125 -0
- package/app/lib/agentcli.mjs +153 -0
- package/app/lib/analyze.mjs +1159 -0
- package/app/lib/cochange.mjs +421 -0
- package/app/lib/datanodes.mjs +127 -0
- package/app/lib/entry.mjs +192 -0
- package/app/lib/featuregraph.mjs +389 -0
- package/app/lib/features.mjs +645 -0
- package/app/lib/fetchrepo-run.mjs +37 -0
- package/app/lib/fetchrepo.mjs +164 -0
- package/app/lib/flow.mjs +1089 -0
- package/app/lib/ladder.mjs +387 -0
- package/app/lib/langs.mjs +630 -0
- package/app/lib/live.mjs +346 -0
- package/app/lib/llm.mjs +594 -0
- package/app/lib/newfile.mjs +126 -0
- package/app/lib/prdiff.mjs +651 -0
- package/app/lib/reveal.mjs +316 -0
- package/app/lib/roots.mjs +186 -0
- package/app/lib/scope.mjs +342 -0
- package/app/lib/session.mjs +389 -0
- package/app/lib/slots.mjs +233 -0
- package/app/lib/ssot.mjs +277 -0
- package/app/lib/teamview.mjs +962 -0
- package/app/lib/terms.ko.mjs +169 -0
- package/app/server.mjs +1959 -0
- package/app/web/shell.css +538 -0
- package/app/web/shell.html +197 -0
- package/app/web/shell.js +638 -0
- package/app/web/stage.js +347 -0
- package/app/web/words.js +85 -0
- package/bin/axmap.mjs +1918 -0
- package/governance/GOVERNANCE.md +433 -0
- package/governance/gate.mjs +526 -0
- package/governance/vote.mjs +501 -0
- package/mcp/README.md +254 -0
- package/mcp/SETUP-FOR-AI.md +186 -0
- package/mcp/install.ps1 +341 -0
- package/mcp/install.sh +339 -0
- package/mcp/server.mjs +969 -0
- package/package.json +48 -0
- package/src/closure.mjs +343 -0
- package/src/governance.mjs +839 -0
- package/src/invariants.mjs +226 -0
- package/src/mrtarget.mjs +284 -0
- package/src/promote.mjs +177 -0
- package/src/protocol.mjs +423 -0
- package/src/repotarget.mjs +81 -0
- package/src/update.mjs +177 -0
- package/src/version.mjs +186 -0
- package/tools/bus.mjs +520 -0
- package/tools/cluster-experiment.mjs +256 -0
- package/tools/cluster-sweep.mjs +226 -0
- package/tools/make-icon.mjs +108 -0
- package/tools/mcp-register.mjs +269 -0
- package/tools/mr-target.mjs +49 -0
- package/tools/persona-bench.mjs +362 -0
- package/tools/pick-repo.mjs +229 -0
- package/tools/promote.mjs +550 -0
- package/tools/reveal-demo.mjs +158 -0
- package/tools/run-tests.mjs +42 -0
- package/tools/setup.mjs +490 -0
- package/tools/shortcut.mjs +121 -0
- package/tools/smoke.mjs +166 -0
- package/tools/topicgraph.py +154 -0
- package/tools/vendor.mjs +382 -0
- package/tools/version.mjs +115 -0
package/src/protocol.mjs
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* axMap 프로토콜의 순수 로직.
|
|
3
|
+
*
|
|
4
|
+
* 이 파일에는 git도, 파일시스템도, 시계도 없다. 입력 -> 출력만 있다.
|
|
5
|
+
* 겹침 판정은 이 시스템의 유일한 진실이므로 레포를 만들지 않고도 검증할 수 있어야 한다.
|
|
6
|
+
* 부수효과(git, fs)는 전부 bin/axmap.mjs 에 있다.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 경로를 비교 가능한 형태로 정규화한다. POSIX 구분자, 앞의 ./ 와 뒤의 / 제거. */
|
|
10
|
+
export function normalizePath(p) {
|
|
11
|
+
return String(p)
|
|
12
|
+
.replace(/\\/g, '/')
|
|
13
|
+
.replace(/\/+/g, '/')
|
|
14
|
+
.replace(/^\.\//, '')
|
|
15
|
+
.replace(/\/+$/, '')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 두 경로가 겹치는가.
|
|
20
|
+
*
|
|
21
|
+
* 같은 경로거나, 한쪽이 다른 쪽의 조상 디렉터리이면 겹친 것으로 본다.
|
|
22
|
+
* src/auth vs src/auth/login.ts -> 겹침 (디렉터리 claim이 파일을 덮음)
|
|
23
|
+
* src/auth/login vs src/auth/logout -> 안 겹침
|
|
24
|
+
*
|
|
25
|
+
* 문자열 prefix 가 아니라 반드시 '/' 경계로 판정한다.
|
|
26
|
+
* 그렇지 않으면 src/auth 가 src/authz 를 잡아먹는다.
|
|
27
|
+
*/
|
|
28
|
+
export function pathsOverlap(a, b) {
|
|
29
|
+
const x = normalizePath(a)
|
|
30
|
+
const y = normalizePath(b)
|
|
31
|
+
if (x === y) return true
|
|
32
|
+
return x.startsWith(y + '/') || y.startsWith(x + '/')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** claim 이 만료되는 시각(ms). since + ttlMs. */
|
|
36
|
+
export function claimExpiresAt(claim) {
|
|
37
|
+
return Date.parse(claim.since) + Number(claim.ttlMs)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isExpired(claim, now) {
|
|
41
|
+
return claimExpiresAt(claim) <= now
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 만료되지 않은 claim 만 남긴다. 죽은 에이전트의 유령 락을 자동으로 걷어내는 지점. */
|
|
45
|
+
export function activeClaims(claims, now) {
|
|
46
|
+
return claims.filter((c) => !isExpired(c, now))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 아직 효력이 있는 내 claim 을 찾는다. 만료되었으면 null 이다.
|
|
51
|
+
*
|
|
52
|
+
* 만료를 "레코드가 사라짐"이 아니라 "레코드는 남고 효력만 잃음"으로 정의했으므로,
|
|
53
|
+
* 레코드의 존재를 효력으로 착각하면 안 된다.
|
|
54
|
+
* 만료된 내 claim 의 paths 를 다음 claim 에 합치면, 그 사이 남이 정당하게 가져간
|
|
55
|
+
* 경로가 검사 없이 부활한다. 락이 조용히 두 명에게 발급되는 최악의 실패다.
|
|
56
|
+
*/
|
|
57
|
+
export function myActiveClaim(claims, me, now) {
|
|
58
|
+
return activeClaims(claims, now).find((c) => c.agent === me) ?? null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 이 프로토콜의 심장.
|
|
63
|
+
*
|
|
64
|
+
* "내가 요청한 경로들이, 나 아닌 누군가가 지금 붙잡고 있는 경로와 겹치는가?"
|
|
65
|
+
* 라는 질문에 직접 답한다. git 의 줄 단위 diff 가 대신 답해줄 수 없는 질문이다.
|
|
66
|
+
*
|
|
67
|
+
* @returns {{ok: boolean, blocks: Array}} blocks 는 막은 이유의 목록
|
|
68
|
+
*/
|
|
69
|
+
export function checkOverlap({ requested, claims, me, now }) {
|
|
70
|
+
const want = requested.map(normalizePath)
|
|
71
|
+
const blocks = []
|
|
72
|
+
|
|
73
|
+
for (const c of activeClaims(claims, now)) {
|
|
74
|
+
if (c.agent === me) continue // 내 claim 과는 겹쳐도 된다 (추가 claim)
|
|
75
|
+
for (const w of want) {
|
|
76
|
+
for (const held of (c.paths ?? []).map(normalizePath)) {
|
|
77
|
+
if (pathsOverlap(w, held)) {
|
|
78
|
+
blocks.push({
|
|
79
|
+
requested: w,
|
|
80
|
+
held,
|
|
81
|
+
holder: c.agent,
|
|
82
|
+
task: c.task ?? null,
|
|
83
|
+
intent: c.intent ?? null,
|
|
84
|
+
since: c.since,
|
|
85
|
+
expiresAt: claimExpiresAt(c),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { ok: blocks.length === 0, blocks }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// 상태 전이
|
|
97
|
+
//
|
|
98
|
+
// 프로토콜의 모든 판단은 여기 있다. bin/axmap.mjs 는 git 과 파일만 다룬다.
|
|
99
|
+
// 이렇게 나눠야 모델 검증기가 "실제로 제품이 쓰는 로직"을 그대로 돌릴 수 있다.
|
|
100
|
+
// 검증기가 같은 로직을 다시 구현하면, 검증하는 대상이 제품이 아니라 검증기가 된다.
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function upsert(claims, record) {
|
|
104
|
+
return [...claims.filter((c) => c.agent !== record.agent), record].sort((a, b) =>
|
|
105
|
+
a.agent < b.agent ? -1 : a.agent > b.agent ? 1 : 0,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* claim 취득. 관문 2 의 판정이 여기서 일어난다.
|
|
111
|
+
* @returns {{ok:true, record, claims, hadExpired}|{ok:false, blocks}}
|
|
112
|
+
*/
|
|
113
|
+
export function applyClaim({ claims, me, requested, now, ttlMs, task = null, intent = null, actor = null }) {
|
|
114
|
+
const want = [...new Set(requested.map(normalizePath))]
|
|
115
|
+
|
|
116
|
+
const verdict = checkOverlap({ requested: want, claims, me, now })
|
|
117
|
+
if (!verdict.ok) return { ok: false, blocks: verdict.blocks }
|
|
118
|
+
|
|
119
|
+
// 반드시 "효력이 남은" 내 claim 하고만 합친다. 만료된 것과 합치면
|
|
120
|
+
// 그 사이 남이 가져간 경로가 관문 2 를 거치지 않고 부활한다.
|
|
121
|
+
const prev = myActiveClaim(claims, me, now)
|
|
122
|
+
const hadExpired = !prev && claims.some((c) => c.agent === me)
|
|
123
|
+
|
|
124
|
+
const record = {
|
|
125
|
+
agent: me,
|
|
126
|
+
task: task ?? prev?.task ?? null,
|
|
127
|
+
intent: intent ?? prev?.intent ?? null,
|
|
128
|
+
// 누가 잡았는지의 '종류'. 화면에서 사람·AI·백그라운드 에이전트를 색으로 나눈다.
|
|
129
|
+
// 프로토콜 판정에는 쓰이지 않는다 — 표시용 정보다.
|
|
130
|
+
actor: actor ?? prev?.actor ?? null,
|
|
131
|
+
since: new Date(now).toISOString(),
|
|
132
|
+
ttlMs,
|
|
133
|
+
paths: [...new Set([...(prev?.paths ?? []).map(normalizePath), ...want])].sort(),
|
|
134
|
+
}
|
|
135
|
+
return { ok: true, record, claims: upsert(claims, record), hadExpired }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 반납. drop 이 비어 있으면 전부 반납한다.
|
|
140
|
+
* 만료된 레코드도 반납할 수 있다 (단순 정리이므로 관문 2 가 필요 없다).
|
|
141
|
+
*/
|
|
142
|
+
export function applyRelease({ claims, me, drop }) {
|
|
143
|
+
const mine = claims.find((c) => c.agent === me)
|
|
144
|
+
if (!mine) return { claims, record: null, unheld: [], hadNothing: true }
|
|
145
|
+
|
|
146
|
+
const held = mine.paths.map(normalizePath)
|
|
147
|
+
const want = [...new Set(drop.map(normalizePath))]
|
|
148
|
+
const unheld = want.filter((p) => !held.includes(p))
|
|
149
|
+
const remaining = want.length ? held.filter((p) => !want.includes(p)) : []
|
|
150
|
+
|
|
151
|
+
if (!remaining.length) {
|
|
152
|
+
return { claims: claims.filter((c) => c.agent !== me), record: null, unheld }
|
|
153
|
+
}
|
|
154
|
+
const record = { ...mine, paths: remaining }
|
|
155
|
+
return { claims: upsert(claims, record), record, unheld }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* TTL 연장.
|
|
160
|
+
* 만료된 claim 은 연장할 수 없다. renew 는 관문 2 를 거치지 않으므로,
|
|
161
|
+
* 만료 이후를 허용하면 그 사이 남이 가져간 경로를 검사 없이 되찾게 된다.
|
|
162
|
+
*/
|
|
163
|
+
export function applyRenew({ claims, me, now, ttlMs }) {
|
|
164
|
+
const mine = myActiveClaim(claims, me, now)
|
|
165
|
+
if (!mine) return { ok: false, reason: 'expired-or-missing' }
|
|
166
|
+
const record = { ...mine, since: new Date(now).toISOString(), ttlMs }
|
|
167
|
+
return { ok: true, record, claims: upsert(claims, record) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// 입력 검증
|
|
172
|
+
//
|
|
173
|
+
// 락 시스템에서 애매한 입력은 조용히 정규화하면 안 된다.
|
|
174
|
+
// 정규화는 서로 다른 두 입력을 같은 것으로 만들 수 있고, 그것이 곧 소유권 충돌이다.
|
|
175
|
+
// 판단이 서지 않으면 거부한다 (fail-closed).
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 에이전트 이름 검증.
|
|
180
|
+
*
|
|
181
|
+
* 이름은 그대로 파일명이 된다. 위험한 문자를 치환해서 통과시키면
|
|
182
|
+
* "agent/a" 와 "agent_a" 가 같은 파일을 가리켜 서로의 claim 을 덮어쓴다.
|
|
183
|
+
* 치환하지 않고 거부한다.
|
|
184
|
+
*/
|
|
185
|
+
export function agentNameError(name) {
|
|
186
|
+
if (!name) return '에이전트 이름이 비어 있습니다.'
|
|
187
|
+
if (name.length > 64) return `에이전트 이름이 너무 깁니다 (최대 64자): ${name}`
|
|
188
|
+
|
|
189
|
+
// 파일 경로를 깨뜨리거나 다른 파일을 가리킬 수 있는 문자만 막는다.
|
|
190
|
+
// 한글·일본어 같은 문자까지 막으면 한국 팀이 자기 이름을 못 쓴다.
|
|
191
|
+
// 치환하지 않고 거부하는 원칙은 그대로다 — 치환이 곧 이름 충돌이기 때문이다.
|
|
192
|
+
const bad = /[\\/:*?"<>|\u0000-\u001f]/.exec(name)
|
|
193
|
+
if (bad) return `에이전트 이름에 쓸 수 없는 문자가 있습니다: ${JSON.stringify(bad[0])} (${name})`
|
|
194
|
+
if (/^[.\s]|[.\s]$/.test(name)) return `에이전트 이름은 점이나 공백으로 시작·끝날 수 없습니다: ${name}`
|
|
195
|
+
if (name === '.' || name === '..') return `에이전트 이름으로 쓸 수 없습니다: ${name}`
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** claim 경로 검증. 저장소 루트 기준 상대 경로만 허용한다. */
|
|
200
|
+
export function claimPathError(p) {
|
|
201
|
+
const raw = String(p ?? '').trim()
|
|
202
|
+
if (!raw) return '빈 경로는 claim 할 수 없습니다.'
|
|
203
|
+
const n = normalizePath(raw)
|
|
204
|
+
if (!n || n === '.') return `저장소 전체를 claim 할 수 없습니다: ${raw}`
|
|
205
|
+
if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) {
|
|
206
|
+
return `절대 경로는 claim 할 수 없습니다: ${raw}\n저장소 루트 기준 상대 경로를 쓰세요.`
|
|
207
|
+
}
|
|
208
|
+
if (n.split('/').includes('..')) {
|
|
209
|
+
return `저장소 밖을 가리키는 경로는 claim 할 수 없습니다: ${raw}`
|
|
210
|
+
}
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 어떤 파일이 내가 claim 한 경로들에 덮이는가. pre-commit 검사용. */
|
|
215
|
+
export function coversPath(claimPaths, file) {
|
|
216
|
+
const f = normalizePath(file)
|
|
217
|
+
return claimPaths.some((p) => {
|
|
218
|
+
const c = normalizePath(p)
|
|
219
|
+
return f === c || f.startsWith(c + '/')
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** ms 를 "22분", "1시간 5분" 같은 사람이 읽는 문자열로. */
|
|
224
|
+
export function humanDuration(ms) {
|
|
225
|
+
if (ms <= 0) return '만료됨'
|
|
226
|
+
const totalMin = Math.round(ms / 60000)
|
|
227
|
+
if (totalMin < 1) return `${Math.round(ms / 1000)}초`
|
|
228
|
+
if (totalMin < 60) return `${totalMin}분`
|
|
229
|
+
const h = Math.floor(totalMin / 60)
|
|
230
|
+
const m = totalMin % 60
|
|
231
|
+
return m ? `${h}시간 ${m}분` : `${h}시간`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 거부 사유를 사람과 AI 가 함께 읽을 수 있는 형태로 만든다.
|
|
236
|
+
*
|
|
237
|
+
* conflict marker 와의 결정적 차이가 여기다. marker 는 "두 버전이 있다"까지만
|
|
238
|
+
* 말하고 무엇을 해야 하는지는 말하지 않는다. 여기서는 누가, 무엇을, 언제까지
|
|
239
|
+
* 잡고 있는지를 말하므로 AI 가 스스로 다른 작업으로 방향을 틀 수 있다.
|
|
240
|
+
*/
|
|
241
|
+
export function formatBlocks(blocks, now) {
|
|
242
|
+
const lines = ['claim 거부 - 다른 에이전트가 점유 중인 경로가 있습니다.', '']
|
|
243
|
+
for (const b of blocks) {
|
|
244
|
+
const remain = humanDuration(b.expiresAt - now)
|
|
245
|
+
lines.push(` x ${b.requested}`)
|
|
246
|
+
if (b.held !== b.requested) lines.push(` (${b.holder} 가 잡은 ${b.held} 에 포함됨)`)
|
|
247
|
+
lines.push(` 점유자 : ${b.holder}${b.task ? ` (${b.task})` : ''}`)
|
|
248
|
+
if (b.intent) lines.push(` 작업 : ${b.intent}`)
|
|
249
|
+
lines.push(` 시작 : ${b.since}`)
|
|
250
|
+
lines.push(` TTL : ${remain} 남음`)
|
|
251
|
+
lines.push('')
|
|
252
|
+
}
|
|
253
|
+
lines.push('다음 중 하나를 하세요:')
|
|
254
|
+
lines.push(' - 겹치지 않는 다른 경로로 작업을 시작한다')
|
|
255
|
+
lines.push(' - axmap status 로 비어 있는 영역을 확인한다')
|
|
256
|
+
lines.push(' - 점유자의 TTL 만료를 기다린다')
|
|
257
|
+
return lines.join('\n')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// 병합 합의 — 게이트는 거부권, 표는 판단 (SPEC 7-3)
|
|
262
|
+
//
|
|
263
|
+
// 작업 노드가 사라져도 저장소가 스스로 굴러가려면 "이 변경을 넣어도 되는가" 를
|
|
264
|
+
// 사람이 그 자리에 없어도 답할 수 있어야 한다. 그 판정이 여기 있다.
|
|
265
|
+
//
|
|
266
|
+
// 게이트(기계 검증)와 표(판단)를 한 다수결에 섞지 않는다. 섞으면 빨간 테스트를
|
|
267
|
+
// 2/3 으로 통과시킬 수 있게 된다. 이 저장소는 "판정은 언제나 종료 코드" 라고
|
|
268
|
+
// 정해 두었고(CLAUDE.md), 종료 코드는 협상 대상이 아니다.
|
|
269
|
+
//
|
|
270
|
+
// 여기도 순수하다. git 도 시계도 없다 — checkOverlap 과 같은 이유로,
|
|
271
|
+
// 과거를 재생해 사후 감사를 할 수 있어야 하기 때문이다(SPEC 7-2).
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 기본 정책.
|
|
276
|
+
*
|
|
277
|
+
* 정족수를 고정값 3 으로 두면 두 방향으로 틀린다 — 오탈자 하나에 에이전트 3대를
|
|
278
|
+
* 깨워야 하고, 프로토콜 심장부를 고치는 데 1표면 충분해진다. 그래서 등급별이다.
|
|
279
|
+
*/
|
|
280
|
+
export const DEFAULT_MERGE_POLICY = {
|
|
281
|
+
defaultNeed: 1,
|
|
282
|
+
// 여러 등급에 걸치면 가장 높은 쪽을 따른다.
|
|
283
|
+
// docs 는 0 이지만 docs/SPEC.md 는 3 이다 — 규격은 코드보다 먼저이므로.
|
|
284
|
+
tiers: [
|
|
285
|
+
{
|
|
286
|
+
need: 3,
|
|
287
|
+
paths: [
|
|
288
|
+
'src/protocol.mjs',
|
|
289
|
+
'src/invariants.mjs',
|
|
290
|
+
'bin/axmap.mjs',
|
|
291
|
+
'docs/SPEC.md',
|
|
292
|
+
'docs/INVARIANTS.md',
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
{ need: 0, paths: ['docs'] },
|
|
296
|
+
],
|
|
297
|
+
// CLAUDE.md 의 검증 목록 그대로다. 여기서 새로 정하지 않는다.
|
|
298
|
+
requiredGates: ['test', 'demo', 'demo:compare', 'demo:chaos', 'smoke', 'desktop:smoke'],
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 이 경로들을 고치려면 찬성표가 몇 개 필요한가.
|
|
303
|
+
* 경로 판정은 claim 과 같은 coversPath 를 쓴다. 판정 규칙을 두 벌 두지 않는다.
|
|
304
|
+
*/
|
|
305
|
+
export function requiredApprovals(paths, policy = DEFAULT_MERGE_POLICY) {
|
|
306
|
+
// 빈 제안은 등급을 알 수 없다. 0표로 조용히 통과시키지 않는다.
|
|
307
|
+
if (!paths?.length) return policy.defaultNeed
|
|
308
|
+
|
|
309
|
+
let need = 0
|
|
310
|
+
for (const raw of paths) {
|
|
311
|
+
const file = normalizePath(raw)
|
|
312
|
+
const hit = policy.tiers.filter((t) => coversPath(t.paths, file))
|
|
313
|
+
// 어느 등급에도 안 걸리는 새 파일이 0표가 되지 않게 기본값으로 떨어뜨린다.
|
|
314
|
+
const n = hit.length ? Math.max(...hit.map((t) => t.need)) : policy.defaultNeed
|
|
315
|
+
need = Math.max(need, n)
|
|
316
|
+
}
|
|
317
|
+
return need
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 표 레코드 검증.
|
|
322
|
+
*
|
|
323
|
+
* voter 는 그대로 파일명이 되므로 agent 와 **같은 규칙**으로 본다.
|
|
324
|
+
* 규칙을 따로 만들면 "agent-a" 와 "agent/a" 가 표에서만 같은 파일이 된다.
|
|
325
|
+
*/
|
|
326
|
+
export function voteError(vote) {
|
|
327
|
+
const nameErr = agentNameError(vote?.voter)
|
|
328
|
+
if (nameErr) return `표의 투표자 이름이 올바르지 않습니다 - ${nameErr}`
|
|
329
|
+
if (vote.verdict !== 'approve' && vote.verdict !== 'reject') {
|
|
330
|
+
return `표의 판정은 approve 또는 reject 여야 합니다: ${JSON.stringify(vote.verdict ?? null)}`
|
|
331
|
+
}
|
|
332
|
+
// head 없는 표는 "어느 코드를 봤는지 모르는 표" 다. 그런 표는 셀 수 없다.
|
|
333
|
+
if (!/^[0-9a-f]{7,40}$/.test(String(vote.head ?? ''))) {
|
|
334
|
+
return `표에 이 표가 본 커밋(head)이 없습니다: ${JSON.stringify(vote.head ?? null)}`
|
|
335
|
+
}
|
|
336
|
+
return null
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 병합 판정. 게이트가 먼저고, 표는 그다음이다.
|
|
341
|
+
*
|
|
342
|
+
* @param {object} p
|
|
343
|
+
* @param {{id:string, agent:string, head:string, paths:string[]}} p.proposal
|
|
344
|
+
* @param {Array<{voter:string, head:string, verdict:string, lens?:string, reason?:string}>} p.votes
|
|
345
|
+
* @param {Object<string,{ok:boolean, detail?:string}>} p.gates 기계 검증 결과
|
|
346
|
+
* @returns {{ok:boolean, need:number, approvals:string[], blocks:Array}}
|
|
347
|
+
*/
|
|
348
|
+
export function checkMerge({ proposal, votes = [], gates = {}, policy = DEFAULT_MERGE_POLICY }) {
|
|
349
|
+
const blocks = []
|
|
350
|
+
const need = requiredApprovals(proposal.paths, policy)
|
|
351
|
+
|
|
352
|
+
// ── 층 1 · 게이트 ──────────────────────────────────────────────────
|
|
353
|
+
// 빨강이면 표를 아예 세지 않고 나간다. 세기 시작하는 순간
|
|
354
|
+
// "2 대 1 이면 되지 않나" 라는 질문이 생기고, 그 질문이 생기면 언젠가 통과한다.
|
|
355
|
+
for (const name of policy.requiredGates) {
|
|
356
|
+
const g = gates[name]
|
|
357
|
+
// 안 돌린 검증과 통과한 검증을 같게 보면 게이트가 없는 것과 같다 (fail-closed).
|
|
358
|
+
if (!g) blocks.push({ kind: 'missing-gate', gate: name })
|
|
359
|
+
else if (!g.ok) blocks.push({ kind: 'gate', gate: name, detail: g.detail ?? null })
|
|
360
|
+
}
|
|
361
|
+
if (blocks.length) return { ok: false, need, approvals: [], blocks }
|
|
362
|
+
|
|
363
|
+
// ── 층 2 · 표 ─────────────────────────────────────────────────────
|
|
364
|
+
// head 가 다른 표는 다른 코드를 본 표다. 제안자가 지적을 고쳐 새 커밋을 올리면
|
|
365
|
+
// 표가 통째로 리셋되는데, 이건 부작용이 아니라 의도다 — 고친 코드는 다시 봐야 한다.
|
|
366
|
+
const current = votes.filter((v) => v.head === proposal.head)
|
|
367
|
+
|
|
368
|
+
// 반대표 하나가 정족수를 이긴다. 발견은 다수결의 대상이 아니다.
|
|
369
|
+
// 2 대 1 로 덮으면 "한 명이 찾아낸 진짜 문제" 와 "한 명이 틀린 것" 을 똑같이 버린다.
|
|
370
|
+
// 나가는 길은 논쟁이 아니라 고치는 것이고, 고치면 head 가 바뀌어 반대표가 사라진다.
|
|
371
|
+
for (const v of current.filter((v) => v.verdict === 'reject')) {
|
|
372
|
+
blocks.push({ kind: 'reject', voter: v.voter, reason: v.reason ?? null })
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const approvals = [
|
|
376
|
+
...new Set(
|
|
377
|
+
current
|
|
378
|
+
// 자기 표는 세지 않는다. 시빌의 가장 싼 형태이고 막는 비용이 한 줄이다.
|
|
379
|
+
.filter((v) => v.verdict === 'approve' && v.voter !== proposal.agent)
|
|
380
|
+
.map((v) => v.voter),
|
|
381
|
+
),
|
|
382
|
+
// 중복은 "투표자당 파일 하나" 규칙상 생길 수 없지만, 세는 쪽은 보수적으로 둔다.
|
|
383
|
+
].sort()
|
|
384
|
+
|
|
385
|
+
if (approvals.length < need) blocks.push({ kind: 'quorum', need, have: approvals.length })
|
|
386
|
+
|
|
387
|
+
return { ok: blocks.length === 0, need, approvals, blocks }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 병합 보류 사유를 사람과 AI 가 함께 읽는 형태로.
|
|
392
|
+
*
|
|
393
|
+
* formatBlocks 와 같은 원칙이다 — "막혔다"까지만 말하면 에이전트가 다음 행동을
|
|
394
|
+
* 고를 수 없다. 무엇이 막았고 무엇을 하면 풀리는지를 함께 준다.
|
|
395
|
+
*/
|
|
396
|
+
export function formatMerge(result, proposal) {
|
|
397
|
+
if (result.ok) {
|
|
398
|
+
return (
|
|
399
|
+
`병합 가능 - ${proposal.id}\n` +
|
|
400
|
+
` 게이트 전부 초록, 찬성 ${result.approvals.length}/${result.need}` +
|
|
401
|
+
(result.approvals.length ? ` (${result.approvals.join(', ')})` : '')
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const lines = [`병합 보류 - ${proposal.id}`, '']
|
|
406
|
+
for (const b of result.blocks) {
|
|
407
|
+
if (b.kind === 'missing-gate') {
|
|
408
|
+
lines.push(` x 게이트 ${b.gate} 를 돌리지 않았습니다`)
|
|
409
|
+
lines.push(' 안 돌린 것은 통과가 아닙니다. 돌리고 결과를 붙이세요.')
|
|
410
|
+
} else if (b.kind === 'gate') {
|
|
411
|
+
lines.push(` x 게이트 ${b.gate} 빨강${b.detail ? ` - ${b.detail}` : ''}`)
|
|
412
|
+
lines.push(' 표로 덮을 수 없습니다. 고쳐야 합니다.')
|
|
413
|
+
} else if (b.kind === 'reject') {
|
|
414
|
+
lines.push(` x ${b.voter} 반대${b.reason ? ` - ${b.reason}` : ''}`)
|
|
415
|
+
lines.push(' 고쳐서 새로 올리면 head 가 바뀌어 이 표는 사라집니다.')
|
|
416
|
+
} else if (b.kind === 'quorum') {
|
|
417
|
+
lines.push(` x 찬성표 부족 ${b.have}/${b.need}`)
|
|
418
|
+
lines.push(' 표는 실시간이 아닙니다. 쌓일 때까지 기다려도 됩니다.')
|
|
419
|
+
}
|
|
420
|
+
lines.push('')
|
|
421
|
+
}
|
|
422
|
+
return lines.join('\n')
|
|
423
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 지금 사람이 보고 있는 저장소가 어디인지를 **프로세스 사이에 전달하는 한 칸.**
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 파일 한 개인가 ────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 데스크톱 앱에서 [폴더 열기] 를 누르면 뷰어는 **프로세스를 갈아끼워서** 대상을
|
|
7
|
+
* 바꾼다(`desktop/main.mjs` 의 `pickAndReopen`). MCP 서버에는 그 수법이 안 통한다 —
|
|
8
|
+
* **MCP 서버를 띄운 것은 우리가 아니라 AI CLI** 이고, 남의 자식 프로세스는
|
|
9
|
+
* 우리가 재시작시킬 수 없다. 그래서 MCP 쪽은 "교체" 가 아니라 "따라가기" 로 푼다:
|
|
10
|
+
* 앱이 여기에 쓰고, MCP 서버가 도구를 부를 때마다 여기를 읽는다.
|
|
11
|
+
*
|
|
12
|
+
* 🔴 이것은 **사람에게 묻는 장치가 아니다.** stdio MCP 서버는 stdout 이 JSON-RPC
|
|
13
|
+
* 전용이라 "저장소를 고르세요" 를 띄울 화면 자체가 없다(거기에 사람이 읽을 글을
|
|
14
|
+
* 쓰면 클라이언트가 파싱에 실패해 서버가 죽는다). 사람의 입력은 앱에서 폴더를
|
|
15
|
+
* 고르는 그 한 번뿐이고, 그 뒤는 이 파일을 다시 읽는 것으로 끝난다.
|
|
16
|
+
*
|
|
17
|
+
* ── 왜 홈 디렉터리인가 ──────────────────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* 앱은 이미 `app.getPath('userData')` 에 상태를 적지만 그건 **Electron 전용 API** 라
|
|
20
|
+
* 순수 node 로 도는 MCP 서버가 같은 경로를 계산할 방법이 없다. 두 프로세스가 **둘 다
|
|
21
|
+
* 계산할 수 있는 자리**여야 하므로 홈 디렉터리를 쓴다.
|
|
22
|
+
*
|
|
23
|
+
* 저장소 안에 두지 않는 이유: 이 값은 *"지금 이 PC 의 이 사람이 무엇을 보고 있나"* 라
|
|
24
|
+
* 커밋되면 안 된다. 팀원마다 다르고 어제와 오늘이 다르다.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs'
|
|
28
|
+
import os from 'node:os'
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 상태를 두는 폴더.
|
|
33
|
+
*
|
|
34
|
+
* `AXMAP_STATE_DIR` 은 **시험과 이동식 설치를 위한 것**이다. 테스트가 진짜 홈
|
|
35
|
+
* 디렉터리를 건드리면 그 PC 를 쓰는 사람의 실제 대상이 바뀌어 버린다 — 테스트가
|
|
36
|
+
* 사람의 작업 환경을 고치는 것은 어떤 이유로도 정당하지 않다.
|
|
37
|
+
*/
|
|
38
|
+
export const stateDir = () =>
|
|
39
|
+
process.env.AXMAP_STATE_DIR ? path.resolve(process.env.AXMAP_STATE_DIR) : path.join(os.homedir(), '.axmap')
|
|
40
|
+
|
|
41
|
+
export const targetFile = () => path.join(stateDir(), 'current.json')
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 사람이 마지막으로 고른 저장소. 없으면 `null`.
|
|
45
|
+
*
|
|
46
|
+
* 🔴 **폴더가 실제로 있는지 확인하고 낸다.** 지워지거나 옮겨진 경로를 그대로 내면
|
|
47
|
+
* 그 뒤 모든 claim 이 존재하지 않는 저장소를 겨누고, 아무 오류 없이 조용히
|
|
48
|
+
* 아무 일도 일어나지 않는다. 확신 있게 틀린 답이라 없는 답보다 나쁘다.
|
|
49
|
+
*
|
|
50
|
+
* 🔴 **던지지 않는다.** 이 함수는 도구를 부를 때마다 불린다. 여기서 예외가 나가면
|
|
51
|
+
* 상태 파일 하나 때문에 MCP 전체가 죽는다. 못 읽으면 "고른 적 없음" 으로 답하고
|
|
52
|
+
* 부르는 쪽의 다음 순위(git 루트)로 떨어지게 둔다.
|
|
53
|
+
*/
|
|
54
|
+
export function readTarget() {
|
|
55
|
+
try {
|
|
56
|
+
const raw = fs.readFileSync(targetFile(), 'utf8')
|
|
57
|
+
const dir = JSON.parse(raw)?.target
|
|
58
|
+
if (typeof dir !== 'string' || !dir.trim()) return null
|
|
59
|
+
const abs = path.resolve(dir)
|
|
60
|
+
return fs.statSync(abs).isDirectory() ? abs : null
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 사람이 고른 저장소를 적는다. 성공했으면 `true`.
|
|
68
|
+
*
|
|
69
|
+
* 실패해도 던지지 않는다 — 이걸 부르는 곳은 앱의 폴더 열기이고, 상태를 못 적었다고
|
|
70
|
+
* 해서 **폴더 열기 자체가 실패해야 할 이유는 없다.** 뷰어는 그대로 열리고
|
|
71
|
+
* MCP 만 따라오지 못한다. 기능이 하나 줄어드는 것과 창이 안 열리는 것은 다르다.
|
|
72
|
+
*/
|
|
73
|
+
export function writeTarget(dir) {
|
|
74
|
+
try {
|
|
75
|
+
fs.mkdirSync(stateDir(), { recursive: true })
|
|
76
|
+
fs.writeFileSync(targetFile(), JSON.stringify({ target: path.resolve(dir) }, null, 2) + '\n')
|
|
77
|
+
return true
|
|
78
|
+
} catch {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/update.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "지금 쓰는 axMap 이 최신인가" 를 **판정만** 하는 층. 아무것도 설치하지 않는다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 이 파일이 따로 있나 ──────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 배포판(npm 레지스트리에 올린 꾸러미)으로 axMap 을 쓰는 사람은 `git pull` 을
|
|
7
|
+
* 할 저장소가 손에 없다. 그 사람에게 "새 버전이 나왔다" 를 알릴 길이 필요하다.
|
|
8
|
+
*
|
|
9
|
+
* 🔴 **알리기만 하고 바꾸지는 않는다.** 사람이 모르는 사이에 도구가 바뀌면
|
|
10
|
+
* 어제 되던 것이 오늘 안 되고, 원인을 찾을 실마리가 없다 — 바뀐 것이 자기
|
|
11
|
+
* 코드가 아니기 때문이다. 적용은 사람이 `axmap update` 를 읽고 한 줄을
|
|
12
|
+
* 직접 실행하는 것으로만 일어난다.
|
|
13
|
+
*
|
|
14
|
+
* ── 🔴 벤더링된 사본에서는 아무것도 하지 않는다 ─────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* 팀 저장소의 `ci/axmap/` 은 이 코드의 **사본**이고(팀 CLAUDE.md 0.3),
|
|
17
|
+
* 그 사본이 최신인지는 npm 이 아니라 `tools/vendor.mjs` 가 정한다. 사본이
|
|
18
|
+
* 레지스트리를 쳐다보면 두 가지가 한꺼번에 나빠진다.
|
|
19
|
+
*
|
|
20
|
+
* 1. 팀 CI 가 매 파이프라인마다 바깥 네트워크에 의존하게 된다 — 벤더링이
|
|
21
|
+
* 끊으려고 했던 바로 그 연결이다
|
|
22
|
+
* 2. "새 버전이 있다" 는 안내가 팀원에게 뜨는데, 그 팀원이 할 수 있는 일은
|
|
23
|
+
* 없다. 사본은 axMap 저장소에서 `vendor.mjs` 를 다시 돌려야만 바뀐다
|
|
24
|
+
*
|
|
25
|
+
* 사본인지 아닌지는 **옆에 `SOURCE.json` 이 있는가**로 가른다. 그 파일은
|
|
26
|
+
* `tools/vendor.mjs` 가 사본에만 써 넣는다(원본 저장소 루트에는 없다).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import fs from 'node:fs'
|
|
30
|
+
import path from 'node:path'
|
|
31
|
+
import { stateDir } from './repotarget.mjs'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 꾸러미 이름. **`package.json` 의 `name` 과 반드시 같아야 한다.**
|
|
35
|
+
*
|
|
36
|
+
* 🔴 그냥 `axmap` 이 아닌 이유: npm 의 `axmap` 은 2022년부터 **남이 쓰고 있는 다른
|
|
37
|
+
* 꾸러미**다(어떤 Map 라이브러리, 1.1.2). `npx axmap` 이라고 안내하면 사람들이
|
|
38
|
+
* 우리 것이 아닌 코드를 받아 실행한다. 스코프를 붙여 소유를 분명히 한다.
|
|
39
|
+
* 사람이 치는 **명령** 이름은 그대로 `axmap` 이다(`package.json` 의 `bin`).
|
|
40
|
+
*
|
|
41
|
+
* 이 상수가 있는 자리가 하나뿐이어야 한다 — 안내문의 `npx ...` 한 줄도 여기서 나온다.
|
|
42
|
+
*/
|
|
43
|
+
export const PACKAGE_NAME = 'axmap-cli'
|
|
44
|
+
|
|
45
|
+
/** 기본 레지스트리. `AXMAP_REGISTRY` 로 갈아끼운다 — 시험과 사내 레지스트리를 위해서다. */
|
|
46
|
+
export const DEFAULT_REGISTRY = 'https://registry.npmjs.org'
|
|
47
|
+
|
|
48
|
+
/** 하루에 한 번만 묻는다. 도구를 부를 때마다 네트워크를 치면 그게 더 나쁜 버그다. */
|
|
49
|
+
export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
50
|
+
|
|
51
|
+
/** 레지스트리가 안 답할 때 기다리는 한계. 넘으면 그냥 포기한다 — 이건 편의지 판정이 아니다. */
|
|
52
|
+
export const FETCH_TIMEOUT_MS = 3000
|
|
53
|
+
|
|
54
|
+
export const registryUrl = (env = process.env) =>
|
|
55
|
+
String(env.AXMAP_REGISTRY || DEFAULT_REGISTRY).replace(/\/+$/, '')
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 버전 비교. `a` 가 크면 1, 같으면 0, 작으면 -1.
|
|
59
|
+
*
|
|
60
|
+
* 🔴 `'1.10.0' > '1.9.0'` 이 문자열 비교로는 거짓이다. 숫자 칸으로 끊어 센다.
|
|
61
|
+
* 사전배포(`1.2.0-rc.1`)는 같은 자리의 정식판보다 **낮다** — semver 의 규칙이고,
|
|
62
|
+
* 안 그러면 rc 를 올린 날 모두에게 "내려가라" 는 안내가 뜬다.
|
|
63
|
+
*/
|
|
64
|
+
export function compareVersions(a, b) {
|
|
65
|
+
const split = (v) => {
|
|
66
|
+
const core = String(v).trim().replace(/^v/, '').split('+')[0]
|
|
67
|
+
const [num, pre = ''] = core.split('-')
|
|
68
|
+
return { nums: num.split('.').map((n) => Number(n) || 0), pre }
|
|
69
|
+
}
|
|
70
|
+
const x = split(a)
|
|
71
|
+
const y = split(b)
|
|
72
|
+
for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) {
|
|
73
|
+
const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0)
|
|
74
|
+
if (d) return d > 0 ? 1 : -1
|
|
75
|
+
}
|
|
76
|
+
if (x.pre === y.pre) return 0
|
|
77
|
+
if (!x.pre) return 1 // 정식판이 사전배포보다 높다
|
|
78
|
+
if (!y.pre) return -1
|
|
79
|
+
return x.pre > y.pre ? 1 : -1
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 이 axMap 이 **벤더링된 사본**인가. 사본이면 갱신 확인을 통째로 건너뛴다.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} axmapRoot `bin/` · `src/` 를 담고 있는 폴더
|
|
86
|
+
*/
|
|
87
|
+
export const isVendored = (axmapRoot) => fs.existsSync(path.join(axmapRoot, 'SOURCE.json'))
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 확인을 하지 말아야 하는 자리인가.
|
|
91
|
+
*
|
|
92
|
+
* `CI` 는 거의 모든 CI 가 스스로 켜 주는 환경변수다. 파이프라인 안에서 바깥
|
|
93
|
+
* 네트워크를 치는 것은 느려지기만 하고 아무도 그 안내를 못 읽는다.
|
|
94
|
+
*/
|
|
95
|
+
export const checkDisabled = (env = process.env) =>
|
|
96
|
+
Boolean(env.AXMAP_NO_UPDATE_CHECK || env.CI)
|
|
97
|
+
|
|
98
|
+
/** 마지막 확인 결과를 두는 자리. 저장소가 아니라 홈이다 — 이건 이 PC 의 사실이다. */
|
|
99
|
+
export const cacheFile = () => path.join(stateDir(), 'update.json')
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 마지막 확인 결과. 못 읽으면 `null`.
|
|
103
|
+
*
|
|
104
|
+
* 🔴 던지지 않는다. 이 값은 편의를 위한 것이라, 캐시 파일 하나가 깨졌다고
|
|
105
|
+
* CLI 전체가 죽을 이유가 없다.
|
|
106
|
+
*/
|
|
107
|
+
export function readCache() {
|
|
108
|
+
try {
|
|
109
|
+
const rec = JSON.parse(fs.readFileSync(cacheFile(), 'utf8'))
|
|
110
|
+
if (!rec || typeof rec !== 'object') return null
|
|
111
|
+
if (typeof rec.latest !== 'string' || typeof rec.checkedAt !== 'number') return null
|
|
112
|
+
return rec
|
|
113
|
+
} catch {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 확인 결과를 적는다. 실패해도 던지지 않는다 — 못 적으면 다음에 다시 물으면 된다. */
|
|
119
|
+
export function writeCache(rec) {
|
|
120
|
+
try {
|
|
121
|
+
fs.mkdirSync(stateDir(), { recursive: true })
|
|
122
|
+
fs.writeFileSync(cacheFile(), JSON.stringify(rec, null, 2) + '\n')
|
|
123
|
+
return true
|
|
124
|
+
} catch {
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 다시 물어볼 때가 됐는가. 기록이 없으면 언제나 그렇다. */
|
|
130
|
+
export const isStale = (rec, nowMs, interval = CHECK_INTERVAL_MS) =>
|
|
131
|
+
!rec || typeof rec.checkedAt !== 'number' || nowMs - rec.checkedAt >= interval
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 사람에게 보여줄 한 줄. 알릴 것이 없으면 `null`.
|
|
135
|
+
*
|
|
136
|
+
* 🔴 부르는 쪽은 이것을 **stderr 로** 낸다. stdout 은 `--json` 이 쓰는 자리라,
|
|
137
|
+
* 여기에 사람이 읽는 글을 섞으면 그 출력을 파싱하는 쪽이 조용히 깨진다.
|
|
138
|
+
*/
|
|
139
|
+
export function noticeLine(current, latest) {
|
|
140
|
+
if (!current || !latest) return null
|
|
141
|
+
if (compareVersions(latest, current) <= 0) return null
|
|
142
|
+
return `새 버전이 있습니다: ${current} → ${latest}\n 받으려면: npx -y ${PACKAGE_NAME}@latest setup`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 레지스트리에 최신 버전을 묻는다.
|
|
147
|
+
*
|
|
148
|
+
* npm 레지스트리는 `GET <registry>/<이름>/latest` 에 `{ "version": ... }` 을 답한다.
|
|
149
|
+
* 인증도 의존성도 필요 없는 평범한 HTTPS GET 하나다 — 그래서 이 저장소의
|
|
150
|
+
* "의존성 없음" 을 깨지 않고 쓸 수 있다.
|
|
151
|
+
*
|
|
152
|
+
* @returns {Promise<string>} 최신 버전 문자열
|
|
153
|
+
* @throws 못 물어봤을 때. 부르는 쪽이 **조용히 넘어갈지**를 정한다.
|
|
154
|
+
*/
|
|
155
|
+
export async function fetchLatest({
|
|
156
|
+
registry = registryUrl(),
|
|
157
|
+
name = PACKAGE_NAME,
|
|
158
|
+
timeoutMs = FETCH_TIMEOUT_MS,
|
|
159
|
+
fetchImpl = globalThis.fetch,
|
|
160
|
+
} = {}) {
|
|
161
|
+
if (typeof fetchImpl !== 'function') throw new Error('이 node 에는 fetch 가 없습니다 (node 18+ 필요)')
|
|
162
|
+
const ac = new AbortController()
|
|
163
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
|
164
|
+
try {
|
|
165
|
+
const res = await fetchImpl(`${registry}/${encodeURIComponent(name)}/latest`, {
|
|
166
|
+
signal: ac.signal,
|
|
167
|
+
headers: { accept: 'application/json' },
|
|
168
|
+
})
|
|
169
|
+
if (!res.ok) throw new Error(`레지스트리가 ${res.status} 를 냈습니다`)
|
|
170
|
+
const body = await res.json()
|
|
171
|
+
const v = body?.version
|
|
172
|
+
if (typeof v !== 'string' || !v.trim()) throw new Error('레지스트리 응답에 version 이 없습니다')
|
|
173
|
+
return v.trim()
|
|
174
|
+
} finally {
|
|
175
|
+
clearTimeout(timer)
|
|
176
|
+
}
|
|
177
|
+
}
|