@rch4com/agent-setup 1.10.0 → 1.10.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/LICENSE +21 -21
- package/README.md +199 -199
- package/install.mjs +163 -163
- package/lib/args.mjs +276 -276
- package/lib/bootstrap/adapter.mjs +96 -96
- package/lib/bootstrap/apply.mjs +380 -380
- package/lib/bootstrap/flow.mjs +140 -140
- package/lib/bootstrap/record.mjs +154 -154
- package/lib/bootstrap/templates.mjs +148 -148
- package/lib/bootstrap/text.mjs +19 -19
- package/lib/catalog.mjs +234 -234
- package/lib/claude-plugins.mjs +64 -64
- package/lib/clis.mjs +111 -111
- package/lib/context.mjs +66 -66
- package/lib/deps.mjs +17 -17
- package/lib/design-md/catalog.json +454 -454
- package/lib/design-md/catalog.mjs +266 -266
- package/lib/design-md/flow.mjs +192 -192
- package/lib/design-md/open.mjs +51 -51
- package/lib/design-md/providers/awesome-design-md.mjs +124 -124
- package/lib/design-md/providers/index.mjs +9 -9
- package/lib/design-md/scan.mjs +295 -295
- package/lib/engine.mjs +115 -115
- package/lib/gitignore.mjs +18 -18
- package/lib/i18n/catalog/en.mjs +426 -426
- package/lib/i18n/catalog/ko.mjs +411 -411
- package/lib/i18n/detect.mjs +39 -39
- package/lib/i18n/index.mjs +93 -93
- package/lib/items/mcp.codebase-memory.mjs +6 -6
- package/lib/items/mcp.notion.mjs +6 -6
- package/lib/items/mcp.supabase.mjs +6 -6
- package/lib/items/mcp.vercel.mjs +6 -6
- package/lib/items/plugin.mattpocock-skills.mjs +8 -8
- package/lib/items/plugin.ponytail.mjs +104 -104
- package/lib/items/skill.gsd.mjs +44 -44
- package/lib/items/skill.gstack.mjs +54 -54
- package/lib/jsonfile.mjs +40 -40
- package/lib/status.mjs +93 -93
- package/lib/tomlfile.mjs +60 -60
- package/lib/tui/detail.mjs +127 -127
- package/lib/tui/progress.mjs +160 -160
- package/lib/tui/render.mjs +268 -268
- package/lib/tui/rows.mjs +317 -317
- package/lib/tui/run.mjs +396 -396
- package/lib/tui/state.mjs +249 -249
- package/lib/update.mjs +85 -85
- package/lib/width.mjs +115 -115
- package/package.json +36 -36
package/lib/tui/detail.mjs
CHANGED
|
@@ -1,127 +1,127 @@
|
|
|
1
|
-
// 상세 패널 — 커서가 놓인 항목의 전문을 여러 줄로 편다.
|
|
2
|
-
// 순수 함수 모듈이다. 색도 커서도 터미널도 모른다 — render.mjs가 배치하고 칠한다.
|
|
3
|
-
//
|
|
4
|
-
// rows.mjs를 import 하지 않는 것이 규칙이다. render.mjs가 이 모듈을 쓰므로,
|
|
5
|
-
// 여기서 rows를 끌어오면 render → detail → rows 방향이 생겨 rows.mjs가 순수
|
|
6
|
-
// 렌더 층의 의존성이 된다. 그래서 사유 그룹핑이 rows가 아니라 여기 있고,
|
|
7
|
-
// rows.mjs가 반대로 여기서 가져다 쓴다.
|
|
8
|
-
import { toText, createT } from '../i18n/index.mjs'
|
|
9
|
-
import { CLIS } from '../clis.mjs'
|
|
10
|
-
import { categoryLabel } from '../design-md/flow.mjs'
|
|
11
|
-
import { cut, labelWidth, pad, width, wrap } from '../width.mjs'
|
|
12
|
-
|
|
13
|
-
// 미배선 사유를 **같은 사유끼리** 묶는다. 사유 하나가 CLI 아홉 개에 그대로
|
|
14
|
-
// 반복되면 줄만 길어지고 "무엇이 왜 빠졌는가"는 오히려 묻힌다.
|
|
15
|
-
// 사유가 둘 이상이면(ponytail처럼) 갈래가 그대로 보인다.
|
|
16
|
-
export function unsupportedGroups(item, t) {
|
|
17
|
-
const entries = Object.entries(item?.unsupported ?? {})
|
|
18
|
-
if (entries.length === 0) return []
|
|
19
|
-
const byReason = new Map()
|
|
20
|
-
for (const [cli, why] of entries) {
|
|
21
|
-
const text = toText(t, why)
|
|
22
|
-
if (!byReason.has(text)) byReason.set(text, [])
|
|
23
|
-
byReason.get(text).push(cli)
|
|
24
|
-
}
|
|
25
|
-
return [...byReason].map(([why, clis]) => ({ clis, why }))
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// 왼쪽 라벨 열('배선'·'미배선'·'출처'·'미리보기'·'대상 파일')의 폭. 로케일마다
|
|
29
|
-
// 길이가 달라 상수로 박으면 열이 어긋나므로 그 로케일의 실제 라벨에서 뽑는다.
|
|
30
|
-
//
|
|
31
|
-
// **이 열에 놓이는 라벨을 하나도 빠뜨리면 안 된다.** 예전에는 배선·미배선만
|
|
32
|
-
// 재서, 한국어의 '미리보기'(8칸)가 폭 7의 열에 들어가 '미리…'로 잘렸다 —
|
|
33
|
-
// pad는 넘치는 글을 cut으로 버리기 때문에 조용히 사라진다.
|
|
34
|
-
const LEAD_KEYS = ['detail.wired', 'detail.unwired', 'detail.provider', 'detail.preview', 'detail.target']
|
|
35
|
-
|
|
36
|
-
function leadWidth(t) {
|
|
37
|
-
return labelWidth(t, LEAD_KEYS) + 1
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function headLine(row, w, t) {
|
|
41
|
-
const item = row.item
|
|
42
|
-
// item.scope는 오늘 모든 항목 종류가 채운다 — 그래도 이 값 하나로 화면
|
|
43
|
-
// 전체가 죽을 수는 없다. t()는 모르는 키에 던지고(i18n/index.mjs) 이
|
|
44
|
-
// 함수는 render() 안에서 돈다 — 항목 하나의 결손이 패널 하나가 아니라
|
|
45
|
-
// TUI 전체를 끌고 내려간다.
|
|
46
|
-
const bits = [item.category, item.scope ? t(`detail.scope.${item.scope}`) : null, t(`status.${row.status}`)]
|
|
47
|
-
return cut(`${item.label} ${bits.filter(Boolean).join(' · ')}`, w)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function wiredLines(item, w, lead, t) {
|
|
51
|
-
const supports = item.supports ?? []
|
|
52
|
-
if (supports.length === 0) return []
|
|
53
|
-
// 경로는 MCP에서만 붙인다. 그때만 clis.mjs 어댑터가 경로의 유일한 진실이다 —
|
|
54
|
-
// plugin·skill은 설치 자리가 항목마다 달라 어댑터가 알지 못한다.
|
|
55
|
-
const showFile = item.category === 'mcp'
|
|
56
|
-
const nameWidth = Math.max(...supports.map((c) => width(c)))
|
|
57
|
-
return supports.map((cli, i) => {
|
|
58
|
-
const head = i === 0 ? pad(t('detail.wired'), lead) : ' '.repeat(lead)
|
|
59
|
-
const file = showFile && CLIS[cli]?.file ? ` ${CLIS[cli].file}` : ''
|
|
60
|
-
// 이름 뒤에 열이 없으면 맞출 대상도 없다 — 경로가 붙는 mcp에서만 이름을 편다.
|
|
61
|
-
const name = showFile ? pad(cli, nameWidth) : cli
|
|
62
|
-
return cut(`${head}✔ ${name}${file}`, w)
|
|
63
|
-
})
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function unwiredLines(item, w, lead, t) {
|
|
67
|
-
const out = []
|
|
68
|
-
unsupportedGroups(item, t).forEach((group, i) => {
|
|
69
|
-
const head = i === 0 ? pad(t('detail.unwired'), lead) : ' '.repeat(lead)
|
|
70
|
-
out.push(cut(`${head}✖ ${group.clis.join('·')}`, w))
|
|
71
|
-
// 사유는 한 칸 더 들여 이어 붙인다. 첫 줄만 └를 달아 어느 CLI 묶음의
|
|
72
|
-
// 사유인지 눈으로 잇는다.
|
|
73
|
-
wrap(group.why, Math.max(1, w - lead - 3)).forEach((line, j) => {
|
|
74
|
-
out.push(cut(`${' '.repeat(lead + 1)}${j === 0 ? '└ ' : ' '}${line}`, w))
|
|
75
|
-
})
|
|
76
|
-
})
|
|
77
|
-
return out
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function agentLines(row, w, t) {
|
|
81
|
-
const item = row.item
|
|
82
|
-
const lead = leadWidth(t)
|
|
83
|
-
const out = [headLine(row, w, t)]
|
|
84
|
-
// 대상 파일은 note보다 먼저 낸다 — MCP의 배선표가 "어느 파일에 쓰이나"를
|
|
85
|
-
// 답하는 자리와 같은 몫이고, 산문보다 먼저 눈에 들어와야 한다.
|
|
86
|
-
if (item.target) out.push(cut(`${pad(t('detail.target'), lead)}${item.target}`, w))
|
|
87
|
-
if (item.note) out.push(...wrap(t(item.note), w))
|
|
88
|
-
if (row.statusDetail) out.push(...wrap(row.statusDetail, w))
|
|
89
|
-
out.push(...wiredLines(item, w, lead, t))
|
|
90
|
-
out.push(...unwiredLines(item, w, lead, t))
|
|
91
|
-
return out
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function designLines(row, w, t) {
|
|
95
|
-
const item = row.item
|
|
96
|
-
const lead = leadWidth(t)
|
|
97
|
-
const bits = [item.category, categoryLabel(t, item.designCategory), t(`status.${row.status}`)]
|
|
98
|
-
const out = [cut(`${item.label} ${bits.filter(Boolean).join(' · ')}`, w)]
|
|
99
|
-
if (item.providerId) out.push(cut(`${pad(t('detail.provider'), lead)}${item.providerId}`, w))
|
|
100
|
-
const target = row.previewTarget ?? item.webUrl ?? item.previewPath
|
|
101
|
-
if (target) out.push(cut(`${pad(t('detail.preview'), lead)}${target}`, w))
|
|
102
|
-
if (item.description) out.push(...wrap(item.description, w))
|
|
103
|
-
return out
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// 액션 행은 체크 대상이 아니라 실행 대상이다 — 배선표가 없다.
|
|
107
|
-
function actionLines(row, w) {
|
|
108
|
-
return [cut(row.label, w), ...wrap(row.fullHint ?? row.hint ?? '', w)]
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// 행 하나와 지면 크기를 받아 줄 배열을 돌려준다. 색도 커서도 모른다.
|
|
112
|
-
export function detailLines(row, { width: columns = 80, height = 8, t = createT('en') } = {}) {
|
|
113
|
-
if (!row || height <= 0) return []
|
|
114
|
-
const w = Math.max(20, columns)
|
|
115
|
-
const lines = row.kind === 'action'
|
|
116
|
-
? actionLines(row, w)
|
|
117
|
-
: row.item?.category === 'design' ? designLines(row, w, t) : agentLines(row, w, t)
|
|
118
|
-
|
|
119
|
-
if (lines.length <= height) return lines
|
|
120
|
-
// 넘치면 조용히 자르지 않는다 — 남은 줄 수와 펼치는 길을 함께 알린다.
|
|
121
|
-
// 안내줄이 한 줄을 차지하므로 본문은 height - 1줄만 쓴다. height===1이면
|
|
122
|
-
// 그 한 줄조차 없어 안내줄 하나가 전부를 대신하고, 그때는 "한 줄 빼고 남은
|
|
123
|
-
// 수"가 아니라 "전부"를 세어야 거짓말이 되지 않는다.
|
|
124
|
-
const room = height - 1
|
|
125
|
-
const marker = cut(t('detail.more', { count: lines.length - Math.max(0, room) }), w)
|
|
126
|
-
return room <= 0 ? [marker] : [...lines.slice(0, room), marker]
|
|
127
|
-
}
|
|
1
|
+
// 상세 패널 — 커서가 놓인 항목의 전문을 여러 줄로 편다.
|
|
2
|
+
// 순수 함수 모듈이다. 색도 커서도 터미널도 모른다 — render.mjs가 배치하고 칠한다.
|
|
3
|
+
//
|
|
4
|
+
// rows.mjs를 import 하지 않는 것이 규칙이다. render.mjs가 이 모듈을 쓰므로,
|
|
5
|
+
// 여기서 rows를 끌어오면 render → detail → rows 방향이 생겨 rows.mjs가 순수
|
|
6
|
+
// 렌더 층의 의존성이 된다. 그래서 사유 그룹핑이 rows가 아니라 여기 있고,
|
|
7
|
+
// rows.mjs가 반대로 여기서 가져다 쓴다.
|
|
8
|
+
import { toText, createT } from '../i18n/index.mjs'
|
|
9
|
+
import { CLIS } from '../clis.mjs'
|
|
10
|
+
import { categoryLabel } from '../design-md/flow.mjs'
|
|
11
|
+
import { cut, labelWidth, pad, width, wrap } from '../width.mjs'
|
|
12
|
+
|
|
13
|
+
// 미배선 사유를 **같은 사유끼리** 묶는다. 사유 하나가 CLI 아홉 개에 그대로
|
|
14
|
+
// 반복되면 줄만 길어지고 "무엇이 왜 빠졌는가"는 오히려 묻힌다.
|
|
15
|
+
// 사유가 둘 이상이면(ponytail처럼) 갈래가 그대로 보인다.
|
|
16
|
+
export function unsupportedGroups(item, t) {
|
|
17
|
+
const entries = Object.entries(item?.unsupported ?? {})
|
|
18
|
+
if (entries.length === 0) return []
|
|
19
|
+
const byReason = new Map()
|
|
20
|
+
for (const [cli, why] of entries) {
|
|
21
|
+
const text = toText(t, why)
|
|
22
|
+
if (!byReason.has(text)) byReason.set(text, [])
|
|
23
|
+
byReason.get(text).push(cli)
|
|
24
|
+
}
|
|
25
|
+
return [...byReason].map(([why, clis]) => ({ clis, why }))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 왼쪽 라벨 열('배선'·'미배선'·'출처'·'미리보기'·'대상 파일')의 폭. 로케일마다
|
|
29
|
+
// 길이가 달라 상수로 박으면 열이 어긋나므로 그 로케일의 실제 라벨에서 뽑는다.
|
|
30
|
+
//
|
|
31
|
+
// **이 열에 놓이는 라벨을 하나도 빠뜨리면 안 된다.** 예전에는 배선·미배선만
|
|
32
|
+
// 재서, 한국어의 '미리보기'(8칸)가 폭 7의 열에 들어가 '미리…'로 잘렸다 —
|
|
33
|
+
// pad는 넘치는 글을 cut으로 버리기 때문에 조용히 사라진다.
|
|
34
|
+
const LEAD_KEYS = ['detail.wired', 'detail.unwired', 'detail.provider', 'detail.preview', 'detail.target']
|
|
35
|
+
|
|
36
|
+
function leadWidth(t) {
|
|
37
|
+
return labelWidth(t, LEAD_KEYS) + 1
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function headLine(row, w, t) {
|
|
41
|
+
const item = row.item
|
|
42
|
+
// item.scope는 오늘 모든 항목 종류가 채운다 — 그래도 이 값 하나로 화면
|
|
43
|
+
// 전체가 죽을 수는 없다. t()는 모르는 키에 던지고(i18n/index.mjs) 이
|
|
44
|
+
// 함수는 render() 안에서 돈다 — 항목 하나의 결손이 패널 하나가 아니라
|
|
45
|
+
// TUI 전체를 끌고 내려간다.
|
|
46
|
+
const bits = [item.category, item.scope ? t(`detail.scope.${item.scope}`) : null, t(`status.${row.status}`)]
|
|
47
|
+
return cut(`${item.label} ${bits.filter(Boolean).join(' · ')}`, w)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function wiredLines(item, w, lead, t) {
|
|
51
|
+
const supports = item.supports ?? []
|
|
52
|
+
if (supports.length === 0) return []
|
|
53
|
+
// 경로는 MCP에서만 붙인다. 그때만 clis.mjs 어댑터가 경로의 유일한 진실이다 —
|
|
54
|
+
// plugin·skill은 설치 자리가 항목마다 달라 어댑터가 알지 못한다.
|
|
55
|
+
const showFile = item.category === 'mcp'
|
|
56
|
+
const nameWidth = Math.max(...supports.map((c) => width(c)))
|
|
57
|
+
return supports.map((cli, i) => {
|
|
58
|
+
const head = i === 0 ? pad(t('detail.wired'), lead) : ' '.repeat(lead)
|
|
59
|
+
const file = showFile && CLIS[cli]?.file ? ` ${CLIS[cli].file}` : ''
|
|
60
|
+
// 이름 뒤에 열이 없으면 맞출 대상도 없다 — 경로가 붙는 mcp에서만 이름을 편다.
|
|
61
|
+
const name = showFile ? pad(cli, nameWidth) : cli
|
|
62
|
+
return cut(`${head}✔ ${name}${file}`, w)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function unwiredLines(item, w, lead, t) {
|
|
67
|
+
const out = []
|
|
68
|
+
unsupportedGroups(item, t).forEach((group, i) => {
|
|
69
|
+
const head = i === 0 ? pad(t('detail.unwired'), lead) : ' '.repeat(lead)
|
|
70
|
+
out.push(cut(`${head}✖ ${group.clis.join('·')}`, w))
|
|
71
|
+
// 사유는 한 칸 더 들여 이어 붙인다. 첫 줄만 └를 달아 어느 CLI 묶음의
|
|
72
|
+
// 사유인지 눈으로 잇는다.
|
|
73
|
+
wrap(group.why, Math.max(1, w - lead - 3)).forEach((line, j) => {
|
|
74
|
+
out.push(cut(`${' '.repeat(lead + 1)}${j === 0 ? '└ ' : ' '}${line}`, w))
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function agentLines(row, w, t) {
|
|
81
|
+
const item = row.item
|
|
82
|
+
const lead = leadWidth(t)
|
|
83
|
+
const out = [headLine(row, w, t)]
|
|
84
|
+
// 대상 파일은 note보다 먼저 낸다 — MCP의 배선표가 "어느 파일에 쓰이나"를
|
|
85
|
+
// 답하는 자리와 같은 몫이고, 산문보다 먼저 눈에 들어와야 한다.
|
|
86
|
+
if (item.target) out.push(cut(`${pad(t('detail.target'), lead)}${item.target}`, w))
|
|
87
|
+
if (item.note) out.push(...wrap(t(item.note), w))
|
|
88
|
+
if (row.statusDetail) out.push(...wrap(row.statusDetail, w))
|
|
89
|
+
out.push(...wiredLines(item, w, lead, t))
|
|
90
|
+
out.push(...unwiredLines(item, w, lead, t))
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function designLines(row, w, t) {
|
|
95
|
+
const item = row.item
|
|
96
|
+
const lead = leadWidth(t)
|
|
97
|
+
const bits = [item.category, categoryLabel(t, item.designCategory), t(`status.${row.status}`)]
|
|
98
|
+
const out = [cut(`${item.label} ${bits.filter(Boolean).join(' · ')}`, w)]
|
|
99
|
+
if (item.providerId) out.push(cut(`${pad(t('detail.provider'), lead)}${item.providerId}`, w))
|
|
100
|
+
const target = row.previewTarget ?? item.webUrl ?? item.previewPath
|
|
101
|
+
if (target) out.push(cut(`${pad(t('detail.preview'), lead)}${target}`, w))
|
|
102
|
+
if (item.description) out.push(...wrap(item.description, w))
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 액션 행은 체크 대상이 아니라 실행 대상이다 — 배선표가 없다.
|
|
107
|
+
function actionLines(row, w) {
|
|
108
|
+
return [cut(row.label, w), ...wrap(row.fullHint ?? row.hint ?? '', w)]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 행 하나와 지면 크기를 받아 줄 배열을 돌려준다. 색도 커서도 모른다.
|
|
112
|
+
export function detailLines(row, { width: columns = 80, height = 8, t = createT('en') } = {}) {
|
|
113
|
+
if (!row || height <= 0) return []
|
|
114
|
+
const w = Math.max(20, columns)
|
|
115
|
+
const lines = row.kind === 'action'
|
|
116
|
+
? actionLines(row, w)
|
|
117
|
+
: row.item?.category === 'design' ? designLines(row, w, t) : agentLines(row, w, t)
|
|
118
|
+
|
|
119
|
+
if (lines.length <= height) return lines
|
|
120
|
+
// 넘치면 조용히 자르지 않는다 — 남은 줄 수와 펼치는 길을 함께 알린다.
|
|
121
|
+
// 안내줄이 한 줄을 차지하므로 본문은 height - 1줄만 쓴다. height===1이면
|
|
122
|
+
// 그 한 줄조차 없어 안내줄 하나가 전부를 대신하고, 그때는 "한 줄 빼고 남은
|
|
123
|
+
// 수"가 아니라 "전부"를 세어야 거짓말이 되지 않는다.
|
|
124
|
+
const room = height - 1
|
|
125
|
+
const marker = cut(t('detail.more', { count: lines.length - Math.max(0, room) }), w)
|
|
126
|
+
return room <= 0 ? [marker] : [...lines.slice(0, room), marker]
|
|
127
|
+
}
|
package/lib/tui/progress.mjs
CHANGED
|
@@ -1,160 +1,160 @@
|
|
|
1
|
-
// 진행 화면 — 순수 함수 모듈이다. 터미널도 시계도 모른다.
|
|
2
|
-
//
|
|
3
|
-
// 시각을 인자로 받는 것이 핵심이다. 모듈 안에서 Date.now()를 부르면 테스트가
|
|
4
|
-
// 실제 시간에 묶여 "14초 경과"를 검증할 방법이 없다.
|
|
5
|
-
import { createT } from '../i18n/index.mjs'
|
|
6
|
-
import { cut, labelWidth, pad, width } from '../width.mjs'
|
|
7
|
-
|
|
8
|
-
const ESC = String.fromCharCode(27)
|
|
9
|
-
const DIM = `${ESC}[2m`
|
|
10
|
-
const BOLD = `${ESC}[1m`
|
|
11
|
-
const RESET = `${ESC}[0m`
|
|
12
|
-
|
|
13
|
-
const MARK = { done: '✔', failed: '✖', running: '▸', pending: ' ', skipped: '–' }
|
|
14
|
-
|
|
15
|
-
export function createProgress(changes) {
|
|
16
|
-
return {
|
|
17
|
-
total: changes.length,
|
|
18
|
-
startedAt: null,
|
|
19
|
-
aborted: false,
|
|
20
|
-
entries: changes.map(({ item, action }) => ({
|
|
21
|
-
item, action, state: 'pending', ok: null, ms: 0, command: null, startedAt: null,
|
|
22
|
-
})),
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// 이벤트 하나를 접어 넣는다. 새 상태를 돌려준다 — 화면은 이 값만 보고 그린다.
|
|
27
|
-
export function applyEvent(progress, event, now) {
|
|
28
|
-
const entries = progress.entries.slice()
|
|
29
|
-
const at = entries[event.index]
|
|
30
|
-
if (!at) return progress
|
|
31
|
-
if (event.phase === 'start') entries[event.index] = { ...at, state: 'running', startedAt: now }
|
|
32
|
-
else if (event.phase === 'command') entries[event.index] = { ...at, command: event.command }
|
|
33
|
-
else if (event.phase === 'done') {
|
|
34
|
-
entries[event.index] = { ...at, state: event.ok ? 'done' : 'failed', ok: event.ok, ms: event.ms ?? 0 }
|
|
35
|
-
}
|
|
36
|
-
return { ...progress, entries, startedAt: progress.startedAt ?? now }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function seconds(ms) {
|
|
40
|
-
return (Math.max(0, ms) / 1000).toFixed(1).replace(/\.0$/, '')
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function elapsedText(ms, t) {
|
|
44
|
-
const total = Math.floor(Math.max(0, ms) / 1000)
|
|
45
|
-
return total >= 60
|
|
46
|
-
? t('progress.elapsedMin', { minutes: Math.floor(total / 60), seconds: total % 60 })
|
|
47
|
-
: t('progress.elapsed', { seconds: total })
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function bar(done, total, room) {
|
|
51
|
-
if (room <= 2) return ''
|
|
52
|
-
const inner = room - 2
|
|
53
|
-
const filled = total === 0 ? 0 : Math.round((done / total) * inner)
|
|
54
|
-
return `[${'█'.repeat(filled)}${'░'.repeat(Math.max(0, inner - filled))}]`
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// 항목 한 개가 차지하는 줄. 실행 중이면 경과와 명령을 함께 낸다.
|
|
58
|
-
function entryLines(entry, w, actionWidth, now, t) {
|
|
59
|
-
let tail = ''
|
|
60
|
-
if (entry.state === 'running') {
|
|
61
|
-
const ran = Math.floor(Math.max(0, now - (entry.startedAt ?? now)) / 1000)
|
|
62
|
-
tail = ` ${t('progress.running', { seconds: ran })}`
|
|
63
|
-
} else if (entry.state === 'done' || entry.state === 'failed') {
|
|
64
|
-
tail = ` ${t('progress.elapsed', { seconds: seconds(entry.ms) })}`
|
|
65
|
-
} else if (entry.state === 'skipped') {
|
|
66
|
-
tail = ` ${t('progress.skipped')}`
|
|
67
|
-
}
|
|
68
|
-
const mark = MARK[entry.state] ?? ' '
|
|
69
|
-
const out = [cut(`${mark} ${pad(t(`change.${entry.action}`), actionWidth)} ${entry.item.label}${tail}`, w)]
|
|
70
|
-
// 명령은 실행 중일 때만. 끝난 뒤에도 남기면 화면이 명령 목록이 된다.
|
|
71
|
-
if (entry.state === 'running' && entry.command) out.push(cut(` ${entry.command}`, w))
|
|
72
|
-
return out
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// 중단되면 아직 시작하지 않은(pending) 항목이 곧 건너뛴 항목이다. applyEvent는
|
|
76
|
-
// Task 9의 이벤트(start·command·done)만 받아 항목 상태가 'skipped'가 되는
|
|
77
|
-
// 경로가 없다 — 그래서 화면을 그릴 때만 pending을 skipped로 겹쳐 본다.
|
|
78
|
-
// 집계와 표시가 같은 규칙을 쓰게 해, "건너뜀 0건"처럼 숫자와 말이 어긋나지 않게 한다.
|
|
79
|
-
function viewEntry(entry, aborted) {
|
|
80
|
-
return aborted && entry.state === 'pending' ? { ...entry, state: 'skipped' } : entry
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function progressLines(progress, opts = {}) {
|
|
84
|
-
const { width: columns = 80, height = 24, color = false, dryRun = false, now = 0, t = createT('en') } = opts
|
|
85
|
-
const w = Math.max(24, columns - 1)
|
|
86
|
-
const paint = (code, text) => (color ? `${code}${text}${RESET}` : text)
|
|
87
|
-
|
|
88
|
-
const done = progress.entries.filter((e) => e.state === 'done' || e.state === 'failed').length
|
|
89
|
-
const skipped = progress.aborted ? progress.entries.filter((e) => e.state === 'pending').length : 0
|
|
90
|
-
const percent = progress.total === 0 ? 100 : Math.round((done / progress.total) * 100)
|
|
91
|
-
const counter = t('progress.counter', { done, total: progress.total, percent })
|
|
92
|
-
const elapsed = progress.startedAt === null ? '' : ` ${elapsedText(now - progress.startedAt, t)}`
|
|
93
|
-
|
|
94
|
-
const title = t('progress.title', { count: progress.total, suffix: dryRun ? ' (dry-run)' : '' })
|
|
95
|
-
const lines = [paint(BOLD, cut(title, w)), '']
|
|
96
|
-
|
|
97
|
-
const meta = ` ${counter}${elapsed}`
|
|
98
|
-
lines.push(cut(`${bar(done, progress.total, Math.max(0, w - width(meta)))}${meta}`, w))
|
|
99
|
-
lines.push('')
|
|
100
|
-
|
|
101
|
-
const actionWidth = labelWidth(t, ['change.install', 'change.complete', 'change.uninstall'])
|
|
102
|
-
const body = Math.max(1, height - lines.length - 2)
|
|
103
|
-
|
|
104
|
-
// 지면이 모자라면 **완료된 것부터** 접는다. 지금 무엇이 도는지가 가장 알고
|
|
105
|
-
// 싶은 정보라, 실행 중 항목은 언제나 화면에 남는다.
|
|
106
|
-
//
|
|
107
|
-
// 실행 중 항목(없으면 마지막으로 끝난 항목)을 기준점으로 잡고 지면이
|
|
108
|
-
// 허락하는 만큼만 위로 거슬러 올라간다. 기준점에서 시작하므로 그 줄이
|
|
109
|
-
// 잘려 나갈 수 없다 — "완료분부터 접는다"를 규칙이 아니라 구조로 보장한다.
|
|
110
|
-
const blocks = progress.entries.map((e) => entryLines(viewEntry(e, progress.aborted), w, actionWidth, now, t))
|
|
111
|
-
const running = progress.entries.findIndex((e) => e.state === 'running')
|
|
112
|
-
const anchor = running === -1 ? Math.max(0, done - 1) : running
|
|
113
|
-
|
|
114
|
-
let from = anchor
|
|
115
|
-
let used = blocks.slice(anchor).reduce((n, b) => n + b.length, 0)
|
|
116
|
-
while (from > 0 && used + blocks[from - 1].length <= body - 1) {
|
|
117
|
-
from--
|
|
118
|
-
used += blocks[from].length
|
|
119
|
-
}
|
|
120
|
-
// '더보기' 줄은 부가 정보일 뿐이다. 지면이 1줄밖에 없으면 실행 중 항목이
|
|
121
|
-
// 이긴다 — 더보기 줄에 밀려 정작 지금 도는 항목이 사라지면 안 된다.
|
|
122
|
-
const showMore = from > 0 && body > 1
|
|
123
|
-
const shown = blocks.slice(from).flat().slice(0, Math.max(1, body - (showMore ? 1 : 0)))
|
|
124
|
-
if (showMore) lines.push(paint(DIM, cut(t('progress.more', { count: from }), w)))
|
|
125
|
-
for (const line of shown) lines.push(line)
|
|
126
|
-
for (let i = lines.length; i < height - 2; i++) lines.push('')
|
|
127
|
-
|
|
128
|
-
lines.push('')
|
|
129
|
-
// done 분기(아래)는 aborted가 아닐 때만 오고, 그때 skipped는 위에서 늘
|
|
130
|
-
// 0이다 — 그래서 skippedSuffix는 항상 빈 문자열이다. progress.doneSkipped
|
|
131
|
-
// 키는 이 분기가 실제로 채울 일이 없어 지웠다(더는 어디서도 쓰지 않는다).
|
|
132
|
-
const foot = progress.aborted
|
|
133
|
-
? t('progress.aborted', { count: skipped })
|
|
134
|
-
: done < progress.total
|
|
135
|
-
? t('progress.abortHint')
|
|
136
|
-
: t('progress.done', {
|
|
137
|
-
ok: progress.entries.filter((e) => e.state === 'done').length,
|
|
138
|
-
failed: progress.entries.filter((e) => e.state === 'failed').length,
|
|
139
|
-
skippedSuffix: '',
|
|
140
|
-
})
|
|
141
|
-
lines.push(paint(DIM, cut(foot, w)))
|
|
142
|
-
return lines.slice(0, height)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// 비TTY(CI·파이프)용. 바를 그리지 않고 항목마다 한 줄씩 흘린다 —
|
|
146
|
-
// ANSI 제어문자로 CI 로그를 더럽히지 않는다.
|
|
147
|
-
export function plainLine(event, t = createT('en')) {
|
|
148
|
-
if (event.phase === 'start') {
|
|
149
|
-
return t('progress.plain', {
|
|
150
|
-
index: event.index + 1,
|
|
151
|
-
total: event.total,
|
|
152
|
-
action: t(`change.${event.action}`),
|
|
153
|
-
label: event.item.label,
|
|
154
|
-
})
|
|
155
|
-
}
|
|
156
|
-
if (event.phase === 'done') {
|
|
157
|
-
return t('progress.plainDone', { mark: event.ok ? '✔' : '✖', seconds: seconds(event.ms ?? 0) })
|
|
158
|
-
}
|
|
159
|
-
return null
|
|
160
|
-
}
|
|
1
|
+
// 진행 화면 — 순수 함수 모듈이다. 터미널도 시계도 모른다.
|
|
2
|
+
//
|
|
3
|
+
// 시각을 인자로 받는 것이 핵심이다. 모듈 안에서 Date.now()를 부르면 테스트가
|
|
4
|
+
// 실제 시간에 묶여 "14초 경과"를 검증할 방법이 없다.
|
|
5
|
+
import { createT } from '../i18n/index.mjs'
|
|
6
|
+
import { cut, labelWidth, pad, width } from '../width.mjs'
|
|
7
|
+
|
|
8
|
+
const ESC = String.fromCharCode(27)
|
|
9
|
+
const DIM = `${ESC}[2m`
|
|
10
|
+
const BOLD = `${ESC}[1m`
|
|
11
|
+
const RESET = `${ESC}[0m`
|
|
12
|
+
|
|
13
|
+
const MARK = { done: '✔', failed: '✖', running: '▸', pending: ' ', skipped: '–' }
|
|
14
|
+
|
|
15
|
+
export function createProgress(changes) {
|
|
16
|
+
return {
|
|
17
|
+
total: changes.length,
|
|
18
|
+
startedAt: null,
|
|
19
|
+
aborted: false,
|
|
20
|
+
entries: changes.map(({ item, action }) => ({
|
|
21
|
+
item, action, state: 'pending', ok: null, ms: 0, command: null, startedAt: null,
|
|
22
|
+
})),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 이벤트 하나를 접어 넣는다. 새 상태를 돌려준다 — 화면은 이 값만 보고 그린다.
|
|
27
|
+
export function applyEvent(progress, event, now) {
|
|
28
|
+
const entries = progress.entries.slice()
|
|
29
|
+
const at = entries[event.index]
|
|
30
|
+
if (!at) return progress
|
|
31
|
+
if (event.phase === 'start') entries[event.index] = { ...at, state: 'running', startedAt: now }
|
|
32
|
+
else if (event.phase === 'command') entries[event.index] = { ...at, command: event.command }
|
|
33
|
+
else if (event.phase === 'done') {
|
|
34
|
+
entries[event.index] = { ...at, state: event.ok ? 'done' : 'failed', ok: event.ok, ms: event.ms ?? 0 }
|
|
35
|
+
}
|
|
36
|
+
return { ...progress, entries, startedAt: progress.startedAt ?? now }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function seconds(ms) {
|
|
40
|
+
return (Math.max(0, ms) / 1000).toFixed(1).replace(/\.0$/, '')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function elapsedText(ms, t) {
|
|
44
|
+
const total = Math.floor(Math.max(0, ms) / 1000)
|
|
45
|
+
return total >= 60
|
|
46
|
+
? t('progress.elapsedMin', { minutes: Math.floor(total / 60), seconds: total % 60 })
|
|
47
|
+
: t('progress.elapsed', { seconds: total })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function bar(done, total, room) {
|
|
51
|
+
if (room <= 2) return ''
|
|
52
|
+
const inner = room - 2
|
|
53
|
+
const filled = total === 0 ? 0 : Math.round((done / total) * inner)
|
|
54
|
+
return `[${'█'.repeat(filled)}${'░'.repeat(Math.max(0, inner - filled))}]`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 항목 한 개가 차지하는 줄. 실행 중이면 경과와 명령을 함께 낸다.
|
|
58
|
+
function entryLines(entry, w, actionWidth, now, t) {
|
|
59
|
+
let tail = ''
|
|
60
|
+
if (entry.state === 'running') {
|
|
61
|
+
const ran = Math.floor(Math.max(0, now - (entry.startedAt ?? now)) / 1000)
|
|
62
|
+
tail = ` ${t('progress.running', { seconds: ran })}`
|
|
63
|
+
} else if (entry.state === 'done' || entry.state === 'failed') {
|
|
64
|
+
tail = ` ${t('progress.elapsed', { seconds: seconds(entry.ms) })}`
|
|
65
|
+
} else if (entry.state === 'skipped') {
|
|
66
|
+
tail = ` ${t('progress.skipped')}`
|
|
67
|
+
}
|
|
68
|
+
const mark = MARK[entry.state] ?? ' '
|
|
69
|
+
const out = [cut(`${mark} ${pad(t(`change.${entry.action}`), actionWidth)} ${entry.item.label}${tail}`, w)]
|
|
70
|
+
// 명령은 실행 중일 때만. 끝난 뒤에도 남기면 화면이 명령 목록이 된다.
|
|
71
|
+
if (entry.state === 'running' && entry.command) out.push(cut(` ${entry.command}`, w))
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 중단되면 아직 시작하지 않은(pending) 항목이 곧 건너뛴 항목이다. applyEvent는
|
|
76
|
+
// Task 9의 이벤트(start·command·done)만 받아 항목 상태가 'skipped'가 되는
|
|
77
|
+
// 경로가 없다 — 그래서 화면을 그릴 때만 pending을 skipped로 겹쳐 본다.
|
|
78
|
+
// 집계와 표시가 같은 규칙을 쓰게 해, "건너뜀 0건"처럼 숫자와 말이 어긋나지 않게 한다.
|
|
79
|
+
function viewEntry(entry, aborted) {
|
|
80
|
+
return aborted && entry.state === 'pending' ? { ...entry, state: 'skipped' } : entry
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function progressLines(progress, opts = {}) {
|
|
84
|
+
const { width: columns = 80, height = 24, color = false, dryRun = false, now = 0, t = createT('en') } = opts
|
|
85
|
+
const w = Math.max(24, columns - 1)
|
|
86
|
+
const paint = (code, text) => (color ? `${code}${text}${RESET}` : text)
|
|
87
|
+
|
|
88
|
+
const done = progress.entries.filter((e) => e.state === 'done' || e.state === 'failed').length
|
|
89
|
+
const skipped = progress.aborted ? progress.entries.filter((e) => e.state === 'pending').length : 0
|
|
90
|
+
const percent = progress.total === 0 ? 100 : Math.round((done / progress.total) * 100)
|
|
91
|
+
const counter = t('progress.counter', { done, total: progress.total, percent })
|
|
92
|
+
const elapsed = progress.startedAt === null ? '' : ` ${elapsedText(now - progress.startedAt, t)}`
|
|
93
|
+
|
|
94
|
+
const title = t('progress.title', { count: progress.total, suffix: dryRun ? ' (dry-run)' : '' })
|
|
95
|
+
const lines = [paint(BOLD, cut(title, w)), '']
|
|
96
|
+
|
|
97
|
+
const meta = ` ${counter}${elapsed}`
|
|
98
|
+
lines.push(cut(`${bar(done, progress.total, Math.max(0, w - width(meta)))}${meta}`, w))
|
|
99
|
+
lines.push('')
|
|
100
|
+
|
|
101
|
+
const actionWidth = labelWidth(t, ['change.install', 'change.complete', 'change.uninstall'])
|
|
102
|
+
const body = Math.max(1, height - lines.length - 2)
|
|
103
|
+
|
|
104
|
+
// 지면이 모자라면 **완료된 것부터** 접는다. 지금 무엇이 도는지가 가장 알고
|
|
105
|
+
// 싶은 정보라, 실행 중 항목은 언제나 화면에 남는다.
|
|
106
|
+
//
|
|
107
|
+
// 실행 중 항목(없으면 마지막으로 끝난 항목)을 기준점으로 잡고 지면이
|
|
108
|
+
// 허락하는 만큼만 위로 거슬러 올라간다. 기준점에서 시작하므로 그 줄이
|
|
109
|
+
// 잘려 나갈 수 없다 — "완료분부터 접는다"를 규칙이 아니라 구조로 보장한다.
|
|
110
|
+
const blocks = progress.entries.map((e) => entryLines(viewEntry(e, progress.aborted), w, actionWidth, now, t))
|
|
111
|
+
const running = progress.entries.findIndex((e) => e.state === 'running')
|
|
112
|
+
const anchor = running === -1 ? Math.max(0, done - 1) : running
|
|
113
|
+
|
|
114
|
+
let from = anchor
|
|
115
|
+
let used = blocks.slice(anchor).reduce((n, b) => n + b.length, 0)
|
|
116
|
+
while (from > 0 && used + blocks[from - 1].length <= body - 1) {
|
|
117
|
+
from--
|
|
118
|
+
used += blocks[from].length
|
|
119
|
+
}
|
|
120
|
+
// '더보기' 줄은 부가 정보일 뿐이다. 지면이 1줄밖에 없으면 실행 중 항목이
|
|
121
|
+
// 이긴다 — 더보기 줄에 밀려 정작 지금 도는 항목이 사라지면 안 된다.
|
|
122
|
+
const showMore = from > 0 && body > 1
|
|
123
|
+
const shown = blocks.slice(from).flat().slice(0, Math.max(1, body - (showMore ? 1 : 0)))
|
|
124
|
+
if (showMore) lines.push(paint(DIM, cut(t('progress.more', { count: from }), w)))
|
|
125
|
+
for (const line of shown) lines.push(line)
|
|
126
|
+
for (let i = lines.length; i < height - 2; i++) lines.push('')
|
|
127
|
+
|
|
128
|
+
lines.push('')
|
|
129
|
+
// done 분기(아래)는 aborted가 아닐 때만 오고, 그때 skipped는 위에서 늘
|
|
130
|
+
// 0이다 — 그래서 skippedSuffix는 항상 빈 문자열이다. progress.doneSkipped
|
|
131
|
+
// 키는 이 분기가 실제로 채울 일이 없어 지웠다(더는 어디서도 쓰지 않는다).
|
|
132
|
+
const foot = progress.aborted
|
|
133
|
+
? t('progress.aborted', { count: skipped })
|
|
134
|
+
: done < progress.total
|
|
135
|
+
? t('progress.abortHint')
|
|
136
|
+
: t('progress.done', {
|
|
137
|
+
ok: progress.entries.filter((e) => e.state === 'done').length,
|
|
138
|
+
failed: progress.entries.filter((e) => e.state === 'failed').length,
|
|
139
|
+
skippedSuffix: '',
|
|
140
|
+
})
|
|
141
|
+
lines.push(paint(DIM, cut(foot, w)))
|
|
142
|
+
return lines.slice(0, height)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 비TTY(CI·파이프)용. 바를 그리지 않고 항목마다 한 줄씩 흘린다 —
|
|
146
|
+
// ANSI 제어문자로 CI 로그를 더럽히지 않는다.
|
|
147
|
+
export function plainLine(event, t = createT('en')) {
|
|
148
|
+
if (event.phase === 'start') {
|
|
149
|
+
return t('progress.plain', {
|
|
150
|
+
index: event.index + 1,
|
|
151
|
+
total: event.total,
|
|
152
|
+
action: t(`change.${event.action}`),
|
|
153
|
+
label: event.item.label,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
if (event.phase === 'done') {
|
|
157
|
+
return t('progress.plainDone', { mark: event.ok ? '✔' : '✖', seconds: seconds(event.ms ?? 0) })
|
|
158
|
+
}
|
|
159
|
+
return null
|
|
160
|
+
}
|