@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.
@@ -0,0 +1,95 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
2
+ import fs from "fs-extra";
3
+ import os from "os";
4
+ import path from "path";
5
+ import * as prompts from "@clack/prompts";
6
+ import { analytics } from "../utils/analytics";
7
+
8
+ describe("analytics command outcome tracking", () => {
9
+ const originalEnv = { ...process.env };
10
+ const tempDirs: string[] = [];
11
+
12
+ beforeEach(() => {
13
+ process.env.NODE_ENV = "prod";
14
+ process.env.POSTHOG_API_KEY = "test-api-key";
15
+ process.env.POSTHOG_HOST = "https://us.i.posthog.com";
16
+ delete process.env.DISABLE_TELEMETRY;
17
+ delete process.env.SEED_DISABLE_TELEMETRY;
18
+ });
19
+
20
+ afterEach(async () => {
21
+ process.env = { ...originalEnv };
22
+ mock.restore();
23
+
24
+ while (tempDirs.length > 0) {
25
+ const dir = tempDirs.pop();
26
+ if (dir) {
27
+ await fs.remove(dir);
28
+ }
29
+ }
30
+ });
31
+
32
+ async function createTempCwd() {
33
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "seed-cli-analytics-"));
34
+ tempDirs.push(cwd);
35
+ return cwd;
36
+ }
37
+
38
+ it("command outcome payload에 status를 포함해야 한다", async () => {
39
+ const cwd = await createTempCwd();
40
+ const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
41
+ new Response(null, { status: 200 }),
42
+ );
43
+ const infoSpy = spyOn(prompts.log, "info").mockImplementation(() => {});
44
+
45
+ await analytics.trackCommandOutcome(cwd, {
46
+ command: "add",
47
+ status: "completed",
48
+ properties: {
49
+ items_count: 2,
50
+ },
51
+ });
52
+
53
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
54
+ expect(infoSpy).toHaveBeenCalled();
55
+
56
+ const [, request] = fetchSpy.mock.calls[0] ?? [];
57
+ const payload = JSON.parse(String(request?.body));
58
+
59
+ expect(payload.event).toBe("seed_cli.add");
60
+ expect(payload.properties).toMatchObject({
61
+ status: "completed",
62
+ items_count: 2,
63
+ $process_person_profile: false,
64
+ });
65
+ });
66
+
67
+ it("failed outcome payload에는 error_type만 포함하고 message는 포함하지 않아야 한다", async () => {
68
+ const cwd = await createTempCwd();
69
+ const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
70
+ new Response(null, { status: 200 }),
71
+ );
72
+ spyOn(prompts.log, "info").mockImplementation(() => {});
73
+
74
+ await analytics.trackCommandFailure(cwd, {
75
+ command: "docs",
76
+ error: new Error("sensitive details"),
77
+ properties: {
78
+ raw_mode: true,
79
+ },
80
+ });
81
+
82
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
83
+
84
+ const [, request] = fetchSpy.mock.calls[0] ?? [];
85
+ const payload = JSON.parse(String(request?.body));
86
+
87
+ expect(payload.event).toBe("seed_cli.docs");
88
+ expect(payload.properties).toMatchObject({
89
+ status: "failed",
90
+ error_type: "Error",
91
+ raw_mode: true,
92
+ });
93
+ expect(payload.properties.error_message).toBeUndefined();
94
+ });
95
+ });
@@ -0,0 +1,195 @@
1
+ import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
2
+ import type { CAC } from "cac";
3
+ import { cac } from "cac";
4
+ import { CliCancelError } from "../utils/error";
5
+ import { analytics } from "../utils/analytics";
6
+
7
+ const introMock = mock(() => {});
8
+ const outroMock = mock(() => {});
9
+ const infoMock = mock(() => {});
10
+ const messageMock = mock(() => {});
11
+ const errorMock = mock(() => {});
12
+ const noteMock = mock(() => {});
13
+ const spinnerStartMock = mock(() => {});
14
+ const spinnerStopMock = mock(() => {});
15
+ const promptInitConfigMock = mock(async () => ({
16
+ tsx: true,
17
+ rsc: false,
18
+ path: "./seed-design",
19
+ telemetry: true,
20
+ }));
21
+ const writeInitConfigFileMock = mock(async () => ({
22
+ relativePath: "seed-design.json",
23
+ }));
24
+
25
+ mock.module("@clack/prompts", () => ({
26
+ intro: introMock,
27
+ outro: outroMock,
28
+ note: noteMock,
29
+ log: {
30
+ info: infoMock,
31
+ message: messageMock,
32
+ error: errorMock,
33
+ },
34
+ spinner: () => ({
35
+ start: spinnerStartMock,
36
+ stop: spinnerStopMock,
37
+ }),
38
+ isCancel: () => false,
39
+ }));
40
+
41
+ mock.module("../utils/init-config", () => ({
42
+ DEFAULT_INIT_CONFIG: {
43
+ tsx: true,
44
+ rsc: false,
45
+ path: "./seed-design",
46
+ telemetry: true,
47
+ },
48
+ promptInitConfig: promptInitConfigMock,
49
+ writeInitConfigFile: writeInitConfigFileMock,
50
+ }));
51
+
52
+ const { addCommand } = await import("../commands/add");
53
+ const { initCommand } = await import("../commands/init");
54
+
55
+ function assertHasCommandAction<T extends { commandAction?: unknown }>(
56
+ command: T,
57
+ name: string,
58
+ ): asserts command is T & { commandAction: NonNullable<T["commandAction"]> } {
59
+ if (!command.commandAction) {
60
+ throw new Error(`Command has no action handler: ${name}`);
61
+ }
62
+ }
63
+
64
+ function getCommand(cli: CAC, name: string) {
65
+ const command = cli.commands.find((item) => item.name === name);
66
+
67
+ if (!command) {
68
+ throw new Error(`Command not found: ${name}`);
69
+ }
70
+
71
+ assertHasCommandAction(command, name);
72
+ return command;
73
+ }
74
+
75
+ describe("command telemetry", () => {
76
+ beforeEach(() => {
77
+ introMock.mockClear();
78
+ outroMock.mockClear();
79
+ infoMock.mockClear();
80
+ messageMock.mockClear();
81
+ errorMock.mockClear();
82
+ noteMock.mockClear();
83
+ spinnerStartMock.mockClear();
84
+ spinnerStopMock.mockClear();
85
+ promptInitConfigMock.mockClear();
86
+ writeInitConfigFileMock.mockClear();
87
+ });
88
+
89
+ it("init 성공 시 completed outcome을 전송해야 한다", async () => {
90
+ const trackCommandOutcomeSpy = spyOn(analytics, "trackCommandOutcome").mockImplementation(
91
+ async () => {},
92
+ );
93
+ const trackCommandFailureSpy = spyOn(analytics, "trackCommandFailure").mockImplementation(
94
+ async () => {},
95
+ );
96
+ const cli = cac("seed-design");
97
+ initCommand(cli);
98
+
99
+ await getCommand(cli, "init").commandAction({
100
+ cwd: "/tmp/seed-design",
101
+ yes: true,
102
+ default: false,
103
+ });
104
+
105
+ expect(writeInitConfigFileMock).toHaveBeenCalledTimes(1);
106
+ expect(trackCommandOutcomeSpy).toHaveBeenCalledWith(
107
+ "/tmp/seed-design",
108
+ expect.objectContaining({
109
+ command: "init",
110
+ status: "completed",
111
+ properties: expect.objectContaining({
112
+ yes_option: true,
113
+ telemetry: true,
114
+ }),
115
+ }),
116
+ );
117
+ expect(trackCommandFailureSpy).not.toHaveBeenCalled();
118
+
119
+ trackCommandOutcomeSpy.mockRestore();
120
+ trackCommandFailureSpy.mockRestore();
121
+ });
122
+
123
+ it("init 취소 시 cancelled outcome을 전송해야 한다", async () => {
124
+ const trackCommandOutcomeSpy = spyOn(analytics, "trackCommandOutcome").mockImplementation(
125
+ async () => {},
126
+ );
127
+ const trackCommandFailureSpy = spyOn(analytics, "trackCommandFailure").mockImplementation(
128
+ async () => {},
129
+ );
130
+ promptInitConfigMock.mockImplementationOnce(async () => {
131
+ throw new CliCancelError("작업이 취소됐어요.");
132
+ });
133
+
134
+ const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
135
+ throw new Error(`EXIT:${code}`);
136
+ }) as never);
137
+
138
+ const cli = cac("seed-design");
139
+ initCommand(cli);
140
+
141
+ await expect(
142
+ getCommand(cli, "init").commandAction({
143
+ cwd: "/tmp/seed-design",
144
+ yes: false,
145
+ default: false,
146
+ }),
147
+ ).rejects.toThrow("EXIT:0");
148
+
149
+ expect(trackCommandOutcomeSpy).toHaveBeenCalledWith(
150
+ "/tmp/seed-design",
151
+ expect.objectContaining({
152
+ command: "init",
153
+ status: "cancelled",
154
+ }),
155
+ );
156
+ expect(trackCommandFailureSpy).not.toHaveBeenCalled();
157
+
158
+ exitSpy.mockRestore();
159
+ trackCommandOutcomeSpy.mockRestore();
160
+ trackCommandFailureSpy.mockRestore();
161
+ });
162
+
163
+ it("add 실패 시 failed outcome을 전송해야 한다", async () => {
164
+ const trackCommandFailureSpy = spyOn(analytics, "trackCommandFailure").mockImplementation(
165
+ async () => {},
166
+ );
167
+ const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
168
+ throw new Error(`EXIT:${code}`);
169
+ }) as never);
170
+
171
+ const cli = cac("seed-design");
172
+ addCommand(cli);
173
+
174
+ await expect(
175
+ getCommand(cli, "add").commandAction([], {
176
+ all: true,
177
+ cwd: "/tmp/seed-design",
178
+ baseUrl: "https://seed-design.io",
179
+ }),
180
+ ).rejects.toThrow("EXIT:1");
181
+
182
+ expect(trackCommandFailureSpy).toHaveBeenCalledWith(
183
+ "/tmp/seed-design",
184
+ expect.objectContaining({
185
+ command: "add",
186
+ error: expect.objectContaining({
187
+ name: "CliError",
188
+ }),
189
+ }),
190
+ );
191
+
192
+ exitSpy.mockRestore();
193
+ trackCommandFailureSpy.mockRestore();
194
+ });
195
+ });
@@ -0,0 +1,150 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
2
+ import { createServer, type Server } from "node:http";
3
+ import path from "node:path";
4
+
5
+ const repoRoot = path.resolve(import.meta.dir, "../../../..");
6
+
7
+ const docsIndex = {
8
+ categories: [
9
+ {
10
+ id: "lynx",
11
+ label: "Lynx",
12
+ sections: [
13
+ {
14
+ id: "components",
15
+ label: "Components",
16
+ items: [
17
+ {
18
+ id: "action-button",
19
+ title: "Action Button",
20
+ docUrl: "/lynx/components/action-button",
21
+ },
22
+ {
23
+ id: "checkbox",
24
+ title: "Checkbox",
25
+ docUrl: "/lynx/components/checkbox",
26
+ snippetKey: "lynx/ui:checkbox",
27
+ snippets: [{ label: "checkbox", path: "checkbox.tsx" }],
28
+ },
29
+ ],
30
+ },
31
+ ],
32
+ },
33
+ ],
34
+ };
35
+
36
+ describe("docs command", () => {
37
+ let server: Server;
38
+ let baseUrl: string;
39
+ const requests: string[] = [];
40
+
41
+ beforeAll(async () => {
42
+ server = createServer((request, response) => {
43
+ const url = request.url ? new URL(request.url, "http://127.0.0.1") : undefined;
44
+ const pathname = url?.pathname ?? "/";
45
+ requests.push(pathname);
46
+
47
+ if (pathname === "/__docs__/index.json") {
48
+ response.writeHead(200, { "Content-Type": "application/json" });
49
+ response.end(JSON.stringify(docsIndex));
50
+ return;
51
+ }
52
+
53
+ response.writeHead(404, { "Content-Type": "text/plain" });
54
+ response.end("Not found");
55
+ });
56
+
57
+ await new Promise<void>((resolve) => {
58
+ server.listen(0, "127.0.0.1", resolve);
59
+ });
60
+
61
+ const address = server.address();
62
+ if (!address || typeof address === "string") {
63
+ throw new Error("Failed to start test docs server");
64
+ }
65
+ baseUrl = `http://127.0.0.1:${address.port}`;
66
+ });
67
+
68
+ afterAll(async () => {
69
+ await new Promise<void>((resolve, reject) => {
70
+ server.close((error) => {
71
+ if (error) {
72
+ reject(error);
73
+ return;
74
+ }
75
+ resolve();
76
+ });
77
+ });
78
+ });
79
+
80
+ async function runDocsCommand(args: string[]) {
81
+ const proc = Bun.spawn({
82
+ cmd: [process.execPath, "packages/cli/src/index.ts", "docs", ...args, "-u", baseUrl],
83
+ cwd: repoRoot,
84
+ env: {
85
+ ...process.env,
86
+ DISABLE_TELEMETRY: "true",
87
+ FORCE_COLOR: "0",
88
+ },
89
+ stderr: "pipe",
90
+ stdout: "pipe",
91
+ });
92
+
93
+ const [exitCode, stdout, stderr] = await Promise.all([
94
+ proc.exited,
95
+ new Response(proc.stdout).text(),
96
+ new Response(proc.stderr).text(),
97
+ ]);
98
+ return { exitCode, stderr, stdout };
99
+ }
100
+
101
+ it("resolves unquoted Lynx component queries to docs and llms links", async () => {
102
+ requests.length = 0;
103
+ const result = await runDocsCommand(["lynx", "action-button"]);
104
+
105
+ if (result.exitCode !== 0) {
106
+ throw new Error(
107
+ `docs command failed\nbaseUrl:${baseUrl}\nrequests:${requests.join(",")}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
108
+ );
109
+ }
110
+ expect(result.stdout).toContain("- docs: http://");
111
+ expect(result.stdout).toContain("/lynx/components/action-button");
112
+ expect(result.stdout).toContain("- llms.txt: http://");
113
+ expect(result.stdout).toContain("/llms/lynx/components/action-button.txt");
114
+ expect(result.stdout).not.toContain("- snippet:");
115
+ });
116
+
117
+ it("prints Lynx snippet URLs for quoted Lynx component queries", async () => {
118
+ requests.length = 0;
119
+ const result = await runDocsCommand(["lynx checkbox"]);
120
+
121
+ if (result.exitCode !== 0) {
122
+ throw new Error(
123
+ `docs command failed\nbaseUrl:${baseUrl}\nrequests:${requests.join(",")}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
124
+ );
125
+ }
126
+ expect(result.stdout).toContain("- docs: http://");
127
+ expect(result.stdout).toContain("/lynx/components/checkbox");
128
+ expect(result.stdout).toContain("- llms.txt: http://");
129
+ expect(result.stdout).toContain("/llms/lynx/components/checkbox.txt");
130
+ expect(result.stdout).toContain(
131
+ "- snippet: https://raw.githubusercontent.com/daangn/seed-design/refs/heads/dev/docs/registry/lynx/ui/checkbox.tsx",
132
+ );
133
+ });
134
+
135
+ it("resolves registry-key queries with an explicit framework", async () => {
136
+ requests.length = 0;
137
+ const result = await runDocsCommand(["ui:checkbox", "--framework", "lynx"]);
138
+
139
+ if (result.exitCode !== 0) {
140
+ throw new Error(
141
+ `docs command failed\nbaseUrl:${baseUrl}\nrequests:${requests.join(",")}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
142
+ );
143
+ }
144
+ expect(result.stdout).toContain("- docs: http://");
145
+ expect(result.stdout).toContain("/lynx/components/checkbox");
146
+ expect(result.stdout).toContain(
147
+ "- snippet: https://raw.githubusercontent.com/daangn/seed-design/refs/heads/dev/docs/registry/lynx/ui/checkbox.tsx",
148
+ );
149
+ });
150
+ });
@@ -0,0 +1,11 @@
1
+ declare module "@babel/plugin-transform-typescript";
2
+
3
+ declare module "@npmcli/disparity-colors" {
4
+ /**
5
+ * Colorizes unified diff output with ANSI escape codes.
6
+ *
7
+ * @param str - A unified diff format string.
8
+ * @param opts.headerLength - Number of lines at the start of the diff to colorize as header.
9
+ */
10
+ export default function colorize(str: string, opts?: { headerLength?: number }): string;
11
+ }
@@ -3,12 +3,27 @@ import * as p from "@clack/prompts";
3
3
  import { getRawConfig } from "./get-config";
4
4
 
5
5
  const EVENT_PREFIX = "seed_cli";
6
+ const COMMAND_STATUSES = ["completed", "cancelled", "failed"] as const;
6
7
 
7
8
  interface TrackOptions {
8
9
  event: string;
9
10
  properties?: Record<string, unknown>;
10
11
  }
11
12
 
13
+ interface TrackCommandOutcomeOptions {
14
+ command: string;
15
+ status: (typeof COMMAND_STATUSES)[number];
16
+ result?: string;
17
+ properties?: Record<string, unknown>;
18
+ }
19
+
20
+ interface TrackCommandFailureOptions {
21
+ command: string;
22
+ error: unknown;
23
+ result?: string;
24
+ properties?: Record<string, unknown>;
25
+ }
26
+
12
27
  /**
13
28
  * 텔레메트리 활성화 여부를 확인합니다.
14
29
  * 우선순위:
@@ -48,6 +63,24 @@ const sessionId = generateSessionId();
48
63
  // 세션당 한 번만 메시지 표시
49
64
  let hasShownMessage = false;
50
65
 
66
+ function omitUndefined(properties: Record<string, unknown>): Record<string, unknown> {
67
+ return Object.fromEntries(
68
+ Object.entries(properties).filter(([, value]) => value !== undefined),
69
+ );
70
+ }
71
+
72
+ function getSafeErrorType(error: unknown): string {
73
+ if (error instanceof Error && error.name) {
74
+ return error.name;
75
+ }
76
+
77
+ if (typeof error === "object" && error !== null) {
78
+ return error.constructor?.name ?? "Object";
79
+ }
80
+
81
+ return typeof error;
82
+ }
83
+
51
84
  /**
52
85
  * PostHog에 이벤트를 전송합니다.
53
86
  */
@@ -76,7 +109,7 @@ async function track(cwd: string, { event, properties = {} }: TrackOptions): Pro
76
109
  // PostHog API 호출 (fire-and-forget)
77
110
  try {
78
111
  if (!process.env.POSTHOG_HOST || !process.env.POSTHOG_API_KEY) {
79
- console.warn("[Analytics] Missing POSTHOG_HOST or POSTHOG_API_KEY");
112
+ console.error("[Telemetry] POSTHOG_HOST 또는 POSTHOG_API_KEY가 없어서 이벤트를 전송하지 않아요.");
80
113
  return;
81
114
  }
82
115
 
@@ -113,6 +146,37 @@ async function track(cwd: string, { event, properties = {} }: TrackOptions): Pro
113
146
  }
114
147
  }
115
148
 
149
+ async function trackCommandOutcome(
150
+ cwd: string,
151
+ { command, status, result, properties = {} }: TrackCommandOutcomeOptions,
152
+ ): Promise<void> {
153
+ await track(cwd, {
154
+ event: command,
155
+ properties: omitUndefined({
156
+ status,
157
+ result,
158
+ ...properties,
159
+ }),
160
+ });
161
+ }
162
+
163
+ async function trackCommandFailure(
164
+ cwd: string,
165
+ { command, error, result, properties = {} }: TrackCommandFailureOptions,
166
+ ): Promise<void> {
167
+ await trackCommandOutcome(cwd, {
168
+ command,
169
+ status: "failed",
170
+ result,
171
+ properties: omitUndefined({
172
+ error_type: getSafeErrorType(error),
173
+ ...properties,
174
+ }),
175
+ });
176
+ }
177
+
116
178
  export const analytics = {
117
179
  track,
180
+ trackCommandFailure,
181
+ trackCommandOutcome,
118
182
  };
@@ -9,9 +9,6 @@ import { highlight } from "./color";
9
9
  const REACT_COMPAT_PACKAGES = ["@seed-design/react", "@seed-design/css"] as const;
10
10
  const LYNX_COMPAT_PACKAGES = ["@seed-design/lynx-react", "@seed-design/lynx-css"] as const;
11
11
 
12
- /** @deprecated Use getCompatPackageNames(framework) instead */
13
- export const COMPAT_PACKAGE_NAMES = REACT_COMPAT_PACKAGES;
14
-
15
12
  export type CompatPackageName =
16
13
  | (typeof REACT_COMPAT_PACKAGES)[number]
17
14
  | (typeof LYNX_COMPAT_PACKAGES)[number];
@@ -12,6 +12,26 @@ import {
12
12
  } from "@/src/schema";
13
13
  import { CliError } from "@/src/utils/error";
14
14
 
15
+ const FETCH_TIMEOUT_MS = 10_000;
16
+
17
+ async function fetchWithTimeout(url: string, timeoutMs = FETCH_TIMEOUT_MS): Promise<Response> {
18
+ const controller = new AbortController();
19
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
20
+ try {
21
+ return await fetch(url, { signal: controller.signal });
22
+ } catch (error) {
23
+ if (error instanceof Error && error.name === "AbortError") {
24
+ throw new CliError({
25
+ message: `요청 시간이 초과되었어요 (${timeoutMs}ms): ${url}`,
26
+ hint: "네트워크 상태를 확인하고 다시 시도해주세요.",
27
+ });
28
+ }
29
+ throw error;
30
+ } finally {
31
+ clearTimeout(timeout);
32
+ }
33
+ }
34
+
15
35
  export async function fetchDocsIndex({ baseUrl }: { baseUrl: string }): Promise<DocsIndex> {
16
36
  const response = await fetch(`${baseUrl}/__docs__/index.json`);
17
37
 
@@ -108,6 +128,78 @@ async function fetchRegistryItem({
108
128
  return parsedItem;
109
129
  }
110
130
 
131
+ export async function fetchLlmsTxt({ url }: { url: string }): Promise<string> {
132
+ const response = await fetchWithTimeout(url);
133
+
134
+ if (!response.ok) {
135
+ throw new CliError({
136
+ message: `llms.txt를 가져오지 못했어요: ${response.status} ${response.statusText}`,
137
+ hint: `${url} 에 접근할 수 있는지 확인해주세요.`,
138
+ });
139
+ }
140
+
141
+ return response.text();
142
+ }
143
+
144
+ /**
145
+ * Try fetching llms.txt content with fallback URL patterns.
146
+ * 1. {baseUrl}/llms/{query}.txt
147
+ * 2. {baseUrl}/llms/{query}/llms.txt (for package changelog index)
148
+ */
149
+ export async function tryFetchLlmsTxt({
150
+ baseUrl,
151
+ query,
152
+ }: {
153
+ baseUrl: string;
154
+ query: string;
155
+ }): Promise<string> {
156
+ const normalizedQuery = query.startsWith("/") ? query.slice(1) : query;
157
+
158
+ const urls = [
159
+ `${baseUrl}/llms/${normalizedQuery}.txt`,
160
+ `${baseUrl}/llms/${normalizedQuery}/llms.txt`,
161
+ ];
162
+
163
+ let lastError: unknown;
164
+
165
+ for (const url of urls) {
166
+ let response: Response;
167
+ try {
168
+ response = await fetchWithTimeout(url);
169
+ } catch (error) {
170
+ lastError = error;
171
+ continue;
172
+ }
173
+
174
+ if (response.ok) {
175
+ return response.text();
176
+ }
177
+
178
+ // 404 → try next URL candidate
179
+ if (response.status === 404) {
180
+ lastError = new CliError({
181
+ message: `llms.txt를 찾을 수 없어요: ${normalizedQuery}`,
182
+ hint: `다음 경로를 시도했어요:\n${urls.map((u) => ` - ${u}`).join("\n")}`,
183
+ });
184
+ continue;
185
+ }
186
+
187
+ // Non-404 errors (5xx, 401, etc.) — propagate immediately
188
+ throw new CliError({
189
+ message: `llms.txt 요청이 실패했어요: ${response.status} ${response.statusText}`,
190
+ hint: `URL: ${url}`,
191
+ });
192
+ }
193
+
194
+ throw (
195
+ lastError ??
196
+ new CliError({
197
+ message: `llms.txt를 찾을 수 없어요: ${normalizedQuery}`,
198
+ hint: `다음 경로를 시도했어요:\n${urls.map((u) => ` - ${u}`).join("\n")}`,
199
+ })
200
+ );
201
+ }
202
+
111
203
  export async function fetchRegistryItems({
112
204
  baseUrl,
113
205
  framework,
@@ -31,7 +31,7 @@ export async function installDependencies({ cwd, deps, dev = false }: InstallDep
31
31
 
32
32
  const isDev = dev ? "-D" : null;
33
33
  const addCommand = packageManager === "npm" ? "install" : "add";
34
- const command = [addCommand, isDev, ...depsToInstall].filter(Boolean);
34
+ const command = [addCommand, isDev, ...depsToInstall].filter((v): v is string => Boolean(v));
35
35
  const commandLabel = `${packageManager} ${command.join(" ")}`;
36
36
 
37
37
  try {
@@ -1,4 +1,4 @@
1
- import type { PublicRegistry, PublicRegistryItem } from "@/src/schema";
1
+ import type { PublicRegistry } from "@/src/schema";
2
2
 
3
3
  export function resolveDependencies({
4
4
  selectedItemKeys,
@@ -13,7 +13,7 @@ export function resolveDependencies({
13
13
  const registryItemsToAdd: { registryId: string; items: PublicRegistry["items"] }[] = [];
14
14
  const npmDependenciesToAdd = new Set<string>();
15
15
 
16
- function collectRegistryItemsToAdd(registryId: string, item: PublicRegistryItem) {
16
+ function collectRegistryItemsToAdd(registryId: string, item: PublicRegistry["items"][number]) {
17
17
  const registryFoundToAdd = registryItemsToAdd.find((r) => r.registryId === registryId);
18
18
 
19
19
  // if already added, skip