sh-ui-cli 0.41.0 → 0.42.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.
- package/bin/sh-ui.mjs +11 -2
- package/data/changelog/versions.json +16 -0
- package/data/registry/react/components/calendar/index.tsx +802 -0
- package/data/registry/react/components/calendar/styles.css +227 -0
- package/data/registry/react/components/date-picker/index.tsx +23 -275
- package/data/registry/react/components/date-picker/styles.css +1 -177
- package/data/registry/react/registry.json +21 -1
- package/package.json +1 -1
- package/src/add.mjs +79 -16
- package/src/mcp.mjs +4 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/* ── Trigger (input-like) ── */
|
|
2
2
|
|
|
3
3
|
.sh-ui-date-picker__trigger {
|
|
4
4
|
display: inline-flex;
|
|
@@ -90,182 +90,6 @@
|
|
|
90
90
|
transform: scale(0.96);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/* ── Calendar ���─ */
|
|
94
|
-
|
|
95
|
-
.sh-ui-calendar {
|
|
96
|
-
width: 17.5rem;
|
|
97
|
-
user-select: none;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
.sh-ui-calendar__header {
|
|
101
|
-
display: flex;
|
|
102
|
-
align-items: center;
|
|
103
|
-
justify-content: space-between;
|
|
104
|
-
margin-bottom: var(--space-2);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
.sh-ui-calendar__title {
|
|
108
|
-
font-size: var(--text-sm);
|
|
109
|
-
font-weight: var(--weight-semibold);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
.sh-ui-calendar__nav {
|
|
113
|
-
display: inline-flex;
|
|
114
|
-
align-items: center;
|
|
115
|
-
justify-content: center;
|
|
116
|
-
width: 1.75rem;
|
|
117
|
-
height: 1.75rem;
|
|
118
|
-
padding: 0;
|
|
119
|
-
border: none;
|
|
120
|
-
border-radius: calc(var(--radius) - 2px);
|
|
121
|
-
background: transparent;
|
|
122
|
-
color: var(--foreground-muted);
|
|
123
|
-
cursor: pointer;
|
|
124
|
-
transition: background-color var(--duration-fast), color var(--duration-fast);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
.sh-ui-calendar__nav:hover {
|
|
128
|
-
background: var(--background-muted);
|
|
129
|
-
color: var(--foreground);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
.sh-ui-calendar__nav:focus-visible {
|
|
133
|
-
outline: var(--border-width-strong) solid var(--foreground);
|
|
134
|
-
outline-offset: 2px;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/* ── Weekdays ── */
|
|
138
|
-
|
|
139
|
-
.sh-ui-calendar__weekdays {
|
|
140
|
-
display: grid;
|
|
141
|
-
grid-template-columns: repeat(7, 1fr);
|
|
142
|
-
margin-bottom: var(--space-1);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
.sh-ui-calendar__weekday {
|
|
146
|
-
display: flex;
|
|
147
|
-
align-items: center;
|
|
148
|
-
justify-content: center;
|
|
149
|
-
height: 2rem;
|
|
150
|
-
font-size: var(--text-xs);
|
|
151
|
-
font-weight: var(--weight-medium);
|
|
152
|
-
color: var(--foreground-muted);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/* ── Grid ��─ */
|
|
156
|
-
|
|
157
|
-
.sh-ui-calendar__grid {
|
|
158
|
-
display: grid;
|
|
159
|
-
grid-template-columns: repeat(7, 1fr);
|
|
160
|
-
outline: none;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
.sh-ui-calendar__grid:focus-visible {
|
|
164
|
-
outline: var(--border-width-strong) solid var(--foreground);
|
|
165
|
-
outline-offset: 2px;
|
|
166
|
-
border-radius: calc(var(--radius) - 2px);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/* ── Day cell ── */
|
|
170
|
-
|
|
171
|
-
.sh-ui-calendar__day {
|
|
172
|
-
display: flex;
|
|
173
|
-
align-items: center;
|
|
174
|
-
justify-content: center;
|
|
175
|
-
width: 2.25rem;
|
|
176
|
-
height: 2.25rem;
|
|
177
|
-
margin: 0.0625rem auto;
|
|
178
|
-
padding: 0;
|
|
179
|
-
border: none;
|
|
180
|
-
border-radius: calc(var(--radius) - 2px);
|
|
181
|
-
background: transparent;
|
|
182
|
-
color: var(--foreground);
|
|
183
|
-
font-size: 0.8125rem;
|
|
184
|
-
font-family: inherit;
|
|
185
|
-
cursor: pointer;
|
|
186
|
-
transition: background-color var(--duration-fast), color var(--duration-fast);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
.sh-ui-calendar__day:hover:not(:disabled) {
|
|
190
|
-
background: var(--background-muted);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
.sh-ui-calendar__day:focus-visible {
|
|
194
|
-
outline: var(--border-width-strong) solid var(--foreground);
|
|
195
|
-
outline-offset: 2px;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
.sh-ui-calendar__day--outside {
|
|
199
|
-
color: var(--foreground-subtle, var(--foreground-muted));
|
|
200
|
-
opacity: 0.4;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
.sh-ui-calendar__day--today {
|
|
204
|
-
font-weight: var(--weight-bold);
|
|
205
|
-
text-decoration: underline;
|
|
206
|
-
text-underline-offset: 0.125rem;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
.sh-ui-calendar__day--selected {
|
|
210
|
-
background: var(--primary);
|
|
211
|
-
color: var(--primary-foreground);
|
|
212
|
-
font-weight: var(--weight-semibold);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
.sh-ui-calendar__day--selected:hover:not(:disabled) {
|
|
216
|
-
background: var(--primary-hover);
|
|
217
|
-
color: var(--primary-foreground);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/* ── Range ── */
|
|
221
|
-
|
|
222
|
-
.sh-ui-calendar__day--in-range {
|
|
223
|
-
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
|
224
|
-
border-radius: 0;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
.sh-ui-calendar__day--in-range:hover:not(:disabled) {
|
|
228
|
-
background: color-mix(in srgb, var(--primary) 22%, transparent);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
.sh-ui-calendar__day--range-start {
|
|
232
|
-
background: var(--primary);
|
|
233
|
-
color: var(--primary-foreground);
|
|
234
|
-
font-weight: var(--weight-semibold);
|
|
235
|
-
border-radius: calc(var(--radius) - 2px) 0 0 calc(var(--radius) - 2px);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
.sh-ui-calendar__day--range-end {
|
|
239
|
-
background: var(--primary);
|
|
240
|
-
color: var(--primary-foreground);
|
|
241
|
-
font-weight: var(--weight-semibold);
|
|
242
|
-
border-radius: 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
.sh-ui-calendar__day--range-start.sh-ui-calendar__day--range-end {
|
|
246
|
-
border-radius: calc(var(--radius) - 2px);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
.sh-ui-calendar__day--range-start:hover:not(:disabled),
|
|
250
|
-
.sh-ui-calendar__day--range-end:hover:not(:disabled) {
|
|
251
|
-
background: var(--primary-hover);
|
|
252
|
-
color: var(--primary-foreground);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/* ── Hint (range picker) ── */
|
|
256
|
-
|
|
257
|
-
.sh-ui-date-picker__hint {
|
|
258
|
-
margin: 0 0 var(--space-2);
|
|
259
|
-
font-size: var(--text-xs);
|
|
260
|
-
color: var(--foreground-muted);
|
|
261
|
-
text-align: center;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
.sh-ui-calendar__day:disabled {
|
|
265
|
-
opacity: 0.3;
|
|
266
|
-
cursor: not-allowed;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
93
|
/* ── Footer ── */
|
|
270
94
|
|
|
271
95
|
.sh-ui-date-picker__footer {
|
|
@@ -847,6 +847,24 @@
|
|
|
847
847
|
"dependencies": [],
|
|
848
848
|
"registryDependencies": []
|
|
849
849
|
},
|
|
850
|
+
"calendar": {
|
|
851
|
+
"name": "calendar",
|
|
852
|
+
"type": "component",
|
|
853
|
+
"files": [
|
|
854
|
+
{
|
|
855
|
+
"src": "components/calendar/index.tsx",
|
|
856
|
+
"dest": "{components}/calendar/index.tsx"
|
|
857
|
+
},
|
|
858
|
+
{
|
|
859
|
+
"src": "components/calendar/styles.css",
|
|
860
|
+
"dest": "{components}/calendar/styles.css"
|
|
861
|
+
}
|
|
862
|
+
],
|
|
863
|
+
"dependencies": [],
|
|
864
|
+
"registryDependencies": [
|
|
865
|
+
"select"
|
|
866
|
+
]
|
|
867
|
+
},
|
|
850
868
|
"date-picker": {
|
|
851
869
|
"name": "date-picker",
|
|
852
870
|
"type": "component",
|
|
@@ -863,7 +881,9 @@
|
|
|
863
881
|
"dependencies": [
|
|
864
882
|
"@base-ui/react"
|
|
865
883
|
],
|
|
866
|
-
"registryDependencies": [
|
|
884
|
+
"registryDependencies": [
|
|
885
|
+
"calendar"
|
|
886
|
+
]
|
|
867
887
|
},
|
|
868
888
|
"skeleton": {
|
|
869
889
|
"name": "skeleton",
|
package/package.json
CHANGED
package/src/add.mjs
CHANGED
|
@@ -3,9 +3,44 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { dirname, resolve, relative } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
import { select } from "@inquirer/prompts";
|
|
6
7
|
import { formatUnifiedDiff } from "./diff.mjs";
|
|
7
8
|
import { getRegistryRoot, getTokensRoot, getPeerVersionsPath } from "./paths.mjs";
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* 기존 파일과 registry 파일 내용이 다를 때 keep/overwrite 결정.
|
|
12
|
+
* strategy 가 "prompt" 면 사용자에게 묻고, 그 외엔 즉시 결정.
|
|
13
|
+
* "ALL" 선택은 이번 add 실행 동안만 유지된다.
|
|
14
|
+
*/
|
|
15
|
+
function makeConflictResolver(strategy) {
|
|
16
|
+
// strategy: "prompt" | "keep" | "overwrite"
|
|
17
|
+
let sticky = strategy === "prompt" ? null : strategy;
|
|
18
|
+
return {
|
|
19
|
+
async resolve(rel) {
|
|
20
|
+
if (sticky) return sticky;
|
|
21
|
+
const choice = await select({
|
|
22
|
+
message: `이미 존재합니다: ${rel} — 어떻게 할까요?`,
|
|
23
|
+
choices: [
|
|
24
|
+
{ name: "그대로 두기 (사용자 변경 유지)", value: "keep" },
|
|
25
|
+
{ name: "덮어쓰기 (registry 버전으로 교체)", value: "overwrite" },
|
|
26
|
+
{ name: "남은 충돌도 모두 그대로 두기", value: "keep-all" },
|
|
27
|
+
{ name: "남은 충돌도 모두 덮어쓰기", value: "overwrite-all" },
|
|
28
|
+
],
|
|
29
|
+
default: "keep",
|
|
30
|
+
});
|
|
31
|
+
if (choice === "keep-all") {
|
|
32
|
+
sticky = "keep";
|
|
33
|
+
return "keep";
|
|
34
|
+
}
|
|
35
|
+
if (choice === "overwrite-all") {
|
|
36
|
+
sticky = "overwrite";
|
|
37
|
+
return "overwrite";
|
|
38
|
+
}
|
|
39
|
+
return choice;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
9
44
|
/**
|
|
10
45
|
* `dependencies` 에 적힌 패키지명을 peer-versions.json 의 버전 범위와 결합.
|
|
11
46
|
* 패키지 자체에 이미 `@version` 이 붙어 있거나 맵에 없으면 그대로 둔다.
|
|
@@ -54,10 +89,12 @@ async function ensureDir(filePath) {
|
|
|
54
89
|
}
|
|
55
90
|
|
|
56
91
|
/**
|
|
57
|
-
* 대상 파일 쓰기 래퍼.
|
|
58
|
-
*
|
|
92
|
+
* 대상 파일 쓰기 래퍼.
|
|
93
|
+
* - diff 모드면 기존 파일과 비교해 diff만 출력하고 skip.
|
|
94
|
+
* - 일반 모드에서 기존 파일과 내용이 다르면 conflictResolver 에 위임.
|
|
95
|
+
* @returns "new" | "unchanged" | "modified" | "kept" | "previewed"
|
|
59
96
|
*/
|
|
60
|
-
async function writeOrDiff({ dest, content, cwd, diffMode, summary, isBinary = false }) {
|
|
97
|
+
async function writeOrDiff({ dest, content, cwd, diffMode, summary, conflictResolver, isBinary = false }) {
|
|
61
98
|
const rel = relative(cwd, dest);
|
|
62
99
|
const exists = existsSync(dest);
|
|
63
100
|
|
|
@@ -72,11 +109,13 @@ async function writeOrDiff({ dest, content, cwd, diffMode, summary, isBinary = f
|
|
|
72
109
|
}
|
|
73
110
|
|
|
74
111
|
if (isBinary) {
|
|
75
|
-
// 바이너리는 diff
|
|
112
|
+
// 바이너리는 diff 비교가 의미 없음. 기존 파일이 있으면 conflictResolver 에 위임.
|
|
76
113
|
if (diffMode) {
|
|
77
114
|
summary.push({ kind: "binary", rel });
|
|
78
115
|
return "previewed";
|
|
79
116
|
}
|
|
117
|
+
const choice = await conflictResolver.resolve(rel);
|
|
118
|
+
if (choice === "keep") return "kept";
|
|
80
119
|
await writeFile(dest, content, "utf8");
|
|
81
120
|
return "modified";
|
|
82
121
|
}
|
|
@@ -97,12 +136,15 @@ async function writeOrDiff({ dest, content, cwd, diffMode, summary, isBinary = f
|
|
|
97
136
|
return "previewed";
|
|
98
137
|
}
|
|
99
138
|
|
|
139
|
+
const choice = await conflictResolver.resolve(rel);
|
|
140
|
+
if (choice === "keep") return "kept";
|
|
141
|
+
|
|
100
142
|
await writeFile(dest, content, "utf8");
|
|
101
143
|
return "modified";
|
|
102
144
|
}
|
|
103
145
|
|
|
104
146
|
/** 특수 컴포넌트: 설정으로 토큰 파일 생성 */
|
|
105
|
-
async function addTokens(config, cwd, diffMode, summary) {
|
|
147
|
+
async function addTokens(config, cwd, diffMode, summary, conflictResolver) {
|
|
106
148
|
const destRel = config.paths?.tokens;
|
|
107
149
|
if (!destRel) throw new Error("paths.tokens 가 설정에 없습니다.");
|
|
108
150
|
const dest = resolve(cwd, destRel);
|
|
@@ -113,13 +155,15 @@ async function addTokens(config, cwd, diffMode, summary) {
|
|
|
113
155
|
? await buildTokensCss(config)
|
|
114
156
|
: await buildTokensDart(config);
|
|
115
157
|
|
|
116
|
-
const result = await writeOrDiff({ dest, content, cwd, diffMode, summary });
|
|
158
|
+
const result = await writeOrDiff({ dest, content, cwd, diffMode, summary, conflictResolver });
|
|
117
159
|
if (!diffMode && result !== "unchanged") {
|
|
118
|
-
|
|
160
|
+
const prefix = result === "kept" ? "↷" : "✓";
|
|
161
|
+
const suffix = result === "kept" ? " (kept)" : "";
|
|
162
|
+
console.log(`${prefix} tokens → ${relative(cwd, dest)}${suffix}`);
|
|
119
163
|
}
|
|
120
164
|
}
|
|
121
165
|
|
|
122
|
-
async function addComponent(name, config, cwd, installed, pendingDeps, diffMode, summary) {
|
|
166
|
+
async function addComponent(name, config, cwd, installed, pendingDeps, diffMode, summary, conflictResolver) {
|
|
123
167
|
const registryRoot = getRegistryRoot(config.platform);
|
|
124
168
|
const registry = JSON.parse(
|
|
125
169
|
await readFile(resolve(registryRoot, "registry.json"), "utf8"),
|
|
@@ -132,16 +176,18 @@ async function addComponent(name, config, cwd, installed, pendingDeps, diffMode,
|
|
|
132
176
|
}
|
|
133
177
|
|
|
134
178
|
for (const dep of entry.registryDependencies ?? []) {
|
|
135
|
-
await addOne(dep, config, cwd, installed, pendingDeps, diffMode, summary);
|
|
179
|
+
await addOne(dep, config, cwd, installed, pendingDeps, diffMode, summary, conflictResolver);
|
|
136
180
|
}
|
|
137
181
|
|
|
138
182
|
for (const file of entry.files) {
|
|
139
183
|
const src = resolve(registryRoot, file.src);
|
|
140
184
|
const dest = resolve(cwd, resolveDest(file.dest, config));
|
|
141
185
|
const content = await readFile(src, "utf8");
|
|
142
|
-
const result = await writeOrDiff({ dest, content, cwd, diffMode, summary });
|
|
186
|
+
const result = await writeOrDiff({ dest, content, cwd, diffMode, summary, conflictResolver });
|
|
143
187
|
if (!diffMode && result !== "unchanged") {
|
|
144
|
-
|
|
188
|
+
const prefix = result === "kept" ? "↷" : "✓";
|
|
189
|
+
const suffix = result === "kept" ? " (kept)" : "";
|
|
190
|
+
console.log(`${prefix} ${name} → ${relative(cwd, dest)}${suffix}`);
|
|
145
191
|
}
|
|
146
192
|
}
|
|
147
193
|
|
|
@@ -197,7 +243,19 @@ function runInstall(pm, deps, cwd) {
|
|
|
197
243
|
});
|
|
198
244
|
}
|
|
199
245
|
|
|
200
|
-
export async function add({
|
|
246
|
+
export async function add({
|
|
247
|
+
cwd,
|
|
248
|
+
names,
|
|
249
|
+
skipInstall = false,
|
|
250
|
+
diffMode = false,
|
|
251
|
+
/**
|
|
252
|
+
* 기존 파일과 registry 파일이 충돌할 때 동작.
|
|
253
|
+
* "prompt" — 인터랙티브 (기본). 비대화형 환경에선 자동으로 "keep" 으로 강등.
|
|
254
|
+
* "keep" — 기존 파일 유지 (사용자 변경 보존).
|
|
255
|
+
* "overwrite" — registry 버전으로 덮어쓰기 (`--force`).
|
|
256
|
+
*/
|
|
257
|
+
onConflict = "prompt",
|
|
258
|
+
}) {
|
|
201
259
|
const configPath = resolve(cwd, "sh-ui.config.json");
|
|
202
260
|
let config;
|
|
203
261
|
try {
|
|
@@ -208,11 +266,16 @@ export async function add({ cwd, names, skipInstall = false, diffMode = false })
|
|
|
208
266
|
);
|
|
209
267
|
}
|
|
210
268
|
|
|
269
|
+
// 비대화형(non-TTY)이면 prompt 를 못 띄우니 안전하게 keep 으로 강등.
|
|
270
|
+
const effectiveStrategy =
|
|
271
|
+
onConflict === "prompt" && !process.stdin.isTTY ? "keep" : onConflict;
|
|
272
|
+
const conflictResolver = makeConflictResolver(effectiveStrategy);
|
|
273
|
+
|
|
211
274
|
const installed = new Set();
|
|
212
275
|
const pendingDeps = new Set();
|
|
213
276
|
const summary = [];
|
|
214
277
|
for (const name of names) {
|
|
215
|
-
await addOne(name, config, cwd, installed, pendingDeps, diffMode, summary);
|
|
278
|
+
await addOne(name, config, cwd, installed, pendingDeps, diffMode, summary, conflictResolver);
|
|
216
279
|
}
|
|
217
280
|
|
|
218
281
|
if (diffMode) {
|
|
@@ -255,13 +318,13 @@ export async function add({ cwd, names, skipInstall = false, diffMode = false })
|
|
|
255
318
|
}
|
|
256
319
|
}
|
|
257
320
|
|
|
258
|
-
async function addOne(name, config, cwd, installed, pendingDeps, diffMode, summary) {
|
|
321
|
+
async function addOne(name, config, cwd, installed, pendingDeps, diffMode, summary, conflictResolver) {
|
|
259
322
|
if (installed.has(name)) return;
|
|
260
323
|
installed.add(name);
|
|
261
324
|
if (name === "tokens") {
|
|
262
|
-
await addTokens(config, cwd, diffMode, summary);
|
|
325
|
+
await addTokens(config, cwd, diffMode, summary, conflictResolver);
|
|
263
326
|
} else {
|
|
264
|
-
await addComponent(name, config, cwd, installed, pendingDeps, diffMode, summary);
|
|
327
|
+
await addComponent(name, config, cwd, installed, pendingDeps, diffMode, summary, conflictResolver);
|
|
265
328
|
}
|
|
266
329
|
}
|
|
267
330
|
|
package/src/mcp.mjs
CHANGED
|
@@ -357,6 +357,8 @@ export async function startMcpServer() {
|
|
|
357
357
|
.describe("작업 디렉토리. 기본 process.cwd()"),
|
|
358
358
|
skipInstall: z.boolean().optional()
|
|
359
359
|
.describe("외부 패키지 자동 설치 생략. 기본 false"),
|
|
360
|
+
overwrite: z.boolean().optional()
|
|
361
|
+
.describe("이미 존재하는 파일도 덮어쓸지. 기본 false (= 사용자 변경 보존)"),
|
|
360
362
|
},
|
|
361
363
|
},
|
|
362
364
|
async (input) => {
|
|
@@ -365,6 +367,8 @@ export async function startMcpServer() {
|
|
|
365
367
|
cwd: resolveCwd(input),
|
|
366
368
|
names: input.names,
|
|
367
369
|
skipInstall: input.skipInstall === true,
|
|
370
|
+
// MCP 컨텍스트는 비대화형 — 명시적으로 overwrite=true 일 때만 덮어쓰고, 아니면 기존 파일 보존.
|
|
371
|
+
onConflict: input.overwrite === true ? "overwrite" : "keep",
|
|
368
372
|
}),
|
|
369
373
|
);
|
|
370
374
|
return textResult(text || "✓ add 완료");
|