@seed-design/cli 1.4.0-alpha.0 → 1.4.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/index.mjs +25 -12
- package/package.json +6 -4
- package/src/commands/add-all.ts +33 -4
- package/src/commands/add.ts +33 -4
- package/src/commands/compat.ts +53 -7
- package/src/commands/docs.ts +353 -79
- package/src/commands/init.ts +32 -3
- package/src/index.ts +1 -0
- package/src/tests/analytics.test.ts +95 -0
- package/src/tests/command-telemetry.test.ts +195 -0
- package/src/tests/docs-command.test.ts +150 -0
- package/src/types/shims.d.ts +11 -0
- package/src/utils/analytics.ts +65 -1
- package/src/utils/compatibility.ts +0 -3
- package/src/utils/fetch.ts +92 -0
- package/src/utils/install.ts +1 -1
- package/src/utils/resolve-dependencies.ts +2 -2
package/src/commands/docs.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { fetchDocsIndex } from "@/src/utils/fetch";
|
|
1
|
+
import { fetchDocsIndex, fetchLlmsTxt, tryFetchLlmsTxt } from "@/src/utils/fetch";
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
3
|
import type { CAC } from "cac";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { BASE_URL } from "../constants";
|
|
6
6
|
import { analytics } from "../utils/analytics";
|
|
7
7
|
import { highlight } from "../utils/color";
|
|
8
|
+
import { getRawConfig } from "../utils/get-config";
|
|
8
9
|
import {
|
|
9
10
|
CliCancelError,
|
|
10
11
|
CliError,
|
|
@@ -18,12 +19,22 @@ const GITHUB_SNIPPET_BASE =
|
|
|
18
19
|
"https://raw.githubusercontent.com/daangn/seed-design/refs/heads/dev/docs/registry";
|
|
19
20
|
|
|
20
21
|
const docsOptionsSchema = z.object({
|
|
21
|
-
query: z
|
|
22
|
+
query: z
|
|
23
|
+
.union([z.string(), z.array(z.string())])
|
|
24
|
+
.optional()
|
|
25
|
+
.transform((query) => {
|
|
26
|
+
const normalized = Array.isArray(query) ? query.join(" ") : query;
|
|
27
|
+
const trimmed = normalized?.trim();
|
|
28
|
+
return trimmed ? trimmed : undefined;
|
|
29
|
+
}),
|
|
22
30
|
baseUrl: z.string().optional(),
|
|
31
|
+
cwd: z.string().default(process.cwd()),
|
|
32
|
+
framework: z.enum(["react", "lynx"]).optional(),
|
|
33
|
+
raw: z.boolean(),
|
|
23
34
|
});
|
|
24
35
|
|
|
25
|
-
function buildSnippetUrl(
|
|
26
|
-
return `${GITHUB_SNIPPET_BASE}/${
|
|
36
|
+
function buildSnippetUrl(registryPath: string, snippetPath: string): string {
|
|
37
|
+
return `${GITHUB_SNIPPET_BASE}/${registryPath}/${snippetPath}`;
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
function printDocsResult(item: DocsItem, baseUrl: string) {
|
|
@@ -33,14 +44,14 @@ function printDocsResult(item: DocsItem, baseUrl: string) {
|
|
|
33
44
|
const lines = [item.id, `- docs: ${docLink}`, `- llms.txt: ${llmsLink}`];
|
|
34
45
|
|
|
35
46
|
if (item.snippetKey && item.snippets && item.snippets.length > 0) {
|
|
36
|
-
const [
|
|
37
|
-
if (
|
|
47
|
+
const [registryPath] = item.snippetKey.split(":");
|
|
48
|
+
if (registryPath) {
|
|
38
49
|
if (item.snippets.length === 1) {
|
|
39
|
-
lines.push(`- snippet: ${buildSnippetUrl(
|
|
50
|
+
lines.push(`- snippet: ${buildSnippetUrl(registryPath, item.snippets[0].path)}`);
|
|
40
51
|
} else {
|
|
41
52
|
lines.push("- snippet:");
|
|
42
53
|
for (const snippet of item.snippets) {
|
|
43
|
-
lines.push(` - ${snippet.label}: ${buildSnippetUrl(
|
|
54
|
+
lines.push(` - ${snippet.label}: ${buildSnippetUrl(registryPath, snippet.path)}`);
|
|
44
55
|
}
|
|
45
56
|
}
|
|
46
57
|
}
|
|
@@ -49,17 +60,152 @@ function printDocsResult(item: DocsItem, baseUrl: string) {
|
|
|
49
60
|
p.log.message(lines.join("\n"));
|
|
50
61
|
}
|
|
51
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Compute the Levenshtein (edit) distance between two strings.
|
|
65
|
+
* Used to suggest similar valid paths when users make typos.
|
|
66
|
+
*/
|
|
67
|
+
function levenshtein(a: string, b: string): number {
|
|
68
|
+
const m = a.length;
|
|
69
|
+
const n = b.length;
|
|
70
|
+
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
|
|
71
|
+
Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
|
72
|
+
);
|
|
73
|
+
for (let i = 1; i <= m; i++) {
|
|
74
|
+
for (let j = 1; j <= n; j++) {
|
|
75
|
+
dp[i][j] = Math.min(
|
|
76
|
+
dp[i - 1][j] + 1,
|
|
77
|
+
dp[i][j - 1] + 1,
|
|
78
|
+
dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return dp[m][n];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Find candidates similar to `input` within `maxDistance` edits, sorted by distance.
|
|
87
|
+
*/
|
|
88
|
+
function findSimilar(input: string, candidates: string[], maxDistance = 3): string[] {
|
|
89
|
+
const q = input.toLowerCase();
|
|
90
|
+
return candidates
|
|
91
|
+
.map((c) => ({ value: c, dist: levenshtein(q, c.toLowerCase()) }))
|
|
92
|
+
.filter(({ dist }) => dist > 0 && dist <= maxDistance)
|
|
93
|
+
.sort((a, b) => a.dist - b.dist)
|
|
94
|
+
.map(({ value }) => value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build a suggestion hint from a path query by fuzzy-matching each segment
|
|
99
|
+
* against the docs index hierarchy.
|
|
100
|
+
*/
|
|
101
|
+
function buildSuggestionHint(segments: string[], categories: DocsCategory[]): string | undefined {
|
|
102
|
+
if (segments.length === 0) return undefined;
|
|
103
|
+
|
|
104
|
+
const suggestions: string[] = [];
|
|
105
|
+
|
|
106
|
+
// Try to fuzzy-match the first segment against category IDs
|
|
107
|
+
const categoryIds = categories.map((c) => c.id);
|
|
108
|
+
const similarCategories = findSimilar(segments[0], categoryIds);
|
|
109
|
+
|
|
110
|
+
if (similarCategories.length === 0) {
|
|
111
|
+
// No similar category — try to find similar full paths across everything
|
|
112
|
+
const allPaths = categories.flatMap((cat) =>
|
|
113
|
+
cat.sections.flatMap((sec) => sec.items.map((item) => `${cat.id}/${sec.id}/${item.id}`)),
|
|
114
|
+
);
|
|
115
|
+
const fullQuery = segments.join("/");
|
|
116
|
+
const similarPaths = findSimilar(fullQuery, allPaths, 5);
|
|
117
|
+
if (similarPaths.length > 0) {
|
|
118
|
+
suggestions.push(...similarPaths.slice(0, 3));
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
const bestCat = categories.find((c) => c.id === similarCategories[0]);
|
|
122
|
+
if (bestCat && segments.length >= 2) {
|
|
123
|
+
const sectionIds = bestCat.sections.map((s) => s.id);
|
|
124
|
+
const similarSections = findSimilar(segments[1], sectionIds);
|
|
125
|
+
|
|
126
|
+
if (similarSections.length > 0) {
|
|
127
|
+
const bestSec = bestCat.sections.find((s) => s.id === similarSections[0]);
|
|
128
|
+
if (bestSec && segments.length >= 3) {
|
|
129
|
+
const itemIds = bestSec.items.map((i) => i.id);
|
|
130
|
+
const similarItems = findSimilar(segments[2], itemIds);
|
|
131
|
+
for (const item of similarItems.slice(0, 3)) {
|
|
132
|
+
suggestions.push(`${bestCat.id}/${bestSec.id}/${item}`);
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
for (const sec of similarSections.slice(0, 3)) {
|
|
136
|
+
suggestions.push(`${bestCat.id}/${sec}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
// Section not found, search items within category
|
|
141
|
+
const allItemIds = bestCat.sections.flatMap((s) =>
|
|
142
|
+
s.items.map((i) => ({ path: `${bestCat.id}/${s.id}/${i.id}`, id: i.id })),
|
|
143
|
+
);
|
|
144
|
+
const similarItems = findSimilar(
|
|
145
|
+
segments[1],
|
|
146
|
+
allItemIds.map((x) => x.id),
|
|
147
|
+
);
|
|
148
|
+
for (const itemId of similarItems.slice(0, 3)) {
|
|
149
|
+
const found = allItemIds.find((x) => x.id === itemId);
|
|
150
|
+
if (found) suggestions.push(found.path);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
for (const cat of similarCategories.slice(0, 3)) {
|
|
155
|
+
suggestions.push(cat);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (suggestions.length === 0) return undefined;
|
|
161
|
+
|
|
162
|
+
const lines = ["", "💡 이것을 의미했나요?"];
|
|
163
|
+
for (const s of suggestions) {
|
|
164
|
+
lines.push(` - ${s}`);
|
|
165
|
+
}
|
|
166
|
+
return lines.join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
52
169
|
/**
|
|
53
170
|
* Parse a path-style query into segments.
|
|
54
171
|
* e.g. "react/components/action-button" → ["react", "components", "action-button"]
|
|
55
172
|
*/
|
|
56
173
|
function parseQueryPath(query: string): string[] {
|
|
57
174
|
return query
|
|
58
|
-
.split(
|
|
175
|
+
.split(/[\/\s]+/)
|
|
59
176
|
.map((s) => s.trim())
|
|
60
177
|
.filter(Boolean);
|
|
61
178
|
}
|
|
62
179
|
|
|
180
|
+
function normalizeRegistryKeySegment(segment: string): string {
|
|
181
|
+
const [, itemId] = segment.split(":");
|
|
182
|
+
return itemId ?? segment;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeDocsQuery({
|
|
186
|
+
query,
|
|
187
|
+
framework,
|
|
188
|
+
categoryIds,
|
|
189
|
+
}: {
|
|
190
|
+
query?: string;
|
|
191
|
+
framework?: string;
|
|
192
|
+
categoryIds: string[];
|
|
193
|
+
}): string | undefined {
|
|
194
|
+
if (!query) return undefined;
|
|
195
|
+
|
|
196
|
+
const segments = parseQueryPath(query).map(normalizeRegistryKeySegment);
|
|
197
|
+
if (!segments.length) return undefined;
|
|
198
|
+
|
|
199
|
+
const [firstSegment] = segments;
|
|
200
|
+
const isCategoryQuery = categoryIds.includes(firstSegment);
|
|
201
|
+
|
|
202
|
+
if (!isCategoryQuery && framework && categoryIds.includes(framework)) {
|
|
203
|
+
return [framework, ...segments].join("/");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return segments.join("/");
|
|
207
|
+
}
|
|
208
|
+
|
|
63
209
|
/**
|
|
64
210
|
* Search all items across all categories/sections.
|
|
65
211
|
*/
|
|
@@ -122,19 +268,31 @@ async function selectCategory(categories: DocsCategory[]): Promise<DocsCategory>
|
|
|
122
268
|
|
|
123
269
|
export const docsCommand = (cli: CAC) => {
|
|
124
270
|
cli
|
|
125
|
-
.command("docs [query]", "문서 링크, llms.txt 링크, 스니펫 링크를 조회합니다")
|
|
271
|
+
.command("docs [...query]", "문서 링크, llms.txt 링크, 스니펫 링크를 조회합니다")
|
|
126
272
|
.option("-u, --baseUrl <baseUrl>", `레지스트리의 기본 URL (기본값: ${BASE_URL})`, {
|
|
127
273
|
default: BASE_URL,
|
|
128
274
|
})
|
|
275
|
+
.option("--cwd <cwd>", "the working directory. defaults to the current directory.", {
|
|
276
|
+
default: process.cwd(),
|
|
277
|
+
})
|
|
278
|
+
.option("-f, --framework <framework>", "프레임워크 (react 또는 lynx)")
|
|
279
|
+
.option("--raw", "llms.txt 내용을 직접 가져와 출력합니다. LLM 파이프에 유용합니다.", {
|
|
280
|
+
default: false,
|
|
281
|
+
})
|
|
129
282
|
.example("seed-design docs")
|
|
130
283
|
.example("seed-design docs action-button")
|
|
131
284
|
.example("seed-design docs react")
|
|
285
|
+
.example("seed-design docs lynx action-button")
|
|
132
286
|
.example("seed-design docs react/components")
|
|
133
287
|
.example("seed-design docs react/components/action-button")
|
|
288
|
+
.example("seed-design docs react/updates/changelog --raw")
|
|
134
289
|
.action(async (query, opts) => {
|
|
135
290
|
const startTime = Date.now();
|
|
136
291
|
const verbose = isVerboseMode(opts);
|
|
137
|
-
|
|
292
|
+
const raw = opts.raw ?? false;
|
|
293
|
+
let trackCwd = process.cwd();
|
|
294
|
+
|
|
295
|
+
if (!raw) p.intro("seed-design docs");
|
|
138
296
|
|
|
139
297
|
try {
|
|
140
298
|
const parsed = docsOptionsSchema.safeParse({ query, ...opts });
|
|
@@ -143,12 +301,24 @@ export const docsCommand = (cli: CAC) => {
|
|
|
143
301
|
}
|
|
144
302
|
|
|
145
303
|
const { data: options } = parsed;
|
|
304
|
+
trackCwd = options.cwd;
|
|
146
305
|
const baseUrl = options.baseUrl ?? BASE_URL;
|
|
306
|
+
const rawConfig = await getRawConfig(options.cwd).catch(() => null);
|
|
307
|
+
const framework = options.framework ?? rawConfig?.framework;
|
|
147
308
|
|
|
148
|
-
|
|
149
|
-
|
|
309
|
+
if (options.raw && !options.query) {
|
|
310
|
+
throw new CliError({
|
|
311
|
+
message: "--raw 모드에서는 쿼리가 필요해요.",
|
|
312
|
+
hint: "예: `seed-design docs react/updates/changelog --raw`",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
150
315
|
|
|
151
316
|
const docsIndex = await (async () => {
|
|
317
|
+
if (raw) {
|
|
318
|
+
return await fetchDocsIndex({ baseUrl });
|
|
319
|
+
}
|
|
320
|
+
const { start, stop } = p.spinner();
|
|
321
|
+
start("문서 목록을 가져오고 있어요...");
|
|
152
322
|
try {
|
|
153
323
|
const index = await fetchDocsIndex({ baseUrl });
|
|
154
324
|
stop("문서 목록을 가져왔어요.");
|
|
@@ -160,44 +330,67 @@ export const docsCommand = (cli: CAC) => {
|
|
|
160
330
|
})();
|
|
161
331
|
|
|
162
332
|
const { categories } = docsIndex;
|
|
163
|
-
|
|
333
|
+
const docsQuery = normalizeDocsQuery({
|
|
334
|
+
query: options.query,
|
|
335
|
+
framework,
|
|
336
|
+
categoryIds: categories.map((category) => category.id),
|
|
337
|
+
});
|
|
338
|
+
let selectedItem: DocsItem | undefined;
|
|
164
339
|
|
|
165
|
-
|
|
166
|
-
|
|
340
|
+
// In --raw mode, wrap index resolution in try-catch to allow fallback to direct URL
|
|
341
|
+
const resolveFromIndex = async (): Promise<DocsItem | undefined> => {
|
|
342
|
+
if (docsQuery) {
|
|
343
|
+
const segments = parseQueryPath(docsQuery);
|
|
167
344
|
|
|
168
|
-
|
|
169
|
-
|
|
345
|
+
// Deep paths (more than category/section/item) can't be resolved from index
|
|
346
|
+
// e.g., react/updates/changelog/react/1.2.9 — skip to fallback in --raw mode
|
|
347
|
+
if (raw && segments.length > 3) {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Try to resolve as path: category / section / item
|
|
352
|
+
const matchedCategory = categories.find((c) => c.id === segments[0]);
|
|
170
353
|
|
|
171
|
-
|
|
172
|
-
|
|
354
|
+
if (matchedCategory && segments.length >= 2) {
|
|
355
|
+
const matchedSection = matchedCategory.sections.find((s) => s.id === segments[1]);
|
|
173
356
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
357
|
+
if (matchedSection && segments.length >= 3) {
|
|
358
|
+
// Full path: category/section/item
|
|
359
|
+
const matchedItem = matchedSection.items.find((i) => i.id === segments[2]);
|
|
360
|
+
if (matchedItem) {
|
|
361
|
+
return matchedItem;
|
|
362
|
+
}
|
|
180
363
|
// Item not found in section — search within the section
|
|
181
364
|
const q = segments[2].toLowerCase();
|
|
182
365
|
const matched = matchedSection.items.filter(
|
|
183
366
|
(i) => i.id.toLowerCase().includes(q) || i.title.toLowerCase().includes(q),
|
|
184
367
|
);
|
|
185
368
|
if (matched.length === 0) {
|
|
369
|
+
const similarItems = findSimilar(
|
|
370
|
+
segments[2],
|
|
371
|
+
matchedSection.items.map((i) => i.id),
|
|
372
|
+
);
|
|
373
|
+
const suggestion =
|
|
374
|
+
similarItems.length > 0
|
|
375
|
+
? `\n\n💡 이것을 의미했나요?\n${similarItems
|
|
376
|
+
.slice(0, 3)
|
|
377
|
+
.map((s) => ` - ${matchedCategory.id}/${matchedSection.id}/${s}`)
|
|
378
|
+
.join("\n")}`
|
|
379
|
+
: "";
|
|
186
380
|
throw new CliError({
|
|
187
|
-
message: `${highlight(
|
|
381
|
+
message: `${highlight(docsQuery)}: 문서를 찾을 수 없어요.${suggestion}`,
|
|
188
382
|
hint: `\`seed-design docs ${matchedCategory.id}/${matchedSection.id}\`로 목록을 확인해보세요.`,
|
|
189
383
|
});
|
|
190
384
|
}
|
|
191
385
|
if (matched.length === 1) {
|
|
192
|
-
|
|
193
|
-
} else {
|
|
194
|
-
selectedItem = await selectItem(matched);
|
|
386
|
+
return matched[0];
|
|
195
387
|
}
|
|
388
|
+
return await selectItem(matched);
|
|
389
|
+
}
|
|
390
|
+
if (matchedSection) {
|
|
391
|
+
// category/section — select item within section
|
|
392
|
+
return await selectItem(matchedSection.items);
|
|
196
393
|
}
|
|
197
|
-
} else if (matchedSection) {
|
|
198
|
-
// category/section — select item within section
|
|
199
|
-
selectedItem = await selectItem(matchedSection.items);
|
|
200
|
-
} else {
|
|
201
394
|
// category/??? — search within category
|
|
202
395
|
const q = segments[1].toLowerCase();
|
|
203
396
|
const matched = matchedCategory.sections.flatMap((s) =>
|
|
@@ -209,61 +402,82 @@ export const docsCommand = (cli: CAC) => {
|
|
|
209
402
|
);
|
|
210
403
|
|
|
211
404
|
if (matched.length === 0) {
|
|
405
|
+
const sectionIds = matchedCategory.sections.map((s) => s.id);
|
|
406
|
+
const similarSections = findSimilar(segments[1], sectionIds);
|
|
407
|
+
const allItemIds = matchedCategory.sections.flatMap((s) =>
|
|
408
|
+
s.items.map((i) => ({
|
|
409
|
+
path: `${matchedCategory.id}/${s.id}/${i.id}`,
|
|
410
|
+
id: i.id,
|
|
411
|
+
})),
|
|
412
|
+
);
|
|
413
|
+
const similarItems = findSimilar(
|
|
414
|
+
segments[1],
|
|
415
|
+
allItemIds.map((x) => x.id),
|
|
416
|
+
);
|
|
417
|
+
const suggestions: string[] = [
|
|
418
|
+
...similarSections.slice(0, 2).map((s) => `${matchedCategory.id}/${s}`),
|
|
419
|
+
...similarItems
|
|
420
|
+
.slice(0, 2)
|
|
421
|
+
.map((id) => allItemIds.find((x) => x.id === id)?.path)
|
|
422
|
+
.filter((p): p is string => p != null),
|
|
423
|
+
];
|
|
424
|
+
const suggestion =
|
|
425
|
+
suggestions.length > 0
|
|
426
|
+
? `\n\n💡 이것을 의미했나요?\n${suggestions.map((s) => ` - ${s}`).join("\n")}`
|
|
427
|
+
: "";
|
|
212
428
|
throw new CliError({
|
|
213
|
-
message: `${highlight(
|
|
429
|
+
message: `${highlight(docsQuery)}: 문서를 찾을 수 없어요.${suggestion}`,
|
|
214
430
|
hint: `\`seed-design docs ${matchedCategory.id}\`로 목록을 확인해보세요.`,
|
|
215
431
|
});
|
|
216
432
|
}
|
|
217
433
|
if (matched.length === 1) {
|
|
218
|
-
|
|
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;
|
|
434
|
+
return matched[0].item;
|
|
230
435
|
}
|
|
436
|
+
const selected = await p.select({
|
|
437
|
+
message: `${highlight(segments[1])}에 해당하는 항목을 선택해주세요`,
|
|
438
|
+
options: matched.map(({ item, sectionLabel }) => ({
|
|
439
|
+
label: `[${sectionLabel}] ${item.title}`,
|
|
440
|
+
value: item,
|
|
441
|
+
hint: item.description,
|
|
442
|
+
})),
|
|
443
|
+
});
|
|
444
|
+
if (p.isCancel(selected)) throw new CliCancelError();
|
|
445
|
+
return selected;
|
|
231
446
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
447
|
+
if (matchedCategory) {
|
|
448
|
+
// Single segment matching a category — drill into it
|
|
449
|
+
if (matchedCategory.sections.length === 1) {
|
|
450
|
+
return await selectItem(matchedCategory.sections[0].items);
|
|
451
|
+
}
|
|
237
452
|
const section = await selectSection(matchedCategory.sections);
|
|
238
|
-
|
|
453
|
+
return await selectItem(section.items);
|
|
239
454
|
}
|
|
240
|
-
} else {
|
|
241
455
|
// No category match — global search
|
|
242
|
-
const matched = searchAllItems(categories,
|
|
456
|
+
const matched = searchAllItems(categories, docsQuery);
|
|
243
457
|
|
|
244
458
|
if (matched.length === 0) {
|
|
459
|
+
const suggestion = buildSuggestionHint(segments, categories);
|
|
245
460
|
throw new CliError({
|
|
246
|
-
message: `${highlight(
|
|
461
|
+
message: `${highlight(docsQuery)}: 문서를 찾을 수 없어요.${suggestion ?? ""}`,
|
|
247
462
|
hint: "`seed-design docs`로 전체 목록을 확인해보세요.",
|
|
248
463
|
});
|
|
249
464
|
}
|
|
250
465
|
if (matched.length === 1) {
|
|
251
|
-
|
|
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;
|
|
466
|
+
return matched[0].item;
|
|
263
467
|
}
|
|
468
|
+
const selected = await p.select({
|
|
469
|
+
message: `${highlight(docsQuery)}에 해당하는 항목을 선택해주세요`,
|
|
470
|
+
options: matched.map(({ item, categoryLabel, sectionLabel }) => ({
|
|
471
|
+
label: `[${categoryLabel} > ${sectionLabel}] ${item.title}`,
|
|
472
|
+
value: item,
|
|
473
|
+
hint: item.description,
|
|
474
|
+
})),
|
|
475
|
+
});
|
|
476
|
+
if (p.isCancel(selected)) throw new CliCancelError();
|
|
477
|
+
return selected;
|
|
264
478
|
}
|
|
265
|
-
|
|
266
|
-
//
|
|
479
|
+
|
|
480
|
+
// No query — full interactive flow: category → section → item
|
|
267
481
|
const category = await selectCategory(categories);
|
|
268
482
|
|
|
269
483
|
let section: DocsSection;
|
|
@@ -273,34 +487,94 @@ export const docsCommand = (cli: CAC) => {
|
|
|
273
487
|
section = await selectSection(category.sections);
|
|
274
488
|
}
|
|
275
489
|
|
|
276
|
-
|
|
490
|
+
return await selectItem(section.items);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// In --raw mode, swallow index resolution errors and fall back to direct URL fetch
|
|
494
|
+
if (raw) {
|
|
495
|
+
try {
|
|
496
|
+
selectedItem = await resolveFromIndex();
|
|
497
|
+
} catch (error) {
|
|
498
|
+
if (isCliCancelError(error)) throw error;
|
|
499
|
+
// index miss in raw mode → will use fallback
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
selectedItem = await resolveFromIndex();
|
|
277
503
|
}
|
|
278
504
|
|
|
279
|
-
|
|
280
|
-
|
|
505
|
+
if (raw) {
|
|
506
|
+
let content: string;
|
|
507
|
+
if (selectedItem) {
|
|
508
|
+
const llmsUrl = `${baseUrl}/llms${selectedItem.docUrl}.txt`;
|
|
509
|
+
content = await fetchLlmsTxt({ url: llmsUrl });
|
|
510
|
+
} else {
|
|
511
|
+
content = await tryFetchLlmsTxt({ baseUrl, query: docsQuery! });
|
|
512
|
+
}
|
|
513
|
+
console.log(content);
|
|
514
|
+
} else {
|
|
515
|
+
printDocsResult(selectedItem!, baseUrl);
|
|
516
|
+
p.outro("완료했어요.");
|
|
517
|
+
}
|
|
281
518
|
|
|
282
519
|
const duration = Date.now() - startTime;
|
|
283
520
|
try {
|
|
284
|
-
await analytics.
|
|
285
|
-
|
|
521
|
+
await analytics.trackCommandOutcome(trackCwd, {
|
|
522
|
+
command: "docs",
|
|
523
|
+
status: "completed",
|
|
286
524
|
properties: {
|
|
287
525
|
query: options.query ?? null,
|
|
288
|
-
item_id: selectedItem.
|
|
289
|
-
has_snippet: !!(selectedItem
|
|
526
|
+
item_id: selectedItem?.id ?? options.query ?? null,
|
|
527
|
+
has_snippet: !!(selectedItem?.snippets && selectedItem.snippets.length > 0),
|
|
528
|
+
raw_mode: raw,
|
|
290
529
|
duration_ms: duration,
|
|
291
530
|
},
|
|
292
531
|
});
|
|
293
532
|
} catch (telemetryError) {
|
|
294
533
|
if (verbose) {
|
|
295
|
-
console.error("[Telemetry] docs
|
|
534
|
+
console.error("[Telemetry] docs 이벤트 전송에 실패했어요:", telemetryError);
|
|
296
535
|
}
|
|
297
536
|
}
|
|
298
537
|
} catch (error) {
|
|
299
538
|
if (isCliCancelError(error)) {
|
|
300
|
-
|
|
539
|
+
try {
|
|
540
|
+
await analytics.trackCommandOutcome(trackCwd, {
|
|
541
|
+
command: "docs",
|
|
542
|
+
status: "cancelled",
|
|
543
|
+
properties: {
|
|
544
|
+
raw_mode: raw,
|
|
545
|
+
duration_ms: Date.now() - startTime,
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
} catch (telemetryError) {
|
|
549
|
+
if (verbose) {
|
|
550
|
+
console.error("[Telemetry] docs 이벤트 전송에 실패했어요:", telemetryError);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (!raw) p.outro(highlight(error.message));
|
|
301
554
|
process.exit(0);
|
|
302
555
|
}
|
|
303
556
|
|
|
557
|
+
try {
|
|
558
|
+
await analytics.trackCommandFailure(trackCwd, {
|
|
559
|
+
command: "docs",
|
|
560
|
+
error,
|
|
561
|
+
properties: {
|
|
562
|
+
raw_mode: raw,
|
|
563
|
+
duration_ms: Date.now() - startTime,
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
} catch (telemetryError) {
|
|
567
|
+
if (verbose) {
|
|
568
|
+
console.error("[Telemetry] docs 이벤트 전송에 실패했어요:", telemetryError);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (raw) {
|
|
573
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
574
|
+
console.error(msg);
|
|
575
|
+
process.exit(1);
|
|
576
|
+
}
|
|
577
|
+
|
|
304
578
|
handleCliError(error, {
|
|
305
579
|
defaultMessage: "문서 조회에 실패했어요.",
|
|
306
580
|
defaultHint: "`--verbose` 옵션으로 상세 오류를 확인해보세요.",
|
package/src/commands/init.ts
CHANGED
|
@@ -32,6 +32,7 @@ export const initCommand = (cli: CAC) => {
|
|
|
32
32
|
.action(async (opts) => {
|
|
33
33
|
const startTime = Date.now();
|
|
34
34
|
const verbose = isVerboseMode(opts);
|
|
35
|
+
const trackCwd = typeof opts?.cwd === "string" ? opts.cwd : process.cwd();
|
|
35
36
|
p.intro("seed-design.json 파일 생성");
|
|
36
37
|
|
|
37
38
|
try {
|
|
@@ -85,8 +86,9 @@ export const initCommand = (cli: CAC) => {
|
|
|
85
86
|
// init 성공 이벤트 추적
|
|
86
87
|
const duration = Date.now() - startTime;
|
|
87
88
|
try {
|
|
88
|
-
await analytics.
|
|
89
|
-
|
|
89
|
+
await analytics.trackCommandOutcome(options.cwd, {
|
|
90
|
+
command: "init",
|
|
91
|
+
status: "completed",
|
|
90
92
|
properties: {
|
|
91
93
|
tsx: config.tsx,
|
|
92
94
|
rsc: config.rsc,
|
|
@@ -97,15 +99,42 @@ export const initCommand = (cli: CAC) => {
|
|
|
97
99
|
});
|
|
98
100
|
} catch (telemetryError) {
|
|
99
101
|
if (verbose) {
|
|
100
|
-
console.error("[Telemetry] init
|
|
102
|
+
console.error("[Telemetry] init 이벤트 전송에 실패했어요:", telemetryError);
|
|
101
103
|
}
|
|
102
104
|
}
|
|
103
105
|
} catch (error) {
|
|
104
106
|
if (isCliCancelError(error)) {
|
|
107
|
+
try {
|
|
108
|
+
await analytics.trackCommandOutcome(trackCwd, {
|
|
109
|
+
command: "init",
|
|
110
|
+
status: "cancelled",
|
|
111
|
+
properties: {
|
|
112
|
+
duration_ms: Date.now() - startTime,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
} catch (telemetryError) {
|
|
116
|
+
if (verbose) {
|
|
117
|
+
console.error("[Telemetry] init 이벤트 전송에 실패했어요:", telemetryError);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
105
120
|
p.outro(highlight(error.message));
|
|
106
121
|
process.exit(0);
|
|
107
122
|
}
|
|
108
123
|
|
|
124
|
+
try {
|
|
125
|
+
await analytics.trackCommandFailure(trackCwd, {
|
|
126
|
+
command: "init",
|
|
127
|
+
error,
|
|
128
|
+
properties: {
|
|
129
|
+
duration_ms: Date.now() - startTime,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
} catch (telemetryError) {
|
|
133
|
+
if (verbose) {
|
|
134
|
+
console.error("[Telemetry] init 이벤트 전송에 실패했어요:", telemetryError);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
109
138
|
handleCliError(error, {
|
|
110
139
|
defaultMessage: "seed-design.json 파일 생성에 실패했어요.",
|
|
111
140
|
defaultHint: "`--verbose` 옵션으로 상세 오류를 확인해보세요.",
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { addAllCommand } from "@/src/commands/add-all";
|
|
|
5
5
|
import { compatCommand } from "@/src/commands/compat";
|
|
6
6
|
import { docsCommand } from "@/src/commands/docs";
|
|
7
7
|
import { initCommand } from "@/src/commands/init";
|
|
8
|
+
|
|
8
9
|
import { getPackageInfo } from "@/src/utils/get-package-info";
|
|
9
10
|
import { cac } from "cac";
|
|
10
11
|
|