@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.
@@ -0,0 +1,160 @@
1
+ import * as p from "@clack/prompts";
2
+ import { ZodError } from "zod";
3
+ import { highlight } from "./color";
4
+
5
+ interface CliErrorOptions {
6
+ message: string;
7
+ hint?: string;
8
+ details?: string[];
9
+ cause?: unknown;
10
+ }
11
+
12
+ interface HandleCliErrorOptions {
13
+ defaultMessage: string;
14
+ defaultHint?: string;
15
+ verbose?: boolean;
16
+ }
17
+
18
+ interface ExecaLikeError {
19
+ command?: string;
20
+ escapedCommand?: string;
21
+ exitCode?: number;
22
+ shortMessage?: string;
23
+ stderr?: string;
24
+ stdout?: string;
25
+ stack?: string;
26
+ }
27
+
28
+ export class CliError extends Error {
29
+ hint?: string;
30
+ details: string[];
31
+
32
+ constructor({ message, hint, details = [], cause }: CliErrorOptions) {
33
+ super(message, { cause });
34
+ this.name = "CliError";
35
+ this.hint = hint;
36
+ this.details = details;
37
+ }
38
+ }
39
+
40
+ export class CliCancelError extends Error {
41
+ constructor(message = "작업이 취소됐어요.") {
42
+ super(message);
43
+ this.name = "CliCancelError";
44
+ }
45
+ }
46
+
47
+ export function isCliCancelError(error: unknown): error is CliCancelError {
48
+ return error instanceof CliCancelError;
49
+ }
50
+
51
+ export function isVerboseMode(options: unknown): boolean {
52
+ if (!options || typeof options !== "object") return false;
53
+ if (!("verbose" in options)) return false;
54
+
55
+ return options.verbose === true;
56
+ }
57
+
58
+ function normalizeError(
59
+ error: unknown,
60
+ defaultHint?: string,
61
+ ): {
62
+ reason: string;
63
+ hint?: string;
64
+ details: string[];
65
+ stack?: string;
66
+ } {
67
+ if (error instanceof CliError) {
68
+ return {
69
+ reason: error.message,
70
+ hint: error.hint ?? defaultHint,
71
+ details: error.details,
72
+ stack: toStack(error.cause ?? error),
73
+ };
74
+ }
75
+
76
+ if (error instanceof ZodError) {
77
+ const issues = error.issues.map((issue) => {
78
+ const path = issue.path.join(".") || "(root)";
79
+ return `${path}: ${issue.message}`;
80
+ });
81
+
82
+ return {
83
+ reason: "입력값 또는 설정 파일 형식이 올바르지 않아요.",
84
+ hint: defaultHint,
85
+ details: issues,
86
+ stack: error.stack,
87
+ };
88
+ }
89
+
90
+ if (error instanceof Error) {
91
+ const execaLike = error as ExecaLikeError;
92
+ const details: string[] = [];
93
+
94
+ if (execaLike.escapedCommand || execaLike.command) {
95
+ details.push(`실행 명령어: ${execaLike.escapedCommand ?? execaLike.command}`);
96
+ }
97
+ if (typeof execaLike.exitCode === "number") {
98
+ details.push(`종료 코드: ${execaLike.exitCode}`);
99
+ }
100
+ if (execaLike.stderr?.trim()) {
101
+ details.push(`stderr: ${execaLike.stderr.trim()}`);
102
+ } else if (execaLike.stdout?.trim()) {
103
+ details.push(`stdout: ${execaLike.stdout.trim()}`);
104
+ }
105
+
106
+ return {
107
+ reason: execaLike.shortMessage ?? error.message,
108
+ hint: defaultHint,
109
+ details,
110
+ stack: error.stack,
111
+ };
112
+ }
113
+
114
+ if (typeof error === "string") {
115
+ return {
116
+ reason: error,
117
+ hint: defaultHint,
118
+ details: [],
119
+ };
120
+ }
121
+
122
+ return {
123
+ reason: "알 수 없는 오류가 발생했어요.",
124
+ hint: defaultHint,
125
+ details: [],
126
+ };
127
+ }
128
+
129
+ function toStack(error: unknown): string | undefined {
130
+ if (error instanceof Error) {
131
+ return error.stack;
132
+ }
133
+
134
+ return undefined;
135
+ }
136
+
137
+ export function handleCliError(
138
+ error: unknown,
139
+ { defaultMessage, defaultHint, verbose = false }: HandleCliErrorOptions,
140
+ ): void {
141
+ const normalized = normalizeError(error, defaultHint);
142
+
143
+ p.log.error(defaultMessage);
144
+ p.log.error(`원인: ${normalized.reason}`);
145
+
146
+ for (const detail of normalized.details) {
147
+ p.log.info(detail);
148
+ }
149
+
150
+ if (normalized.hint) {
151
+ p.log.info(`해결 힌트: ${normalized.hint}`);
152
+ }
153
+
154
+ if (verbose && normalized.stack) {
155
+ p.log.message(highlight("\n[verbose] stack trace"));
156
+ p.log.message(normalized.stack);
157
+ }
158
+
159
+ p.outro(highlight("작업에 실패했어요."));
160
+ }
@@ -1,9 +1,8 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { cosmiconfig } from "cosmiconfig";
3
- import { execa } from "execa";
4
3
  import { z } from "zod";
5
- import { highlight } from "./color";
6
- import { getPackageManager } from "./get-package-manager";
4
+ import { CliCancelError, CliError } from "./error";
5
+ import { DEFAULT_INIT_CONFIG, writeInitConfigFile } from "./init-config";
7
6
 
8
7
  const MODULE_NAME = "seed-design";
9
8
 
@@ -23,31 +22,45 @@ export const configSchema = z
23
22
 
24
23
  export type Config = z.infer<typeof configSchema>;
25
24
 
26
- export async function getConfig(cwd: string) {
25
+ export async function getConfig(cwd: string): Promise<Config> {
27
26
  const config = await getRawConfig(cwd);
28
- if (!config) return null;
27
+ if (config) return config;
29
28
 
30
- return configSchema.parse(config);
31
- }
29
+ p.log.error("프로젝트 루트 경로에 `seed-design.json` 파일이 없어요.");
32
30
 
33
- export async function getRawConfig(cwd: string): Promise<Config | null> {
34
- try {
35
- const configResult = await explorer.search(cwd);
36
- return configSchema.parse(configResult.config);
37
- } catch {
38
- p.log.error("프로젝트 루트 경로에 `seed-design.json` 파일이 없어요.");
31
+ const isConfirm = await p.confirm({ message: "seed-design.json 파일을 생성하시겠어요?" });
39
32
 
40
- const isConfirm = await p.confirm({ message: "seed-design.json 파일을 생성하시겠어요?" });
41
-
42
- if (!isConfirm) {
43
- p.outro(highlight("작업이 취소됐어요."));
44
- process.exit(1);
45
- }
33
+ if (p.isCancel(isConfirm) || !isConfirm) {
34
+ throw new CliCancelError();
35
+ }
46
36
 
47
- const packageManager = await getPackageManager(cwd);
37
+ try {
38
+ await writeInitConfigFile({
39
+ cwd,
40
+ config: DEFAULT_INIT_CONFIG,
41
+ });
42
+ p.log.message("seed-design.json 파일이 생성됐어요.");
43
+ return configSchema.parse(DEFAULT_INIT_CONFIG);
44
+ } catch (error) {
45
+ throw new CliError({
46
+ message: "seed-design.json 파일 생성에 실패했어요.",
47
+ hint: "디렉토리 쓰기 권한과 경로를 확인한 뒤 다시 시도해보세요.",
48
+ cause: error,
49
+ });
50
+ }
51
+ }
48
52
 
49
- await execa(packageManager, ["seed-design", "init", "--default"], { cwd });
53
+ export async function getRawConfig(cwd: string): Promise<Config | null> {
54
+ const configResult = await explorer.search(cwd);
55
+ if (!configResult || configResult.isEmpty) return null;
50
56
 
51
- p.log.message("seed-design.json 파일이 생성됐어요.");
57
+ try {
58
+ return configSchema.parse(configResult.config);
59
+ } catch (error) {
60
+ throw new CliError({
61
+ message: "seed-design.json 형식이 올바르지 않아요.",
62
+ hint: "https://seed-design.com/react/getting-started/cli/configuration 문서를 참고해 주세요.",
63
+ cause: error,
64
+ });
52
65
  }
53
66
  }
@@ -4,15 +4,15 @@ import type { PackageJson } from "type-fest";
4
4
 
5
5
  const PACKAGE_JSON = "package.json";
6
6
 
7
- function getPackagePath() {
8
- const packageJsonPath = findup(PACKAGE_JSON);
7
+ function getPackagePath(cwd = process.cwd()) {
8
+ const packageJsonPath = findup(PACKAGE_JSON, { cwd });
9
9
  if (!packageJsonPath) {
10
10
  throw new Error("No package.json file found in the project.");
11
11
  }
12
12
  return packageJsonPath;
13
13
  }
14
14
 
15
- export function getPackageInfo() {
16
- const packageJsonPath = getPackagePath();
15
+ export function getPackageInfo(cwd = process.cwd()) {
16
+ const packageJsonPath = getPackagePath(cwd);
17
17
  return fs.readJSONSync(packageJsonPath) as PackageJson;
18
18
  }
@@ -0,0 +1,67 @@
1
+ import * as p from "@clack/prompts";
2
+ import fs from "fs-extra";
3
+ import path from "path";
4
+ import { highlight } from "./color";
5
+ import { CliCancelError } from "./error";
6
+
7
+ import type { Config } from "./get-config";
8
+
9
+ export const DEFAULT_INIT_CONFIG: Config = {
10
+ rsc: false,
11
+ tsx: true,
12
+ path: "./seed-design",
13
+ telemetry: true,
14
+ };
15
+
16
+ export async function promptInitConfig(): Promise<Config> {
17
+ const group = await p.group(
18
+ {
19
+ tsx: () =>
20
+ p.confirm({
21
+ message: `${highlight("TypeScript")}를 사용중이신가요?`,
22
+ initialValue: DEFAULT_INIT_CONFIG.tsx,
23
+ }),
24
+ rsc: () =>
25
+ p.confirm({
26
+ message: `${highlight("React Server Components")}를 사용중이신가요?`,
27
+ initialValue: DEFAULT_INIT_CONFIG.rsc,
28
+ }),
29
+ path: () =>
30
+ p.text({
31
+ message: `${highlight("seed-design 폴더")} 경로를 입력해주세요. (기본값은 프로젝트 루트에 생성됩니다.)`,
32
+ initialValue: DEFAULT_INIT_CONFIG.path,
33
+ defaultValue: DEFAULT_INIT_CONFIG.path,
34
+ placeholder: DEFAULT_INIT_CONFIG.path,
35
+ }),
36
+ telemetry: () =>
37
+ p.confirm({
38
+ message: `개선을 위해 ${highlight("익명 사용 데이터")}를 수집할까요?`,
39
+ initialValue: DEFAULT_INIT_CONFIG.telemetry,
40
+ }),
41
+ },
42
+ {
43
+ onCancel: () => {
44
+ throw new CliCancelError();
45
+ },
46
+ },
47
+ );
48
+
49
+ return group;
50
+ }
51
+
52
+ export async function writeInitConfigFile({
53
+ cwd,
54
+ config,
55
+ }: {
56
+ cwd: string;
57
+ config: Config;
58
+ }): Promise<{ relativePath: string; targetPath: string }> {
59
+ const targetPath = path.resolve(cwd, "seed-design.json");
60
+ await fs.writeFile(targetPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
61
+ const relativePath = path.relative(process.cwd(), targetPath);
62
+
63
+ return {
64
+ relativePath,
65
+ targetPath,
66
+ };
67
+ }
@@ -1,5 +1,6 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { execa } from "execa";
3
+ import { CliError } from "./error";
3
4
  import { getPackageManager } from "./get-package-manager";
4
5
  import { getPackageInfo } from "./get-package-info";
5
6
 
@@ -12,7 +13,7 @@ interface InstallDependenciesProps {
12
13
  export async function installDependencies({ cwd, deps, dev = false }: InstallDependenciesProps) {
13
14
  const { start, stop } = p.spinner();
14
15
  const packageManager = await getPackageManager(cwd);
15
- const packageInfo = getPackageInfo();
16
+ const packageInfo = getPackageInfo(cwd);
16
17
 
17
18
  // 이미 설치된 의존성 필터링
18
19
  const existingDeps = {
@@ -31,12 +32,18 @@ export async function installDependencies({ cwd, deps, dev = false }: InstallDep
31
32
  const isDev = dev ? "-D" : null;
32
33
  const addCommand = packageManager === "npm" ? "install" : "add";
33
34
  const command = [addCommand, isDev, ...depsToInstall].filter(Boolean);
35
+ const commandLabel = `${packageManager} ${command.join(" ")}`;
34
36
 
35
37
  try {
36
38
  await execa(packageManager, command, { cwd });
37
39
  } catch (error) {
38
- console.error(`의존성 설치 실패: ${error}`);
39
- process.exit(1);
40
+ stop("의존성 설치에 실패했어요.");
41
+ throw new CliError({
42
+ message: "의존성 설치에 실패했어요.",
43
+ hint: "네트워크 상태를 확인하고, 설치 명령어를 직접 실행해 상세 오류를 확인해보세요.",
44
+ details: [`실행 명령어: ${commandLabel}`],
45
+ cause: error,
46
+ });
40
47
  }
41
48
 
42
49
  stop("의존성 설치가 완료됐어요.");