@lark-apaas/miaoda-cli 0.1.39-alpha.fc0f733 → 0.1.39-beta.179a78f

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/README.md CHANGED
@@ -54,19 +54,13 @@ miaoda file ls --output json
54
54
 
55
55
  ## 全栈应用的 MCP UI 检查
56
56
 
57
- `miaoda app sync` 为 `nestjs-react-fullstack` 应用同步 `scripts/lint.js`,在
58
- `tsconfig.node.json` 中追加 `server/mcp/ui` 排除项,并保留现有配置及继承的排除路径。
59
- 根 `eslint.config.js` / `.mjs` / `.cjs` 会增量追加 UI 全局忽略,直接 ESLint 调用和编辑器检查均生效。
60
- 迁移不执行配置文件;无法静态识别的自定义导出保留并提示手动补齐。
61
- 已知模板版本的 `type:check`、`eslint` 命令会迁移到独立检查入口;自定义命令保留并提示。
62
-
63
- - `npm run type:check`:检查服务端、客户端及 MCP UI。
64
- - `npm run type:check:mcp-ui` / `npm run eslint:mcp-ui`:单独检查 MCP UI。
65
- - `npm run lint -- --files <files...>`:按改动文件选择检查范围。
66
-
67
- 同步不会生成 MCP UI 目录。开发 MCP UI 时,由 Agent 按 MCP 指南创建
68
- `server/mcp/ui/tsconfig.json` 和根目录 `eslint.mcp-ui.config.cjs`。
69
- 没有 UI 源码时跳过独立检查;存在 UI 源码但缺少配置时明确报错。
57
+ 创建 MCP UI 时,Agent 在 `server/mcp/ui` 中同时生成 `tsconfig.json` 和 `eslint.config.cjs`,修改 UI 或其 shared 依赖后主动运行独立检查。模板和 CLI 不生成 UI 配置,也不将 UI 检查接入主工程 lint/type:check。
58
+
59
+ 主工程保留原 client/server 检查。Agent 从工程根执行 `npx --no-install tsc --noEmit --project server/mcp/ui/tsconfig.json`,再执行 `npx --no-install eslint --config server/mcp/ui/eslint.config.cjs "server/mcp/ui/**/*.{ts,tsx,js,jsx}" --max-warnings 0`;必须检查退出码并修复错误。无需辅助脚本或新增 npm 命令。
60
+
61
+ CLI 不注入 MCP UI 检查命令;主工程保留原有 client/server 检查,UI 由 Agent 按独立配置执行标准 tsc/eslint。
62
+
63
+ 存量 sync 不改写 ESLint 源码;UI 全局排除项由平台 ESLint 预设提供。TS 仅向已有显式 `exclude` 数组追加目录,保留注释和原项;依赖继承且未显式声明 `exclude` 的自定义配置保持不变并提示人工调整。自定义检查命令保留。lint.js 和 build.sh 仍由平台脚本模板维护,不依赖预设包中的脚本执行入口。
70
64
 
71
65
  ## 打包可本地访问的静态产物
72
66
 
@@ -50,9 +50,11 @@ async function getErrorLog(req) {
50
50
  * GET /v1/pipeline/app/:appID/instance/:instanceID/detail — 轮询发布状态
51
51
  *
52
52
  * 走 lark.apaas.devops_platform PSM;URL 前缀不同但共用同一管理端 gateway。
53
+ * 显式带 withTransformApprovalData=true 取审批展示视图(job 上挂 approval);IDL 默认 false,
54
+ * 不带时拿不到审批链接与拒绝原因。该参数只改返回投影,无副作用。
53
55
  */
54
56
  async function queryPipelineInstance(req) {
55
- const url = `/v1/pipeline/app/${encodeURIComponent(req.appID)}/instance/${encodeURIComponent(req.instanceID)}/detail`;
57
+ const url = `/v1/pipeline/app/${encodeURIComponent(req.appID)}/instance/${encodeURIComponent(req.instanceID)}/detail?withTransformApprovalData=true`;
56
58
  return (0, http_1.getInnerApi)(url, {
57
59
  errPrefix: 'Failed to query pipeline',
58
60
  defaultErrCode: DEFAULT_ERR_CODE,
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TERMINAL_NODE_STATUSES = void 0;
4
+ exports.approvalStatusText = approvalStatusText;
5
+ exports.findApproval = findApproval;
6
+ exports.approvalURL = approvalURL;
7
+ const types_1 = require("./types");
8
+ /** 流水线终态;REJECTED 为卡点审批被拒 */
9
+ exports.TERMINAL_NODE_STATUSES = [
10
+ types_1.NodeStatus.SUCCESS,
11
+ types_1.NodeStatus.FAILED,
12
+ types_1.NodeStatus.CANCELED,
13
+ types_1.NodeStatus.REJECTED,
14
+ ];
15
+ const APPROVAL_STATUS_TEXT = {
16
+ [types_1.ApprovalStatus.UNSPECIFIED]: 'unknown',
17
+ [types_1.ApprovalStatus.PENDING]: 'pending',
18
+ [types_1.ApprovalStatus.APPROVED]: 'approved',
19
+ [types_1.ApprovalStatus.REJECTED]: 'rejected',
20
+ [types_1.ApprovalStatus.CANCELED]: 'canceled',
21
+ [types_1.ApprovalStatus.FAILED]: 'failed',
22
+ };
23
+ /** 仍需用户知道点什么的卡点:在等谁、为什么被拒、平台没把审批发出去;已通过 / 已撤回不再展示 */
24
+ const SURFACED_APPROVAL_STATUSES = [
25
+ types_1.ApprovalStatus.PENDING,
26
+ types_1.ApprovalStatus.REJECTED,
27
+ types_1.ApprovalStatus.FAILED,
28
+ ];
29
+ /** 卡点审批状态数字 → 可读字符串;未知值为 unknown(n) */
30
+ function approvalStatusText(v) {
31
+ return APPROVAL_STATUS_TEXT[Number(v)] ?? `unknown(${String(v)})`;
32
+ }
33
+ function isRecord(v) {
34
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
35
+ }
36
+ function* jobsOf(stages) {
37
+ if (!Array.isArray(stages))
38
+ return;
39
+ for (const stage of stages) {
40
+ if (!isRecord(stage) || !Array.isArray(stage.jobs))
41
+ continue;
42
+ for (const group of stage.jobs) {
43
+ if (!Array.isArray(group))
44
+ continue;
45
+ for (const job of group)
46
+ if (isRecord(job))
47
+ yield job;
48
+ }
49
+ }
50
+ }
51
+ /**
52
+ * 从 CliQueryPipelineInstance 的 PipelineInstance(请求带 withTransformApprovalData=true,返回审批展示视图)中摘出需要输出的卡点:
53
+ * 第一个 status 为 PENDING / REJECTED / FAILED 的 Job.approval。一次发布可以被拦多次
54
+ * (build / db_check / deploy),已通过的卡点可能排在前面,不能取它;流水线串行,需要输出的至多一个。
55
+ * 与妙搭发布面板 findApprovalJob 同一口径。
56
+ *
57
+ * 原样透传,status 转为可读字符串,原始数字保留在 status_code(与 detail 各层 status 的约定一致)。
58
+ * 是否在审批中由顶层 status(PENDING)表达,这里只负责摘出审批链接、拒绝原因等上下文。
59
+ */
60
+ function findApproval(instance) {
61
+ for (const job of jobsOf(instance.stages)) {
62
+ const approval = job.approval;
63
+ if (!isRecord(approval) || !SURFACED_APPROVAL_STATUSES.includes(approval.status))
64
+ continue;
65
+ return {
66
+ ...approval,
67
+ status: approvalStatusText(approval.status),
68
+ status_code: approval.status,
69
+ };
70
+ }
71
+ return undefined;
72
+ }
73
+ /** 取审批链接;缺失或不是非空字符串时返回 undefined。是否可信由调用方(Skill)判断。 */
74
+ function approvalURL(approval) {
75
+ const url = approval?.approvalURL;
76
+ return typeof url === 'string' && url !== '' ? url : undefined;
77
+ }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.NodeStatus = exports.nodeStatusFromText = exports.nodeStatusText = exports.errorJobSchema = exports.deployGetSchema = exports.deployHistorySchema = exports.batchSavePluginInstances = exports.TosActionType = exports.LocalReleaseStatus = exports.applyTosDiff = exports.getModernLastPublishedVersion = exports.getModernReleaseStatus = exports.createModernRelease = exports.callbackStatic = exports.updateLocalRelease = exports.createLocalRelease = exports.preRelease = exports.queryPipelineInstance = exports.getErrorLog = exports.listPipelineInstances = exports.createRelease = void 0;
3
+ exports.TERMINAL_NODE_STATUSES = exports.approvalStatusText = exports.approvalURL = exports.findApproval = exports.ApprovalStatus = exports.NodeStatus = exports.nodeStatusFromText = exports.nodeStatusText = exports.errorJobSchema = exports.deployGetSchema = exports.deployHistorySchema = exports.batchSavePluginInstances = exports.TosActionType = exports.LocalReleaseStatus = exports.applyTosDiff = exports.getModernLastPublishedVersion = exports.getModernReleaseStatus = exports.createModernRelease = exports.callbackStatic = exports.updateLocalRelease = exports.createLocalRelease = exports.preRelease = exports.queryPipelineInstance = exports.getErrorLog = exports.listPipelineInstances = exports.createRelease = void 0;
4
4
  var api_1 = require("./api");
5
5
  Object.defineProperty(exports, "createRelease", { enumerable: true, get: function () { return api_1.createRelease; } });
6
6
  Object.defineProperty(exports, "listPipelineInstances", { enumerable: true, get: function () { return api_1.listPipelineInstances; } });
@@ -28,3 +28,9 @@ Object.defineProperty(exports, "nodeStatusText", { enumerable: true, get: functi
28
28
  Object.defineProperty(exports, "nodeStatusFromText", { enumerable: true, get: function () { return schemas_1.nodeStatusFromText; } });
29
29
  var types_1 = require("./types");
30
30
  Object.defineProperty(exports, "NodeStatus", { enumerable: true, get: function () { return types_1.NodeStatus; } });
31
+ Object.defineProperty(exports, "ApprovalStatus", { enumerable: true, get: function () { return types_1.ApprovalStatus; } });
32
+ var approval_1 = require("./approval");
33
+ Object.defineProperty(exports, "findApproval", { enumerable: true, get: function () { return approval_1.findApproval; } });
34
+ Object.defineProperty(exports, "approvalURL", { enumerable: true, get: function () { return approval_1.approvalURL; } });
35
+ Object.defineProperty(exports, "approvalStatusText", { enumerable: true, get: function () { return approval_1.approvalStatusText; } });
36
+ Object.defineProperty(exports, "TERMINAL_NODE_STATUSES", { enumerable: true, get: function () { return approval_1.TERMINAL_NODE_STATUSES; } });
@@ -13,6 +13,9 @@ const NODE_STATUS_TEXT = {
13
13
  [types_1.NodeStatus.FAILED]: 'failed',
14
14
  [types_1.NodeStatus.CANCELED]: 'canceled',
15
15
  [types_1.NodeStatus.HOLD_ON]: 'hold_on',
16
+ [types_1.NodeStatus.SKIPPED]: 'skipped',
17
+ [types_1.NodeStatus.PENDING]: 'pending',
18
+ [types_1.NodeStatus.REJECTED]: 'rejected',
16
19
  };
17
20
  function nodeStatusText(v) {
18
21
  if (typeof v === 'string')
@@ -8,7 +8,7 @@
8
8
  // 发布单的唯一标识是 pipelineTaskID,对外即 deploy-id;error-log 等接口直接用它做
9
9
  // URL 路径段(instanceID)。曾经的 Release/releaseID 概念已全面废弃。
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.NodeStatus = void 0;
11
+ exports.ApprovalStatus = exports.NodeStatus = void 0;
12
12
  /** Pipeline 节点状态(devops_platform) */
13
13
  var NodeStatus;
14
14
  (function (NodeStatus) {
@@ -18,5 +18,23 @@ var NodeStatus;
18
18
  NodeStatus[NodeStatus["SUCCESS"] = 3] = "SUCCESS";
19
19
  NodeStatus[NodeStatus["FAILED"] = 4] = "FAILED";
20
20
  NodeStatus[NodeStatus["CANCELED"] = 5] = "CANCELED";
21
+ /** 已废弃:服务端不再产出,保留仅为兼容历史数据 */
21
22
  NodeStatus[NodeStatus["HOLD_ON"] = 6] = "HOLD_ON";
23
+ /** 流水线内部中间态 */
24
+ NodeStatus[NodeStatus["SKIPPED"] = 7] = "SKIPPED";
25
+ NodeStatus[NodeStatus["PENDING"] = 8] = "PENDING";
26
+ /** 卡点审批被拒(人工拒绝 / 超时自动拒绝) */
27
+ NodeStatus[NodeStatus["REJECTED"] = 9] = "REJECTED";
22
28
  })(NodeStatus || (exports.NodeStatus = NodeStatus = {}));
29
+ // ── 发布审批(卡点)信息 ────────────
30
+ /** 卡点审批状态(devops_platform ApprovalStatus) */
31
+ var ApprovalStatus;
32
+ (function (ApprovalStatus) {
33
+ ApprovalStatus[ApprovalStatus["UNSPECIFIED"] = 0] = "UNSPECIFIED";
34
+ ApprovalStatus[ApprovalStatus["PENDING"] = 1] = "PENDING";
35
+ ApprovalStatus[ApprovalStatus["APPROVED"] = 2] = "APPROVED";
36
+ ApprovalStatus[ApprovalStatus["REJECTED"] = 3] = "REJECTED";
37
+ ApprovalStatus[ApprovalStatus["CANCELED"] = 4] = "CANCELED";
38
+ /** 卡点异常:发起审批失败等平台侧错误 */
39
+ ApprovalStatus[ApprovalStatus["FAILED"] = 5] = "FAILED";
40
+ })(ApprovalStatus || (exports.ApprovalStatus = ApprovalStatus = {}));
@@ -4,6 +4,18 @@ exports.registerDeployCommands = registerDeployCommands;
4
4
  const commander_1 = require("commander");
5
5
  const shared_1 = require("../../../cli/commands/shared");
6
6
  const index_1 = require("../../../cli/handlers/deploy/index");
7
+ /** deploy history --status 可选值(pipeline 节点状态);hold_on 已被服务端废弃,仅为兼容保留 */
8
+ const HISTORY_STATUS_CHOICES = [
9
+ 'todo',
10
+ 'running',
11
+ 'success',
12
+ 'failed',
13
+ 'canceled',
14
+ 'rejected',
15
+ 'pending',
16
+ 'skipped',
17
+ 'hold_on',
18
+ ];
7
19
  const COMMON_TIME_HELP = `
8
20
  时间格式:
9
21
  - 相对时间:30m(30 分钟前)、1h、2d、1w
@@ -21,8 +33,9 @@ function registerDeployCommands(program) {
21
33
  .description('触发发布;支持 --wait 阻塞到终态。子命令:get / history / error-log')
22
34
  .addOption((0, shared_1.appIdOption)().hideHelp())
23
35
  .addOption((0, shared_1.branchOption)().hideHelp())
24
- .option('--wait', '阻塞直到流水线终态', false)
36
+ .option('--wait', '阻塞直到流水线终态或进入人工审批', false)
25
37
  .option('--timeout <sec>', '--wait 最长等待秒数(默认 300)', parseTimeout, 300)
38
+ .option('--apply-reason <text>', '发布申请理由,写入审批单(单行,最多 1000 字符;是否必填由服务端按应用类型判定)')
26
39
  .addHelpText('after', `
27
40
  不要用异步模式或后台模式调用 deploy(例如 &、nohup、后台 task、detached run,或任何会让命令提前返回的包装方式),否则调用可能提前结束,Agent 会误判发布已完成。
28
41
  部署前置检查(Agent 必须执行)
@@ -31,20 +44,29 @@ function registerDeployCommands(program) {
31
44
  - 是本次任务的产物 → 先 commit + push 再 deploy
32
45
  - 不能确定意图 → 用 AskUser 跟用户确认(不要默默继续)
33
46
 
47
+ 发布理由与审批
48
+ 需要审批的应用发布时须带 --apply-reason(是否必填由服务端判定,缺失时服务端会报错)。
49
+ 理由为单行文本,最多 1000 字符,不能包含控制字符、零宽字符与双向控制字符。
50
+ 发布进入人工审批(status=pending)后 --wait 会立即返回,退出码为 0,不会轮询到超时。
51
+
34
52
  JSON 输出
35
53
  不带 --wait:{"data": {"pipelineTaskID": "..."}}(pipelineTaskID 即 deploy-id)
36
- 带 --wait:data 额外包含 detail(pipeline 终态)
54
+ 带 --wait:data 额外包含 detail(pipeline 当前状态);
55
+ 有卡点信息时还包含 approval(如 status、approvalURL、被拒时的 message)。
37
56
  detail/status 字段为可读字符串;对应 *_code 字段保留 BAM 原始数字枚举。
38
57
 
39
58
  状态确认(Agent 必须执行)
40
- 优先使用 --wait,但无论是否传 --wait,只要 deploy 调用后没有明确的 success / failed / canceled 终态,
59
+ 优先使用 --wait。status 为 pending 时停止轮询,告诉用户正在等待审批负责人处理;
60
+ 用户确认审批处理完后,查询同一个 deploy-id,不要重新发起发布。
61
+ 其余情况下,只要 deploy 调用后没有明确的 success / failed / rejected / canceled 终态或 pending,
41
62
  都必须每 2s 轮询一次当前发布状态:拿到 pipelineTaskID 就执行 \`miaoda deploy get <pipelineTaskID> --json\`;
42
63
  如果没有拿到 deploy-id 或输出为空,先执行 \`miaoda deploy history --limit 1 --json\` 找到对应发布记录并取得 ID,
43
- 再用这个 ID 每 2s 执行一次 \`miaoda deploy get <ID> --json\`,直到 status 进入终态。
64
+ 再用这个 ID 每 2s 执行一次 \`miaoda deploy get <ID> --json\`,直到 status 进入终态或 pending。
44
65
 
45
66
  示例
46
67
  $ miaoda deploy
47
68
  $ miaoda deploy --wait --timeout 600
69
+ $ miaoda deploy --wait --apply-reason "修复登录页报错"
48
70
  $ miaoda deploy get 12345
49
71
  $ miaoda deploy history --status failed
50
72
  `);
@@ -55,6 +77,7 @@ JSON 输出
55
77
  branch: rawOpts.branch,
56
78
  wait: rawOpts.wait,
57
79
  timeout: rawOpts.timeout,
80
+ applyReason: rawOpts.applyReason,
58
81
  });
59
82
  }));
60
83
  registerDeployGet(deployCmd);
@@ -72,6 +95,7 @@ JSON 输出
72
95
  {"data": {"ID": ..., "status": ..., "creator": "...", "createdAt": ..., "updatedAt": ..., ...}, "next_cursor": null, "has_more": false}
73
96
  ID 即 deploy-id(== pipelineTaskID)
74
97
  data.status 为可读字符串;data.status_code 为 BAM 原始数字枚举。
98
+ status 为 pending / rejected / failed 且存在审批单时,额外包含 approval(如 status、approvalURL、被拒时的 message)。
75
99
 
76
100
  示例
77
101
  $ miaoda deploy get 12345
@@ -90,8 +114,8 @@ function registerDeployHistory(parent) {
90
114
  .description('查询发布历史(按时间倒序,分页)')
91
115
  .addOption((0, shared_1.appIdOption)().hideHelp())
92
116
  .addOption(new commander_1.Option('--status <status>', '状态过滤(pipeline 节点状态,大小写不敏感)')
93
- .choices(['todo', 'running', 'success', 'failed', 'canceled', 'hold_on'])
94
- .argParser((0, shared_1.caseInsensitiveChoice)(['todo', 'running', 'success', 'failed', 'canceled', 'hold_on'])))
117
+ .choices(HISTORY_STATUS_CHOICES)
118
+ .argParser((0, shared_1.caseInsensitiveChoice)(HISTORY_STATUS_CHOICES)))
95
119
  .option('--since <time>', '起始时间')
96
120
  .option('--until <time>', '截止时间')
97
121
  .option('--limit <n>', '返回条数上限(1~100)', parseLimit, 50)
@@ -34,20 +34,28 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.handleDeploy = handleDeploy;
37
+ exports.writeApprovalNotice = writeApprovalNotice;
37
38
  const api = __importStar(require("../../../api/index"));
38
39
  const output_1 = require("../../../utils/output");
39
40
  const index_1 = require("../../../api/deploy/index");
40
41
  const polling_1 = require("./polling");
41
42
  const format_1 = require("./format");
43
+ const helpers_1 = require("./helpers");
42
44
  /** miaoda deploy [--branch ...] [--wait] [--timeout 300] */
43
45
  async function handleDeploy(opts) {
44
46
  const appID = opts.appId;
45
- const resp = await api.deploy.createRelease({ appID, branch: opts.branch });
47
+ if (opts.applyReason !== undefined)
48
+ (0, helpers_1.validateApplyReason)(opts.applyReason);
49
+ const resp = await api.deploy.createRelease({
50
+ appID,
51
+ branch: opts.branch,
52
+ ...(opts.applyReason !== undefined && { applyReason: opts.applyReason }),
53
+ });
46
54
  const pipelineTaskID = resp.pipelineTaskID ?? '';
47
55
  if (!opts.wait) {
48
56
  if (!(0, output_1.isJsonMode)()) {
49
57
  process.stdout.write(`✓ Deployment triggered. (deploy-id: ${pipelineTaskID})\n`);
50
- process.stdout.write(` hint: No terminal status yet. Poll every 2s with \`miaoda deploy get ${pipelineTaskID} --json\`; if no deploy-id is visible, first get it from \`miaoda deploy history --limit 1 --json\`.\n`);
58
+ process.stdout.write(` hint: No terminal status yet. Poll every 2s with \`miaoda deploy get ${pipelineTaskID} --json\`; if no deploy-id is visible, first get it from \`miaoda deploy history --limit 1 --json\`. Stop polling when status is pending (awaiting approval) and hand it to the user.\n`);
51
59
  }
52
60
  (0, output_1.emit)({
53
61
  data: { pipelineTaskID },
@@ -64,7 +72,8 @@ async function handleDeploy(opts) {
64
72
  pipelineTaskID,
65
73
  timeoutSec: opts.timeout ?? 300,
66
74
  });
67
- if (!(0, output_1.isJsonMode)()) {
75
+ const approval = (0, index_1.findApproval)(detail);
76
+ if (!(0, output_1.isJsonMode)() && !writeApprovalNotice(detail.status, approval, pipelineTaskID)) {
68
77
  if (detail.status === index_1.NodeStatus.SUCCESS) {
69
78
  process.stdout.write('✓ 发布成功\n');
70
79
  }
@@ -77,8 +86,52 @@ async function handleDeploy(opts) {
77
86
  }
78
87
  }
79
88
  (0, output_1.emit)({
80
- data: { pipelineTaskID, detail: (0, format_1.formatPipelineInstance)(detail) },
89
+ data: { pipelineTaskID, ...(approval && { approval }), detail: (0, format_1.formatPipelineInstance)(detail) },
81
90
  next_cursor: null,
82
91
  has_more: false,
83
92
  });
84
93
  }
94
+ /** 控制字符与不可见的 Unicode 格式字符替换成空格,防止服务端字段改写终端输出 */
95
+ function toDisplay(text) {
96
+ return text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, ' ').trim();
97
+ }
98
+ function writeReason(approval) {
99
+ const message = approval?.message;
100
+ if (typeof message === 'string' && message !== '') {
101
+ process.stdout.write(` 原因:${toDisplay(message)}\n`);
102
+ }
103
+ }
104
+ /** 卡点异常:平台没把审批发出去,没有任何一方做过裁决,不是代码问题 */
105
+ function writeApprovalGateFailed(approval) {
106
+ process.stdout.write('✗ 发布卡点异常:审批未能发起,不是代码问题,请稍后重试或联系平台\n');
107
+ writeReason(approval);
108
+ }
109
+ function writeRejected(approval) {
110
+ process.stdout.write('✗ 发布审批未通过,本次未上线\n');
111
+ writeReason(approval);
112
+ }
113
+ /**
114
+ * 审批相关状态的 pretty 提示:审批中 / 审批被拒 / 卡点异常。
115
+ * 不属于这三类时不输出并返回 false,由调用方按普通状态处理。
116
+ */
117
+ function writeApprovalNotice(status, approval, pipelineTaskID) {
118
+ if (status === index_1.NodeStatus.PENDING) {
119
+ writeApprovalHint(approval, pipelineTaskID);
120
+ }
121
+ else if (status === index_1.NodeStatus.REJECTED) {
122
+ writeRejected(approval);
123
+ }
124
+ else if (status === index_1.NodeStatus.FAILED && approval?.status === 'failed') {
125
+ writeApprovalGateFailed(approval);
126
+ }
127
+ else {
128
+ return false;
129
+ }
130
+ return true;
131
+ }
132
+ function writeApprovalHint(approval, pipelineTaskID) {
133
+ const url = (0, index_1.approvalURL)(approval);
134
+ process.stdout.write('⏸ 发布已进入人工审批,等待审批负责人处理\n');
135
+ process.stdout.write(url ? ` 审批链接:${toDisplay(url)}\n` : ' 服务端未返回审批链接\n');
136
+ process.stdout.write(` hint: 审批处理完成后运行 \`miaoda deploy get ${pipelineTaskID}\` 查询本次发布结果,不要重新发起发布\n`);
137
+ }
@@ -29,6 +29,7 @@ function formatPipelineInstance(detail) {
29
29
  jobs: stage.jobs.map((group) => group.map((job) => ({
30
30
  ...job,
31
31
  ...formatStatusMetadata(job.status),
32
+ ...formatJobApproval(job),
32
33
  steps: job.steps?.map((step) => ({
33
34
  ...step,
34
35
  ...formatStatusMetadata(step.status),
@@ -37,3 +38,15 @@ function formatPipelineInstance(detail) {
37
38
  })),
38
39
  };
39
40
  }
41
+ /** Job 上的卡点信息同样翻译 status,与 data.approval 的表示保持一致 */
42
+ function formatJobApproval(job) {
43
+ if (!job.approval)
44
+ return {};
45
+ return {
46
+ approval: {
47
+ ...job.approval,
48
+ status: (0, index_1.approvalStatusText)(job.approval.status),
49
+ status_code: job.approval.status,
50
+ },
51
+ };
52
+ }
@@ -41,16 +41,28 @@ const error_1 = require("../../../utils/error");
41
41
  const index_1 = require("../../../api/deploy/index");
42
42
  const helpers_1 = require("./helpers");
43
43
  const format_1 = require("./format");
44
+ const polling_1 = require("./polling");
45
+ const deploy_1 = require("./deploy");
44
46
  /**
45
47
  * 单页拉取上限:BAM listPipelineInstances 限制 1~500,取 500 让 deploy get
46
48
  * 在最近 500 条记录里按 ID 命中。
47
49
  */
48
50
  const DEPLOY_GET_LOOKUP_LIMIT = 500;
51
+ /**
52
+ * 可能挂着审批单的状态:审批中、审批被拒,以及卡点异常(平台没能发起审批,顶层表现为 FAILED)。
53
+ * 只有这些状态才额外查详情取审批单,其余状态保持单次列表请求。
54
+ */
55
+ const APPROVAL_LOOKUP_STATUSES = [
56
+ index_1.NodeStatus.PENDING,
57
+ index_1.NodeStatus.REJECTED,
58
+ index_1.NodeStatus.FAILED,
59
+ ];
49
60
  /**
50
61
  * miaoda deploy get <deploy-id>
51
62
  *
52
63
  * BAM 没有 by-ID 的查询接口,复用 listPipelineInstances 在最近窗口里按 ID 筛一条。
53
64
  * 命中失败时抛 DEPLOY_NOT_FOUND,提示用户用 history 查窗口。
65
+ * status 为 pending / rejected / failed 时额外查详情,有审批单则输出 approval(审批链接、拒绝原因等)。
54
66
  */
55
67
  async function handleDeployGet(opts) {
56
68
  if (!opts.deployId)
@@ -67,5 +79,33 @@ async function handleDeployGet(opts) {
67
79
  next_actions: ['运行 `miaoda deploy history` 确认 deploy-id 是否在最近窗口内'],
68
80
  });
69
81
  }
70
- (0, output_1.emit)({ data: (0, format_1.formatSimpleInstance)(instance), next_cursor: null, has_more: false }, index_1.deployGetSchema);
82
+ const approval = APPROVAL_LOOKUP_STATUSES.includes(instance.status)
83
+ ? await lookupApproval(appID, opts.deployId, instance.status)
84
+ : undefined;
85
+ (0, output_1.emit)({
86
+ data: { ...(0, format_1.formatSimpleInstance)(instance), ...(approval && { approval }) },
87
+ next_cursor: null,
88
+ has_more: false,
89
+ }, index_1.deployGetSchema);
90
+ if (!(0, output_1.isJsonMode)())
91
+ (0, deploy_1.writeApprovalNotice)(instance.status, approval, opts.deployId);
92
+ }
93
+ /**
94
+ * 列表项不含审批信息:按同一个 ID 查详情接口(审批展示视图)取审批单。
95
+ *
96
+ * pending / rejected 时详情失败直接抛错:丢掉审批链接 / 拒绝原因却无人察觉,比报错更糟。
97
+ * failed 时降级为只输出列表数据并在 stderr 提示:普通构建失败远多于卡点异常,不能因为详情接口
98
+ * 出错就让调用方连 status 都拿不到,卡住失败排查。
99
+ */
100
+ async function lookupApproval(appID, deployId, status) {
101
+ try {
102
+ return (0, index_1.findApproval)((0, polling_1.normalizePipelineInstance)(await api.deploy.queryPipelineInstance({ appID, instanceID: deployId })));
103
+ }
104
+ catch (err) {
105
+ if (status !== index_1.NodeStatus.FAILED)
106
+ throw err;
107
+ const reason = err instanceof Error ? err.message : String(err);
108
+ process.stderr.write(`warning: 查询审批单失败,输出不含 approval:${reason}\n`);
109
+ return undefined;
110
+ }
71
111
  }
@@ -3,18 +3,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseStatusFlag = parseStatusFlag;
4
4
  exports.validateLimit = validateLimit;
5
5
  exports.parseDeployId = parseDeployId;
6
+ exports.validateApplyReason = validateApplyReason;
6
7
  const args_1 = require("../../../utils/args");
7
8
  const index_1 = require("../../../api/deploy/index");
8
9
  /**
9
10
  * 把 CLI --status 字符串映射成 BAM NodeStatus 数字;非法值抛 ARGS_INVALID。
10
11
  *
11
12
  * 历史接口(CliListPipelineInstances)返回 SimpleInstance,状态语义是 pipeline 节点状态,
12
- * 取值 todo / running / success / failed / canceled / hold_on。
13
+ * 取值 todo / running / success / failed / canceled / rejected / pending / skipped / hold_on(已废弃)。
13
14
  */
14
15
  function parseStatusFlag(text) {
15
16
  const value = (0, index_1.nodeStatusFromText)(text);
16
17
  if (value === undefined) {
17
- (0, args_1.failArgs)(`--status 无效:'${text}'。可选值:todo | running | success | failed | canceled | hold_on`);
18
+ (0, args_1.failArgs)(`--status 无效:'${text}'。可选值:todo | running | success | failed | canceled | rejected | pending | skipped | hold_on`);
18
19
  }
19
20
  return value;
20
21
  }
@@ -39,3 +40,29 @@ function parseDeployId(text) {
39
40
  }
40
41
  return n;
41
42
  }
43
+ /** 发布理由上限,按 Unicode code point 计,对齐 lark-cli `apps +release-create` */
44
+ const MAX_APPLY_REASON_CODE_POINTS = 1000;
45
+ /** C0 / C1 控制字符(含换行、制表符) */
46
+ const CONTROL_CHARS = /\p{Cc}/u;
47
+ /**
48
+ * 不可见格式字符(零宽、BOM、双向控制、词连接符等)与行/段分隔符:肉眼不可见,却会改变审批单里的展示。
49
+ * 与 deploy 输出服务端文本时的过滤口径一致。
50
+ */
51
+ const INVISIBLE_CHARS = /[\p{Cf}\u2028\u2029]/u;
52
+ /**
53
+ * 校验 --apply-reason 的格式;通过后调用方原样透传,不 trim。
54
+ * 是否必填由服务端按应用类型判定,这里不管。
55
+ */
56
+ function validateApplyReason(value) {
57
+ if (value.trim() === '')
58
+ (0, args_1.failArgs)('--apply-reason 不能为空');
59
+ if (CONTROL_CHARS.test(value)) {
60
+ (0, args_1.failArgs)('--apply-reason 不能包含换行、制表符等控制字符,需为单行文本');
61
+ }
62
+ if (INVISIBLE_CHARS.test(value)) {
63
+ (0, args_1.failArgs)('--apply-reason 不能包含零宽字符、双向控制字符或行/段分隔符');
64
+ }
65
+ if (Array.from(value).length > MAX_APPLY_REASON_CODE_POINTS) {
66
+ (0, args_1.failArgs)(`--apply-reason 最多 ${String(MAX_APPLY_REASON_CODE_POINTS)} 个字符`);
67
+ }
68
+ }
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.DEFAULT_POLL_INTERVAL_MS = void 0;
40
40
  exports.waitForPipeline = waitForPipeline;
41
+ exports.normalizePipelineInstance = normalizePipelineInstance;
41
42
  const ora_1 = __importDefault(require("ora"));
42
43
  const api = __importStar(require("../../../api/index"));
43
44
  const error_1 = require("../../../utils/error");
@@ -45,16 +46,11 @@ const output_1 = require("../../../utils/output");
45
46
  const index_1 = require("../../../api/deploy/index");
46
47
  /** 默认轮询间隔(毫秒) */
47
48
  exports.DEFAULT_POLL_INTERVAL_MS = 2_000;
48
- const TERMINAL_STATUSES = [
49
- index_1.NodeStatus.SUCCESS,
50
- index_1.NodeStatus.FAILED,
51
- index_1.NodeStatus.CANCELED,
52
- ];
53
49
  function defaultSleep(ms) {
54
50
  return new Promise((res) => setTimeout(res, ms));
55
51
  }
56
52
  /**
57
- * 轮询 pipeline 状态直到终态或超时。
53
+ * 轮询 pipeline 状态直到终态、进入人工审批或超时。
58
54
  *
59
55
  * - JSON 模式:轮询期间静默;终态后由 caller 输出最终信封。
60
56
  * - Pretty 非 TTY:同 JSON。
@@ -85,7 +81,11 @@ async function waitForPipeline(opts) {
85
81
  const detail = normalizePipelineInstance(resp);
86
82
  if (spinner)
87
83
  advanceProgress(detail, printedStages, spinner);
88
- if (TERMINAL_STATUSES.includes(detail.status)) {
84
+ if (index_1.TERMINAL_NODE_STATUSES.includes(detail.status)) {
85
+ return detail;
86
+ }
87
+ // 等待人工审批(顶层 PENDING):可能持续数小时,立即交还给调用方,不再轮询到超时
88
+ if (detail.status === index_1.NodeStatus.PENDING) {
89
89
  return detail;
90
90
  }
91
91
  if (now() - start >= timeoutMs) {
@@ -113,7 +113,7 @@ function advanceProgress(detail, printed, spinner) {
113
113
  for (const stage of stages) {
114
114
  if (printed.has(stage.id))
115
115
  continue;
116
- if (!TERMINAL_STATUSES.includes(stage.status))
116
+ if (!index_1.TERMINAL_NODE_STATUSES.includes(stage.status))
117
117
  continue;
118
118
  spinner.stopAndPersist({
119
119
  symbol: stageSymbol(stage),
@@ -153,7 +153,11 @@ function isPipelineInstance(value) {
153
153
  return typeof item.status === 'number';
154
154
  }
155
155
  function stageSymbol(stage) {
156
- return stage.status === index_1.NodeStatus.SUCCESS ? '✓' : stage.status === index_1.NodeStatus.FAILED ? '✗' : '•';
156
+ if (stage.status === index_1.NodeStatus.SUCCESS)
157
+ return '✓';
158
+ if (stage.status === index_1.NodeStatus.FAILED || stage.status === index_1.NodeStatus.REJECTED)
159
+ return '✗';
160
+ return '•';
157
161
  }
158
162
  function formatStageBody(stage) {
159
163
  const cost = stage.costTime >= 0 ? `(${(stage.costTime / 1000).toFixed(1)}s)` : '';
@@ -159,43 +159,12 @@ exports.SYNC_CONFIG = {
159
159
  to: `npx -y ${fullstack_cli_pin_1.FULLSTACK_CLI_PIN_SPEC} sync --disable-gen-openapi`,
160
160
  ifStartsWith: 'npx -y @lark-apaas/fullstack-cli sync',
161
161
  },
162
- { type: 'eslint-ignore', to: 'eslint.config.js', pattern: 'server/mcp/ui/**' },
163
- { type: 'eslint-ignore', to: 'eslint.config.mjs', pattern: 'server/mcp/ui/**' },
164
- { type: 'eslint-ignore', to: 'eslint.config.cjs', pattern: 'server/mcp/ui/**' },
165
- // MCP UI 使用独立浏览器配置;仅迁移已知模板命令,不生成 UI 目录或配置。
162
+ // MCP UI 使用独立浏览器配置;只补服务端排除项,不生成 UI 目录和配置。
166
163
  {
167
164
  type: 'typescript-exclude',
168
165
  to: 'tsconfig.node.json',
169
166
  path: 'server/mcp/ui',
170
167
  },
171
- {
172
- type: 'add-script',
173
- name: 'type:check:mcp-ui',
174
- command: 'node ./scripts/lint.js --typecheck-mcp-ui',
175
- overwrite: false,
176
- },
177
- {
178
- type: 'add-script',
179
- name: 'eslint:mcp-ui',
180
- command: 'node ./scripts/lint.js --eslint-mcp-ui',
181
- overwrite: false,
182
- },
183
- {
184
- type: 'patch-script',
185
- name: 'type:check',
186
- to: 'node ./scripts/lint.js --typecheck',
187
- ifEquals: [
188
- 'concurrently -n "server,client" -c "blue,green" "npm run type:check:server" "npm run type:check:client"',
189
- 'concurrently "npm run type:check:server" "npm run type:check:client"',
190
- 'concurrently "npm run type:check:client" "npm run type:check:server"',
191
- ],
192
- },
193
- {
194
- type: 'patch-script',
195
- name: 'eslint',
196
- to: 'eslint . --quiet --ignore-pattern "server/mcp/ui/**"',
197
- ifEquals: ['eslint . --quiet', 'eslint .'],
198
- },
199
168
  // ===== miaoda-cli 本地开发新增规则(fullstack-cli sync 不会执行) =====
200
169
  // M1. scripts.dev:local —— 本地用户绕过沙箱判定直接跑本地链路(npm run dev:local)。
201
170
  // dev.sh 在 MIAODA_DEP_CACHE_DIR 非空时跑 dev.js(沙箱保活)、否则 exec dev-local.js;显式 dev:local
@@ -44,7 +44,6 @@ const file_ops_1 = require("./file-ops");
44
44
  const merge_json_1 = require("./merge-json");
45
45
  const logger_1 = require("./logger");
46
46
  const typescript_exclude_1 = require("./typescript-exclude");
47
- const eslint_ignore_1 = require("./eslint-ignore");
48
47
  /**
49
48
  * 顺序执行 SyncRule[],逐条 apply。一条 rule 报错不影响后续 rule(catch + 记录),最终
50
49
  * 在 handler 层决定是否整体 fail。
@@ -108,19 +107,6 @@ function applyOne(rule, opts) {
108
107
  case 'patch-script': {
109
108
  return applyPatchScript(rule, targetDir, logPrefix);
110
109
  }
111
- case 'eslint-ignore': {
112
- const result = (0, eslint_ignore_1.addEslintGlobalIgnore)(node_path_1.default.join(targetDir, rule.to), rule.pattern);
113
- if (result.warning)
114
- (0, logger_1.log)(logPrefix, ` ⚠ ${result.warning}`);
115
- if (result.changed)
116
- (0, logger_1.log)(logPrefix, ` ✓ ${rule.to} (global ignore ${rule.pattern})`);
117
- return {
118
- rule,
119
- action: result.changed ? 'patched' : result.warning ? 'skipped' : 'noop',
120
- path: rule.to,
121
- detail: result.warning,
122
- };
123
- }
124
110
  case 'typescript-exclude': {
125
111
  const changed = (0, typescript_exclude_1.excludeTypeScriptPath)(node_path_1.default.join(targetDir, rule.to), rule.path);
126
112
  (0, logger_1.log)(logPrefix, ` ${changed ? '✓' : '○'} ${rule.to} (exclude ${rule.path})`);
@@ -235,10 +221,7 @@ function applyPatchScript(rule, targetDir, logPrefix) {
235
221
  (0, logger_1.log)(logPrefix, ` ○ scripts.${rule.name} (already patched)`);
236
222
  return { rule, action: 'noop', path: 'package.json' };
237
223
  }
238
- const matches = rule.ifEquals
239
- ? rule.ifEquals.includes(current)
240
- : rule.ifStartsWith !== undefined && current.startsWith(rule.ifStartsWith);
241
- if (!matches) {
224
+ if (!current.startsWith(rule.ifStartsWith)) {
242
225
  (0, logger_1.log)(logPrefix, ` ⚠ scripts.${rule.name} customized, skip patch`);
243
226
  return { rule, action: 'skipped', path: 'package.json' };
244
227
  }
@@ -5,58 +5,32 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.excludeTypeScriptPath = excludeTypeScriptPath;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
- const node_path_1 = __importDefault(require("node:path"));
9
- const typescript_1 = __importDefault(require("typescript"));
10
8
  const jsonc_parser_1 = require("jsonc-parser");
11
9
  const error_1 = require("./error");
12
- /** 追加排除项,保留 JSONC 注释和继承的排除路径;不创建缺失的配置。 */
10
+ /** Only append to explicit template excludes; never rewrite inherited user configuration. */
13
11
  function excludeTypeScriptPath(filename, excludedPath) {
14
12
  if (!node_fs_1.default.existsSync(filename))
15
13
  return false;
16
14
  const source = node_fs_1.default.readFileSync(filename, 'utf8');
17
15
  const errors = [];
18
16
  const raw = (0, jsonc_parser_1.parse)(source, errors, { allowTrailingComma: true });
19
- if (errors.length || !raw || typeof raw !== 'object' || Array.isArray(raw)) {
20
- throw new error_1.AppError('INVALID_CONFIG', `Invalid TypeScript configuration: ${filename}`);
21
- }
22
- const parsed = typescript_1.default.getParsedCommandLineOfConfigFile(filename, {}, {
23
- ...typescript_1.default.sys,
24
- onUnRecoverableConfigFileDiagnostic: (diagnostic) => {
25
- throw new error_1.AppError('INVALID_CONFIG', typescript_1.default.flattenDiagnosticMessageText(diagnostic.messageText, '\n'));
26
- },
27
- });
28
- // 初始化中的空工程允许迁移,其他错误不能被静默忽略。
29
- const diagnostics = parsed?.errors.filter((d) => d.code !== 18003 && d.code !== 18002) ?? [];
30
- if (!parsed || diagnostics.length) {
31
- throw new error_1.AppError('INVALID_CONFIG', `Cannot migrate ${filename}: ${diagnostics.map((d) => typescript_1.default.flattenDiagnosticMessageText(d.messageText, '\n')).join('; ')}`);
32
- }
33
- const effective = parsed.raw;
34
- const excludes = effective.exclude ?? [
35
- 'node_modules',
36
- 'bower_components',
37
- 'jspm_packages',
38
- ...[parsed.options.outDir, parsed.options.declarationDir]
39
- .filter((p) => Boolean(p))
40
- .map((p) => node_path_1.default.relative(node_path_1.default.dirname(filename), p).split(node_path_1.default.sep).join('/')),
41
- ];
42
- if (!Array.isArray(excludes) || excludes.some((p) => typeof p !== 'string')) {
17
+ if (errors.length || !raw || typeof raw !== 'object')
43
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;
44
22
  }
45
- const paths = excludes;
46
- if (paths.includes(excludedPath))
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))
47
26
  return false;
48
- const ownExclude = raw.exclude;
49
- const formattingOptions = {
50
- insertSpaces: true,
51
- tabSize: 2,
52
- eol: source.includes('\r\n') ? '\r\n' : '\n',
53
- };
54
- const edits = Array.isArray(ownExclude)
55
- ? (0, jsonc_parser_1.modify)(source, ['exclude', ownExclude.length], excludedPath, {
56
- formattingOptions,
57
- isArrayInsertion: true,
58
- })
59
- : (0, jsonc_parser_1.modify)(source, ['exclude'], [...paths, excludedPath], { formattingOptions });
60
- node_fs_1.default.writeFileSync(filename, (0, jsonc_parser_1.applyEdits)(source, edits));
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
+ })));
61
35
  return true;
62
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/miaoda-cli",
3
- "version": "0.1.39-alpha.fc0f733",
3
+ "version": "0.1.39-beta.179a78f",
4
4
  "description": "Miaoda 平台命令行工具,面向 Agent 调用",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -33,8 +33,7 @@
33
33
  "jsonc-parser": "^3.3.1",
34
34
  "ora": "^5.4.1",
35
35
  "picocolors": "^1.1.1",
36
- "which": "^7.0.0",
37
- "typescript": "^5.8.3"
36
+ "which": "^7.0.0"
38
37
  },
39
38
  "devDependencies": {
40
39
  "@types/cross-spawn": "^6.0.6",
@@ -52,6 +51,7 @@
52
51
  "prettier": "^3.8.3",
53
52
  "tsc-alias": "^1.8.11",
54
53
  "tsx": "^4.19.4",
54
+ "typescript": "^5.8.3",
55
55
  "vitest": "^4.1.4",
56
56
  "xml2js": "^0.6.2"
57
57
  },
@@ -18,31 +18,19 @@ function runCommand(command, args) {
18
18
  shell: false,
19
19
  });
20
20
 
21
- child.on('close', (code) => resolve(code ?? 1));
21
+ child.on('close', (code) => resolve(code || 0));
22
22
  child.on('error', () => resolve(1));
23
23
  });
24
24
  }
25
25
 
26
- // 串行依次执行每个任务,全部跑完后再聚合退出码:
27
- // 降低并发资源占用、让各任务输出按顺序清晰可读,同时保留“一次暴露所有 lint 问题”的行为。
28
- async function runTasksSerially(taskSpecs) {
29
- let exitCode = 0;
30
- for (const [command, args] of taskSpecs) {
31
- const code = await runCommand(command, args);
32
- if (code !== 0) {
33
- exitCode = 1;
34
- }
35
- }
36
- return exitCode;
37
- }
38
-
39
26
  function normalizeProjectFile(filePath) {
40
27
  const absolutePath = path.isAbsolute(filePath)
41
28
  ? filePath
42
29
  : path.resolve(cwd, filePath);
43
30
 
44
31
  if (!fs.existsSync(absolutePath)) {
45
- console.log(`[lint] Deleted file: ${filePath}; checking affected projects`);
32
+ console.warn(`[lint] Skip missing file: ${filePath}`);
33
+ return null;
46
34
  }
47
35
 
48
36
  const relativePath = path.relative(cwd, absolutePath);
@@ -106,92 +94,17 @@ function canRunStylelint() {
106
94
  return STYLELINT_CONFIG_FILES.some(file => fs.existsSync(path.join(cwd, file)));
107
95
  }
108
96
 
109
- const UI_DIR = 'server/mcp/ui';
110
- const UI_TYPECHECK = 'node ./scripts/lint.js --typecheck';
111
-
112
- function isUiFile(file) {
113
- return file.startsWith(`${UI_DIR}/`);
114
- }
115
-
116
- function uiSourceFiles() {
117
- const files = [];
118
- function visit(relative) {
119
- const absolute = path.join(cwd, relative);
120
- if (!fs.existsSync(absolute)) return;
121
- if (fs.lstatSync(absolute).isSymbolicLink()) {
122
- throw new Error(`MCP UI 检查不支持符号链接: ${relative}`);
123
- }
124
- for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) {
125
- if (['node_modules', 'dist', '.git'].includes(entry.name)) continue;
126
- const file = `${relative}/${entry.name}`;
127
- if (entry.isSymbolicLink()) throw new Error(`MCP UI 检查不支持符号链接: ${file}`);
128
- if (entry.isDirectory()) visit(file);
129
- else if (isEslintTarget(file) && !/\.d\.(?:ts|mts|cts)$/.test(file)) files.push(file);
130
- }
131
- }
132
- visit(UI_DIR);
133
- return files.sort();
134
- }
135
-
136
- function uiTasks({ types = true, eslint = true } = {}) {
137
- const sources = uiSourceFiles();
138
- if (!sources.length) return [];
139
- const tasks = [];
140
- if (types) {
141
- const config = `${UI_DIR}/tsconfig.json`;
142
- if (!fs.existsSync(path.join(cwd, config))) {
143
- throw new Error(`MCP UI 缺少 ${config},请按 mcp-guide 补齐独立浏览器 TypeScript 配置。`);
144
- }
145
- const ts = require('typescript');
146
- const parsed = ts.getParsedCommandLineOfConfigFile(path.join(cwd, config), {}, {
147
- ...ts.sys,
148
- onUnRecoverableConfigFileDiagnostic: d => { throw new Error(ts.flattenDiagnosticMessageText(d.messageText, '\n')); },
149
- });
150
- if (!parsed || parsed.errors.length) throw new Error(`MCP UI TypeScript 配置无效: ${config}`);
151
- const included = new Set(parsed.fileNames.map(file => path.resolve(file)));
152
- const missing = sources.filter(file => !included.has(path.resolve(cwd, file)));
153
- if (missing.length) throw new Error(`MCP UI TypeScript 配置未覆盖源码: ${missing.join(', ')};请检查 include/exclude,JS 源码需开启 allowJs。`);
154
- tasks.push([getBinName('npx'), ['--no-install', 'tsc', '--noEmit', '-p', config]]);
155
- }
156
- if (eslint) {
157
- const config = ['eslint.mcp-ui.config.js', 'eslint.mcp-ui.config.cjs', 'eslint.mcp-ui.config.mjs']
158
- .find(file => fs.existsSync(path.join(cwd, file)));
159
- if (!config) throw new Error('MCP UI 缺少独立 ESLint 配置,请按 mcp-guide 创建 eslint.mcp-ui.config.cjs。');
160
- tasks.push([getBinName('npx'), ['--no-install', 'eslint', '--config', config, '--quiet', '--max-warnings', '0', ...sources]]);
161
- }
162
- return tasks;
163
- }
164
-
165
- function packageScripts() {
166
- return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')).scripts || {};
167
- }
168
-
169
- async function runTypeCheck() {
170
- const scripts = packageScripts();
171
- const tasks = ['type:check:server', 'type:check:client'].map(name => {
172
- if (!scripts[name]) throw new Error(`缺少 scripts.${name},请保留应用原有类型检查命令。`);
173
- return [getBinName('npm'), ['run', name]];
174
- });
175
- tasks.push(...uiTasks({ eslint: false }));
176
- process.exit(await runTasksSerially(tasks));
177
- }
178
-
179
97
  async function runDefaultLint() {
180
- const taskSpecs = [
181
- [getBinName('npm'), ['run', 'eslint']],
182
- [getBinName('npm'), ['run', 'type:check']],
183
- ];
184
-
185
- // Customized type:check remains untouched; ensure full lint still checks UI.
186
- taskSpecs.push(...uiTasks({ types: packageScripts()['type:check'] !== UI_TYPECHECK }));
98
+ const commands = ['npm run eslint', 'npm run type:check'];
187
99
 
188
100
  if (canRunStylelint()) {
189
- taskSpecs.push([getBinName('npm'), ['run', 'stylelint']]);
101
+ commands.push('npm run stylelint');
190
102
  } else {
191
103
  console.warn('[lint] Skip stylelint: missing scripts.stylelint or stylelint config');
192
104
  }
193
105
 
194
- process.exit(await runTasksSerially(taskSpecs));
106
+ const code = await runCommand(getBinName('npx'), ['concurrently', ...commands]);
107
+ process.exit(code);
195
108
  }
196
109
 
197
110
  async function runSelectiveLint(inputFiles) {
@@ -204,20 +117,15 @@ async function runSelectiveLint(inputFiles) {
204
117
  process.exit(0);
205
118
  }
206
119
 
207
- const eslintFiles = normalizedFiles.filter(file => fs.existsSync(path.join(cwd, file)) && isEslintTarget(file) && !isUiFile(file) && !file.startsWith('eslint.mcp-ui.config.'));
208
- const stylelintFiles = normalizedFiles.filter(file => fs.existsSync(path.join(cwd, file)) && isStylelintTarget(file));
120
+ const eslintFiles = normalizedFiles.filter(isEslintTarget);
121
+ const stylelintFiles = normalizedFiles.filter(isStylelintTarget);
209
122
  const typeCheckFiles = normalizedFiles.filter(isTypeCheckTarget);
210
123
 
211
124
  const clientTypeFiles = [];
212
125
  const serverTypeFiles = [];
213
- const rootConfigChanged = normalizedFiles.some(file => /^tsconfig(?:\.[^/]*)?\.json$/.test(file));
214
- if (rootConfigChanged) { clientTypeFiles.push('config'); serverTypeFiles.push('config'); }
215
- let checkUi = rootConfigChanged || normalizedFiles.some(file => isUiFile(file) || file.startsWith('shared/') || file.startsWith('eslint.mcp-ui.config.'));
216
126
 
217
127
  for (const filePath of typeCheckFiles) {
218
- if (isUiFile(filePath)) {
219
- checkUi = true;
220
- } else if (filePath.startsWith('client/')) {
128
+ if (filePath.startsWith('client/')) {
221
129
  clientTypeFiles.push(filePath);
222
130
  } else if (filePath.startsWith('server/')) {
223
131
  serverTypeFiles.push(filePath);
@@ -227,46 +135,36 @@ async function runSelectiveLint(inputFiles) {
227
135
  }
228
136
  }
229
137
 
230
- const taskSpecs = [];
138
+ const tasks = [];
231
139
 
232
140
  if (eslintFiles.length > 0) {
233
- taskSpecs.push([getBinName('npx'), ['eslint', '--quiet', ...eslintFiles]]);
141
+ tasks.push(runCommand(getBinName('npx'), ['eslint', '--quiet', ...eslintFiles]));
234
142
  }
235
143
 
236
144
  if (stylelintFiles.length > 0 && !canRunStylelint()) {
237
145
  console.warn('[lint] Skip stylelint: missing scripts.stylelint or stylelint config');
238
146
  } else if (stylelintFiles.length > 0) {
239
- taskSpecs.push([getBinName('npx'), ['stylelint', '--quiet', ...stylelintFiles]]);
147
+ tasks.push(runCommand(getBinName('npx'), ['stylelint', '--quiet', ...stylelintFiles]));
240
148
  }
241
149
 
242
150
  if (clientTypeFiles.length > 0) {
243
- taskSpecs.push([getBinName('npm'), ['run', 'type:check:client']]);
151
+ tasks.push(runCommand(getBinName('npm'), ['run', 'type:check:client']));
244
152
  }
245
153
 
246
154
  if (serverTypeFiles.length > 0) {
247
- taskSpecs.push([getBinName('npm'), ['run', 'type:check:server']]);
155
+ tasks.push(runCommand(getBinName('npm'), ['run', 'type:check:server']));
248
156
  }
249
157
 
250
- if (checkUi) taskSpecs.push(...uiTasks());
251
-
252
- if (taskSpecs.length === 0) {
158
+ if (tasks.length === 0) {
253
159
  console.log('[lint] No supported files matched for lint');
254
160
  process.exit(0);
255
161
  }
256
162
 
257
- process.exit(await runTasksSerially(taskSpecs));
163
+ const results = await Promise.all(tasks);
164
+ process.exit(results.some(code => code !== 0) ? 1 : 0);
258
165
  }
259
166
 
260
167
  async function main() {
261
- const args = process.argv.slice(2);
262
- const modes = ['--typecheck', '--typecheck-mcp-ui', '--eslint-mcp-ui'];
263
- if (args.length && !(args[0] === '--files' && args.length > 1) && !(args.length === 1 && modes.includes(args[0]))) {
264
- throw new Error('用法: lint.js [--files <paths...> | --typecheck | --typecheck-mcp-ui | --eslint-mcp-ui]');
265
- }
266
- if (args[0] === '--files' && args.slice(1).some(arg => arg.startsWith('--'))) throw new Error('--files 不可与其他检查模式混用');
267
- if (args.includes('--typecheck')) return runTypeCheck();
268
- if (args.includes('--typecheck-mcp-ui')) return process.exit(await runTasksSerially(uiTasks({ eslint: false })));
269
- if (args.includes('--eslint-mcp-ui')) return process.exit(await runTasksSerially(uiTasks({ types: false })));
270
168
  const files = parseFilesArg(process.argv.slice(2));
271
169
  if (files === null) {
272
170
  await runDefaultLint();
@@ -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 解析
@@ -1,169 +0,0 @@
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.addEslintGlobalIgnore = addEslintGlobalIgnore;
7
- const node_fs_1 = __importDefault(require("node:fs"));
8
- const typescript_1 = __importDefault(require("typescript"));
9
- const error_1 = require("./error");
10
- /** 只解析源码,不加载用户配置;用局部插入保留自定义规则和注释。 */
11
- function addEslintGlobalIgnore(filename, pattern) {
12
- if (!node_fs_1.default.existsSync(filename))
13
- return { changed: false };
14
- const source = node_fs_1.default.readFileSync(filename, 'utf8');
15
- const file = typescript_1.default.createSourceFile(filename, source, typescript_1.default.ScriptTarget.Latest, true, typescript_1.default.ScriptKind.JS);
16
- const diagnostics = file
17
- .parseDiagnostics;
18
- if (diagnostics.length)
19
- throw new error_1.AppError('INVALID_CONFIG', `Invalid ESLint configuration: ${filename}`);
20
- const variables = new Map();
21
- const declarations = new Map();
22
- const aliasReferences = new Set();
23
- const resolvedAliases = new Set();
24
- const exportTargets = new Set();
25
- const helpers = new Set();
26
- const namespaces = new Set();
27
- const exports = [];
28
- const moduleName = (node) => {
29
- if (!typescript_1.default.isCallExpression(node) ||
30
- !typescript_1.default.isIdentifier(node.expression) ||
31
- node.expression.text !== 'require')
32
- return;
33
- const arg = node.arguments.at(0);
34
- return arg && typescript_1.default.isStringLiteral(arg) ? arg.text : undefined;
35
- };
36
- for (const statement of file.statements) {
37
- if (typescript_1.default.isImportDeclaration(statement) && typescript_1.default.isStringLiteral(statement.moduleSpecifier)) {
38
- const module = statement.moduleSpecifier.text;
39
- const clause = statement.importClause;
40
- if (module === 'typescript-eslint' && clause?.name)
41
- namespaces.add(clause.name.text);
42
- const bindings = clause?.namedBindings;
43
- if (bindings && typescript_1.default.isNamespaceImport(bindings) && module === 'typescript-eslint')
44
- namespaces.add(bindings.name.text);
45
- if (bindings && typescript_1.default.isNamedImports(bindings)) {
46
- for (const element of bindings.elements) {
47
- const name = element.propertyName?.text ?? element.name.text;
48
- if ((module === 'eslint/config' && name === 'defineConfig') ||
49
- (module === 'typescript-eslint' && name === 'config'))
50
- helpers.add(element.name.text);
51
- }
52
- }
53
- }
54
- if (typescript_1.default.isVariableStatement(statement) && statement.declarationList.flags & typescript_1.default.NodeFlags.Const) {
55
- for (const declaration of statement.declarationList.declarations) {
56
- if (!declaration.initializer)
57
- continue;
58
- const module = moduleName(declaration.initializer);
59
- if (typescript_1.default.isIdentifier(declaration.name)) {
60
- variables.set(declaration.name.text, declaration.initializer);
61
- declarations.set(declaration.name.text, declaration.name);
62
- if (module === 'typescript-eslint')
63
- namespaces.add(declaration.name.text);
64
- }
65
- else if (typescript_1.default.isObjectBindingPattern(declaration.name)) {
66
- for (const element of declaration.name.elements) {
67
- const name = element.propertyName ?? element.name;
68
- if (!typescript_1.default.isIdentifier(name) || !typescript_1.default.isIdentifier(element.name))
69
- continue;
70
- if ((module === 'eslint/config' && name.text === 'defineConfig') ||
71
- (module === 'typescript-eslint' && name.text === 'config'))
72
- helpers.add(element.name.text);
73
- }
74
- }
75
- }
76
- }
77
- if (typescript_1.default.isExportAssignment(statement) && !statement.isExportEquals)
78
- exports.push(statement.expression);
79
- if (typescript_1.default.isExpressionStatement(statement) && typescript_1.default.isBinaryExpression(statement.expression)) {
80
- const { left, right, operatorToken } = statement.expression;
81
- if (operatorToken.kind === typescript_1.default.SyntaxKind.EqualsToken &&
82
- typescript_1.default.isPropertyAccessExpression(left) &&
83
- typescript_1.default.isIdentifier(left.expression) &&
84
- left.expression.text === 'module' &&
85
- left.name.text === 'exports') {
86
- exports.push(right);
87
- exportTargets.add(left);
88
- }
89
- }
90
- }
91
- function unwrap(node, seen = new Set()) {
92
- if (typescript_1.default.isParenthesizedExpression(node))
93
- return unwrap(node.expression, seen);
94
- if (typescript_1.default.isIdentifier(node) && !seen.has(node.text)) {
95
- const value = variables.get(node.text);
96
- if (value) {
97
- resolvedAliases.add(node.text);
98
- aliasReferences.add(node);
99
- seen.add(node.text);
100
- return unwrap(value, seen);
101
- }
102
- }
103
- return node;
104
- }
105
- const root = exports.length === 1 ? unwrap(exports[0]) : undefined;
106
- // const 只固定变量绑定,不能保证数组不被 pop/push 或传给其他函数修改。
107
- // 有额外引用时保留原配置,避免改变初始化数组影响用户后续操作。
108
- function checkReferences(node) {
109
- if (typescript_1.default.isIdentifier(node) &&
110
- resolvedAliases.has(node.text) &&
111
- declarations.get(node.text) !== node &&
112
- !aliasReferences.has(node))
113
- return true;
114
- // module["exports"]、(module).exports、module 别名也可能修改导出数组。
115
- if (typescript_1.default.isIdentifier(node) &&
116
- node.text === 'module' &&
117
- !(typescript_1.default.isPropertyAccessExpression(node.parent) &&
118
- node.parent.expression === node &&
119
- exportTargets.has(node.parent)))
120
- return true;
121
- return typescript_1.default.forEachChild(node, (child) => checkReferences(child) || undefined) ?? false;
122
- }
123
- const unsafeReference = checkReferences(file);
124
- const isHelper = (node) => {
125
- const callee = node.expression;
126
- return typescript_1.default.isIdentifier(callee)
127
- ? helpers.has(callee.text)
128
- : typescript_1.default.isPropertyAccessExpression(callee) &&
129
- typescript_1.default.isIdentifier(callee.expression) &&
130
- namespaces.has(callee.expression.text) &&
131
- callee.name.text === 'config';
132
- };
133
- const entries = root && typescript_1.default.isArrayLiteralExpression(root)
134
- ? root.elements
135
- : root && typescript_1.default.isCallExpression(root) && isHelper(root)
136
- ? root.arguments
137
- : undefined;
138
- if (!entries || unsafeReference) {
139
- return {
140
- changed: false,
141
- warning: `Cannot safely migrate ${filename}; add ${JSON.stringify(pattern)} to its global ignores manually.`,
142
- };
143
- }
144
- // 只认真正的全局 ignores;带 files/rules 的局部忽略不能阻止服务端配置匹配 UI。
145
- // 仅检查最后一个配置,确保此前的否定规则或动态配置无法撤销新增忽略。
146
- const last = entries.at(-1);
147
- if (last && typescript_1.default.isObjectLiteralExpression(last)) {
148
- const props = last.properties;
149
- const nameOf = (p) => p.name && (typescript_1.default.isIdentifier(p.name) || typescript_1.default.isStringLiteral(p.name)) ? p.name.text : undefined;
150
- if (new Set(props.map(nameOf)).size === props.length &&
151
- props.every((p) => typescript_1.default.isPropertyAssignment(p) && ['ignores', 'name'].includes(nameOf(p) ?? ''))) {
152
- const ignores = props.find((p) => nameOf(p) === 'ignores');
153
- if (ignores &&
154
- typescript_1.default.isPropertyAssignment(ignores) &&
155
- typescript_1.default.isArrayLiteralExpression(ignores.initializer)) {
156
- const patterns = ignores.initializer.elements;
157
- if (patterns.length &&
158
- patterns.every(typescript_1.default.isStringLiteral) &&
159
- patterns[patterns.length - 1].text === pattern)
160
- return { changed: false };
161
- }
162
- }
163
- }
164
- const eol = source.includes('\r\n') ? '\r\n' : '\n';
165
- const position = last ? last.end : entries.pos;
166
- const addition = `${last ? ',' : ''}${eol} { ignores: [${JSON.stringify(pattern)}] }`;
167
- node_fs_1.default.writeFileSync(filename, source.slice(0, position) + addition + source.slice(position));
168
- return { changed: true };
169
- }