@rch4com/agent-setup 1.10.0 → 1.11.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.
Files changed (53) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +199 -199
  3. package/install.mjs +163 -163
  4. package/lib/args.mjs +276 -276
  5. package/lib/bootstrap/adapter.mjs +96 -96
  6. package/lib/bootstrap/apply.mjs +380 -380
  7. package/lib/bootstrap/flow.mjs +140 -140
  8. package/lib/bootstrap/record.mjs +154 -154
  9. package/lib/bootstrap/templates.mjs +148 -148
  10. package/lib/bootstrap/text.mjs +19 -19
  11. package/lib/catalog.mjs +234 -234
  12. package/lib/claude-plugins.mjs +64 -64
  13. package/lib/clis.mjs +111 -111
  14. package/lib/context.mjs +66 -66
  15. package/lib/deps.mjs +17 -17
  16. package/lib/design-md/catalog.json +454 -454
  17. package/lib/design-md/catalog.mjs +266 -266
  18. package/lib/design-md/flow.mjs +192 -192
  19. package/lib/design-md/open.mjs +51 -51
  20. package/lib/design-md/providers/awesome-design-md.mjs +124 -124
  21. package/lib/design-md/providers/index.mjs +9 -9
  22. package/lib/design-md/scan.mjs +295 -295
  23. package/lib/engine.mjs +115 -115
  24. package/lib/gitignore.mjs +18 -18
  25. package/lib/i18n/catalog/en.mjs +431 -426
  26. package/lib/i18n/catalog/ko.mjs +416 -411
  27. package/lib/i18n/detect.mjs +39 -39
  28. package/lib/i18n/index.mjs +93 -93
  29. package/lib/items/mcp.codebase-memory.mjs +6 -6
  30. package/lib/items/mcp.notion.mjs +6 -6
  31. package/lib/items/mcp.supabase.mjs +6 -6
  32. package/lib/items/mcp.vercel.mjs +6 -6
  33. package/lib/items/plugin.bkit.mjs +5 -3
  34. package/lib/items/plugin.ecc.mjs +17 -2
  35. package/lib/items/plugin.mattpocock-skills.mjs +18 -8
  36. package/lib/items/plugin.ponytail.mjs +106 -104
  37. package/lib/items/plugin.superpowers.mjs +7 -4
  38. package/lib/items/plugin.understand-anything.mjs +13 -2
  39. package/lib/items/skill.diagram-design.mjs +14 -0
  40. package/lib/items/skill.gsd.mjs +46 -44
  41. package/lib/items/skill.gstack.mjs +54 -54
  42. package/lib/jsonfile.mjs +40 -40
  43. package/lib/status.mjs +93 -93
  44. package/lib/tomlfile.mjs +60 -60
  45. package/lib/tui/detail.mjs +127 -127
  46. package/lib/tui/progress.mjs +160 -160
  47. package/lib/tui/render.mjs +268 -268
  48. package/lib/tui/rows.mjs +317 -317
  49. package/lib/tui/run.mjs +396 -396
  50. package/lib/tui/state.mjs +249 -249
  51. package/lib/update.mjs +85 -85
  52. package/lib/width.mjs +115 -115
  53. package/package.json +36 -36
@@ -1,192 +1,192 @@
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
- import { plainLine } from '../tui/progress.mjs'
9
-
10
- // design 항목의 detect는 installed/absent만 돌려준다(catalog.mjs) — partial은
11
- // 열을 넓히기만 하고 실제로 나오지 않는다.
12
- const DESIGN_STATUS_KEYS = ['status.installed', 'status.absent']
13
-
14
- // 카탈로그의 카테고리는 공급자가 준 영어 데이터라 번역하지 않는다.
15
- // 우리가 만든 catch-all(__other·__local)만 번역 대상이다.
16
- export function categoryLabel(t, id) {
17
- return id.startsWith('__') ? t(`category.${id.slice(2)}`) : id
18
- }
19
-
20
- function report(results, log, t) {
21
- for (const r of results) {
22
- const message = toText(t, r.message)
23
- log(` ${r.ok ? '✔' : '✖'} ${t(`change.${r.action}`)} ${r.item.label}${message ? ` — ${message}` : ''}`)
24
- }
25
- }
26
-
27
- // ── 비대화형/공용 코어 ─────────────────────────────────────────────
28
-
29
- function printList(states, log, t) {
30
- const byProvider = new Map()
31
- for (const s of states) {
32
- if (!byProvider.has(s.item.providerId)) byProvider.set(s.item.providerId, [])
33
- byProvider.get(s.item.providerId).push(s)
34
- }
35
- // 폭은 그 로케일의 실제 라벨에서 뽑는다. padEnd는 코드 유닛을 세어 한글이
36
- // 밀리고, 상수로 박으면 영어('Not installed' 13칸)가 넘쳐 이름 열이 어긋난다.
37
- const statusWidth = labelWidth(t, DESIGN_STATUS_KEYS)
38
- for (const [pid, group] of byProvider) {
39
- log(`[${pid}${group[0].item.local ? ` · ${t('design.localTag')}` : ''}]`)
40
- for (const s of group) {
41
- log(` ${pad(t(`status.${s.status}`), statusWidth)} ${s.item.name} — ${s.item.label} [${categoryLabel(t, s.item.designCategory)}]`)
42
- }
43
- }
44
- }
45
-
46
- // 미리보기는 관대하게 해석한다: 알 수 없거나 중복된 토큰은 예외 없이 안내하고 건너뛴다.
47
- function openPreviews(items, tokensStr, opener, log, t) {
48
- for (const tok of tokensStr.split(',').map((s) => s.trim()).filter(Boolean)) {
49
- const slash = tok.indexOf('/')
50
- const matches = slash >= 0
51
- ? items.filter((i) => i.providerId === tok.slice(0, slash) && i.name === tok.slice(slash + 1))
52
- : items.filter((i) => i.name === tok)
53
- if (matches.length === 0) { log(t('design.unknownItem', { token: tok })); continue }
54
- if (matches.length > 1) {
55
- log(t('design.ambiguous', { token: tok, options: matches.map((m) => `${m.providerId}/${m.name}`).join(', ') }))
56
- continue
57
- }
58
- openPreview(opener, matches[0], log, t)
59
- }
60
- }
61
-
62
- // 보이는 집합(visibleStates) 안에서만 diff/적용한다.
63
- async function applyVisible(root, visibleStates, selectedIds, { dryRun, log, t }) {
64
- const changes = planChanges(visibleStates, selectedIds)
65
- if (changes.length === 0) { log(t('apply.noChanges')); return [] }
66
- // design --set도 비대화형 경로다 — runClassic의 --set과 대칭으로, 바를
67
- // 그리지 않고 평문 한 줄씩 흘린다. ANSI 제어문자로 로그를 더럽히지 않는다.
68
- const results = await apply(root, changes, {
69
- dryRun,
70
- log,
71
- t,
72
- onProgress: (event) => {
73
- const line = plainLine(event, t)
74
- if (line) log(line)
75
- },
76
- })
77
- report(results, log, t)
78
- return results
79
- }
80
-
81
- async function installedItems(root, items) {
82
- const out = []
83
- for (const item of items) {
84
- if ((await item.detect({ root })).status === 'installed') out.push(item)
85
- }
86
- return out
87
- }
88
-
89
- export async function refreshCatalog({ dryRun, fetchImpl, log, catalogFile = CATALOG_PATH, t = createT('en') }) {
90
- const next = { updatedAt: new Date().toISOString(), providers: {} }
91
- for (const provider of PROVIDERS) {
92
- try {
93
- const entries = await provider.fetchCatalog(fetchImpl)
94
- next.providers[provider.id] = { label: provider.label, entries }
95
- log(t('design.provider.count', { label: provider.label, count: entries.length }))
96
- } catch (err) {
97
- log(t('design.provider.failed', { label: provider.label, message: err.message }))
98
- }
99
- }
100
- const total = allEntries(next).length
101
- if (total === 0) { log(t('design.catalog.empty')); return { total: 0 } }
102
- if (!dryRun) saveCatalog(next, catalogFile)
103
- log(dryRun ? t('design.catalog.planned', { total }) : t('design.catalog.updated', { total }))
104
- return { total }
105
- }
106
-
107
- export async function updateInstalled(root, items, { dryRun, log, t = createT('en') }) {
108
- const installed = await installedItems(root, items)
109
- if (installed.length === 0) { log(t('design.none')); return }
110
- for (const item of installed) {
111
- try {
112
- await item.install({ root, dryRun, fresh: true })
113
- log(t('design.updated', { label: item.label }))
114
- } catch (err) {
115
- log(t('design.updateFailed', { label: item.label, message: err.message }))
116
- }
117
- }
118
- }
119
-
120
- export async function findStale(root, items, { log, t = createT('en') }) {
121
- const installed = await installedItems(root, items)
122
- const stale = []
123
- for (const item of installed) {
124
- const local = item.localText({ root })
125
- if (local == null) continue
126
- let remote
127
- try { remote = await item.remoteText() } catch { remote = null }
128
- if (remote == null) { log(t('design.sourceCheckFailed', { label: item.label })); continue }
129
- if (sha256(local) !== sha256(remote)) stale.push(item)
130
- }
131
- return stale
132
- }
133
-
134
- // ── 진입점 ────────────────────────────────────────────────────────
135
-
136
- export async function runDesign(root, opts = {}) {
137
- const {
138
- dryRun = false,
139
- list = false,
140
- set = null,
141
- sync = null,
142
- preview = null,
143
- fetchImpl = netFetch,
144
- log = console.log,
145
- designDirs = [],
146
- env = process.env,
147
- catalogFile = CATALOG_PATH,
148
- t = createT('en'),
149
- } = opts
150
- const opener = opts.opener ?? makeOpener(dryRun, log)
151
-
152
- const catalog = loadCatalog(catalogFile)
153
- // 디렉터리 스캔이 먼저다 — 프로바이더 등록 없이도 사내 정의가 목록에 오른다.
154
- const sources = opts.sources ?? discoverSources({
155
- ...(opts.bundleDir ? { bundleDir: opts.bundleDir } : {}),
156
- extraDirs: [...extraDirsFromEnv(env), ...designDirs],
157
- log,
158
- t,
159
- })
160
- const items = buildItems(catalog, { fetchImpl, sources })
161
-
162
- if (list) {
163
- printList(await scan(root, items), log, t)
164
- return
165
- }
166
- if (preview != null) {
167
- openPreviews(items, preview, opener, log, t)
168
- return
169
- }
170
- if (sync != null) {
171
- await runSync(root, items, { op: sync, dryRun, fetchImpl, log, catalogFile, t })
172
- return
173
- }
174
- if (set != null) {
175
- const selected = new Set(resolveTokens(items, set).map((i) => i.id))
176
- await applyVisible(root, await scan(root, items), selected, { dryRun, log, t })
177
- return
178
- }
179
- printList(await scan(root, items), log, t)
180
- }
181
-
182
- async function runSync(root, items, { op: chosen, dryRun, fetchImpl, log, catalogFile, t }) {
183
- if (chosen === 'catalog') { await refreshCatalog({ dryRun, fetchImpl, log, catalogFile, t }); return }
184
- if (chosen === 'installed') { await updateInstalled(root, items, { dryRun, log, t }); return }
185
- if (chosen === 'stale') {
186
- const stale = await findStale(root, items, { log, t })
187
- if (stale.length === 0) { log(t('design.allCurrent')); return }
188
- log(t('design.staleList', { count: stale.length, names: stale.map((i) => i.name).join(', ') }))
189
- return
190
- }
191
- log(t('design.unknownSync', { action: chosen }))
192
- }
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
+ import { plainLine } from '../tui/progress.mjs'
9
+
10
+ // design 항목의 detect는 installed/absent만 돌려준다(catalog.mjs) — partial은
11
+ // 열을 넓히기만 하고 실제로 나오지 않는다.
12
+ const DESIGN_STATUS_KEYS = ['status.installed', 'status.absent']
13
+
14
+ // 카탈로그의 카테고리는 공급자가 준 영어 데이터라 번역하지 않는다.
15
+ // 우리가 만든 catch-all(__other·__local)만 번역 대상이다.
16
+ export function categoryLabel(t, id) {
17
+ return id.startsWith('__') ? t(`category.${id.slice(2)}`) : id
18
+ }
19
+
20
+ function report(results, log, t) {
21
+ for (const r of results) {
22
+ const message = toText(t, r.message)
23
+ log(` ${r.ok ? '✔' : '✖'} ${t(`change.${r.action}`)} ${r.item.label}${message ? ` — ${message}` : ''}`)
24
+ }
25
+ }
26
+
27
+ // ── 비대화형/공용 코어 ─────────────────────────────────────────────
28
+
29
+ function printList(states, log, t) {
30
+ const byProvider = new Map()
31
+ for (const s of states) {
32
+ if (!byProvider.has(s.item.providerId)) byProvider.set(s.item.providerId, [])
33
+ byProvider.get(s.item.providerId).push(s)
34
+ }
35
+ // 폭은 그 로케일의 실제 라벨에서 뽑는다. padEnd는 코드 유닛을 세어 한글이
36
+ // 밀리고, 상수로 박으면 영어('Not installed' 13칸)가 넘쳐 이름 열이 어긋난다.
37
+ const statusWidth = labelWidth(t, DESIGN_STATUS_KEYS)
38
+ for (const [pid, group] of byProvider) {
39
+ log(`[${pid}${group[0].item.local ? ` · ${t('design.localTag')}` : ''}]`)
40
+ for (const s of group) {
41
+ log(` ${pad(t(`status.${s.status}`), statusWidth)} ${s.item.name} — ${s.item.label} [${categoryLabel(t, s.item.designCategory)}]`)
42
+ }
43
+ }
44
+ }
45
+
46
+ // 미리보기는 관대하게 해석한다: 알 수 없거나 중복된 토큰은 예외 없이 안내하고 건너뛴다.
47
+ function openPreviews(items, tokensStr, opener, log, t) {
48
+ for (const tok of tokensStr.split(',').map((s) => s.trim()).filter(Boolean)) {
49
+ const slash = tok.indexOf('/')
50
+ const matches = slash >= 0
51
+ ? items.filter((i) => i.providerId === tok.slice(0, slash) && i.name === tok.slice(slash + 1))
52
+ : items.filter((i) => i.name === tok)
53
+ if (matches.length === 0) { log(t('design.unknownItem', { token: tok })); continue }
54
+ if (matches.length > 1) {
55
+ log(t('design.ambiguous', { token: tok, options: matches.map((m) => `${m.providerId}/${m.name}`).join(', ') }))
56
+ continue
57
+ }
58
+ openPreview(opener, matches[0], log, t)
59
+ }
60
+ }
61
+
62
+ // 보이는 집합(visibleStates) 안에서만 diff/적용한다.
63
+ async function applyVisible(root, visibleStates, selectedIds, { dryRun, log, t }) {
64
+ const changes = planChanges(visibleStates, selectedIds)
65
+ if (changes.length === 0) { log(t('apply.noChanges')); return [] }
66
+ // design --set도 비대화형 경로다 — runClassic의 --set과 대칭으로, 바를
67
+ // 그리지 않고 평문 한 줄씩 흘린다. ANSI 제어문자로 로그를 더럽히지 않는다.
68
+ const results = await apply(root, changes, {
69
+ dryRun,
70
+ log,
71
+ t,
72
+ onProgress: (event) => {
73
+ const line = plainLine(event, t)
74
+ if (line) log(line)
75
+ },
76
+ })
77
+ report(results, log, t)
78
+ return results
79
+ }
80
+
81
+ async function installedItems(root, items) {
82
+ const out = []
83
+ for (const item of items) {
84
+ if ((await item.detect({ root })).status === 'installed') out.push(item)
85
+ }
86
+ return out
87
+ }
88
+
89
+ export async function refreshCatalog({ dryRun, fetchImpl, log, catalogFile = CATALOG_PATH, t = createT('en') }) {
90
+ const next = { updatedAt: new Date().toISOString(), providers: {} }
91
+ for (const provider of PROVIDERS) {
92
+ try {
93
+ const entries = await provider.fetchCatalog(fetchImpl)
94
+ next.providers[provider.id] = { label: provider.label, entries }
95
+ log(t('design.provider.count', { label: provider.label, count: entries.length }))
96
+ } catch (err) {
97
+ log(t('design.provider.failed', { label: provider.label, message: err.message }))
98
+ }
99
+ }
100
+ const total = allEntries(next).length
101
+ if (total === 0) { log(t('design.catalog.empty')); return { total: 0 } }
102
+ if (!dryRun) saveCatalog(next, catalogFile)
103
+ log(dryRun ? t('design.catalog.planned', { total }) : t('design.catalog.updated', { total }))
104
+ return { total }
105
+ }
106
+
107
+ export async function updateInstalled(root, items, { dryRun, log, t = createT('en') }) {
108
+ const installed = await installedItems(root, items)
109
+ if (installed.length === 0) { log(t('design.none')); return }
110
+ for (const item of installed) {
111
+ try {
112
+ await item.install({ root, dryRun, fresh: true })
113
+ log(t('design.updated', { label: item.label }))
114
+ } catch (err) {
115
+ log(t('design.updateFailed', { label: item.label, message: err.message }))
116
+ }
117
+ }
118
+ }
119
+
120
+ export async function findStale(root, items, { log, t = createT('en') }) {
121
+ const installed = await installedItems(root, items)
122
+ const stale = []
123
+ for (const item of installed) {
124
+ const local = item.localText({ root })
125
+ if (local == null) continue
126
+ let remote
127
+ try { remote = await item.remoteText() } catch { remote = null }
128
+ if (remote == null) { log(t('design.sourceCheckFailed', { label: item.label })); continue }
129
+ if (sha256(local) !== sha256(remote)) stale.push(item)
130
+ }
131
+ return stale
132
+ }
133
+
134
+ // ── 진입점 ────────────────────────────────────────────────────────
135
+
136
+ export async function runDesign(root, opts = {}) {
137
+ const {
138
+ dryRun = false,
139
+ list = false,
140
+ set = null,
141
+ sync = null,
142
+ preview = null,
143
+ fetchImpl = netFetch,
144
+ log = console.log,
145
+ designDirs = [],
146
+ env = process.env,
147
+ catalogFile = CATALOG_PATH,
148
+ t = createT('en'),
149
+ } = opts
150
+ const opener = opts.opener ?? makeOpener(dryRun, log)
151
+
152
+ const catalog = loadCatalog(catalogFile)
153
+ // 디렉터리 스캔이 먼저다 — 프로바이더 등록 없이도 사내 정의가 목록에 오른다.
154
+ const sources = opts.sources ?? discoverSources({
155
+ ...(opts.bundleDir ? { bundleDir: opts.bundleDir } : {}),
156
+ extraDirs: [...extraDirsFromEnv(env), ...designDirs],
157
+ log,
158
+ t,
159
+ })
160
+ const items = buildItems(catalog, { fetchImpl, sources })
161
+
162
+ if (list) {
163
+ printList(await scan(root, items), log, t)
164
+ return
165
+ }
166
+ if (preview != null) {
167
+ openPreviews(items, preview, opener, log, t)
168
+ return
169
+ }
170
+ if (sync != null) {
171
+ await runSync(root, items, { op: sync, dryRun, fetchImpl, log, catalogFile, t })
172
+ return
173
+ }
174
+ if (set != null) {
175
+ const selected = new Set(resolveTokens(items, set).map((i) => i.id))
176
+ await applyVisible(root, await scan(root, items), selected, { dryRun, log, t })
177
+ return
178
+ }
179
+ printList(await scan(root, items), log, t)
180
+ }
181
+
182
+ async function runSync(root, items, { op: chosen, dryRun, fetchImpl, log, catalogFile, t }) {
183
+ if (chosen === 'catalog') { await refreshCatalog({ dryRun, fetchImpl, log, catalogFile, t }); return }
184
+ if (chosen === 'installed') { await updateInstalled(root, items, { dryRun, log, t }); return }
185
+ if (chosen === 'stale') {
186
+ const stale = await findStale(root, items, { log, t })
187
+ if (stale.length === 0) { log(t('design.allCurrent')); return }
188
+ log(t('design.staleList', { count: stale.length, names: stale.map((i) => i.name).join(', ') }))
189
+ return
190
+ }
191
+ log(t('design.unknownSync', { action: chosen }))
192
+ }
@@ -1,51 +1,51 @@
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
- }
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
+ }