fix: improve trans script migration hint
This commit is contained in:
@@ -66,6 +66,8 @@ function normalizeErrorPayload(command: string, error: unknown): Record<string,
|
||||
return { message };
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const structured = structuredCliErrorPayload(error, message);
|
||||
if (structured !== null) return structured;
|
||||
const debug = process.env.UNIDESK_CLI_DEBUG === "1" || process.env.UNIDESK_CLI_FULL_ERROR === "1" || process.env.UNIDESK_CLI_RAW_ERROR === "1";
|
||||
return {
|
||||
name: error.name,
|
||||
@@ -78,6 +80,20 @@ function normalizeErrorPayload(command: string, error: unknown): Record<string,
|
||||
return { message };
|
||||
}
|
||||
|
||||
function structuredCliErrorPayload(error: Error, message: string): Record<string, unknown> | null {
|
||||
const record = error as Error & Record<string, unknown>;
|
||||
if (!Object.prototype.hasOwnProperty.call(record, "replacementExamples") && !Object.prototype.hasOwnProperty.call(record, "migrationHint")) return null;
|
||||
const payload: Record<string, unknown> = {
|
||||
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<string, unknown> | null {
|
||||
if (error.name !== "SshFileTransferError") return null;
|
||||
const details = (error as Error & { details?: unknown }).details;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+66
-10
@@ -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 = "<route>"): 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>" : command;
|
||||
return {
|
||||
posixSh: `${entrypoint} ${route} sh -- ${shellQuote(commandText)}`,
|
||||
bash: `${entrypoint} ${route} bash -- ${shellQuote(commandText)}`,
|
||||
templatePosixSh: `${entrypoint} ${route} sh -- '<command>'`,
|
||||
templateBash: `${entrypoint} ${route} bash -- '<command>'`,
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
+8
-1
@@ -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:");
|
||||
}
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
Reference in New Issue
Block a user