@simplysm/sd-cli 14.0.98 → 14.0.99

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.
Files changed (60) hide show
  1. package/dist/commands/init/generators/client.d.ts.map +1 -1
  2. package/dist/commands/init/generators/client.js +2 -1
  3. package/dist/commands/init/generators/client.js.map +1 -1
  4. package/dist/commands/init/init-client.d.ts +5 -0
  5. package/dist/commands/init/init-client.d.ts.map +1 -0
  6. package/dist/commands/init/init-client.js +117 -0
  7. package/dist/commands/init/init-client.js.map +1 -0
  8. package/dist/commands/init/normalize.d.ts.map +1 -1
  9. package/dist/commands/init/normalize.js +3 -1
  10. package/dist/commands/init/normalize.js.map +1 -1
  11. package/dist/commands/init/patch.d.ts +16 -0
  12. package/dist/commands/init/patch.d.ts.map +1 -0
  13. package/dist/commands/init/patch.js +247 -0
  14. package/dist/commands/init/patch.js.map +1 -0
  15. package/dist/commands/init/prompts.d.ts +6 -1
  16. package/dist/commands/init/prompts.d.ts.map +1 -1
  17. package/dist/commands/init/prompts.js +46 -31
  18. package/dist/commands/init/prompts.js.map +1 -1
  19. package/dist/commands/init/recover.d.ts +12 -0
  20. package/dist/commands/init/recover.d.ts.map +1 -0
  21. package/dist/commands/init/recover.js +183 -0
  22. package/dist/commands/init/recover.js.map +1 -0
  23. package/dist/commands/init/ts-ast.d.ts +8 -0
  24. package/dist/commands/init/ts-ast.d.ts.map +1 -0
  25. package/dist/commands/init/ts-ast.js +39 -0
  26. package/dist/commands/init/ts-ast.js.map +1 -0
  27. package/dist/commands/init/types.d.ts +2 -0
  28. package/dist/commands/init/types.d.ts.map +1 -1
  29. package/dist/commands/init/validate.d.ts +3 -1
  30. package/dist/commands/init/validate.d.ts.map +1 -1
  31. package/dist/commands/init/validate.js +15 -0
  32. package/dist/commands/init/validate.js.map +1 -1
  33. package/dist/esbuild/esbuild-client-config.d.ts.map +1 -1
  34. package/dist/esbuild/esbuild-client-config.js +17 -0
  35. package/dist/esbuild/esbuild-client-config.js.map +1 -1
  36. package/dist/sd-cli-entry.d.ts.map +1 -1
  37. package/dist/sd-cli-entry.js +15 -2
  38. package/dist/sd-cli-entry.js.map +1 -1
  39. package/package.json +4 -4
  40. package/src/commands/init/generators/client.ts +6 -1
  41. package/src/commands/init/init-client.ts +155 -0
  42. package/src/commands/init/normalize.ts +3 -1
  43. package/src/commands/init/patch.ts +306 -0
  44. package/src/commands/init/prompts.ts +56 -34
  45. package/src/commands/init/recover.ts +236 -0
  46. package/src/commands/init/templates/client/public/robots.txt.hbs +2 -0
  47. package/src/commands/init/templates/client/src/app/home/master/role-permission/role.list.ts.hbs +12 -14
  48. package/src/commands/init/templates/client/src/app/home/master/user.list.ts.hbs +25 -27
  49. package/src/commands/init/templates/client/src/app/home/system/data-log/data-log.list.ts.hbs +29 -31
  50. package/src/commands/init/templates/client/src/app/home/system/system-log/system-log.list.ts.hbs +46 -48
  51. package/src/commands/init/templates/workspace-root/gitignore +9 -1
  52. package/src/commands/init/ts-ast.ts +48 -0
  53. package/src/commands/init/types.ts +2 -0
  54. package/src/commands/init/validate.ts +24 -1
  55. package/src/esbuild/esbuild-client-config.ts +17 -0
  56. package/src/sd-cli-entry.ts +18 -5
  57. package/tests/init/patch.spec.ts +222 -0
  58. package/tests/init/recover.spec.ts +236 -0
  59. package/tests/init/render.spec.ts +19 -0
  60. package/src/commands/init/templates/client/public/robots.txt +0 -1
@@ -0,0 +1,155 @@
1
+ import crypto from "crypto";
2
+ import path from "path";
3
+ import { createLogger } from "@simplysm/core-common";
4
+ import { fsx } from "@simplysm/core-node";
5
+ import { generateClient } from "./generators/client";
6
+ import { normalize } from "./normalize";
7
+ import {
8
+ type FilePatch,
9
+ patchAppStructure,
10
+ patchDevService,
11
+ patchRootPackageJson,
12
+ patchSdConfig,
13
+ patchVitestConfig,
14
+ } from "./patch";
15
+ import { promptInitClient } from "./prompts";
16
+ import { recoverWorkspace } from "./recover";
17
+ import type { InitInput, RenderData } from "./types";
18
+ import { validateInitClientInput } from "./validate";
19
+ import { shellSpawn } from "../../utils/shell-spawn";
20
+
21
+ const logger = createLogger("sd:cli:init-client");
22
+
23
+ export interface InitClientOptions {
24
+ cwd: string;
25
+ }
26
+
27
+ export async function runInitClient(opts: InitClientOptions): Promise<void> {
28
+ const recovered = await recoverWorkspace(opts.cwd);
29
+
30
+ const { client: newClientInput, mobileAppId } = await promptInitClient(
31
+ recovered.input.mobileAppId != null,
32
+ );
33
+ await validateInitClientInput(opts.cwd, newClientInput, recovered.input.clients);
34
+
35
+ const mergedInput: InitInput = {
36
+ ...recovered.input,
37
+ clients: [...recovered.input.clients, newClientInput],
38
+ mobileAppId: recovered.input.mobileAppId ?? mobileAppId,
39
+ };
40
+ const data: RenderData = {
41
+ ...normalize(mergedInput),
42
+ // client-common 은 새로 생성하지 않음 — 실재 여부에 맞춰 신규 클라이언트의 의존 포함 여부 결정
43
+ hasClientCommon: recovered.hasClientCommonPkg,
44
+ jwtSecret: crypto.randomBytes(32).toString("hex"),
45
+ };
46
+ const client = data.clients[data.clients.length - 1];
47
+ const outDir = path.resolve(opts.cwd, "packages", client.name);
48
+
49
+ //-- mobile 은 루트 package.json 의 description 이 capacitor 앱 표시 이름(appName)으로 들어감
50
+ if (client.isMobile && data.description.trim().length === 0) {
51
+ throw new Error(
52
+ "mobile 클라이언트는 capacitor 앱 표시 이름으로 루트 package.json 의 description 을 사용합니다. description 을 채운 뒤 다시 실행하세요.",
53
+ );
54
+ }
55
+
56
+ //-- 공유 파일 패치를 메모리에서 준비 (원자성: 전부 준비된 뒤에만 기록)
57
+ const isFirstMobile =
58
+ client.isMobile && recovered.input.clients.every((c) => c.type !== "mobile");
59
+ const needsAppStructure = data.hasAuth && client.hasRouter;
60
+
61
+ //-- 기존 라우팅 클라이언트 0개면 기본 메뉴 구조 export("appStructureItems")는
62
+ // 파일에 남기되 서버 초기화의 import·권한 계산에서 신규 export 로 대체 (init 동등)
63
+ const isFirstRouterClient = recovered.input.clients.every((c) => !c.hasRouter);
64
+ const fallbackExportName =
65
+ isFirstRouterClient && recovered.appStructureExportNames.includes("appStructureItems")
66
+ ? "appStructureItems"
67
+ : undefined;
68
+ const combineExportNames =
69
+ fallbackExportName != null
70
+ ? recovered.appStructureExportNames.filter((n) => n !== fallbackExportName)
71
+ : recovered.appStructureExportNames;
72
+
73
+ const patchTargets: { relPath: string; apply: (src: string) => FilePatch }[] = [
74
+ { relPath: "sd.config.ts", apply: (src) => patchSdConfig(src, data, client) },
75
+ { relPath: "vitest.config.ts", apply: (src) => patchVitestConfig(src, client) },
76
+ ...(isFirstMobile
77
+ ? [{ relPath: "package.json", apply: (src: string) => patchRootPackageJson(src, client) }]
78
+ : []),
79
+ ...(needsAppStructure
80
+ ? [
81
+ {
82
+ relPath: "packages/common/src/app-structure.ts",
83
+ apply: (src: string) => patchAppStructure(src, data, client),
84
+ },
85
+ {
86
+ relPath: "packages/server/src/services/dev.service.ts",
87
+ apply: (src: string) =>
88
+ patchDevService(src, combineExportNames, data, client, fallbackExportName),
89
+ },
90
+ ]
91
+ : []),
92
+ ];
93
+
94
+ const writes: { absPath: string; content: string }[] = [];
95
+ const manualGuides: { relPath: string; snippet: string }[] = [];
96
+ for (const target of patchTargets) {
97
+ const absPath = path.resolve(opts.cwd, target.relPath);
98
+ if (!(await fsx.exists(absPath))) {
99
+ const r = target.apply("");
100
+ manualGuides.push({ relPath: target.relPath, snippet: r.snippet });
101
+ continue;
102
+ }
103
+ const r = target.apply(await fsx.read(absPath));
104
+ if (r.patched != null) {
105
+ writes.push({ absPath, content: r.patched });
106
+ } else {
107
+ manualGuides.push({ relPath: target.relPath, snippet: r.snippet });
108
+ }
109
+ }
110
+
111
+ logger.info(`클라이언트 패키지 생성 시작: packages/${client.name}`);
112
+
113
+ //-- 신규 패키지 생성 (실패 시 생성분 제거 후 중단 — 공유 파일은 아직 미기록)
114
+ try {
115
+ await generateClient(opts.cwd, data, client);
116
+ } catch (err) {
117
+ try {
118
+ await fsx.rm(outDir);
119
+ } catch {
120
+ const leftovers = await fsx.glob(path.join(outDir, "**/*")).catch(() => []);
121
+ logger.error(
122
+ `생성 잔여물 제거 실패 — packages/${client.name} 를 수동 삭제하세요. 남은 항목:\n` +
123
+ [outDir, ...leftovers].map((p) => ` - ${p}`).join("\n"),
124
+ );
125
+ }
126
+ throw err;
127
+ }
128
+
129
+ //-- 공유 파일 일괄 기록
130
+ for (const w of writes) {
131
+ await fsx.write(w.absPath, w.content);
132
+ }
133
+
134
+ logger.info(`클라이언트 패키지 생성 완료: packages/${client.name}`);
135
+
136
+ for (const guide of manualGuides) {
137
+ logger.error(
138
+ `${guide.relPath} 자동 갱신 실패 — 아래 내용을 직접 반영하세요:\n${guide.snippet}`,
139
+ );
140
+ }
141
+
142
+ logger.info("의존성 설치 중...");
143
+ await shellSpawn("pnpm", ["install", "--config.dangerously-allow-all-builds=true"], {
144
+ cwd: opts.cwd,
145
+ stdio: "inherit",
146
+ });
147
+ logger.info("의존성 설치 완료.");
148
+
149
+ if (client.isMobile) {
150
+ logger.info("다음 단계:");
151
+ logger.info(
152
+ ` 1. (prod 빌드 필요 시) capacitor 키스토어를 packages/${client.name}/res/ 에 배치 후 sd.config.ts 의 capacitor.platform.android.sign 블록 수동 추가`,
153
+ );
154
+ }
155
+ }
@@ -50,6 +50,7 @@ export function normalize(input: InitInput): NormalizedInput {
50
50
  const isMobile = c.type === "mobile";
51
51
  const hasRouter = isMobile ? false : c.hasRouter;
52
52
  const baseName = name.startsWith("client-") ? name.slice("client-".length) : name;
53
+ const useSsg = hasRouter && !isMobile && (c.useSsg ?? false);
53
54
  return {
54
55
  name,
55
56
  type: c.type,
@@ -57,7 +58,8 @@ export function normalize(input: InitInput): NormalizedInput {
57
58
  isMobile,
58
59
  appStructureName: `${str.toCamelCase(baseName)}AppStructureItems`,
59
60
  needsNgIcons: (hasRouter && hasAuth) || hasDb,
60
- useSsg: hasRouter && !isMobile && (c.useSsg ?? false),
61
+ useSsg,
62
+ robotsDirective: useSsg ? "Allow: /" : "Disallow: /",
61
63
  };
62
64
  });
63
65
 
@@ -0,0 +1,306 @@
1
+ import ts from "typescript";
2
+ import { findPackagesObject, getPropertyName, getProp } from "./ts-ast";
3
+ import type { ClientSpec, RenderData } from "./types";
4
+
5
+ /** 공유 파일 1개에 대한 패치 결과 */
6
+ export interface FilePatch {
7
+ /** 패치 성공 시 새 파일 내용. 앵커 탐색 실패 시 undefined */
8
+ patched?: string;
9
+ /** 수동 반영 안내용 스니펫 */
10
+ snippet: string;
11
+ }
12
+
13
+ interface TextEdit {
14
+ start: number;
15
+ end: number;
16
+ text: string;
17
+ }
18
+
19
+ function applyEdits(source: string, edits: TextEdit[]): string {
20
+ let result = source;
21
+ for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
22
+ result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
23
+ }
24
+ return result;
25
+ }
26
+
27
+ function lineStartOf(source: string, pos: number): number {
28
+ return source.lastIndexOf("\n", pos - 1) + 1;
29
+ }
30
+
31
+ function parseTs(fileName: string, source: string): ts.SourceFile {
32
+ return ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true);
33
+ }
34
+
35
+ //-- sd.config.ts
36
+
37
+ function buildSdConfigClientBlock(data: RenderData, client: ClientSpec): string {
38
+ const lines: string[] = [];
39
+ lines.push(` "${client.name}": {`);
40
+ lines.push(` target: "client",`);
41
+ if (data.hasServer) {
42
+ lines.push(` server: "server",`);
43
+ }
44
+ if (client.useSsg) {
45
+ lines.push(` prerender: ["/"],`);
46
+ }
47
+ if (client.isMobile) {
48
+ lines.push(` pwa: false,`);
49
+ lines.push(` capacitor: {`);
50
+ lines.push(` appId: "${data.mobileAppId}",`);
51
+ lines.push(` appName: "${data.description}",`);
52
+ lines.push(` icon: "res/icon.png",`);
53
+ lines.push(` platform: {`);
54
+ lines.push(` android: {},`);
55
+ lines.push(` },`);
56
+ lines.push(` },`);
57
+ }
58
+ lines.push(` },`);
59
+ return lines.join("\n") + "\n";
60
+ }
61
+
62
+ export function patchSdConfig(source: string, data: RenderData, client: ClientSpec): FilePatch {
63
+ const block = buildSdConfigClientBlock(data, client);
64
+ const snippet = block;
65
+
66
+ const sf = parseTs("sd.config.ts", source);
67
+ const packagesObj = findPackagesObject(sf);
68
+ if (packagesObj == null) return { snippet };
69
+
70
+ const closeBracePos = packagesObj.getEnd() - 1;
71
+ const lastProp = packagesObj.properties.at(-1);
72
+ if (lastProp != null && !source.slice(lastProp.end, closeBracePos).includes(",")) {
73
+ return { snippet };
74
+ }
75
+
76
+ const insertPos = lineStartOf(source, closeBracePos);
77
+ return {
78
+ patched: applyEdits(source, [{ start: insertPos, end: insertPos, text: block }]),
79
+ snippet,
80
+ };
81
+ }
82
+
83
+ //-- vitest.config.ts
84
+
85
+ function buildVitestProjectBlock(client: ClientSpec): string {
86
+ return (
87
+ [
88
+ ` {`,
89
+ ` extends: true,`,
90
+ ` plugins: [sdAngularPlugin({ pkg: "${client.name}" })],`,
91
+ ` test: {`,
92
+ ` name: "${client.name}",`,
93
+ ` setupFiles: ["packages/${client.name}/tests/vitest.setup.ts"],`,
94
+ ` include: ["packages/${client.name}/tests/**/*.spec.{ts,js,mjs,cjs}"],`,
95
+ ` browser: {`,
96
+ ` provider: playwright(),`,
97
+ ` enabled: true,`,
98
+ ` headless: true,`,
99
+ ` screenshotFailures: false,`,
100
+ ` instances: [{ browser: "chromium", viewport: { width: 1920, height: 1080 } }],`,
101
+ ` },`,
102
+ ` },`,
103
+ ` },`,
104
+ ].join("\n") + "\n"
105
+ );
106
+ }
107
+
108
+ export function patchVitestConfig(source: string, client: ClientSpec): FilePatch {
109
+ const block = buildVitestProjectBlock(client);
110
+ const snippet = block;
111
+
112
+ const sf = parseTs("vitest.config.ts", source);
113
+
114
+ let projectsArray: ts.ArrayLiteralExpression | undefined;
115
+ const visit = (node: ts.Node): void => {
116
+ if (projectsArray != null) return;
117
+ if (
118
+ ts.isPropertyAssignment(node) &&
119
+ getPropertyName(node.name) === "projects" &&
120
+ ts.isArrayLiteralExpression(node.initializer)
121
+ ) {
122
+ projectsArray = node.initializer;
123
+ return;
124
+ }
125
+ ts.forEachChild(node, visit);
126
+ };
127
+ visit(sf);
128
+ if (projectsArray == null) return { snippet };
129
+
130
+ const closeBracketPos = projectsArray.getEnd() - 1;
131
+ const lastElement = projectsArray.elements.at(-1);
132
+ if (lastElement != null && !source.slice(lastElement.end, closeBracketPos).includes(",")) {
133
+ return { snippet };
134
+ }
135
+
136
+ const insertPos = lineStartOf(source, closeBracketPos);
137
+ return {
138
+ patched: applyEdits(source, [{ start: insertPos, end: insertPos, text: block }]),
139
+ snippet,
140
+ };
141
+ }
142
+
143
+ //-- 루트 package.json
144
+
145
+ export function patchRootPackageJson(source: string, client: ClientSpec): FilePatch {
146
+ const block = ` "run-device": "sd-cli device -t ${client.name}",\n "-----": "",\n`;
147
+ const snippet = block;
148
+
149
+ const sf = ts.parseJsonText("package.json", source);
150
+ const rootStmt = sf.statements.at(0);
151
+ if (rootStmt == null || !ts.isObjectLiteralExpression(rootStmt.expression)) {
152
+ return { snippet };
153
+ }
154
+ const scriptsExpr = getProp(rootStmt.expression, "scripts");
155
+ if (scriptsExpr == null || !ts.isObjectLiteralExpression(scriptsExpr)) {
156
+ return { snippet };
157
+ }
158
+
159
+ const separatorProp = scriptsExpr.properties.find(
160
+ (p) => ts.isPropertyAssignment(p) && getPropertyName(p.name) === "----",
161
+ );
162
+ if (separatorProp != null) {
163
+ const lineEnd = source.indexOf("\n", separatorProp.end);
164
+ if (lineEnd < 0) return { snippet };
165
+ const insertPos = lineEnd + 1;
166
+ return {
167
+ patched: applyEdits(source, [{ start: insertPos, end: insertPos, text: block }]),
168
+ snippet,
169
+ };
170
+ }
171
+
172
+ const replaceDepsProp = scriptsExpr.properties.find(
173
+ (p) => ts.isPropertyAssignment(p) && getPropertyName(p.name) === "replace-deps",
174
+ );
175
+ if (replaceDepsProp != null) {
176
+ const insertPos = lineStartOf(source, replaceDepsProp.getStart(sf));
177
+ return {
178
+ patched: applyEdits(source, [{ start: insertPos, end: insertPos, text: block }]),
179
+ snippet,
180
+ };
181
+ }
182
+
183
+ return { snippet };
184
+ }
185
+
186
+ //-- common/src/app-structure.ts
187
+
188
+ function buildAppStructureBlock(data: RenderData, client: ClientSpec): string {
189
+ return `export const ${client.appStructureName}: AppStructureItem[] = [
190
+ { title: "메인화면", code: "main", isNotMenu: true },
191
+ { title: "내 정보 수정", code: "my-info", isNotMenu: true },
192
+
193
+ {
194
+ code: "master",
195
+ title: "기준정보",
196
+ icon: tablerDatabase,
197
+ children: [
198
+ { code: "role-permission", title: "역할/권한", perms: ["use", "edit"] },
199
+ { code: "${data.userEntityKebab}", title: "${data.userEntityLabel}", perms: ["use", "edit"] },
200
+ ],
201
+ },
202
+ {
203
+ code: "system",
204
+ title: "시스템",
205
+ icon: tablerSettings,
206
+ children: [
207
+ { code: "data-log", title: "데이터 변경내역", perms: ["use"] },
208
+ { code: "system-log", title: "시스템 로그", perms: ["use"] },
209
+ ],
210
+ },
211
+ ];
212
+ `;
213
+ }
214
+
215
+ export function patchAppStructure(source: string, data: RenderData, client: ClientSpec): FilePatch {
216
+ const block = buildAppStructureBlock(data, client);
217
+ const base = source.endsWith("\n") ? source : `${source}\n`;
218
+ return { patched: `${base}\n${block}`, snippet: block };
219
+ }
220
+
221
+ //-- server/src/services/dev.service.ts
222
+
223
+ export function patchDevService(
224
+ source: string,
225
+ existingExportNames: string[],
226
+ data: RenderData,
227
+ client: ClientSpec,
228
+ /** 권한 계산·import 에서 신규 export 로 대체할 기본 export 이름 (기존 라우팅 클라이언트 0개 케이스) */
229
+ replaceExportName?: string,
230
+ ): FilePatch {
231
+ const combinedNames = [...existingExportNames, client.appStructureName];
232
+ const combinedExpr =
233
+ combinedNames.length > 1
234
+ ? `[${combinedNames.map((n) => `...${n}`).join(", ")}]`
235
+ : combinedNames[0];
236
+ const snippet = [
237
+ `- import 에 ${client.appStructureName} 추가 (from "@${data.workspaceName}/common")`,
238
+ `- getFlatPermissions 첫 번째 인자를 ${combinedExpr} 로 변경`,
239
+ ].join("\n");
240
+
241
+ const sf = parseTs("dev.service.ts", source);
242
+
243
+ //-- ① import 명세자 추가 (대체 대상이 있으면 그 명세자를 신규 이름으로 교체)
244
+ const moduleName = `@${data.workspaceName}/common`;
245
+ let importSpecifiers: ts.NodeArray<ts.ImportSpecifier> | undefined;
246
+ for (const stmt of sf.statements) {
247
+ if (
248
+ ts.isImportDeclaration(stmt) &&
249
+ ts.isStringLiteral(stmt.moduleSpecifier) &&
250
+ stmt.moduleSpecifier.text === moduleName &&
251
+ stmt.importClause?.namedBindings != null &&
252
+ ts.isNamedImports(stmt.importClause.namedBindings) &&
253
+ stmt.importClause.namedBindings.elements.length > 0
254
+ ) {
255
+ importSpecifiers = stmt.importClause.namedBindings.elements;
256
+ break;
257
+ }
258
+ }
259
+ if (importSpecifiers == null) return { snippet };
260
+
261
+ const replaceTarget =
262
+ replaceExportName != null
263
+ ? importSpecifiers.find((s) => s.name.text === replaceExportName)
264
+ : undefined;
265
+ const importEdit: TextEdit =
266
+ replaceTarget != null
267
+ ? {
268
+ start: replaceTarget.getStart(sf),
269
+ end: replaceTarget.end,
270
+ text: client.appStructureName,
271
+ }
272
+ : (() => {
273
+ const lastSpecifier = importSpecifiers[importSpecifiers.length - 1];
274
+ return {
275
+ start: lastSpecifier.end,
276
+ end: lastSpecifier.end,
277
+ text: `, ${client.appStructureName}`,
278
+ };
279
+ })();
280
+
281
+ //-- ② getFlatPermissions 첫 번째 인자 교체
282
+ let permsArg: ts.Expression | undefined;
283
+ const visit = (node: ts.Node): void => {
284
+ if (permsArg != null) return;
285
+ if (
286
+ ts.isCallExpression(node) &&
287
+ ts.isIdentifier(node.expression) &&
288
+ node.expression.text === "getFlatPermissions" &&
289
+ node.arguments.length > 0
290
+ ) {
291
+ permsArg = node.arguments[0];
292
+ return;
293
+ }
294
+ ts.forEachChild(node, visit);
295
+ };
296
+ visit(sf);
297
+ if (permsArg == null) return { snippet };
298
+
299
+ return {
300
+ patched: applyEdits(source, [
301
+ importEdit,
302
+ { start: permsArg.getStart(sf), end: permsArg.end, text: combinedExpr },
303
+ ]),
304
+ snippet,
305
+ };
306
+ }
@@ -49,7 +49,7 @@ export async function promptInit(workspaceNameDefault: string): Promise<InitInpu
49
49
  });
50
50
 
51
51
  hasAuth = await confirm({
52
- message: "사용자 인증을 사용할까요? (사용자/역할/권한/로그 테이블 부트스트랩)",
52
+ message: "사용자 인증을 사용할까요? (사용자/역할/권한)",
53
53
  default: true,
54
54
  });
55
55
  if (hasAuth) {
@@ -92,43 +92,12 @@ export async function promptInit(workspaceNameDefault: string): Promise<InitInpu
92
92
  : await confirm({ message: "다른 client 를 더 추가할까요?", default: false });
93
93
  if (!shouldAdd) break;
94
94
 
95
- const clientName = await input({
96
- message: "client 이름 (예: admin → 자동으로 client-admin):",
97
- validate: (v) => KEBAB_CASE_RE.test(v) || "영문 kebab-case 만 허용됩니다.",
98
- });
99
- const clientType = await select<ClientType>({
100
- message: "client 타입:",
101
- choices: [
102
- { name: "일반 웹", value: "web" },
103
- { name: "모바일 (capacitor)", value: "mobile" },
104
- ],
105
- });
106
-
107
- let hasRouter = false;
108
- if (clientType !== "mobile") {
109
- hasRouter = await confirm({
110
- message: `${clientName}: 라우팅 (@angular/router) 을 쓸까요?`,
111
- default: true,
112
- });
113
- }
114
-
115
- let useSsg = false;
116
- if (clientType === "web" && hasRouter) {
117
- useSsg = await confirm({
118
- message: `${clientName}: SSG (SEO 용 빌드 타임 프리렌더) 를 쓸까요?`,
119
- default: false,
120
- });
121
- }
122
-
123
- clients.push({ name: clientName, type: clientType, hasRouter, useSsg });
95
+ clients.push(await promptClientSpec());
124
96
  }
125
97
 
126
98
  let mobileAppId: string | undefined;
127
99
  if (clients.some((c) => c.type === "mobile")) {
128
- mobileAppId = await input({
129
- message: "capacitor appId (reverse-DNS, 예: kr.co.example.app):",
130
- validate: (v) => APPID_RE.test(v) || "reverse-DNS 형식이어야 합니다.",
131
- });
100
+ mobileAppId = await promptMobileAppId();
132
101
  }
133
102
 
134
103
  return {
@@ -146,3 +115,56 @@ export async function promptInit(workspaceNameDefault: string): Promise<InitInpu
146
115
  serverPort,
147
116
  };
148
117
  }
118
+
119
+ /** init client — 신규 클라이언트 질문 (워크스페이스에 capacitor appId 가 없고 mobile 이면 appId 질문 포함) */
120
+ export async function promptInitClient(
121
+ hasExistingMobileAppId: boolean,
122
+ ): Promise<{ client: ClientInputSpec; mobileAppId?: string }> {
123
+ const client = await promptClientSpec();
124
+
125
+ let mobileAppId: string | undefined;
126
+ if (client.type === "mobile" && !hasExistingMobileAppId) {
127
+ mobileAppId = await promptMobileAppId();
128
+ }
129
+
130
+ return { client, mobileAppId };
131
+ }
132
+
133
+ async function promptClientSpec(): Promise<ClientInputSpec> {
134
+ const clientName = await input({
135
+ message: "client 이름 (예: admin → 자동으로 client-admin):",
136
+ validate: (v) => KEBAB_CASE_RE.test(v) || "영문 kebab-case 만 허용됩니다.",
137
+ });
138
+ const clientType = await select<ClientType>({
139
+ message: "client 타입:",
140
+ choices: [
141
+ { name: "일반 웹", value: "web" },
142
+ { name: "모바일 (capacitor)", value: "mobile" },
143
+ ],
144
+ });
145
+
146
+ let hasRouter = false;
147
+ if (clientType !== "mobile") {
148
+ hasRouter = await confirm({
149
+ message: `${clientName}: 라우팅 (@angular/router) 을 쓸까요?`,
150
+ default: true,
151
+ });
152
+ }
153
+
154
+ let useSsg = false;
155
+ if (clientType === "web" && hasRouter) {
156
+ useSsg = await confirm({
157
+ message: `${clientName}: SSG (SEO 용 빌드 타임 프리렌더) 를 쓸까요?`,
158
+ default: false,
159
+ });
160
+ }
161
+
162
+ return { name: clientName, type: clientType, hasRouter, useSsg };
163
+ }
164
+
165
+ async function promptMobileAppId(): Promise<string> {
166
+ return input({
167
+ message: "capacitor appId (reverse-DNS, 예: kr.co.example.app):",
168
+ validate: (v) => APPID_RE.test(v) || "reverse-DNS 형식이어야 합니다.",
169
+ });
170
+ }