@seed-design/cli 1.2.1 → 1.2.2

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.
@@ -11,6 +11,18 @@ import { BASE_URL } from "../constants";
11
11
  import { highlight } from "../utils/color";
12
12
  import { installDependencies } from "../utils/install";
13
13
  import { analytics } from "../utils/analytics";
14
+ import {
15
+ analyzeRegistryItemCompatibility,
16
+ getProjectSeedPackageVersionSpecs,
17
+ logCompatibilityReport,
18
+ } from "../utils/compatibility";
19
+ import {
20
+ CliCancelError,
21
+ CliError,
22
+ handleCliError,
23
+ isCliCancelError,
24
+ isVerboseMode,
25
+ } from "../utils/error";
14
26
 
15
27
  const addOptionsSchema = z.object({
16
28
  itemIds: z.array(z.string()).optional(),
@@ -42,150 +54,168 @@ export const addCommand = (cli: CAC) => {
42
54
  .example("seed-design add ui:alert-dialog")
43
55
  .action(async (itemIds, opts) => {
44
56
  const startTime = Date.now();
57
+ const verbose = isVerboseMode(opts);
45
58
  p.intro("seed-design add");
46
59
 
47
- const {
48
- success,
49
- data: { all, ...options },
50
- error,
51
- } = addOptionsSchema.safeParse({ itemIds, ...opts });
60
+ try {
61
+ const parsed = addOptionsSchema.safeParse({ itemIds, ...opts });
62
+ if (!parsed.success) {
63
+ throw parsed.error;
64
+ }
52
65
 
53
- if (!success) {
54
- p.log.error(`잘못된 옵션이에요: ${error?.message}`);
66
+ const {
67
+ data: { all, ...options },
68
+ } = parsed;
55
69
 
56
- process.exit(1);
57
- }
70
+ if (all) {
71
+ throw new CliError({
72
+ message:
73
+ "`--all` 옵션은 더 이상 지원되지 않아요. 대신 `seed-design add-all` 명령어를 사용해주세요.",
74
+ });
75
+ }
58
76
 
59
- if (all) {
60
- p.log.error(
61
- "`--all` 옵션은 이상 지원되지 않아요. 대신 `seed-design add-all` 명령어를 사용해주세요.",
62
- );
77
+ const cwd = options.cwd;
78
+ const baseUrl = options.baseUrl;
79
+ const config = await getConfig(cwd);
80
+ const rootPath = path.resolve(cwd, config.path);
63
81
 
64
- process.exit(1);
65
- }
82
+ const { start, stop } = p.spinner();
83
+ start("Registry를 가져오고 있어요...");
66
84
 
67
- const cwd = options.cwd;
68
- const baseUrl = options.baseUrl;
69
- const config = await getConfig(cwd);
70
- const rootPath = path.resolve(cwd, config.path);
71
-
72
- const { start, stop } = p.spinner();
73
-
74
- start("Registry를 가져오고 있어요...");
75
-
76
- const publicRegistries = await Promise.all(
77
- (await fetchAvailableRegistries({ baseUrl })).map(async ({ id }) =>
78
- fetchRegistry({ baseUrl, registryId: id }),
79
- ),
80
- );
81
-
82
- stop("Registry를 가져왔어요.");
83
-
84
- const selectedItemKeys: string[] = await (async () => {
85
- if (options.itemIds.length > 0) return options.itemIds;
86
-
87
- const selected = await p.multiselect({
88
- message: "추가할 항목을 선택해주세요 (스페이스 바로 여러 개 선택 가능)",
89
- options: publicRegistries
90
- .filter(({ hideFromCLICatalog }) => !hideFromCLICatalog)
91
- .flatMap(({ id: registryId, items }) =>
92
- items
93
- .filter(({ hideFromCLICatalog }) => !hideFromCLICatalog)
94
- .sort((a, b) => a.id.localeCompare(b.id))
95
- .map(({ id, description, deprecated }) => ({
96
- label: `${deprecated ? "(deprecated) " : ""}${highlight(registryId)}:${id}`,
97
- value: `${registryId}:${id}`,
98
- hint: description,
99
-
100
- // used for sorting
101
- deprecated,
102
- registryItemCount: items.length,
103
- })),
104
- )
105
- .sort((a, b) => {
106
- if (a.deprecated !== b.deprecated) return a.deprecated ? 1 : -1;
107
-
108
- return b.registryItemCount - a.registryItemCount;
109
- }),
110
- });
85
+ const publicRegistries = await (async () => {
86
+ try {
87
+ const registries = await Promise.all(
88
+ (await fetchAvailableRegistries({ baseUrl })).map(async ({ id }) =>
89
+ fetchRegistry({ baseUrl, registryId: id }),
90
+ ),
91
+ );
92
+ stop("Registry를 가져왔어요.");
111
93
 
112
- if (p.isCancel(selected)) {
113
- p.log.error("취소되었어요.");
114
- process.exit(0);
115
- }
94
+ return registries;
95
+ } catch (error) {
96
+ stop("Registry를 가져오지 못했어요.");
97
+ throw error;
98
+ }
99
+ })();
116
100
 
117
- return selected;
118
- })();
101
+ const selectedItemKeys: string[] = await (async () => {
102
+ if (options.itemIds?.length) {
103
+ return options.itemIds;
104
+ }
119
105
 
120
- if (!selectedItemKeys?.length) {
121
- p.log.error("항목을 찾을 없어요.");
106
+ const selected = await p.multiselect({
107
+ message: "추가할 항목을 선택해주세요 (스페이스 바로 여러 개 선택 가능)",
108
+ options: publicRegistries
109
+ .filter(({ hideFromCLICatalog }) => !hideFromCLICatalog)
110
+ .flatMap(({ id: registryId, items }) =>
111
+ items
112
+ .filter(({ hideFromCLICatalog }) => !hideFromCLICatalog)
113
+ .sort((a, b) => a.id.localeCompare(b.id))
114
+ .map(({ id, description, deprecated }) => ({
115
+ label: `${deprecated ? "(deprecated) " : ""}${highlight(registryId)}:${id}`,
116
+ value: `${registryId}:${id}`,
117
+ hint: description,
118
+
119
+ // used for sorting
120
+ deprecated,
121
+ registryItemCount: items.length,
122
+ })),
123
+ )
124
+ .sort((a, b) => {
125
+ if (a.deprecated !== b.deprecated) return a.deprecated ? 1 : -1;
126
+
127
+ return b.registryItemCount - a.registryItemCount;
128
+ }),
129
+ });
122
130
 
123
- process.exit(0);
124
- }
131
+ if (p.isCancel(selected)) {
132
+ throw new CliCancelError();
133
+ }
125
134
 
126
- p.log.message(`선택된 항목: ${highlight(selectedItemKeys.join(", "))}`);
135
+ return selected;
136
+ })();
127
137
 
128
- const filteredItemKeys: string[] = [];
138
+ if (!selectedItemKeys?.length) {
139
+ throw new CliCancelError("추가할 항목이 선택되지 않았어요.");
140
+ }
129
141
 
130
- for (const itemKey of selectedItemKeys) {
131
- const [registryId, ...rest] = itemKey.split(":");
132
- const itemId = rest.join(":");
142
+ p.log.message(`선택된 항목: ${highlight(selectedItemKeys.join(", "))}`);
133
143
 
134
- if (!registryId || !itemId) {
135
- p.log.error(
136
- `${highlight(itemKey)}: 항목 이름이 잘못되었어요. ${highlight("ui:action-button")}과 같은 형식으로 입력해보세요.`,
137
- );
144
+ const filteredItemKeys: string[] = [];
138
145
 
139
- process.exit(1);
140
- }
146
+ for (const itemKey of selectedItemKeys) {
147
+ const [registryId, ...rest] = itemKey.split(":");
148
+ const itemId = rest.join(":");
141
149
 
142
- const foundItem = publicRegistries
143
- .find((r) => r.id === registryId)
144
- ?.items.find((i) => i.id === itemId);
150
+ if (!registryId || !itemId) {
151
+ throw new CliError({
152
+ message: `${highlight(itemKey)}: 항목 이름이 잘못되었어요.`,
153
+ hint: `${highlight("ui:action-button")}과 같은 형식으로 입력해보세요.`,
154
+ });
155
+ }
145
156
 
146
- if (!foundItem) {
147
- p.log.error(`${highlight(itemKey)}: 항목을 찾을 없어요.`);
157
+ const foundItem = publicRegistries
158
+ .find((r) => r.id === registryId)
159
+ ?.items.find((i) => i.id === itemId);
148
160
 
149
- process.exit(1);
150
- }
161
+ if (!foundItem) {
162
+ throw new CliError({
163
+ message: `${highlight(itemKey)}: 항목을 찾을 수 없어요.`,
164
+ });
165
+ }
151
166
 
152
- if (foundItem.deprecated) {
153
- const confirm = await p.confirm({
154
- message: `${highlight(foundItem.id)}: deprecated 되었어요. 추가할까요?`,
155
- initialValue: false,
156
- });
167
+ if (foundItem.deprecated) {
168
+ const confirm = await p.confirm({
169
+ message: `${highlight(foundItem.id)}: deprecated 되었어요. 추가할까요?`,
170
+ initialValue: false,
171
+ });
157
172
 
158
- if (confirm === false || p.isCancel(confirm)) {
159
- p.log.info(`${highlight(foundItem.id)}: 추가하지 않을게요.`);
173
+ if (p.isCancel(confirm)) {
174
+ throw new CliCancelError();
175
+ }
160
176
 
161
- continue;
177
+ if (confirm === false) {
178
+ p.log.info(`${highlight(foundItem.id)}: 추가하지 않을게요.`);
179
+ continue;
180
+ }
162
181
  }
182
+
183
+ filteredItemKeys.push(itemKey);
163
184
  }
164
185
 
165
- filteredItemKeys.push(itemKey);
166
- }
186
+ const { registryItemsToAdd, npmDependenciesToAdd } = resolveDependencies({
187
+ selectedItemKeys: filteredItemKeys,
188
+ publicRegistries,
189
+ });
167
190
 
168
- const { registryItemsToAdd, npmDependenciesToAdd } = resolveDependencies({
169
- selectedItemKeys: filteredItemKeys,
170
- publicRegistries,
171
- });
191
+ const compatibilityReport = analyzeRegistryItemCompatibility({
192
+ publicRegistries,
193
+ itemKeys: registryItemsToAdd.flatMap(({ registryId, items }) =>
194
+ items.map((item) => `${registryId}:${item.id}`),
195
+ ),
196
+ projectPackageVersions: getProjectSeedPackageVersionSpecs(options.cwd),
197
+ });
172
198
 
173
- p.log.info(
174
- `추가할 항목: ${highlight(registryItemsToAdd.map((r) => r.items.map((i) => `${r.registryId}:${i.id}`).join(", ")).join(", ") || "없음")}
199
+ logCompatibilityReport({
200
+ report: compatibilityReport,
201
+ title: "현재 프로젝트 버전과 호환되지 않을 수 있는 스니펫이 있어요.",
202
+ });
203
+
204
+ p.log.info(
205
+ `추가할 항목: ${highlight(registryItemsToAdd.map((r) => r.items.map((i) => `${r.registryId}:${i.id}`).join(", ")).join(", ") || "없음")}
175
206
 
176
207
  설치할 의존성: ${highlight(Array.from(npmDependenciesToAdd).join(", ") || "없음")}`,
177
- );
208
+ );
178
209
 
179
- await writeRegistryItemSnippets({
180
- registryItemsToAdd,
181
- rootPath,
182
- cwd,
183
- baseUrl,
184
- config,
185
- onDiff: options.onDiff,
186
- });
210
+ await writeRegistryItemSnippets({
211
+ registryItemsToAdd,
212
+ rootPath,
213
+ cwd,
214
+ baseUrl,
215
+ config,
216
+ onDiff: options.onDiff,
217
+ });
187
218
 
188
- try {
189
219
  const { installed, filtered } = await installDependencies({
190
220
  cwd,
191
221
  deps: Array.from(npmDependenciesToAdd),
@@ -205,31 +235,46 @@ export const addCommand = (cli: CAC) => {
205
235
  }
206
236
  }
207
237
  p.outro("완료했어요.");
238
+
239
+ // add 성공 이벤트 추적
240
+ const duration = Date.now() - startTime;
241
+ const uniqueRegistries = new Set(registryItemsToAdd.map((r) => r.registryId));
242
+ const hasDeprecated = selectedItemKeys.some((itemKey) => {
243
+ const [registryId, ...rest] = itemKey.split(":");
244
+ const itemId = rest.join(":");
245
+ return publicRegistries
246
+ .find((r) => r.id === registryId)
247
+ ?.items.find((i) => i.id === itemId)?.deprecated;
248
+ });
249
+
250
+ try {
251
+ await analytics.track(options.cwd, {
252
+ event: "add",
253
+ properties: {
254
+ items_count: filteredItemKeys.length,
255
+ registries: Array.from(uniqueRegistries),
256
+ has_deprecated: hasDeprecated,
257
+ dependencies_count: npmDependenciesToAdd.size,
258
+ duration_ms: duration,
259
+ },
260
+ });
261
+ } catch (telemetryError) {
262
+ if (verbose) {
263
+ console.error("[Telemetry] add tracking failed:", telemetryError);
264
+ }
265
+ }
208
266
  } catch (error) {
209
- p.log.error(`추가에 실패했어요. ${error}`);
210
- p.outro(highlight("작업이 취소됐어요."));
267
+ if (isCliCancelError(error)) {
268
+ p.outro(highlight(error.message));
269
+ process.exit(0);
270
+ }
271
+
272
+ handleCliError(error, {
273
+ defaultMessage: "추가에 실패했어요.",
274
+ defaultHint: "`--verbose` 옵션으로 상세 오류를 확인해보세요.",
275
+ verbose,
276
+ });
211
277
  process.exit(1);
212
278
  }
213
-
214
- // add 성공 이벤트 추적
215
- const duration = Date.now() - startTime;
216
- const uniqueRegistries = new Set(registryItemsToAdd.map((r) => r.registryId));
217
- const hasDeprecated = selectedItemKeys.some((itemKey) => {
218
- const [registryId, ...rest] = itemKey.split(":");
219
- const itemId = rest.join(":");
220
- return publicRegistries.find((r) => r.id === registryId)?.items.find((i) => i.id === itemId)
221
- ?.deprecated;
222
- });
223
-
224
- await analytics.track(options.cwd, {
225
- event: "add",
226
- properties: {
227
- items_count: filteredItemKeys.length,
228
- registries: Array.from(uniqueRegistries),
229
- has_deprecated: hasDeprecated,
230
- dependencies_count: npmDependenciesToAdd.size,
231
- duration_ms: duration,
232
- },
233
- });
234
279
  });
235
280
  };
@@ -0,0 +1,281 @@
1
+ import { fetchAvailableRegistries, fetchRegistry } from "@/src/utils/fetch";
2
+ import { getRawConfig } from "@/src/utils/get-config";
3
+ import * as p from "@clack/prompts";
4
+ import path from "path";
5
+ import { z } from "zod";
6
+
7
+ import type { CAC } from "cac";
8
+ import { BASE_URL } from "../constants";
9
+ import { analytics } from "../utils/analytics";
10
+ import { highlight } from "../utils/color";
11
+ import {
12
+ analyzeRegistryItemCompatibility,
13
+ findInstalledSnippetItemKeys,
14
+ getProjectSeedPackageVersionSpecs,
15
+ logCompatibilityReport,
16
+ } from "../utils/compatibility";
17
+ import { CliError, handleCliError, isCliCancelError, isVerboseMode } from "../utils/error";
18
+
19
+ const compatOptionsSchema = z.object({
20
+ itemIds: z.array(z.string()).optional(),
21
+ component: z.union([z.string(), z.array(z.string())]).optional(),
22
+ all: z.boolean(),
23
+ registry: z.string().optional(),
24
+ cwd: z.string(),
25
+ baseUrl: z.string().optional(),
26
+ });
27
+
28
+ function parseTargetInputs({
29
+ itemIds,
30
+ component,
31
+ }: {
32
+ itemIds?: string[];
33
+ component?: string | string[];
34
+ }) {
35
+ const normalizeInput = (value: string) => value.trim().replace(/\s+/g, "-");
36
+ const itemInputs = (itemIds ?? []).map(normalizeInput).filter(Boolean);
37
+ const componentInputs = (Array.isArray(component) ? component : [component])
38
+ .filter((value): value is string => !!value)
39
+ .flatMap((value) => value.split(","))
40
+ .map(normalizeInput)
41
+ .filter(Boolean);
42
+
43
+ return Array.from(new Set([...itemInputs, ...componentInputs]));
44
+ }
45
+
46
+ function resolveExplicitItemKeys({
47
+ publicRegistries,
48
+ targetInputs,
49
+ defaultRegistry,
50
+ }: {
51
+ publicRegistries: Array<{ id?: string; items?: Array<{ id?: string }> }>;
52
+ targetInputs: string[];
53
+ defaultRegistry?: string;
54
+ }) {
55
+ const allItemKeys = publicRegistries
56
+ .filter((registry): registry is { id: string; items: Array<{ id: string }> } => {
57
+ return typeof registry.id === "string" && Array.isArray(registry.items);
58
+ })
59
+ .flatMap((registry) =>
60
+ registry.items
61
+ .filter((item): item is { id: string } => typeof item.id === "string")
62
+ .map((item) => `${registry.id}:${item.id}`),
63
+ );
64
+ const result = new Set<string>();
65
+
66
+ for (const input of targetInputs) {
67
+ const itemKey = input.includes(":")
68
+ ? input
69
+ : defaultRegistry
70
+ ? `${defaultRegistry}:${input}`
71
+ : (() => {
72
+ const matchedItemKeys = allItemKeys.filter((itemKey) => itemKey.endsWith(`:${input}`));
73
+ if (!matchedItemKeys.length) {
74
+ throw new CliError({
75
+ message: `${highlight(input)}: 항목을 찾을 수 없어요.`,
76
+ hint: `${highlight("ui:action-button")}처럼 registry를 포함해서 입력해보세요.`,
77
+ });
78
+ }
79
+
80
+ if (matchedItemKeys.length > 1) {
81
+ throw new CliError({
82
+ message: `${highlight(input)}: 같은 이름의 항목이 여러 레지스트리에 있어요.`,
83
+ details: matchedItemKeys.map((itemKey) => `- ${itemKey}`),
84
+ hint: `${highlight("ui:action-button")}처럼 registry를 포함해서 입력해보세요.`,
85
+ });
86
+ }
87
+
88
+ return matchedItemKeys[0];
89
+ })();
90
+
91
+ if (!allItemKeys.includes(itemKey)) {
92
+ throw new CliError({
93
+ message: `${highlight(itemKey)}: 항목을 찾을 수 없어요.`,
94
+ });
95
+ }
96
+
97
+ result.add(itemKey);
98
+ }
99
+
100
+ return Array.from(result);
101
+ }
102
+
103
+ export const compatCommand = (cli: CAC) => {
104
+ cli
105
+ .command("compat [...item-ids]", "check snippet compatibility")
106
+ .option("-c, --component <component>", "검사할 컴포넌트. 여러 번 또는 쉼표로 지정 가능")
107
+ .option("-a, --all", "모든 registry 항목을 검사", {
108
+ default: false,
109
+ })
110
+ .option("-r, --registry <registryId>", "컴포넌트 shorthand 입력 시 기본 registry")
111
+ .option("--cwd <cwd>", "the working directory. defaults to the current directory.", {
112
+ default: process.cwd(),
113
+ })
114
+ .option(
115
+ "-u, --baseUrl <baseUrl>",
116
+ "the base url of the registry. defaults to the current directory.",
117
+ { default: BASE_URL },
118
+ )
119
+ .example("seed-design compat")
120
+ .example("seed-design compat -c action-button")
121
+ .example("seed-design compat ui:action-button ui:alert-dialog")
122
+ .example("seed-design compat --all")
123
+ .action(async (itemIds, opts) => {
124
+ const startTime = Date.now();
125
+ const verbose = isVerboseMode(opts);
126
+ p.intro("seed-design compat");
127
+
128
+ try {
129
+ const parsed = compatOptionsSchema.safeParse({ itemIds, ...opts });
130
+ if (!parsed.success) {
131
+ throw parsed.error;
132
+ }
133
+
134
+ const { data: options } = parsed;
135
+ const { start, stop } = p.spinner();
136
+
137
+ start("Registry를 가져오고 있어요...");
138
+ const publicRegistries = await (async () => {
139
+ try {
140
+ const registries = await Promise.all(
141
+ (await fetchAvailableRegistries({ baseUrl: options.baseUrl })).map(async ({ id }) =>
142
+ fetchRegistry({ baseUrl: options.baseUrl, registryId: id }),
143
+ ),
144
+ );
145
+ stop("Registry를 가져왔어요.");
146
+ return registries;
147
+ } catch (error) {
148
+ stop("Registry를 가져오지 못했어요.");
149
+ throw error;
150
+ }
151
+ })();
152
+
153
+ const targetInputs = parseTargetInputs({
154
+ itemIds: options.itemIds,
155
+ component: options.component,
156
+ });
157
+
158
+ const targetItemKeys = (() => {
159
+ if (options.all) {
160
+ return publicRegistries.flatMap((registry) =>
161
+ registry.items.map((item) => `${registry.id}:${item.id}`),
162
+ );
163
+ }
164
+
165
+ if (targetInputs.length > 0) {
166
+ return resolveExplicitItemKeys({
167
+ publicRegistries,
168
+ targetInputs,
169
+ defaultRegistry: options.registry,
170
+ });
171
+ }
172
+
173
+ const rawConfigPromise = getRawConfig(options.cwd);
174
+ return rawConfigPromise;
175
+ })();
176
+
177
+ const resolvedTargetItemKeys = Array.isArray(targetItemKeys)
178
+ ? targetItemKeys
179
+ : await (async () => {
180
+ const rawConfig = await targetItemKeys;
181
+ if (!rawConfig) {
182
+ throw new CliError({
183
+ message: "seed-design.json 파일이 없어 설치된 스니펫 경로를 알 수 없어요.",
184
+ hint: "`seed-design init`으로 설정을 만든 뒤 실행하거나, `--all`/`-c`로 검사 대상을 직접 지정해주세요.",
185
+ });
186
+ }
187
+
188
+ const rootPath = path.resolve(options.cwd, rawConfig.path);
189
+ const installedItemKeys = findInstalledSnippetItemKeys({
190
+ publicRegistries,
191
+ rootPath,
192
+ });
193
+
194
+ if (!installedItemKeys.length) {
195
+ p.log.info(
196
+ `${highlight(path.relative(options.cwd, rootPath) || rawConfig.path)}에서 설치된 스니펫을 찾지 못했어요.`,
197
+ );
198
+ return [];
199
+ }
200
+
201
+ return installedItemKeys;
202
+ })();
203
+
204
+ if (!resolvedTargetItemKeys.length) {
205
+ p.outro("검사할 스니펫이 없어요.");
206
+ process.exit(0);
207
+ }
208
+
209
+ const projectPackageVersions = getProjectSeedPackageVersionSpecs(options.cwd);
210
+ const compatibilityReport = analyzeRegistryItemCompatibility({
211
+ publicRegistries,
212
+ itemKeys: resolvedTargetItemKeys,
213
+ projectPackageVersions,
214
+ });
215
+
216
+ p.log.info(`검사 대상: ${highlight(compatibilityReport.checkedItemKeys.join(", "))}`);
217
+
218
+ if (!compatibilityReport.issues.length) {
219
+ p.outro("모든 스니펫이 현재 @seed-design/react, @seed-design/css와 호환돼요.");
220
+
221
+ try {
222
+ await analytics.track(options.cwd, {
223
+ event: "compat",
224
+ properties: {
225
+ checked_items_count: compatibilityReport.checkedItemKeys.length,
226
+ incompatible_items_count: 0,
227
+ duration_ms: Date.now() - startTime,
228
+ },
229
+ });
230
+ } catch (telemetryError) {
231
+ if (verbose) {
232
+ console.error("[Telemetry] compat tracking failed:", telemetryError);
233
+ }
234
+ }
235
+
236
+ process.exit(0);
237
+ }
238
+
239
+ logCompatibilityReport({
240
+ report: compatibilityReport,
241
+ title: "현재 프로젝트 버전과 호환되지 않는 스니펫을 찾았어요.",
242
+ });
243
+ p.log.info(
244
+ "필요한 버전으로 @seed-design/react 또는 @seed-design/css를 맞춘 뒤 다시 실행해보세요.",
245
+ );
246
+ p.outro("호환성 이슈가 있어요.");
247
+
248
+ try {
249
+ await analytics.track(options.cwd, {
250
+ event: "compat",
251
+ properties: {
252
+ checked_items_count: compatibilityReport.checkedItemKeys.length,
253
+ incompatible_items_count: new Set(
254
+ compatibilityReport.issues.map((issue) => issue.itemKey),
255
+ ).size,
256
+ issue_count: compatibilityReport.issues.length,
257
+ duration_ms: Date.now() - startTime,
258
+ },
259
+ });
260
+ } catch (telemetryError) {
261
+ if (verbose) {
262
+ console.error("[Telemetry] compat tracking failed:", telemetryError);
263
+ }
264
+ }
265
+
266
+ process.exit(1);
267
+ } catch (error) {
268
+ if (isCliCancelError(error)) {
269
+ p.outro(highlight(error.message));
270
+ process.exit(0);
271
+ }
272
+
273
+ handleCliError(error, {
274
+ defaultMessage: "호환성 검사에 실패했어요.",
275
+ defaultHint: "`--verbose` 옵션으로 상세 오류를 확인해보세요.",
276
+ verbose,
277
+ });
278
+ process.exit(1);
279
+ }
280
+ });
281
+ };