@lark-apaas/miaoda-cli 0.1.37 → 0.1.39-alpha.24bc5ff

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 (46) hide show
  1. package/README.md +10 -0
  2. package/dist/api/deploy/approval.js +77 -0
  3. package/dist/api/deploy/index.js +7 -1
  4. package/dist/api/deploy/schemas.js +3 -0
  5. package/dist/api/deploy/types.js +19 -1
  6. package/dist/cli/commands/deploy/index.js +30 -6
  7. package/dist/cli/commands/deploy/modern.js +34 -11
  8. package/dist/cli/commands/skills/index.js +1 -1
  9. package/dist/cli/handlers/app/export.js +3 -3
  10. package/dist/cli/handlers/app/migrate.js +4 -1
  11. package/dist/cli/handlers/app/sync.js +2 -2
  12. package/dist/cli/handlers/db/sql.js +8 -6
  13. package/dist/cli/handlers/deploy/deploy.js +57 -4
  14. package/dist/cli/handlers/deploy/format.js +13 -0
  15. package/dist/cli/handlers/deploy/get.js +24 -1
  16. package/dist/cli/handlers/deploy/helpers.js +36 -2
  17. package/dist/cli/handlers/deploy/modern.js +39 -3
  18. package/dist/cli/handlers/deploy/polling.js +13 -9
  19. package/dist/cli/handlers/plugin/plugin-local.js +5 -4
  20. package/dist/config/migrate-configs/vite-react-to-nestjs-react-fullstack.js +13 -2
  21. package/dist/config/sync-configs/index.js +3 -1
  22. package/dist/config/sync-configs/nestjs-react-fullstack.js +6 -0
  23. package/dist/config/sync-configs/vite-react.js +20 -0
  24. package/dist/services/app/init/async-install.js +13 -2
  25. package/dist/services/app/init/install.js +4 -3
  26. package/dist/services/deploy/modern/atoms/build.js +3 -3
  27. package/dist/services/deploy/modern/atoms/local-release.js +3 -0
  28. package/dist/services/deploy/modern/atoms/tosutil.js +6 -8
  29. package/dist/services/deploy/modern/patch/actions.js +7 -1
  30. package/dist/services/deploy/modern/patch/index.js +2 -1
  31. package/dist/services/deploy/modern/pipelines/design-local.js +7 -1
  32. package/dist/services/deploy/modern/pipelines/local.js +7 -1
  33. package/dist/utils/archive.js +103 -0
  34. package/dist/utils/coding-steering.js +30 -2
  35. package/dist/utils/file-ops.js +28 -8
  36. package/dist/utils/http.js +6 -6
  37. package/dist/utils/npm-pack.js +6 -7
  38. package/dist/utils/run-npm.js +102 -0
  39. package/dist/utils/sync-rule.js +6 -0
  40. package/dist/utils/typescript-exclude.js +36 -0
  41. package/dist/utils/which.js +27 -0
  42. package/package.json +6 -2
  43. package/upgrade/templates/nestjs-react-fullstack/templates/nest-cli.json +1 -10
  44. package/upgrade/templates/nestjs-react-fullstack/templates/scripts/build.sh +5 -0
  45. package/upgrade/templates/nestjs-react-fullstack/templates/scripts/prune-smart.js +41 -1
  46. package/upgrade/templates/vite-react/templates/scripts/build.sh +61 -0
@@ -237,7 +237,33 @@ function syncCodingSteering(opts) {
237
237
  * - 已是软链但指错地方:删旧的,建新的(updated)
238
238
  * - 已是普通目录 / 文件:不覆盖,警告并跳过(conflict)—— 用户可能手动放了别的 skills
239
239
  * - 不存在:建新的(created)
240
+ *
241
+ * 软链建不出来时返回 unsupported 而不是抛错:Windows 上非管理员且未开「开发者模式」
242
+ * 时 symlinkSync 报 EPERM,这不该让整条 steering 同步(进而 app init / app sync)失败。
243
+ * 退化后果是 Claude Code 默认路径读不到 skills —— 功能降级但项目可用,且日志里给了
244
+ * 明确的开启方式。不做「复制代替软链」的兜底:那会让两份内容各自漂移,比读不到更糟。
245
+ */
246
+ /**
247
+ * 建目录软链,失败只 warn 不抛。返回是否建成。
248
+ *
249
+ * 主要挡的是 Windows 的 EPERM(创建软链需要 SeCreateSymbolicLinkPrivilege,
250
+ * 默认只给管理员;开启「开发者模式」后普通用户也可以)。其它 errno 一样按降级处理 ——
251
+ * 这一步是锦上添花,不值得中断整次同步。
240
252
  */
253
+ function trySymlink(linkTarget, linkPath, logPrefix) {
254
+ try {
255
+ node_fs_1.default.symlinkSync(linkTarget, linkPath, 'dir');
256
+ return true;
257
+ }
258
+ catch (err) {
259
+ const code = err.code ?? 'UNKNOWN';
260
+ const hint = process.platform === 'win32'
261
+ ? '(Windows 需以管理员运行,或在「设置 → 隐私和安全性 → 开发者选项」开启开发者模式)'
262
+ : '';
263
+ (0, logger_2.log)(logPrefix, ` ⚠ .claude/skills 软链创建失败 [${code}],跳过${hint};skills 仍在 .agents/skills 下可用`);
264
+ return false;
265
+ }
266
+ }
241
267
  function ensureClaudeSkillsSymlink(targetDir, logPrefix) {
242
268
  const linkPath = node_path_1.default.join(targetDir, '.claude', 'skills');
243
269
  const linkTarget = node_path_1.default.join('..', '.agents', 'skills');
@@ -251,7 +277,8 @@ function ensureClaudeSkillsSymlink(targetDir, logPrefix) {
251
277
  throw err;
252
278
  }
253
279
  if (existing === null) {
254
- node_fs_1.default.symlinkSync(linkTarget, linkPath, 'dir');
280
+ if (!trySymlink(linkTarget, linkPath, logPrefix))
281
+ return 'unsupported';
255
282
  (0, logger_2.log)(logPrefix, ` ✓ .claude/skills → ${linkTarget} (symlink created)`);
256
283
  return 'created';
257
284
  }
@@ -261,7 +288,8 @@ function ensureClaudeSkillsSymlink(targetDir, logPrefix) {
261
288
  return 'noop';
262
289
  }
263
290
  node_fs_1.default.unlinkSync(linkPath);
264
- node_fs_1.default.symlinkSync(linkTarget, linkPath, 'dir');
291
+ if (!trySymlink(linkTarget, linkPath, logPrefix))
292
+ return 'unsupported';
265
293
  (0, logger_2.log)(logPrefix, ` ✓ .claude/skills → ${linkTarget} (symlink updated, was → ${current})`);
266
294
  return 'updated';
267
295
  }
@@ -63,7 +63,15 @@ function removeLineFromContent(content, pattern) {
63
63
  // 不碰权限 —— 它跑在同一次调用里、紧随 syncSandboxSkills 之后,目录已经可写;
64
64
  // 各写入方各自 chmod 会与收尾的 lockDirReadonly 形成隐式耦合,范围一旦不对称就会
65
65
  // 出「跑一次 skills sync 把整个 skills 目录解封」这种事。
66
+ //
67
+ // **平台前提**:整套只读锁语义是 POSIX mode 位的,靠 shell `chmod -R` 实现。Windows
68
+ // 没有对应概念(NTFS 用 ACL,mode 位不映射),所以那里直接跳过加解锁 —— 锁和解锁
69
+ // 成对失效,语义仍自洽:目录始终可写,只是少了「防手改」这层保护。这是有意的降级,
70
+ // 不是遗漏;不用 fs.chmodSync 模拟,因为它在 Windows 上对目录基本是 no-op,
71
+ // 只会制造「以为锁上了」的假象。
66
72
  // ─────────────────────────────────────────────────────────────────────────────
73
+ /** POSIX mode 位语义是否可用;Windows 上整套只读锁降级为 no-op */
74
+ const SUPPORTS_POSIX_MODE = process.platform !== 'win32';
67
75
  /**
68
76
  * 解锁目录使其可写(`chmod -R u+w`)。
69
77
  *
@@ -71,8 +79,12 @@ function removeLineFromContent(content, pattern) {
71
79
  * 只加 owner 写位、不动 g/o,与老链路一致。目录不存在直接返回 false(调用方
72
80
  * 通常紧接着 mkdir)。失败只 debug 不抛:解锁是尽力而为,真写不进去让后续
73
81
  * 写操作自己报错,错误信息更具体。
82
+ *
83
+ * Windows 返回 false(没上过锁,也就无需解锁)。
74
84
  */
75
85
  function unlockDirForWrite(dir) {
86
+ if (!SUPPORTS_POSIX_MODE)
87
+ return false;
76
88
  if (!node_fs_1.default.existsSync(dir))
77
89
  return false;
78
90
  try {
@@ -92,8 +104,12 @@ function unlockDirForWrite(dir) {
92
104
  * {@link unlockDirForWrite}。
93
105
  *
94
106
  * **调用时机必须是全部写入完成之后**——锁上就写不进去了。
107
+ *
108
+ * Windows 直接返回 false(不加锁,对应 unlockDirForWrite 也不解锁)。
95
109
  */
96
110
  function lockDirReadonly(dir) {
111
+ if (!SUPPORTS_POSIX_MODE)
112
+ return false;
97
113
  if (!node_fs_1.default.existsSync(dir))
98
114
  return false;
99
115
  try {
@@ -112,18 +128,22 @@ function lockDirReadonly(dir) {
112
128
  * 只读锁下;另外老 update.sh 曾以 root 跑过,可能留下异主文件(如 root-owned 的
113
129
  * `.last-update`),`rmSync{force:true}` 只忽略 ENOENT、遇 EACCES 仍抛。
114
130
  * shell `rm -rf` 对异主文件 / 怪符号链接比 rmSync 宽容,故作主档。
131
+ *
132
+ * Windows 上没有 `rm`(也没有异主/只读锁那套前提),直接走 `fs.rmSync` 这一层。
115
133
  */
116
134
  function forceRemove(target) {
117
135
  if (!node_fs_1.default.existsSync(target))
118
136
  return;
119
- unlockDirForWrite(target);
120
- try {
121
- (0, node_child_process_1.execFileSync)('rm', ['-rf', target], { stdio: ['ignore', 'ignore', 'pipe'] });
122
- if (!node_fs_1.default.existsSync(target))
123
- return;
124
- }
125
- catch (err) {
126
- (0, logger_1.debug)(`file-ops: rm -rf ${target} failed: ${err.message}`);
137
+ if (SUPPORTS_POSIX_MODE) {
138
+ unlockDirForWrite(target);
139
+ try {
140
+ (0, node_child_process_1.execFileSync)('rm', ['-rf', target], { stdio: ['ignore', 'ignore', 'pipe'] });
141
+ if (!node_fs_1.default.existsSync(target))
142
+ return;
143
+ }
144
+ catch (err) {
145
+ (0, logger_1.debug)(`file-ops: rm -rf ${target} failed: ${err.message}`);
146
+ }
127
147
  }
128
148
  node_fs_1.default.rmSync(target, { recursive: true, force: true });
129
149
  }
@@ -64,13 +64,13 @@ function createClient(opts) {
64
64
  * 默认灰度泳道。当前 miaoda CLI 接入的新接口(modern deploy 等)只在该泳道生效,
65
65
  * 缺省走这个。MIAODA_CANARY_HEADER 可显式覆盖;设为空字符串则去掉头。
66
66
  *
67
- * ⚠️ TEMP(creative-upgrade 联调用):默认泳道临时指向开发泳道 boe_debug_detail_lookup,
68
- * 使 alpha 发版进沙箱镜像后,app export / code-archive 等新接口无需 wrapper 即可命中
69
- * 尚未合流的 studio_server 改动。
70
- * 上线前必须还原为 'boe_miaoda_ccm',或改为由 agent_tool 按请求 x-tt-env 注入
71
- * MIAODA_CANARY_HEADER(详见 http.ts 的 applyCanaryHeader 与 agent_tool ExecShellCmd)。
67
+ * 联调未合流的服务端改动时,不要改这个默认值 —— 用 MIAODA_CANARY_HEADER 覆盖,
68
+ * 或由 agent_tool 按请求 x-tt-env 注入(详见 applyCanaryHeader 与 agent_tool
69
+ * ExecShellCmd)。改默认值意味着所有沙箱都被带去开发泳道,且历史上多次忘记还原
70
+ * (boe_miaoda_doubao → ppe_miaoda_ccm → boe_miaoda_ccm → boe_debug_detail_lookup
71
+ * → boe_creative_history)。
72
72
  */
73
- const DEFAULT_CANARY_HEADER = 'boe_debug_detail_lookup';
73
+ const DEFAULT_CANARY_HEADER = 'prod';
74
74
  /**
75
75
  * 注入 x-tt-env 小流量头:env 显式设值 > DEFAULT_CANARY_HEADER。
76
76
  *
@@ -6,12 +6,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.pickLaneVersion = pickLaneVersion;
7
7
  exports.extractLaneFromTgzKey = extractLaneFromTgzKey;
8
8
  exports.fetchNpmPackage = fetchNpmPackage;
9
- const node_child_process_1 = require("node:child_process");
10
9
  const node_fs_1 = __importDefault(require("node:fs"));
11
10
  const node_os_1 = __importDefault(require("node:os"));
12
11
  const node_path_1 = __importDefault(require("node:path"));
12
+ const archive_1 = require("./archive");
13
13
  const error_1 = require("./error");
14
14
  const logger_1 = require("./logger");
15
+ const run_npm_1 = require("./run-npm");
15
16
  const DEFAULT_REGISTRY = 'https://registry.npmmirror.com/';
16
17
  /**
17
18
  * 从 `npm view <pkg> versions --json` 里过滤出后缀 `-alpha.<X>` 的版本。
@@ -29,7 +30,7 @@ function pickLaneVersion(packageName, X, registry) {
29
30
  const reg = registry ?? process.env.MIAODA_NPM_REGISTRY ?? DEFAULT_REGISTRY;
30
31
  const suffix = `-alpha.${X}`;
31
32
  try {
32
- const stdout = (0, node_child_process_1.execFileSync)('npm', ['view', packageName, 'versions', '--json', '--registry', reg], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
33
+ const stdout = (0, run_npm_1.execNpmSyncCapture)(['view', packageName, 'versions', '--json', '--registry', reg], { stdio: ['pipe', 'pipe', 'pipe'] });
33
34
  const versions = JSON.parse(stdout);
34
35
  if (!Array.isArray(versions))
35
36
  return null;
@@ -75,7 +76,7 @@ function extractLaneFromTgzKey(tgzKey) {
75
76
  return lane;
76
77
  }
77
78
  /**
78
- * `npm pack <pkg>@<ver> --registry <r>` + `tar -xzf`,解到临时目录。
79
+ * `npm pack <pkg>@<ver> --registry <r>` + 解压(走 archive 跨平台封装),解到临时目录。
79
80
  * extractDir 是包内容根(含 package.json)。使用完必须 cleanup()。
80
81
  */
81
82
  function fetchNpmPackage(opts) {
@@ -85,7 +86,7 @@ function fetchNpmPackage(opts) {
85
86
  const tmpDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'miaoda-pack-'));
86
87
  try {
87
88
  (0, logger_1.debug)(`npm pack ${pkgSpec} --registry ${registry} → ${tmpDir}`);
88
- const stdout = (0, node_child_process_1.execFileSync)('npm', ['pack', pkgSpec, '--pack-destination', tmpDir, '--json', '--registry', registry], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
89
+ const stdout = (0, run_npm_1.execNpmSyncCapture)(['pack', pkgSpec, '--pack-destination', tmpDir, '--json', '--registry', registry], { stdio: ['pipe', 'pipe', 'pipe'] });
89
90
  const packInfo = JSON.parse(stdout);
90
91
  const tgzFilename = packInfo[0]?.filename;
91
92
  const pkgVersion = packInfo[0]?.version ?? version;
@@ -95,9 +96,7 @@ function fetchNpmPackage(opts) {
95
96
  const tgzPath = node_path_1.default.join(tmpDir, tgzFilename);
96
97
  const extractDir = node_path_1.default.join(tmpDir, 'extracted');
97
98
  node_fs_1.default.mkdirSync(extractDir, { recursive: true });
98
- (0, node_child_process_1.execFileSync)('tar', ['-xzf', tgzPath, '-C', extractDir], {
99
- stdio: ['ignore', 'pipe', 'pipe'],
100
- });
99
+ (0, archive_1.extractTarGz)(tgzPath, extractDir);
101
100
  return {
102
101
  extractDir: node_path_1.default.join(extractDir, 'package'),
103
102
  version: pkgVersion,
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.execNpmSync = execNpmSync;
7
+ exports.execNpmSyncCapture = execNpmSyncCapture;
8
+ exports.runNpmSync = runNpmSync;
9
+ exports.spawnNpm = spawnNpm;
10
+ const cross_spawn_1 = __importDefault(require("cross-spawn"));
11
+ const error_1 = require("../utils/error");
12
+ const logger_1 = require("../utils/logger");
13
+ /**
14
+ * 跨平台 npm 执行入口 —— 仓库内**所有** npm 调用必须走这里,禁止再裸写
15
+ * `execFileSync('npm', ...)` / `spawnSync('npm', ...)` / `spawn('npm', ...)`,
16
+ * 也禁止直接 import `cross-spawn` 绕过这层(两条都由 ESLint 强制)。
17
+ *
18
+ * 为什么必须封装:Windows 上 npm 不是可执行文件而是 `npm.cmd` 批处理包装。
19
+ * - `execFile` / `spawn` 不带 shell 时按可执行文件解析,命中不到 `npm` → ENOENT;
20
+ * - 自 Node 18.20.2 / 20.12.2(CVE-2024-27980)起,不带 `shell: true` 执行
21
+ * `.cmd` / `.bat` 会直接抛 EINVAL,连"碰巧能跑"都不再成立。
22
+ *
23
+ * 为什么用 `cross-spawn` 而不是自己处理:正确的做法不只是"加 shell"。cross-spawn
24
+ * 在 Windows 上先判断目标是否真可执行文件,只在需要时切到 cmd.exe,并对参数做完整的
25
+ * 元字符转义(还专门处理了 `node_modules/.bin/*.cmd` 这类 cmd-shim 需要**双重**转义
26
+ * 的情况)。这些边角自己写必定漏,而它是 5000 万周下载量级的生态基建。
27
+ *
28
+ * 这层封装自身只做三件事:钉死命令名为 `npm`、把 spawn 结果的失败语义统一成
29
+ * `AppError`、给调用点一个稳定的收口位置。
30
+ */
31
+ /** npm 子进程失败时统一抛的错误码 */
32
+ const NPM_FAILED_CODE = 'NPM_COMMAND_FAILED';
33
+ /** stderr 被 pipe 时,附进错误信息的尾部长度上限(避免把整篇 npm 日志灌进 message) */
34
+ const STDERR_TAIL_LIMIT = 800;
35
+ /**
36
+ * 取 stderr 尾部拼进错误信息。
37
+ *
38
+ * 参数刻意收窄成结构类型并显式带 `null`:node 的 `SpawnSyncReturns.stderr` 类型上是
39
+ * 非空的,但 `stdio: 'inherit' | 'ignore'` 时运行时实际为 null(输出已直出或丢弃)。
40
+ * 照类型写会漏掉这种情况,照运行时写又会被 no-unnecessary-condition 判成多余判断 ——
41
+ * 所以在这里把真实契约写清楚。
42
+ */
43
+ function stderrTail(result) {
44
+ const raw = result.stderr;
45
+ if (raw === null || raw === undefined)
46
+ return '';
47
+ const text = typeof raw === 'string' ? raw : raw.toString('utf-8');
48
+ const trimmed = text.trim();
49
+ if (trimmed === '')
50
+ return '';
51
+ const tail = trimmed.length > STDERR_TAIL_LIMIT ? trimmed.slice(-STDERR_TAIL_LIMIT) : trimmed;
52
+ return `\n${tail}`;
53
+ }
54
+ /**
55
+ * 把 spawnSync 结果里的失败翻译成 AppError。
56
+ *
57
+ * 两类失败要分开报,因为处置动作不同:
58
+ * - `error` 非空 = 进程根本没起来(npm 不在 PATH、权限问题);
59
+ * - `status !== 0` = npm 起来了但命令失败(装包失败、脚本报错),此时 stderr 才有内容。
60
+ */
61
+ function assertNpmOk(args, result) {
62
+ if (result.error) {
63
+ throw new error_1.AppError(NPM_FAILED_CODE, `npm 启动失败(npm ${args.join(' ')}):${result.error.message}`, {
64
+ next_actions: ['确认本机已安装 npm 且在 PATH 中', '可加 --verbose 查看执行详情'],
65
+ });
66
+ }
67
+ if (result.status !== 0) {
68
+ const code = result.status === null ? `signal ${String(result.signal)}` : `exit ${String(result.status)}`;
69
+ throw new error_1.AppError(NPM_FAILED_CODE, `npm ${args.join(' ')} 失败(${code})${stderrTail(result)}`, { next_actions: ['检查上方 npm 输出定位具体错误'] });
70
+ }
71
+ }
72
+ /**
73
+ * 同步跑 npm,失败抛 `NPM_COMMAND_FAILED`(对齐原先 `execFileSync('npm', args)` 的
74
+ * "失败即抛"语义)。`stdio` 等选项原样透传。
75
+ */
76
+ function execNpmSync(args, options = {}) {
77
+ (0, logger_1.debug)(`run-npm: npm ${args.join(' ')}`);
78
+ const result = cross_spawn_1.default.sync('npm', args, options);
79
+ assertNpmOk(args, result);
80
+ }
81
+ /**
82
+ * 同步跑 npm 并拿回 stdout 字符串,失败抛 `NPM_COMMAND_FAILED`。
83
+ * 用于需要解析输出的场景(如 `npm pack --json`)。
84
+ */
85
+ function execNpmSyncCapture(args, options = {}) {
86
+ (0, logger_1.debug)(`run-npm: npm ${args.join(' ')} (capture)`);
87
+ const result = cross_spawn_1.default.sync('npm', args, { ...options, encoding: 'utf-8' });
88
+ assertNpmOk(args, result);
89
+ return result.stdout;
90
+ }
91
+ /**
92
+ * 同步跑 npm,**不抛错**,返回 spawnSync 结果(对齐 `spawnSync('npm', args)` 的语义)。
93
+ * 调用方自行判 `error` / `status` —— 用于需要把失败当正常分支处理的场景。
94
+ */
95
+ function runNpmSync(args, options = {}) {
96
+ (0, logger_1.debug)(`run-npm: npm ${args.join(' ')} (no-throw)`);
97
+ return cross_spawn_1.default.sync('npm', args, options);
98
+ }
99
+ function spawnNpm(args, options = {}) {
100
+ (0, logger_1.debug)(`run-npm: npm ${args.join(' ')} (async)`);
101
+ return (0, cross_spawn_1.default)('npm', args, options);
102
+ }
@@ -43,6 +43,7 @@ const jsonc = __importStar(require("jsonc-parser"));
43
43
  const file_ops_1 = require("./file-ops");
44
44
  const merge_json_1 = require("./merge-json");
45
45
  const logger_1 = require("./logger");
46
+ const typescript_exclude_1 = require("./typescript-exclude");
46
47
  /**
47
48
  * 顺序执行 SyncRule[],逐条 apply。一条 rule 报错不影响后续 rule(catch + 记录),最终
48
49
  * 在 handler 层决定是否整体 fail。
@@ -106,6 +107,11 @@ function applyOne(rule, opts) {
106
107
  case 'patch-script': {
107
108
  return applyPatchScript(rule, targetDir, logPrefix);
108
109
  }
110
+ case 'typescript-exclude': {
111
+ const changed = (0, typescript_exclude_1.excludeTypeScriptPath)(node_path_1.default.join(targetDir, rule.to), rule.path);
112
+ (0, logger_1.log)(logPrefix, ` ${changed ? '✓' : '○'} ${rule.to} (exclude ${rule.path})`);
113
+ return { rule, action: changed ? 'patched' : 'noop', path: rule.to };
114
+ }
109
115
  case 'merge-json': {
110
116
  return applyMergeJson(rule, sourceRoot, targetDir, logPrefix);
111
117
  }
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.excludeTypeScriptPath = excludeTypeScriptPath;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const jsonc_parser_1 = require("jsonc-parser");
9
+ const error_1 = require("./error");
10
+ /** Only append to explicit template excludes; never rewrite inherited user configuration. */
11
+ function excludeTypeScriptPath(filename, excludedPath) {
12
+ if (!node_fs_1.default.existsSync(filename))
13
+ return false;
14
+ const source = node_fs_1.default.readFileSync(filename, 'utf8');
15
+ const errors = [];
16
+ const raw = (0, jsonc_parser_1.parse)(source, errors, { allowTrailingComma: true });
17
+ if (errors.length || !raw || typeof raw !== 'object')
18
+ throw new error_1.AppError('INVALID_CONFIG', `Invalid exclude array: ${filename}`);
19
+ if (raw.exclude === undefined) {
20
+ console.warn(`Cannot safely update inherited excludes in ${filename}; add ${excludedPath} to the effective TypeScript exclude configuration.`);
21
+ return false;
22
+ }
23
+ if (!Array.isArray(raw.exclude) || raw.exclude.some((p) => typeof p !== 'string'))
24
+ throw new error_1.AppError('INVALID_CONFIG', `Invalid exclude array: ${filename}`);
25
+ if (raw.exclude.includes(excludedPath))
26
+ return false;
27
+ node_fs_1.default.writeFileSync(filename, (0, jsonc_parser_1.applyEdits)(source, (0, jsonc_parser_1.modify)(source, ['exclude', raw.exclude.length], excludedPath, {
28
+ isArrayInsertion: true,
29
+ formattingOptions: {
30
+ insertSpaces: true,
31
+ tabSize: 2,
32
+ eol: source.includes('\r\n') ? '\r\n' : '\n',
33
+ },
34
+ })));
35
+ return true;
36
+ }
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.whichSync = whichSync;
7
+ const which_1 = __importDefault(require("which"));
8
+ /**
9
+ * 跨平台可执行文件查找 —— 仓库内查 PATH 的唯一入口,禁止 shell out `which`。
10
+ *
11
+ * 为什么不 shell out:`which` 在 Windows 上不存在(对应的是 `where`),而 `where`
12
+ * 的输出格式与退出码语义又跟 `which` 不同。
13
+ *
14
+ * 为什么用社区 `which` 包而不是自己遍历 PATH:Windows 要按 PATHEXT 逐个后缀试探
15
+ * (`npm` 实际是 `npm.cmd`),POSIX 要看执行位,还有 all / delimiter 这些边角。
16
+ * 这些是 npm 官方维护的 `which` 已经做完并被全生态验证过的事,自己写只会漏。
17
+ *
18
+ * 保留这层薄封装而不是让各处直接 `import which`,是为了:
19
+ * 1. 把 `null`(社区库的「没找到」)归一成仓库内统一的 `undefined` optional 语义;
20
+ * 2. 留一个收口点 —— 换实现、加缓存、加平台特例都只改这里;
21
+ * 3. 配合 ESLint no-restricted-imports,保证不会有人绕过去直接用。
22
+ */
23
+ function whichSync(name) {
24
+ if (name === '')
25
+ return undefined;
26
+ return which_1.default.sync(name, { nothrow: true }) ?? undefined;
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/miaoda-cli",
3
- "version": "0.1.37",
3
+ "version": "0.1.39-alpha.24bc5ff",
4
4
  "description": "Miaoda 平台命令行工具,面向 Agent 调用",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -29,12 +29,16 @@
29
29
  "dependencies": {
30
30
  "@lark-apaas/http-client": "^0.1.5",
31
31
  "commander": "^13.1.0",
32
+ "cross-spawn": "^7.0.6",
32
33
  "jsonc-parser": "^3.3.1",
33
34
  "ora": "^5.4.1",
34
- "picocolors": "^1.1.1"
35
+ "picocolors": "^1.1.1",
36
+ "which": "^7.0.0"
35
37
  },
36
38
  "devDependencies": {
39
+ "@types/cross-spawn": "^6.0.6",
37
40
  "@types/node": "^22.15.3",
41
+ "@types/which": "^3.0.4",
38
42
  "@typescript-eslint/eslint-plugin": "^8.58.2",
39
43
  "@typescript-eslint/parser": "^8.58.2",
40
44
  "@vitest/coverage-v8": "^4.1.4",
@@ -11,15 +11,6 @@
11
11
  "outDir": "dist/server",
12
12
  "watchAssets": true
13
13
  }
14
- ],
15
- "plugins": [
16
- {
17
- "name": "@nestjs/swagger",
18
- "options": {
19
- "introspectComments": true,
20
- "classValidatorShim": true
21
- }
22
- }
23
14
  ]
24
15
  }
25
- }
16
+ }
@@ -146,6 +146,11 @@ STEP_START=$(node -e "console.log(Date.now())")
146
146
  # 使用 mv 而非 cp:HTML 不能上传到公网 CDN,移走后 dist/client 中不再包含 HTML
147
147
  if [ -d "$DIST_DIR/client" ]; then
148
148
  mkdir -p "$DIST_DIR/dist/client"
149
+ # public 先进(顶层 client 打包被排除;server setBaseViewsDir + 中间件同源 serve)
150
+ if [ -d "$ROOT_DIR/client/public" ]; then
151
+ cp -R "$ROOT_DIR/client/public/." "$DIST_DIR/dist/client/"
152
+ fi
153
+ # 构建产物 HTML 随后 move,覆盖 public 里的同名文件(保证入口页是构建版,不被 public 静默覆盖)
149
154
  find "$DIST_DIR/client" -maxdepth 1 -name "*.html" -exec mv {} "$DIST_DIR/dist/client/" \;
150
155
  fi
151
156
 
@@ -6,6 +6,13 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
 
8
8
  const ROOT_DIR = path.resolve(__dirname, '..');
9
+
10
+ // 加载提交的 .env,供读取构建期配置(如 MIAODA_RUNTIME_ENTRIES)。dotenv 不一定安装 → 静默降级。
11
+ try {
12
+ require('dotenv').config({ path: path.join(ROOT_DIR, '.env') });
13
+ } catch {
14
+ // dotenv 未安装:跳过;MIAODA_RUNTIME_ENTRIES 仍可来自真实环境变量
15
+ }
9
16
  const DIST_DIR = path.join(ROOT_DIR, 'dist');
10
17
  const DIST_SERVER_DIR = path.join(DIST_DIR, 'server');
11
18
  const ROOT_PACKAGE_JSON = path.join(ROOT_DIR, 'package.json');
@@ -16,6 +23,36 @@ const OUT_PACKAGE_JSON = path.join(DIST_DIR, 'package.json');
16
23
  // Server 入口文件
17
24
  const SERVER_ENTRY = path.join(DIST_SERVER_DIR, 'main.js');
18
25
 
26
+ // nest sourceRoot(源码根,默认 server):把 .env 里声明的源码路径映射到构建产物位置
27
+ let SOURCE_ROOT = 'server';
28
+ try {
29
+ const nestCli = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'nest-cli.json'), 'utf8'));
30
+ if (nestCli.sourceRoot) SOURCE_ROOT = nestCli.sourceRoot;
31
+ } catch {
32
+ // 读不到 nest-cli.json 则用默认 server
33
+ }
34
+ const SRC_TO_OUT_EXT = { '.mts': '.mjs', '.cts': '.cjs', '.ts': '.js' };
35
+
36
+ // 额外 trace 入口:经 new Function/动态 import 加载、nft 无法从 main.js 静态发现的 ESM runtime。
37
+ // 应用在提交的 .env 里用 MIAODA_RUNTIME_ENTRIES 声明【源码路径】(相对项目根,逗号分隔),例如:
38
+ // MIAODA_RUNTIME_ENTRIES=server/modules/ethics/runtime/review-run.mjs,server/modules/ethics/runtime/parse-core.mjs
39
+ // 脚本按 nest sourceRoot 自动映射到 dist/server 下的构建产物(并重写 .mts→.mjs / .ts→.js)。
40
+ const RUNTIME_ENTRIES = (process.env.MIAODA_RUNTIME_ENTRIES || '')
41
+ .split(',')
42
+ .map((s) => s.trim())
43
+ .filter(Boolean)
44
+ .map((srcRel) => {
45
+ const fromSrc = path.relative(SOURCE_ROOT, srcRel); // 去掉 sourceRoot 前缀
46
+ const ext = path.extname(fromSrc);
47
+ const outRel = SRC_TO_OUT_EXT[ext] ? fromSrc.slice(0, -ext.length) + SRC_TO_OUT_EXT[ext] : fromSrc;
48
+ return path.join(DIST_SERVER_DIR, outRel);
49
+ })
50
+ .filter((p) => {
51
+ if (fs.existsSync(p)) return true;
52
+ console.warn(`⚠️ MIAODA_RUNTIME_ENTRIES 入口在构建产物中不存在,已忽略: ${path.relative(ROOT_DIR, p)}`);
53
+ return false;
54
+ });
55
+
19
56
  // Node.js 内置模块列表
20
57
  const BUILTIN_MODULES = new Set([
21
58
  'assert', 'buffer', 'child_process', 'cluster', 'crypto', 'dgram', 'dns',
@@ -145,12 +182,15 @@ async function smartPrune() {
145
182
  }
146
183
 
147
184
  console.log(`📂 分析入口文件: ${path.relative(ROOT_DIR, SERVER_ENTRY)}`);
185
+ if (RUNTIME_ENTRIES.length) {
186
+ console.log(`📎 额外 trace 入口 (${RUNTIME_ENTRIES.length}): ${RUNTIME_ENTRIES.map((p) => path.relative(DIST_SERVER_DIR, p)).join(', ')}`);
187
+ }
148
188
 
149
189
  // 2. 使用 @vercel/nft 追踪依赖
150
190
  console.log('🔎 追踪实际依赖...');
151
191
  const analyzeStart = Date.now();
152
192
 
153
- const { fileList } = await nodeFileTrace([SERVER_ENTRY], {
193
+ const { fileList } = await nodeFileTrace([SERVER_ENTRY, ...RUNTIME_ENTRIES], {
154
194
  base: ROOT_DIR,
155
195
  processCwd: ROOT_DIR,
156
196
  ts: false, // 禁用 TS 解析
@@ -0,0 +1,61 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
5
+ OUTPUT="$ROOT/dist/output"
6
+ OUTPUT_RESOURCE="$ROOT/dist/output_resource"
7
+ OUTPUT_STATIC="$ROOT/dist/output_static"
8
+
9
+ # 映射平台环境变量到 preset 期望的变量名
10
+ # MIAODA_APP_ID → /app/<appId> 作为客户端 base path
11
+ # MIAODA_RESOURCE_CDN_PREFIX → assets (JS/CSS) 的 CDN 前缀
12
+ # CLI 注入约定见 miaoda-cli src/services/deploy/modern/atoms/build.ts
13
+ export CLIENT_BASE_PATH="${MIAODA_APP_ID:+/app/$MIAODA_APP_ID}"
14
+ export ASSETS_CDN_PATH="${MIAODA_RESOURCE_CDN_PREFIX:-/}"
15
+ export STATIC_ASSETS_BASE_URL="${MIAODA_STATIC_CDN_PREFIX}"
16
+ export NODE_ENV="${NODE_ENV:-production}"
17
+
18
+ # 清理
19
+ rm -rf "$ROOT/dist"
20
+
21
+ # 1. Vite 构建 → dist/client/(相对于项目根目录输出)
22
+ npx vite build --outDir "$ROOT/dist/client" --emptyOutDir
23
+
24
+ # 2. public/ 静态资源 + HTML → dist/output/(模型 B:public = 应用根目录,同源 /app/<appId>/*)
25
+ mkdir -p "$OUTPUT"
26
+ # 2a. public/*:vite copyPublicDir 已把 public/* 平铺进 dist/client,但下面白名单只取
27
+ # *.html/routes.json,其余(favicon / 图片 / 运行时 fetch 的数据文件)会随 dist/client 被删。
28
+ # 从源 public/ 补拷到 output/(同源根),让 fetch('/x') / resolveAppUrl('/x') 线上可用。
29
+ # 先拷 public、再拷 HTML,保证构建产出的 index.html/routes.json 覆盖 public 里的同名文件(若有)。
30
+ if [ -d "$ROOT/public" ]; then
31
+ cp -R "$ROOT/public/." "$OUTPUT/"
32
+ fi
33
+ # 2b. HTML + routes.json(构建产物)
34
+ find "$ROOT/dist/client" -maxdepth 1 \( -name '*.html' -o -name 'routes.json' \) -exec cp {} "$OUTPUT/" \;
35
+
36
+ # 3. assets/ → dist/output_resource/(JS/CSS/字体,上传到 CDN)
37
+ if [ -d "$ROOT/dist/client/assets" ]; then
38
+ mkdir -p "$OUTPUT_RESOURCE"
39
+ cp -r "$ROOT/dist/client/assets" "$OUTPUT_RESOURCE/"
40
+ fi
41
+
42
+ # 4. 私有静态资源 → dist/output_static/(排除代码文件)
43
+ if [ -d "$ROOT/shared/static" ]; then
44
+ mkdir -p "$OUTPUT_STATIC"
45
+ rsync -a --exclude='*.ts' --exclude='*.tsx' --exclude='*.js' --exclude='*.jsx' "$ROOT/shared/static/" "$OUTPUT_STATIC/"
46
+ fi
47
+
48
+ # 5. capability 配置 → dist/output_capabilities/
49
+ if [ -d "$ROOT/shared/capabilities" ]; then
50
+ mkdir -p "$ROOT/dist/output_capabilities"
51
+ cp -r "$ROOT/shared/capabilities/." "$ROOT/dist/output_capabilities/"
52
+ fi
53
+
54
+ # 清理中间产物
55
+ rm -rf "$ROOT/dist/client"
56
+
57
+ echo "Build complete"
58
+ echo " HTML → dist/output/"
59
+ [ -d "$OUTPUT_RESOURCE" ] && echo " Resource → dist/output_resource/" || true
60
+ [ -d "$OUTPUT_STATIC" ] && echo " Static → dist/output_static/" || true
61
+ [ -d "$ROOT/dist/output_capabilities" ] && echo " Capabilities → dist/output_capabilities/" || true