247 lines
7.9 KiB
TypeScript
247 lines
7.9 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { existsSync, rmSync } from "node:fs";
|
|
import { mkdtemp } from "node:fs/promises";
|
|
import { platform, tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import arg from "arg";
|
|
import { config } from "dotenv";
|
|
import type { AgentResult } from "./agents/shared.ts";
|
|
import { type Inputs, main } from "./main.ts";
|
|
import { defineFixture } from "./test/utils.ts";
|
|
import { log } from "./utils/cli.ts";
|
|
import { setupTestRepo } from "./utils/setup.ts";
|
|
|
|
/**
|
|
* default play fixture for ad-hoc testing.
|
|
* change this freely without affecting any tests.
|
|
*/
|
|
export const playFixture = defineFixture(
|
|
{
|
|
prompt: `What is 2 + 2? Reply with just the number.`,
|
|
effort: "mini",
|
|
},
|
|
{ localOnly: true }
|
|
);
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
// load action's .env file in case it exists for local dev
|
|
config();
|
|
// also load .env from repo root (for monorepo structure)
|
|
config({ path: join(__dirname, "..", ".env") });
|
|
|
|
export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult> {
|
|
// create unique temp directory path in OS temp location for parallel execution
|
|
// use a parent dir from mkdtemp, then clone into a 'repo' subdirectory
|
|
const tempParent = await mkdtemp(join(tmpdir(), "pullfrog-play-"));
|
|
const tempDir = join(tempParent, "repo");
|
|
const originalCwd = process.cwd();
|
|
|
|
try {
|
|
setupTestRepo({ tempDir });
|
|
process.chdir(tempDir);
|
|
// set GITHUB_WORKSPACE to tempDir so main() doesn't try to chdir to the CI checkout path
|
|
process.env.GITHUB_WORKSPACE = tempDir;
|
|
|
|
// allow passing full Inputs object or just a prompt string
|
|
const inputs: Inputs =
|
|
typeof inputsOrPrompt === "string" ? { prompt: inputsOrPrompt } : inputsOrPrompt;
|
|
|
|
// set INPUT_* env vars for @actions/core.getInput()
|
|
for (const [key, value] of Object.entries(inputs)) {
|
|
if (value !== undefined && value !== null) {
|
|
process.env[`INPUT_${key.toUpperCase()}`] = String(value);
|
|
}
|
|
}
|
|
|
|
const result = await main();
|
|
|
|
process.chdir(originalCwd);
|
|
|
|
if (result.success) {
|
|
log.success("Action completed successfully");
|
|
return { success: true, output: result.output || undefined, error: undefined };
|
|
} else {
|
|
log.error(`Action failed: ${result.error || "Unknown error"}`);
|
|
return { success: false, error: result.error || undefined, output: undefined };
|
|
}
|
|
} catch (err) {
|
|
const errorMessage = (err as Error).message;
|
|
log.error(`Error: ${errorMessage}`);
|
|
return { success: false, error: errorMessage, output: undefined };
|
|
} finally {
|
|
// cleanup temp directory
|
|
process.chdir(originalCwd);
|
|
rmSync(tempParent, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
const args = arg({
|
|
"--help": Boolean,
|
|
"--raw": String,
|
|
"--local": Boolean,
|
|
"-h": "--help",
|
|
"-l": "--local",
|
|
});
|
|
|
|
if (args["--help"]) {
|
|
log.info(`
|
|
Usage: node play.ts [options]
|
|
|
|
Test the Pullfrog action with the inline playFixture.
|
|
|
|
Options:
|
|
--raw [prompt] Use raw string as prompt instead of playFixture
|
|
--local, -l Run locally (default: runs in Docker)
|
|
-h, --help Show this help message
|
|
|
|
Environment:
|
|
PLAY_LOCAL=1 Same as --local
|
|
PLAY_FIXTURE JSON fixture passed by test runner (internal)
|
|
|
|
Examples:
|
|
node play.ts # Run inline playFixture
|
|
node play.ts --raw "Hello world" # Use raw string as prompt
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
|
|
// default: run in Docker (unless --local or PLAY_LOCAL=1 or already inside Docker)
|
|
const isInsideDocker = existsSync("/.dockerenv");
|
|
const useLocal = args["--local"] || process.env.PLAY_LOCAL === "1" || isInsideDocker;
|
|
|
|
if (!useLocal) {
|
|
log.info("» running in Docker container...");
|
|
|
|
const passArgs = process.argv
|
|
.slice(2)
|
|
// shell-escape each argument to handle special characters in JSON payloads
|
|
.map((arg) => `'${arg.replace(/'/g, "'\\''")}'`)
|
|
.join(" ");
|
|
const nodeCmd = `node play.ts ${passArgs}`;
|
|
|
|
// pass all env vars to docker
|
|
const envFlags = Object.entries(process.env).flatMap(([key, value]) =>
|
|
value !== undefined ? ["-e", `${key}=${value}`] : []
|
|
);
|
|
|
|
// SSH for git - platform-specific handling
|
|
const sshFlags: string[] = [];
|
|
let sshSetupCmd = "";
|
|
const plat = platform();
|
|
const home = process.env.HOME;
|
|
|
|
if (plat === "win32") {
|
|
throw new Error(
|
|
"Docker mode is not supported on native Windows. Use WSL2 or set PLAY_LOCAL=1."
|
|
);
|
|
} else if (plat === "darwin") {
|
|
// macOS: Docker Desktop SSH agent forwarding
|
|
if (home) {
|
|
const knownHostsPath = join(home, ".ssh", "known_hosts");
|
|
if (existsSync(knownHostsPath)) {
|
|
sshFlags.push("-v", `${knownHostsPath}:/root/.ssh/known_hosts:ro`);
|
|
}
|
|
}
|
|
sshFlags.push(
|
|
"-v",
|
|
"/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock",
|
|
"-e",
|
|
"SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock"
|
|
);
|
|
} else {
|
|
// Linux/WSL: copy .ssh files into container with correct permissions
|
|
if (home) {
|
|
const sshDir = join(home, ".ssh");
|
|
if (existsSync(sshDir)) {
|
|
sshFlags.push("-v", `${sshDir}:/tmp/.ssh-host:ro`);
|
|
// copy ssh keys, add github.com to known_hosts, set GIT_SSH_COMMAND to use them
|
|
sshSetupCmd =
|
|
"mkdir -p /tmp/home/.ssh && cp /tmp/.ssh-host/id_* /tmp/home/.ssh/ 2>/dev/null; chmod 600 /tmp/home/.ssh/id_* 2>/dev/null; " +
|
|
"ssh-keyscan -t ed25519,rsa github.com >> /tmp/home/.ssh/known_hosts 2>/dev/null; chmod 644 /tmp/home/.ssh/known_hosts; " +
|
|
"export GIT_SSH_COMMAND='ssh -i /tmp/home/.ssh/id_rsa -o UserKnownHostsFile=/tmp/home/.ssh/known_hosts -o StrictHostKeyChecking=no'; ";
|
|
}
|
|
}
|
|
}
|
|
|
|
// always allocate a pseudo-TTY - Claude Code may require it
|
|
const ttyFlags = ["-t"];
|
|
|
|
// run as current user to avoid Claude CLI's root user restriction
|
|
const uid = process.getuid?.() ?? 1000;
|
|
const gid = process.getgid?.() ?? 1000;
|
|
|
|
// use agent-specific volume to avoid conflicts when running in parallel
|
|
const agentOverride = process.env.AGENT_OVERRIDE ?? "default";
|
|
const volumeName = `pullfrog-action-node-modules-${agentOverride}`;
|
|
|
|
// initialize volume with correct ownership (runs as root briefly)
|
|
spawnSync(
|
|
"docker",
|
|
[
|
|
"run",
|
|
"--rm",
|
|
"-v",
|
|
`${volumeName}:/app/action/node_modules`,
|
|
"node:24",
|
|
"chown",
|
|
"-R",
|
|
`${uid}:${gid}`,
|
|
"/app/action/node_modules",
|
|
],
|
|
{ stdio: "ignore", cwd: __dirname }
|
|
);
|
|
|
|
const result = spawnSync(
|
|
"docker",
|
|
[
|
|
"run",
|
|
"--rm",
|
|
...ttyFlags,
|
|
"--user",
|
|
`${uid}:${gid}`,
|
|
"-v",
|
|
`${__dirname}:/app/action:cached`,
|
|
"-v",
|
|
`${volumeName}:/app/action/node_modules`,
|
|
"-w",
|
|
"/app/action",
|
|
...envFlags,
|
|
...sshFlags,
|
|
"-e",
|
|
"COREPACK_ENABLE_DOWNLOAD_PROMPT=0",
|
|
"-e",
|
|
"HOME=/tmp/home",
|
|
"-e",
|
|
"TMPDIR=/tmp",
|
|
"node:24",
|
|
"bash",
|
|
"-c",
|
|
`${sshSetupCmd}mkdir -p /tmp/home/.config /tmp/home/.cache && corepack pnpm install --frozen-lockfile --ignore-scripts && ${nodeCmd}`,
|
|
],
|
|
{ stdio: "inherit", cwd: __dirname }
|
|
);
|
|
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
|
|
// check for fixture passed via env var (from test runner)
|
|
if (process.env.PLAY_FIXTURE) {
|
|
const fixtureFromEnv = JSON.parse(process.env.PLAY_FIXTURE) as Inputs;
|
|
const result = await run(fixtureFromEnv);
|
|
process.exit(result.success ? 0 : 1);
|
|
}
|
|
|
|
if (args["--raw"]) {
|
|
const result = await run(args["--raw"]);
|
|
process.exit(result.success ? 0 : 1);
|
|
}
|
|
|
|
// no args - use inline playFixture
|
|
const result = await run(playFixture);
|
|
process.exit(result.success ? 0 : 1);
|
|
}
|