@apps-in-toss/cli 3.0.1 → 3.0.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.
Files changed (2) hide show
  1. package/dist/index.js +314 -35
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,17 +1,19 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { Builtins, Cli, Command, Option } from "clipanion";
3
4
  import * as p from "@clack/prompts";
4
5
  import path from "path";
5
6
  import { appendFile, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
6
- import path$1, { extname, join, resolve } from "node:path";
7
+ import path$1, { basename, dirname, extname, join, parse, resolve } from "node:path";
7
8
  import { AppsInTossBundle, PlatformType } from "@apps-in-toss/ait-format";
8
9
  import { loadConfig } from "c12";
9
10
  import { uuidv7 } from "uuidv7";
10
- import fs, { existsSync } from "node:fs";
11
+ import fs, { existsSync, readFileSync } from "node:fs";
11
12
  import assert from "node:assert";
12
13
  import os from "node:os";
13
14
  import { setTimeout } from "node:timers/promises";
14
15
  import jscodeshift from "jscodeshift";
16
+ import { spawn } from "node:child_process";
15
17
  //#region src/utils/getPackageRoot.ts
16
18
  function getPackageRoot(base) {
17
19
  let dirPath = base;
@@ -430,12 +432,227 @@ var DeployCommand = class extends Command {
430
432
  }
431
433
  };
432
434
  //#endregion
435
+ //#region src/utils/jscodeshift.ts
436
+ /**
437
+ * 설정 파일 확장자에 맞는 파서를 골라요.
438
+ * 기본 파서는 JavaScript 전용이라 .ts 설정 파일의 type 선언, satisfies 같은
439
+ * TypeScript 문법에서 파싱이 실패해요.
440
+ */
441
+ function getParserForFile(filepath) {
442
+ switch (extname(filepath)) {
443
+ case ".ts":
444
+ case ".mts":
445
+ case ".cts": return jscodeshift.withParser("ts");
446
+ case ".tsx": return jscodeshift.withParser("tsx");
447
+ default: return jscodeshift.withParser("babel");
448
+ }
449
+ }
450
+ /**
451
+ * 이름이 있는 오브젝트 프로퍼티만 골라내요.
452
+ * 파서에 따라 Property(estree) 또는 ObjectProperty(babel) 노드로 파싱되므로 둘 다 확인해요.
453
+ */
454
+ function asNamedProperty(prop) {
455
+ if ((jscodeshift.Property.check(prop) || jscodeshift.ObjectProperty.check(prop)) && jscodeshift.Identifier.check(prop.key)) return prop;
456
+ return null;
457
+ }
458
+ //#endregion
459
+ //#region src/utils/devtools.ts
460
+ const DEVTOOLS_PACKAGE_NAME = "@apps-in-toss/devtools";
461
+ const DEVTOOLS_UNPLUGIN_SPECIFIER = "@apps-in-toss/devtools/unplugin";
462
+ const DEVTOOLS_IMPORT_NAME = "aitDevtools";
463
+ /**
464
+ * changesets fixed 그룹으로 CLI와 devtools가 항상 같은 버전으로 배포돼요.
465
+ * 그래서 CLI 자신의 버전이 곧 함께 설치할 devtools의 호환 버전이에요.
466
+ */
467
+ function getDevtoolsVersionRange() {
468
+ const { version } = createRequire(import.meta.url)("@apps-in-toss/cli/package.json");
469
+ return `^${version}`;
470
+ }
471
+ const BUNDLER_CONFIG_BASES = [
472
+ ["vite.config", "vite"],
473
+ ["webpack.config", "webpack"],
474
+ ["rspack.config", "rspack"],
475
+ ["rollup.config", "rollup"]
476
+ ];
477
+ const CONFIG_EXTENSIONS = [
478
+ ".ts",
479
+ ".mts",
480
+ ".cts",
481
+ ".js",
482
+ ".mjs",
483
+ ".cjs"
484
+ ];
485
+ /**
486
+ * 프로젝트 루트에서 devtools unplugin을 꽂을 번들러 설정 파일을 찾아요.
487
+ */
488
+ function findBundlerConfig(projectRoot) {
489
+ for (const [base, bundler] of BUNDLER_CONFIG_BASES) for (const extension of CONFIG_EXTENSIONS) {
490
+ const path = join(projectRoot, `${base}${extension}`);
491
+ if (existsSync(path)) return {
492
+ path,
493
+ bundler
494
+ };
495
+ }
496
+ return null;
497
+ }
498
+ /**
499
+ * 번들러 설정 소스의 plugins 배열 맨 앞에 devtools unplugin을 꽂아요.
500
+ * 맨 앞이어야 SDK → mock alias가 다른 플러그인의 resolve보다 먼저 걸려요.
501
+ *
502
+ * 파일에 쓰지 않고 변환된 소스만 돌려줘요. 변환할 수 없는 모양(CommonJS 설정,
503
+ * plugins 배열 없음)이면 needs-manual로 알려서 호출부가 수동 안내를 하게 해요.
504
+ */
505
+ function injectDevtoolsPlugin(source, configPath, bundler) {
506
+ if (source.includes("@apps-in-toss/devtools/unplugin")) return { status: "already-installed" };
507
+ if (/\bmodule\.exports\b|\brequire\s*\(/.test(source)) return {
508
+ status: "needs-manual",
509
+ reason: "CommonJS 형식 설정 파일은 자동으로 수정하지 않아요."
510
+ };
511
+ const j = getParserForFile(configPath);
512
+ let root;
513
+ try {
514
+ root = j(source);
515
+ } catch (error) {
516
+ return {
517
+ status: "needs-manual",
518
+ reason: `설정 파일을 파싱하지 못했어요: ${error instanceof Error ? error.message : String(error)}`
519
+ };
520
+ }
521
+ let pluginsArray = null;
522
+ root.find(jscodeshift.ObjectExpression).forEach((path) => {
523
+ if (pluginsArray != null) return;
524
+ for (const rawProp of path.value.properties) {
525
+ const prop = asNamedProperty(rawProp);
526
+ if (prop?.key.name === "plugins" && jscodeshift.ArrayExpression.check(prop.value)) {
527
+ pluginsArray = prop.value;
528
+ return;
529
+ }
530
+ }
531
+ });
532
+ if (pluginsArray == null) return {
533
+ status: "needs-manual",
534
+ reason: "설정 파일에서 plugins 배열을 찾지 못했어요."
535
+ };
536
+ const pluginCall = jscodeshift.callExpression(jscodeshift.memberExpression(jscodeshift.identifier(DEVTOOLS_IMPORT_NAME), jscodeshift.identifier(bundler)), []);
537
+ pluginsArray.elements.unshift(pluginCall);
538
+ const importDecl = jscodeshift.importDeclaration([jscodeshift.importDefaultSpecifier(jscodeshift.identifier(DEVTOOLS_IMPORT_NAME))], jscodeshift.stringLiteral(DEVTOOLS_UNPLUGIN_SPECIFIER));
539
+ const imports = root.find(jscodeshift.ImportDeclaration);
540
+ if (imports.size() > 0) imports.at(imports.size() - 1).insertAfter(importDecl);
541
+ else root.get().node.program.body.unshift(importDecl);
542
+ return {
543
+ status: "transformed",
544
+ source: root.toSource()
545
+ };
546
+ }
547
+ /**
548
+ * 자동 주입에 실패했을 때 보여줄 수동 설치 안내예요.
549
+ */
550
+ function devtoolsManualGuide(bundler = "vite") {
551
+ return [
552
+ `번들러 설정의 plugins 배열 맨 앞에 devtools를 직접 추가해주세요.`,
553
+ "",
554
+ ` import ${DEVTOOLS_IMPORT_NAME} from "${DEVTOOLS_UNPLUGIN_SPECIFIER}";`,
555
+ "",
556
+ ` plugins: [${DEVTOOLS_IMPORT_NAME}.${bundler}(), /* 기존 플러그인들 */]`
557
+ ].join("\n");
558
+ }
559
+ //#endregion
433
560
  //#region src/utils/ensureSelect.ts
434
561
  async function ensureSelect({ value, prompt }) {
435
562
  if (value) return value;
436
563
  return await prompt();
437
564
  }
438
565
  //#endregion
566
+ //#region src/utils/installDependencies.ts
567
+ function asPackageManager(name) {
568
+ if (name === "npm" || name === "yarn" || name === "pnpm" || name === "bun") return name;
569
+ return null;
570
+ }
571
+ /**
572
+ * CLI를 실행한 패키지 매니저를 `npm_config_user_agent`에서 읽어요.
573
+ * `yarn ait ...`, `npx ait ...`처럼 매니저를 거쳐 실행되면 항상 세팅되는 값이라
574
+ * lockfile 추측보다 정확해요. 예: "yarn/4.12.0 npm/? node/v24.14.0 darwin arm64"
575
+ */
576
+ function packageManagerFromUserAgent(userAgent) {
577
+ return asPackageManager(userAgent?.split("/")[0]);
578
+ }
579
+ /**
580
+ * 프로젝트에서 위로 올라가며 package.json의 `packageManager` 필드를 찾아요.
581
+ * 모노레포처럼 필드가 상위 디렉토리에 있는 경우까지 커버해요.
582
+ */
583
+ function packageManagerFromField(projectRoot) {
584
+ let dir = projectRoot;
585
+ const { root } = parse(dir);
586
+ while (true) {
587
+ const packageJsonPath = join(dir, "package.json");
588
+ if (existsSync(packageJsonPath)) try {
589
+ const { packageManager } = JSON.parse(readFileSync(packageJsonPath, { encoding: "utf-8" }));
590
+ const found = asPackageManager(packageManager?.split("@")[0]);
591
+ if (found != null) return found;
592
+ } catch {}
593
+ if (dir === root) return null;
594
+ dir = dirname(dir);
595
+ }
596
+ }
597
+ /**
598
+ * 의존성을 설치할 패키지 매니저를 알아내요.
599
+ * CLI를 실행한 매니저(`npm_config_user_agent`)가 1순위, 직접 실행됐을 때는
600
+ * `packageManager` 필드로 폴백하고, 둘 다 없으면 npm을 써요.
601
+ */
602
+ function detectPackageManager(projectRoot) {
603
+ return packageManagerFromUserAgent(process.env.npm_config_user_agent) ?? packageManagerFromField(projectRoot) ?? "npm";
604
+ }
605
+ /**
606
+ * 감지한 패키지 매니저로 의존성 설치를 한 번 실행해요.
607
+ * 실패해도 전체 흐름을 멈추지 않고, 직접 실행할 명령어를 안내해요.
608
+ */
609
+ async function installDependencies(projectRoot) {
610
+ const packageManager = detectPackageManager(projectRoot);
611
+ const spinner = p.spinner();
612
+ spinner.start(`의존성을 설치하는 중... (${packageManager} install)`);
613
+ const { code, output } = await runInstall(packageManager, projectRoot);
614
+ if (code === 0) {
615
+ spinner.stop(`의존성을 설치했어요. (${packageManager} install)`);
616
+ return;
617
+ }
618
+ spinner.stop(`${packageManager} install 실행에 실패했어요.`);
619
+ const outputTail = output.trim().split("\n").slice(-15).join("\n");
620
+ if (outputTail.length > 0) p.log.message(outputTail);
621
+ p.log.warn(`직접 실행해주세요: ${packageManager} install`);
622
+ }
623
+ function runInstall(packageManager, cwd) {
624
+ return new Promise((resolvePromise) => {
625
+ const child = spawn(packageManager, ["install"], {
626
+ cwd,
627
+ shell: process.platform === "win32",
628
+ stdio: [
629
+ "ignore",
630
+ "pipe",
631
+ "pipe"
632
+ ]
633
+ });
634
+ let output = "";
635
+ child.stdout.on("data", (chunk) => {
636
+ output += chunk.toString();
637
+ });
638
+ child.stderr.on("data", (chunk) => {
639
+ output += chunk.toString();
640
+ });
641
+ child.on("error", (error) => {
642
+ resolvePromise({
643
+ code: null,
644
+ output: `${output}\n${error.message}`
645
+ });
646
+ });
647
+ child.on("close", (code) => {
648
+ resolvePromise({
649
+ code,
650
+ output
651
+ });
652
+ });
653
+ });
654
+ }
655
+ //#endregion
439
656
  //#region src/utils/kebabCase.ts
440
657
  /**
441
658
  * 문자열을 케밥-케이스로 변환해요. (예: "My App" -> "my-app", "myApp" -> "my-app")
@@ -484,6 +701,11 @@ async function templateWebFramework({ appName, cwd, skipInput }) {
484
701
  packageJson.scripts ??= {};
485
702
  if (packageJson.scripts.build) packageJson.scripts.build = packageJson.scripts.build + " && ait build";
486
703
  packageJson.scripts.deploy = "ait deploy";
704
+ p.log.step(`${DEVTOOLS_PACKAGE_NAME}를 devDependencies에 추가하는 중...`);
705
+ packageJson.devDependencies = {
706
+ ...packageJson.devDependencies,
707
+ [DEVTOOLS_PACKAGE_NAME]: getDevtoolsVersionRange()
708
+ };
487
709
  await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), { encoding: "utf-8" });
488
710
  p.log.step(".gitignore 파일을 업데이트하는 중...");
489
711
  await appendFile(join(cwd, ".gitignore"), "\n*.ait\n");
@@ -502,6 +724,33 @@ async function templateWebFramework({ appName, cwd, skipInput }) {
502
724
  webBundleDir
503
725
  });
504
726
  await writeFile(join(cwd, "apps-in-toss.config.ts"), config, { encoding: "utf-8" });
727
+ await setupDevtoolsPlugin(cwd);
728
+ await installDependencies(cwd);
729
+ }
730
+ /**
731
+ * 번들러 설정의 plugins 맨 앞에 devtools unplugin을 자동으로 꽂아줘요.
732
+ * 자동 주입이 어려운 모양이면 파일을 건드리지 않고 수동 안내만 보여줘요.
733
+ */
734
+ async function setupDevtoolsPlugin(cwd) {
735
+ const bundlerConfig = findBundlerConfig(cwd);
736
+ if (bundlerConfig == null) {
737
+ p.note(devtoolsManualGuide(), "devtools 수동 설치 안내");
738
+ return;
739
+ }
740
+ const result = injectDevtoolsPlugin(await readFile(bundlerConfig.path, { encoding: "utf-8" }), bundlerConfig.path, bundlerConfig.bundler);
741
+ switch (result.status) {
742
+ case "transformed":
743
+ await writeFile(bundlerConfig.path, result.source, { encoding: "utf-8" });
744
+ p.log.step(`${basename(bundlerConfig.path)}에 devtools 플러그인을 추가했어요.`);
745
+ break;
746
+ case "already-installed":
747
+ p.log.info("devtools 플러그인이 이미 설정되어 있어요.");
748
+ break;
749
+ case "needs-manual":
750
+ p.log.warn(result.reason);
751
+ p.note(devtoolsManualGuide(bundlerConfig.bundler), "devtools 수동 설치 안내");
752
+ break;
753
+ }
505
754
  }
506
755
  var InitCommand = class extends Command {
507
756
  static paths = [["init"]];
@@ -560,14 +809,15 @@ async function migrateWebFrameworkV3() {
560
809
  process.exit(1);
561
810
  }
562
811
  await runMigration(plan);
812
+ await installDependencies(projectRoot);
563
813
  p.log.success("마이그레이션이 완료됐어요.");
564
- printPostMigrationNotes(plan.appName);
814
+ printPostMigrationNotes(plan);
565
815
  }
566
816
  /**
567
817
  * 마이그레이션 이후 개발자가 꼭 알아야 하는 유의사항을 안내해요.
568
818
  */
569
- function printPostMigrationNotes(appName) {
570
- const host = appName ?? "<appName>";
819
+ function printPostMigrationNotes(plan) {
820
+ const host = plan.appName ?? "<appName>";
571
821
  p.note([
572
822
  "1. SDK 3.x 출시 후 롤백이 안돼요",
573
823
  " SDK 3.x 이상이 적용된 앱 번들을 출시하면 SDK 2.x 버전으로 롤백할 수 없어요.",
@@ -579,8 +829,13 @@ function printPostMigrationNotes(appName) {
579
829
  " Origin 허용 목록에 다음 도메인을 등록하세요.",
580
830
  "",
581
831
  ` - https://${host}.web.tossmini.com : 실제 서비스 환경`,
582
- ` - https://${host}.private-web.tossmini.com : 콘솔 QR 테스트 환경`
832
+ ` - https://${host}.private-web.tossmini.com : 콘솔 QR 테스트 환경`,
833
+ "",
834
+ `3. 개발용 devtools가 함께 설치돼요`,
835
+ ` ${DEVTOOLS_PACKAGE_NAME}가 devDependencies에 추가됐어요.`,
836
+ " 브라우저에서 mock SDK와 devtools 패널로 미니앱을 개발할 수 있어요."
583
837
  ].join("\n"), "유의사항");
838
+ if (plan.devtoolsManualBundler != null) p.note(devtoolsManualGuide(plan.devtoolsManualBundler), "devtools 수동 설치 안내");
584
839
  }
585
840
  /**
586
841
  * 파일을 변경하기 전에 모든 조건을 검증하고, 실행할 변경 내용을 준비해요.
@@ -662,8 +917,13 @@ async function planMigration(projectRoot) {
662
917
  ...packageJson.parsed.scripts,
663
918
  dev: devCommand,
664
919
  build: `${buildCommand} && ait build`
920
+ },
921
+ devDependencies: {
922
+ ...packageJson.parsed.devDependencies,
923
+ [DEVTOOLS_PACKAGE_NAME]: getDevtoolsVersionRange()
665
924
  }
666
925
  };
926
+ const { bundlerConfig, devtoolsManualBundler } = await planDevtoolsPluginInjection(projectRoot);
667
927
  const appName = graniteConfig.config.appName;
668
928
  return {
669
929
  issues,
@@ -676,33 +936,74 @@ async function planMigration(projectRoot) {
676
936
  hasExistingAppsInTossConfig,
677
937
  packageJsonPath,
678
938
  packageJsonSource: packageJson.source,
679
- nextPackageJsonSource: JSON.stringify(nextPackageJson, null, 2) + (packageJson.source.endsWith("\n") ? "\n" : "")
939
+ nextPackageJsonSource: JSON.stringify(nextPackageJson, null, 2) + (packageJson.source.endsWith("\n") ? "\n" : ""),
940
+ bundlerConfig,
941
+ devtoolsManualBundler
680
942
  }
681
943
  };
682
944
  }
683
945
  /**
946
+ * devtools unplugin을 꽂을 번들러 설정 변경을 메모리에서만 준비해요.
947
+ * 자동으로 못 꽂는 모양이면 파일을 건드리지 않고 수동 안내 대상만 정해요.
948
+ */
949
+ async function planDevtoolsPluginInjection(projectRoot) {
950
+ const found = findBundlerConfig(projectRoot);
951
+ if (found == null) return {
952
+ bundlerConfig: null,
953
+ devtoolsManualBundler: "vite"
954
+ };
955
+ const source = (await readFile(found.path)).toString();
956
+ const result = injectDevtoolsPlugin(source, found.path, found.bundler);
957
+ switch (result.status) {
958
+ case "transformed": return {
959
+ bundlerConfig: {
960
+ path: found.path,
961
+ source,
962
+ nextSource: result.source
963
+ },
964
+ devtoolsManualBundler: null
965
+ };
966
+ case "already-installed": return {
967
+ bundlerConfig: null,
968
+ devtoolsManualBundler: null
969
+ };
970
+ case "needs-manual": return {
971
+ bundlerConfig: null,
972
+ devtoolsManualBundler: found.bundler
973
+ };
974
+ }
975
+ }
976
+ /**
684
977
  * 준비된 변경 내용을 트랜잭션으로 실행해요.
685
978
  * 스텝이 하나라도 실패하면 완료된 스텝을 역순으로 되돌리고 종료해요.
686
979
  * 기존 apps-in-toss.config가 있으면 백업해뒀다가, 모두 성공하면 백업을 지워요.
687
980
  */
688
981
  async function runMigration(plan) {
689
982
  const backupPath = `${plan.appsInTossConfigPath}.bak`;
983
+ const backupSteps = plan.hasExistingAppsInTossConfig ? [{
984
+ description: "기존 apps-in-toss.config 파일을 백업해요.",
985
+ run: () => rename(plan.appsInTossConfigPath, backupPath),
986
+ undo: () => rename(backupPath, plan.appsInTossConfigPath)
987
+ }] : [];
988
+ const bundlerConfig = plan.bundlerConfig;
989
+ const bundlerConfigSteps = bundlerConfig == null ? [] : [{
990
+ description: "번들러 설정에 devtools 플러그인을 추가해요.",
991
+ run: () => atomicWriteFile(bundlerConfig.path, bundlerConfig.nextSource),
992
+ undo: () => atomicWriteFile(bundlerConfig.path, bundlerConfig.source)
993
+ }];
690
994
  await executeTransaction([
691
- ...plan.hasExistingAppsInTossConfig ? [{
692
- description: "기존 apps-in-toss.config 파일을 백업해요.",
693
- run: () => rename(plan.appsInTossConfigPath, backupPath),
694
- undo: () => rename(backupPath, plan.appsInTossConfigPath)
695
- }] : [],
995
+ ...backupSteps,
696
996
  {
697
997
  description: "granite.config를 apps-in-toss.config로 마이그레이션해요.",
698
998
  run: () => atomicWriteFile(plan.appsInTossConfigPath, plan.appsInTossConfigSource),
699
999
  undo: () => rm(plan.appsInTossConfigPath, { force: true })
700
1000
  },
701
1001
  {
702
- description: "package.json의 dev, build 스크립트를 수정해요.",
1002
+ description: "package.json의 dev, build 스크립트를 수정하고 devtools를 추가해요.",
703
1003
  run: () => atomicWriteFile(plan.packageJsonPath, plan.nextPackageJsonSource),
704
1004
  undo: () => atomicWriteFile(plan.packageJsonPath, plan.packageJsonSource)
705
1005
  },
1006
+ ...bundlerConfigSteps,
706
1007
  {
707
1008
  description: "granite.config 파일을 삭제해요.",
708
1009
  run: () => rm(plan.graniteConfigPath),
@@ -789,28 +1090,6 @@ function transformGraniteConfig(source, configPath) {
789
1090
  });
790
1091
  return root.toSource();
791
1092
  }
792
- /**
793
- * granite.config 파일 확장자에 맞는 파서를 골라요.
794
- * 기본 파서는 JavaScript 전용이라 .ts 설정 파일의 type 선언, satisfies 같은
795
- * TypeScript 문법에서 파싱이 실패해요.
796
- */
797
- function getParserForFile(filepath) {
798
- switch (extname(filepath)) {
799
- case ".ts":
800
- case ".mts":
801
- case ".cts": return jscodeshift.withParser("ts");
802
- case ".tsx": return jscodeshift.withParser("tsx");
803
- default: return jscodeshift.withParser("babel");
804
- }
805
- }
806
- /**
807
- * 이름이 있는 오브젝트 프로퍼티만 골라내요.
808
- * 파서에 따라 Property(estree) 또는 ObjectProperty(babel) 노드로 파싱되므로 둘 다 확인해요.
809
- */
810
- function asNamedProperty(prop) {
811
- if ((jscodeshift.Property.check(prop) || jscodeshift.ObjectProperty.check(prop)) && jscodeshift.Identifier.check(prop.key)) return prop;
812
- return null;
813
- }
814
1093
  function getErrorMessage(error) {
815
1094
  return error instanceof Error ? error.message : String(error);
816
1095
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apps-in-toss/cli",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "license": "BSD-3-Clause",
5
5
  "bin": {
6
6
  "ait": "dist/index.js"