diff --git a/docs/reference/cli.md b/docs/reference/cli.md index aa8e9dfc..a0653774 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -34,7 +34,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary`、`missing-token`、`auth-failed`、`github-transient`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response` 或 `unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应优先用该字段分流。`github-transient` 表示 GitHub DNS/API 连接在收到 HTTP 状态前失败,输出应带 `retryable=true` 或等价 commander action;这不是缺 token、认证失败、权限不足或 PR 语义失败。 - `codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` 是派发/steer 前的本地 dry-run prompt lint。它只读取 prompt 文本,返回 `dryRun=true`、`mutation=false`、`declaredClass`、`effectiveClass`、`requiredClass`、`dispatchDisposition`、缺失或矛盾项和有界 evidence,不访问 live service、不提交任务、不打印完整 prompt。分级固定为 `read-only`、`live-read`、`live-mutating`;未声明时按 `read-only` 处理。`codex submit --dry-run` 与 `codex steer --dry-run` 会嵌入同一 `promptLint` 结果,帮助指挥官在 dispatch/steer 前发现缺失或矛盾的 live mutation 授权。长期规则见 `docs/reference/code-queue-supervision.md` 的 DEV 测试授权分级。 - `gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]` 通过 GitHub REST 列出 issue,默认 `state=open`、`limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。GitHub issues API 可能混入 PR,CLI 会从 `.data.issues` 中过滤 pull request。 -- `gh issue read [--repo owner/name] [--json body,title,state,comments] [--raw|--full]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;`view` 只保留为兼容别名。`owner/repo#number` shorthand 会自动派生 `--repo owner/repo` 和 issue number;若同时提供冲突的显式 `--repo`,CLI 必须结构化失败并给出 `gh issue read --repo owner/repo --json body,title,state,comments` 与 shorthand raw 的可执行命令。兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`--raw` 与 `--full` 只在 read/view 上可用,是显式完整披露别名,会选择完整支持字段集并保持结构化 JSON 输出;默认 list/read 输出仍不得扩散到无界非 JSON 文本。`gh issue create --title --body-file <file> [--label label[,label...]]... [--dry-run]`、`gh issue update <number> --mode replace|append --body-file <file> [--title ...] [--dry-run]`、`gh issue comment create <number> --body-file <file> [--dry-run]`、`gh issue comment delete <commentId> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`--label` 仅用于 `issue create`,支持重复传入和逗号分隔;`--dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payload,GitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`。 +- `gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON;`view` 只保留为兼容别名。`owner/repo#number` shorthand 会自动派生 `--repo owner/repo` 和 issue number;若同时提供冲突的显式 `--repo`,CLI 必须结构化失败并给出 `gh issue read <number> --repo owner/repo --json body,title,state,comments` 与 shorthand raw 的可执行命令。兼容旧脚本的 `--json body` 和 `--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed`。`--raw` 与 `--full` 只在 read/view 上可用,是显式完整披露别名,会选择完整支持字段集并保持结构化 JSON 输出;当最终 `gh` JSON 超过 20 KiB 时,CLI 必须把完整 JSON 写入 `/tmp/unidesk-cli-output/*.json`,stdout 只返回 `outputTruncated=true`、dump path、总 bytes/lines 和 head/tail 预览。默认 list/read 输出仍不得扩散到无界非 JSON 文本。`gh issue create --title <title> --body-file <file> [--label label[,label...]]... [--dry-run]`、`gh issue update <number> --mode replace|append --body-file <file> [--title ...] [--dry-run]`、`gh issue comment create <number> --body-file <file> [--dry-run]`、`gh issue comment delete <commentId> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`--label` 仅用于 `issue create`,支持重复传入和逗号分隔;`--dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payload,GitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`。 - `gh issue update <number> --mode replace|append --body-file <file>` 是正文更新主入口,`edit` 保留为兼容别名。`replace` 用文件正文替换现有 body;`append` 先读取当前 body,再按 UTF-8 文件字节追加,保留真实换行、反引号和 Markdown 表格。更新默认拒绝字面量 `null`、空白正文和过短正文;只有真实需要写短正文时才允许显式加 `--allow-short-body`,返回 JSON 会报告该风险。#20 总看板和指挥简报类 issue 是长期 body-only issue,`--body-profile auto` 会按 issue number 自动启用 #20/#24 legacy guard:#20 必须包含 `## 看板(OPEN)`,#24 legacy 指挥简报必须包含 `## 常驻观察与长期建议`。显式 `--body-profile commander-brief` 不再固定 #24;#24 仍兼容,标题为 `YYYY-MM-DD 指挥简报(北京时间)` 或既有正文首行/关键 heading 表明为每日滚动指挥简报的 issue 也合法,并仍必须包含 `## 常驻观察与长期建议`。对非简报 issue 显式使用 `commander-brief` 会结构化失败为 `profile-issue-mismatch`。`--dry-run` 不 PATCH GitHub,输出新正文长度、SHA、关键标题检查结果、字面量 `\n`、反引号、Markdown 表格和 shell 污染信号;若环境里有 `GH_TOKEN` 或 `GITHUB_TOKEN`,dry-run 还会只读抓取旧正文长度、SHA 和 `updatedAt` 作为更新前对照。正式写入可带 `--expect-updated-at <updated_at>` 或 `--expect-body-sha <sha256>`,CLI 会先读当前 issue,匹配后才 PATCH,防止旧缓存覆盖新正文。 - #20 只允许承担长期 UniDesk 指挥官 / Code Queue / CLI / infra 治理总看板职责;每日进展必须写入当天滚动指挥简报 issue,并由 #20 顶部“指挥简报索引”引用。HWLAB 用户反馈、Cloud Workbench、DEV-LIVE、M3 虚拟硬件可信闭环等产品 issue 必须写到 `pikasTech/HWLAB`;#20 只可记录 UniDesk 侧 commander/Code Queue/CLI/infra 支撑工作。`gh issue read/view 20` 会返回 `codeQueueBoardHint`;`gh issue update/edit 20` 的 body guard 会拒绝 `## 更新 YYYY-MM-DD HH:mm 北京时间`、`## YYYY-MM-DD HH:mm 北京时间指挥更新` 和 `### YYYY-MM-DD HH:mm CST:...` 这类简报段落,也会拒绝把 `pikasTech/HWLAB#N`、`HWLAB#N` 或 HWLAB 产品/live 验证行写入 #20,并在 `codeQueueBoardHint` 中提示改写到每日简报 issue 或 `pikasTech/HWLAB`;`gh issue board-row list|get|update|add|move|delete|upsert --board-issue 20` 也会返回同一 hint,提醒不要把每日简报或 HWLAB 产品看板混入 #20。 - `gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是 legacy #24 指挥简报的通知入口。正式执行会先读取 GitHub 上 #24 旧正文并通过 #24 body profile guard,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文,失败只体现在返回 JSON 的 `claudeqq.ok=false` 和结构化 `degradedReason`。每日滚动简报 issue 可用普通 `gh issue update <number> --body-profile commander-brief --dry-run` 和并发 guard 更新,但此通知 helper 仍只支持 #24。带通知 flag 的 `--dry-run` 不 PATCH、不发送;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`,可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID` 和 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 覆盖。 diff --git a/scripts/gh-cli-issue-guard-contract-test.ts b/scripts/gh-cli-issue-guard-contract-test.ts index 31925337..14915183 100644 --- a/scripts/gh-cli-issue-guard-contract-test.ts +++ b/scripts/gh-cli-issue-guard-contract-test.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; @@ -218,6 +218,16 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque body: "# 普通任务\n\n## 背景\n\n- 不是指挥简报。\n", html_url: "https://github.com/pikasTech/unidesk/issues/47", }; + const largeIssueBody = Array.from({ length: 900 }, (_, index) => `large-output-line-${String(index + 1).padStart(4, "0")} ${"x".repeat(60)}`).join("\n"); + const largeIssue = { + ...issue, + id: 2090, + number: 90, + title: "large issue output fixture", + body: largeIssueBody, + html_url: "https://github.com/pikasTech/unidesk/issues/90", + updated_at: "2026-05-20T09:00:00Z", + }; const issueList = [ { id: 2001, @@ -459,6 +469,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque sendJson(res, 200, nonBriefIssue); return; } + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/90") { + sendJson(res, 200, largeIssue); + return; + } if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/60") { sendJson(res, 200, legacyBoardIssue); return; @@ -471,6 +485,10 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque sendJson(res, 200, comments); return; } + if (req.method === "GET" && req.url === "/repos/pikasTech/unidesk/issues/90/comments?per_page=100") { + sendJson(res, 200, []); + return; + } if (req.method === "GET" && req.url === "/repos/pikasTech/HWLAB/issues/7/comments?per_page=100") { sendJson(res, 200, [{ id: 7001, body: "shorthand comment", html_url: "https://github.com/pikasTech/HWLAB/issues/7#issuecomment-7001", user: { login: "tester" }, created_at: "2026-05-20T03:10:00Z", updated_at: "2026-05-20T03:10:00Z" }]); return; @@ -645,6 +663,19 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { assertCondition(Array.isArray(firstLabels) && firstLabels[0]?.name === "cli", "issue list default fields should include labels", listDefaultData); assertCondition(defaultIssues.every((item) => typeof item.number === "number" && typeof item.url === "string"), "issue list default fields should expose stable JSON", listDefaultData); + const largeRead = await runCli(["gh", "issue", "read", "90", "--repo", "pikasTech/unidesk", "--full"], env); + assertCondition(largeRead.status === 0, "large issue read should succeed", largeRead.json ?? { stdout: largeRead.stdout }); + assertCondition(largeRead.stdout.length < 20_000, "large issue read stdout should stay bounded", { bytes: largeRead.stdout.length }); + const largeReadData = dataOf(largeRead.json ?? {}); + assertCondition(largeReadData.outputTruncated === true, "large issue read should be dumped instead of printed fully", largeReadData); + const dump = largeReadData.dump as JsonRecord; + assertCondition(typeof dump.path === "string" && existsSync(String(dump.path)), "large issue dump file should exist", dump); + assertCondition(Number(dump.bytes ?? 0) > 20_000, "dump should record full output size", dump); + assertCondition(Number(dump.lines ?? 0) > 20, "dump should record total line count", dump); + assertCondition(String(dump.head ?? "").length > 0 && String(dump.tail ?? "").length > 0, "dump should include head and tail previews", dump); + const dumpText = readFileSync(String(dump.path), "utf8"); + assertCondition(dumpText.includes("large-output-line-0900"), "dump file should contain full original JSON", { path: dump.path, tail: dumpText.slice(-500) }); + const scanEscape = await runCli(["gh", "issue", "scan-escape", "--repo", "pikasTech/unidesk", "--limit", "4", "--dry-run"], env); assertCondition(scanEscape.status === 0, "issue scan-escape dry-run should succeed", scanEscape.json ?? { stdout: scanEscape.stdout }); const scanData = dataOf(scanEscape.json ?? {}); @@ -1465,6 +1496,7 @@ export async function runGhCliIssueGuardContract(): Promise<JsonRecord> { "issue list supports state/limit/json with stable selected fields", "acceptance issue list command succeeds under mock GitHub", "issue list default fields include labels and filter pull requests", + "large gh issue read output is dumped to a temp file with bounded stdout and head/tail metadata", "issue scan-escape classifies pollution, explanatory mentions, and body risks", "issue cleanup-plan remains dry-run with body/comment cleanup suggestions", "issue board-audit returns read-only board structure, disables OPEN/CLOSED coverage validation, and keeps compatibility fields empty without writes", diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index d93c24d9..363f42cd 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -5575,6 +5575,7 @@ export function ghHelp(): unknown { "PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.", "issue read is the canonical read path; view remains a compatibility alias. Read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. Read supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.", "--raw and --full are explicit full-disclosure aliases for gh issue read/view and gh pr read/view. They request the full supported read/view JSON field set while keeping default command output structured JSON.", + "GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.", "issue create accepts repeatable --label values and comma-separated labels; dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.", "--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.", "update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.", diff --git a/scripts/src/output.ts b/scripts/src/output.ts index f3427535..e8bae54b 100644 --- a/scripts/src/output.ts +++ b/scripts/src/output.ts @@ -1,3 +1,8 @@ +import { randomBytes } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + export interface JsonEnvelope<T> { ok: boolean; command: string; @@ -5,6 +10,10 @@ export interface JsonEnvelope<T> { error?: unknown; } +const GH_OUTPUT_DUMP_THRESHOLD_BYTES = 20 * 1024; +const OUTPUT_DUMP_PREVIEW_CHARS = 2000; +const OUTPUT_DUMP_DIR = join(tmpdir(), "unidesk-cli-output"); + function isEpipe(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EPIPE"; } @@ -21,7 +30,7 @@ process.stderr.on("error", (error) => { export function emitJson<T>(command: string, data: T, ok = true): void { const envelope: JsonEnvelope<T> = { ok, command, data }; - safeStdoutWrite(`${JSON.stringify(envelope, null, 2)}\n`); + safeStdoutWrite(renderEnvelope(command, envelope)); } export function emitError(command: string, error: unknown): void { @@ -29,7 +38,119 @@ export function emitError(command: string, error: unknown): void { ? { name: error.name, message: error.message, stack: error.stack ?? null } : { message: String(error) }; const envelope: JsonEnvelope<never> = { ok: false, command, error: payload }; - safeStdoutWrite(`${JSON.stringify(envelope, null, 2)}\n`); + safeStdoutWrite(renderEnvelope(command, envelope)); +} + +function renderEnvelope<T>(command: string, envelope: JsonEnvelope<T>): string { + const fullText = `${JSON.stringify(envelope, null, 2)}\n`; + if (!shouldDumpLargeOutput(command, fullText)) return fullText; + + const dump = dumpLargeOutput(command, fullText); + const compactPayload = { + outputTruncated: true, + reason: "stdout-json-exceeded-threshold", + message: "Full JSON output was written to a temporary file; stdout contains only bounded head/tail previews.", + dump, + summary: summarizeEnvelope(envelope), + }; + const compactEnvelope: JsonEnvelope<Record<string, unknown>> = envelope.ok + ? { ok: envelope.ok, command: envelope.command, data: compactPayload } + : { ok: envelope.ok, command: envelope.command, error: compactPayload }; + return `${JSON.stringify(compactEnvelope, null, 2)}\n`; +} + +function shouldDumpLargeOutput(command: string, text: string): boolean { + if (!(command === "gh" || command.startsWith("gh "))) return false; + if (process.env.UNIDESK_CLI_GH_OUTPUT_DUMP_DISABLED === "1") return false; + const threshold = configuredDumpThresholdBytes(); + return Buffer.byteLength(text, "utf8") > threshold; +} + +function configuredDumpThresholdBytes(): number { + const raw = process.env.UNIDESK_CLI_GH_OUTPUT_DUMP_THRESHOLD_BYTES; + if (raw === undefined || raw.trim().length === 0) return GH_OUTPUT_DUMP_THRESHOLD_BYTES; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) return GH_OUTPUT_DUMP_THRESHOLD_BYTES; + return value; +} + +function dumpLargeOutput(command: string, text: string): Record<string, unknown> { + mkdirSync(OUTPUT_DUMP_DIR, { recursive: true, mode: 0o700 }); + const timestamp = new Date().toISOString().replace(/[:.]/gu, "-"); + const suffix = randomBytes(4).toString("hex"); + const slug = command.replace(/[^A-Za-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 80) || "command"; + const path = join(OUTPUT_DUMP_DIR, `${timestamp}-${process.pid}-${suffix}-${slug}.json`); + writeFileSync(path, text, { encoding: "utf8", mode: 0o600 }); + return { + path, + thresholdBytes: configuredDumpThresholdBytes(), + bytes: Buffer.byteLength(text, "utf8"), + chars: text.length, + lines: countLines(text), + headChars: OUTPUT_DUMP_PREVIEW_CHARS, + tailChars: OUTPUT_DUMP_PREVIEW_CHARS, + head: text.slice(0, OUTPUT_DUMP_PREVIEW_CHARS), + tail: text.slice(Math.max(0, text.length - OUTPUT_DUMP_PREVIEW_CHARS)), + readCommands: { + full: `cat ${JSON.stringify(path)}`, + head: `sed -n '1,80p' ${JSON.stringify(path)}`, + tail: `tail -80 ${JSON.stringify(path)}`, + }, + }; +} + +function countLines(text: string): number { + if (text.length === 0) return 0; + const lineBreaks = text.match(/\r\n|\r|\n/gu)?.length ?? 0; + return lineBreaks + (/(\r\n|\r|\n)$/u.test(text) ? 0 : 1); +} + +function summarizeEnvelope(envelope: JsonEnvelope<unknown>): Record<string, unknown> { + const source = typeof envelope.data === "object" && envelope.data !== null + ? envelope.data as Record<string, unknown> + : typeof envelope.error === "object" && envelope.error !== null + ? envelope.error as Record<string, unknown> + : {}; + const summaryKeys = [ + "ok", + "command", + "repo", + "number", + "state", + "stateDetail", + "title", + "url", + "count", + "limit", + "degradedReason", + "runnerDisposition", + ]; + const summary: Record<string, unknown> = { + ok: envelope.ok, + command: envelope.command, + }; + for (const key of summaryKeys) { + if (key in source) summary[key] = source[key]; + } + const issue = source.issue; + if (typeof issue === "object" && issue !== null) { + const issueRecord = issue as Record<string, unknown>; + summary.issue = pickSummary(issueRecord, ["number", "title", "state", "url", "bodyChars", "commentCount"]); + } + const pullRequest = source.pullRequest; + if (typeof pullRequest === "object" && pullRequest !== null) { + const prRecord = pullRequest as Record<string, unknown>; + summary.pullRequest = pickSummary(prRecord, ["number", "title", "state", "stateDetail", "url", "draft", "merged", "mergedAt"]); + } + return summary; +} + +function pickSummary(source: Record<string, unknown>, keys: string[]): Record<string, unknown> { + const result: Record<string, unknown> = {}; + for (const key of keys) { + if (key in source) result[key] = source[key]; + } + return result; } function safeStdoutWrite(text: string): void {