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/tools/vendor.mjs
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* axMap 이 자기 저장소로 떨어져 나가도 **팀 저장소의 CI 가 안 죽게** 하는 도구.
|
|
4
|
+
*
|
|
5
|
+
* 하는 일은 하나다 — CI 진입점에서 닿는 파일만 팀 저장소 안의 `ci/axmap/` 으로
|
|
6
|
+
* 복사(**벤더링** vendoring — 남의 코드를 내 저장소 안에 사본으로 두고 그 사본으로
|
|
7
|
+
* 돌리는 것)하고, 어디서 언제 무엇을 떠 왔는지를 같이 적어 둔다.
|
|
8
|
+
*
|
|
9
|
+
* node axmap/tools/vendor.mjs --to <팀저장소 루트> 기본값: 지금 저장소 루트
|
|
10
|
+
* node axmap/tools/vendor.mjs --check 쓰지 않고 무엇이 바뀔지만 본다
|
|
11
|
+
* node axmap/tools/vendor.mjs --repo <URL> 출처 주소를 직접 준다
|
|
12
|
+
*
|
|
13
|
+
* 종료 코드: 0 성공 · 1 판정 불가(환경 문제) · 2 거부(고치면 풀린다)
|
|
14
|
+
* `--check` 는 사본이 낡았을 때도 2 를 낸다.
|
|
15
|
+
*
|
|
16
|
+
* ── 왜 서브모듈도 토큰도 아닌 복사인가 ──────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* 서브모듈(submodule — 다른 저장소를 내 저장소 안에 링크로 매다는 git 기능)이나
|
|
19
|
+
* CI 에서 axMap 을 clone 하는 방식은 **팀 CI 가 남의 저장소를 쳐다보게** 만든다.
|
|
20
|
+
* 그러면 axMap 이 잠깐 깨지거나 접근 토큰이 만료된 날 팀 파이프라인이 통째로
|
|
21
|
+
* 빨개진다 — 팀의 코드는 하나도 안 바뀌었는데.
|
|
22
|
+
*
|
|
23
|
+
* 사본은 그 연결을 끊는다. 대신 "사본이 낡을 수 있다" 는 값을 치른다. 그 값을
|
|
24
|
+
* 받아들이는 이유는 여기서 복사하는 것이 **판정 규칙**이지 제품 코드가 아니어서다.
|
|
25
|
+
* 규칙은 자주 안 바뀌고, 바뀌면 사람이 이 명령을 다시 돌린다.
|
|
26
|
+
*
|
|
27
|
+
* ── 무엇을 복사할지 손으로 적지 않는다 ──────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* 목록은 `src/closure.mjs` 가 import 를 따라가 구한다. `test/closure.test.mjs`
|
|
30
|
+
* 도 같은 함수를 쓴다 — 두 벌로 적으면 어긋난 날 테스트는 초록인데 사본만 깨진다.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { spawnSync } from 'node:child_process'
|
|
34
|
+
import crypto from 'node:crypto'
|
|
35
|
+
import fs from 'node:fs'
|
|
36
|
+
import path from 'node:path'
|
|
37
|
+
import { fileURLToPath } from 'node:url'
|
|
38
|
+
import { CI_ENTRYPOINTS, importClosure, formatClosureProblems } from '../src/closure.mjs'
|
|
39
|
+
|
|
40
|
+
const AXMAP = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
41
|
+
|
|
42
|
+
/** 사본이 놓이는 자리. 팀 저장소 루트 기준. */
|
|
43
|
+
const VENDOR_REL = 'ci/axmap'
|
|
44
|
+
|
|
45
|
+
/** 사본 안에서 나머지 전부의 해시를 들고 있는 파일. 자기 자신은 못 센다. */
|
|
46
|
+
const MANIFEST_NAME = 'manifest.sha256'
|
|
47
|
+
const SOURCE_NAME = 'SOURCE.json'
|
|
48
|
+
const README_NAME = 'README.md'
|
|
49
|
+
|
|
50
|
+
const EXIT = { OK: 0, UNDECIDABLE: 1, REFUSED: 2 }
|
|
51
|
+
|
|
52
|
+
// ── 인자 ──────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const argv = process.argv.slice(2)
|
|
55
|
+
const flag = (n) => {
|
|
56
|
+
const i = argv.indexOf(n)
|
|
57
|
+
return i < 0 ? null : (argv[i + 1] ?? null)
|
|
58
|
+
}
|
|
59
|
+
const has = (n) => argv.includes(n)
|
|
60
|
+
|
|
61
|
+
if (has('--help') || has('-h')) {
|
|
62
|
+
console.log(
|
|
63
|
+
[
|
|
64
|
+
'사용법:',
|
|
65
|
+
' node axmap/tools/vendor.mjs [--to <팀저장소 루트>] [--check] [--repo <URL>]',
|
|
66
|
+
'',
|
|
67
|
+
` --to 사본을 놓을 저장소의 루트. 그 아래 ${VENDOR_REL}/ 이 만들어진다`,
|
|
68
|
+
' (기본값: 지금 저장소의 루트)',
|
|
69
|
+
' --check 아무것도 쓰지 않고 무엇이 달라질지만 본다. 낡았으면 종료 코드 2',
|
|
70
|
+
' --repo SOURCE.json 에 적을 출처 주소. 안 주면 git remote 에서 찾는다',
|
|
71
|
+
].join('\n'),
|
|
72
|
+
)
|
|
73
|
+
process.exit(EXIT.OK)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const checkOnly = has('--check')
|
|
77
|
+
|
|
78
|
+
function die(msg, code = EXIT.UNDECIDABLE) {
|
|
79
|
+
console.error(msg)
|
|
80
|
+
process.exit(code)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── git ───────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
function git(args, cwd = AXMAP) {
|
|
86
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true })
|
|
87
|
+
return { code: r.status ?? 1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const toplevel = git(['rev-parse', '--show-toplevel'])
|
|
91
|
+
if (toplevel.code !== 0) {
|
|
92
|
+
die(`git 저장소 안이 아닙니다: ${AXMAP}\n ${toplevel.err}`)
|
|
93
|
+
}
|
|
94
|
+
const SOURCE_ROOT = path.resolve(toplevel.out)
|
|
95
|
+
|
|
96
|
+
// ── 목적지 ────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const dest = path.resolve(flag('--to') ?? SOURCE_ROOT)
|
|
99
|
+
if (!fs.existsSync(dest) || !fs.statSync(dest).isDirectory()) {
|
|
100
|
+
die(`--to 로 준 자리가 폴더가 아닙니다: ${dest}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const vendorDir = path.join(dest, ...VENDOR_REL.split('/'))
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 🔴 자기 안으로 복사하지 않는다. 사본이 폐포 안에 들어가면 다음 실행이 사본의
|
|
107
|
+
* 사본을 만들고, 그 다음은 그 사본의 사본을 만든다.
|
|
108
|
+
*/
|
|
109
|
+
const insideSource = !path.relative(AXMAP, vendorDir).startsWith('..')
|
|
110
|
+
if (insideSource) {
|
|
111
|
+
die(
|
|
112
|
+
`사본을 axMap 자기 안에 만들 수는 없습니다: ${vendorDir}\n` +
|
|
113
|
+
' --to 로 팀 저장소의 루트를 주세요.\n' +
|
|
114
|
+
' 예: node axmap/tools/vendor.mjs --to ../S15P21E201',
|
|
115
|
+
EXIT.REFUSED,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── 무엇을 복사할지 (손으로 적지 않는다) ──────────────────────────────────
|
|
120
|
+
|
|
121
|
+
const readSource = (rel) => {
|
|
122
|
+
try {
|
|
123
|
+
return fs.readFileSync(path.join(AXMAP, ...rel.split('/')), 'utf8')
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const closure = importClosure({ entries: CI_ENTRYPOINTS, readFile: readSource })
|
|
130
|
+
const problems = formatClosureProblems(closure)
|
|
131
|
+
if (problems.length > 0) {
|
|
132
|
+
console.error('복사할 목록을 확정할 수 없습니다.')
|
|
133
|
+
console.error('')
|
|
134
|
+
for (const line of problems) console.error(` 🔴 ${line}`)
|
|
135
|
+
console.error('')
|
|
136
|
+
console.error(' 고친 다음 다시 돌리세요. 반쪽짜리 사본을 만드는 것보다 안 만드는 것이 낫습니다.')
|
|
137
|
+
process.exit(EXIT.REFUSED)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── 출처가 커밋된 상태인가 ────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
const sha = git(['rev-parse', 'HEAD']).out
|
|
143
|
+
if (!/^[0-9a-f]{40}$/.test(sha)) {
|
|
144
|
+
die(`출처 커밋을 읽지 못했습니다 (git rev-parse HEAD → "${sha}")`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 🔴 **복사할 파일이 커밋된 상태여야 한다.**
|
|
149
|
+
*
|
|
150
|
+
* 사본에는 출처 커밋 sha 가 박힌다. 그 sha 가 가리키는 내용과 사본의 바이트가
|
|
151
|
+
* 다르면 사본은 자기 출처에 대해 거짓말을 하는 것이고, 그게 벤더링에서 제일 나쁜
|
|
152
|
+
* 상태다 — 사본이 낡은 것은 diff 로 보이지만, 출처를 속이는 사본은 안 보인다.
|
|
153
|
+
*
|
|
154
|
+
* 🔴 온 저장소가 아니라 **복사되는 파일만** 본다.
|
|
155
|
+
*
|
|
156
|
+
* `git status --porcelain` 전체를 조건으로 걸면 이 도구 자신을 처음 만든 커밋
|
|
157
|
+
* 직전에는 절대 돌릴 수 없다 — 새 파일이 있으니 언제나 더럽다. 그런데 지켜야
|
|
158
|
+
* 하는 성질은 "저장소가 깨끗한가" 가 아니라 **"내가 복사하는 바이트가 이 sha 에
|
|
159
|
+
* 있는 바이트와 같은가"** 다. 그건 복사 대상만 보면 정확히 판정된다.
|
|
160
|
+
* 나머지 더러운 것은 아래에서 알려만 주고 막지 않는다.
|
|
161
|
+
*/
|
|
162
|
+
const absOfClosure = closure.files.map((rel) => path.join(AXMAP, ...rel.split('/')))
|
|
163
|
+
const dirtyCopied = git(['status', '--porcelain', '--', ...absOfClosure])
|
|
164
|
+
if (dirtyCopied.code !== 0) {
|
|
165
|
+
die(`git status 를 읽지 못했습니다\n ${dirtyCopied.err}`)
|
|
166
|
+
}
|
|
167
|
+
if (dirtyCopied.out !== '') {
|
|
168
|
+
console.error('복사할 파일 중에 커밋되지 않은 것이 있습니다. 복사하지 않았습니다.')
|
|
169
|
+
console.error('')
|
|
170
|
+
for (const line of dirtyCopied.out.split('\n')) console.error(` ${line}`)
|
|
171
|
+
console.error('')
|
|
172
|
+
console.error(` 사본에는 출처 커밋(${sha.slice(0, 8)})이 박힙니다. 지금 복사하면 그 sha 에`)
|
|
173
|
+
console.error(' 있는 내용과 사본의 내용이 달라지고, 나중에 아무도 그것을 알아챌 수 없습니다.')
|
|
174
|
+
console.error(' 먼저 커밋한 뒤 다시 돌리세요.')
|
|
175
|
+
process.exit(EXIT.REFUSED)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const dirtyRest = git(['status', '--porcelain'])
|
|
179
|
+
if (dirtyRest.out !== '') {
|
|
180
|
+
console.log('참고: 복사 대상은 아니지만 커밋되지 않은 것이 있습니다 (막지는 않습니다).')
|
|
181
|
+
for (const line of dirtyRest.out.split('\n')) console.log(` ${line}`)
|
|
182
|
+
console.log('')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── 출처 주소 · 브랜치 ────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/** `bin/axmap.mjs` · `governance/gate.mjs` 와 같은 순서로 원격을 고른다. */
|
|
188
|
+
function sourceRepoUrl() {
|
|
189
|
+
const given = flag('--repo')
|
|
190
|
+
if (given) return given
|
|
191
|
+
const named = git(['config', '--get', 'axmap.remote']).out || 'origin'
|
|
192
|
+
const url = git(['remote', 'get-url', named]).out
|
|
193
|
+
return url || null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const repoUrl = sourceRepoUrl()
|
|
197
|
+
const branch = (() => {
|
|
198
|
+
const b = git(['rev-parse', '--abbrev-ref', 'HEAD']).out
|
|
199
|
+
return b && b !== 'HEAD' ? b : null
|
|
200
|
+
})()
|
|
201
|
+
|
|
202
|
+
// ── 만들 내용을 먼저 메모리에 짓는다 ──────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/** @type {Map<string, Buffer>} 사본 안 상대 경로 → 바이트 */
|
|
205
|
+
const next = new Map()
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 🔴 바이트 그대로 옮긴다. 텍스트로 읽어 다시 쓰면 Windows 에서 줄바꿈이 CRLF 로
|
|
209
|
+
* 바뀌고, 리눅스 러너에서 해시를 다시 재는 순간 전부 어긋난다.
|
|
210
|
+
*/
|
|
211
|
+
for (const rel of closure.files) {
|
|
212
|
+
next.set(rel, fs.readFileSync(path.join(AXMAP, ...rel.split('/'))))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
next.set(README_NAME, Buffer.from(renderReadme(), 'utf8'))
|
|
216
|
+
|
|
217
|
+
// ── 이미 있는 사본을 먼저 읽어 둔다 (무엇이 달라지는지 말하려고) ──────────
|
|
218
|
+
|
|
219
|
+
/** @type {Map<string, Buffer>} */
|
|
220
|
+
const prev = new Map()
|
|
221
|
+
if (fs.existsSync(vendorDir)) {
|
|
222
|
+
for (const rel of walk(vendorDir)) {
|
|
223
|
+
prev.set(rel, fs.readFileSync(path.join(vendorDir, ...rel.split('/'))))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 복사 시각. 내용이 하나도 안 바뀌었으면 **전에 적힌 시각을 그대로 둔다.**
|
|
229
|
+
*
|
|
230
|
+
* 안 그러면 돌릴 때마다 SOURCE.json 과 manifest 만 바뀐 diff 가 생기고, 리뷰어는
|
|
231
|
+
* 곧 그 두 파일의 변경을 안 보게 된다. 그런데 이 두 파일이 바뀌었다는 사실이
|
|
232
|
+
* 바로 `ci/verify-vendor.mjs` 가 리뷰어에게 보여 주려는 신호다 — 잡음으로 만들면
|
|
233
|
+
* 그 신호가 죽는다.
|
|
234
|
+
*/
|
|
235
|
+
const contentSame =
|
|
236
|
+
[...next.keys()].every((k) => prev.has(k) && prev.get(k).equals(next.get(k))) &&
|
|
237
|
+
[...prev.keys()].every((k) => k === MANIFEST_NAME || k === SOURCE_NAME || next.has(k))
|
|
238
|
+
|
|
239
|
+
let vendoredAt = new Date().toISOString()
|
|
240
|
+
if (contentSame && prev.has(SOURCE_NAME)) {
|
|
241
|
+
try {
|
|
242
|
+
const old = JSON.parse(prev.get(SOURCE_NAME).toString('utf8'))
|
|
243
|
+
if (old?.source?.commit === sha && typeof old.vendoredAt === 'string') vendoredAt = old.vendoredAt
|
|
244
|
+
} catch {
|
|
245
|
+
// 못 읽으면 그냥 지금 시각을 쓴다. 여기서 죽을 이유가 없다.
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
next.set(SOURCE_NAME, Buffer.from(renderSourceJson(vendoredAt), 'utf8'))
|
|
250
|
+
next.set(MANIFEST_NAME, Buffer.from(renderManifest(next), 'utf8'))
|
|
251
|
+
|
|
252
|
+
// ── 무엇이 달라지는가 ─────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
const added = [...next.keys()].filter((k) => !prev.has(k)).sort()
|
|
255
|
+
const removed = [...prev.keys()].filter((k) => !next.has(k)).sort()
|
|
256
|
+
const changed = [...next.keys()].filter((k) => prev.has(k) && !prev.get(k).equals(next.get(k))).sort()
|
|
257
|
+
const same = [...next.keys()].filter((k) => prev.has(k) && prev.get(k).equals(next.get(k))).sort()
|
|
258
|
+
|
|
259
|
+
console.log(`출처 ${repoUrl ?? '(원격을 찾지 못했습니다)'}`)
|
|
260
|
+
console.log(`커밋 ${sha}${branch ? ` (${branch})` : ''}`)
|
|
261
|
+
console.log(`사본 ${vendorDir}`)
|
|
262
|
+
console.log('')
|
|
263
|
+
for (const rel of removed) console.log(` - 사라짐 ${rel}`)
|
|
264
|
+
for (const rel of added) console.log(` + 새로 ${rel}`)
|
|
265
|
+
for (const rel of changed) console.log(` ~ 바뀜 ${rel}`)
|
|
266
|
+
for (const rel of same) console.log(` 그대로 ${rel}`)
|
|
267
|
+
console.log('')
|
|
268
|
+
|
|
269
|
+
const differs = added.length > 0 || removed.length > 0 || changed.length > 0
|
|
270
|
+
|
|
271
|
+
if (checkOnly) {
|
|
272
|
+
if (!differs) {
|
|
273
|
+
console.log('사본은 출처와 같습니다. 아무것도 쓰지 않았습니다.')
|
|
274
|
+
process.exit(EXIT.OK)
|
|
275
|
+
}
|
|
276
|
+
console.log('사본이 출처와 다릅니다. 아무것도 쓰지 않았습니다.')
|
|
277
|
+
console.log(` 맞추려면: node axmap/tools/vendor.mjs --to ${dest}`)
|
|
278
|
+
process.exit(EXIT.REFUSED)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── 쓴다 ──────────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 🔴 지우고 새로 만든다. 덮어쓰기만 하면 출처에서 **사라진** 파일이 사본에 남고,
|
|
285
|
+
* 남은 그 파일은 아무도 안 고치는데 계속 import 될 수 있다.
|
|
286
|
+
*/
|
|
287
|
+
if (fs.existsSync(vendorDir)) fs.rmSync(vendorDir, { recursive: true, force: true })
|
|
288
|
+
for (const [rel, buf] of [...next.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
289
|
+
const abs = path.join(vendorDir, ...rel.split('/'))
|
|
290
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
291
|
+
fs.writeFileSync(abs, buf)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
console.log(differs ? '사본을 새로 만들었습니다.' : '사본을 다시 만들었습니다 (내용은 같습니다).')
|
|
295
|
+
console.log(` 확인: node ${path.posix.join('ci', 'verify-vendor.mjs')} (${dest} 에서)`)
|
|
296
|
+
process.exit(EXIT.OK)
|
|
297
|
+
|
|
298
|
+
// ── 만드는 것들 ───────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/** 사본 폴더 아래의 모든 파일을 posix 상대 경로로. */
|
|
301
|
+
function walk(root, rel = '') {
|
|
302
|
+
const out = []
|
|
303
|
+
for (const e of fs.readdirSync(path.join(root, ...rel.split('/').filter(Boolean)), { withFileTypes: true })) {
|
|
304
|
+
const child = rel ? `${rel}/${e.name}` : e.name
|
|
305
|
+
if (e.isDirectory()) out.push(...walk(root, child))
|
|
306
|
+
else out.push(child)
|
|
307
|
+
}
|
|
308
|
+
return out.sort()
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* `<hex> <상대경로>` 를 경로 정렬로. 줄바꿈은 LF 고정.
|
|
313
|
+
*
|
|
314
|
+
* 🔴 manifest 자신은 목록에 못 넣는다 — 자기 해시를 자기 안에 적을 수 없다.
|
|
315
|
+
* 대신 `ci/verify-vendor.mjs` 가 manifest 를 뺀 나머지 전부를 세므로,
|
|
316
|
+
* 목록에 없는 파일이 사본에 슬쩍 들어오는 것은 잡힌다.
|
|
317
|
+
*/
|
|
318
|
+
function renderManifest(files) {
|
|
319
|
+
const rows = [...files.keys()]
|
|
320
|
+
.filter((rel) => rel !== MANIFEST_NAME)
|
|
321
|
+
.sort()
|
|
322
|
+
.map((rel) => `${crypto.createHash('sha256').update(files.get(rel)).digest('hex')} ${rel}`)
|
|
323
|
+
return `${rows.join('\n')}\n`
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function renderSourceJson(at) {
|
|
327
|
+
const body = {
|
|
328
|
+
_: '이 폴더는 다른 저장소에서 복사해 온 사본입니다. 손으로 고치지 마세요 — README.md 를 보세요.',
|
|
329
|
+
source: {
|
|
330
|
+
repository: repoUrl,
|
|
331
|
+
commit: sha,
|
|
332
|
+
branch,
|
|
333
|
+
generator: 'axmap/tools/vendor.mjs',
|
|
334
|
+
},
|
|
335
|
+
vendoredAt: at,
|
|
336
|
+
entrypoints: [...closure.entries].sort(),
|
|
337
|
+
files: [...closure.files],
|
|
338
|
+
}
|
|
339
|
+
return `${JSON.stringify(body, null, 2)}\n`
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function renderReadme() {
|
|
343
|
+
return [
|
|
344
|
+
'# `ci/axmap/` — 손으로 고치지 마세요',
|
|
345
|
+
'',
|
|
346
|
+
'이 폴더의 파일은 전부 **다른 저장소에서 복사해 온 사본**입니다',
|
|
347
|
+
'(**벤더링** vendoring — 남의 코드를 내 저장소 안에 두고 그 사본으로 돌리는 것).',
|
|
348
|
+
'원본이 어디의 어느 커밋인지는 `SOURCE.json` 에 있습니다.',
|
|
349
|
+
'',
|
|
350
|
+
'## 왜 사본인가',
|
|
351
|
+
'',
|
|
352
|
+
'팀 CI(**코드를 올릴 때마다 자동으로 도는 검사**)가 이 파일들을 부릅니다.',
|
|
353
|
+
'CI 가 실행할 때마다 axMap 저장소를 clone 해 오게 만들면, axMap 이 잠깐 깨지거나',
|
|
354
|
+
'접근 토큰이 만료된 날 **팀 코드는 하나도 안 바뀌었는데 팀 파이프라인이 빨개집니다.**',
|
|
355
|
+
'사본은 그 연결을 끊습니다. 팀 저장소는 axMap 을 쳐다보지 않습니다.',
|
|
356
|
+
'',
|
|
357
|
+
'## 고쳐야 한다면',
|
|
358
|
+
'',
|
|
359
|
+
'```bash',
|
|
360
|
+
'# 1. axMap 저장소에서 고치고 커밋한다',
|
|
361
|
+
'# 2. 거기서 이 명령을 돌린다',
|
|
362
|
+
'node tools/vendor.mjs --to <이 저장소의 루트>',
|
|
363
|
+
'# 3. 여기 생긴 변경을 MR 로 올린다',
|
|
364
|
+
'```',
|
|
365
|
+
'',
|
|
366
|
+
'여기서 직접 고치면 `ci/verify-vendor.mjs` 가 파이프라인에서 막습니다.',
|
|
367
|
+
'그 검사는 `manifest.sha256` 에 적힌 해시와 실제 파일을 대조합니다.',
|
|
368
|
+
'',
|
|
369
|
+
'## 폴더 구조를 평평하게 만들지 마세요',
|
|
370
|
+
'',
|
|
371
|
+
'`tools/promote.mjs` 는 형제 폴더의 `governance/gate.mjs` 를 경로로 찾아',
|
|
372
|
+
'자식 프로세스로 띄웁니다. `src/…` 상대 import 도 마찬가지입니다.',
|
|
373
|
+
'구조를 바꾸면 **테스트는 통과하는데 CI 에서만** 조용히 깨집니다.',
|
|
374
|
+
'',
|
|
375
|
+
'## 여기 없는 것',
|
|
376
|
+
'',
|
|
377
|
+
'CI 진입점에서 import 로 닿지 않는 파일은 복사되지 않습니다.',
|
|
378
|
+
'axMap 의 나머지(선점 장부·MCP 서버·데스크톱 앱)는 팀 CI 가 부르지 않으므로',
|
|
379
|
+
'여기 없습니다. 그것들이 필요하면 axMap 저장소를 직접 쓰세요.',
|
|
380
|
+
'',
|
|
381
|
+
].join('\n')
|
|
382
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 버전 태그를 붙이는 CLI — git 과 시계를 담당하는 층.
|
|
4
|
+
*
|
|
5
|
+
* 판정은 전부 `src/version.mjs` 의 순수 함수에 있다. 여기는 git 호출과 출력만 한다.
|
|
6
|
+
*
|
|
7
|
+
* node tools/version.mjs current 지금 버전
|
|
8
|
+
* node tools/version.mjs next 다음 버전만 계산 (아무것도 안 바꾼다)
|
|
9
|
+
* node tools/version.mjs bump [--push] 태그를 만든다
|
|
10
|
+
*
|
|
11
|
+
* --branch <이름> 브랜치를 직접 준다 (CI 에서 detached HEAD 일 때)
|
|
12
|
+
* --dry-run bump 가 무엇을 할지만 보여준다
|
|
13
|
+
*
|
|
14
|
+
* ── 왜 파일이 아니라 태그인가 ────────────────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* `VERSION` 파일이나 `package.json` 의 version 을 올리면 **머지마다 텍스트 충돌**이
|
|
17
|
+
* 난다. dev 두 갈래가 각자 1.0.1 로 올리면 같은 줄이 부딪히고, 사람이 손으로 푸는
|
|
18
|
+
* 순간 어느 쪽 숫자가 맞는지 아무도 모른다.
|
|
19
|
+
*
|
|
20
|
+
* 이 저장소는 이미 같은 문제를 한 번 풀었다 — 장부를 append 로그 하나가 아니라
|
|
21
|
+
* 파일 하나씩으로 쪼갠 이유가 그것이다(SPEC 2절). 태그는 ref 라서 같은 줄을 다투지
|
|
22
|
+
* 않고, git 이 이름 충돌을 CAS 로 막아준다. 이미 있는 태그는 두 번 만들 수 없다.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { spawnSync } from 'node:child_process'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
import { fileURLToPath } from 'node:url'
|
|
28
|
+
import { planBump, latestVersion, formatVersion } from '../src/version.mjs'
|
|
29
|
+
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
31
|
+
|
|
32
|
+
function git(args) {
|
|
33
|
+
const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8', windowsHide: true })
|
|
34
|
+
return { code: r.status ?? 1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function die(msg, code = 1) { console.error(msg); process.exit(code) }
|
|
38
|
+
|
|
39
|
+
const argv = process.argv.slice(2)
|
|
40
|
+
const cmd = argv[0] ?? 'current'
|
|
41
|
+
const flag = (n) => { const i = argv.indexOf(n); return i < 0 ? null : argv[i + 1] }
|
|
42
|
+
const has = (n) => argv.includes(n)
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 지금 브랜치.
|
|
46
|
+
*
|
|
47
|
+
* 🔴 CI 는 보통 detached HEAD 다. `rev-parse --abbrev-ref HEAD` 가 "HEAD" 를 준다.
|
|
48
|
+
* 그 상태에서 브랜치 이름을 추측하면 엉뚱한 자리를 올린다 — GitLab 이 주는
|
|
49
|
+
* `CI_COMMIT_BRANCH` 를 먼저 본다. 둘 다 없으면 **추측하지 않고 멈춘다.**
|
|
50
|
+
*/
|
|
51
|
+
function currentBranch() {
|
|
52
|
+
const given = flag('--branch') ?? process.env.CI_COMMIT_BRANCH ?? process.env.CI_COMMIT_REF_NAME
|
|
53
|
+
if (given) return given
|
|
54
|
+
const b = git(['rev-parse', '--abbrev-ref', 'HEAD']).out
|
|
55
|
+
if (!b || b === 'HEAD') {
|
|
56
|
+
die(
|
|
57
|
+
'지금 어느 브랜치인지 알 수 없습니다 (detached HEAD).\n' +
|
|
58
|
+
' --branch <이름> 으로 직접 주거나, CI 라면 CI_COMMIT_BRANCH 를 씁니다.\n' +
|
|
59
|
+
'추측하지 않는 이유: 단계를 잘못 읽으면 major 를 올려야 할 때 patch 를 올립니다.',
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
return b
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tags() {
|
|
66
|
+
// 정렬은 src/version.mjs 가 다시 하므로 여기서는 목록만 가져온다.
|
|
67
|
+
const r = git(['tag', '--list', 'v*'])
|
|
68
|
+
if (r.code !== 0) die(`git tag 실패\n${r.err}`)
|
|
69
|
+
return r.out.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (cmd === 'current') {
|
|
73
|
+
const v = latestVersion(tags())
|
|
74
|
+
console.log(v ? `v${formatVersion(v)}` : '아직 버전 태그가 없습니다.')
|
|
75
|
+
process.exit(0)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (cmd === 'next' || cmd === 'bump') {
|
|
79
|
+
const branch = currentBranch()
|
|
80
|
+
let plan
|
|
81
|
+
try { plan = planBump(branch, tags()) } catch (e) { die(e.message) }
|
|
82
|
+
|
|
83
|
+
// 🔴 제품이 아닌 파트는 여기서 끝난다. **종료 코드 0 이다** — 고장이 아니라
|
|
84
|
+
// 해당 없음이다. 1 을 주면 CI 가 빨개지고, 그러면 문서를 고칠 때마다
|
|
85
|
+
// 누군가 "왜 빨간가" 를 다시 찾는다.
|
|
86
|
+
if (plan.skip) {
|
|
87
|
+
console.log(`브랜치 ${branch} → 버전 없음`)
|
|
88
|
+
console.log(` ${plan.reason}`)
|
|
89
|
+
process.exit(0)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const label = { main: 'major (한 주치를 main 으로)', func: 'minor (하루치를 func 로)', dev: 'patch (dev 작업)' }[plan.level]
|
|
93
|
+
console.log(`브랜치 ${branch} → ${label}`)
|
|
94
|
+
console.log(` ${plan.from ? 'v' + plan.from : '(없음)'} → ${plan.tag}`)
|
|
95
|
+
|
|
96
|
+
if (cmd === 'next' || has('--dry-run')) process.exit(0)
|
|
97
|
+
|
|
98
|
+
// 🔴 이미 있으면 덮어쓰지 않는다. 같은 번호가 서로 다른 커밋을 가리키는 순간
|
|
99
|
+
// 버전은 아무것도 가리키지 않게 된다. git 이 여기서 CAS 역할을 한다.
|
|
100
|
+
const t = git(['tag', '-a', plan.tag, '-m', `${plan.tag} (${plan.level}: ${branch})`])
|
|
101
|
+
if (t.code !== 0) die(`태그를 만들지 못했습니다\n${t.err}`)
|
|
102
|
+
console.log(` 태그 생성: ${plan.tag}`)
|
|
103
|
+
|
|
104
|
+
if (has('--push')) {
|
|
105
|
+
const remote = git(['config', '--get', 'axmap.remote']).out || 'origin'
|
|
106
|
+
const p = git(['push', remote, plan.tag])
|
|
107
|
+
if (p.code !== 0) die(`태그 push 실패 (${remote})\n${p.err}`)
|
|
108
|
+
console.log(` push 완료: ${remote}`)
|
|
109
|
+
} else {
|
|
110
|
+
console.log(` (원격에 올리려면 --push)`)
|
|
111
|
+
}
|
|
112
|
+
process.exit(0)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
die(`알 수 없는 명령: ${cmd}\n current | next | bump`)
|