mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
* feat(workspaces): gate fork repository automation Cross-repository change requests can execute contributor-controlled package hooks even when the setup command comes from the base repository. Persist the checkout provenance and require one explicit workspace-level setup action before repository automation runs. * fix(workspaces): clarify blocked script errors * fix(app): keep long toasts readable * test(server): expect fork repository provenance * test(server): make setup marker portable * test(app): retry transient sidebar layout
251 lines
8.7 KiB
TypeScript
251 lines
8.7 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./paseo-home-fork";
|
|
import { startIsolatedHostDaemon } from "./isolated-host-daemon";
|
|
|
|
export interface E2EWorker {
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
export interface E2EWorkerOptions {
|
|
forkProviders?: string[];
|
|
injectPaseoTools?: boolean;
|
|
daemonConfig?: Record<string, unknown>;
|
|
environment?: Record<string, string>;
|
|
}
|
|
|
|
function resolveOptionalHome(value: string | undefined): string | null {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) return null;
|
|
return resolvePaseoHomePath(trimmed === "current" ? "~/.paseo" : trimmed);
|
|
}
|
|
|
|
async function createFakeEditorBin(): Promise<string> {
|
|
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
|
let realGhPath = "";
|
|
try {
|
|
const locator = process.platform === "win32" ? "where.exe" : "which";
|
|
const candidates = execFileSync(locator, ["gh"], { encoding: "utf8" })
|
|
.split(/\r?\n/u)
|
|
.map((candidate) => candidate.trim())
|
|
.filter(Boolean);
|
|
realGhPath =
|
|
candidates.find(
|
|
(candidate) =>
|
|
process.platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(path.extname(candidate)),
|
|
) ?? "";
|
|
} catch {
|
|
// The local GitHub fixture remains usable without a system gh binary.
|
|
}
|
|
const fakeEditorSource = `#!/usr/bin/env node
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const recordPath = process.env.PASEO_E2E_EDITOR_RECORD_PATH;
|
|
if (recordPath) {
|
|
fs.appendFileSync(recordPath, JSON.stringify({
|
|
command: path.basename(process.argv[1]),
|
|
args: process.argv.slice(2),
|
|
cwd: process.cwd(),
|
|
at: Date.now()
|
|
}) + "\\n");
|
|
}
|
|
`;
|
|
for (const editorCommand of ["cursor", "code"]) {
|
|
const editorPath = path.join(binDir, editorCommand);
|
|
await writeFile(editorPath, fakeEditorSource);
|
|
await chmod(editorPath, 0o755);
|
|
if (process.platform === "win32") {
|
|
await writeFile(`${editorPath}.cmd`, `@node "%~dp0${editorCommand}" %*\r\n`);
|
|
}
|
|
}
|
|
|
|
const fakeGhPath = path.join(binDir, "gh");
|
|
const fakeGhSource = `#!/usr/bin/env node
|
|
const { spawnSync } = require("child_process");
|
|
const args = process.argv.slice(2);
|
|
const fixtureRemote = "https://github.com/paseo-e2e/local-fixture.git";
|
|
const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"]
|
|
}).stdout?.trim();
|
|
|
|
if (origin === fixtureRemote) {
|
|
const command = args.slice(0, 2).join(" ");
|
|
if (command === "auth status") process.exit(0);
|
|
if (command === "repo view") {
|
|
process.stdout.write(JSON.stringify({ owner: { login: "paseo-e2e" }, name: "local-fixture", parent: null }));
|
|
process.exit(0);
|
|
}
|
|
if (command === "issue list") {
|
|
process.stdout.write("[]");
|
|
process.exit(0);
|
|
}
|
|
if (command === "pr list" || command === "pr view") {
|
|
const isFork = args.includes("2");
|
|
const pr = {
|
|
number: isFork ? 2 : 1,
|
|
title: "Use pasted PR as start ref",
|
|
url: "https://github.com/paseo-e2e/local-fixture/pull/" + (isFork ? 2 : 1),
|
|
state: "OPEN",
|
|
body: null,
|
|
labels: [],
|
|
baseRefName: "main",
|
|
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
|
|
updatedAt: "2026-01-01T00:00:00Z"
|
|
};
|
|
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
|
process.exit(0);
|
|
}
|
|
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
|
|
const isFork = args.some((arg) => arg === "number=2");
|
|
process.stdout.write(JSON.stringify({
|
|
data: { repository: { pullRequest: {
|
|
number: isFork ? 2 : 1,
|
|
baseRefName: "main",
|
|
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
|
|
isCrossRepository: isFork,
|
|
headRepositoryOwner: { login: isFork ? "fork-owner" : "paseo-e2e" },
|
|
headRepository: {
|
|
sshUrl: isFork ? "git@github.com:fork-owner/local-fixture.git" : "git@github.com:paseo-e2e/local-fixture.git",
|
|
url: isFork ? "https://github.com/fork-owner/local-fixture" : fixtureRemote
|
|
}
|
|
} } }
|
|
}));
|
|
process.exit(0);
|
|
}
|
|
process.stderr.write("Unsupported local GitHub fixture command: " + args.join(" ") + "\\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
const realGhPath = ${JSON.stringify(realGhPath)};
|
|
if (!realGhPath) process.exit(127);
|
|
const result = spawnSync(realGhPath, args, { stdio: "inherit" });
|
|
process.exit(result.status ?? 1);
|
|
`;
|
|
await writeFile(fakeGhPath, fakeGhSource);
|
|
await chmod(fakeGhPath, 0o755);
|
|
if (process.platform === "win32") {
|
|
await writeFile(`${fakeGhPath}.cmd`, '@node "%~dp0gh" %*\r\n');
|
|
}
|
|
return binDir;
|
|
}
|
|
|
|
async function applyMetadataFork(targetHome: string, providerIds: string[]): Promise<void> {
|
|
const sourceHome = resolveOptionalHome(process.env.E2E_FORK_PASEO_HOME_FROM);
|
|
if (!sourceHome) return;
|
|
const result = await forkPaseoHomeMetadata({ sourceHome, targetHome });
|
|
process.env.E2E_FORK_SOURCE_PASEO_HOME = result.sourceHome;
|
|
process.env.E2E_FORK_TARGET_PASEO_HOME = result.targetHome;
|
|
process.env.E2E_FORK_COPIED_FILES = String(result.copiedFiles);
|
|
process.env.E2E_FORK_COPIED_BYTES = String(result.copiedBytes);
|
|
|
|
if (providerIds.length === 0) return;
|
|
|
|
const sourceConfig = JSON.parse(
|
|
await readFile(path.join(result.sourceHome, "config.json"), "utf8"),
|
|
);
|
|
const sourceProviders = sourceConfig.agents?.providers ?? {};
|
|
const providers = Object.fromEntries(
|
|
providerIds.map((providerId: string) => {
|
|
const provider = sourceProviders[providerId];
|
|
if (!provider) {
|
|
throw new Error(`E2E provider '${providerId}' is not configured in ${result.sourceHome}`);
|
|
}
|
|
return [providerId, provider];
|
|
}),
|
|
);
|
|
await writeFile(
|
|
path.join(targetHome, "config.json"),
|
|
`${JSON.stringify({ version: 1, agents: { providers } }, null, 2)}\n`,
|
|
);
|
|
}
|
|
|
|
export async function startE2EWorker(
|
|
workerIndex: number,
|
|
options: E2EWorkerOptions = {},
|
|
): Promise<E2EWorker> {
|
|
const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME);
|
|
const paseoHome = requestedRoot
|
|
? path.join(requestedRoot, `worker-${workerIndex}`)
|
|
: await mkdtemp(path.join(tmpdir(), `paseo-e2e-worker-${workerIndex}-`));
|
|
const preserveHome = Boolean(requestedRoot) || process.env.E2E_KEEP_PASEO_HOME === "1";
|
|
const fakeEditorBin = await createFakeEditorBin();
|
|
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
|
|
const serverId = `srv_e2e_worker_${workerIndex}`;
|
|
|
|
try {
|
|
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
|
|
// Worker-scoped fixture config lets a spec exercise provider discovery without
|
|
// reading the developer's provider state or sharing configuration with other specs.
|
|
if (options.daemonConfig) {
|
|
await writeFile(
|
|
path.join(paseoHome, "config.json"),
|
|
`${JSON.stringify(options.daemonConfig, null, 2)}\n`,
|
|
);
|
|
}
|
|
if (options.injectPaseoTools) {
|
|
await enablePaseoTools(paseoHome);
|
|
}
|
|
const daemon = await startIsolatedHostDaemon(serverId, {
|
|
paseoHome,
|
|
preserveHome,
|
|
environment: {
|
|
NODE_ENV: "development",
|
|
PATH: `${fakeEditorBin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
PASEO_E2E_EDITOR_RECORD_PATH: editorRecordPath,
|
|
...options.environment,
|
|
},
|
|
});
|
|
|
|
process.env.E2E_DAEMON_PORT = String(daemon.port);
|
|
process.env.E2E_SERVER_ID = daemon.serverId;
|
|
process.env.E2E_PASEO_HOME = daemon.paseoHome;
|
|
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
|
|
delete process.env.E2E_RELAY_PORT;
|
|
delete process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
|
|
|
console.log(
|
|
`[e2e] Worker ${workerIndex} daemon started on port ${daemon.port}, home: ${daemon.paseoHome}`,
|
|
);
|
|
return {
|
|
close: async () => {
|
|
await daemon.close();
|
|
await rm(fakeEditorBin, { recursive: true, force: true });
|
|
console.log(`[e2e] Worker ${workerIndex} daemon stopped`);
|
|
},
|
|
};
|
|
} catch (error) {
|
|
await rm(fakeEditorBin, { recursive: true, force: true });
|
|
if (!preserveHome) await rm(paseoHome, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function enablePaseoTools(paseoHome: string): Promise<void> {
|
|
const configPath = path.join(paseoHome, "config.json");
|
|
const existing = existsSync(configPath)
|
|
? JSON.parse(await readFile(configPath, "utf8"))
|
|
: { version: 1 };
|
|
await writeFile(
|
|
configPath,
|
|
`${JSON.stringify(
|
|
{
|
|
...existing,
|
|
daemon: {
|
|
...existing.daemon,
|
|
mcp: {
|
|
...existing.daemon?.mcp,
|
|
enabled: true,
|
|
injectIntoAgents: true,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
}
|