@rch4com/agent-setup 1.0.0 → 1.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.
@@ -4,6 +4,9 @@ import { lstatSync, mkdirSync, writeFileSync, readFileSync, appendFileSync } fro
4
4
  import { dirname } from 'node:path'
5
5
  import { repoPath, repoPathStrict } from '../context.mjs'
6
6
  import { ensureGitignoreEntries } from '../gitignore.mjs'
7
+ import { hashBody, normalizeBody } from './text.mjs'
8
+ import { BEGIN_MARKER, END_MARKER, extractBlock, managedKey } from './record.mjs'
9
+ import { configureAdapter } from './adapter.mjs'
7
10
 
8
11
  // existsSync는 깨진 심볼릭 링크에 false를 반환한다. 그대로 쓰면 사용자가 만든
9
12
  // 링크를 덮어쓰게 되므로 lstat으로 "항목이 있는가"를 본다.
@@ -16,12 +19,9 @@ export function pathExists(target) {
16
19
  }
17
20
  }
18
21
 
19
- // 두 OS가 같은 파일을 만들도록 항상 LF + 끝 개행 1개로 정규화한다.
20
- // writeText(새 파일)와 ensureBlocks의 덧붙이기(기존 파일) 양쪽에서 쓴다 —
21
- // 덧붙이기도 이 정규화를 거치지 않으면 CRLF가 LF 파일에 새어 들어간다.
22
- function normalizeBody(text) {
23
- return text.replace(/\r\n/g, '\n').trim() + '\n'
24
- }
22
+ // normalizeBody는 text.mjs가 단일 출처다 — writeText(새 파일)와 ensureBlocks의
23
+ // 덧붙이기(기존 파일)가 쓰는 정규화와 해시 판정이 갈리면, 갓 쓴 파일조차
24
+ // 드리프트로 보고된다.
25
25
 
26
26
  function writeText(file, text) {
27
27
  const body = normalizeBody(text)
@@ -57,8 +57,6 @@ export function ensureFiles(root, files, { dryRun = false, log }) {
57
57
  })
58
58
  }
59
59
 
60
- const BEGIN_MARKER = '<!-- agent-kit:begin -->'
61
-
62
60
  export function ensureBlocks(root, blocks, { dryRun = false, log }) {
63
61
  return blocks.map(({ path: rel, block }) => {
64
62
  // 존재 확인은 어휘적 경로로 한다 — 만들지 않을 것이면 지켜야 할 쓰기도 없다.
@@ -267,3 +265,115 @@ export function ensureJsonKeys(root, entries, { dryRun = false, log }) {
267
265
  return { ok: true, action: 'insert', path: rel }
268
266
  })
269
267
  }
268
+
269
+ // flow.mjs와 update.mjs가 같은 실패 격리를 쓴다 — 어댑터 하나가 실패해도
270
+ // 나머지를 계속해야 한다.
271
+ export function configureAdapterSafe(root, entry, ctx) {
272
+ try {
273
+ return configureAdapter(root, entry, ctx)
274
+ } catch (err) {
275
+ return { ok: false, action: 'link', path: entry.path, message: err.message }
276
+ }
277
+ }
278
+
279
+ // 갱신 경로. 생성 경로(ensureFiles·ensureBlocks)와 분리한 이유는 계약이
280
+ // 반대이기 때문이다 — 생성은 "있으면 손대지 않는다", 갱신은 "우리가 쓴
281
+ // 그대로면 바꾼다". 한 함수에 두 계약을 넣으면 어느 쪽도 읽히지 않는다.
282
+ //
283
+ // managed[key]가 우리가 마지막으로 쓴 내용의 해시다.
284
+ // 현재 == managed → 우리가 쓴 그대로 → 교체
285
+ // 현재 != managed → 사용자가 고쳤다 → 건드리지 않는다(force면 교체)
286
+ // managed 없음/null → 출처 불명 → 건드리지 않는다(force면 교체)
287
+ // 파일 없음 → 생성 (새 도구 지원이 이 경로로 들어온다)
288
+ function decide(current, recorded, force) {
289
+ if (current === null) return 'create'
290
+ if (recorded && hashBody(current) === recorded) return 'update'
291
+ return force ? 'update' : 'drift'
292
+ }
293
+
294
+ function readOrNull(target) {
295
+ try {
296
+ return readFileSync(target, 'utf8')
297
+ } catch {
298
+ return null
299
+ }
300
+ }
301
+
302
+ export function updateFiles(root, files, { managed = {}, dryRun = false, force = false, log }) {
303
+ return files.map(({ path: rel, template }) => {
304
+ const lexical = repoPath(root, rel)
305
+ const exists = pathExists(lexical)
306
+ const current = exists ? readOrNull(lexical) : null
307
+ if (exists && current === null) {
308
+ log(`경고: ${rel}을 읽을 수 없어 건너뜁니다`)
309
+ return { ok: true, action: 'warn', path: rel, message: '읽기 실패' }
310
+ }
311
+
312
+ const wanted = hashBody(template)
313
+ // 이미 새 템플릿과 같으면 쓰지 않는다 — 무의미한 mtime 변경과
314
+ // "갱신 N건" 과대 보고를 막는다.
315
+ if (current !== null && hashBody(current) === wanted) {
316
+ return { ok: true, action: 'skip', path: rel, hash: wanted }
317
+ }
318
+
319
+ const verdict = decide(current, managed[rel], force)
320
+ if (verdict === 'drift') {
321
+ log(`사용자 수정 — 건드리지 않음: ${rel}`)
322
+ return { ok: true, action: 'drift', path: rel }
323
+ }
324
+
325
+ // 검사가 던지면 하지 않은 동작을 보고하지 않도록 로그보다 먼저 수행한다.
326
+ const target = repoPathStrict(root, rel)
327
+ log(verdict === 'create' ? `파일 생성: ${rel}` : `파일 갱신: ${rel}`)
328
+ if (!dryRun) writeText(target, template)
329
+ return { ok: true, action: verdict, path: rel, hash: wanted }
330
+ })
331
+ }
332
+
333
+ export function updateBlocks(root, blocks, { managed = {}, dryRun = false, force = false, log }) {
334
+ return blocks.map(({ path: rel, block }) => {
335
+ const key = managedKey(rel, true)
336
+ const wanted = hashBody(extractBlock(block) ?? block)
337
+
338
+ if (!pathExists(repoPath(root, rel))) {
339
+ const target = repoPathStrict(root, rel)
340
+ log(`파일 생성: ${rel}`)
341
+ if (!dryRun) writeText(target, block)
342
+ return { ok: true, action: 'create', path: rel, hash: wanted }
343
+ }
344
+
345
+ const text = readOrNull(repoPath(root, rel))
346
+ if (text === null) {
347
+ log(`경고: ${rel}을 읽을 수 없어 건너뜁니다`)
348
+ return { ok: true, action: 'warn', path: rel, message: '읽기 실패' }
349
+ }
350
+
351
+ const current = extractBlock(text)
352
+ // 마커가 없는 기존 파일은 갱신 대상이 아니다. 블록을 처음 붙이는 것은
353
+ // 생성 경로(ensureBlocks)의 일이고, 갱신이 append까지 하면 사용자가
354
+ // 일부러 지운 블록을 되살리게 된다.
355
+ if (current === null) {
356
+ log(`관리 블록 없음 — 건드리지 않음: ${rel}`)
357
+ return { ok: true, action: 'drift', path: rel, message: '관리 블록 없음' }
358
+ }
359
+
360
+ if (hashBody(current) === wanted) return { ok: true, action: 'skip', path: rel, hash: wanted }
361
+
362
+ if (hashBody(current) !== managed[key] && !force) {
363
+ log(`블록 사용자 수정 — 건드리지 않음: ${rel}`)
364
+ return { ok: true, action: 'drift', path: rel }
365
+ }
366
+
367
+ const target = repoPathStrict(root, rel)
368
+ log(`관리 블록 갱신: ${rel}`)
369
+ if (!dryRun) {
370
+ // 마커를 포함한 구간을 통째로 새 블록으로 바꾼다. 앞뒤 사용자 본문은
371
+ // 손대지 않으므로 줄바꿈 스타일도 그대로 남는다.
372
+ const begin = text.indexOf(BEGIN_MARKER)
373
+ const end = text.indexOf(END_MARKER, begin) + END_MARKER.length
374
+ const next = text.slice(0, begin) + normalizeBody(block).trimEnd() + text.slice(end)
375
+ writeFileSync(target, next, { encoding: 'utf8' })
376
+ }
377
+ return { ok: true, action: 'update', path: rel, hash: wanted }
378
+ })
379
+ }
@@ -1,13 +1,15 @@
1
1
  // 부트스트랩 진입점 — 순서와 보고만 담당한다.
2
2
  // 무엇을 만들지는 manifest.mjs가, 어떻게 만들지는 apply.mjs·adapter.mjs가 안다.
3
3
  import { MANIFEST } from './manifest.mjs'
4
- import { ensureBlocks, ensureDirs, ensureFiles, ensureIgnore, ensureJsonKeys } from './apply.mjs'
5
- import { configureAdapter } from './adapter.mjs'
4
+ import {
5
+ configureAdapterSafe, ensureBlocks, ensureDirs, ensureFiles, ensureIgnore, ensureJsonKeys,
6
+ } from './apply.mjs'
7
+ import { RECORD_REL, collectManaged, emptyRecord, readRecord, writeRecord } from './record.mjs'
6
8
 
7
9
  const SKILL_MODES = ['auto', 'link', 'copy']
8
10
 
9
11
  export function runBootstrap(root, opts = {}) {
10
- const { dryRun = false, skillMode = 'auto', log = console.log, manifest = MANIFEST } = opts
12
+ const { dryRun = false, skillMode = 'auto', adopt = false, log = console.log, manifest = MANIFEST } = opts
11
13
 
12
14
  if (!SKILL_MODES.includes(skillMode)) {
13
15
  throw new Error(`--skill-mode는 ${SKILL_MODES.join(', ')} 중 하나여야 합니다: ${skillMode}`)
@@ -19,23 +21,36 @@ export function runBootstrap(root, opts = {}) {
19
21
  say(`저장소 루트: ${root}`)
20
22
  say('글로벌 설정 경로는 읽거나 수정하지 않습니다.')
21
23
 
22
- const results = [
24
+ // --adopt는 이미 있는 저장소를 기록 체계로 끌어오는 용도라 파일을 만들지
25
+ // 않는다. 설치기를 복사해 쓰던 저장소가 여기로 들어온다.
26
+ const results = adopt ? [] : [
23
27
  ...ensureDirs(root, manifest.dirs, ctx),
24
28
  ...ensureFiles(root, manifest.files, ctx),
25
29
  ...ensureJsonKeys(root, manifest.settings ?? [], ctx),
26
30
  ...ensureBlocks(root, manifest.blocks, ctx),
27
31
  ]
28
32
 
29
- // 어댑터는 항목별로 실패를 격리한다 — 하나가 실패해도 나머지를 계속한다.
30
- for (const entry of manifest.adapters) {
31
- try {
32
- results.push(configureAdapter(root, entry, { ...ctx, skillMode }))
33
- } catch (err) {
34
- results.push({ ok: false, action: 'link', path: entry.path, message: err.message })
33
+ if (!adopt) {
34
+ // 어댑터는 항목별로 실패를 격리한다 — 하나가 실패해도 나머지를 계속한다.
35
+ // update.mjs도 같은 격리를 써야 하므로 apply.mjs가 단일 출처다.
36
+ for (const entry of manifest.adapters) {
37
+ results.push(configureAdapterSafe(root, entry, { ...ctx, skillMode }))
35
38
  }
39
+
40
+ results.push(...ensureIgnore(root, manifest.ignore, ctx))
36
41
  }
37
42
 
38
- results.push(...ensureIgnore(root, manifest.ignore, ctx))
43
+ // 기존 기록의 items·design은 보존한다 — 부트스트랩은 배선만 다루므로
44
+ // 사용자가 고른 설치 항목을 지울 권한이 없다. dry-run에서는 읽지 않는다:
45
+ // 깨진 기록이 "무엇이 바뀔지 확인"까지 막을 이유가 없다.
46
+ const previous = dryRun ? null : readRecord(root)
47
+ const record = {
48
+ ...emptyRecord({ skillMode }),
49
+ items: previous?.items ?? [],
50
+ design: previous?.design ?? [],
51
+ managed: collectManaged(root, manifest),
52
+ }
53
+ results.push(writeRecord(root, record, ctx))
39
54
 
40
55
  const failed = results.filter((r) => !r.ok)
41
56
  log('')
@@ -49,6 +64,7 @@ export function runBootstrap(root, opts = {}) {
49
64
  say(`적용 도구: ${manifest.tools.join(', ')}`)
50
65
  say('도구별 설정은 모두 현재 저장소 안에만 생성되었습니다.')
51
66
  say('기존 설정 파일은 덮어쓰지 않았습니다.')
67
+ say(`설치 기록: ${RECORD_REL}`)
52
68
 
53
- return { results, failed }
69
+ return { results, failed, record }
54
70
  }
@@ -0,0 +1,142 @@
1
+ // 저장소에 커밋되는 설치 기록.
2
+ //
3
+ // 이 파일은 "의도"다 — 실제 상태의 근거는 여전히 스캔이다. 기록이 더하는
4
+ // 것은 재현성(팀원이 같은 결과를 얻는다)과 버전 고정, 그리고 "우리가 쓴
5
+ // 그대로인가"를 판정할 해시다.
6
+ //
7
+ // 설치기 안의 manifest.mjs(무엇을 생성할지 선언)와 이름이 겹치지 않게
8
+ // 저장소 쪽 파일은 '설치 기록'이라 부른다.
9
+ //
10
+ // 부트스트랩 그래프에 속하므로 node: 내장 모듈만 쓴다.
11
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12
+ import { dirname } from 'node:path'
13
+ import { repoPath, repoPathStrict } from '../context.mjs'
14
+ import { hashBody, normalizeBody } from './text.mjs'
15
+
16
+ export const RECORD_REL = '.agent-kit/agent-setup.json'
17
+ export const FORMAT_VERSION = 1
18
+
19
+ // 발행된 패키지에도 package.json은 항상 들어간다(npm이 무조건 포함한다).
20
+ // createRequire 대신 URL로 읽어 의존성 0을 유지한다.
21
+ let cachedVersion
22
+ export function toolVersion() {
23
+ if (!cachedVersion) {
24
+ const url = new URL('../../package.json', import.meta.url)
25
+ cachedVersion = JSON.parse(readFileSync(url, 'utf8')).version
26
+ }
27
+ return cachedVersion
28
+ }
29
+
30
+ export function emptyRecord({ skillMode = 'auto' } = {}) {
31
+ return {
32
+ formatVersion: FORMAT_VERSION,
33
+ pinnedVersion: toolVersion(),
34
+ skillMode,
35
+ items: [],
36
+ design: [],
37
+ managed: {},
38
+ }
39
+ }
40
+
41
+ // 없으면 null. 있으면 필드를 채워 돌려준다 — 손으로 편집해 필드가 빠져도
42
+ // 죽지 않아야 한다. 형식 버전이 다르면 추측하지 않고 던진다.
43
+ export function readRecord(root) {
44
+ const target = repoPath(root, RECORD_REL)
45
+ let text
46
+ try {
47
+ text = readFileSync(target, 'utf8')
48
+ } catch (err) {
49
+ if (err.code === 'ENOENT') return null
50
+ throw new Error(`${RECORD_REL}을 읽을 수 없습니다 (${err.code ?? err.message})`)
51
+ }
52
+
53
+ let parsed
54
+ try {
55
+ parsed = JSON.parse(text)
56
+ } catch (err) {
57
+ throw new Error(`${RECORD_REL}을 읽을 수 없습니다 — JSON이 아닙니다 (${err.message})`)
58
+ }
59
+
60
+ if (parsed.formatVersion !== FORMAT_VERSION) {
61
+ throw new Error(
62
+ `${RECORD_REL}의 형식 버전이 ${parsed.formatVersion}입니다. ` +
63
+ `이 도구는 ${FORMAT_VERSION}을 씁니다 — 도구를 올리거나 기록을 다시 만드세요.`,
64
+ )
65
+ }
66
+
67
+ return {
68
+ formatVersion: parsed.formatVersion,
69
+ pinnedVersion: parsed.pinnedVersion ?? null,
70
+ skillMode: parsed.skillMode ?? 'auto',
71
+ items: Array.isArray(parsed.items) ? parsed.items : [],
72
+ design: Array.isArray(parsed.design) ? parsed.design : [],
73
+ managed: parsed.managed && typeof parsed.managed === 'object' ? parsed.managed : {},
74
+ }
75
+ }
76
+
77
+ // pinnedVersion은 여기서 실행 중 버전으로 맞춘다 — 기록을 쓰는 명령만
78
+ // 버전을 옮길 수 있어야 고정이 거짓말하지 않는다.
79
+ export function writeRecord(root, record, { dryRun = false, log } = {}) {
80
+ const target = repoPathStrict(root, RECORD_REL)
81
+ const body = `${JSON.stringify({ ...record, pinnedVersion: toolVersion() }, null, 2)}\n`
82
+ log?.(`설치 기록 기록: ${RECORD_REL}`)
83
+ if (!dryRun) {
84
+ mkdirSync(dirname(target), { recursive: true })
85
+ writeFileSync(target, body, { encoding: 'utf8' })
86
+ }
87
+ return { ok: true, action: dryRun ? 'skip' : 'write', path: RECORD_REL }
88
+ }
89
+
90
+ // 마커는 apply.mjs의 ensureBlocks(생성)와 updateBlocks(갱신), 그리고 여기의
91
+ // extractBlock(판정)이 함께 쓴다. 정의가 둘로 갈리면 한쪽만 고치는 회귀가
92
+ // 생기므로 이 모듈이 단일 출처가 되고 apply.mjs가 import한다.
93
+ export const BEGIN_MARKER = '<!-- agent-kit:begin -->'
94
+ export const END_MARKER = '<!-- agent-kit:end -->'
95
+
96
+ // 블록은 파일 전체가 아니라 마커 사이 본문만 관리 대상이다. 파일 전체 해시와
97
+ // 섞이지 않게 키에 접미사를 붙인다.
98
+ export const BLOCK_SUFFIX = '#agent-kit'
99
+
100
+ export function managedKey(rel, isBlock) {
101
+ return isBlock ? `${rel}${BLOCK_SUFFIX}` : rel
102
+ }
103
+
104
+ // 마커 사이 본문을 정규화해 돌려준다. 마커가 없거나 순서가 뒤집혀 있으면 null.
105
+ export function extractBlock(text) {
106
+ const begin = text.indexOf(BEGIN_MARKER)
107
+ if (begin === -1) return null
108
+ const end = text.indexOf(END_MARKER, begin + BEGIN_MARKER.length)
109
+ if (end === -1) return null
110
+ return normalizeBody(text.slice(begin + BEGIN_MARKER.length, end))
111
+ }
112
+
113
+ function readOrNull(root, rel) {
114
+ try {
115
+ return readFileSync(repoPath(root, rel), 'utf8')
116
+ } catch {
117
+ return null
118
+ }
119
+ }
120
+
121
+ // 채택 규칙: 현재 내용이 이 버전의 템플릿과 정규화 후 일치하는 것만 해시를
122
+ // 남긴다. 그러지 않으면 사용자가 이미 고쳐 둔 파일이 "우리가 쓴 그대로"로
123
+ // 위장되어 다음 update에 날아간다. 값이 null인 키는 update가 절대 덮어쓰지
124
+ // 않으며, 없는 파일도 키를 남겨 update의 생성 분기가 집어간다.
125
+ export function collectManaged(root, manifest) {
126
+ const managed = {}
127
+
128
+ for (const { path: rel, template } of manifest.files ?? []) {
129
+ const text = readOrNull(root, rel)
130
+ const wanted = hashBody(template)
131
+ managed[rel] = text !== null && hashBody(text) === wanted ? wanted : null
132
+ }
133
+
134
+ for (const { path: rel, block } of manifest.blocks ?? []) {
135
+ const text = readOrNull(root, rel)
136
+ const wanted = hashBody(extractBlock(block) ?? block)
137
+ const current = text === null ? null : extractBlock(text)
138
+ managed[managedKey(rel, true)] = current !== null && hashBody(current) === wanted ? wanted : null
139
+ }
140
+
141
+ return managed
142
+ }
@@ -0,0 +1,19 @@
1
+ // 정규화와 해시의 단일 출처.
2
+ //
3
+ // 파일을 쓸 때와 "우리가 쓴 그대로인가"를 판정할 때 같은 정규화를 써야 한다.
4
+ // 둘이 갈리면 갓 쓴 파일조차 드리프트로 보고된다. 그래서 이 모듈을 최하위에
5
+ // 두고 apply.mjs(쓰기)와 record.mjs(판정)가 함께 import한다 — 한쪽에만
6
+ // 정규화 규칙을 두면 두 모듈이 서로를 import하는 순환이 생긴다.
7
+ //
8
+ // 부트스트랩 그래프에 속하므로 node: 내장 모듈만 쓴다.
9
+ import { createHash } from 'node:crypto'
10
+
11
+ // 두 OS가 같은 파일을 만들도록 항상 LF + 끝 개행 1개로 정규화한다.
12
+ export function normalizeBody(text) {
13
+ return text.replace(/\r\n/g, '\n').trim() + '\n'
14
+ }
15
+
16
+ // 정규화한 내용의 해시. 워킹트리 줄바꿈이 CRLF든 LF든 같은 값이 나온다.
17
+ export function hashBody(text) {
18
+ return `sha256:${createHash('sha256').update(normalizeBody(text), 'utf8').digest('hex')}`
19
+ }
package/lib/status.mjs ADDED
@@ -0,0 +1,92 @@
1
+ // status — 의도(설치 기록) / 실제(스캔) / 가용(최신 패키지)을 나란히 보여준다.
2
+ //
3
+ // 실제 상태의 근거는 여전히 스캔이다. 기록은 의도일 뿐이므로 둘이 어긋나면
4
+ // 어느 쪽에만 있는지 갈라 보여준다 — 수동 설치·제거를 그대로 잡는다는
5
+ // 기존 강점을 기록이 가리지 않게 한다.
6
+ import { MANIFEST } from './bootstrap/manifest.mjs'
7
+ import { updateBlocks, updateFiles } from './bootstrap/apply.mjs'
8
+ import { readRecord, toolVersion } from './bootstrap/record.mjs'
9
+
10
+ export async function collectStatus(root, { manifest = MANIFEST, items = [], latest } = {}) {
11
+ const record = readRecord(root)
12
+
13
+ // dry-run으로 갱신 판정만 얻는다 — 판정 로직을 두 벌 두면 status와 update가
14
+ // 다른 답을 내는 순간이 온다.
15
+ const silent = () => {}
16
+ const verdicts = record
17
+ ? [
18
+ ...updateFiles(root, manifest.files, { managed: record.managed, dryRun: true, log: silent }),
19
+ ...updateBlocks(root, manifest.blocks, { managed: record.managed, dryRun: true, log: silent }),
20
+ ]
21
+ : []
22
+
23
+ const count = (action) => verdicts.filter((v) => v.action === action).length
24
+
25
+ const states = []
26
+ for (const item of items) {
27
+ try {
28
+ const r = await item.detect({ root })
29
+ states.push({ id: item.id, status: r.status })
30
+ } catch {
31
+ // 감지 실패로 status 전체가 죽으면 진단 도구가 아니다.
32
+ states.push({ id: item.id, status: 'absent' })
33
+ }
34
+ }
35
+ const installed = states.filter((s) => s.status !== 'absent').map((s) => s.id)
36
+ const intended = new Set(record?.items ?? [])
37
+
38
+ return {
39
+ hasRecord: Boolean(record),
40
+ tool: {
41
+ pinned: record?.pinnedVersion ?? null,
42
+ running: toolVersion(),
43
+ latest: latest ?? null,
44
+ },
45
+ files: {
46
+ total: verdicts.length,
47
+ current: count('skip'),
48
+ pending: count('update') + count('create'),
49
+ drift: count('drift'),
50
+ },
51
+ items: {
52
+ installed,
53
+ recordOnly: [...intended].filter((id) => !installed.includes(id)),
54
+ repoOnly: installed.filter((id) => !intended.has(id)),
55
+ },
56
+ }
57
+ }
58
+
59
+ export function formatStatus(report) {
60
+ const lines = []
61
+ const { tool, files, items } = report
62
+
63
+ if (!report.hasRecord) {
64
+ lines.push('설치 기록이 없습니다.')
65
+ lines.push(' 이 저장소를 기록 체계로 끌어오려면 bootstrap --adopt 를 실행하세요.')
66
+ lines.push(' 파일을 만들지 않고, 템플릿과 같은 파일만 관리 대상으로 기록합니다.')
67
+ return lines.join('\n')
68
+ }
69
+
70
+ const version = tool.latest && tool.latest !== tool.running
71
+ ? `${tool.pinned} 고정 · 실행 중 ${tool.running} · 최신 ${tool.latest}`
72
+ : `${tool.pinned} 고정 · 실행 중 ${tool.running}`
73
+ lines.push(`도구 ${version}`)
74
+ if (tool.pinned !== tool.running) lines.push(' → update로 고정 버전을 옮길 수 있습니다')
75
+
76
+ lines.push(`관리 파일 ${files.total}개 중 ${files.current} 최신 · ${files.pending} 갱신 대기 · ${files.drift} 사용자 수정`)
77
+ if (files.pending > 0) lines.push(' → update')
78
+ if (files.drift > 0) lines.push(' → 사용자 수정 파일은 update가 건드리지 않습니다')
79
+
80
+ lines.push(`항목 설치됨 ${items.installed.join(', ') || '(없음)'}`)
81
+ if (items.recordOnly.length) lines.push(` 기록에만 ${items.recordOnly.join(', ')}`)
82
+ if (items.repoOnly.length) lines.push(` 저장소에만 ${items.repoOnly.join(', ')}`)
83
+
84
+ return lines.join('\n')
85
+ }
86
+
87
+ export async function runStatus(root, { json = false, log = console.log } = {}) {
88
+ const { loadItems } = await import('./catalog.mjs')
89
+ const report = await collectStatus(root, { items: await loadItems() })
90
+ log(json ? JSON.stringify(report, null, 2) : formatStatus(report))
91
+ return report
92
+ }
package/lib/update.mjs ADDED
@@ -0,0 +1,85 @@
1
+ // update 흐름 — 관리 파일을 최신 템플릿으로 옮기고 드리프트를 보고한다.
2
+ //
3
+ // 갱신 판정은 apply.mjs의 updateFiles·updateBlocks가 하고, 여기는 순서와
4
+ // 보고, 그리고 기록 갱신만 담당한다. flow.mjs가 생성 쪽에서 하는 역할과 같다.
5
+ import { execFileSync } from 'node:child_process'
6
+ import { MANIFEST } from './bootstrap/manifest.mjs'
7
+ import {
8
+ configureAdapterSafe, ensureIgnore, ensureJsonKeys, updateBlocks, updateFiles,
9
+ } from './bootstrap/apply.mjs'
10
+ import { RECORD_REL, managedKey, readRecord, toolVersion, writeRecord } from './bootstrap/record.mjs'
11
+
12
+ // git이 유일한 되돌리기 수단이다. 커밋되지 않은 변경 위에 덮어쓰면 사용자가
13
+ // 잃은 것을 복구할 방법이 없다.
14
+ function assertCleanWorktree(root) {
15
+ const out = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' })
16
+ if (out.trim()) {
17
+ throw new Error(
18
+ '--force는 워킹트리가 깨끗할 때만 쓸 수 있습니다. ' +
19
+ 'git이 유일한 되돌리기 수단이라 커밋되지 않은 변경 위에 덮어쓰면 복구할 수 없습니다.',
20
+ )
21
+ }
22
+ }
23
+
24
+ export async function runUpdate(root, opts = {}) {
25
+ const { dryRun = false, force = false, log = console.log, manifest = MANIFEST } = opts
26
+ const say = (message) => log(`[agent-setup] ${message}`)
27
+
28
+ const record = readRecord(root)
29
+ if (!record) {
30
+ throw new Error(
31
+ `${RECORD_REL}이 없습니다. 이 저장소를 기록 체계로 끌어오려면 먼저 ` +
32
+ '`bootstrap --adopt`를 실행하세요 — 파일을 만들지 않고 기록만 만듭니다.',
33
+ )
34
+ }
35
+
36
+ if (force) assertCleanWorktree(root)
37
+
38
+ // 2단계에서는 버전 차이를 보고만 한다. 여기서 중단시키면 기존 사용자의
39
+ // 실행이 오류로 죽어 파괴적 변경이 된다. 엄격 검사는 apply와 함께 3단계다.
40
+ const running = toolVersion()
41
+ if (record.pinnedVersion && record.pinnedVersion !== running) {
42
+ say(`고정 ${record.pinnedVersion} → 실행 중 ${running}`)
43
+ }
44
+
45
+ const ctx = { dryRun, force, log: say, managed: record.managed }
46
+ const results = [
47
+ ...updateFiles(root, manifest.files, ctx),
48
+ ...updateBlocks(root, manifest.blocks, ctx),
49
+ // 키 보장과 gitignore 항목 추가는 원래 멱등이라 그대로 재실행한다.
50
+ ...ensureJsonKeys(root, manifest.settings ?? [], { dryRun, log: say }),
51
+ ...ensureIgnore(root, manifest.ignore, { dryRun, log: say }),
52
+ ]
53
+
54
+ // 어댑터는 링크가 끊겼을 수 있어 재검증한다. 항목별로 실패를 격리한다.
55
+ for (const entry of manifest.adapters) {
56
+ results.push(configureAdapterSafe(root, entry, {
57
+ dryRun, log: say, skillMode: record.skillMode,
58
+ }))
59
+ }
60
+
61
+ // 성공한 것만 기록에 옮긴다. 드리프트는 hash를 담지 않으므로 옛 해시가
62
+ // 남아, 사용자가 원복하면 다음 update가 다시 집어간다.
63
+ const managed = { ...record.managed }
64
+ for (const r of results) {
65
+ if (!r.hash) continue
66
+ const isBlock = manifest.blocks.some((b) => b.path === r.path)
67
+ managed[managedKey(r.path, isBlock)] = r.hash
68
+ }
69
+
70
+ const drift = results.filter((r) => r.action === 'drift')
71
+ const updated = results.filter((r) => r.action === 'update')
72
+ const created = results.filter((r) => r.action === 'create')
73
+
74
+ if (!dryRun) writeRecord(root, { ...record, managed }, { dryRun, log: say })
75
+
76
+ log('')
77
+ say(`갱신 ${updated.length}건 · 신규 ${created.length}건 · 드리프트 ${drift.length}건`)
78
+ if (drift.length > 0) {
79
+ say('드리프트 (건드리지 않았습니다)')
80
+ for (const d of drift) say(` ${d.path}${d.message ? ` — ${d.message}` : ''}`)
81
+ say('최신 템플릿을 반영하려면 update --force (워킹트리가 깨끗해야 합니다)')
82
+ }
83
+
84
+ return { results, drift, record: { ...record, managed } }
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rch4com/agent-setup",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "여러 코딩 에이전트 CLI를 한 저장소에서 함께 쓰기 위한 저장소 범위 부트스트랩과 선택 항목 설치기",
5
5
  "keywords": [
6
6
  "agents-md",
@@ -1,14 +0,0 @@
1
- {
2
- "version": 1,
3
- "entries": {
4
- "79a93d4a2f8f50b95f852280616242fee1855dc99a3c75211917f55e72e95fae": {
5
- "last_emitted_at_ms": 1785327065269,
6
- "message": "Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests)."
7
- },
8
- "466399dafa2d20f60587180bad0a07358eb7f2bce724df0ff682f038ad33f5ad": {
9
- "last_emitted_at_ms": 1785327034117,
10
- "message": "Read multiple files in parallel when possible for faster analysis."
11
- }
12
- },
13
- "updated_at": "2026-07-29T12:11:05.269Z"
14
- }