From 4d4feacb31d971a0aff08eea7725fe6476fdb482 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 20 Jun 2026 07:23:11 +0000 Subject: [PATCH] fix: improve trans script migration hint --- docs/reference/cli.md | 4 +-- scripts/src/output.ts | 16 +++++++++ scripts/src/ssh.test.ts | 35 +++++++++++++++++++ scripts/src/ssh.ts | 76 +++++++++++++++++++++++++++++++++++------ scripts/ssh-cli.ts | 9 ++++- scripts/tran | 4 +++ scripts/trans | 2 ++ 7 files changed, 133 insertions(+), 13 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7de923d5..18cb47e6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -251,7 +251,7 @@ exec /root/unidesk/scripts/trans "$@" 主 server 上的人工/Codex 分布式敏捷操作必须直接写 `trans ...`,不要在 Codex 工具调用里退回完整 `bun scripts/cli.ts ssh ...` 前缀。例如 `trans D601:/home/ubuntu/workspace/hwlab-dev git status --short --branch`、`trans D601:k3s kubectl get pods -n hwlab-dev` 或 `trans D601:k3s:hwlab-dev:hwlab-cloud-web exec --cwd /tmp -- pwd`。`tran` 是历史兼容 wrapper 和 runner 固化入口;新写长期参考、AGENTS 索引和 CLI help 时优先写 `trans ...`。 -`trans` 同样遵守 route/operation 解析器;route 后面的第一个 token 不是原生 ssh 命令字符串。带变量展开、管道、重定向或多条命令的远端逻辑,默认使用 `trans G14:/root/hwlab sh <<'SH'`;`sh` 走目标节点 `/bin/sh`,并继承 provider-gateway/G14 已长期化的 proxy 环境。需要 Bash 专有语法,例如 `set -o pipefail`、数组、`[[ ... ]]` 或 `${var:0:8}` 子串展开时,必须把 operation 写成 `bash`,例如 `trans D601:/home/ubuntu/workspace/hwlab-v03 bash <<'BASH'`。需要临时单步执行一行远端 shell 逻辑、且不想先创建脚本文件或 heredoc 时,使用 `trans G14:/root/hwlab sh -- 'sed -n "1,20p" a && sed -n "1,20p" b'` 或 `bash -- ''`,CLI 会把单个字符串放进目标节点对应 shell 的 `-c`,第二个 `sed`、管道和重定向都会留在远端;`sh --` 与 `bash --` 只接受一个 shell command 字符串,多 argv 单进程命令必须走 `argv`、`exec` 或已知直接子命令。`script` 和 `shell` operation 已移除并会失败;不得用 `--shell bash` 或旧名绕过显式 shell 选择。`sh` 和 `bash` helper 会在用户 shell 文本前注入一个极小的 POSIX 兼容 `printf` wrapper,使 `printf "--- section ---\n"` 这类高频排障分隔标题在 dash/sh 与 bash 下行为一致;direct argv 形态不注入该 wrapper。单进程命令才直接写成 argv,例如 `trans G14:/root/hwlab git status --short --branch`。遇到分布式开发摩擦时,优先补强 `trans`/`tran` 的 route/operation、stdin helper 或目标节点环境,并把稳定解法写回长期参考文档,不要退回多层 shell 字符串拼接。 +`trans` 同样遵守 route/operation 解析器;route 后面的第一个 token 不是原生 ssh 命令字符串。带变量展开、管道、重定向或多条命令的远端逻辑,默认使用 `trans G14:/root/hwlab sh <<'SH'`;`sh` 走目标节点 `/bin/sh`,并继承 provider-gateway/G14 已长期化的 proxy 环境。需要 Bash 专有语法,例如 `set -o pipefail`、数组、`[[ ... ]]` 或 `${var:0:8}` 子串展开时,必须把 operation 写成 `bash`,例如 `trans D601:/home/ubuntu/workspace/hwlab-v03 bash <<'BASH'`。需要临时单步执行一行远端 shell 逻辑、且不想先创建脚本文件或 heredoc 时,使用 `trans G14:/root/hwlab sh -- 'sed -n "1,20p" a && sed -n "1,20p" b'` 或 `bash -- ''`,CLI 会把单个字符串放进目标节点对应 shell 的 `-c`,第二个 `sed`、管道和重定向都会留在远端;`sh --` 与 `bash --` 只接受一个 shell command 字符串,多 argv 单进程命令必须走 `argv`、`exec` 或已知直接子命令。`script` 和 `shell` operation 已移除并会失败;失败 JSON 必须保留用户入口名(`trans`/`tran`/`ssh`)、route、operation 和 `replacementExamples`,其中包含 `trans sh -- ''` 与 `trans bash -- ''` 形态;不得用 `--shell bash` 或旧名绕过显式 shell 选择。`sh` 和 `bash` helper 会在用户 shell 文本前注入一个极小的 POSIX 兼容 `printf` wrapper,使 `printf "--- section ---\n"` 这类高频排障分隔标题在 dash/sh 与 bash 下行为一致;direct argv 形态不注入该 wrapper。单进程命令才直接写成 argv,例如 `trans G14:/root/hwlab git status --short --branch`。遇到分布式开发摩擦时,优先补强 `trans`/`tran` 的 route/operation、stdin helper 或目标节点环境,并把稳定解法写回长期参考文档,不要退回多层 shell 字符串拼接。 本地检索或引用 Markdown 命令片段时也要避免外层 shell 误解析。任何包含反引号的 pattern,例如文档里的 `` `trans ...` ``,都必须用单引号、`rg -F -e` 或 stdin/file 传入;禁止写进双引号参数,因为 shell 会先执行 backtick command substitution,可能在搜索旧文档时反而误触 `trans ... script` 这类已移除 operation。 @@ -260,7 +260,7 @@ exec /root/unidesk/scripts/trans "$@" - 长期参考、AGENTS 索引、CLI help、Codex 任务脚本、CI/CD 排障和人工远端操作必须统一把已知的远端 workspace 写在 route 的第一个 token,而不是塞进 `cd` 串。`route` 段只表达分布式定位,operation 段才执行命令;workspace 路径是定位信息,不是命令。 - 标准形态是 `trans :/absolute/workspace [args...]`:例如 `trans G14:/root/hwlab git status --short --branch`、`trans G14:/root/hwlab-v02 sh -- 'git fetch origin v0.2 && git pull --ff-only origin v0.2'`、`trans D601:/home/ubuntu/workspace/unidesk-dev sh <<'SH'`、`trans D601:/home/ubuntu/workspace/hwlab-v03 bash <<'BASH'`、`trans G14:/root/hwlab apply-patch < patch.diff` 和 `trans G14:/root/hwlab glob --root . --pattern 'web/hwlab-cloud-web/*.ts' --contains session-tabs`。 -- 反面形态必须删除或迁移:`trans G14 sh -- 'cd /root/hwlab && git status --short --branch'`、`trans G14 sh <<'SH' cd /root/hwlab-v02 git fetch origin v0.2 SH`、`tran G14 sh -- 'cd /home/ubuntu/workspace/unidesk-dev && ...'`、`trans G14 script -- '...'`、`trans G14 shell '...'`、`bun scripts/cli.ts ssh G14 -- 'cd /root/hwlab && ...'`。这些写法把已知 workspace 写进 command 字符串,破坏 route/operation 分离,引入本地 shell 二次解析、远端 cwd 漂移和并行 worktree 切换摩擦,或继续使用已移除的模糊 shell 旧名。 +- 反面形态必须删除或迁移:`trans G14 sh -- 'cd /root/hwlab && git status --short --branch'`、`trans G14 sh <<'SH' cd /root/hwlab-v02 git fetch origin v0.2 SH`、`tran G14 sh -- 'cd /home/ubuntu/workspace/unidesk-dev && ...'`、route 后继续写旧的 `script` / `shell` operation、`bun scripts/cli.ts ssh G14 -- 'cd /root/hwlab && ...'`。这些写法把已知 workspace 写进 command 字符串,破坏 route/operation 分离,引入本地 shell 二次解析、远端 cwd 漂移和并行 worktree 切换摩擦,或继续使用已移除的模糊 shell 旧名。 - 例外只限于一次性探测、临时 heredoc 草稿或旧文档复用;任何被复用第二次的 `cd && ...` 都必须重写成 `trans :/absolute/workspace` 形式。 - 当远端存在多个并行 workspace(例如 `G14:/root/hwlab` 与 `G14:/root/hwlab-v02`)时,route 必须显式带 workspace,CLI 的 `pwd` 输出、后续 `apply-patch` 的相对路径和 `sh`/`bash` 的 cwd 全部跟随该 workspace;切换 workspace 必须切换 route,不允许在同一次 `trans` 链里再 `cd`。 - 本规则覆盖所有 host workspace 形态,包括 `G14:/root/hwlab`、`G14:/root/hwlab-v02`、`G14:/root/agentrun-v01`、`D601:/home/ubuntu/workspace/unidesk-dev`、`D601:/home/ubuntu/workspace/hwlab-dev`;provider-gateway 侧已经把它们注册为 host workspace route。 diff --git a/scripts/src/output.ts b/scripts/src/output.ts index 6c02c009..4d906187 100644 --- a/scripts/src/output.ts +++ b/scripts/src/output.ts @@ -66,6 +66,8 @@ function normalizeErrorPayload(command: string, error: unknown): Record | null { + const record = error as Error & Record; + if (!Object.prototype.hasOwnProperty.call(record, "replacementExamples") && !Object.prototype.hasOwnProperty.call(record, "migrationHint")) return null; + const payload: Record = { + name: error.name, + message, + }; + for (const key of ["code", "level", "entrypoint", "route", "operation", "replacementExamples", "migrationHint", "note"]) { + const value = record[key]; + if (value !== undefined) payload[key] = value; + } + return payload; +} + function sshFileTransferErrorDetails(error: Error): Record | null { if (error.name !== "SshFileTransferError") return null; const details = (error as Error & { details?: unknown }).details; diff --git a/scripts/src/ssh.test.ts b/scripts/src/ssh.test.ts index 47f64fcc..b3704e10 100644 --- a/scripts/src/ssh.test.ts +++ b/scripts/src/ssh.test.ts @@ -74,3 +74,38 @@ describe("ssh stdout bounded streaming", () => { expect(hint).toContain("\"transport\":\"frontend-websocket\""); }); }); + +describe("ssh removed shell aliases", () => { + test("reports route-aware replacement examples for trans script", () => { + const previousEntrypoint = process.env.UNIDESK_SSH_ENTRYPOINT; + process.env.UNIDESK_SSH_ENTRYPOINT = "trans"; + try { + parseSshInvocation("D601:/tmp", ["script", "--", "pwd"]); + throw new Error("expected script alias to fail"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const payload = error as Error & { + code?: string; + entrypoint?: string; + route?: string; + operation?: string; + replacementExamples?: { + posixSh?: string; + bash?: string; + }; + }; + expect(payload.code).toBe("ssh-removed-shell-alias"); + expect(payload.entrypoint).toBe("trans"); + expect(payload.route).toBe("D601:/tmp"); + expect(payload.operation).toBe("script"); + expect(payload.replacementExamples?.posixSh).toBe("trans D601:/tmp sh -- 'pwd'"); + expect(payload.replacementExamples?.bash).toBe("trans D601:/tmp bash -- 'pwd'"); + } finally { + if (previousEntrypoint === undefined) { + delete process.env.UNIDESK_SSH_ENTRYPOINT; + } else { + process.env.UNIDESK_SSH_ENTRYPOINT = previousEntrypoint; + } + } + }); +}); diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 2d0a5e34..25b8f7c9 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -118,6 +118,36 @@ export interface SshRuntimeTimeoutHint { note: string; } +export interface RemovedShellAliasReplacementExamples { + posixSh: string; + bash: string; + templatePosixSh: string; + templateBash: string; +} + +export class SshRemovedShellAliasError extends Error { + code = "ssh-removed-shell-alias"; + level = "error"; + entrypoint: string; + route: string; + operation: string; + replacementExamples: RemovedShellAliasReplacementExamples; + migrationHint: string; + note = "`sh` runs POSIX /bin/sh; `bash` is only for Bash-specific syntax. The removed alias intentionally no longer chooses a hidden shell dialect."; + + constructor(operation: string, route: string, operationArgs: string[]) { + const entrypoint = sshDisplayEntrypoint(); + const replacementExamples = removedShellAliasReplacementExamples(entrypoint, route, operationArgs); + super(`${entrypoint} ${route} ${operation} operation has been removed because it hides the shell dialect; use explicit sh for POSIX /bin/sh or bash for Bash syntax`); + this.name = "SshRemovedShellAliasError"; + this.entrypoint = entrypoint; + this.route = route; + this.operation = operation; + this.replacementExamples = replacementExamples; + this.migrationHint = `Rewrite as ${replacementExamples.posixSh} for POSIX shell, or ${replacementExamples.bash} for Bash syntax.`; + } +} + export interface SshStdoutTruncationHint { code: "ssh-stdout-truncated"; level: "warning"; @@ -920,7 +950,7 @@ export function isSshSkillDiscoveryArgs(args: string[]): boolean { return subcommand === "skills" || subcommand === "skill-discover" || subcommand === "discover-skills" || (subcommand === "skill" && args[1] === "discover"); } -export function parseSshArgs(args: string[]): ParsedSshArgs { +export function parseSshArgs(args: string[], routeRaw = ""): ParsedSshArgs { const subcommand = args[0] ?? ""; if (isSshFileTransferOperation(args)) { return { remoteCommand: null, requiresStdin: false, invocationKind: "helper" }; @@ -949,7 +979,7 @@ export function parseSshArgs(args: string[]): ParsedSshArgs { return { remoteCommand: null, requiresStdin: true, invocationKind: "helper" }; } if (subcommand === "script" || subcommand === "shell") { - throw removedShellAliasError(subcommand); + throw removedShellAliasError(subcommand, routeRaw, args.slice(1)); } if (subcommand === "sh" || subcommand === "bash") { return buildShellCommand(args.slice(1), subcommand, `ssh ${subcommand}`); @@ -1035,7 +1065,7 @@ export function parseSshInvocation(target: string, args: string[]): ParsedSshInv if ((operationArgs[0] ?? "") === "k3s") { throw new Error(`ssh k3s shorthand is unsupported; use route syntax instead: trans ${route.providerId}:k3s ${operationArgs.slice(1).join(" ")}`.trim()); } - return { providerId: route.providerId, route, parsed: parseSshArgs(operationArgs) }; + return { providerId: route.providerId, route, parsed: parseSshArgs(operationArgs, route.raw) }; } export function parseSshRoute(target: string): ParsedSshRoute { @@ -1669,7 +1699,7 @@ function parseK3sControlPlaneOperation(route: ParsedSshRoute, args: string[]): P throw new Error("remote patch entrypoints are `apply-patch` for the default v2 engine and `apply-patch-v1` for the legacy helper"); } if (operation === "script" || operation === "shell") { - throw removedShellAliasError(operation); + throw removedShellAliasError(operation, route.raw, args.slice(1)); } if (operation === "sh" || operation === "bash") { return buildK3sScriptOperation(args.slice(1), operation, `ssh ${route.providerId}:k3s ${operation}`); @@ -1678,7 +1708,7 @@ function parseK3sControlPlaneOperation(route: ParsedSshRoute, args: string[]): P if (args.length > 1) throw new Error(`ssh ${route.providerId}:k3s guard does not accept extra arguments`); return { remoteCommand: buildK3sGuardCommand(route.providerId), requiresStdin: false, invocationKind: "helper" }; } - return { remoteCommand: buildK3sCommand(route.providerId, args), requiresStdin: false, invocationKind: "helper" }; + return { remoteCommand: buildK3sCommand(route.providerId, route.raw, args), requiresStdin: false, invocationKind: "helper" }; } function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedSshArgs { @@ -1706,7 +1736,7 @@ function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedS throw new Error("remote patch entrypoints are `apply-patch` for the default v2 engine and `apply-patch-v1` for the legacy helper"); } if (operation === "script" || operation === "shell") { - throw removedShellAliasError(operation); + throw removedShellAliasError(operation, route.raw, operationArgs); } if (operation === "sh" || operation === "bash") { return buildK3sScriptOperation([...targetArgs, ...operationArgs], operation, `ssh ${route.raw} ${operation}`); @@ -1831,7 +1861,7 @@ function effectiveApplyPatchV2Invocation(invocation: ParsedSshInvocation, argv: }; } -function buildK3sCommand(providerId: string, args: string[]): string { +function buildK3sCommand(providerId: string, routeRaw: string, args: string[]): string { const action = args[0] ?? ""; if (action.length === 0 || action === "--help" || action === "-h" || action === "help") { throw new Error("ssh k3s requires a subcommand: guard, kubectl, get, describe, logs or exec"); @@ -1839,7 +1869,7 @@ function buildK3sCommand(providerId: string, args: string[]): string { if (action === "guard") return buildK3sGuardCommand(providerId); if (action === "exec") return buildK3sExecCommand(args.slice(1)); if (action === "script" || action === "shell") { - throw removedShellAliasError(action); + throw removedShellAliasError(action, routeRaw, args.slice(1)); } if (action === "sh" || action === "bash") { const parsed = buildK3sScriptOperation(args.slice(1), action, `ssh k3s ${action}`); @@ -1920,8 +1950,34 @@ function buildK3sExecCommand(args: string[]): string { return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]); } -function removedShellAliasError(operation: string): Error { - return new Error(`ssh ${operation} operation has been removed because it hides the shell dialect; use explicit \`sh\` for POSIX /bin/sh or \`bash\` for Bash syntax`); +function removedShellAliasError(operation: string, routeRaw: string, operationArgs: string[]): Error { + return new SshRemovedShellAliasError(operation, routeRaw, operationArgs); +} + +function sshDisplayEntrypoint(): string { + const raw = process.env.UNIDESK_SSH_ENTRYPOINT?.trim(); + return raw === "trans" || raw === "tran" || raw === "ssh" ? raw : "ssh"; +} + +function removedShellAliasReplacementExamples(entrypoint: string, route: string, operationArgs: string[]): RemovedShellAliasReplacementExamples { + const command = removedShellAliasInlineCommand(operationArgs); + const commandText = command === null ? "" : command; + return { + posixSh: `${entrypoint} ${route} sh -- ${shellQuote(commandText)}`, + bash: `${entrypoint} ${route} bash -- ${shellQuote(commandText)}`, + templatePosixSh: `${entrypoint} ${route} sh -- ''`, + templateBash: `${entrypoint} ${route} bash -- ''`, + }; +} + +function removedShellAliasInlineCommand(operationArgs: string[]): string | null { + if (operationArgs.length === 0) return null; + const separatorIndex = operationArgs.indexOf("--"); + if (separatorIndex >= 0) { + const command = operationArgs.slice(separatorIndex + 1); + return command.length === 1 ? command[0] ?? null : null; + } + return operationArgs.length === 1 && !(operationArgs[0] ?? "").startsWith("-") ? operationArgs[0] ?? null : null; } function buildK3sScriptOperation(args: string[], shell: "sh" | "bash", commandName: string): ParsedSshArgs { diff --git a/scripts/ssh-cli.ts b/scripts/ssh-cli.ts index 975b35ce..c436f3be 100644 --- a/scripts/ssh-cli.ts +++ b/scripts/ssh-cli.ts @@ -7,13 +7,20 @@ import { runSsh } from "./src/ssh"; const remoteOptions = extractRemoteCliOptions(process.argv.slice(2)); const args = normalizeSshCommandArgs(remoteOptions.args); -const commandName = args.join(" ") || "ssh"; +const commandName = displayCommandName(args); function normalizeSshCommandArgs(rawArgs: string[]): string[] { if (rawArgs[0] === "ssh") return rawArgs; return ["ssh", ...rawArgs]; } +function displayCommandName(normalizedArgs: string[]): string { + const rawEntrypoint = process.env.UNIDESK_SSH_ENTRYPOINT?.trim(); + const entrypoint = rawEntrypoint === "trans" || rawEntrypoint === "tran" || rawEntrypoint === "ssh" ? rawEntrypoint : "ssh"; + const displayArgs = normalizedArgs[0] === "ssh" ? normalizedArgs.slice(1) : normalizedArgs; + return [entrypoint, ...displayArgs].join(" ") || entrypoint; +} + function isGhContentRouteTarget(target: string | undefined): boolean { return typeof target === "string" && target.startsWith("gh:"); } diff --git a/scripts/tran b/scripts/tran index e3d732ff..db85449c 100755 --- a/scripts/tran +++ b/scripts/tran @@ -49,8 +49,12 @@ if [ -n "${CODE_QUEUE_SERVICE_ROLE:-}" ] || [ -n "${CODE_QUEUE_INSTANCE_ID:-}" ] fi if [ "$runner_env" = 1 ] && [ -n "$host" ] && [ "${UNIDESK_TRAN_LOCAL:-}" != "1" ]; then + UNIDESK_SSH_ENTRYPOINT=${UNIDESK_SSH_ENTRYPOINT:-tran} + export UNIDESK_SSH_ENTRYPOINT bun "$repo/scripts/ssh-cli.ts" --main-server-ip "$host" ssh "$@" exit $? fi +UNIDESK_SSH_ENTRYPOINT=${UNIDESK_SSH_ENTRYPOINT:-tran} +export UNIDESK_SSH_ENTRYPOINT bun "$repo/scripts/ssh-cli.ts" ssh "$@" diff --git a/scripts/trans b/scripts/trans index ffdc38d3..5baffbce 100755 --- a/scripts/trans +++ b/scripts/trans @@ -7,4 +7,6 @@ if [ ! -f "$repo/scripts/ssh-cli.ts" ]; then repo=$(CDPATH= cd -- "$self_dir/.." && pwd) fi +UNIDESK_SSH_ENTRYPOINT=${UNIDESK_SSH_ENTRYPOINT:-trans} +export UNIDESK_SSH_ENTRYPOINT exec bun "$repo/scripts/ssh-cli.ts" ssh "$@"