@seed-design/cli 1.2.1 → 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.
@@ -0,0 +1,312 @@
1
+ import { fetchDocsIndex } from "@/src/utils/fetch";
2
+ import * as p from "@clack/prompts";
3
+ import type { CAC } from "cac";
4
+ import { z } from "zod";
5
+ import { BASE_URL } from "../constants";
6
+ import { analytics } from "../utils/analytics";
7
+ import { highlight } from "../utils/color";
8
+ import {
9
+ CliCancelError,
10
+ CliError,
11
+ handleCliError,
12
+ isCliCancelError,
13
+ isVerboseMode,
14
+ } from "../utils/error";
15
+ import type { DocsCategory, DocsItem, DocsSection } from "../schema";
16
+
17
+ const GITHUB_SNIPPET_BASE =
18
+ "https://raw.githubusercontent.com/daangn/seed-design/refs/heads/dev/docs/registry";
19
+
20
+ const docsOptionsSchema = z.object({
21
+ query: z.string().optional(),
22
+ baseUrl: z.string().optional(),
23
+ });
24
+
25
+ function buildSnippetUrl(registryId: string, snippetPath: string): string {
26
+ return `${GITHUB_SNIPPET_BASE}/${registryId}/${snippetPath}`;
27
+ }
28
+
29
+ function printDocsResult(item: DocsItem, baseUrl: string) {
30
+ const docLink = `${baseUrl}${item.docUrl}`;
31
+ const llmsLink = `${baseUrl}/llms${item.docUrl}.txt`;
32
+
33
+ const lines = [item.id, `- docs: ${docLink}`, `- llms.txt: ${llmsLink}`];
34
+
35
+ if (item.snippetKey && item.snippets && item.snippets.length > 0) {
36
+ const [registryId] = item.snippetKey.split(":");
37
+ if (registryId === "ui" || registryId === "breeze") {
38
+ if (item.snippets.length === 1) {
39
+ lines.push(`- snippet: ${buildSnippetUrl(registryId, item.snippets[0].path)}`);
40
+ } else {
41
+ lines.push("- snippet:");
42
+ for (const snippet of item.snippets) {
43
+ lines.push(` - ${snippet.label}: ${buildSnippetUrl(registryId, snippet.path)}`);
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+ p.log.message(lines.join("\n"));
50
+ }
51
+
52
+ /**
53
+ * Parse a path-style query into segments.
54
+ * e.g. "react/components/action-button" → ["react", "components", "action-button"]
55
+ */
56
+ function parseQueryPath(query: string): string[] {
57
+ return query
58
+ .split("/")
59
+ .map((s) => s.trim())
60
+ .filter(Boolean);
61
+ }
62
+
63
+ /**
64
+ * Search all items across all categories/sections.
65
+ */
66
+ function searchAllItems(
67
+ categories: DocsCategory[],
68
+ query: string,
69
+ ): { item: DocsItem; categoryLabel: string; sectionLabel: string }[] {
70
+ const q = query.toLowerCase();
71
+ return categories.flatMap((cat) =>
72
+ cat.sections.flatMap((sec) =>
73
+ sec.items
74
+ .filter((item) => item.id.toLowerCase().includes(q) || item.title.toLowerCase().includes(q))
75
+ .map((item) => ({
76
+ item,
77
+ categoryLabel: cat.label,
78
+ sectionLabel: sec.label,
79
+ })),
80
+ ),
81
+ );
82
+ }
83
+
84
+ async function selectItem(items: DocsItem[]): Promise<DocsItem> {
85
+ const selected = await p.select({
86
+ message: "항목을 선택해주세요",
87
+ options: items.map((item) => ({
88
+ label: `${item.deprecated ? "(deprecated) " : ""}${item.title}`,
89
+ value: item,
90
+ hint: item.description,
91
+ })),
92
+ });
93
+ if (p.isCancel(selected)) throw new CliCancelError();
94
+ return selected;
95
+ }
96
+
97
+ async function selectSection(sections: DocsSection[]): Promise<DocsSection> {
98
+ const selected = await p.select({
99
+ message: "섹션을 선택해주세요",
100
+ options: sections.map((sec) => ({
101
+ label: sec.label,
102
+ value: sec,
103
+ hint: `${sec.items.length}개 항목`,
104
+ })),
105
+ });
106
+ if (p.isCancel(selected)) throw new CliCancelError();
107
+ return selected;
108
+ }
109
+
110
+ async function selectCategory(categories: DocsCategory[]): Promise<DocsCategory> {
111
+ const selected = await p.select({
112
+ message: "카테고리를 선택해주세요",
113
+ options: categories.map((cat) => ({
114
+ label: cat.label,
115
+ value: cat,
116
+ hint: `${cat.sections.reduce((sum, s) => sum + s.items.length, 0)}개 항목`,
117
+ })),
118
+ });
119
+ if (p.isCancel(selected)) throw new CliCancelError();
120
+ return selected;
121
+ }
122
+
123
+ export const docsCommand = (cli: CAC) => {
124
+ cli
125
+ .command("docs [query]", "문서 링크, llms.txt 링크, 스니펫 링크를 조회합니다")
126
+ .option("-u, --baseUrl <baseUrl>", `레지스트리의 기본 URL (기본값: ${BASE_URL})`, {
127
+ default: BASE_URL,
128
+ })
129
+ .example("seed-design docs")
130
+ .example("seed-design docs action-button")
131
+ .example("seed-design docs react")
132
+ .example("seed-design docs react/components")
133
+ .example("seed-design docs react/components/action-button")
134
+ .action(async (query, opts) => {
135
+ const startTime = Date.now();
136
+ const verbose = isVerboseMode(opts);
137
+ p.intro("seed-design docs");
138
+
139
+ try {
140
+ const parsed = docsOptionsSchema.safeParse({ query, ...opts });
141
+ if (!parsed.success) {
142
+ throw parsed.error;
143
+ }
144
+
145
+ const { data: options } = parsed;
146
+ const baseUrl = options.baseUrl ?? BASE_URL;
147
+
148
+ const { start, stop } = p.spinner();
149
+ start("문서 목록을 가져오고 있어요...");
150
+
151
+ const docsIndex = await (async () => {
152
+ try {
153
+ const index = await fetchDocsIndex({ baseUrl });
154
+ stop("문서 목록을 가져왔어요.");
155
+ return index;
156
+ } catch (error) {
157
+ stop("문서 목록을 가져오지 못했어요.");
158
+ throw error;
159
+ }
160
+ })();
161
+
162
+ const { categories } = docsIndex;
163
+ let selectedItem: DocsItem;
164
+
165
+ if (options.query) {
166
+ const segments = parseQueryPath(options.query);
167
+
168
+ // Try to resolve as path: category / section / item
169
+ const matchedCategory = categories.find((c) => c.id === segments[0]);
170
+
171
+ if (matchedCategory && segments.length >= 2) {
172
+ const matchedSection = matchedCategory.sections.find((s) => s.id === segments[1]);
173
+
174
+ if (matchedSection && segments.length >= 3) {
175
+ // Full path: category/section/item
176
+ const matchedItem = matchedSection.items.find((i) => i.id === segments[2]);
177
+ if (matchedItem) {
178
+ selectedItem = matchedItem;
179
+ } else {
180
+ // Item not found in section — search within the section
181
+ const q = segments[2].toLowerCase();
182
+ const matched = matchedSection.items.filter(
183
+ (i) => i.id.toLowerCase().includes(q) || i.title.toLowerCase().includes(q),
184
+ );
185
+ if (matched.length === 0) {
186
+ throw new CliError({
187
+ message: `${highlight(options.query)}: 문서를 찾을 수 없어요.`,
188
+ hint: `\`seed-design docs ${matchedCategory.id}/${matchedSection.id}\`로 목록을 확인해보세요.`,
189
+ });
190
+ }
191
+ if (matched.length === 1) {
192
+ selectedItem = matched[0];
193
+ } else {
194
+ selectedItem = await selectItem(matched);
195
+ }
196
+ }
197
+ } else if (matchedSection) {
198
+ // category/section — select item within section
199
+ selectedItem = await selectItem(matchedSection.items);
200
+ } else {
201
+ // category/??? — search within category
202
+ const q = segments[1].toLowerCase();
203
+ const matched = matchedCategory.sections.flatMap((s) =>
204
+ s.items
205
+ .filter(
206
+ (i) => i.id.toLowerCase().includes(q) || i.title.toLowerCase().includes(q),
207
+ )
208
+ .map((item) => ({ item, sectionLabel: s.label })),
209
+ );
210
+
211
+ if (matched.length === 0) {
212
+ throw new CliError({
213
+ message: `${highlight(options.query)}: 문서를 찾을 수 없어요.`,
214
+ hint: `\`seed-design docs ${matchedCategory.id}\`로 목록을 확인해보세요.`,
215
+ });
216
+ }
217
+ if (matched.length === 1) {
218
+ selectedItem = matched[0].item;
219
+ } else {
220
+ const selected = await p.select({
221
+ message: `${highlight(segments[1])}에 해당하는 항목을 선택해주세요`,
222
+ options: matched.map(({ item, sectionLabel }) => ({
223
+ label: `[${sectionLabel}] ${item.title}`,
224
+ value: item,
225
+ hint: item.description,
226
+ })),
227
+ });
228
+ if (p.isCancel(selected)) throw new CliCancelError();
229
+ selectedItem = selected;
230
+ }
231
+ }
232
+ } else if (matchedCategory) {
233
+ // Single segment matching a category — drill into it
234
+ if (matchedCategory.sections.length === 1) {
235
+ selectedItem = await selectItem(matchedCategory.sections[0].items);
236
+ } else {
237
+ const section = await selectSection(matchedCategory.sections);
238
+ selectedItem = await selectItem(section.items);
239
+ }
240
+ } else {
241
+ // No category match — global search
242
+ const matched = searchAllItems(categories, options.query);
243
+
244
+ if (matched.length === 0) {
245
+ throw new CliError({
246
+ message: `${highlight(options.query)}: 문서를 찾을 수 없어요.`,
247
+ hint: "`seed-design docs`로 전체 목록을 확인해보세요.",
248
+ });
249
+ }
250
+ if (matched.length === 1) {
251
+ selectedItem = matched[0].item;
252
+ } else {
253
+ const selected = await p.select({
254
+ message: `${highlight(options.query)}에 해당하는 항목을 선택해주세요`,
255
+ options: matched.map(({ item, categoryLabel, sectionLabel }) => ({
256
+ label: `[${categoryLabel} > ${sectionLabel}] ${item.title}`,
257
+ value: item,
258
+ hint: item.description,
259
+ })),
260
+ });
261
+ if (p.isCancel(selected)) throw new CliCancelError();
262
+ selectedItem = selected;
263
+ }
264
+ }
265
+ } else {
266
+ // Full interactive flow: category → section → item
267
+ const category = await selectCategory(categories);
268
+
269
+ let section: DocsSection;
270
+ if (category.sections.length === 1) {
271
+ section = category.sections[0];
272
+ } else {
273
+ section = await selectSection(category.sections);
274
+ }
275
+
276
+ selectedItem = await selectItem(section.items);
277
+ }
278
+
279
+ printDocsResult(selectedItem, baseUrl);
280
+ p.outro("완료했어요.");
281
+
282
+ const duration = Date.now() - startTime;
283
+ try {
284
+ await analytics.track(process.cwd(), {
285
+ event: "docs",
286
+ properties: {
287
+ query: options.query ?? null,
288
+ item_id: selectedItem.id,
289
+ has_snippet: !!(selectedItem.snippets && selectedItem.snippets.length > 0),
290
+ duration_ms: duration,
291
+ },
292
+ });
293
+ } catch (telemetryError) {
294
+ if (verbose) {
295
+ console.error("[Telemetry] docs tracking failed:", telemetryError);
296
+ }
297
+ }
298
+ } catch (error) {
299
+ if (isCliCancelError(error)) {
300
+ p.outro(highlight(error.message));
301
+ process.exit(0);
302
+ }
303
+
304
+ handleCliError(error, {
305
+ defaultMessage: "문서 조회에 실패했어요.",
306
+ defaultHint: "`--verbose` 옵션으로 상세 오류를 확인해보세요.",
307
+ verbose,
308
+ });
309
+ process.exit(1);
310
+ }
311
+ });
312
+ };
@@ -1,9 +1,9 @@
1
1
  import * as p from "@clack/prompts";
2
- import fs from "fs-extra";
3
- import path from "path";
4
2
  import { z } from "zod";
5
3
  import { analytics } from "../utils/analytics";
6
4
  import { highlight } from "../utils/color";
5
+ import { handleCliError, isCliCancelError, isVerboseMode } from "../utils/error";
6
+ import { DEFAULT_INIT_CONFIG, promptInitConfig, writeInitConfigFile } from "../utils/init-config";
7
7
 
8
8
  import type { Config } from "@/src/utils/get-config";
9
9
 
@@ -13,6 +13,7 @@ import dedent from "dedent";
13
13
  const initOptionsSchema = z.object({
14
14
  cwd: z.string(),
15
15
  yes: z.boolean().optional(),
16
+ default: z.boolean().optional(),
16
17
  });
17
18
 
18
19
  export const initCommand = (cli: CAC) => {
@@ -22,64 +23,38 @@ export const initCommand = (cli: CAC) => {
22
23
  default: process.cwd(),
23
24
  })
24
25
  .option("-y, --yes", "모든 질문에 대해 기본값으로 답변합니다.")
26
+ .option("--default", "Deprecated. --yes와 동일하게 기본값으로 생성합니다.")
25
27
  .action(async (opts) => {
26
28
  const startTime = Date.now();
29
+ const verbose = isVerboseMode(opts);
27
30
  p.intro("seed-design.json 파일 생성");
28
31
 
29
- const options = initOptionsSchema.parse(opts);
30
- const isYesOption = options.yes;
31
- let config: Config = {
32
- rsc: false,
33
- tsx: true,
34
- path: "./seed-design",
35
- telemetry: true,
36
- };
37
-
38
- if (!isYesOption) {
39
- const group = await p.group(
40
- {
41
- tsx: () =>
42
- p.confirm({
43
- message: `${highlight("TypeScript")}를 사용중이신가요?`,
44
- initialValue: true,
45
- }),
46
- rsc: () =>
47
- p.confirm({
48
- message: `${highlight("React Server Components")}를 사용중이신가요?`,
49
- initialValue: false,
50
- }),
51
- path: () =>
52
- p.text({
53
- message: `${highlight("seed-design 폴더")} 경로를 입력해주세요. (기본값은 프로젝트 루트에 생성됩니다.)`,
54
- initialValue: "./seed-design",
55
- defaultValue: "./seed-design",
56
- placeholder: "./seed-design",
57
- }),
58
- telemetry: () =>
59
- p.confirm({
60
- message: `개선을 위해 ${highlight("익명 사용 데이터")}를 수집할까요?`,
61
- initialValue: true,
62
- }),
63
- },
64
- {
65
- onCancel: () => {
66
- p.cancel("작업이 취소됐어요.");
67
- process.exit(0);
68
- },
69
- },
70
- );
32
+ try {
33
+ const parsed = initOptionsSchema.safeParse(opts);
34
+ if (!parsed.success) {
35
+ throw parsed.error;
36
+ }
71
37
 
72
- config = {
73
- ...group,
74
- };
75
- }
38
+ const options = parsed.data;
39
+ const isDefaultMode = options.yes || options.default;
40
+ const config: Config = isDefaultMode ? DEFAULT_INIT_CONFIG : await promptInitConfig();
76
41
 
77
- try {
78
42
  const { start, stop } = p.spinner();
79
43
  start("seed-design.json 파일 생성중...");
80
- const targetPath = path.resolve(options.cwd, "seed-design.json");
81
- await fs.writeFile(targetPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
82
- const relativePath = path.relative(process.cwd(), targetPath);
44
+ const relativePath = await (async () => {
45
+ try {
46
+ const result = await writeInitConfigFile({
47
+ cwd: options.cwd,
48
+ config,
49
+ });
50
+
51
+ return result.relativePath;
52
+ } catch (error) {
53
+ stop("seed-design.json 파일 생성이 중단됐어요.");
54
+ throw error;
55
+ }
56
+ })();
57
+
83
58
  stop(`seed-design.json 파일이 ${highlight(relativePath)}에 생성됐어요.`);
84
59
 
85
60
  p.log.info(highlight("seed-design add {component} 명령어로 컴포넌트를 추가해보세요!"));
@@ -99,23 +74,37 @@ export const initCommand = (cli: CAC) => {
99
74
  );
100
75
 
101
76
  p.outro("작업이 완료됐어요.");
77
+
78
+ // init 성공 이벤트 추적
79
+ const duration = Date.now() - startTime;
80
+ try {
81
+ await analytics.track(options.cwd, {
82
+ event: "init",
83
+ properties: {
84
+ tsx: config.tsx,
85
+ rsc: config.rsc,
86
+ telemetry: config.telemetry,
87
+ yes_option: isDefaultMode,
88
+ duration_ms: duration,
89
+ },
90
+ });
91
+ } catch (telemetryError) {
92
+ if (verbose) {
93
+ console.error("[Telemetry] init tracking failed:", telemetryError);
94
+ }
95
+ }
102
96
  } catch (error) {
103
- p.log.error(`seed-design.json 파일 생성에 실패했어요. ${error}`);
104
- p.outro(highlight("작업이 취소됐어요."));
97
+ if (isCliCancelError(error)) {
98
+ p.outro(highlight(error.message));
99
+ process.exit(0);
100
+ }
101
+
102
+ handleCliError(error, {
103
+ defaultMessage: "seed-design.json 파일 생성에 실패했어요.",
104
+ defaultHint: "`--verbose` 옵션으로 상세 오류를 확인해보세요.",
105
+ verbose,
106
+ });
105
107
  process.exit(1);
106
108
  }
107
-
108
- // init 성공 이벤트 추적
109
- const duration = Date.now() - startTime;
110
- await analytics.track(options.cwd, {
111
- event: "init",
112
- properties: {
113
- tsx: config.tsx,
114
- rsc: config.rsc,
115
- telemetry: config.telemetry,
116
- yes_option: isYesOption,
117
- duration_ms: duration,
118
- },
119
- });
120
109
  });
121
110
  };
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { addCommand } from "@/src/commands/add";
4
4
  import { addAllCommand } from "@/src/commands/add-all";
5
+ import { compatCommand } from "@/src/commands/compat";
6
+ import { docsCommand } from "@/src/commands/docs";
5
7
  import { initCommand } from "@/src/commands/init";
6
8
  import { getPackageInfo } from "@/src/utils/get-package-info";
7
9
  import { cac } from "cac";
@@ -12,9 +14,13 @@ const CLI = cac(NAME);
12
14
  async function main() {
13
15
  const packageInfo = getPackageInfo();
14
16
 
17
+ CLI.option("--verbose", "오류 상세 정보를 출력합니다.");
18
+
15
19
  /* Commands */
16
20
  addCommand(CLI);
17
21
  addAllCommand(CLI);
22
+ compatCommand(CLI);
23
+ docsCommand(CLI);
18
24
  initCommand(CLI);
19
25
 
20
26
  CLI.version(packageInfo.version || "1.0.0", "-v, --version");
package/src/schema.ts CHANGED
@@ -27,7 +27,13 @@ export const publicRegistryItemSchema = z.object({
27
27
 
28
28
  ///////////////////////////////////////////////////////////////
29
29
 
30
- snippets: z.array(z.object({ path: z.string(), content: z.string() })),
30
+ snippets: z.array(
31
+ z.object({
32
+ path: z.string(),
33
+ dependencies: z.record(z.string(), z.string()).optional(),
34
+ content: z.string(),
35
+ }),
36
+ ),
31
37
  });
32
38
 
33
39
  /**
@@ -39,9 +45,14 @@ export const publicRegistrySchema = z.object({
39
45
  hideFromCLICatalog: z.boolean().optional(),
40
46
 
41
47
  items: z.array(
42
- publicRegistryItemSchema
43
- .omit({ snippets: true })
44
- .extend({ snippets: z.array(z.object({ path: z.string() })) }),
48
+ publicRegistryItemSchema.omit({ snippets: true }).extend({
49
+ snippets: z.array(
50
+ z.object({
51
+ path: z.string(),
52
+ dependencies: z.record(z.string(), z.string()).optional(),
53
+ }),
54
+ ),
55
+ }),
45
56
  ),
46
57
  });
47
58
 
@@ -53,3 +64,41 @@ export const publicAvailableRegistriesSchema = z.array(z.object({ id: z.string()
53
64
  export type PublicRegistryItem = z.infer<typeof publicRegistryItemSchema>;
54
65
  export type PublicRegistry = z.infer<typeof publicRegistrySchema>;
55
66
  export type PublicAvailableRegistries = z.infer<typeof publicAvailableRegistriesSchema>;
67
+
68
+ ///////////////////////////////////////////////////////////////
69
+
70
+ export const docsSnippetSchema = z.object({
71
+ label: z.string(),
72
+ path: z.string(),
73
+ });
74
+
75
+ export const docsItemSchema = z.object({
76
+ id: z.string(),
77
+ title: z.string(),
78
+ description: z.string().optional(),
79
+ docUrl: z.string(),
80
+ deprecated: z.boolean().optional(),
81
+ snippetKey: z.string().optional(),
82
+ snippets: z.array(docsSnippetSchema).optional(),
83
+ });
84
+
85
+ export const docsSectionSchema = z.object({
86
+ id: z.string(),
87
+ label: z.string(),
88
+ items: z.array(docsItemSchema),
89
+ });
90
+
91
+ export const docsCategorySchema = z.object({
92
+ id: z.string(),
93
+ label: z.string(),
94
+ sections: z.array(docsSectionSchema),
95
+ });
96
+
97
+ export const docsIndexSchema = z.object({
98
+ categories: z.array(docsCategorySchema),
99
+ });
100
+
101
+ export type DocsItem = z.infer<typeof docsItemSchema>;
102
+ export type DocsSection = z.infer<typeof docsSectionSchema>;
103
+ export type DocsCategory = z.infer<typeof docsCategorySchema>;
104
+ export type DocsIndex = z.infer<typeof docsIndexSchema>;