@rch4com/agent-setup 1.1.0 → 1.3.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.
Files changed (55) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +197 -55
  3. package/install.mjs +152 -101
  4. package/lib/args.mjs +276 -311
  5. package/lib/bootstrap/adapter.mjs +96 -95
  6. package/lib/bootstrap/apply.mjs +380 -379
  7. package/lib/bootstrap/flow.mjs +73 -70
  8. package/lib/bootstrap/record.mjs +154 -142
  9. package/lib/bootstrap/templates.mjs +148 -148
  10. package/lib/bootstrap/text.mjs +19 -19
  11. package/lib/catalog.mjs +213 -145
  12. package/lib/claude-plugins.mjs +64 -64
  13. package/lib/clis.mjs +102 -102
  14. package/lib/context.mjs +66 -65
  15. package/lib/deps.mjs +5 -7
  16. package/lib/design-md/catalog.json +454 -454
  17. package/lib/design-md/catalog.mjs +266 -268
  18. package/lib/design-md/flow.mjs +181 -166
  19. package/lib/design-md/open.mjs +51 -50
  20. package/lib/design-md/providers/awesome-design-md.mjs +117 -114
  21. package/lib/design-md/providers/index.mjs +9 -9
  22. package/lib/design-md/scan.mjs +14 -10
  23. package/lib/engine.mjs +48 -44
  24. package/lib/gitignore.mjs +18 -18
  25. package/lib/i18n/catalog/en.mjs +369 -0
  26. package/lib/i18n/catalog/ko.mjs +359 -0
  27. package/lib/i18n/detect.mjs +39 -0
  28. package/lib/i18n/index.mjs +93 -0
  29. package/lib/items/mcp.codebase-memory.mjs +6 -6
  30. package/lib/items/mcp.graphify.mjs +8 -0
  31. package/lib/items/mcp.headroom.mjs +8 -0
  32. package/lib/items/mcp.notion.mjs +6 -6
  33. package/lib/items/mcp.supabase.mjs +6 -6
  34. package/lib/items/mcp.vercel.mjs +6 -6
  35. package/lib/items/plugin.bkit.mjs +7 -7
  36. package/lib/items/plugin.ecc.mjs +11 -0
  37. package/lib/items/plugin.impeccable.mjs +14 -0
  38. package/lib/items/plugin.mattpocock-skills.mjs +2 -2
  39. package/lib/items/plugin.ponytail.mjs +104 -0
  40. package/lib/items/plugin.superpowers.mjs +7 -7
  41. package/lib/items/plugin.understand-anything.mjs +10 -0
  42. package/lib/items/skill.caveman.mjs +7 -0
  43. package/lib/items/skill.gsd.mjs +25 -24
  44. package/lib/items/skill.gstack.mjs +40 -39
  45. package/lib/items/skill.karpathy.mjs +7 -0
  46. package/lib/items/skill.taste.mjs +9 -0
  47. package/lib/jsonfile.mjs +40 -40
  48. package/lib/status.mjs +93 -92
  49. package/lib/tomlfile.mjs +60 -60
  50. package/lib/tui/render.mjs +41 -63
  51. package/lib/tui/rows.mjs +278 -206
  52. package/lib/tui/run.mjs +64 -28
  53. package/lib/update.mjs +85 -85
  54. package/lib/width.mjs +57 -0
  55. package/package.json +36 -36
@@ -1,114 +1,117 @@
1
- // awesome-design-md (VoltAgent) 프로바이더.
2
- // 카탈로그: README `## Collection`의 `### 카테고리` + 링크 목록을 파싱한다.
3
- // 링크 URL `getdesign.md/<name>/design-md`의 <name>이 곧 GitHub 폴더명이다.
4
- // 파일: 폴더에는 DESIGN.md만 있다(preview.html 없음). 렌더된 미리보기는 웹페이지.
5
- import { existsSync, readFileSync } from 'node:fs'
6
- import { join, dirname } from 'node:path'
7
- import { fileURLToPath } from 'node:url'
8
-
9
- // lib/design-md/cache/<name>/DESIGN.md — 인스톨러에 동봉된 오프라인 번들.
10
- const BUNDLE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'cache')
11
-
12
- const REPO = 'VoltAgent/awesome-design-md'
13
- const BRANCH = 'main'
14
- const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/design-md`
15
- const README_URL = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/README.md`
16
- const TREE_URL = `https://api.github.com/repos/${REPO}/git/trees/${BRANCH}?recursive=1`
17
- const UNCATEGORIZED = '기타'
18
-
19
- const ENTRY_RE = /^-\s*\[\*\*(.+?)\*\*\]\(\s*https?:\/\/getdesign\.md\/([^/)]+)\/design-md\s*\)\s*-\s*(.*)$/
20
-
21
- // README `## Collection` 블록만 대상으로 카테고리·항목을 뽑는다.
22
- export function parseReadme(md) {
23
- const lines = md.split(/\r?\n/)
24
- const entries = []
25
- let inCollection = false
26
- let category = UNCATEGORIZED
27
- for (const line of lines) {
28
- const h2 = /^##\s+(.*)$/.exec(line)
29
- if (h2) {
30
- inCollection = /^collection\b/i.test(h2[1].trim())
31
- continue
32
- }
33
- if (!inCollection) continue
34
- const h3 = /^###\s+(.*)$/.exec(line)
35
- if (h3) {
36
- category = h3[1].trim()
37
- continue
38
- }
39
- const m = ENTRY_RE.exec(line.trim())
40
- if (m) {
41
- entries.push({ name: m[2].trim(), label: m[1].trim(), category, description: m[3].trim() })
42
- }
43
- }
44
- return entries
45
- }
46
-
47
- // tree API에서 design-md/<name>/ 폴더명 집합을 뽑는다(orphan 보강용).
48
- export function parseTreeNames(treeJson) {
49
- const names = new Set()
50
- for (const e of treeJson?.tree ?? []) {
51
- const m = /^design-md\/([^/]+)\//.exec(e.path)
52
- if (m) names.add(m[1])
53
- }
54
- return names
55
- }
56
-
57
- export const awesomeDesignMd = {
58
- id: 'awesome-design-md',
59
- label: 'awesome-design-md (VoltAgent)',
60
- files: ['DESIGN.md'],
61
-
62
- fileUrl(name, file = 'DESIGN.md') {
63
- return `${RAW_BASE}/${encodeURIComponent(name)}/${file}`
64
- },
65
-
66
- // fileUrl과 같이 인코딩한다 — name은 원격 README에서 오므로, 그대로 이어
67
- // 붙이면 URL이 아닌 문자가 미리보기 대상 문자열에 섞인다.
68
- webUrl(name) {
69
- return `https://getdesign.md/${encodeURIComponent(name)}/design-md`
70
- },
71
-
72
- bundlePath(name, file = 'DESIGN.md') {
73
- return join(BUNDLE_DIR, this.id, name, file)
74
- },
75
-
76
- // 동봉 번들에서 읽는다(없으면 null). 오프라인 설치용.
77
- bundledText(name, file = 'DESIGN.md') {
78
- if (/[\\/]/.test(name)) return null
79
- const p = this.bundlePath(name, file)
80
- return existsSync(p) ? readFileSync(p, 'utf8') : null
81
- },
82
-
83
- async fetchFile(fetchImpl, name, file = 'DESIGN.md') {
84
- const res = await fetchImpl(this.fileUrl(name, file))
85
- if (!res.ok) return null
86
- return res.text()
87
- },
88
-
89
- // README 파싱(주) + tree orphan 보강(선택). 이름순 정렬 반환.
90
- async fetchCatalog(fetchImpl) {
91
- const readmeRes = await fetchImpl(README_URL)
92
- if (!readmeRes.ok) throw new Error(`README 가져오기 실패: HTTP ${readmeRes.status}`)
93
- const entries = parseReadme(await readmeRes.text())
94
- const seen = new Set(entries.map((e) => e.name))
95
-
96
- try {
97
- const treeRes = await fetchImpl(TREE_URL, { headers: { 'User-Agent': 'agent-installer' } })
98
- if (treeRes.ok) {
99
- for (const name of parseTreeNames(await treeRes.json())) {
100
- if (!seen.has(name)) {
101
- entries.push({ name, label: name, category: UNCATEGORIZED, description: '' })
102
- seen.add(name)
103
- }
104
- }
105
- }
106
- } catch {
107
- // tree 실패는 무시 — README만으로도 카탈로그는 유효하다.
108
- }
109
-
110
- return entries.sort((a, b) => a.name.localeCompare(b.name))
111
- },
112
- }
113
-
114
- export default awesomeDesignMd
1
+ // awesome-design-md (VoltAgent) 프로바이더.
2
+ // 카탈로그: README `## Collection`의 `### 카테고리` + 링크 목록을 파싱한다.
3
+ // 링크 URL `getdesign.md/<name>/design-md`의 <name>이 곧 GitHub 폴더명이다.
4
+ // 파일: 폴더에는 DESIGN.md만 있다(preview.html 없음). 렌더된 미리보기는 웹페이지.
5
+ import { existsSync, readFileSync } from 'node:fs'
6
+ import { join, dirname } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { LocalizedError } from '../../i18n/index.mjs'
9
+
10
+ // lib/design-md/cache/<name>/DESIGN.md — 인스톨러에 동봉된 오프라인 번들.
11
+ const BUNDLE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'cache')
12
+
13
+ const REPO = 'VoltAgent/awesome-design-md'
14
+ const BRANCH = 'main'
15
+ const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/design-md`
16
+ const README_URL = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/README.md`
17
+ const TREE_URL = `https://api.github.com/repos/${REPO}/git/trees/${BRANCH}?recursive=1`
18
+ // scan.mjs의 BUNDLE_CATEGORY와 같은 값이어야 한다 — 둘 다 카테고리 못 얻은
19
+ // 항목이 모이는 자리라, 갈라지면 같은 그룹이 헤더 두 개로 쪼개진다.
20
+ export const UNCATEGORIZED = '__other'
21
+
22
+ const ENTRY_RE = /^-\s*\[\*\*(.+?)\*\*\]\(\s*https?:\/\/getdesign\.md\/([^/)]+)\/design-md\s*\)\s*-\s*(.*)$/
23
+
24
+ // README `## Collection` 블록만 대상으로 카테고리·항목을 뽑는다.
25
+ export function parseReadme(md) {
26
+ const lines = md.split(/\r?\n/)
27
+ const entries = []
28
+ let inCollection = false
29
+ let category = UNCATEGORIZED
30
+ for (const line of lines) {
31
+ const h2 = /^##\s+(.*)$/.exec(line)
32
+ if (h2) {
33
+ inCollection = /^collection\b/i.test(h2[1].trim())
34
+ continue
35
+ }
36
+ if (!inCollection) continue
37
+ const h3 = /^###\s+(.*)$/.exec(line)
38
+ if (h3) {
39
+ category = h3[1].trim()
40
+ continue
41
+ }
42
+ const m = ENTRY_RE.exec(line.trim())
43
+ if (m) {
44
+ entries.push({ name: m[2].trim(), label: m[1].trim(), category, description: m[3].trim() })
45
+ }
46
+ }
47
+ return entries
48
+ }
49
+
50
+ // tree API에서 design-md/<name>/ 폴더명 집합을 뽑는다(orphan 보강용).
51
+ export function parseTreeNames(treeJson) {
52
+ const names = new Set()
53
+ for (const e of treeJson?.tree ?? []) {
54
+ const m = /^design-md\/([^/]+)\//.exec(e.path)
55
+ if (m) names.add(m[1])
56
+ }
57
+ return names
58
+ }
59
+
60
+ export const awesomeDesignMd = {
61
+ id: 'awesome-design-md',
62
+ label: 'awesome-design-md (VoltAgent)',
63
+ files: ['DESIGN.md'],
64
+
65
+ fileUrl(name, file = 'DESIGN.md') {
66
+ return `${RAW_BASE}/${encodeURIComponent(name)}/${file}`
67
+ },
68
+
69
+ // fileUrl과 같이 인코딩한다 — name은 원격 README에서 오므로, 그대로 이어
70
+ // 붙이면 URL이 아닌 문자가 미리보기 대상 문자열에 섞인다.
71
+ webUrl(name) {
72
+ return `https://getdesign.md/${encodeURIComponent(name)}/design-md`
73
+ },
74
+
75
+ bundlePath(name, file = 'DESIGN.md') {
76
+ return join(BUNDLE_DIR, this.id, name, file)
77
+ },
78
+
79
+ // 동봉 번들에서 읽는다(없으면 null). 오프라인 설치용.
80
+ bundledText(name, file = 'DESIGN.md') {
81
+ if (/[\\/]/.test(name)) return null
82
+ const p = this.bundlePath(name, file)
83
+ return existsSync(p) ? readFileSync(p, 'utf8') : null
84
+ },
85
+
86
+ async fetchFile(fetchImpl, name, file = 'DESIGN.md') {
87
+ const res = await fetchImpl(this.fileUrl(name, file))
88
+ if (!res.ok) return null
89
+ return res.text()
90
+ },
91
+
92
+ // README 파싱(주) + tree orphan 보강(선택). 이름순 정렬 반환.
93
+ async fetchCatalog(fetchImpl) {
94
+ const readmeRes = await fetchImpl(README_URL)
95
+ if (!readmeRes.ok) throw new LocalizedError('error.readmeFetch', { status: readmeRes.status })
96
+ const entries = parseReadme(await readmeRes.text())
97
+ const seen = new Set(entries.map((e) => e.name))
98
+
99
+ try {
100
+ const treeRes = await fetchImpl(TREE_URL, { headers: { 'User-Agent': 'agent-installer' } })
101
+ if (treeRes.ok) {
102
+ for (const name of parseTreeNames(await treeRes.json())) {
103
+ if (!seen.has(name)) {
104
+ entries.push({ name, label: name, category: UNCATEGORIZED, description: '' })
105
+ seen.add(name)
106
+ }
107
+ }
108
+ }
109
+ } catch {
110
+ // tree 실패는 무시 — README만으로도 카탈로그는 유효하다.
111
+ }
112
+
113
+ return entries.sort((a, b) => a.name.localeCompare(b.name))
114
+ },
115
+ }
116
+
117
+ export default awesomeDesignMd
@@ -1,9 +1,9 @@
1
- // design.md 프로바이더 레지스트리 — 소스 확장 지점.
2
- // 새 소스 추가 = 프로바이더 모듈 1개 작성 + 여기 등록.
3
- import { awesomeDesignMd } from './awesome-design-md.mjs'
4
-
5
- export const PROVIDERS = [awesomeDesignMd]
6
-
7
- export function getProvider(id) {
8
- return PROVIDERS.find((p) => p.id === id)
9
- }
1
+ // design.md 프로바이더 레지스트리 — 소스 확장 지점.
2
+ // 새 소스 추가 = 프로바이더 모듈 1개 작성 + 여기 등록.
3
+ import { awesomeDesignMd } from './awesome-design-md.mjs'
4
+
5
+ export const PROVIDERS = [awesomeDesignMd]
6
+
7
+ export function getProvider(id) {
8
+ return PROVIDERS.find((p) => p.id === id)
9
+ }
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
8
8
  import { StringDecoder } from 'node:string_decoder'
9
9
  import { isSafeSegment } from './catalog.mjs'
10
10
  import { PROVIDERS } from './providers/index.mjs'
11
+ import { createT } from '../i18n/index.mjs'
11
12
 
12
13
  const HERE = dirname(fileURLToPath(import.meta.url))
13
14
  export const BUNDLE_DIR = join(HERE, 'cache')
@@ -25,8 +26,11 @@ const SKIP_DIRS = new Set(['node_modules', '.git', '.svn'])
25
26
  const FM_OPEN = /^---[ \t]*\r?\n/
26
27
  const FM_CLOSE = /\r?\n---[ \t]*(\r?\n|$)/
27
28
 
28
- export const BUNDLE_CATEGORY = '기타' // 번들 캐시에서 카테고리를 못 얻었을 때
29
- export const LOCAL_CATEGORY = '사내' // 외부(사내) 경로에서 카테고리를 못 얻었을 때
29
+ // 카테고리는 정렬 키이자 그룹 헤더다. 표시 문자열을 그대로 쓰면 번역하는
30
+ // 순간 정렬과 그룹 묶기가 어긋나므로 id를 두고 표시할 때만 번역한다.
31
+ // tui/rows.mjs의 CATCH_ALL_CATEGORY와 같은 값이어야 한다.
32
+ export const BUNDLE_CATEGORY = '__other'
33
+ export const LOCAL_CATEGORY = '__local'
30
34
 
31
35
  // 설치 경로 `design-md/<소스>/<이름>/`에 쓰이므로 경로에 위험한 문자만 걷어낸다.
32
36
  // 한글 등 유니코드 이름은 그대로 살린다 — 사내 디렉터리명이 대개 그렇다.
@@ -165,7 +169,7 @@ function subdirs(dir) {
165
169
  // 디렉터리 트리에서 DESIGN.md를 찾아 엔트리로 만든다.
166
170
  // `<루트>/<카테고리…>/<이름>/DESIGN.md` — DESIGN.md가 있는 폴더가 곧 항목(리프)이다.
167
171
  // 루트 바로 아래 DESIGN.md가 있으면 루트 폴더명이 항목 이름이 된다.
168
- export function scanDesignDir(dir, { defaultCategory = LOCAL_CATEGORY, maxDepth = MAX_DEPTH, log } = {}) {
172
+ export function scanDesignDir(dir, { defaultCategory = LOCAL_CATEGORY, maxDepth = MAX_DEPTH, log, t = createT('en') } = {}) {
169
173
  const root = resolve(dir)
170
174
  const entries = []
171
175
  const seen = new Set()
@@ -177,7 +181,7 @@ export function scanDesignDir(dir, { defaultCategory = LOCAL_CATEGORY, maxDepth
177
181
  const prefixed = sanitizeId([...segs.slice(0, -1), name].join('-'))
178
182
  let candidate = prefixed
179
183
  for (let n = 2; seen.has(candidate); n++) candidate = `${prefixed}-${n}`
180
- if (log) log(` design.md 이름이 겹쳐 '${candidate}'로 구분합니다: ${segs.join('/')}`)
184
+ if (log) log(t('design.nameClash', { picked: candidate, path: segs.join('/') }))
181
185
  return candidate
182
186
  }
183
187
 
@@ -251,7 +255,7 @@ export function makeDirProvider({ id, label, dir, entries }) {
251
255
 
252
256
  // 번들 캐시 하위 디렉터리 + 지정한 외부 경로를 소스 목록으로 만든다.
253
257
  // 반환: [{ id, dir, label, bundled, entries, provider }]
254
- export function discoverSources({ bundleDir = BUNDLE_DIR, extraDirs = [], reservedIds, log } = {}) {
258
+ export function discoverSources({ bundleDir = BUNDLE_DIR, extraDirs = [], reservedIds, log, t = createT('en') } = {}) {
255
259
  const sources = []
256
260
  const usedIds = new Set()
257
261
  // 등록된 프로바이더 id는 번들 캐시 전용이다. 외부 경로가 그 id를 차지하면
@@ -259,18 +263,18 @@ export function discoverSources({ bundleDir = BUNDLE_DIR, extraDirs = [], reserv
259
263
  const reserved = new Set(reservedIds ?? PROVIDERS.map((p) => p.id))
260
264
 
261
265
  const add = ({ id, dir, bundled, defaultCategory }) => {
262
- const entries = scanDesignDir(dir, { defaultCategory, log })
266
+ const entries = scanDesignDir(dir, { defaultCategory, log, t })
263
267
  if (entries.length === 0) {
264
- if (!bundled && log) log(` design.md 소스에 DESIGN.md가 없습니다: ${dir}`)
268
+ if (!bundled && log) log(t('design.noDesignFile', { path: dir }))
265
269
  return
266
270
  }
267
271
  const base = sanitizeId(id)
268
272
  const taken = (candidate) => usedIds.has(candidate) || (!bundled && reserved.has(candidate))
269
273
  let uid = base
270
274
  for (let n = 2; taken(uid); n++) uid = `${base}-${n}`
271
- if (uid !== base && log) log(` design.md 소스 id가 겹쳐 '${uid}'로 사용합니다: ${dir}`)
275
+ if (uid !== base && log) log(t('design.sourceIdClash', { picked: uid, path: dir }))
272
276
  usedIds.add(uid)
273
- const label = bundled ? uid : `${uid} (로컬)`
277
+ const label = bundled ? uid : t('design.localSuffix', { id: uid })
274
278
  sources.push({ id: uid, dir, label, bundled, entries, provider: makeDirProvider({ id: uid, label, dir, entries }) })
275
279
  }
276
280
 
@@ -282,7 +286,7 @@ export function discoverSources({ bundleDir = BUNDLE_DIR, extraDirs = [], reserv
282
286
  for (const spec of extraDirs) {
283
287
  const { id, dir } = parseDirSpec(spec)
284
288
  if (!existsSync(dir)) {
285
- if (log) log(` design.md 경로를 찾을 수 없습니다: ${dir}`)
289
+ if (log) log(t('design.pathMissing', { path: dir }))
286
290
  continue
287
291
  }
288
292
  add({ id, dir, bundled: false, defaultCategory: LOCAL_CATEGORY })
package/lib/engine.mjs CHANGED
@@ -1,44 +1,48 @@
1
- import { makeExec } from './catalog.mjs'
2
-
3
- export async function scan(root, items) {
4
- const states = []
5
- for (const item of items) {
6
- try {
7
- const r = await item.detect({ root })
8
- states.push({ item, status: r.status, detail: r.detail })
9
- } catch (err) {
10
- states.push({ item, status: 'absent', detail: `감지 실패: ${err.message}` })
11
- }
12
- }
13
- return states
14
- }
15
-
16
- export function planChanges(states, selectedIds) {
17
- const changes = []
18
- for (const { item, status } of states) {
19
- const selected = selectedIds.has(item.id)
20
- if (selected && status === 'absent') changes.push({ item, action: 'install' })
21
- else if (selected && status === 'partial') changes.push({ item, action: 'complete' })
22
- else if (!selected && status !== 'absent') changes.push({ item, action: 'uninstall' })
23
- }
24
- return changes
25
- }
26
-
27
- export async function apply(root, changes, { dryRun = false, log = console.log } = {}) {
28
- const exec = makeExec(dryRun, log)
29
- const results = []
30
- for (const { item, action } of changes) {
31
- // log까지 넘긴다 — 파일을 직접 쓰는 항목(MCP·design.md)은 exec를 거치지
32
- // 않아 dry-run에서 아무것도 보고하지 못했다. 결과줄은 ✔ 설치라고 찍히는데
33
- // 무엇이 바뀔지는 알 수 없는 상태였다.
34
- const ctx = { root, dryRun, exec, log }
35
- try {
36
- const fn = action === 'uninstall' ? item.uninstall : item.install
37
- const r = await fn.call(item, ctx)
38
- results.push({ item, action, ok: true, message: r?.message })
39
- } catch (err) {
40
- results.push({ item, action, ok: false, message: err.message })
41
- }
42
- }
43
- return results
44
- }
1
+ import { makeExec } from './catalog.mjs'
2
+ import { createT, msg } from './i18n/index.mjs'
3
+
4
+ export async function scan(root, items) {
5
+ const states = []
6
+ for (const item of items) {
7
+ try {
8
+ const r = await item.detect({ root })
9
+ states.push({ item, status: r.status, detail: r.detail })
10
+ } catch (err) {
11
+ states.push({ item, status: 'absent', detail: msg('item.scanFailed', { message: err.message }) })
12
+ }
13
+ }
14
+ return states
15
+ }
16
+
17
+ export function planChanges(states, selectedIds) {
18
+ const changes = []
19
+ for (const { item, status } of states) {
20
+ const selected = selectedIds.has(item.id)
21
+ if (selected && status === 'absent') changes.push({ item, action: 'install' })
22
+ else if (selected && status === 'partial') changes.push({ item, action: 'complete' })
23
+ else if (!selected && status !== 'absent') changes.push({ item, action: 'uninstall' })
24
+ }
25
+ return changes
26
+ }
27
+
28
+ export async function apply(root, changes, { dryRun = false, log = console.log, t = createT('en') } = {}) {
29
+ const exec = makeExec(dryRun, log)
30
+ const results = []
31
+ for (const { item, action } of changes) {
32
+ // log까지 넘긴다 — 파일을 직접 쓰는 항목(MCP·design.md)은 exec를 거치지
33
+ // 않아 dry-run에서 아무것도 보고하지 못했다. 결과줄은 ✔ 설치라고 찍히는데
34
+ // 무엇이 바뀔지는 알 수 없는 상태였다.
35
+ const ctx = { root, dryRun, exec, log, t }
36
+ try {
37
+ const fn = action === 'uninstall' ? item.uninstall : item.install
38
+ const r = await fn.call(item, ctx)
39
+ results.push({ item, action, ok: true, message: r?.message })
40
+ } catch (err) {
41
+ // LocalizedError는 .key를 들고 있다 — 구조체로 옮겨 담아야 표시하는
42
+ // 쪽에서 toText로 활성 로케일에 맞게 다시 렌더할 수 있다. 그냥
43
+ // err.message만 쓰면 LocalizedError.message는 항상 영어라 로케일이 섞인다.
44
+ results.push({ item, action, ok: false, message: err.key ? msg(err.key, err.params) : err.message })
45
+ }
46
+ }
47
+ return results
48
+ }
package/lib/gitignore.mjs CHANGED
@@ -1,18 +1,18 @@
1
- import { readFileSync, writeFileSync, existsSync } from 'node:fs'
2
- import { repoPathStrict } from './context.mjs'
3
-
4
- export function ensureGitignoreEntries(root, entries) {
5
- // 함수의 목적 자체가 쓰기이므로 맨 앞에서 한 번 엄격 검사한다 —
6
- // bootstrap의 ensureIgnore와 같은 규칙. 어휘적 검사만으로는 .gitignore가
7
- // 저장소 밖을 가리키는 링크일 때를 막지 못한다.
8
- const file = repoPathStrict(root, '.gitignore')
9
- const text = existsSync(file) ? readFileSync(file, 'utf8') : ''
10
- const lines = new Set(text.split(/\r?\n/))
11
- const missing = entries.filter((e) => !lines.has(e))
12
- if (missing.length === 0) return
13
- // 파일의 우세 줄바꿈을 따른다 — CRLF 파일에 덧붙인 줄만 LF로 섞이지 않게.
14
- // apply.mjs의 ensureJsonKeys가 JSON에 대해 지키는 규칙과 같다.
15
- const eol = text.includes('\r\n') ? '\r\n' : '\n'
16
- const sep = text.length === 0 || text.endsWith('\n') ? '' : eol
17
- writeFileSync(file, text + sep + missing.join(eol) + eol)
18
- }
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
2
+ import { repoPathStrict } from './context.mjs'
3
+
4
+ export function ensureGitignoreEntries(root, entries) {
5
+ // 함수의 목적 자체가 쓰기이므로 맨 앞에서 한 번 엄격 검사한다 —
6
+ // bootstrap의 ensureIgnore와 같은 규칙. 어휘적 검사만으로는 .gitignore가
7
+ // 저장소 밖을 가리키는 링크일 때를 막지 못한다.
8
+ const file = repoPathStrict(root, '.gitignore')
9
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : ''
10
+ const lines = new Set(text.split(/\r?\n/))
11
+ const missing = entries.filter((e) => !lines.has(e))
12
+ if (missing.length === 0) return
13
+ // 파일의 우세 줄바꿈을 따른다 — CRLF 파일에 덧붙인 줄만 LF로 섞이지 않게.
14
+ // apply.mjs의 ensureJsonKeys가 JSON에 대해 지키는 규칙과 같다.
15
+ const eol = text.includes('\r\n') ? '\r\n' : '\n'
16
+ const sep = text.length === 0 || text.endsWith('\n') ? '' : eol
17
+ writeFileSync(file, text + sep + missing.join(eol) + eol)
18
+ }