diff --git a/mcp/comment.ts b/mcp/comment.ts index 3522221..4ff456e 100644 --- a/mcp/comment.ts +++ b/mcp/comment.ts @@ -14,6 +14,12 @@ import { execute, tool } from "./shared.ts"; */ export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action"; +export function isLeapingIntoActionCommentBody(body: string): boolean { + const content = stripExistingFooter(body).trimStart(); + const firstLine = content.split(/\r?\n/, 1)[0]?.trimEnd() ?? ""; + return new RegExp(`(^|\\s)${LEAPING_INTO_ACTION_PREFIX}(\\.\\.\\.)?$`).test(firstLine); +} + function buildCommentFooter(ctx: ToolContext, customParts?: string[]): string { const runId = ctx.runId; return buildPullfrogFooter({ diff --git a/package.json b/package.json index 7ba79ed..cfda094 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pullfrog", - "version": "0.0.201", + "version": "0.0.202", "type": "module", "bin": { "pullfrog": "dist/cli.mjs", diff --git a/runCli.ts b/runCli.ts index 22e7276..b57907b 100644 --- a/runCli.ts +++ b/runCli.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { accessSync, constants, existsSync } from "node:fs"; import { delimiter, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import actionPackageJson from "./package.json" with { type: "json" }; @@ -20,6 +20,48 @@ interface RuntimeContext { const NPM_REGISTRY = "https://registry.npmjs.org"; const FALLBACK_PACKAGE_SPEC = `pullfrog@^${actionPackageJson.version}`; +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function canAccessExecutable(path: string): boolean { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + if (process.platform !== "win32") { + return false; + } + } + + try { + accessSync(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +function resolveExecutable(params: { command: string; env: NodeJS.ProcessEnv }): string | null { + const pathValue = params.env.PATH ?? ""; + const pathEntries = pathValue.split(delimiter).filter(Boolean); + const extensions = + process.platform === "win32" + ? (params.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) + : [""]; + + for (const pathEntry of pathEntries) { + for (const extension of extensions) { + const candidate = join(pathEntry, `${params.command}${extension.toLowerCase()}`); + if (canAccessExecutable(candidate)) { + return candidate; + } + } + } + + return null; +} + function createRuntimeContext(): RuntimeContext { const actionRoot = dirname(fileURLToPath(import.meta.url)); const nodeBinDir = dirname(process.execPath); @@ -38,28 +80,77 @@ function createRuntimeContext(): RuntimeContext { }; } -function runNpx(context: RuntimeContext, packageSpec: string, cliArgs: string[]): void { - const npxPath = - process.platform === "win32" - ? join(context.nodeBinDir, "npx.cmd") - : join(context.nodeBinDir, "npx"); - execFileSync(npxPath, ["--yes", packageSpec, ...cliArgs], { - cwd: process.env.GITHUB_WORKSPACE || context.actionRoot, +function runCommand(params: { context: RuntimeContext; command: string; args: string[] }): void { + execFileSync(params.command, params.args, { + cwd: process.env.GITHUB_WORKSPACE || params.context.actionRoot, stdio: "inherit", - env: context.env, + env: params.context.env, }); } +// resolve a launcher binary by walking PATH (which already has the action +// runtime's nodeBinDir prepended). some hosted Node 24 runner pools ship +// `node` at `externals/node24/bin/node` without the sibling `npx`/`corepack`, +// so a hardcoded sibling path can't be relied on — fall back to whatever the +// runner image provides on PATH. +function requireExecutable(params: { + context: RuntimeContext; + command: string; + purpose: string; +}): string { + const resolved = resolveExecutable({ command: params.command, env: params.context.env }); + if (!resolved) { + throw new Error( + `could not find ${params.command} on PATH (needed to ${params.purpose}); ` + + `runtime PATH was: ${params.context.env.PATH ?? ""}` + ); + } + return resolved; +} + +function runPackageCli(context: RuntimeContext, packageSpec: string, cliArgs: string[]): void { + const npxPath = resolveExecutable({ command: "npx", env: context.env }); + if (npxPath) { + runCommand({ context, command: npxPath, args: ["--yes", packageSpec, ...cliArgs] }); + return; + } + + const corepackPath = resolveExecutable({ command: "corepack", env: context.env }); + if (corepackPath) { + console.warn("» npx not found, using corepack pnpm dlx"); + runCommand({ context, command: corepackPath, args: ["pnpm", "dlx", packageSpec, ...cliArgs] }); + return; + } + + throw new Error( + `could not find npx or corepack on PATH to run ${packageSpec}; ` + + `runtime PATH was: ${context.env.PATH ?? ""}` + ); +} + function ensureActionDependencies(context: RuntimeContext): void { const nodeModulesPath = join(context.actionRoot, "node_modules"); if (existsSync(nodeModulesPath)) { return; } - const corepackPath = - process.platform === "win32" - ? join(context.nodeBinDir, "corepack.cmd") - : join(context.nodeBinDir, "corepack"); + const corepackPath = requireExecutable({ + context, + command: "corepack", + purpose: "install action dependencies via pnpm", + }); + const adjacentCorepack = join( + context.nodeBinDir, + process.platform === "win32" ? "corepack.cmd" : "corepack" + ); + if (corepackPath !== adjacentCorepack) { + // bad-runner case: GitHub's externals/node24/bin/ is missing the corepack + // sibling, so we resolved via PATH instead. logging this lets us correlate + // bootstrap path to runner pool when validating the fix. + console.warn( + `» nodeBinDir corepack missing (${adjacentCorepack}); using PATH-resolved ${corepackPath}` + ); + } execFileSync(corepackPath, ["pnpm", "install", "--frozen-lockfile", "--ignore-scripts"], { cwd: context.actionRoot, stdio: "inherit", @@ -87,7 +178,7 @@ function runPullfrogCliInner(context: RuntimeContext, cliArgs: string[]): void { return; } - runNpx(context, FALLBACK_PACKAGE_SPEC, cliArgs); + runPackageCli(context, FALLBACK_PACKAGE_SPEC, cliArgs); } export function runPullfrogCli(params: RunPullfrogCliParams): void { @@ -96,7 +187,8 @@ export function runPullfrogCli(params: RunPullfrogCliParams): void { if (params.swallowErrors) { try { runPullfrogCliInner(context, params.cliArgs); - } catch { + } catch (error) { + console.warn(`» pullfrog cleanup bootstrap failed: ${getErrorMessage(error)}`); // best-effort cleanup } return; diff --git a/utils/postCleanup.ts b/utils/postCleanup.ts index f942f06..3084a18 100644 --- a/utils/postCleanup.ts +++ b/utils/postCleanup.ts @@ -1,4 +1,4 @@ -import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts"; +import { isLeapingIntoActionCommentBody } from "../mcp/comment.ts"; import { getApiUrl } from "./apiUrl.ts"; import { buildPullfrogFooter } from "./buildPullfrogFooter.ts"; import { log } from "./cli.ts"; @@ -68,7 +68,7 @@ async function validateStuckProgressComment(ctx: PostCleanupContext): Promise