diff --git a/.agents/skills/unidesk-trans/references/operations.md b/.agents/skills/unidesk-trans/references/operations.md index f44fe52a..0cb41b86 100644 --- a/.agents/skills/unidesk-trans/references/operations.md +++ b/.agents/skills/unidesk-trans/references/operations.md @@ -11,4 +11,6 @@ For a Windows workspace, run tools that belong to the Windows installation or PA `download` returns verified bytes/SHA-256 evidence. On Windows routes, drive-letter paths are automatically mapped through the same provider's WSL `/mnt/` host route for binary streaming; for example `trans D518:win download 'D:\tmp\image.png' /tmp/image.png` does not require manually rewriting the source path to `/mnt/d/tmp/image.png`. Relative Windows downloads use the `win//` route workspace, such as `trans D518:win/d/tmp download image.png /tmp/image.png`. UNC paths still need to be copied to a drive-letter path first. +`trans --help download` 返回紧凑的 download 合同、可执行示例和当前 `runtime.repoRoot` / `runtime.cliPath`。PATH wrapper 不根据 cwd 选择源码根;验证独立 worktree 时显式设置 `UNIDESK_TRANS_REPO_ROOT=`,并先读取帮助中的实际 root,避免把主 worktree 的旧实现误认为当前 cwd 的实现。 + Ordinary trans/SSH work must fit the short connection budget. Long build, CI/CD, trace, probe or rollout work must use submit-and-poll through a controlled job/status surface. diff --git a/docs/MDTODO/trans-download-scoped-help.md b/docs/MDTODO/trans-download-scoped-help.md new file mode 100644 index 00000000..b3a3d211 --- /dev/null +++ b/docs/MDTODO/trans-download-scoped-help.md @@ -0,0 +1,19 @@ +# 收敛 trans download scoped help + +- 来源: +- 范围:仅收敛 `trans --help download` 的帮助路由、紧凑合同、定向测试和 PR 验收。 +- 禁止项:不修改文件传输实现,不提高 stdout 门限,不部署运行面。 + + +## R1 [completed] + +闭环修复 [UniDesk #1744](https://github.com/pikasTech/unidesk/issues/1744) 的 trans download scoped help 可见性,完成任务后将详细报告写入[任务报告](./details/trans-download-scoped-help/R1_Task_Report.md)。 +### R1.1 [completed] + +复现并确认 [#1744](https://github.com/pikasTech/unidesk/issues/1744) 的帮助路由、输出爆炸与实际 entrypoint/root 选择合同,完成任务后将详细报告写入[任务报告](./details/trans-download-scoped-help/R1.1_Task_Report.md)。 +### R1.2 [completed] + +实现 [#1744](https://github.com/pikasTech/unidesk/issues/1744) 的紧凑 download scoped help、可执行示例和当前 repo root 披露,完成任务后将详细报告写入[任务报告](./details/trans-download-scoped-help/R1.2_Task_Report.md)。 +### R1.3 [completed] + +完成 [#1744](https://github.com/pikasTech/unidesk/issues/1744) 的定向测试、原命令验收、提交 PR 与 preflight,完成任务后将详细报告写入[任务报告](./details/trans-download-scoped-help/R1.3_Task_Report.md)。 \ No newline at end of file diff --git a/scripts/cli.ts b/scripts/cli.ts index 48d86223..f1dc1188 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -27,7 +27,7 @@ import { withGhDefaultRendered } from "./src/gh/default-render"; import { isGhContentRoute, runGhContentRoute } from "./src/gh-route"; import { runGitToolsCommand } from "./src/git-tools"; import { runCommanderCommand } from "./src/commander"; -import { isHelpToken, rootHelp, serverHelp, sshHelp, staticNamespaceHelp } from "./src/help"; +import { isHelpToken, rootHelp, serverHelp, sshHelp, sshHelpScope, staticNamespaceHelp } from "./src/help"; import { healthHelp, runHealthCommand } from "./src/health"; import { runServerCleanupCommand } from "./src/server-cleanup"; import { runGcCommand } from "./src/gc"; @@ -236,7 +236,7 @@ async function main(): Promise { } if (top === "ssh" && (sub === undefined || isHelpToken(sub) || (isHelpToken(third) && args.length === 3))) { - emitJson(commandName, sshHelp()); + emitJson(commandName, sshHelp(sshHelpScope(args))); return; } diff --git a/scripts/src/help.ts b/scripts/src/help.ts index b14dda08..ac79a807 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -1,11 +1,13 @@ // SPEC: PJ2026-01060509 出站诊断 draft-2026-06-26-p8-egress-job-friction. // SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower. // Static CLI help for job aliases and server lifecycle progressive disclosure. +import { join } from "node:path"; import { ghHelp, ghScopedHelp } from "./gh"; import { authBrokerHelp } from "./auth-broker"; import { platformDbHelp } from "./platform-db"; import { secretsHelp } from "./secrets"; import { healthHelp } from "./health"; +import { repoRoot } from "./config"; export function rootHelp(): unknown { return { @@ -167,7 +169,16 @@ export function serverHelp(action: string | undefined = undefined): unknown { }; } -export function sshHelp(): unknown { +export type SshHelpScope = "download"; + +export function sshHelpScope(args: string[]): SshHelpScope | undefined { + const [top, helpToken, scope] = args; + if (top !== "ssh" || args.length !== 3 || !isHelpToken(helpToken)) return undefined; + return scope === "download" ? scope : undefined; +} + +export function sshHelp(scope: SshHelpScope | undefined = undefined): unknown { + if (scope === "download") return sshDownloadHelp(); return { command: "ssh", output: "json", @@ -255,6 +266,63 @@ export function sshHelp(): unknown { }; } +function sshDownloadHelp(): unknown { + const configuredRepoRoot = process.env.UNIDESK_TRANS_REPO_ROOT?.trim() || null; + const rawEntrypoint = process.env.UNIDESK_SSH_ENTRYPOINT?.trim(); + const entrypoint = rawEntrypoint === "trans" || rawEntrypoint === "tran" || rawEntrypoint === "ssh" + ? rawEntrypoint + : "ssh"; + return { + command: `${entrypoint} download`, + output: "json", + scope: "download", + description: "Download one remote file through the selected route and verify remote/local bytes plus SHA-256 automatically.", + runtime: { + entrypoint, + repoRoot, + cliPath: join(repoRoot, "scripts", "ssh-cli.ts"), + cwdSelectsRepoRoot: false, + rootSelection: { + environmentVariable: "UNIDESK_TRANS_REPO_ROOT", + configuredValue: configuredRepoRoot, + explicitExample: `UNIDESK_TRANS_REPO_ROOT=${repoRoot} trans --help download`, + note: "The PATH wrapper selects its source root independently of cwd; set UNIDESK_TRANS_REPO_ROOT explicitly when validating a worktree implementation.", + }, + }, + usage: [ + "trans download ", + "trans download --inactivity-timeout-ms ", + "trans --help download", + ], + options: { + "--inactivity-timeout-ms ": "Allow a controlled progress-emitting download to stay open while data continues flowing; ordinary trans operations retain the short runtime budget.", + }, + contract: { + pathOrder: ["remote-file", "local-file"], + transport: "host.ssh.tcp-pool", + strategy: "tcp-pool-stdout-stream", + progress: "unidesk.ssh.download.progress JSON is emitted on stderr with bytes, remainingBytes and throughputBytesPerSecond.", + verification: { + automatic: true, + fields: ["bytes", "sha256"], + success: "verified=true and verification.match.{bytes,sha256}=true", + partialFilePolicy: "The local target is removed when streaming or verification fails.", + }, + }, + examples: { + host: "trans D601:/tmp download /tmp/trace.json ./trace.json", + k3sWorkload: "trans NC01:k3s:hwlab-v03:hwlab-cloud-api:hwlab-cloud-api download /tmp/trace.json ./trace.json", + windowsDrive: "trans D601:win download 'D:\\tmp\\trace.json' ./trace.json", + explicitWorktree: `UNIDESK_TRANS_REPO_ROOT=${repoRoot} trans NC01:k3s:hwlab-v03:hwlab-cloud-api:hwlab-cloud-api download /tmp/trace.json ./trace.json`, + }, + notes: [ + "A k3s download requires a workload route; the control-plane-only :k3s route cannot name a remote file inside a workload.", + "Windows drive-letter paths are mapped through the same provider's WSL /mnt/ host route before verified stdout streaming.", + "The final JSON result is the integrity proof; a separate manual sha256sum comparison is not required.", + ], + }; +} + function configHelp(): unknown { return { command: "config show", diff --git a/scripts/src/ssh-help.test.ts b/scripts/src/ssh-help.test.ts new file mode 100644 index 00000000..d0b8cbd1 --- /dev/null +++ b/scripts/src/ssh-help.test.ts @@ -0,0 +1,85 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, test } from "bun:test"; +import { repoRoot } from "./config"; +import { sshHelp, sshHelpScope } from "./help"; + +describe("ssh download scoped help", () => { + test("selects only the exact help-first download scope", () => { + expect(sshHelpScope(["ssh", "--help", "download"])).toBe("download"); + expect(sshHelpScope(["ssh", "help", "download"])).toBe("download"); + expect(sshHelpScope(["ssh", "--help"])).toBeUndefined(); + expect(sshHelpScope(["ssh", "NC01:k3s", "--help"])).toBeUndefined(); + expect(sshHelpScope(["ssh", "--help", "download", "extra"])).toBeUndefined(); + }); + + test("keeps download help bounded and discloses the executing source root", () => { + const scoped = sshHelp("download") as { + scope: string; + runtime: { repoRoot: string; cliPath: string; cwdSelectsRepoRoot: boolean; rootSelection: { environmentVariable: string } }; + usage: string[]; + examples: Record; + contract: { pathOrder: string[]; transport: string; verification: { automatic: boolean } }; + }; + const full = sshHelp() as { usage: string[] }; + const serialized = JSON.stringify(scoped); + + expect(scoped.scope).toBe("download"); + expect(scoped.usage.length).toBeLessThanOrEqual(4); + expect(scoped.usage.length).toBeLessThan(full.usage.length); + expect(Buffer.byteLength(serialized, "utf8")).toBeLessThan(10_240); + expect(serialized).not.toContain("git exact-commit"); + expect(scoped.runtime.repoRoot).toBe(repoRoot); + expect(scoped.runtime.cliPath).toBe(`${repoRoot}/scripts/ssh-cli.ts`); + expect(scoped.runtime.cwdSelectsRepoRoot).toBe(false); + expect(scoped.runtime.rootSelection.environmentVariable).toBe("UNIDESK_TRANS_REPO_ROOT"); + expect(scoped.contract.pathOrder).toEqual(["remote-file", "local-file"]); + expect(scoped.contract.transport).toBe("host.ssh.tcp-pool"); + expect(scoped.contract.verification.automatic).toBe(true); + expect(scoped.examples.host).toContain("download /tmp/trace.json ./trace.json"); + expect(scoped.examples.k3sWorkload).toBe( + "trans NC01:k3s:hwlab-v03:hwlab-cloud-api:hwlab-cloud-api download /tmp/trace.json ./trace.json", + ); + expect(scoped.examples.windowsDrive).toContain("D:\\tmp\\trace.json"); + expect(scoped.examples.explicitWorktree).toBe( + `UNIDESK_TRANS_REPO_ROOT=${repoRoot} trans NC01:k3s:hwlab-v03:hwlab-cloud-api:hwlab-cloud-api download /tmp/trace.json ./trace.json`, + ); + }); + + test("renders the original trans command without stdout dump fallback", () => { + const result = spawnSync("bun", ["scripts/ssh-cli.ts", "--help", "download"], { + cwd: repoRoot, + env: { + ...process.env, + UNIDESK_SSH_ENTRYPOINT: "trans", + UNIDESK_TRANS_REPO_ROOT: repoRoot, + }, + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(Buffer.byteLength(result.stdout, "utf8")).toBeLessThan(10_240); + const rendered = JSON.parse(result.stdout) as { + ok: boolean; + command: string; + data: { scope: string; runtime: { repoRoot: string }; usage: string[]; outputTruncated?: boolean }; + }; + expect(rendered.ok).toBe(true); + expect(rendered.command).toBe("trans --help download"); + expect(rendered.data.scope).toBe("download"); + expect(rendered.data.runtime.repoRoot).toBe(repoRoot); + expect(rendered.data.usage.length).toBeLessThanOrEqual(4); + expect(rendered.data.outputTruncated).toBeUndefined(); + + const unified = spawnSync("bun", ["scripts/cli.ts", "ssh", "--help", "download"], { + cwd: repoRoot, + env: process.env, + encoding: "utf8", + }); + expect(unified.status).toBe(0); + expect(unified.stderr).toBe(""); + const unifiedRendered = JSON.parse(unified.stdout) as { data: { scope: string; runtime: { repoRoot: string } } }; + expect(unifiedRendered.data.scope).toBe("download"); + expect(unifiedRendered.data.runtime.repoRoot).toBe(repoRoot); + }); +}); diff --git a/scripts/ssh-cli.ts b/scripts/ssh-cli.ts index c436f3be..0afce9e0 100644 --- a/scripts/ssh-cli.ts +++ b/scripts/ssh-cli.ts @@ -1,5 +1,5 @@ import { readConfig } from "./src/config"; -import { isHelpToken, sshHelp } from "./src/help"; +import { isHelpToken, sshHelp, sshHelpScope } from "./src/help"; import { emitError, emitJson } from "./src/output"; import { extractRemoteCliOptions } from "./src/remote-options"; import { runRemoteSshCli } from "./src/remote-ssh"; @@ -30,7 +30,7 @@ async function main(): Promise { if (top !== "ssh") throw new Error(`ssh-cli supports only the ssh command, got: ${top || ""}`); if (sub === undefined || isHelpToken(sub) || (isHelpToken(third) && args.length === 3)) { - emitJson(commandName, sshHelp()); + emitJson(commandName, sshHelp(sshHelpScope(args))); return; }