@simplysm/sd-cli 14.0.97 → 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.
- package/dist/commands/init/generators/client.d.ts.map +1 -1
- package/dist/commands/init/generators/client.js +2 -1
- package/dist/commands/init/generators/client.js.map +1 -1
- package/dist/commands/init/generators/root.d.ts.map +1 -1
- package/dist/commands/init/generators/root.js +0 -1
- package/dist/commands/init/generators/root.js.map +1 -1
- package/dist/commands/init/init-client.d.ts +5 -0
- package/dist/commands/init/init-client.d.ts.map +1 -0
- package/dist/commands/init/init-client.js +117 -0
- package/dist/commands/init/init-client.js.map +1 -0
- package/dist/commands/init/init.d.ts.map +1 -1
- package/dist/commands/init/init.js +1 -2
- package/dist/commands/init/init.js.map +1 -1
- package/dist/commands/init/normalize.d.ts.map +1 -1
- package/dist/commands/init/normalize.js +3 -1
- package/dist/commands/init/normalize.js.map +1 -1
- package/dist/commands/init/patch.d.ts +16 -0
- package/dist/commands/init/patch.d.ts.map +1 -0
- package/dist/commands/init/patch.js +247 -0
- package/dist/commands/init/patch.js.map +1 -0
- package/dist/commands/init/prompts.d.ts +6 -1
- package/dist/commands/init/prompts.d.ts.map +1 -1
- package/dist/commands/init/prompts.js +46 -31
- package/dist/commands/init/prompts.js.map +1 -1
- package/dist/commands/init/recover.d.ts +12 -0
- package/dist/commands/init/recover.d.ts.map +1 -0
- package/dist/commands/init/recover.js +183 -0
- package/dist/commands/init/recover.js.map +1 -0
- package/dist/commands/init/ts-ast.d.ts +8 -0
- package/dist/commands/init/ts-ast.d.ts.map +1 -0
- package/dist/commands/init/ts-ast.js +39 -0
- package/dist/commands/init/ts-ast.js.map +1 -0
- package/dist/commands/init/types.d.ts +2 -0
- package/dist/commands/init/types.d.ts.map +1 -1
- package/dist/commands/init/validate.d.ts +3 -1
- package/dist/commands/init/validate.d.ts.map +1 -1
- package/dist/commands/init/validate.js +15 -0
- package/dist/commands/init/validate.js.map +1 -1
- package/dist/esbuild/esbuild-client-config.d.ts.map +1 -1
- package/dist/esbuild/esbuild-client-config.js +17 -0
- package/dist/esbuild/esbuild-client-config.js.map +1 -1
- package/dist/sd-cli-entry.d.ts.map +1 -1
- package/dist/sd-cli-entry.js +15 -2
- package/dist/sd-cli-entry.js.map +1 -1
- package/package.json +4 -4
- package/src/commands/init/generators/client.ts +6 -1
- package/src/commands/init/generators/root.ts +0 -1
- package/src/commands/init/init-client.ts +155 -0
- package/src/commands/init/init.ts +63 -64
- package/src/commands/init/normalize.ts +3 -1
- package/src/commands/init/patch.ts +306 -0
- package/src/commands/init/prompts.ts +56 -34
- package/src/commands/init/recover.ts +236 -0
- package/src/commands/init/templates/client/public/robots.txt.hbs +2 -0
- package/src/commands/init/templates/client/src/app/home/master/role-permission/role.list.ts.hbs +12 -14
- package/src/commands/init/templates/client/src/app/home/master/user.list.ts.hbs +25 -27
- package/src/commands/init/templates/client/src/app/home/system/data-log/data-log.list.ts.hbs +29 -31
- package/src/commands/init/templates/client/src/app/home/system/system-log/system-log.list.ts.hbs +46 -48
- package/src/commands/init/templates/workspace-root/gitignore +9 -1
- package/src/commands/init/ts-ast.ts +48 -0
- package/src/commands/init/types.ts +2 -0
- package/src/commands/init/validate.ts +24 -1
- package/src/esbuild/esbuild-client-config.ts +17 -0
- package/src/sd-cli-entry.ts +18 -5
- package/tests/init/patch.spec.ts +222 -0
- package/tests/init/recover.spec.ts +236 -0
- package/tests/init/render.spec.ts +19 -0
- package/src/commands/init/templates/client/public/robots.txt +0 -1
- package/src/commands/init/templates/workspace-root/.prettierignore +0 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
/** sd.config.ts 의 `packages` 객체 리터럴 탐색 */
|
|
4
|
+
export function findPackagesObject(sf: ts.SourceFile): ts.ObjectLiteralExpression | undefined {
|
|
5
|
+
let found: ts.ObjectLiteralExpression | undefined;
|
|
6
|
+
const visit = (node: ts.Node): void => {
|
|
7
|
+
if (found != null) return;
|
|
8
|
+
if (
|
|
9
|
+
ts.isPropertyAssignment(node) &&
|
|
10
|
+
getPropertyName(node.name) === "packages" &&
|
|
11
|
+
ts.isObjectLiteralExpression(node.initializer)
|
|
12
|
+
) {
|
|
13
|
+
found = node.initializer;
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
ts.forEachChild(node, visit);
|
|
17
|
+
};
|
|
18
|
+
visit(sf);
|
|
19
|
+
return found;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getPropertyName(propName: ts.PropertyName): string | undefined {
|
|
23
|
+
if (ts.isIdentifier(propName) || ts.isStringLiteral(propName)) return propName.text;
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getProp(
|
|
28
|
+
obj: ts.ObjectLiteralExpression,
|
|
29
|
+
key: string,
|
|
30
|
+
): ts.Expression | undefined {
|
|
31
|
+
for (const p of obj.properties) {
|
|
32
|
+
if (ts.isPropertyAssignment(p) && getPropertyName(p.name) === key) return p.initializer;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getObjectProp(
|
|
38
|
+
obj: ts.ObjectLiteralExpression,
|
|
39
|
+
key: string,
|
|
40
|
+
): ts.ObjectLiteralExpression | undefined {
|
|
41
|
+
const expr = getProp(obj, key);
|
|
42
|
+
return expr != null && ts.isObjectLiteralExpression(expr) ? expr : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getStringProp(obj: ts.ObjectLiteralExpression, key: string): string | undefined {
|
|
46
|
+
const expr = getProp(obj, key);
|
|
47
|
+
return expr != null && ts.isStringLiteral(expr) ? expr.text : undefined;
|
|
48
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import path from "path";
|
|
1
2
|
import { fsx } from "@simplysm/core-node";
|
|
2
|
-
import type { InitInput } from "./types";
|
|
3
|
+
import type { ClientInputSpec, InitInput } from "./types";
|
|
3
4
|
|
|
4
5
|
const KEBAB_CASE_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
5
6
|
|
|
@@ -46,3 +47,25 @@ export function validateInput(input: InitInput): void {
|
|
|
46
47
|
clientNames.add(normalized);
|
|
47
48
|
}
|
|
48
49
|
}
|
|
50
|
+
|
|
51
|
+
/** init client — 신규 클라이언트가 기존 워크스페이스와 충돌하지 않는지 검증 */
|
|
52
|
+
export async function validateInitClientInput(
|
|
53
|
+
cwd: string,
|
|
54
|
+
newClient: ClientInputSpec,
|
|
55
|
+
existingClients: ClientInputSpec[],
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
if (!KEBAB_CASE_RE.test(newClient.name)) {
|
|
58
|
+
throw new Error(`client 이름은 영문 kebab-case 여야 합니다. 입력값: "${newClient.name}"`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const toNormalized = (clientName: string): string =>
|
|
62
|
+
clientName.startsWith("client-") ? clientName : `client-${clientName}`;
|
|
63
|
+
const normalized = toNormalized(newClient.name);
|
|
64
|
+
if (existingClients.some((c) => toNormalized(c.name) === normalized)) {
|
|
65
|
+
throw new Error(`이미 존재하는 client 입니다: "${normalized}"`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (await fsx.exists(path.resolve(cwd, "packages", normalized))) {
|
|
69
|
+
throw new Error(`이미 존재하는 패키지 디렉토리입니다: packages/${normalized}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -175,6 +175,23 @@ export async function createClientEsbuildContext(
|
|
|
175
175
|
entryNames: isDev ? "[name]" : "[name]-[hash]",
|
|
176
176
|
chunkNames: "[name]-[hash]",
|
|
177
177
|
assetNames: isDev ? "[name]" : "[name]-[hash]",
|
|
178
|
+
// CSS/SCSS url() 로 참조하는 폰트·이미지 자산을 outdir로 복사하고 url을 출력 경로로 재작성.
|
|
179
|
+
// esbuild 기본 loader 맵에는 js/ts/css/json/text만 있어, 미설정 시 url(*.woff) 등에서
|
|
180
|
+
// "No loader is configured" 에러가 발생한다.
|
|
181
|
+
loader: {
|
|
182
|
+
".woff": "file",
|
|
183
|
+
".woff2": "file",
|
|
184
|
+
".ttf": "file",
|
|
185
|
+
".eot": "file",
|
|
186
|
+
".otf": "file",
|
|
187
|
+
".svg": "file",
|
|
188
|
+
".png": "file",
|
|
189
|
+
".jpg": "file",
|
|
190
|
+
".jpeg": "file",
|
|
191
|
+
".gif": "file",
|
|
192
|
+
".webp": "file",
|
|
193
|
+
".ico": "file",
|
|
194
|
+
},
|
|
178
195
|
bundle: true,
|
|
179
196
|
splitting: options.legacyModule !== true,
|
|
180
197
|
format: "esm",
|
package/src/sd-cli-entry.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { runWatch } from "./commands/watch";
|
|
|
10
10
|
import { runDev } from "./commands/dev";
|
|
11
11
|
import { runBuild } from "./commands/build";
|
|
12
12
|
import { runInit } from "./commands/init/init";
|
|
13
|
+
import { runInitClient } from "./commands/init/init-client";
|
|
13
14
|
import { runPublish } from "./commands/publish/publish-command";
|
|
14
15
|
import { runReplaceDeps } from "./commands/replace-deps";
|
|
15
16
|
import path from "path";
|
|
@@ -333,11 +334,23 @@ export function createCliParser(argv: string[]): Argv {
|
|
|
333
334
|
},
|
|
334
335
|
)
|
|
335
336
|
.command(
|
|
336
|
-
"init",
|
|
337
|
-
"Bootstrap a new SI workspace via interactive prompts",
|
|
338
|
-
(cmd) =>
|
|
339
|
-
|
|
340
|
-
|
|
337
|
+
"init [kind]",
|
|
338
|
+
"Bootstrap a new SI workspace via interactive prompts (kind=client: add a client package to an existing workspace)",
|
|
339
|
+
(cmd) =>
|
|
340
|
+
cmd
|
|
341
|
+
.version(false)
|
|
342
|
+
.hide("help")
|
|
343
|
+
.positional("kind", {
|
|
344
|
+
describe: "init 대상 (생략: 새 워크스페이스 부트스트랩)",
|
|
345
|
+
type: "string",
|
|
346
|
+
choices: ["client"] as const,
|
|
347
|
+
}),
|
|
348
|
+
async (args) => {
|
|
349
|
+
if (args.kind === "client") {
|
|
350
|
+
await runInitClient({ cwd: process.cwd() });
|
|
351
|
+
} else {
|
|
352
|
+
await runInit({ cwd: process.cwd() });
|
|
353
|
+
}
|
|
341
354
|
},
|
|
342
355
|
)
|
|
343
356
|
.demandCommand(1, "Please specify a command.")
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { normalize } from "../../src/commands/init/normalize";
|
|
5
|
+
import {
|
|
6
|
+
patchAppStructure,
|
|
7
|
+
patchDevService,
|
|
8
|
+
patchRootPackageJson,
|
|
9
|
+
patchSdConfig,
|
|
10
|
+
patchVitestConfig,
|
|
11
|
+
} from "../../src/commands/init/patch";
|
|
12
|
+
import { renderTemplate } from "../../src/commands/init/render";
|
|
13
|
+
import type { ClientInputSpec, InitInput, RenderData } from "../../src/commands/init/types";
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const TPL_ROOT = path.resolve(__dirname, "../../src/commands/init/templates");
|
|
17
|
+
|
|
18
|
+
function buildData(input: InitInput): RenderData {
|
|
19
|
+
return { ...normalize(input), jwtSecret: "test-jwt-secret" };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const baseInput: InitInput = {
|
|
23
|
+
workspaceName: "demo",
|
|
24
|
+
description: "Demo Workspace",
|
|
25
|
+
hasServer: true,
|
|
26
|
+
hasDb: true,
|
|
27
|
+
dbDialect: "mysql",
|
|
28
|
+
dbContextName: "main",
|
|
29
|
+
hasAuth: true,
|
|
30
|
+
userEntityName: "user",
|
|
31
|
+
userEntityLabel: "사용자",
|
|
32
|
+
clients: [{ name: "admin", type: "web", hasRouter: true }],
|
|
33
|
+
serverPort: 40080,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** before: 기존 클라이언트만 / after: 신규 클라이언트 포함 — 라운드트립 동등성 기준 데이터 쌍 */
|
|
37
|
+
function buildPair(
|
|
38
|
+
newClient: ClientInputSpec,
|
|
39
|
+
inputOverride?: Partial<InitInput>,
|
|
40
|
+
): { before: RenderData; after: RenderData; client: RenderData["clients"][number] } {
|
|
41
|
+
const input = { ...baseInput, ...inputOverride };
|
|
42
|
+
const before = buildData(input);
|
|
43
|
+
const after = buildData({ ...input, clients: [...input.clients, newClient] });
|
|
44
|
+
const client = after.clients[after.clients.length - 1];
|
|
45
|
+
return { before, after, client };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("patchSdConfig", () => {
|
|
49
|
+
it("web 클라이언트 추가 — init 동등 결과", async () => {
|
|
50
|
+
const { before, after, client } = buildPair({ name: "portal", type: "web", hasRouter: true });
|
|
51
|
+
const tplPath = path.join(TPL_ROOT, "workspace-root/sd.config.ts.hbs");
|
|
52
|
+
const source = await renderTemplate(tplPath, before);
|
|
53
|
+
const expected = await renderTemplate(tplPath, after);
|
|
54
|
+
|
|
55
|
+
const r = patchSdConfig(source, after, client);
|
|
56
|
+
|
|
57
|
+
expect(r.patched).toBe(expected);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("mobile 클라이언트 추가 (appId 포함) — init 동등 결과", async () => {
|
|
61
|
+
const { before, after, client } = buildPair(
|
|
62
|
+
{ name: "pda", type: "mobile", hasRouter: false },
|
|
63
|
+
{ mobileAppId: "kr.co.demo.app" },
|
|
64
|
+
);
|
|
65
|
+
const tplPath = path.join(TPL_ROOT, "workspace-root/sd.config.ts.hbs");
|
|
66
|
+
const source = await renderTemplate(tplPath, before);
|
|
67
|
+
const expected = await renderTemplate(tplPath, after);
|
|
68
|
+
|
|
69
|
+
const r = patchSdConfig(source, after, client);
|
|
70
|
+
|
|
71
|
+
expect(r.patched).toBe(expected);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("SSG 클라이언트 추가 — prerender 포함", async () => {
|
|
75
|
+
const { before, after, client } = buildPair({
|
|
76
|
+
name: "www",
|
|
77
|
+
type: "web",
|
|
78
|
+
hasRouter: true,
|
|
79
|
+
useSsg: true,
|
|
80
|
+
});
|
|
81
|
+
const tplPath = path.join(TPL_ROOT, "workspace-root/sd.config.ts.hbs");
|
|
82
|
+
const source = await renderTemplate(tplPath, before);
|
|
83
|
+
const expected = await renderTemplate(tplPath, after);
|
|
84
|
+
|
|
85
|
+
const r = patchSdConfig(source, after, client);
|
|
86
|
+
|
|
87
|
+
expect(r.patched).toBe(expected);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("packages 정의를 찾을 수 없으면 패치 실패 + 스니펫 제공", () => {
|
|
91
|
+
const { after, client } = buildPair({ name: "portal", type: "web", hasRouter: true });
|
|
92
|
+
|
|
93
|
+
const r = patchSdConfig("export default {};\n", after, client);
|
|
94
|
+
|
|
95
|
+
expect(r.patched).toBeUndefined();
|
|
96
|
+
expect(r.snippet).toContain('"client-portal"');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("patchVitestConfig", () => {
|
|
101
|
+
it("클라이언트 project 추가 — init 동등 결과", async () => {
|
|
102
|
+
const { before, after, client } = buildPair({ name: "portal", type: "web", hasRouter: true });
|
|
103
|
+
const tplPath = path.join(TPL_ROOT, "workspace-root/vitest.config.ts.hbs");
|
|
104
|
+
const source = await renderTemplate(tplPath, before);
|
|
105
|
+
const expected = await renderTemplate(tplPath, after);
|
|
106
|
+
|
|
107
|
+
const r = patchVitestConfig(source, client);
|
|
108
|
+
|
|
109
|
+
expect(r.patched).toBe(expected);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("projects 배열을 찾을 수 없으면 패치 실패 + 스니펫 제공", () => {
|
|
113
|
+
const { client } = buildPair({ name: "portal", type: "web", hasRouter: true });
|
|
114
|
+
|
|
115
|
+
const r = patchVitestConfig("export default {};\n", client);
|
|
116
|
+
|
|
117
|
+
expect(r.patched).toBeUndefined();
|
|
118
|
+
expect(r.snippet).toContain("client-portal");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("patchRootPackageJson", () => {
|
|
123
|
+
it("첫 mobile 클라이언트 — run-device 스크립트 추가, init 동등 결과", async () => {
|
|
124
|
+
const { before, after, client } = buildPair(
|
|
125
|
+
{ name: "pda", type: "mobile", hasRouter: false },
|
|
126
|
+
{ mobileAppId: "kr.co.demo.app" },
|
|
127
|
+
);
|
|
128
|
+
const tplPath = path.join(TPL_ROOT, "workspace-root/package.json.hbs");
|
|
129
|
+
const source = await renderTemplate(tplPath, before);
|
|
130
|
+
const expected = await renderTemplate(tplPath, after);
|
|
131
|
+
|
|
132
|
+
const r = patchRootPackageJson(source, client);
|
|
133
|
+
|
|
134
|
+
expect(r.patched).toBe(expected);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("scripts 앵커를 찾을 수 없으면 패치 실패 + 스니펫 제공", () => {
|
|
138
|
+
const { client } = buildPair(
|
|
139
|
+
{ name: "pda", type: "mobile", hasRouter: false },
|
|
140
|
+
{ mobileAppId: "kr.co.demo.app" },
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const r = patchRootPackageJson("{}", client);
|
|
144
|
+
|
|
145
|
+
expect(r.patched).toBeUndefined();
|
|
146
|
+
expect(r.snippet).toContain("run-device");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("patchAppStructure", () => {
|
|
151
|
+
it("라우팅 클라이언트 추가 — export 블록 추가, init 동등 결과", async () => {
|
|
152
|
+
const { before, after, client } = buildPair({ name: "portal", type: "web", hasRouter: true });
|
|
153
|
+
const tplPath = path.join(TPL_ROOT, "common/src/app-structure.ts.hbs");
|
|
154
|
+
const source = await renderTemplate(tplPath, before);
|
|
155
|
+
const expected = await renderTemplate(tplPath, after);
|
|
156
|
+
|
|
157
|
+
const r = patchAppStructure(source, after, client);
|
|
158
|
+
|
|
159
|
+
expect(r.patched).toBe(expected);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("patchDevService", () => {
|
|
164
|
+
const tplPath = path.join(TPL_ROOT, "server/src/services/dev.service.ts.hbs");
|
|
165
|
+
|
|
166
|
+
it("1개 → 2개 — import 추가 + 권한 평탄화 인자가 배열 결합으로 교체", async () => {
|
|
167
|
+
const { before, after, client } = buildPair({ name: "portal", type: "web", hasRouter: true });
|
|
168
|
+
const source = await renderTemplate(tplPath, before);
|
|
169
|
+
const expected = await renderTemplate(tplPath, after);
|
|
170
|
+
|
|
171
|
+
const r = patchDevService(source, before.appStructureNames, after, client);
|
|
172
|
+
|
|
173
|
+
expect(r.patched).toBe(expected);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("2개 → 3개 — 기존 배열 결합에 원소 추가", async () => {
|
|
177
|
+
const twoClients: InitInput = {
|
|
178
|
+
...baseInput,
|
|
179
|
+
clients: [
|
|
180
|
+
{ name: "admin", type: "web", hasRouter: true },
|
|
181
|
+
{ name: "portal", type: "web", hasRouter: true },
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
const before = buildData(twoClients);
|
|
185
|
+
const after = buildData({
|
|
186
|
+
...twoClients,
|
|
187
|
+
clients: [...twoClients.clients, { name: "shop", type: "web", hasRouter: true }],
|
|
188
|
+
});
|
|
189
|
+
const client = after.clients[after.clients.length - 1];
|
|
190
|
+
const source = await renderTemplate(tplPath, before);
|
|
191
|
+
const expected = await renderTemplate(tplPath, after);
|
|
192
|
+
|
|
193
|
+
const r = patchDevService(source, before.appStructureNames, after, client);
|
|
194
|
+
|
|
195
|
+
expect(r.patched).toBe(expected);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("0개 → 1개 (기본 export 워크스페이스) — import·권한 인자가 신규 단독으로 대체, init 동등 결과", async () => {
|
|
199
|
+
const serverOnly: InitInput = { ...baseInput, clients: [] };
|
|
200
|
+
const before = buildData(serverOnly); // appStructureNames = ["appStructureItems"]
|
|
201
|
+
const after = buildData({
|
|
202
|
+
...serverOnly,
|
|
203
|
+
clients: [{ name: "portal", type: "web", hasRouter: true }],
|
|
204
|
+
});
|
|
205
|
+
const client = after.clients[after.clients.length - 1];
|
|
206
|
+
const source = await renderTemplate(tplPath, before);
|
|
207
|
+
const expected = await renderTemplate(tplPath, after);
|
|
208
|
+
|
|
209
|
+
const r = patchDevService(source, [], after, client, "appStructureItems");
|
|
210
|
+
|
|
211
|
+
expect(r.patched).toBe(expected);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("import 앵커를 찾을 수 없으면 패치 실패 + 스니펫 제공", () => {
|
|
215
|
+
const { before, after, client } = buildPair({ name: "portal", type: "web", hasRouter: true });
|
|
216
|
+
|
|
217
|
+
const r = patchDevService("export const x = 1;\n", before.appStructureNames, after, client);
|
|
218
|
+
|
|
219
|
+
expect(r.patched).toBeUndefined();
|
|
220
|
+
expect(r.snippet).toContain(client.appStructureName);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { normalize } from "../../src/commands/init/normalize";
|
|
7
|
+
import { recoverWorkspace } from "../../src/commands/init/recover";
|
|
8
|
+
import { renderTemplate } from "../../src/commands/init/render";
|
|
9
|
+
import type { InitInput, RenderData } from "../../src/commands/init/types";
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const TPL_ROOT = path.resolve(__dirname, "../../src/commands/init/templates");
|
|
13
|
+
|
|
14
|
+
function buildData(input: InitInput): RenderData {
|
|
15
|
+
return { ...normalize(input), jwtSecret: "test-jwt-secret" };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const tmpDirs: string[] = [];
|
|
19
|
+
|
|
20
|
+
async function createTmpDir(): Promise<string> {
|
|
21
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "sd-init-recover-"));
|
|
22
|
+
tmpDirs.push(dir);
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
for (const dir of tmpDirs.splice(0)) {
|
|
28
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** init 템플릿 렌더로 recover 가 읽는 파일들만 기존 워크스페이스 형태로 구성 */
|
|
33
|
+
async function buildWorkspace(input: InitInput): Promise<string> {
|
|
34
|
+
const cwd = await createTmpDir();
|
|
35
|
+
const data = buildData(input);
|
|
36
|
+
|
|
37
|
+
await fs.writeFile(
|
|
38
|
+
path.join(cwd, "package.json"),
|
|
39
|
+
await renderTemplate(path.join(TPL_ROOT, "workspace-root/package.json.hbs"), data),
|
|
40
|
+
);
|
|
41
|
+
await fs.writeFile(
|
|
42
|
+
path.join(cwd, "sd.config.ts"),
|
|
43
|
+
await renderTemplate(path.join(TPL_ROOT, "workspace-root/sd.config.ts.hbs"), data),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (data.hasAuth) {
|
|
47
|
+
const commonSrc = path.join(cwd, "packages/common/src");
|
|
48
|
+
await fs.mkdir(commonSrc, { recursive: true });
|
|
49
|
+
await fs.writeFile(
|
|
50
|
+
path.join(commonSrc, "app-structure.ts"),
|
|
51
|
+
await renderTemplate(path.join(TPL_ROOT, "common/src/app-structure.ts.hbs"), data),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (data.hasDb) {
|
|
56
|
+
const masterDir = path.join(cwd, "packages/common/src", data.dbFolderName, "tables/master");
|
|
57
|
+
await fs.mkdir(masterDir, { recursive: true });
|
|
58
|
+
if (data.hasAuth) {
|
|
59
|
+
await fs.writeFile(path.join(masterDir, `${data.userEntityKebab}.ts`), "");
|
|
60
|
+
await fs.writeFile(path.join(masterDir, `${data.userEntityKebab}-config.ts`), "");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (data.hasClientCommon) {
|
|
65
|
+
await fs.mkdir(path.join(cwd, "packages/client-common/src"), { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const client of data.clients) {
|
|
69
|
+
const srcDir = path.join(cwd, "packages", client.name, "src");
|
|
70
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
71
|
+
if (client.hasRouter) {
|
|
72
|
+
await fs.writeFile(path.join(srcDir, "routes.ts"), "");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return cwd;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("recoverWorkspace", () => {
|
|
80
|
+
it("server+DB(mysql)+인증(user/사용자)+web/mobile 클라이언트 — 전체 라운드트립", async () => {
|
|
81
|
+
const cwd = await buildWorkspace({
|
|
82
|
+
workspaceName: "demo",
|
|
83
|
+
description: "Demo Workspace",
|
|
84
|
+
hasServer: true,
|
|
85
|
+
hasDb: true,
|
|
86
|
+
dbDialect: "mysql",
|
|
87
|
+
dbContextName: "main",
|
|
88
|
+
hasAuth: true,
|
|
89
|
+
userEntityName: "user",
|
|
90
|
+
userEntityLabel: "사용자",
|
|
91
|
+
mobileAppId: "kr.co.demo.app",
|
|
92
|
+
clients: [
|
|
93
|
+
{ name: "admin", type: "web", hasRouter: true },
|
|
94
|
+
{ name: "pda", type: "mobile", hasRouter: false },
|
|
95
|
+
],
|
|
96
|
+
serverPort: 40080,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const r = await recoverWorkspace(cwd);
|
|
100
|
+
|
|
101
|
+
expect(r.input.workspaceName).toBe("demo");
|
|
102
|
+
expect(r.input.description).toBe("Demo Workspace");
|
|
103
|
+
expect(r.input.hasServer).toBe(true);
|
|
104
|
+
expect(r.input.hasDb).toBe(true);
|
|
105
|
+
expect(r.input.dbDialect).toBe("mysql");
|
|
106
|
+
expect(r.input.dbContextName).toBe("main");
|
|
107
|
+
expect(r.input.hasAuth).toBe(true);
|
|
108
|
+
expect(r.input.userEntityName).toBe("user");
|
|
109
|
+
expect(r.input.userEntityLabel).toBe("사용자");
|
|
110
|
+
expect(r.input.mobileAppId).toBe("kr.co.demo.app");
|
|
111
|
+
expect(r.input.serverPort).toBe(40080);
|
|
112
|
+
expect(r.input.clients).toEqual([
|
|
113
|
+
{ name: "client-admin", type: "web", hasRouter: true, useSsg: false },
|
|
114
|
+
{ name: "client-pda", type: "mobile", hasRouter: false, useSsg: false },
|
|
115
|
+
]);
|
|
116
|
+
expect(r.hasClientCommonPkg).toBe(true);
|
|
117
|
+
expect(r.appStructureExportNames).toEqual(["adminAppStructureItems"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("server=N + web 클라이언트 1개 — client-common 부재", async () => {
|
|
121
|
+
const cwd = await buildWorkspace({
|
|
122
|
+
workspaceName: "front-only",
|
|
123
|
+
description: "Front Only",
|
|
124
|
+
hasServer: false,
|
|
125
|
+
hasDb: false,
|
|
126
|
+
clients: [{ name: "admin", type: "web", hasRouter: true }],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const r = await recoverWorkspace(cwd);
|
|
130
|
+
|
|
131
|
+
expect(r.input.hasServer).toBe(false);
|
|
132
|
+
expect(r.input.hasDb).toBe(false);
|
|
133
|
+
expect(r.input.hasAuth).toBe(false);
|
|
134
|
+
expect(r.input.clients).toEqual([
|
|
135
|
+
{ name: "client-admin", type: "web", hasRouter: true, useSsg: false },
|
|
136
|
+
]);
|
|
137
|
+
expect(r.hasClientCommonPkg).toBe(false);
|
|
138
|
+
expect(r.appStructureExportNames).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("server=Y DB=N — DB 정보 없음", async () => {
|
|
142
|
+
const cwd = await buildWorkspace({
|
|
143
|
+
workspaceName: "no-db",
|
|
144
|
+
description: "No DB",
|
|
145
|
+
hasServer: true,
|
|
146
|
+
hasDb: false,
|
|
147
|
+
clients: [{ name: "admin", type: "web", hasRouter: true }],
|
|
148
|
+
serverPort: 41000,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const r = await recoverWorkspace(cwd);
|
|
152
|
+
|
|
153
|
+
expect(r.input.hasServer).toBe(true);
|
|
154
|
+
expect(r.input.hasDb).toBe(false);
|
|
155
|
+
expect(r.input.dbDialect).toBeUndefined();
|
|
156
|
+
expect(r.input.hasAuth).toBe(false);
|
|
157
|
+
expect(r.input.serverPort).toBe(41000);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("커스텀 사용자 엔티티(employee/직원) + 커스텀 DB context 복원", async () => {
|
|
161
|
+
const cwd = await buildWorkspace({
|
|
162
|
+
workspaceName: "custom",
|
|
163
|
+
description: "Custom",
|
|
164
|
+
hasServer: true,
|
|
165
|
+
hasDb: true,
|
|
166
|
+
dbDialect: "postgresql",
|
|
167
|
+
dbContextName: "erp",
|
|
168
|
+
hasAuth: true,
|
|
169
|
+
userEntityName: "employee",
|
|
170
|
+
userEntityLabel: "직원",
|
|
171
|
+
clients: [{ name: "admin", type: "web", hasRouter: true }],
|
|
172
|
+
serverPort: 40080,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const r = await recoverWorkspace(cwd);
|
|
176
|
+
|
|
177
|
+
expect(r.input.dbDialect).toBe("postgresql");
|
|
178
|
+
expect(r.input.dbContextName).toBe("erp");
|
|
179
|
+
expect(r.input.userEntityName).toBe("employee");
|
|
180
|
+
expect(r.input.userEntityLabel).toBe("직원");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("SSG 클라이언트 — prerender 로 useSsg 복원", async () => {
|
|
184
|
+
const cwd = await buildWorkspace({
|
|
185
|
+
workspaceName: "ssg",
|
|
186
|
+
description: "SSG",
|
|
187
|
+
hasServer: true,
|
|
188
|
+
hasDb: false,
|
|
189
|
+
clients: [{ name: "www", type: "web", hasRouter: true, useSsg: true }],
|
|
190
|
+
serverPort: 40080,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const r = await recoverWorkspace(cwd);
|
|
194
|
+
|
|
195
|
+
expect(r.input.clients).toEqual([
|
|
196
|
+
{ name: "client-www", type: "web", hasRouter: true, useSsg: true },
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("클라이언트 0개 init 워크스페이스 — appStructure fallback 식별자 복원", async () => {
|
|
201
|
+
const cwd = await buildWorkspace({
|
|
202
|
+
workspaceName: "server-only",
|
|
203
|
+
description: "Server Only",
|
|
204
|
+
hasServer: true,
|
|
205
|
+
hasDb: true,
|
|
206
|
+
dbDialect: "mysql",
|
|
207
|
+
dbContextName: "main",
|
|
208
|
+
hasAuth: true,
|
|
209
|
+
userEntityName: "user",
|
|
210
|
+
userEntityLabel: "사용자",
|
|
211
|
+
clients: [],
|
|
212
|
+
serverPort: 40080,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const r = await recoverWorkspace(cwd);
|
|
216
|
+
|
|
217
|
+
expect(r.input.clients).toEqual([]);
|
|
218
|
+
expect(r.appStructureExportNames).toEqual(["appStructureItems"]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("sd.config.ts 없음 — 에러", async () => {
|
|
222
|
+
const cwd = await createTmpDir();
|
|
223
|
+
await fs.writeFile(
|
|
224
|
+
path.join(cwd, "package.json"),
|
|
225
|
+
JSON.stringify({ name: "broken", description: "" }),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
await expect(recoverWorkspace(cwd)).rejects.toThrow();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("루트 package.json 없음 — 에러", async () => {
|
|
232
|
+
const cwd = await createTmpDir();
|
|
233
|
+
|
|
234
|
+
await expect(recoverWorkspace(cwd)).rejects.toThrow();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -991,6 +991,25 @@ describe("SSG 클라이언트 스캐폴드", () => {
|
|
|
991
991
|
expect(out).not.toContain("provideClientHydration");
|
|
992
992
|
});
|
|
993
993
|
|
|
994
|
+
it("client/public/robots.txt: SSG ON → 전체 허용", async () => {
|
|
995
|
+
const out = await renderTemplate(
|
|
996
|
+
path.join(TPL_ROOT, "client/public/robots.txt.hbs"),
|
|
997
|
+
ssgCtx(true),
|
|
998
|
+
);
|
|
999
|
+
expect(out).toContain("User-agent: *");
|
|
1000
|
+
expect(out).toContain("Allow: /");
|
|
1001
|
+
expect(out).not.toContain("Disallow: /");
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("client/public/robots.txt: SSG OFF → 전체 차단", async () => {
|
|
1005
|
+
const out = await renderTemplate(
|
|
1006
|
+
path.join(TPL_ROOT, "client/public/robots.txt.hbs"),
|
|
1007
|
+
ssgCtx(false),
|
|
1008
|
+
);
|
|
1009
|
+
expect(out).toContain("User-agent: *");
|
|
1010
|
+
expect(out).toContain("Disallow: /");
|
|
1011
|
+
});
|
|
1012
|
+
|
|
994
1013
|
it("client/main.server.ts: 서버 부트스트랩 default export + 최소 provider", async () => {
|
|
995
1014
|
const out = await renderTemplate(path.join(TPL_ROOT, "client/src/main.server.ts.hbs"), ssgCtx(true));
|
|
996
1015
|
expect(out).toContain('import { provideServerRendering } from "@angular/platform-server";');
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
User-agent: * Disallow: /
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
*.hbs
|