@simplysm/sd-cli 13.0.0-beta.45 → 13.0.0-beta.47

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 (102) hide show
  1. package/README.md +3 -3
  2. package/dist/builders/BaseBuilder.js.map +0 -1
  3. package/dist/builders/DtsBuilder.js.map +0 -1
  4. package/dist/builders/LibraryBuilder.js.map +0 -1
  5. package/dist/builders/index.js.map +0 -1
  6. package/dist/builders/types.js.map +0 -1
  7. package/dist/capacitor/capacitor.js.map +0 -1
  8. package/dist/commands/add-client.js.map +0 -1
  9. package/dist/commands/add-server.js.map +0 -1
  10. package/dist/commands/build.js.map +0 -1
  11. package/dist/commands/dev.js.map +0 -1
  12. package/dist/commands/device.js.map +0 -1
  13. package/dist/commands/init.js.map +0 -1
  14. package/dist/commands/lint.js.map +0 -1
  15. package/dist/commands/publish.js.map +0 -1
  16. package/dist/commands/typecheck.js.map +0 -1
  17. package/dist/commands/watch.js.map +0 -1
  18. package/dist/electron/electron.js.map +0 -1
  19. package/dist/index.js.map +0 -1
  20. package/dist/infra/ResultCollector.js.map +0 -1
  21. package/dist/infra/SignalHandler.js.map +0 -1
  22. package/dist/infra/WorkerManager.js.map +0 -1
  23. package/dist/infra/index.js.map +0 -1
  24. package/dist/orchestrators/WatchOrchestrator.js.map +0 -1
  25. package/dist/orchestrators/index.js.map +0 -1
  26. package/dist/sd-cli.js.map +0 -1
  27. package/dist/sd-config.types.js.map +0 -1
  28. package/dist/utils/build-env.js.map +0 -1
  29. package/dist/utils/config-editor.js.map +0 -1
  30. package/dist/utils/copy-src.js.map +0 -1
  31. package/dist/utils/esbuild-config.d.ts +1 -0
  32. package/dist/utils/esbuild-config.d.ts.map +1 -1
  33. package/dist/utils/esbuild-config.js +13 -2
  34. package/dist/utils/esbuild-config.js.map +1 -2
  35. package/dist/utils/listr-manager.js.map +0 -1
  36. package/dist/utils/output-utils.js.map +0 -1
  37. package/dist/utils/package-utils.js.map +0 -1
  38. package/dist/utils/replace-deps.js.map +0 -1
  39. package/dist/utils/sd-config.js.map +0 -1
  40. package/dist/utils/spawn.js.map +0 -1
  41. package/dist/utils/tailwind-config-deps.js.map +0 -1
  42. package/dist/utils/template.js.map +0 -1
  43. package/dist/utils/tsconfig.js.map +0 -1
  44. package/dist/utils/typecheck-serialization.js.map +0 -1
  45. package/dist/utils/vite-config.js.map +0 -1
  46. package/dist/utils/worker-events.js.map +0 -1
  47. package/dist/workers/client.worker.js.map +0 -1
  48. package/dist/workers/dts.worker.js.map +0 -1
  49. package/dist/workers/library.worker.js.map +0 -1
  50. package/dist/workers/server-runtime.worker.js.map +0 -1
  51. package/dist/workers/server.worker.js.map +0 -1
  52. package/package.json +6 -4
  53. package/src/builders/BaseBuilder.ts +141 -0
  54. package/src/builders/DtsBuilder.ts +138 -0
  55. package/src/builders/LibraryBuilder.ts +161 -0
  56. package/src/builders/index.ts +4 -0
  57. package/src/builders/types.ts +55 -0
  58. package/src/capacitor/capacitor.ts +827 -0
  59. package/src/commands/add-client.ts +135 -0
  60. package/src/commands/add-server.ts +150 -0
  61. package/src/commands/build.ts +475 -0
  62. package/src/commands/dev.ts +602 -0
  63. package/src/commands/device.ts +151 -0
  64. package/src/commands/init.ts +104 -0
  65. package/src/commands/lint.ts +216 -0
  66. package/src/commands/publish.ts +836 -0
  67. package/src/commands/typecheck.ts +329 -0
  68. package/src/commands/watch.ts +38 -0
  69. package/src/electron/electron.ts +329 -0
  70. package/src/index.ts +1 -0
  71. package/src/infra/ResultCollector.ts +81 -0
  72. package/src/infra/SignalHandler.ts +52 -0
  73. package/src/infra/WorkerManager.ts +65 -0
  74. package/src/infra/index.ts +3 -0
  75. package/src/orchestrators/WatchOrchestrator.ts +211 -0
  76. package/src/orchestrators/index.ts +1 -0
  77. package/src/sd-cli.ts +307 -0
  78. package/src/sd-config.types.ts +271 -0
  79. package/src/utils/build-env.ts +12 -0
  80. package/src/utils/config-editor.ts +131 -0
  81. package/src/utils/copy-src.ts +60 -0
  82. package/src/utils/esbuild-config.ts +263 -0
  83. package/src/utils/listr-manager.ts +89 -0
  84. package/src/utils/output-utils.ts +61 -0
  85. package/src/utils/package-utils.ts +63 -0
  86. package/src/utils/replace-deps.ts +163 -0
  87. package/src/utils/sd-config.ts +44 -0
  88. package/src/utils/spawn.ts +79 -0
  89. package/src/utils/tailwind-config-deps.ts +95 -0
  90. package/src/utils/template.ts +51 -0
  91. package/src/utils/tsconfig.ts +111 -0
  92. package/src/utils/typecheck-serialization.ts +82 -0
  93. package/src/utils/vite-config.ts +184 -0
  94. package/src/utils/worker-events.ts +102 -0
  95. package/src/workers/client.worker.ts +236 -0
  96. package/src/workers/dts.worker.ts +416 -0
  97. package/src/workers/library.worker.ts +245 -0
  98. package/src/workers/server-runtime.worker.ts +154 -0
  99. package/src/workers/server.worker.ts +435 -0
  100. package/templates/add-client/__CLIENT__/package.json.hbs +1 -1
  101. package/templates/add-server/__SERVER__/package.json.hbs +2 -2
  102. package/templates/init/package.json.hbs +3 -3
@@ -0,0 +1,827 @@
1
+ import path from "path";
2
+ import { fsExists, fsMkdir, fsRead, fsReadJson, fsWrite, fsWriteJson, fsGlob, fsCopy, fsRm } from "@simplysm/core-node";
3
+ import { env } from "@simplysm/core-common";
4
+ import { consola } from "consola";
5
+ import sharp from "sharp";
6
+ import type { SdCapacitorConfig } from "../sd-config.types";
7
+ import { spawn } from "../utils/spawn";
8
+
9
+ /**
10
+ * package.json 타입
11
+ */
12
+ interface NpmConfig {
13
+ name: string;
14
+ version: string;
15
+ dependencies?: Record<string, string>;
16
+ devDependencies?: Record<string, string>;
17
+ peerDependencies?: Record<string, string>;
18
+ volta?: unknown;
19
+ }
20
+
21
+ /**
22
+ * 설정 검증 에러
23
+ */
24
+ class CapacitorConfigError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "CapacitorConfigError";
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Capacitor 프로젝트 관리 클래스
33
+ *
34
+ * - Capacitor 프로젝트 초기화
35
+ * - Android APK/AAB 빌드
36
+ * - 디바이스에서 앱 실행
37
+ */
38
+ export class Capacitor {
39
+ private static readonly _ANDROID_KEYSTORE_FILE_NAME = "android.keystore";
40
+ private static readonly _LOCK_FILE_NAME = ".capacitor.lock";
41
+ private static readonly _logger = consola.withTag("sd:cli:capacitor");
42
+
43
+ private readonly _capPath: string;
44
+ private readonly _platforms: string[];
45
+ private readonly _npmConfig: NpmConfig;
46
+
47
+ private constructor(
48
+ private readonly _pkgPath: string,
49
+ private readonly _config: SdCapacitorConfig,
50
+ npmConfig: NpmConfig,
51
+ ) {
52
+ this._platforms = Object.keys(this._config.platform ?? {});
53
+ this._npmConfig = npmConfig;
54
+ this._capPath = path.resolve(this._pkgPath, ".capacitor");
55
+ }
56
+
57
+ /**
58
+ * Capacitor 인스턴스 생성 (설정 검증 포함)
59
+ */
60
+ static async create(pkgPath: string, config: SdCapacitorConfig): Promise<Capacitor> {
61
+ // F5: 런타임 설정 검증
62
+ Capacitor._validateConfig(config);
63
+
64
+ const npmConfig = await fsReadJson<NpmConfig>(path.resolve(pkgPath, "package.json"));
65
+ return new Capacitor(pkgPath, config, npmConfig);
66
+ }
67
+
68
+ /**
69
+ * F5: 설정 검증
70
+ */
71
+ private static _validateConfig(config: SdCapacitorConfig): void {
72
+ if (typeof config.appId !== "string" || config.appId.trim() === "") {
73
+ throw new CapacitorConfigError("capacitor.appId는 필수입니다.");
74
+ }
75
+ if (!/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/i.test(config.appId)) {
76
+ throw new CapacitorConfigError(`capacitor.appId 형식이 올바르지 않습니다: ${config.appId}`);
77
+ }
78
+ if (typeof config.appName !== "string" || config.appName.trim() === "") {
79
+ throw new CapacitorConfigError("capacitor.appName은 필수입니다.");
80
+ }
81
+ if (config.platform != null) {
82
+ const platforms = Object.keys(config.platform);
83
+ for (const p of platforms) {
84
+ if (p !== "android") {
85
+ throw new CapacitorConfigError(`지원하지 않는 플랫폼: ${p} (현재 android만 지원)`);
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * 명령어 실행 (로깅 포함)
93
+ */
94
+ private async _exec(cmd: string, args: string[], cwd: string): Promise<string> {
95
+ Capacitor._logger.debug(`실행 명령: ${cmd} ${args.join(" ")}`);
96
+ const result = await spawn(cmd, args, { cwd });
97
+ Capacitor._logger.debug(`실행 결과: ${result}`);
98
+ return result;
99
+ }
100
+
101
+ /**
102
+ * F10: 동시 실행 방지를 위한 잠금 획득
103
+ */
104
+ private async _acquireLock(): Promise<void> {
105
+ const lockPath = path.resolve(this._capPath, Capacitor._LOCK_FILE_NAME);
106
+ if (await fsExists(lockPath)) {
107
+ const lockContent = await fsRead(lockPath);
108
+ throw new Error(
109
+ `다른 Capacitor 작업이 진행 중입니다 (PID: ${lockContent}). ` + `문제가 있다면 ${lockPath} 파일을 삭제하세요.`,
110
+ );
111
+ }
112
+ await fsMkdir(this._capPath);
113
+ await fsWrite(lockPath, String(process.pid));
114
+ }
115
+
116
+ /**
117
+ * F10: 잠금 해제
118
+ */
119
+ private async _releaseLock(): Promise<void> {
120
+ const lockPath = path.resolve(this._capPath, Capacitor._LOCK_FILE_NAME);
121
+ await fsRm(lockPath);
122
+ }
123
+
124
+ /**
125
+ * F4: 외부 도구 검증
126
+ */
127
+ private async _validateTools(): Promise<void> {
128
+ // Android SDK 확인
129
+ const sdkPath = await this._findAndroidSdk();
130
+ if (sdkPath == null) {
131
+ throw new Error(
132
+ "Android SDK를 찾을 수 없습니다.\n" +
133
+ "1. Android Studio를 설치하거나\n" +
134
+ "2. ANDROID_HOME 또는 ANDROID_SDK_ROOT 환경변수를 설정하세요.",
135
+ );
136
+ }
137
+
138
+ // Java 확인 (android 플랫폼일 때만)
139
+ if (this._platforms.includes("android")) {
140
+ const javaPath = await this._findJava21();
141
+ if (javaPath == null) {
142
+ Capacitor._logger.warn("Java 21을 찾을 수 없습니다. Gradle이 내장 JDK를 사용하거나 빌드가 실패할 수 있습니다.");
143
+ }
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Capacitor 프로젝트 초기화
149
+ *
150
+ * 1. package.json 생성 및 의존성 설치
151
+ * 2. capacitor.config.ts 생성
152
+ * 3. 플랫폼 추가 (android)
153
+ * 4. 아이콘 설정
154
+ * 5. Android 네이티브 설정
155
+ * 6. cap sync 또는 cap copy 실행
156
+ */
157
+ async initialize(): Promise<void> {
158
+ await this._acquireLock();
159
+
160
+ try {
161
+ // F4: 외부 도구 검증
162
+ await this._validateTools();
163
+
164
+ // 1. Capacitor 프로젝트 초기화
165
+ const changed = await this._initCap();
166
+
167
+ // 2. Capacitor 설정 파일 생성
168
+ await this._writeCapConf();
169
+
170
+ // 3. 플랫폼 관리 (F12: 멱등성 - 이미 존재하면 스킵)
171
+ await this._addPlatforms();
172
+
173
+ // 4. 아이콘 설정 (F6: 에러 복구)
174
+ await this._setupIcon();
175
+
176
+ // 5. Android 네이티브 설정
177
+ if (this._platforms.includes("android")) {
178
+ await this._configureAndroid();
179
+ }
180
+
181
+ // 6. 웹 자산 동기화
182
+ if (changed) {
183
+ await this._exec("npx", ["cap", "sync"], this._capPath);
184
+ } else {
185
+ await this._exec("npx", ["cap", "copy"], this._capPath);
186
+ }
187
+ } finally {
188
+ await this._releaseLock();
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Android APK/AAB 빌드
194
+ */
195
+ async build(outPath: string): Promise<void> {
196
+ await this._acquireLock();
197
+
198
+ try {
199
+ const buildType = this._config.debug ? "debug" : "release";
200
+
201
+ for (const platform of this._platforms) {
202
+ await this._exec("npx", ["cap", "copy", platform], this._capPath);
203
+
204
+ if (platform === "android") {
205
+ await this._buildAndroid(outPath, buildType);
206
+ } else {
207
+ throw new Error(`지원하지 않는 플랫폼: ${platform}`);
208
+ }
209
+ }
210
+ } finally {
211
+ await this._releaseLock();
212
+ }
213
+ }
214
+
215
+ /**
216
+ * 디바이스에서 앱 실행 (WebView를 개발 서버로 연결)
217
+ */
218
+ async runOnDevice(url?: string): Promise<void> {
219
+ // F11: URL 검증
220
+ if (url != null) {
221
+ this._validateUrl(url);
222
+ await this._updateServerUrl(url);
223
+ }
224
+
225
+ for (const platform of this._platforms) {
226
+ await this._exec("npx", ["cap", "copy", platform], this._capPath);
227
+
228
+ try {
229
+ await this._exec("npx", ["cap", "run", platform], this._capPath);
230
+ } catch (err) {
231
+ if (platform === "android") {
232
+ try {
233
+ await this._exec("adb", ["kill-server"], this._capPath);
234
+ } catch {
235
+ // adb kill-server 실패는 무시
236
+ }
237
+ }
238
+ throw err;
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * F11: URL 검증
245
+ */
246
+ private _validateUrl(url: string): void {
247
+ try {
248
+ const parsed = new URL(url);
249
+ if (!["http:", "https:"].includes(parsed.protocol)) {
250
+ throw new Error(`지원하지 않는 프로토콜: ${parsed.protocol}`);
251
+ }
252
+ } catch (err) {
253
+ if (err instanceof TypeError) {
254
+ throw new Error(`유효하지 않은 URL: ${url}`);
255
+ }
256
+ throw err;
257
+ }
258
+ }
259
+
260
+ //#region Private - 초기화
261
+
262
+ /**
263
+ * Capacitor 프로젝트 기본 초기화 (package.json, npm install, cap init)
264
+ */
265
+ private async _initCap(): Promise<boolean> {
266
+ const depChanged = await this._setupNpmConf();
267
+ if (!depChanged) return false;
268
+
269
+ // pnpm install
270
+ const installResult = await this._exec("pnpm", ["install"], this._capPath);
271
+ Capacitor._logger.debug(`pnpm install 완료: ${installResult}`);
272
+
273
+ // F12: cap init 멱등성 - capacitor.config.ts가 없을 때만 실행
274
+ const configPath = path.resolve(this._capPath, "capacitor.config.ts");
275
+ if (!(await fsExists(configPath))) {
276
+ await this._exec("npx", ["cap", "init", this._config.appName, this._config.appId], this._capPath);
277
+ }
278
+
279
+ // 기본 www/index.html 생성
280
+ const wwwPath = path.resolve(this._capPath, "www");
281
+ await fsMkdir(wwwPath);
282
+ await fsWrite(path.resolve(wwwPath, "index.html"), "<!DOCTYPE html><html><head></head><body></body></html>");
283
+
284
+ return true;
285
+ }
286
+
287
+ /**
288
+ * package.json 설정
289
+ */
290
+ private async _setupNpmConf(): Promise<boolean> {
291
+ const projNpmConfigPath = path.resolve(this._pkgPath, "../../package.json");
292
+
293
+ // F3: 파일 존재 확인
294
+ if (!(await fsExists(projNpmConfigPath))) {
295
+ throw new Error(`루트 package.json을 찾을 수 없습니다: ${projNpmConfigPath}`);
296
+ }
297
+
298
+ const projNpmConfig = await fsReadJson<NpmConfig>(projNpmConfigPath);
299
+
300
+ const capNpmConfPath = path.resolve(this._capPath, "package.json");
301
+ const orgCapNpmConf: NpmConfig = (await fsExists(capNpmConfPath))
302
+ ? await fsReadJson<NpmConfig>(capNpmConfPath)
303
+ : { name: "", version: "" };
304
+
305
+ const capNpmConf: NpmConfig = { ...orgCapNpmConf };
306
+ capNpmConf.name = this._config.appId;
307
+ capNpmConf.version = this._npmConfig.version;
308
+ if (projNpmConfig.volta != null) {
309
+ capNpmConf.volta = projNpmConfig.volta;
310
+ }
311
+
312
+ // 기본 의존성
313
+ capNpmConf.dependencies = capNpmConf.dependencies ?? {};
314
+ capNpmConf.dependencies["@capacitor/core"] = "^7.0.0";
315
+ capNpmConf.dependencies["@capacitor/app"] = "^7.0.0";
316
+ for (const platform of this._platforms) {
317
+ capNpmConf.dependencies[`@capacitor/${platform}`] = "^7.0.0";
318
+ }
319
+
320
+ capNpmConf.devDependencies = capNpmConf.devDependencies ?? {};
321
+ capNpmConf.devDependencies["@capacitor/cli"] = "^7.0.0";
322
+ capNpmConf.devDependencies["@capacitor/assets"] = "^3.0.0";
323
+
324
+ // 플러그인 패키지 설정
325
+ const mainDeps = {
326
+ ...this._npmConfig.dependencies,
327
+ ...this._npmConfig.devDependencies,
328
+ ...this._npmConfig.peerDependencies,
329
+ };
330
+
331
+ const usePlugins = Object.keys(this._config.plugins ?? {});
332
+
333
+ const prevPlugins = Object.keys(capNpmConf.dependencies).filter(
334
+ (item) => !["@capacitor/core", "@capacitor/android", "@capacitor/ios", "@capacitor/app"].includes(item),
335
+ );
336
+
337
+ // 사용하지 않는 플러그인 제거
338
+ for (const prevPlugin of prevPlugins) {
339
+ if (!usePlugins.includes(prevPlugin)) {
340
+ delete capNpmConf.dependencies[prevPlugin];
341
+ Capacitor._logger.debug(`플러그인 제거: ${prevPlugin}`);
342
+ }
343
+ }
344
+
345
+ // 새 플러그인 추가
346
+ for (const plugin of usePlugins) {
347
+ if (!(plugin in capNpmConf.dependencies)) {
348
+ const version = mainDeps[plugin] ?? "*";
349
+ capNpmConf.dependencies[plugin] = version;
350
+ Capacitor._logger.debug(`플러그인 추가: ${plugin}@${version}`);
351
+ }
352
+ }
353
+
354
+ // 저장
355
+ await fsMkdir(this._capPath);
356
+ await fsWriteJson(capNpmConfPath, capNpmConf, { space: 2 });
357
+
358
+ // 의존성 변경 여부 확인
359
+ const isChanged =
360
+ orgCapNpmConf.volta !== capNpmConf.volta ||
361
+ JSON.stringify(orgCapNpmConf.dependencies) !== JSON.stringify(capNpmConf.dependencies) ||
362
+ JSON.stringify(orgCapNpmConf.devDependencies) !== JSON.stringify(capNpmConf.devDependencies);
363
+
364
+ return isChanged;
365
+ }
366
+
367
+ /**
368
+ * capacitor.config.ts 생성
369
+ */
370
+ private async _writeCapConf(): Promise<void> {
371
+ const confPath = path.resolve(this._capPath, "capacitor.config.ts");
372
+
373
+ // 플러그인 옵션 생성
374
+ const pluginOptions: Record<string, Record<string, unknown>> = {};
375
+ for (const [pluginName, options] of Object.entries(this._config.plugins ?? {})) {
376
+ if (options !== true) {
377
+ const configKey = this._toPascalCase(pluginName.split("/").at(-1)!);
378
+ pluginOptions[configKey] = options;
379
+ }
380
+ }
381
+
382
+ const pluginsConfigStr =
383
+ Object.keys(pluginOptions).length > 0 ? JSON.stringify(pluginOptions, null, 2).replace(/^/gm, " ").trim() : "{}";
384
+
385
+ const configContent = `import type { CapacitorConfig } from "@capacitor/cli";
386
+
387
+ const config: CapacitorConfig = {
388
+ appId: "${this._config.appId}",
389
+ appName: "${this._config.appName}",
390
+ server: {
391
+ androidScheme: "http",
392
+ cleartext: true
393
+ },
394
+ android: {},
395
+ plugins: ${pluginsConfigStr},
396
+ };
397
+
398
+ export default config;
399
+ `;
400
+
401
+ await fsWrite(confPath, configContent);
402
+ }
403
+
404
+ /**
405
+ * 플랫폼 추가 (F12: 멱등성 보장)
406
+ */
407
+ private async _addPlatforms(): Promise<void> {
408
+ for (const platform of this._platforms) {
409
+ const platformPath = path.resolve(this._capPath, platform);
410
+ if (await fsExists(platformPath)) {
411
+ Capacitor._logger.debug(`플랫폼 이미 존재: ${platform}`);
412
+ continue;
413
+ }
414
+
415
+ await this._exec("npx", ["cap", "add", platform], this._capPath);
416
+ }
417
+ }
418
+
419
+ /**
420
+ * 아이콘 설정 (F6: 에러 복구)
421
+ */
422
+ private async _setupIcon(): Promise<void> {
423
+ const assetsDirPath = path.resolve(this._capPath, "assets");
424
+
425
+ if (this._config.icon != null) {
426
+ const iconSource = path.resolve(this._pkgPath, this._config.icon);
427
+
428
+ // F6: 소스 아이콘 존재 확인
429
+ if (!(await fsExists(iconSource))) {
430
+ Capacitor._logger.warn(`아이콘 파일을 찾을 수 없습니다: ${iconSource}. 기본 아이콘을 사용합니다.`);
431
+ return;
432
+ }
433
+
434
+ try {
435
+ await fsMkdir(assetsDirPath);
436
+
437
+ // 아이콘 생성
438
+ const logoPath = path.resolve(assetsDirPath, "logo.png");
439
+
440
+ const logoSize = Math.floor(1024 * 0.6);
441
+ const padding = Math.floor((1024 - logoSize) / 2);
442
+
443
+ // F6: sharp 에러 처리
444
+ await sharp(iconSource)
445
+ .resize(logoSize, logoSize, {
446
+ fit: "contain",
447
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
448
+ })
449
+ .extend({
450
+ top: padding,
451
+ bottom: padding,
452
+ left: padding,
453
+ right: padding,
454
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
455
+ })
456
+ .toFile(logoPath);
457
+
458
+ await this._exec(
459
+ "npx",
460
+ ["@capacitor/assets", "generate", "--iconBackgroundColor", "#ffffff", "--splashBackgroundColor", "#ffffff"],
461
+ this._capPath,
462
+ );
463
+ } catch (err) {
464
+ Capacitor._logger.warn(
465
+ `아이콘 생성 실패: ${err instanceof Error ? err.message : err}. 기본 아이콘을 사용합니다.`,
466
+ );
467
+ // F6: 실패해도 계속 진행 (기본 아이콘 사용)
468
+ }
469
+ } else {
470
+ await fsRm(assetsDirPath);
471
+ }
472
+ }
473
+
474
+ //#endregion
475
+
476
+ //#region Private - Android 설정
477
+
478
+ /**
479
+ * Android 네이티브 설정
480
+ */
481
+ private async _configureAndroid(): Promise<void> {
482
+ const androidPath = path.resolve(this._capPath, "android");
483
+
484
+ // F3: Android 디렉토리 존재 확인
485
+ if (!(await fsExists(androidPath))) {
486
+ throw new Error(`Android 프로젝트 디렉토리가 없습니다: ${androidPath}`);
487
+ }
488
+
489
+ await this._configureAndroidJavaHomePath(androidPath);
490
+ await this._configureAndroidSdkPath(androidPath);
491
+ await this._configureAndroidManifest(androidPath);
492
+ await this._configureAndroidBuildGradle(androidPath);
493
+ }
494
+
495
+ /**
496
+ * JAVA_HOME 경로 설정 (gradle.properties)
497
+ */
498
+ private async _configureAndroidJavaHomePath(androidPath: string): Promise<void> {
499
+ const gradlePropsPath = path.resolve(androidPath, "gradle.properties");
500
+
501
+ // F3: 파일 존재 확인
502
+ if (!(await fsExists(gradlePropsPath))) {
503
+ Capacitor._logger.warn(`gradle.properties 파일이 없습니다: ${gradlePropsPath}`);
504
+ return;
505
+ }
506
+
507
+ let content = await fsRead(gradlePropsPath);
508
+
509
+ const java21Path = await this._findJava21();
510
+ if (java21Path != null && !content.includes("org.gradle.java.home")) {
511
+ // F9: Windows 경로 이스케이프 개선
512
+ const escapedPath = java21Path.replace(/\\/g, "\\\\");
513
+ content += `\norg.gradle.java.home=${escapedPath}\n`;
514
+ await fsWrite(gradlePropsPath, content);
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Java 21 경로 자동 탐색
520
+ */
521
+ private async _findJava21(): Promise<string | undefined> {
522
+ const patterns = [
523
+ "C:/Program Files/Amazon Corretto/jdk21*",
524
+ "C:/Program Files/Eclipse Adoptium/jdk-21*",
525
+ "C:/Program Files/Java/jdk-21*",
526
+ "C:/Program Files/Microsoft/jdk-21*",
527
+ "/usr/lib/jvm/java-21*",
528
+ "/usr/lib/jvm/temurin-21*",
529
+ ];
530
+
531
+ for (const pattern of patterns) {
532
+ const matches = await fsGlob(pattern);
533
+ if (matches.length > 0) {
534
+ return matches.sort().at(-1);
535
+ }
536
+ }
537
+
538
+ return undefined;
539
+ }
540
+
541
+ /**
542
+ * Android SDK 경로 설정 (local.properties)
543
+ */
544
+ private async _configureAndroidSdkPath(androidPath: string): Promise<void> {
545
+ const localPropsPath = path.resolve(androidPath, "local.properties");
546
+
547
+ const sdkPath = await this._findAndroidSdk();
548
+ if (sdkPath != null) {
549
+ // F9: 항상 forward slash 사용 (Gradle 호환)
550
+ await fsWrite(localPropsPath, `sdk.dir=${sdkPath.replace(/\\/g, "/")}\n`);
551
+ } else {
552
+ throw new Error(
553
+ "Android SDK를 찾을 수 없습니다.\n" +
554
+ "1. Android Studio를 설치하거나\n" +
555
+ "2. ANDROID_HOME 또는 ANDROID_SDK_ROOT 환경변수를 설정하세요.",
556
+ );
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Android SDK 경로 탐색
562
+ */
563
+ private async _findAndroidSdk(): Promise<string | undefined> {
564
+ const fromEnv = (env["ANDROID_HOME"] ?? env["ANDROID_SDK_ROOT"]) as string | undefined;
565
+ if (fromEnv != null && (await fsExists(fromEnv))) {
566
+ return fromEnv;
567
+ }
568
+
569
+ const candidates = [
570
+ path.resolve((env["LOCALAPPDATA"] as string | undefined) ?? "", "Android/Sdk"),
571
+ path.resolve((env["HOME"] as string | undefined) ?? "", "Android/Sdk"),
572
+ "C:/Program Files/Android/Sdk",
573
+ "C:/Android/Sdk",
574
+ ];
575
+
576
+ for (const candidate of candidates) {
577
+ if (await fsExists(candidate)) {
578
+ return candidate;
579
+ }
580
+ }
581
+
582
+ return undefined;
583
+ }
584
+
585
+ /**
586
+ * AndroidManifest.xml 수정 (F3: 에러 처리 추가)
587
+ */
588
+ private async _configureAndroidManifest(androidPath: string): Promise<void> {
589
+ const manifestPath = path.resolve(androidPath, "app/src/main/AndroidManifest.xml");
590
+
591
+ // F3: 파일 존재 확인
592
+ if (!(await fsExists(manifestPath))) {
593
+ throw new Error(`AndroidManifest.xml 파일이 없습니다: ${manifestPath}`);
594
+ }
595
+
596
+ let content = await fsRead(manifestPath);
597
+
598
+ // usesCleartextTraffic 설정
599
+ if (!content.includes("android:usesCleartextTraffic")) {
600
+ content = content.replace("<application", '<application android:usesCleartextTraffic="true"');
601
+ }
602
+
603
+ // 추가 권한 설정
604
+ const permissions = this._config.platform?.android?.permissions ?? [];
605
+ for (const perm of permissions) {
606
+ const permTag = `<uses-permission android:name="android.permission.${perm.name}"`;
607
+ if (!content.includes(permTag)) {
608
+ const maxSdkAttr = perm.maxSdkVersion != null ? ` android:maxSdkVersion="${perm.maxSdkVersion}"` : "";
609
+ const ignoreAttr = perm.ignore != null ? ` tools:ignore="${perm.ignore}"` : "";
610
+ const permLine = ` ${permTag}${maxSdkAttr}${ignoreAttr} />\n`;
611
+
612
+ if (perm.ignore != null && !content.includes("xmlns:tools=")) {
613
+ content = content.replace(
614
+ "<manifest xmlns:android",
615
+ '<manifest xmlns:tools="http://schemas.android.com/tools" xmlns:android',
616
+ );
617
+ }
618
+
619
+ content = content.replace("</manifest>", `${permLine}</manifest>`);
620
+ }
621
+ }
622
+
623
+ // 추가 application 설정
624
+ const appConfig = this._config.platform?.android?.config;
625
+ if (appConfig) {
626
+ for (const [key, value] of Object.entries(appConfig)) {
627
+ const attr = `android:${key}="${value}"`;
628
+ if (!content.includes(`android:${key}=`)) {
629
+ content = content.replace("<application", `<application ${attr}`);
630
+ }
631
+ }
632
+ }
633
+
634
+ // intentFilters 설정
635
+ const intentFilters = this._config.platform?.android?.intentFilters ?? [];
636
+ for (const filter of intentFilters) {
637
+ const filterKey = filter.action ?? filter.category ?? "";
638
+ if (filterKey && !content.includes(filterKey)) {
639
+ const actionLine = filter.action != null ? `<action android:name="${filter.action}"/>` : "";
640
+ const categoryLine = filter.category != null ? `<category android:name="${filter.category}"/>` : "";
641
+
642
+ content = content.replace(
643
+ /(<activity[\s\S]*?android:name="\.MainActivity"[\s\S]*?>)/,
644
+ `$1
645
+ <intent-filter>
646
+ ${actionLine}
647
+ ${categoryLine}
648
+ </intent-filter>`,
649
+ );
650
+ }
651
+ }
652
+
653
+ await fsWrite(manifestPath, content);
654
+ }
655
+
656
+ /**
657
+ * build.gradle 수정 (F3: 에러 처리 추가)
658
+ */
659
+ private async _configureAndroidBuildGradle(androidPath: string): Promise<void> {
660
+ const buildGradlePath = path.resolve(androidPath, "app/build.gradle");
661
+
662
+ // F3: 파일 존재 확인
663
+ if (!(await fsExists(buildGradlePath))) {
664
+ throw new Error(`build.gradle 파일이 없습니다: ${buildGradlePath}`);
665
+ }
666
+
667
+ let content = await fsRead(buildGradlePath);
668
+
669
+ // versionName, versionCode 설정
670
+ const version = this._npmConfig.version;
671
+ const versionParts = version.split(".");
672
+ const versionCode =
673
+ parseInt(versionParts[0] ?? "0") * 10000 +
674
+ parseInt(versionParts[1] ?? "0") * 100 +
675
+ parseInt(versionParts[2] ?? "0");
676
+
677
+ content = content.replace(/versionCode \d+/, `versionCode ${versionCode}`);
678
+ content = content.replace(/versionName "[^"]+"/, `versionName "${version}"`);
679
+
680
+ // SDK 버전 설정
681
+ if (this._config.platform?.android?.sdkVersion != null) {
682
+ const sdkVersion = this._config.platform.android.sdkVersion;
683
+ content = content.replace(/minSdkVersion .+/, `minSdkVersion ${sdkVersion}`);
684
+ content = content.replace(/targetSdkVersion .+/, `targetSdkVersion ${sdkVersion}`);
685
+ } else {
686
+ content = content.replace(/minSdkVersion .+/, `minSdkVersion rootProject.ext.minSdkVersion`);
687
+ content = content.replace(/targetSdkVersion .+/, `targetSdkVersion rootProject.ext.targetSdkVersion`);
688
+ }
689
+
690
+ // Signing 설정
691
+ const keystorePath = path.resolve(this._capPath, Capacitor._ANDROID_KEYSTORE_FILE_NAME);
692
+ const signConfig = this._config.platform?.android?.sign;
693
+ if (signConfig) {
694
+ const keystoreSource = path.resolve(this._pkgPath, signConfig.keystore);
695
+ // F3: keystore 파일 존재 확인
696
+ if (!(await fsExists(keystoreSource))) {
697
+ throw new Error(`keystore 파일을 찾을 수 없습니다: ${keystoreSource}`);
698
+ }
699
+ await fsCopy(keystoreSource, keystorePath);
700
+
701
+ // F9: 상대 경로를 forward slash로 변환
702
+ const keystoreRelativePath = path.relative(path.dirname(buildGradlePath), keystorePath).replace(/\\/g, "/");
703
+ const keystoreType = signConfig.keystoreType ?? "jks";
704
+
705
+ if (!content.includes("signingConfigs")) {
706
+ const signingConfigsBlock = `
707
+ signingConfigs {
708
+ release {
709
+ storeFile file("${keystoreRelativePath}")
710
+ storePassword '${signConfig.storePassword}'
711
+ keyAlias '${signConfig.alias}'
712
+ keyPassword '${signConfig.password}'
713
+ storeType "${keystoreType}"
714
+ }
715
+ }
716
+ `;
717
+ content = content.replace(/(android\s*\{)/, (match) => `${match}${signingConfigsBlock}`);
718
+ }
719
+
720
+ if (!content.includes("signingConfig signingConfigs.release")) {
721
+ content = content.replace(
722
+ /(buildTypes\s*\{[\s\S]*?release\s*\{)/,
723
+ `$1\n signingConfig signingConfigs.release`,
724
+ );
725
+ }
726
+ } else {
727
+ await fsRm(keystorePath);
728
+ }
729
+
730
+ await fsWrite(buildGradlePath, content);
731
+ }
732
+
733
+ //#endregion
734
+
735
+ //#region Private - 빌드
736
+
737
+ /**
738
+ * Android 빌드
739
+ */
740
+ private async _buildAndroid(outPath: string, buildType: string): Promise<void> {
741
+ const androidPath = path.resolve(this._capPath, "android");
742
+ const targetOutPath = path.resolve(outPath, "android");
743
+
744
+ const isBundle = this._config.platform?.android?.bundle;
745
+ const gradleTask = buildType === "release" ? (isBundle ? "bundleRelease" : "assembleRelease") : "assembleDebug";
746
+
747
+ // Gradle 빌드 실행 (크로스 플랫폼)
748
+ // F9: Windows에서 cmd.exe를 통해 실행 (shell: false 이므로)
749
+ if (process.platform === "win32") {
750
+ await this._exec("cmd", ["/c", "gradlew.bat", gradleTask, "--no-daemon"], androidPath);
751
+ } else {
752
+ await this._exec("sh", ["./gradlew", gradleTask, "--no-daemon"], androidPath);
753
+ }
754
+
755
+ // 빌드 결과물 복사
756
+ await this._copyAndroidBuildOutput(androidPath, targetOutPath, buildType);
757
+ }
758
+
759
+ /**
760
+ * Android 빌드 결과물 복사
761
+ */
762
+ private async _copyAndroidBuildOutput(androidPath: string, targetOutPath: string, buildType: string): Promise<void> {
763
+ const isBundle = this._config.platform?.android?.bundle;
764
+ const isSigned = Boolean(this._config.platform?.android?.sign);
765
+
766
+ const ext = isBundle ? "aab" : "apk";
767
+ const outputType = isBundle ? "bundle" : "apk";
768
+ const fileName = isSigned ? `app-${buildType}.${ext}` : `app-${buildType}-unsigned.${ext}`;
769
+
770
+ const sourcePath = path.resolve(androidPath, "app/build/outputs", outputType, buildType, fileName);
771
+
772
+ const actualPath = (await fsExists(sourcePath))
773
+ ? sourcePath
774
+ : path.resolve(androidPath, "app/build/outputs", outputType, buildType, `app-${buildType}.${ext}`);
775
+
776
+ if (!(await fsExists(actualPath))) {
777
+ Capacitor._logger.warn(`빌드 결과물을 찾을 수 없습니다: ${actualPath}`);
778
+ return;
779
+ }
780
+
781
+ const outputFileName = `${this._config.appName}${isSigned ? "" : "-unsigned"}-latest.${ext}`;
782
+
783
+ await fsMkdir(targetOutPath);
784
+ await fsCopy(actualPath, path.resolve(targetOutPath, outputFileName));
785
+
786
+ // 버전별 저장
787
+ const updatesPath = path.resolve(targetOutPath, "updates");
788
+ await fsMkdir(updatesPath);
789
+ await fsCopy(actualPath, path.resolve(updatesPath, `${this._npmConfig.version}.${ext}`));
790
+ }
791
+
792
+ //#endregion
793
+
794
+ //#region Private - 디바이스 실행
795
+
796
+ /**
797
+ * capacitor.config.ts의 server.url 업데이트
798
+ */
799
+ private async _updateServerUrl(url: string): Promise<void> {
800
+ const configPath = path.resolve(this._capPath, "capacitor.config.ts");
801
+
802
+ if (!(await fsExists(configPath))) return;
803
+
804
+ let content = await fsRead(configPath);
805
+
806
+ if (content.includes("url:")) {
807
+ content = content.replace(/url:\s*"[^"]*"/, `url: "${url}"`);
808
+ } else if (content.includes("server:")) {
809
+ content = content.replace(/server:\s*\{/, `server: {\n url: "${url}",`);
810
+ }
811
+
812
+ await fsWrite(configPath, content);
813
+ }
814
+
815
+ //#endregion
816
+
817
+ //#region Private - 유틸리티
818
+
819
+ /**
820
+ * 문자열을 PascalCase로 변환
821
+ */
822
+ private _toPascalCase(str: string): string {
823
+ return str.replace(/[-_](.)/g, (_, c: string) => c.toUpperCase()).replace(/^./, (c) => c.toUpperCase());
824
+ }
825
+
826
+ //#endregion
827
+ }