@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,166 +1,181 @@
1
- import { scan, planChanges, apply } from '../engine.mjs'
2
- import { loadCatalog, saveCatalog, buildItems, allEntries, sha256, resolveTokens, netFetch, CATALOG_PATH } from './catalog.mjs'
3
- import { PROVIDERS } from './providers/index.mjs'
4
- import { discoverSources, extraDirsFromEnv } from './scan.mjs'
5
- import { makeOpener, openPreview } from './open.mjs'
6
-
7
- const STATUS_LABEL = { installed: '설치됨', partial: '일부', absent: '미설치' }
8
-
9
- function report(results, log) {
10
- const ACTION = { install: '설치', complete: '보완', uninstall: '제거' }
11
- for (const r of results) {
12
- log(` ${r.ok ? '✔' : '✖'} ${ACTION[r.action]} ${r.item.label}${r.message ? ` — ${r.message}` : ''}`)
13
- }
14
- }
15
-
16
- // ── 비대화형/공용 코어 ─────────────────────────────────────────────
17
-
18
- function printList(states, log) {
19
- const byProvider = new Map()
20
- for (const s of states) {
21
- if (!byProvider.has(s.item.providerId)) byProvider.set(s.item.providerId, [])
22
- byProvider.get(s.item.providerId).push(s)
23
- }
24
- for (const [pid, group] of byProvider) {
25
- log(`[${pid}${group[0].item.local ? ' · 로컬' : ''}]`)
26
- for (const s of group) {
27
- log(` ${STATUS_LABEL[s.status].padEnd(4)} ${s.item.name} — ${s.item.label} [${s.item.designCategory}]`)
28
- }
29
- }
30
- }
31
-
32
- // 미리보기는 관대하게 해석한다: 알 수 없거나 중복된 토큰은 예외 없이 안내하고 건너뛴다.
33
- function openPreviews(items, tokensStr, opener, log) {
34
- for (const tok of tokensStr.split(',').map((s) => s.trim()).filter(Boolean)) {
35
- const slash = tok.indexOf('/')
36
- const matches = slash >= 0
37
- ? items.filter((i) => i.providerId === tok.slice(0, slash) && i.name === tok.slice(slash + 1))
38
- : items.filter((i) => i.name === tok)
39
- if (matches.length === 0) { log(` 알 수 없는 항목: ${tok}`); continue }
40
- if (matches.length > 1) {
41
- log(` 중복된 이름 '${tok}' — 제공자를 지정하세요: ${matches.map((m) => `${m.providerId}/${m.name}`).join(', ')}`)
42
- continue
43
- }
44
- openPreview(opener, matches[0], log)
45
- }
46
- }
47
-
48
- // 보이는 집합(visibleStates) 안에서만 diff/적용한다.
49
- async function applyVisible(root, visibleStates, selectedIds, { dryRun, log }) {
50
- const changes = planChanges(visibleStates, selectedIds)
51
- if (changes.length === 0) { log('변경할 항목이 없습니다.'); return [] }
52
- const results = await apply(root, changes, { dryRun, log })
53
- report(results, log)
54
- return results
55
- }
56
-
57
- async function installedItems(root, items) {
58
- const out = []
59
- for (const item of items) {
60
- if ((await item.detect({ root })).status === 'installed') out.push(item)
61
- }
62
- return out
63
- }
64
-
65
- export async function refreshCatalog({ dryRun, fetchImpl, log, catalogFile = CATALOG_PATH }) {
66
- const next = { updatedAt: new Date().toISOString(), providers: {} }
67
- for (const provider of PROVIDERS) {
68
- try {
69
- const entries = await provider.fetchCatalog(fetchImpl)
70
- next.providers[provider.id] = { label: provider.label, entries }
71
- log(` ${provider.label}: ${entries.length}개`)
72
- } catch (err) {
73
- log(` ${provider.label}: 실패 — ${err.message}`)
74
- }
75
- }
76
- const total = allEntries(next).length
77
- if (total === 0) { log('가져온 항목이 없어 카탈로그를 갱신하지 않습니다.'); return { total: 0 } }
78
- if (!dryRun) saveCatalog(next, catalogFile)
79
- log(dryRun ? ` [dry-run] ${total}개로 갱신 예정` : `카탈로그 갱신됨: ${total}개`)
80
- return { total }
81
- }
82
-
83
- export async function updateInstalled(root, items, { dryRun, log }) {
84
- const installed = await installedItems(root, items)
85
- if (installed.length === 0) { log('설치된 design.md가 없습니다.'); return }
86
- for (const item of installed) {
87
- try {
88
- await item.install({ root, dryRun, fresh: true })
89
- log(` ✔ 업데이트 ${item.label}`)
90
- } catch (err) {
91
- log(` ✖ 업데이트 ${item.label} — ${err.message}`)
92
- }
93
- }
94
- }
95
-
96
- export async function findStale(root, items, { log }) {
97
- const installed = await installedItems(root, items)
98
- const stale = []
99
- for (const item of installed) {
100
- const local = item.localText({ root })
101
- if (local == null) continue
102
- let remote
103
- try { remote = await item.remoteText() } catch { remote = null }
104
- if (remote == null) { log(` ? ${item.label}: 원본 확인 실패`); continue }
105
- if (sha256(local) !== sha256(remote)) stale.push(item)
106
- }
107
- return stale
108
- }
109
-
110
- // ── 진입점 ────────────────────────────────────────────────────────
111
-
112
- export async function runDesign(root, opts = {}) {
113
- const {
114
- dryRun = false,
115
- list = false,
116
- set = null,
117
- sync = null,
118
- preview = null,
119
- fetchImpl = netFetch,
120
- log = console.log,
121
- designDirs = [],
122
- env = process.env,
123
- catalogFile = CATALOG_PATH,
124
- } = opts
125
- const opener = opts.opener ?? makeOpener(dryRun, log)
126
-
127
- const catalog = loadCatalog(catalogFile)
128
- // 디렉터리 스캔이 먼저다 — 프로바이더 등록 없이도 사내 정의가 목록에 오른다.
129
- const sources = opts.sources ?? discoverSources({
130
- ...(opts.bundleDir ? { bundleDir: opts.bundleDir } : {}),
131
- extraDirs: [...extraDirsFromEnv(env), ...designDirs],
132
- log,
133
- })
134
- const items = buildItems(catalog, { fetchImpl, sources })
135
-
136
- if (list) {
137
- printList(await scan(root, items), log)
138
- return
139
- }
140
- if (preview != null) {
141
- openPreviews(items, preview, opener, log)
142
- return
143
- }
144
- if (sync != null) {
145
- await runSync(root, items, { op: sync, dryRun, fetchImpl, log, catalogFile })
146
- return
147
- }
148
- if (set != null) {
149
- const selected = new Set(resolveTokens(items, set).map((i) => i.id))
150
- await applyVisible(root, await scan(root, items), selected, { dryRun, log })
151
- return
152
- }
153
- printList(await scan(root, items), log)
154
- }
155
-
156
- async function runSync(root, items, { op: chosen, dryRun, fetchImpl, log, catalogFile }) {
157
- if (chosen === 'catalog') { await refreshCatalog({ dryRun, fetchImpl, log, catalogFile }); return }
158
- if (chosen === 'installed') { await updateInstalled(root, items, { dryRun, log }); return }
159
- if (chosen === 'stale') {
160
- const stale = await findStale(root, items, { log })
161
- if (stale.length === 0) { log('모든 설치본이 최신입니다.'); return }
162
- log(`오래된 항목 ${stale.length}개: ${stale.map((i) => i.name).join(', ')}`)
163
- return
164
- }
165
- log(`알 수 없는 동기화 작업: ${chosen}`)
166
- }
1
+ import { scan, planChanges, apply } from '../engine.mjs'
2
+ import { loadCatalog, saveCatalog, buildItems, allEntries, sha256, resolveTokens, netFetch, CATALOG_PATH } from './catalog.mjs'
3
+ import { PROVIDERS } from './providers/index.mjs'
4
+ import { discoverSources, extraDirsFromEnv } from './scan.mjs'
5
+ import { makeOpener, openPreview } from './open.mjs'
6
+ import { createT, toText } from '../i18n/index.mjs'
7
+ import { labelWidth, pad } from '../width.mjs'
8
+
9
+ // design 항목의 detect는 installed/absent만 돌려준다(catalog.mjs) — partial은
10
+ // 열을 넓히기만 하고 실제로 나오지 않는다.
11
+ const DESIGN_STATUS_KEYS = ['status.installed', 'status.absent']
12
+
13
+ // 카탈로그의 카테고리는 공급자가 준 영어 데이터라 번역하지 않는다.
14
+ // 우리가 만든 catch-all(__other·__local)만 번역 대상이다.
15
+ export function categoryLabel(t, id) {
16
+ return id.startsWith('__') ? t(`category.${id.slice(2)}`) : id
17
+ }
18
+
19
+ function report(results, log, t) {
20
+ for (const r of results) {
21
+ const message = toText(t, r.message)
22
+ log(` ${r.ok ? '✔' : '✖'} ${t(`change.${r.action}`)} ${r.item.label}${message ? ` — ${message}` : ''}`)
23
+ }
24
+ }
25
+
26
+ // ── 비대화형/공용 코어 ─────────────────────────────────────────────
27
+
28
+ function printList(states, log, t) {
29
+ const byProvider = new Map()
30
+ for (const s of states) {
31
+ if (!byProvider.has(s.item.providerId)) byProvider.set(s.item.providerId, [])
32
+ byProvider.get(s.item.providerId).push(s)
33
+ }
34
+ // 폭은 그 로케일의 실제 라벨에서 뽑는다. padEnd는 코드 유닛을 세어 한글이
35
+ // 밀리고, 상수로 박으면 영어('Not installed' 13칸)가 넘쳐 이름 열이 어긋난다.
36
+ const statusWidth = labelWidth(t, DESIGN_STATUS_KEYS)
37
+ for (const [pid, group] of byProvider) {
38
+ log(`[${pid}${group[0].item.local ? ` · ${t('design.localTag')}` : ''}]`)
39
+ for (const s of group) {
40
+ log(` ${pad(t(`status.${s.status}`), statusWidth)} ${s.item.name} — ${s.item.label} [${categoryLabel(t, s.item.designCategory)}]`)
41
+ }
42
+ }
43
+ }
44
+
45
+ // 미리보기는 관대하게 해석한다: 알 수 없거나 중복된 토큰은 예외 없이 안내하고 건너뛴다.
46
+ function openPreviews(items, tokensStr, opener, log, t) {
47
+ for (const tok of tokensStr.split(',').map((s) => s.trim()).filter(Boolean)) {
48
+ const slash = tok.indexOf('/')
49
+ const matches = slash >= 0
50
+ ? items.filter((i) => i.providerId === tok.slice(0, slash) && i.name === tok.slice(slash + 1))
51
+ : items.filter((i) => i.name === tok)
52
+ if (matches.length === 0) { log(t('design.unknownItem', { token: tok })); continue }
53
+ if (matches.length > 1) {
54
+ log(t('design.ambiguous', { token: tok, options: matches.map((m) => `${m.providerId}/${m.name}`).join(', ') }))
55
+ continue
56
+ }
57
+ openPreview(opener, matches[0], log, t)
58
+ }
59
+ }
60
+
61
+ // 보이는 집합(visibleStates) 안에서만 diff/적용한다.
62
+ async function applyVisible(root, visibleStates, selectedIds, { dryRun, log, t }) {
63
+ const changes = planChanges(visibleStates, selectedIds)
64
+ if (changes.length === 0) { log(t('apply.noChanges')); return [] }
65
+ const results = await apply(root, changes, { dryRun, log, t })
66
+ report(results, log, t)
67
+ return results
68
+ }
69
+
70
+ async function installedItems(root, items) {
71
+ const out = []
72
+ for (const item of items) {
73
+ if ((await item.detect({ root })).status === 'installed') out.push(item)
74
+ }
75
+ return out
76
+ }
77
+
78
+ export async function refreshCatalog({ dryRun, fetchImpl, log, catalogFile = CATALOG_PATH, t = createT('en') }) {
79
+ const next = { updatedAt: new Date().toISOString(), providers: {} }
80
+ for (const provider of PROVIDERS) {
81
+ try {
82
+ const entries = await provider.fetchCatalog(fetchImpl)
83
+ next.providers[provider.id] = { label: provider.label, entries }
84
+ log(t('design.provider.count', { label: provider.label, count: entries.length }))
85
+ } catch (err) {
86
+ log(t('design.provider.failed', { label: provider.label, message: err.message }))
87
+ }
88
+ }
89
+ const total = allEntries(next).length
90
+ if (total === 0) { log(t('design.catalog.empty')); return { total: 0 } }
91
+ if (!dryRun) saveCatalog(next, catalogFile)
92
+ log(dryRun ? t('design.catalog.planned', { total }) : t('design.catalog.updated', { total }))
93
+ return { total }
94
+ }
95
+
96
+ export async function updateInstalled(root, items, { dryRun, log, t = createT('en') }) {
97
+ const installed = await installedItems(root, items)
98
+ if (installed.length === 0) { log(t('design.none')); return }
99
+ for (const item of installed) {
100
+ try {
101
+ await item.install({ root, dryRun, fresh: true })
102
+ log(t('design.updated', { label: item.label }))
103
+ } catch (err) {
104
+ log(t('design.updateFailed', { label: item.label, message: err.message }))
105
+ }
106
+ }
107
+ }
108
+
109
+ export async function findStale(root, items, { log, t = createT('en') }) {
110
+ const installed = await installedItems(root, items)
111
+ const stale = []
112
+ for (const item of installed) {
113
+ const local = item.localText({ root })
114
+ if (local == null) continue
115
+ let remote
116
+ try { remote = await item.remoteText() } catch { remote = null }
117
+ if (remote == null) { log(t('design.sourceCheckFailed', { label: item.label })); continue }
118
+ if (sha256(local) !== sha256(remote)) stale.push(item)
119
+ }
120
+ return stale
121
+ }
122
+
123
+ // ── 진입점 ────────────────────────────────────────────────────────
124
+
125
+ export async function runDesign(root, opts = {}) {
126
+ const {
127
+ dryRun = false,
128
+ list = false,
129
+ set = null,
130
+ sync = null,
131
+ preview = null,
132
+ fetchImpl = netFetch,
133
+ log = console.log,
134
+ designDirs = [],
135
+ env = process.env,
136
+ catalogFile = CATALOG_PATH,
137
+ t = createT('en'),
138
+ } = opts
139
+ const opener = opts.opener ?? makeOpener(dryRun, log)
140
+
141
+ const catalog = loadCatalog(catalogFile)
142
+ // 디렉터리 스캔이 먼저다 — 프로바이더 등록 없이도 사내 정의가 목록에 오른다.
143
+ const sources = opts.sources ?? discoverSources({
144
+ ...(opts.bundleDir ? { bundleDir: opts.bundleDir } : {}),
145
+ extraDirs: [...extraDirsFromEnv(env), ...designDirs],
146
+ log,
147
+ t,
148
+ })
149
+ const items = buildItems(catalog, { fetchImpl, sources })
150
+
151
+ if (list) {
152
+ printList(await scan(root, items), log, t)
153
+ return
154
+ }
155
+ if (preview != null) {
156
+ openPreviews(items, preview, opener, log, t)
157
+ return
158
+ }
159
+ if (sync != null) {
160
+ await runSync(root, items, { op: sync, dryRun, fetchImpl, log, catalogFile, t })
161
+ return
162
+ }
163
+ if (set != null) {
164
+ const selected = new Set(resolveTokens(items, set).map((i) => i.id))
165
+ await applyVisible(root, await scan(root, items), selected, { dryRun, log, t })
166
+ return
167
+ }
168
+ printList(await scan(root, items), log, t)
169
+ }
170
+
171
+ async function runSync(root, items, { op: chosen, dryRun, fetchImpl, log, catalogFile, t }) {
172
+ if (chosen === 'catalog') { await refreshCatalog({ dryRun, fetchImpl, log, catalogFile, t }); return }
173
+ if (chosen === 'installed') { await updateInstalled(root, items, { dryRun, log, t }); return }
174
+ if (chosen === 'stale') {
175
+ const stale = await findStale(root, items, { log, t })
176
+ if (stale.length === 0) { log(t('design.allCurrent')); return }
177
+ log(t('design.staleList', { count: stale.length, names: stale.map((i) => i.name).join(', ') }))
178
+ return
179
+ }
180
+ log(t('design.unknownSync', { action: chosen }))
181
+ }
@@ -1,50 +1,51 @@
1
- import { execFileSync } from 'node:child_process'
2
- import { existsSync } from 'node:fs'
3
-
4
- // 미리보기 대상은 신뢰 경계 밖이다 — 이름이 원격 README 파싱이나 디렉터리
5
- // 스캔에서 온다. http(s) URL이거나 실제로 존재하는 로컬 경로일 때만 OS에
6
- // 넘긴다. `javascript:`처럼 스킴만 바꾼 대상이 기본 앱으로 열리지 않게 한다.
7
- export function isOpenableTarget(target) {
8
- const text = String(target ?? '')
9
- // eslint-disable-next-line no-control-regex
10
- if (!text || /[\x00-\x1f\x7f]/.test(text)) return false
11
- if (/^https?:\/\//i.test(text)) return true
12
- return existsSync(text)
13
- }
14
-
15
- // OS 기본 브라우저/앱으로 target(URL 또는 파일)을 연다. 주입 가능(테스트 대체).
16
- export function makeOpener(dryRun, log = console.log) {
17
- return (target) => {
18
- if (dryRun) {
19
- log(` [dry-run] open ${target}`)
20
- return { ok: true }
21
- }
22
- if (!isOpenableTarget(target)) return { ok: false, output: '열 수 있는 대상이 아닙니다' }
23
- // Windows에서 `cmd /c start`를 쓰지 않는다 — Node는 셸 없이 cmd를 부를 때
24
- // 공백 없는 인자를 quote하지 않고, cmd는 `&`·`|`를 명령 구분자로 읽는다.
25
- // 그러면 이름에 섞인 `&`가 곧바로 명령 실행이 된다. rundll32는 셸을 거치지
26
- // 않아 인자가 그대로 전달되고, URL과 로컬 파일을 모두 기본 앱으로 연다.
27
- const [cmd, args] =
28
- process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', target]] :
29
- process.platform === 'darwin' ? ['open', [target]] :
30
- ['xdg-open', [target]]
31
- try {
32
- execFileSync(cmd, args, { stdio: 'ignore' })
33
- return { ok: true }
34
- } catch (err) {
35
- return { ok: false, output: String(err.message) }
36
- }
37
- }
38
- }
39
-
40
- // 항목의 미리보기를 연다: 웹 URL이 있으면 그 페이지, 로컬 항목은 원본 파일을 기본 앱으로.
41
- export function openPreview(opener, item, log = console.log) {
42
- const target = item.webUrl ?? item.previewPath ?? null
43
- if (!target) {
44
- log(` ${item.label}: 미리보기 URL이 없습니다.`)
45
- return { ok: false }
46
- }
47
- const r = opener(target)
48
- if (!r.ok) log(` ${item.label}: 열기 실패 — 직접 여세요: ${target}`)
49
- return r
50
- }
1
+ import { execFileSync } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { createT, msg } from '../i18n/index.mjs'
4
+
5
+ // 미리보기 대상은 신뢰 경계 밖이다 — 이름이 원격 README 파싱이나 디렉터리
6
+ // 스캔에서 온다. http(s) URL이거나 실제로 존재하는 로컬 경로일 때만 OS에
7
+ // 넘긴다. `javascript:`처럼 스킴만 바꾼 대상이 기본 앱으로 열리지 않게 한다.
8
+ export function isOpenableTarget(target) {
9
+ const text = String(target ?? '')
10
+ // eslint-disable-next-line no-control-regex
11
+ if (!text || /[\x00-\x1f\x7f]/.test(text)) return false
12
+ if (/^https?:\/\//i.test(text)) return true
13
+ return existsSync(text)
14
+ }
15
+
16
+ // OS 기본 브라우저/앱으로 target(URL 또는 파일)을 연다. 주입 가능(테스트 대체).
17
+ export function makeOpener(dryRun, log = console.log) {
18
+ return (target) => {
19
+ if (dryRun) {
20
+ log(` [dry-run] open ${target}`)
21
+ return { ok: true }
22
+ }
23
+ if (!isOpenableTarget(target)) return { ok: false, output: msg('design.notOpenable') }
24
+ // Windows에서 `cmd /c start`를 쓰지 않는다 — Node는 셸 없이 cmd를 부를 때
25
+ // 공백 없는 인자를 quote하지 않고, cmd는 `&`·`|`를 명령 구분자로 읽는다.
26
+ // 그러면 이름에 섞인 `&`가 곧바로 명령 실행이 된다. rundll32는 셸을 거치지
27
+ // 않아 인자가 그대로 전달되고, URL과 로컬 파일을 모두 기본 앱으로 연다.
28
+ const [cmd, args] =
29
+ process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', target]] :
30
+ process.platform === 'darwin' ? ['open', [target]] :
31
+ ['xdg-open', [target]]
32
+ try {
33
+ execFileSync(cmd, args, { stdio: 'ignore' })
34
+ return { ok: true }
35
+ } catch (err) {
36
+ return { ok: false, output: String(err.message) }
37
+ }
38
+ }
39
+ }
40
+
41
+ // 항목의 미리보기를 연다: 웹 URL이 있으면 그 페이지, 로컬 항목은 원본 파일을 기본 앱으로.
42
+ export function openPreview(opener, item, log = console.log, t = createT('en')) {
43
+ const target = item.webUrl ?? item.previewPath ?? null
44
+ if (!target) {
45
+ log(t('design.noPreviewUrl', { label: item.label }))
46
+ return { ok: false }
47
+ }
48
+ const r = opener(target)
49
+ if (!r.ok) log(t('design.openFailed', { label: item.label, target }))
50
+ return r
51
+ }