Merge remote-tracking branch 'origin/master' into fix/1726-pac-source-artifact-sync

This commit is contained in:
Codex
2026-07-11 06:52:30 +02:00
22 changed files with 690 additions and 18 deletions
+157
View File
@@ -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 状态");
});
});
+7 -2
View File
@@ -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)}`,
+4
View File
@@ -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 并确认其为 opendraft 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
View File
@@ -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);
+1 -1
View File
@@ -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;
+7 -3
View File
@@ -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:");
+169
View File
@@ -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,
};
}
+1
View File
@@ -595,6 +595,7 @@ export interface GitHubIssueListResult {
export interface GitHubPullRequest {
id: number;
node_id?: string;
number: number;
title: string;
body: string | null;
+122 -3
View File
@@ -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;
+5
View File
@@ -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(() => {
+17
View File
@@ -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({
+16
View File
@@ -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
View File
@@ -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";