@seed-design/cli 1.2.0 → 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("의존성 설치가 완료됐어요.");
@@ -15,14 +15,14 @@ export async function writeRegistryItemSnippets({
15
15
  cwd,
16
16
  baseUrl,
17
17
  config,
18
- overwrite = false,
18
+ onDiff,
19
19
  }: {
20
20
  registryItemsToAdd: { registryId: string; items: PublicRegistry["items"] }[];
21
21
  rootPath: string;
22
22
  cwd: string;
23
23
  baseUrl: string;
24
24
  config: Config;
25
- overwrite?: boolean;
25
+ onDiff?: "overwrite" | "backup";
26
26
  }) {
27
27
  const registryResult: { name: string; path: string }[] = [];
28
28
 
@@ -74,9 +74,17 @@ export async function writeRegistryItemSnippets({
74
74
  continue;
75
75
  }
76
76
 
77
+ const filename = path.basename(filePath);
78
+ const ext = path.extname(filePath);
79
+ const base = path.basename(filePath, ext);
80
+ const timestamp = Date.now();
81
+ const legacyFilename = `legacy-${base}-${timestamp}${ext}`;
82
+
77
83
  // diff가 있는 경우
78
- if (!overwrite) {
79
- // diff 생성 및 색상 적용
84
+ const action = await (async () => {
85
+ if (onDiff) return onDiff;
86
+
87
+ // interactive mode
80
88
  const patch = createPatch(relativePath, existingContent, content);
81
89
  const coloredDiff = colorize(patch);
82
90
 
@@ -85,13 +93,7 @@ export async function writeRegistryItemSnippets({
85
93
  );
86
94
  p.log.message(coloredDiff);
87
95
 
88
- const filename = path.basename(filePath);
89
- const ext = path.extname(filePath);
90
- const base = path.basename(filePath, ext);
91
- const timestamp = Date.now();
92
- const legacyFilename = `legacy-${base}-${timestamp}${ext}`;
93
-
94
- const action = await p.select({
96
+ return p.select({
95
97
  message:
96
98
  "현재 파일에 스타일 변경, 로깅 등 커스터마이징이 적용되어 있는 경우 신규 파일에 동일한 커스터마이징을 적용하는 것을 검토해보세요.",
97
99
  options: [
@@ -103,20 +105,20 @@ export async function writeRegistryItemSnippets({
103
105
  { value: "skip", label: "새 파일 받지 않고 그대로 두기" },
104
106
  ],
105
107
  });
108
+ })();
106
109
 
107
- if (p.isCancel(action) || action === "skip") {
108
- p.log.info(`${highlight(relativePath)}: 파일을 받지 않고 건너뛰었어요.`);
109
- continue;
110
- }
111
-
112
- if (action === "backup") {
113
- const dir = path.dirname(filePath);
114
- const legacyPath = path.join(dir, legacyFilename);
115
- await fs.rename(filePath, legacyPath);
116
- p.log.info(
117
- `${highlight(relativePath)}: 기존 파일을 ${highlight(path.relative(cwd, legacyPath))}로 옮겼어요.`,
118
- );
119
- }
110
+ if (p.isCancel(action) || action === "skip") {
111
+ p.log.info(`${highlight(relativePath)}: 파일을 받지 않고 건너뛰었어요.`);
112
+ continue;
113
+ }
114
+
115
+ if (action === "backup") {
116
+ const dir = path.dirname(filePath);
117
+ const legacyPath = path.join(dir, legacyFilename);
118
+ await fs.rename(filePath, legacyPath);
119
+ p.log.info(
120
+ `${highlight(relativePath)}: 기존 파일을 ${highlight(path.relative(cwd, legacyPath))}로 옮겼어요.`,
121
+ );
120
122
  }
121
123
  }
122
124