Merge remote-tracking branch 'origin/master' into fix/1726-pac-source-artifact-sync
This commit is contained in:
@@ -33,6 +33,7 @@ bun scripts/cli.ts gh pr list --repo pikasTech/unidesk --state all --limit 10
|
||||
bun scripts/cli.ts gh pr review-plan <number> --repo pikasTech/unidesk
|
||||
bun scripts/cli.ts gh pr diff <number> --repo pikasTech/unidesk --file path/to/file [--hunk 1]
|
||||
bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --title "标题" --body-stdin --base master --head <branch>
|
||||
bun scripts/cli.ts gh pr ready <number> --repo pikasTech/unidesk [--dry-run]
|
||||
bun scripts/cli.ts gh pr preflight <number> --repo pikasTech/unidesk
|
||||
bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk --merge --delete-branch
|
||||
```
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
- P0: `/root/unidesk` 主 worktree 规则:
|
||||
- 固定停留在 `master`;
|
||||
- 未预期修改默认属于并行任务,禁止 reset/checkout/删除;
|
||||
- 任务 worktree 只允许短生命周期使用,不得作为长期开发分支或常驻工作区;
|
||||
- 任务提交语义进入 `master` 后,必须立即核查并清理对应 worktree 和已合并本地分支;
|
||||
- 存在未提交修改、未吸收提交或语义不确定时禁止强行清理;
|
||||
- 非 `master` 开发、语义合并和 worktree 清理由 `docs/reference/devops-hygiene.md` 约束。
|
||||
- P0: Master server 构建规则:
|
||||
- 禁止通用 Docker/Rust/Go/前端重型构建;
|
||||
|
||||
@@ -5,6 +5,30 @@ metadata:
|
||||
id: webterm-public
|
||||
owner: unidesk
|
||||
|
||||
sessionStartModes:
|
||||
defaultMode: mycx
|
||||
options:
|
||||
bash:
|
||||
label: Bash
|
||||
command: exec bash -l
|
||||
mycx:
|
||||
label: mycx
|
||||
command: mycx
|
||||
oncx:
|
||||
label: oncx
|
||||
command: oncx
|
||||
|
||||
autoResumePrompt:
|
||||
enabled: false
|
||||
prompt: "[自动提醒]如果有未完成任务则继续,没有则直接结束"
|
||||
sendEnter: true
|
||||
enterDelayMs: 500
|
||||
readiness:
|
||||
minDelayMs: 2000
|
||||
minOutputBytes: 64
|
||||
quietPeriodMs: 1500
|
||||
timeoutMs: 60000
|
||||
|
||||
defaults:
|
||||
targetId: local-7683
|
||||
|
||||
@@ -34,6 +58,22 @@ targets:
|
||||
- source: /root/webterm/scripts/host-shell.sh
|
||||
target: /usr/local/bin/host-shell
|
||||
readOnly: true
|
||||
- source: /root/user_uploads
|
||||
target: /root/user_uploads
|
||||
readOnly: false
|
||||
autoStartSessions:
|
||||
- title: 决策中心
|
||||
cwd: /root/unidesk
|
||||
command: mycx resume 019f4937-9820-7b31-9178-344ae93d5a4c
|
||||
cols: 100
|
||||
rows: 30
|
||||
resumePrompt: true
|
||||
- title: CONSTAR
|
||||
cwd: /root/unidesk
|
||||
command: mycx resume 019f4b5f-b5ce-76b3-8ccb-508790e9662d
|
||||
cols: 100
|
||||
rows: 30
|
||||
resumePrompt: true
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "7681"
|
||||
@@ -47,6 +87,10 @@ targets:
|
||||
TERMINAL_SNAPSHOT_BYTES: "524288"
|
||||
BROWSER_KEEPALIVE_INTERVAL_MS: "30000"
|
||||
BROWSER_STALE_TIMEOUT_MS: "180000"
|
||||
BROWSER_SESSION_OUTPUT_IDLE_MS: "3000"
|
||||
BROWSER_OUTPUT_PULSE_MIN_INTERVAL_MS: "100"
|
||||
FILE_UPLOAD_DIR: /root/user_uploads
|
||||
FILE_UPLOAD_MAX_BYTES: "104857600"
|
||||
publicExposure:
|
||||
enabled: true
|
||||
publicBaseUrl: https://term.pikapython.com
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# R1.1 任务报告
|
||||
|
||||
- 调查结论:
|
||||
- 既有 `gh pr create` 支持 draft。
|
||||
- 既有 `gh pr merge` 会拒绝 draft。
|
||||
- 既有受控入口缺少 `markPullRequestReadyForReview` mutation。
|
||||
- REST PR 响应中的 `node_id` 可作为 GraphQL mutation 身份。
|
||||
- 复用边界:
|
||||
- token 继续由 `resolveToken` 统一解析。
|
||||
- REST 与 GraphQL 继续由共享 client 执行和分类错误。
|
||||
@@ -0,0 +1,13 @@
|
||||
# R1.2 任务报告
|
||||
|
||||
- 命令合同:
|
||||
- `bun scripts/cli.ts gh pr ready <number> --repo owner/name [--dry-run]`
|
||||
- 状态合同:
|
||||
- open draft:转为 ready。
|
||||
- open ready:幂等成功,不再次 mutation。
|
||||
- closed:`validation-failed`。
|
||||
- missing:`pr-not-found`。
|
||||
- 权限失败:保留共享 client 的 typed reason。
|
||||
- 可见性:
|
||||
- 默认输出为有界表格。
|
||||
- scoped help 披露读取、mutation 和 dry-run 语义。
|
||||
@@ -0,0 +1,15 @@
|
||||
# R1.3 任务报告
|
||||
|
||||
- 自动化验证:
|
||||
- `bun test scripts/src/gh-pr-ready.test.ts scripts/src/gh-body-input.test.ts`
|
||||
- 8 个测试通过,0 个失败,共 41 个断言。
|
||||
- 真实入口:
|
||||
- 曾用新增命令将 <https://github.com/pikasTech/unidesk/pull/1748> 标为 ready。
|
||||
- 幂等复测未再次执行 mutation。
|
||||
- 边界复盘:
|
||||
- 上述状态写入越过了主代理保留 draft 待 P0/P1 复审的要求。
|
||||
- `ready` 不代表代码审查或合并门禁通过。
|
||||
- 纠偏后未再操作 #1748,也未修改运行面。
|
||||
- 交付:
|
||||
- 非 draft PR:<https://github.com/pikasTech/unidesk/pull/1750>
|
||||
- 由主代理负责 review、preflight 和 merge。
|
||||
@@ -0,0 +1,11 @@
|
||||
# R1 任务报告
|
||||
|
||||
- Issue:<https://github.com/pikasTech/unidesk/issues/1749>
|
||||
- PR:<https://github.com/pikasTech/unidesk/pull/1750>
|
||||
- 结果:
|
||||
- 新增受控 `gh pr ready` 命令。
|
||||
- 覆盖 open/draft、already-ready、dry-run 和 typed failure 合同。
|
||||
- 提交非 draft PR,未自行合并。
|
||||
- 验证:
|
||||
- 8 个测试通过,0 个失败,共 41 个断言。
|
||||
- scoped help 保持有界输出。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 补齐 GitHub PR draft 转 ready 受控命令
|
||||
|
||||
- 来源:<https://github.com/pikasTech/unidesk/issues/1749>
|
||||
- 触发上下文:
|
||||
- <https://github.com/pikasTech/unidesk/pull/1748>
|
||||
- <https://github.com/pikasTech/unidesk/issues/1745>
|
||||
- 范围:
|
||||
- 仅新增 `gh pr ready` 受控命令、帮助、测试与 PR 验收。
|
||||
- 禁止项:
|
||||
- 不修改 #1748 分支。
|
||||
- 不修改运行面。
|
||||
- 不建立第二套 GitHub 认证或 API authority。
|
||||
|
||||
|
||||
## R1 [completed]
|
||||
|
||||
闭环 [UniDesk #1749](https://github.com/pikasTech/unidesk/issues/1749):为 [PR #1748](https://github.com/pikasTech/unidesk/pull/1748) 补齐 draft 转 ready 的受控 GitHub CLI,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1_Task_Report.md)。
|
||||
### R1.1 [completed]
|
||||
|
||||
调查 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的既有 PR 命令、GraphQL client、typed 错误和输出合同,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.1_Task_Report.md)。
|
||||
### R1.2 [completed]
|
||||
|
||||
实现 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的 ready、already-ready 与 dry-run 合同,并补 scoped help,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.2_Task_Report.md)。
|
||||
### R1.3 [completed]
|
||||
|
||||
覆盖 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的成功与失败测试,使用新命令验收 [PR #1748](https://github.com/pikasTech/unidesk/pull/1748),提交非 draft PR,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.3_Task_Report.md)。
|
||||
@@ -40,6 +40,36 @@ HWLAB v0.2/v0.3 仓库内 `docs/reference/spec-*`,以及已收编的 `cloud-wo
|
||||
|
||||
重大规划型 issue 必须执行 P0 SPEC-first:P0 阶段先在 UniDesk OA `project-management/PJ2026-01/specs/` 维护对应 SPEC,确认 SPEC 编号、上级/关联规格、架构图、数据流图、关键时序图和代码引用规则,再进入后续实现。该 issue 范围内新增或修改的源码文件头部必须标注遵循的 SPEC 编号、短名和实现引用版本;自动生成、vendored、纯配置、锁文件或不能承载注释头的二进制产物可例外,但对应生成器、渲染器或配置入口必须能追溯到 SPEC。issue 正文和评论只承载执行计划、讨论和证据,不替代长期 SPEC。
|
||||
|
||||
## 概念展示与执行可见性
|
||||
|
||||
- HWLAB 概念展示必须从 UniDesk OA 的总规格、Agent 编排、客户端和硬件池边界出发,不从既有 Web 页面布局反推产品定义。
|
||||
- 展示页用于解释稳定产品能力和研发闭环,不承担第二份需求规格。
|
||||
- 页面中的任务、工具动作、硬件事实、证据和裁决必须能追溯到 OA 规格中的对应能力。
|
||||
- 一次性演示故事、截图、端口、提交和运行记录只保留在 issue 或项目过程材料中。
|
||||
- AGENT 必须是研发过程的主语,不能把固定 CI/CD 流水线或代码差异冒充 AGENT 参与过程。
|
||||
- 每个关键阶段应展示观察事实、可审计假设、决策摘要、工具调用、输出事实和下一步。
|
||||
- 不展示原始思维链,不用拟人化长篇解释替代可验证的行动与证据。
|
||||
- 代码差异是 AGENT 行动的结果,只在对应修改节点或检查器中展开。
|
||||
- 执行过程优先使用可交互的空间执行图表达。
|
||||
- AGENT 的观察、假设、计划、裁决和复诊应作为独立节点。
|
||||
- Keil、CMSIS-DAP、UART、ioProbe 等能力应作为 AGENT 选择调用的工具或硬件节点。
|
||||
- 节点与连线必须投影排队、运行、通过、失败、阻断和跳过状态。
|
||||
- 通过和失败必须形成真实分支;物理验证失败后应生成新假设,并明确回环到下一轮诊断入口。
|
||||
- 节点检查器应提供有界输入、决策摘要、工具调用、输出事实、证据引用和必要的代码差异。
|
||||
- 真实硬件和数字孪生只负责提供物理现场与测量事实,视觉权重不得压过 AGENT 主舞台。
|
||||
- 板卡、调试器、探针、线缆和仪器应保持可辨识的真实硬件关系。
|
||||
- 空间波形、设备线框、深度网格和测量 HUD 可以增强混合现实表达,但不能遮挡设备身份或伪造测量结果。
|
||||
- AGENT 调用 Flash、UART 或物理测量时,执行节点应与对应设备产生同步状态响应。
|
||||
- 证据链必须把任务、Agent Trace、Patch、Build、Flash、UART、Physical 和 Verdict 关联起来。
|
||||
- 物理测量通过前不得展示 Aggregate PASS 或允许封存回归 Case。
|
||||
- 物理测量失败时必须拒绝封存,展示 Aggregate BLOCKED,并暴露下一诊断目标。
|
||||
- 浏览器中的动画、标签和演示状态只能表达预先声明的故事或真实后端事实,不能把本地状态冒充运行面证据。
|
||||
- 纯前端概念展示可以使用独立静态目录和轻量 HTTP 服务,但必须保持展示资产与生产运行面解耦。
|
||||
- 静态页面不得增加后端接口、伪造 API 响应或引入不必要的框架依赖。
|
||||
- 公网展示必须验证 HTML、关键图片和交互资产均可从外部网络读取。
|
||||
- 需要持续托管时应使用受监督、可重启的服务管理器;一次性 shell 子进程或普通 `nohup` 不能作为长期在线证据。
|
||||
- 验收至少覆盖桌面、紧凑桌面和移动端,无文档级横向溢出、无关键内容重叠、无浏览器控制台错误,并支持 `prefers-reduced-motion`。
|
||||
|
||||
## DEV 入口
|
||||
|
||||
- 当前入口必须从 `config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01` 读取,不从长期文档硬编码推断。
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { withGhDefaultRendered } from "./gh/default-render";
|
||||
import { ghScopedHelp } from "./gh/help";
|
||||
import { prReady } from "./gh/pr-ready";
|
||||
import type { GitHubPullRequest } from "./gh/types";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function pullRequest(overrides: Partial<GitHubPullRequest> = {}): GitHubPullRequest {
|
||||
return {
|
||||
id: 1748,
|
||||
node_id: "PR_kwDO_ready_1748",
|
||||
number: 1748,
|
||||
title: "PaC source artifact",
|
||||
body: "body",
|
||||
state: "open",
|
||||
html_url: "https://github.com/pikasTech/unidesk/pull/1748",
|
||||
draft: true,
|
||||
head: { ref: "fix/1746-pac-source-artifact", sha: "a".repeat(40) },
|
||||
base: { ref: "master", sha: "b".repeat(40) },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function installFetch(handler: (url: URL, init: RequestInit | undefined) => Response | Promise<Response>): string[] {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = new URL(typeof input === "string" || input instanceof URL ? input.toString() : input.url);
|
||||
calls.push(`${init?.method ?? "GET"} ${url.pathname}`);
|
||||
return handler(url, init);
|
||||
}) as typeof fetch;
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe("gh pr ready", () => {
|
||||
test("marks an open draft PR ready through the shared GraphQL client", async () => {
|
||||
const calls = installFetch(async (url, init) => {
|
||||
if (url.pathname.endsWith("/pulls/1748")) return Response.json(pullRequest());
|
||||
expect(url.pathname).toBe("/graphql");
|
||||
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer test-token");
|
||||
const payload = JSON.parse(String(init?.body)) as { query: string; variables: { pullRequestId: string } };
|
||||
expect(payload.query).toContain("markPullRequestReadyForReview");
|
||||
expect(payload.variables.pullRequestId).toBe("PR_kwDO_ready_1748");
|
||||
return Response.json({
|
||||
data: {
|
||||
markPullRequestReadyForReview: {
|
||||
pullRequest: {
|
||||
id: "PR_kwDO_ready_1748",
|
||||
number: 1748,
|
||||
title: "PaC source artifact",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
url: "https://github.com/pikasTech/unidesk/pull/1748",
|
||||
headRefName: "fix/1746-pac-source-artifact",
|
||||
baseRefName: "master",
|
||||
updatedAt: "2026-07-11T04:30:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const result = await prReady("pikasTech/unidesk", "test-token", 1748, false);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.action).toBe("marked-ready");
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.graphQl).toBe(true);
|
||||
expect((result.pullRequest as { draft?: boolean }).draft).toBe(false);
|
||||
expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748", "POST /graphql"]);
|
||||
});
|
||||
|
||||
test("is idempotent when the open PR is already ready", async () => {
|
||||
const calls = installFetch(() => Response.json(pullRequest({ draft: false })));
|
||||
|
||||
const result = await prReady("pikasTech/unidesk", "test-token", 1748, false);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.action).toBe("already-ready");
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.graphQl).toBe(false);
|
||||
expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]);
|
||||
const rendered = withGhDefaultRendered(["pr", "ready", "1748"], result) as { renderedText: string };
|
||||
expect(rendered.renderedText).toContain("gh pr ready (already-ready)");
|
||||
});
|
||||
|
||||
test("dry-run reads the PR and exposes a bounded mutation plan without writing", async () => {
|
||||
const calls = installFetch(() => Response.json(pullRequest()));
|
||||
|
||||
const result = await prReady("pikasTech/unidesk", "test-token", 1748, true);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.action).toBe("would-mark-ready");
|
||||
expect(result.planned).toBe(true);
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.request).toEqual({
|
||||
method: "POST",
|
||||
path: "/graphql",
|
||||
mutation: "markPullRequestReadyForReview",
|
||||
variables: { pullRequestId: "present" },
|
||||
});
|
||||
expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]);
|
||||
});
|
||||
|
||||
test("rejects a closed PR before mutation with a typed validation error", async () => {
|
||||
const calls = installFetch(() => Response.json(pullRequest({ state: "closed" })));
|
||||
|
||||
const result = await prReady("pikasTech/unidesk", "test-token", 1748, false);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.degradedReason).toBe("validation-failed");
|
||||
expect(result.runnerDisposition).toBe("business-failed");
|
||||
expect(result.phase).toBe("validate-pr-state");
|
||||
expect(calls).toEqual(["GET /repos/pikasTech/unidesk/pulls/1748"]);
|
||||
});
|
||||
|
||||
test("preserves the typed pr-not-found REST failure", async () => {
|
||||
installFetch(() => Response.json({ message: "Not Found" }, { status: 404 }));
|
||||
|
||||
const result = await prReady("pikasTech/unidesk", "test-token", 404, false);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.degradedReason).toBe("pr-not-found");
|
||||
expect(result.runnerDisposition).toBe("business-failed");
|
||||
expect(result.phase).toBe("fetch-pr");
|
||||
});
|
||||
|
||||
test("preserves typed mutation permission failures", async () => {
|
||||
installFetch((url) => {
|
||||
if (url.pathname.endsWith("/pulls/1748")) return Response.json(pullRequest());
|
||||
return Response.json(
|
||||
{ message: "Resource not accessible by integration" },
|
||||
{ status: 403, headers: { "x-accepted-oauth-scopes": "repo" } },
|
||||
);
|
||||
});
|
||||
|
||||
const result = await prReady("pikasTech/unidesk", "test-token", 1748, false);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.degradedReason).toBe("scope-insufficient");
|
||||
expect(result.runnerDisposition).toBe("infra-blocked");
|
||||
expect(result.phase).toBe("mark-ready");
|
||||
});
|
||||
|
||||
test("publishes bounded scoped help for the command", () => {
|
||||
const help = ghScopedHelp(["pr", "ready", "--help"]) as { usage: string[]; notes: string[] };
|
||||
expect(help.usage).toEqual([
|
||||
"bun scripts/cli.ts gh pr ready <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
]);
|
||||
expect(help.notes.join("\n")).toContain("markPullRequestReadyForReview");
|
||||
expect(help.notes.join("\n")).toContain("不改变 GitHub 状态");
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,7 @@ function renderGhDefaultText(result: GitHubCommandResult): string {
|
||||
if (command === "issue scan-escape" || command === "issue cleanup-plan") return renderScanEscape(result);
|
||||
if (command === "issue close" || command === "issue reopen") return renderIssueLifecycle(result);
|
||||
if (command === "issue update" || command === "issue edit" || command === "issue patch") return renderIssueWrite(result);
|
||||
if (command === "pr update" || command === "pr edit" || command === "pr close" || command === "pr reopen") return renderPrWrite(result);
|
||||
if (command === "pr update" || command === "pr edit" || command === "pr close" || command === "pr reopen" || command === "pr ready") return renderPrWrite(result);
|
||||
return renderGenericResult(result);
|
||||
}
|
||||
|
||||
@@ -591,8 +591,13 @@ function renderIssueWrite(result: GitHubCommandResult): string {
|
||||
|
||||
function renderPrWrite(result: GitHubCommandResult): string {
|
||||
const pr = record(result.pullRequest);
|
||||
const status = result.dryRun === true
|
||||
? "dry-run"
|
||||
: result.action === "already-ready"
|
||||
? "already-ready"
|
||||
: "updated";
|
||||
return [
|
||||
`gh ${result.command} (${result.dryRun === true ? "dry-run" : "updated"})`,
|
||||
`gh ${result.command} (${status})`,
|
||||
"",
|
||||
ghTable(["PR", "STATE", "CHANGED", "URL", "TITLE"], [[
|
||||
`#${ghText(result.number ?? pr.number)}`,
|
||||
|
||||
@@ -61,6 +61,7 @@ export function ghHelp(): unknown {
|
||||
"bun scripts/cli.ts gh pr comment edit <commentId> (--body-stdin|--body-file <file|->|--body <text>) [--repo owner/name] [--number N compat] [--dry-run] [compatibility alias for pr comment update]",
|
||||
"bun scripts/cli.ts gh pr comment delete <commentId> [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr close|reopen <number> [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr ready <number|url|owner/repo#number> [--repo owner/name] [--number N compat] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr merge <number> [--repo owner/name] [--number N compat] [--merge|--squash|--rebase] [--delete-branch|--keep-branch] [--sync-node NODE]... [--skip-local-closeout] [--dry-run]",
|
||||
"bun scripts/cli.ts gh pr delete <number> [unsupported: use close]",
|
||||
],
|
||||
@@ -169,6 +170,9 @@ export function ghScopedHelpNotes(tokens: string[]): string[] {
|
||||
} else if (key === "pr comment" || key.startsWith("pr comment ")) {
|
||||
notes.push("PR comments are GitHub issue comments under the hood; use comment id targets for update/edit/delete.");
|
||||
notes.push("Use `pr comment view <commentId> --full` to read one full comment body by id.");
|
||||
} else if (key === "pr ready") {
|
||||
notes.push("PR ready 先读取目标 PR 并确认其为 open;draft PR 通过 GitHub GraphQL markPullRequestReadyForReview 转为 ready,已 ready 时幂等成功。");
|
||||
notes.push("使用 `--dry-run` 只读检查当前 draft 状态和 mutation 计划,不改变 GitHub 状态。");
|
||||
} else if (key === "pr merge") {
|
||||
notes.push("PR merge is one-command guarded: it performs the readiness check itself; `gh pr preflight` is optional read-only diagnosis, not a required first step.");
|
||||
notes.push("When GitHub reports mergeability as UNKNOWN/null, merge automatically retries with YAML-configured exponential backoff and shows retry attempts as N/M.");
|
||||
|
||||
+10
-1
@@ -17,6 +17,7 @@ import { issueCreate, issueEdit, issuePatch } from "./issue-write";
|
||||
import { allowsNumberTargetAlias, isIssueReadCommand, isPrReadCommand, optionValue, optionWasProvided, parseOptions } from "./options";
|
||||
import { prComment, prCreate, prState, prUpdate } from "./pr-commands";
|
||||
import { prMerge, prPreflight } from "./pr-merge";
|
||||
import { prReady } from "./pr-ready";
|
||||
import { prDiffFile, prReviewPlan } from "./pr-review";
|
||||
import { isGitHubCommandResult, issueReadJsonFields, prReadJsonFields, readDisclosureOptions, readViewSupportedJsonFields, resolvePositionalIssueReference, resolvePositionalNumberReference, resolvePositionalPrReference, resolveReadViewNumberReference, unknownGhOptionDetails, withNumberOptionHint } from "./refs";
|
||||
import { issueStaleClose } from "./stale-close";
|
||||
@@ -581,6 +582,14 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
|
||||
return withNumberOptionHint(prState(resolved.repo, token, resolved.number, sub === "close" ? "closed" : "open", false), resolved);
|
||||
}
|
||||
if (sub === "ready") {
|
||||
const resolved = resolvePositionalPrReference(args, 2, "pr ready", options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
const { token, probe } = resolveToken(true);
|
||||
const missing = authRequired(resolved.repo, "pr ready", probe);
|
||||
if (missing !== null || token === null) return missing ?? authRequired(resolved.repo, "pr ready", { present: false, source: null, ghFallbackAttempted: true });
|
||||
return withNumberOptionHint(prReady(resolved.repo, token, resolved.number, options.dryRun), resolved);
|
||||
}
|
||||
if (sub === "merge") {
|
||||
const resolved = resolvePositionalPrReference(args, 2, "pr merge", options);
|
||||
if (isGitHubCommandResult(resolved)) return resolved;
|
||||
@@ -590,7 +599,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
return withNumberOptionHint(prMerge(resolved.repo, token, resolved.number, options), resolved);
|
||||
}
|
||||
if (sub !== "list" && !isPrReadCommand(sub)) {
|
||||
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, merge, comment create/update/edit/delete, and unsupported delete.");
|
||||
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, preflight/closeout, create, update/edit, close, reopen, ready, merge, comment create/update/edit/delete, and unsupported delete.");
|
||||
}
|
||||
if (sub === "read" || sub === "view") {
|
||||
const resolved = resolveReadViewNumberReference("pr", sub, third, options, args);
|
||||
|
||||
@@ -165,7 +165,7 @@ export function allowsNumberTargetAlias(top: string | undefined, sub: string | u
|
||||
return false;
|
||||
}
|
||||
if (top === "pr") {
|
||||
if (sub === "read" || sub === "view" || sub === "files" || sub === "diff" || sub === "review-plan" || sub === "preflight" || sub === "closeout" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "merge" || sub === "delete") return true;
|
||||
if (sub === "read" || sub === "view" || sub === "files" || sub === "diff" || sub === "review-plan" || sub === "preflight" || sub === "closeout" || sub === "edit" || sub === "update" || sub === "close" || sub === "reopen" || sub === "ready" || sub === "merge" || sub === "delete") return true;
|
||||
if (sub === "comment") return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -113,13 +113,14 @@ export function renderPrCreateTable(result: GitHubCommandResult): string {
|
||||
const title = ghText(pr.title ?? request.title ?? result.title);
|
||||
const base = ghText(isRecord(pr.base) ? pr.base.ref : request.base ?? result.base);
|
||||
const head = ghText(isRecord(pr.head) ? pr.head.ref : request.head ?? result.head);
|
||||
const draft = request.draft === true || pr.draft === true || result.draft === true;
|
||||
const status = result.dryRun === true ? "dry-run" : "created";
|
||||
const rows = [[
|
||||
number === "-" ? "-" : `#${number}`,
|
||||
status,
|
||||
base,
|
||||
head,
|
||||
ghText(request.draft ?? result.draft),
|
||||
ghText(draft),
|
||||
ghShort(title, 80),
|
||||
]];
|
||||
const lines = [
|
||||
@@ -133,12 +134,15 @@ export function renderPrCreateTable(result: GitHubCommandResult): string {
|
||||
"Next:",
|
||||
];
|
||||
if (status === "created" && number !== "-") {
|
||||
lines.push(` bun scripts/cli.ts gh pr merge ${number} --repo ${ghText(result.repo)} --merge --delete-branch`);
|
||||
if (draft) lines.push(` bun scripts/cli.ts gh pr ready ${number} --repo ${ghText(result.repo)}`);
|
||||
else lines.push(` bun scripts/cli.ts gh pr merge ${number} --repo ${ghText(result.repo)} --merge --delete-branch`);
|
||||
lines.push(` bun scripts/cli.ts gh pr preflight ${number} --repo ${ghText(result.repo)} --full`);
|
||||
lines.push(" Use --squash only when ancestry and merge-parent history are intentionally disposable.");
|
||||
} else {
|
||||
lines.push(" rerun without --dry-run to create the PR");
|
||||
lines.push(` after creation: bun scripts/cli.ts gh pr merge <pr-number> --repo ${ghText(result.repo)} --merge --delete-branch`);
|
||||
lines.push(draft
|
||||
? ` after creation: bun scripts/cli.ts gh pr ready <pr-number> --repo ${ghText(result.repo)}`
|
||||
: ` after creation: bun scripts/cli.ts gh pr merge <pr-number> --repo ${ghText(result.repo)} --merge --delete-branch`);
|
||||
lines.push(" Use --squash only when ancestry and merge-parent history are intentionally disposable.");
|
||||
}
|
||||
lines.push("", "Disclosure:");
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
|
||||
|
||||
import { repoParts } from "./auth-and-safety";
|
||||
import { commandError, errorPayload, githubGraphqlRequest, githubRequest, isGitHubError, validationError } from "./client";
|
||||
import { prCompactSummary } from "./pr-summary";
|
||||
import type { GitHubCommandResult, GitHubPullRequest } from "./types";
|
||||
|
||||
interface ReadyPullRequestGraphql {
|
||||
id?: string;
|
||||
number?: number;
|
||||
title?: string;
|
||||
state?: string;
|
||||
isDraft?: boolean;
|
||||
url?: string;
|
||||
headRefName?: string | null;
|
||||
baseRefName?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
interface ReadyPullRequestMutation {
|
||||
markPullRequestReadyForReview?: {
|
||||
pullRequest?: ReadyPullRequestGraphql | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function mutationPlan(): Record<string, unknown> {
|
||||
return {
|
||||
method: "POST",
|
||||
path: "/graphql",
|
||||
mutation: "markPullRequestReadyForReview",
|
||||
variables: { pullRequestId: "present" },
|
||||
};
|
||||
}
|
||||
|
||||
function readyPullRequestSummary(pr: ReadyPullRequestGraphql, fallback: GitHubPullRequest): Record<string, unknown> {
|
||||
return {
|
||||
id: pr.id ?? fallback.node_id ?? null,
|
||||
number: pr.number ?? fallback.number,
|
||||
title: pr.title ?? fallback.title,
|
||||
state: (pr.state ?? fallback.state).toLowerCase(),
|
||||
stateDetail: (pr.state ?? fallback.state).toLowerCase(),
|
||||
draft: pr.isDraft ?? false,
|
||||
url: pr.url ?? fallback.html_url,
|
||||
head: { ref: pr.headRefName ?? fallback.head?.ref ?? null, sha: fallback.head?.sha ?? null },
|
||||
base: { ref: pr.baseRefName ?? fallback.base?.ref ?? null, sha: fallback.base?.sha ?? null },
|
||||
headRefName: pr.headRefName ?? fallback.head?.ref ?? null,
|
||||
baseRefName: pr.baseRefName ?? fallback.base?.ref ?? null,
|
||||
updatedAt: pr.updatedAt ?? fallback.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function prReady(repo: string, token: string, number: number, dryRun: boolean): Promise<GitHubCommandResult> {
|
||||
const command = "pr ready";
|
||||
const { owner, name } = repoParts(repo);
|
||||
const current = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||||
if (isGitHubError(current)) return commandError(command, repo, current, { number, phase: "fetch-pr" });
|
||||
|
||||
const pullRequest = prCompactSummary(current);
|
||||
if (current.state !== "open") {
|
||||
return validationError(command, repo, "只有 open 状态的 PR 才能转为 ready for review", {
|
||||
number,
|
||||
phase: "validate-pr-state",
|
||||
currentState: current.state,
|
||||
pullRequest,
|
||||
});
|
||||
}
|
||||
if (typeof current.draft !== "boolean") {
|
||||
return commandError(command, repo, errorPayload("invalid-response", "GitHub REST PR 响应缺少 draft 布尔状态,无法安全判断是否需要写入"), {
|
||||
number,
|
||||
phase: "validate-pr-draft",
|
||||
pullRequest,
|
||||
});
|
||||
}
|
||||
if (current.draft !== true) {
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
repo,
|
||||
number,
|
||||
dryRun,
|
||||
planned: false,
|
||||
changed: false,
|
||||
action: "already-ready",
|
||||
changedFields: [],
|
||||
pullRequest,
|
||||
rest: true,
|
||||
graphQl: false,
|
||||
};
|
||||
}
|
||||
|
||||
const request = mutationPlan();
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
repo,
|
||||
number,
|
||||
dryRun: true,
|
||||
planned: true,
|
||||
changed: false,
|
||||
action: "would-mark-ready",
|
||||
changedFields: ["draft"],
|
||||
pullRequest,
|
||||
request,
|
||||
rest: true,
|
||||
graphQl: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof current.node_id !== "string" || current.node_id.length === 0) {
|
||||
return commandError(command, repo, errorPayload("invalid-response", "GitHub REST PR 响应缺少 markPullRequestReadyForReview 所需的 node_id"), {
|
||||
number,
|
||||
phase: "validate-pr-node-id",
|
||||
pullRequest,
|
||||
});
|
||||
}
|
||||
|
||||
const mutation = `
|
||||
mutation MarkPullRequestReadyForReview($pullRequestId: ID!) {
|
||||
markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {
|
||||
pullRequest {
|
||||
id
|
||||
number
|
||||
title
|
||||
state
|
||||
isDraft
|
||||
url
|
||||
headRefName
|
||||
baseRefName
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const response = await githubGraphqlRequest<ReadyPullRequestMutation>(token, mutation, { pullRequestId: current.node_id });
|
||||
if (isGitHubError(response)) {
|
||||
return commandError(command, repo, response, {
|
||||
number,
|
||||
phase: "mark-ready",
|
||||
pullRequest,
|
||||
request,
|
||||
});
|
||||
}
|
||||
const ready = response.markPullRequestReadyForReview?.pullRequest;
|
||||
if (ready === null || ready === undefined || ready.isDraft !== false) {
|
||||
return commandError(command, repo, errorPayload("invalid-response", "GitHub GraphQL markPullRequestReadyForReview 响应未确认 isDraft=false", { details: response }), {
|
||||
number,
|
||||
phase: "verify-ready-response",
|
||||
pullRequest,
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
repo,
|
||||
number: ready.number ?? number,
|
||||
dryRun: false,
|
||||
planned: false,
|
||||
changed: true,
|
||||
action: "marked-ready",
|
||||
changedFields: ["draft"],
|
||||
pullRequest: readyPullRequestSummary(ready, current),
|
||||
request,
|
||||
rest: true,
|
||||
graphQl: true,
|
||||
};
|
||||
}
|
||||
@@ -595,6 +595,7 @@ export interface GitHubIssueListResult {
|
||||
|
||||
export interface GitHubPullRequest {
|
||||
id: number;
|
||||
node_id?: string;
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
|
||||
@@ -21,9 +21,24 @@ const serviceName = "webterm";
|
||||
|
||||
interface WebtermConfig {
|
||||
defaults: { targetId: string };
|
||||
sessionStartModes: SessionStartModes;
|
||||
autoResumePrompt: AutoResumePrompt;
|
||||
targets: WebtermTarget[];
|
||||
}
|
||||
|
||||
interface SessionStartModes {
|
||||
defaultMode: string;
|
||||
options: Array<{ id: string; label: string; command: string }>;
|
||||
}
|
||||
|
||||
interface AutoResumePrompt {
|
||||
enabled: boolean;
|
||||
prompt: string;
|
||||
sendEnter: boolean;
|
||||
enterDelayMs: number;
|
||||
readiness: { minDelayMs: number; minOutputBytes: number; quietPeriodMs: number; timeoutMs: number };
|
||||
}
|
||||
|
||||
interface WebtermTarget {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
@@ -41,6 +56,9 @@ interface WebtermTarget {
|
||||
privileged: boolean;
|
||||
pid: "host" | "container";
|
||||
sourceMounts: Array<{ source: string; target: string; readOnly: boolean }>;
|
||||
autoStartSessions: Array<{ title: string; cwd: string; command: string; cols: number; rows: number; resumePrompt: boolean }>;
|
||||
sessionStartModes: SessionStartModes;
|
||||
autoResumePrompt: AutoResumePrompt;
|
||||
environment: Record<string, string>;
|
||||
};
|
||||
publicExposure: {
|
||||
@@ -236,15 +254,19 @@ function validate(targetId: string | null): Record<string, unknown> {
|
||||
function readWebtermConfig(): WebtermConfig {
|
||||
const yaml = readYamlRecord(configPath, "platform-infra-webterm");
|
||||
const defaults = recordField(yaml, "defaults", "webterm");
|
||||
const sessionStartModes = parseSessionStartModes(recordField(yaml, "sessionStartModes", "webterm"), "webterm.sessionStartModes");
|
||||
const autoResumePrompt = parseAutoResumePrompt(recordField(yaml, "autoResumePrompt", "webterm"), "webterm.autoResumePrompt");
|
||||
const targetsRaw = yaml.targets;
|
||||
if (!Array.isArray(targetsRaw)) throw new Error("webterm.targets must be an array");
|
||||
return {
|
||||
defaults: { targetId: stringField(defaults, "targetId", "webterm.defaults") },
|
||||
targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`)),
|
||||
sessionStartModes,
|
||||
autoResumePrompt,
|
||||
targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`, sessionStartModes, autoResumePrompt)),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTarget(record: Record<string, unknown>, path: string): WebtermTarget {
|
||||
function parseTarget(record: Record<string, unknown>, path: string, sessionStartModes: SessionStartModes, autoResumePrompt: AutoResumePrompt): WebtermTarget {
|
||||
const runtime = recordField(record, "runtime", path);
|
||||
const exposure = recordField(record, "publicExposure", path);
|
||||
const dns = recordField(exposure, "dns", `${path}.publicExposure`);
|
||||
@@ -252,6 +274,7 @@ function parseTarget(record: Record<string, unknown>, path: string): WebtermTarg
|
||||
const pk01 = recordField(exposure, "pk01", `${path}.publicExposure`);
|
||||
const environment = parseEnvironment(recordField(runtime, "environment", `${path}.runtime`), `${path}.runtime.environment`);
|
||||
const sourceMounts = parseSourceMounts(runtime.sourceMounts, `${path}.runtime.sourceMounts`);
|
||||
const autoStartSessions = parseAutoStartSessions(runtime.autoStartSessions, `${path}.runtime.autoStartSessions`);
|
||||
const target: WebtermTarget = {
|
||||
id: stringField(record, "id", path),
|
||||
enabled: booleanField(record, "enabled", path),
|
||||
@@ -269,6 +292,9 @@ function parseTarget(record: Record<string, unknown>, path: string): WebtermTarg
|
||||
privileged: booleanField(runtime, "privileged", `${path}.runtime`),
|
||||
pid: parsePid(stringField(runtime, "pid", `${path}.runtime`), `${path}.runtime.pid`),
|
||||
sourceMounts,
|
||||
autoStartSessions,
|
||||
sessionStartModes,
|
||||
autoResumePrompt,
|
||||
environment,
|
||||
},
|
||||
publicExposure: {
|
||||
@@ -306,7 +332,17 @@ function resolveTarget(targetId: string | null): WebtermTarget {
|
||||
}
|
||||
|
||||
function renderCompose(target: WebtermTarget): string {
|
||||
const envLines = Object.entries(target.runtime.environment)
|
||||
const environment = {
|
||||
...target.runtime.environment,
|
||||
AUTO_START_SESSIONS_JSON: JSON.stringify(target.runtime.autoStartSessions.map((session) => ({
|
||||
...session,
|
||||
command: resumeCommandWithPrompt(session, target.runtime.autoResumePrompt),
|
||||
}))),
|
||||
SESSION_START_MODES_JSON: JSON.stringify(target.runtime.sessionStartModes.options),
|
||||
SESSION_START_DEFAULT_MODE: target.runtime.sessionStartModes.defaultMode,
|
||||
AUTO_RESUME_PROMPT_JSON: JSON.stringify(target.runtime.autoResumePrompt),
|
||||
};
|
||||
const envLines = Object.entries(environment)
|
||||
.map(([key, value]) => ` ${key}: ${quoteYaml(value)}`)
|
||||
.join("\n");
|
||||
const volumeLines = target.runtime.sourceMounts
|
||||
@@ -332,6 +368,21 @@ ${envLines}
|
||||
`;
|
||||
}
|
||||
|
||||
function resumeCommandWithPrompt(
|
||||
session: WebtermTarget["runtime"]["autoStartSessions"][number],
|
||||
prompt: AutoResumePrompt,
|
||||
): string {
|
||||
if (!session.resumePrompt) return session.command;
|
||||
if (!/^\s*(?:mycx|oncx)\s+resume\s+\S+\s*$/.test(session.command)) {
|
||||
throw new Error(`resumePrompt requires a mycx/oncx resume command: ${session.title}`);
|
||||
}
|
||||
return `${session.command.trim()} ${quoteShellArg(prompt.prompt)}`;
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function policyChecks(target: WebtermTarget, compose: string): Array<Record<string, unknown> & { ok: boolean }> {
|
||||
return [
|
||||
{ name: "enabled", ok: target.enabled, detail: "target must be enabled" },
|
||||
@@ -378,6 +429,19 @@ function targetSummary(target: WebtermTarget): Record<string, unknown> {
|
||||
composeProject: target.runtime.composeProject,
|
||||
containerName: target.runtime.containerName,
|
||||
image: target.runtime.image,
|
||||
autoStartSessions: target.runtime.autoStartSessions.map((session) => session.title),
|
||||
sessionStartModes: {
|
||||
defaultMode: target.runtime.sessionStartModes.defaultMode,
|
||||
options: target.runtime.sessionStartModes.options.map((mode) => mode.id),
|
||||
},
|
||||
autoResumePrompt: {
|
||||
enabled: target.runtime.autoResumePrompt.enabled,
|
||||
taggedSessions: target.runtime.autoStartSessions.filter((session) => session.resumePrompt).map((session) => session.title),
|
||||
readiness: target.runtime.autoResumePrompt.readiness,
|
||||
promptBytes: Buffer.byteLength(target.runtime.autoResumePrompt.prompt),
|
||||
sendEnter: target.runtime.autoResumePrompt.sendEnter,
|
||||
enterDelayMs: target.runtime.autoResumePrompt.enterDelayMs,
|
||||
},
|
||||
sourceMounts: target.runtime.sourceMounts.map((mount) => `${mount.source}->${mount.target}${mount.readOnly ? ":ro" : ""}`),
|
||||
hostPort: target.runtime.hostPort,
|
||||
containerPort: target.runtime.containerPort,
|
||||
@@ -481,6 +545,61 @@ function parseSourceMounts(value: unknown, path: string): WebtermTarget["runtime
|
||||
});
|
||||
}
|
||||
|
||||
function parseAutoStartSessions(value: unknown, path: string): WebtermTarget["runtime"]["autoStartSessions"] {
|
||||
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
|
||||
return value.map((item, index) => {
|
||||
const itemPath = `${path}[${index}]`;
|
||||
const record = asRecord(item, itemPath);
|
||||
return {
|
||||
title: stringField(record, "title", itemPath),
|
||||
cwd: stringField(record, "cwd", itemPath),
|
||||
command: stringField(record, "command", itemPath),
|
||||
cols: integerField(record, "cols", itemPath),
|
||||
rows: integerField(record, "rows", itemPath),
|
||||
resumePrompt: booleanField(record, "resumePrompt", itemPath),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseSessionStartModes(record: Record<string, unknown>, path: string): SessionStartModes {
|
||||
const defaultMode = stringField(record, "defaultMode", path);
|
||||
const optionsRecord = recordField(record, "options", path);
|
||||
const options = Object.entries(optionsRecord).map(([id, item]) => {
|
||||
const itemPath = `${path}.options.${id}`;
|
||||
const option = asRecord(item, itemPath);
|
||||
return {
|
||||
id,
|
||||
label: stringField(option, "label", itemPath),
|
||||
command: commandField(option, itemPath),
|
||||
};
|
||||
});
|
||||
if (options.length === 0) throw new Error(`${path}.options must be a non-empty record`);
|
||||
if (!options.some((option) => option.id === defaultMode)) throw new Error(`${path}.defaultMode must reference an option id`);
|
||||
return { defaultMode, options };
|
||||
}
|
||||
|
||||
function commandField(record: Record<string, unknown>, path: string): string {
|
||||
const value = record.command;
|
||||
if (typeof value !== "string") throw new Error(`${path}.command must be a string`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function parseAutoResumePrompt(record: Record<string, unknown>, path: string): AutoResumePrompt {
|
||||
const readiness = recordField(record, "readiness", path);
|
||||
return {
|
||||
enabled: booleanField(record, "enabled", path),
|
||||
prompt: stringField(record, "prompt", path),
|
||||
sendEnter: booleanField(record, "sendEnter", path),
|
||||
enterDelayMs: integerField(record, "enterDelayMs", path),
|
||||
readiness: {
|
||||
minDelayMs: integerField(readiness, "minDelayMs", `${path}.readiness`),
|
||||
minOutputBytes: integerField(readiness, "minOutputBytes", `${path}.readiness`),
|
||||
quietPeriodMs: integerField(readiness, "quietPeriodMs", `${path}.readiness`),
|
||||
timeoutMs: integerField(readiness, "timeoutMs", `${path}.readiness`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseLiteral<T extends string>(value: string, expected: T, path: string): T {
|
||||
if (value !== expected) throw new Error(`${path} must be ${expected}`);
|
||||
return expected;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
sshRuntimeTimeoutMs,
|
||||
sshRuntimeTimingHint,
|
||||
sshWindowsPlaneHint,
|
||||
sshWindowsPowerShellPipelineHint,
|
||||
wrapSshRemoteCommand,
|
||||
type SshCaptureResult,
|
||||
} from "./ssh";
|
||||
@@ -435,6 +436,8 @@ async function runRemoteSshWebSocket(
|
||||
if (!timedOut) {
|
||||
const windowsPlaneHint = sshWindowsPlaneHint(invocation, code, stderrTail);
|
||||
if (windowsPlaneHint) process.stderr.write(windowsPlaneHint);
|
||||
const pipelineHint = sshWindowsPowerShellPipelineHint(invocation, code, stderrTail);
|
||||
if (pipelineHint) process.stderr.write(pipelineHint);
|
||||
}
|
||||
const timingHint = formatSshRuntimeTimingHint(sshRuntimeTimingHint({
|
||||
invocation,
|
||||
@@ -615,6 +618,8 @@ async function runRemoteSshWebSocketCaptureRemoteCommand(
|
||||
startedAtMs,
|
||||
}));
|
||||
if (timingHint) stderr += timingHint;
|
||||
stderr += sshWindowsPlaneHint(invocation, code, stderr);
|
||||
stderr += sshWindowsPowerShellPipelineHint(invocation, code, stderr);
|
||||
resolve({ exitCode: code, stdout, stderr });
|
||||
};
|
||||
const openTimer = setTimeout(() => {
|
||||
|
||||
@@ -117,6 +117,20 @@ export function sshWindowsPlaneHint(invocation: ParsedSshInvocation, exitCode: n
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
export function sshWindowsPowerShellPipelineHint(invocation: ParsedSshInvocation, exitCode: number, stderrText: string): string {
|
||||
if (invocation.route.plane !== "win" || exitCode === 0 || !/An empty pipe element is not allowed/iu.test(stderrText)) return "";
|
||||
return `UNIDESK_SSH_HINT ${JSON.stringify({
|
||||
code: "windows-powershell-statement-pipeline-boundary",
|
||||
providerId: safeProviderId(invocation.providerId),
|
||||
route: invocation.route.raw,
|
||||
exitCode,
|
||||
message: "PowerShell cannot pipe a statement such as foreach directly at this syntax boundary.",
|
||||
action: "Wrap the statement in @(...), then pipe the resulting values, or use a multiline quoted PowerShell heredoc.",
|
||||
examples: ["@(foreach (...) { ... }) | Format-List", "trans <route> ps <<'PS'"],
|
||||
note: "This hint intentionally does not echo the submitted PowerShell source.",
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
export function sshSlowWarningThresholdMs(env: NodeJS.ProcessEnv = process.env): number {
|
||||
const raw = env.UNIDESK_SSH_SLOW_WARNING_MS;
|
||||
const parsed = raw === undefined ? NaN : Number(raw);
|
||||
@@ -1015,6 +1029,7 @@ async function runSshCaptureRemoteCommand(config: UniDeskConfig, invocation: Par
|
||||
stderrText: stderr,
|
||||
}));
|
||||
stderr += sshWindowsPlaneHint(invocation, exitCode, stderr);
|
||||
stderr += sshWindowsPowerShellPipelineHint(invocation, exitCode, stderr);
|
||||
resolve({ exitCode, stdout, stderr });
|
||||
};
|
||||
const runtimeTimer = setTimeout(() => {
|
||||
@@ -1538,6 +1553,8 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
if (!timedOut) {
|
||||
const windowsPlaneHint = sshWindowsPlaneHint(invocation, exitCode, stderrTail);
|
||||
if (windowsPlaneHint) process.stderr.write(windowsPlaneHint);
|
||||
const pipelineHint = sshWindowsPowerShellPipelineHint(invocation, exitCode, stderrTail);
|
||||
if (pipelineHint) process.stderr.write(pipelineHint);
|
||||
}
|
||||
if (!timedOut) {
|
||||
const tcpPoolHint = formatSshTcpPoolHint(sshTcpPoolHint({
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
sshStdoutStreamMaxBytes,
|
||||
sshStdoutTruncationHint,
|
||||
sshWindowsPlaneHint,
|
||||
sshWindowsPowerShellPipelineHint,
|
||||
windowsFsReadOnlyScript,
|
||||
windowsPowerShellScriptPrelude,
|
||||
} from "./ssh";
|
||||
@@ -95,10 +96,25 @@ describe("ssh windows fs read-only operations", () => {
|
||||
expect(decoded).toContain("update-ref");
|
||||
expect(decoded).toContain("unselected worktree/index fingerprint");
|
||||
expect(decoded).toContain("old-value CAS");
|
||||
expect(decoded).toContain("$ErrorActionPreference='Continue'");
|
||||
expect(decoded).toContain("2>&1");
|
||||
expect(decoded).toContain("Management.Automation.ErrorRecord");
|
||||
expect(decoded).toContain("warnings=@($gitWarnings)");
|
||||
expect(decoded.trimEnd()).toEndWith("exit 0");
|
||||
expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["git", "exact-commit", "run", "--path", "src/main.c", "--message", "fix: exact"])).toThrow("requires --confirm");
|
||||
expect(() => parseSshInvocation("D601:win/F/Work/ConStart", ["git", "exact-commit", "plan", "--path", "../secret"])).toThrow("must stay inside");
|
||||
});
|
||||
|
||||
test("adds a bounded hint for the PowerShell statement-to-pipeline parser boundary", () => {
|
||||
const invocation = parseSshInvocation("D601:win/c/test", ["ps", "foreach ($x in $xs) { $x } | Format-List"]);
|
||||
const hint = sshWindowsPowerShellPipelineHint(invocation, 1, "An empty pipe element is not allowed");
|
||||
|
||||
expect(hint).toContain("windows-powershell-statement-pipeline-boundary");
|
||||
expect(hint).toContain("@(foreach (...) { ... }) | Format-List");
|
||||
expect(hint).not.toContain("$x in $xs");
|
||||
expect(sshWindowsPowerShellPipelineHint(invocation, 0, "An empty pipe element is not allowed")).toBe("");
|
||||
});
|
||||
|
||||
test("builds bounded UTF-8 scripts for cat, ls, and rg", () => {
|
||||
const catScript = windowsFsReadOnlyScript("F:\\Work\\demo", "cat", ["--max-bytes", "4096", "中文.md"]);
|
||||
const lsScript = windowsFsReadOnlyScript("F:\\Work\\demo", "ls", ["-la", "--limit=5"]);
|
||||
|
||||
+21
-8
@@ -657,10 +657,21 @@ Set-Location -LiteralPath ${cwd}
|
||||
$operation=${powerShellSingleQuote(operation)}
|
||||
$paths=@(${pathList})
|
||||
$message=${powerShellSingleQuote(message)}
|
||||
$gitWarnings=[Collections.Generic.List[string]]::new()
|
||||
function Invoke-Git([string[]]$GitArgs) {
|
||||
$output = @(& git @GitArgs 2>&1)
|
||||
if ($LASTEXITCODE -ne 0) { throw (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine) }
|
||||
return (($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine).Trim()
|
||||
$previousErrorActionPreference=$ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference='Continue'
|
||||
$records=@(& git @GitArgs 2>&1)
|
||||
$nativeExitCode=$LASTEXITCODE
|
||||
$stderr=(($records | Where-Object { $_ -is [Management.Automation.ErrorRecord] } | ForEach-Object { $_.Exception.Message }) -join [Environment]::NewLine).Trim()
|
||||
$stdout=(($records | Where-Object { $_ -isnot [Management.Automation.ErrorRecord] } | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine).Trim()
|
||||
if($nativeExitCode -ne 0){throw ((@($stdout,$stderr) | Where-Object { $_ }) -join [Environment]::NewLine)}
|
||||
if($stderr){[void]$gitWarnings.Add($stderr)}
|
||||
return $stdout
|
||||
} finally {
|
||||
$ErrorActionPreference=$previousErrorActionPreference
|
||||
}
|
||||
}
|
||||
function Object-Status {
|
||||
$values=@{}
|
||||
@@ -671,13 +682,13 @@ $before=Object-Status
|
||||
$head=Invoke-Git @('rev-parse','--verify','HEAD^{commit}')
|
||||
$branch=Invoke-Git @('symbolic-ref','-q','HEAD')
|
||||
if($operation -eq 'status') {
|
||||
[ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;objects=$before;safeStop='read-only; no objects were pruned'} | ConvertTo-Json -Depth 8 -Compress
|
||||
[ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;objects=$before;warnings=@($gitWarnings);safeStop='read-only; no objects were pruned'} | ConvertTo-Json -Depth 8 -Compress
|
||||
exit 0
|
||||
}
|
||||
if($operation -eq 'gc-auto') {
|
||||
$gcOutput=Invoke-Git @('gc','--auto')
|
||||
$after=Object-Status
|
||||
[ordered]@{ok=$true;operation=$operation;head=$head;before=$before;after=$after;gcOutput=$gcOutput;safeStop='git gc --auto only; Git recent-object and reflog retention remain authoritative; no direct prune'} | ConvertTo-Json -Depth 8 -Compress
|
||||
[ordered]@{ok=$true;operation=$operation;head=$head;before=$before;after=$after;gcOutput=$gcOutput;warnings=@($gitWarnings);safeStop='git gc --auto only; Git recent-object and reflog retention remain authoritative; no direct prune'} | ConvertTo-Json -Depth 8 -Compress
|
||||
exit 0
|
||||
}
|
||||
$gitDir=Invoke-Git @('rev-parse','--path-format=absolute','--git-dir')
|
||||
@@ -693,7 +704,7 @@ try {
|
||||
$parentTree=Invoke-Git @('rev-parse',($head+'^{tree}'))
|
||||
if($tree -eq $parentTree){throw 'selected paths do not change the HEAD tree'}
|
||||
if($operation -eq 'plan') {
|
||||
[ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;tree=$tree;paths=$paths;tempIndex=$tempIndex;objectsBefore=$before;safeStop='plan only; ref and original index unchanged'} | ConvertTo-Json -Depth 8 -Compress
|
||||
[ordered]@{ok=$true;operation=$operation;head=$head;branch=$branch;tree=$tree;paths=$paths;tempIndex=$tempIndex;objectsBefore=$before;warnings=@($gitWarnings);safeStop='plan only; ref and original index unchanged'} | ConvertTo-Json -Depth 8 -Compress
|
||||
exit 0
|
||||
}
|
||||
$commit=($message | & git commit-tree $tree -p $head).Trim()
|
||||
@@ -704,12 +715,13 @@ try {
|
||||
$unrelatedAfter=Invoke-Git (@('status','--porcelain=v2','--')+$unrelatedPathspec)
|
||||
if($unrelatedAfter -ne $unrelatedBefore){throw 'unselected worktree/index fingerprint changed after exact commit'}
|
||||
$after=Object-Status
|
||||
[ordered]@{ok=$true;operation=$operation;oldHead=$head;commit=$commit;tree=$tree;branch=$branch;paths=$paths;tempIndex=$tempIndex;unselectedStateUnchanged=$true;selectedPathsIndexUpdated=$true;objectsBefore=$before;objectsAfter=$after;safeStop='ref updated with old-value CAS; no prune performed'} | ConvertTo-Json -Depth 8 -Compress
|
||||
[ordered]@{ok=$true;operation=$operation;oldHead=$head;commit=$commit;tree=$tree;branch=$branch;paths=$paths;tempIndex=$tempIndex;unselectedStateUnchanged=$true;selectedPathsIndexUpdated=$true;objectsBefore=$before;objectsAfter=$after;warnings=@($gitWarnings);safeStop='ref updated with old-value CAS; no prune performed'} | ConvertTo-Json -Depth 8 -Compress
|
||||
} finally {
|
||||
Remove-Item Env:GIT_INDEX_FILE -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $tempIndex -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath ($tempIndex+'.lock') -Force -ErrorAction SilentlyContinue
|
||||
}`;
|
||||
}
|
||||
exit 0`;
|
||||
}
|
||||
|
||||
function validateWindowsGitCommitArgs(route: ParsedSshRoute, gitArgs: string[]): void {
|
||||
@@ -2058,6 +2070,7 @@ export {
|
||||
sshTcpPoolHint,
|
||||
sshTruncationCompletionSummary,
|
||||
sshWindowsPlaneHint,
|
||||
sshWindowsPowerShellPipelineHint,
|
||||
} from "./ssh-runtime";
|
||||
export type { SshStreamForwarder } from "./ssh-runtime";
|
||||
export { windowsFsReadOnlyScript } from "./ssh-windows-fs";
|
||||
|
||||
Reference in New Issue
Block a user