fix push-to-action: use CLI direct invocation for token acquisition

the inline `node -e` + `TOKEN=$(...)` approach broke because
`core.getIDToken()` in @actions/core writes `::debug::` and
`::add-mask::` to stdout, polluting the captured value.

`node cli.ts gha token` uses `core.setOutput()` which writes to
the $GITHUB_OUTPUT file instead of stdout.

Made-with: Cursor
This commit is contained in:
Colin McDonnell
2026-04-12 00:47:41 +00:00
committed by pullfrog[bot]
parent 61bbfb932e
commit 421607cf97
21 changed files with 4292 additions and 3217 deletions
+8 -8
View File
@@ -80,7 +80,7 @@ jobs:
tag_name: ${{ steps.version.outputs.tag }}
release_name: "${{ steps.version.outputs.tag }}"
body: |
## 📦 @pullfrog/pullfrog ${{ steps.version.outputs.version }}
## 📦 pullfrog ${{ steps.version.outputs.version }}
### Usage in GitHub Actions
@@ -91,16 +91,16 @@ jobs:
### Installation via npm
```bash
npm install @pullfrog/pullfrog@${{ steps.version.outputs.version }}
npm install pullfrog@${{ steps.version.outputs.version }}
```
draft: false
prerelease: false
# - name: Publish to npm
# if: steps.check_tag.outputs.exists == 'false'
# run: npm publish --access public
# env:
# NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish to npm
if: steps.check_tag.outputs.exists == 'false'
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Summary
if: always()
@@ -118,5 +118,5 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Published to" >> $GITHUB_STEP_SUMMARY
echo "- GitHub Release: [View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }})" >> $GITHUB_STEP_SUMMARY
echo "- npm Registry: [@pullfrog/pullfrog@${{ steps.version.outputs.version }}](https://www.npmjs.com/package/@pullfrog/pullfrog/v/${{ steps.version.outputs.version }})" >> $GITHUB_STEP_SUMMARY
echo "- npm Registry: [pullfrog@${{ steps.version.outputs.version }}](https://www.npmjs.com/package/pullfrog/v/${{ steps.version.outputs.version }})" >> $GITHUB_STEP_SUMMARY
fi
+2 -2
View File
@@ -35,8 +35,8 @@ outputs:
runs:
using: "node24"
main: "entry"
post: "post"
main: "entry.ts"
post: "post.ts"
post-if: "failure() || cancelled()"
branding:
+104
View File
@@ -0,0 +1,104 @@
import { basename } from "node:path";
import arg from "arg";
import pc from "picocolors";
import { runCli as runGhaCli } from "./commands/gha.ts";
import { runCli as runInitCli } from "./commands/init.ts";
const VERSION = process.env.CLI_VERSION ?? "0.0.0";
const bin = basename(process.argv[1] || "");
const PROG = bin === "pf" || bin === "pullfrog" ? bin : "pullfrog";
const rawArgs = process.argv.slice(2);
function printMainUsage(stream: typeof console.log): void {
stream(`usage: ${PROG} <command>\n`);
stream("commands:");
stream(" init set up pullfrog on the current repository");
stream("");
stream("global options:");
stream(" -h, --help show help");
stream(" -v, --version show version");
}
function parseGlobalArgs(args: string[]) {
return arg(
{
"--help": Boolean,
"--version": Boolean,
"-h": "--help",
"-v": "--version",
},
{
argv: args,
stopAtPositional: true,
}
);
}
function exitWithUsageError(message: string): never {
console.error(`${message}\n`);
printMainUsage(console.error);
process.exit(1);
}
async function run(): Promise<void> {
let globalParsed: ReturnType<typeof parseGlobalArgs>;
try {
globalParsed = parseGlobalArgs(rawArgs);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
exitWithUsageError(message);
}
if (globalParsed["--version"]) {
console.log(VERSION);
process.exit(0);
}
const command = globalParsed._[0];
const commandArgs = globalParsed._.slice(1);
if (!command) {
if (globalParsed["--help"]) {
console.log(`${pc.bold("pullfrog")} v${VERSION}\n`);
printMainUsage(console.log);
process.exit(0);
}
printMainUsage(console.log);
process.exit(0);
}
if (command === "init") {
await runInitCli({
args: commandArgs,
prog: PROG,
showHelp: globalParsed["--help"] === true,
});
return;
}
if (command === "gha") {
await runGhaCli({
args: commandArgs,
prog: PROG,
showHelp: globalParsed["--help"] === true,
});
return;
}
if (globalParsed["--help"]) {
printMainUsage(console.log);
process.exit(0);
}
console.error(`unknown command: ${pc.bold(command)}\n`);
printMainUsage(console.error);
process.exit(1);
}
try {
await run();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(pc.red(message));
process.exit(1);
}
+162
View File
@@ -0,0 +1,162 @@
import { dirname } from "node:path";
import * as core from "@actions/core";
import arg from "arg";
import { main } from "../main.ts";
import { log } from "../utils/cli.ts";
import { runPostCleanup } from "../utils/postCleanup.ts";
import { acquireInstallationToken, revokeInstallationToken } from "../utils/token.ts";
// GitHub Actions runs the action entry point with the node24 binary specified
// in action.yml, but doesn't add that binary's directory to PATH. Without this,
// spawned processes (pnpm, npm, etc.) resolve to the runner's default node (v20).
process.env.PATH = `${dirname(process.execPath)}:${process.env.PATH}`;
const STATE_TOKEN = "token";
interface GhaCliParams {
args: string[];
prog: string;
showHelp?: boolean;
}
async function runMain(): Promise<void> {
try {
const result = await main();
if (!result.success) {
throw new Error(result.error || "agent execution failed");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "unknown error occurred";
core.setFailed(`action failed: ${errorMessage}`);
}
}
async function runPost(): Promise<void> {
log.debug(`[post] script started at ${new Date().toISOString()}`);
try {
await runPostCleanup();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`[post] unexpected error: ${message}`);
}
}
async function tokenMain(): Promise<void> {
const reposInput = core.getInput("repos");
const additionalRepos = reposInput
? reposInput
.split(",")
.map((r) => r.trim())
.filter(Boolean)
: [];
const token = await acquireInstallationToken({ repos: additionalRepos });
core.setSecret(token);
core.saveState(STATE_TOKEN, token);
core.setOutput("token", token);
const scope = additionalRepos.length
? `current repo + ${additionalRepos.join(", ")}`
: "current repo only";
core.info(`» installation token acquired (${scope})`);
}
async function tokenPost(): Promise<void> {
const token = core.getState(STATE_TOKEN);
if (!token) {
core.debug("no token found in state, skipping revocation");
return;
}
await revokeInstallationToken(token);
core.info("» installation token revoked");
}
function printGhaUsage(params: { stream: typeof console.log; prog: string }): void {
params.stream(`usage: ${params.prog} gha [token] [--post]\n`);
params.stream("run the github action runtime flow.");
params.stream("");
params.stream("subcommands:");
params.stream(" token acquire a github app installation token");
params.stream("");
params.stream("options:");
params.stream(" -h, --help show help");
params.stream(" --post run post-cleanup flow");
}
function parseGhaArgs(args: string[]) {
return arg(
{
"--help": Boolean,
"--post": Boolean,
"-h": "--help",
},
{
argv: args,
}
);
}
export async function runCli(params: GhaCliParams): Promise<void> {
if (params.showHelp) {
printGhaUsage({ stream: console.log, prog: params.prog });
return;
}
let parsed: ReturnType<typeof parseGhaArgs>;
try {
parsed = parseGhaArgs(params.args);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${message}\n`);
printGhaUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
if (parsed["--help"]) {
printGhaUsage({ stream: console.log, prog: params.prog });
return;
}
const normalizedArgs = ["gha"];
const positional = parsed._;
if (positional.length > 1) {
console.error(`unexpected positional arguments for gha: ${positional.slice(1).join(" ")}\n`);
printGhaUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
if (positional[0] === "token") {
normalizedArgs.push("token");
} else if (positional[0]) {
console.error(`unknown gha subcommand: ${positional[0]}\n`);
printGhaUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
if (parsed["--post"]) {
normalizedArgs.push("--post");
}
await run(normalizedArgs);
}
export async function run(args: string[]) {
try {
if (args.includes("token")) {
if (args.includes("--post")) {
await tokenPost();
} else {
await tokenMain();
}
} else if (args.includes("--post")) {
await runPost();
} else {
await runMain();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
core.setFailed(message);
}
}
+964
View File
@@ -0,0 +1,964 @@
import { execFileSync } from "node:child_process";
import * as p from "@clack/prompts";
import arg from "arg";
import pc from "picocolors";
import { modelAliases, type ProviderConfig, providers } from "../models.ts";
const PULLFROG_API_URL = (process.env.PULLFROG_API_URL || "https://pullfrog.com").replace(
/\/+$/,
""
);
function link(text: string, url: string): string {
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
}
type CliProvider = {
id: string;
name: string;
envVars: readonly string[];
models: { value: string; label: string; hint?: string | undefined }[];
};
function buildProviders(): CliProvider[] {
return Object.entries(providers)
.filter(([key]) => key !== "opencode" && key !== "openrouter")
.map(([key, config]: [string, ProviderConfig]) => {
const aliases = modelAliases.filter((a) => a.provider === key);
const recommended = aliases.find((a) => a.preferred);
const sorted = [...aliases].sort((a, b) => {
if (a.preferred && !b.preferred) return -1;
if (!a.preferred && b.preferred) return 1;
return 0;
});
return {
id: key,
name: config.displayName,
envVars: config.envVars,
models: sorted.map((a) => ({
value: a.slug,
label: a.displayName,
hint: a === recommended ? "recommended" : undefined,
})),
};
});
}
const CLI_PROVIDERS = buildProviders();
function resolveModelProvider(slug: string): CliProvider | null {
const providerId = slug.split("/")[0];
return CLI_PROVIDERS.find((p) => p.id === providerId) ?? null;
}
// ── helpers ──
// active spinner reference so bail/catch can clean up the terminal
let activeSpin: ReturnType<typeof p.spinner> | null = null;
function bail(msg: string): never {
if (activeSpin) {
activeSpin.stop(pc.red("failed"));
activeSpin = null;
}
p.cancel(msg);
process.exit(1);
}
function handleCancel<T>(value: T | symbol): asserts value is T {
if (p.isCancel(value)) {
if (activeSpin) {
activeSpin.stop(pc.red("canceled."));
activeSpin = null;
}
p.cancel("canceled.");
process.exit(0);
}
}
function getGhToken(): string {
let token: string;
try {
token = execFileSync("gh", ["auth", "token"], { encoding: "utf-8" }).trim();
} catch {
bail(
`gh cli not found or not authenticated.\n` +
` ${pc.dim("install:")} https://cli.github.com\n` +
` ${pc.dim("then:")} gh auth login`
);
}
if (!token) {
bail(
`gh cli returned an empty token. try re-authenticating:\n` +
` ${pc.dim("run:")} gh auth login`
);
}
return token;
}
type GhApiResult<T = unknown> = { data: T; scopes: string | null };
async function ghApi<T = unknown>(path: string, token: string): Promise<GhApiResult<T>> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
try {
const response = await fetch(`https://api.github.com${path}`, {
headers: {
authorization: `Bearer ${token}`,
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28",
},
signal: controller.signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`github api ${path} returned ${response.status}: ${body}`);
}
const data = (await response.json().catch(() => {
throw new Error(`github api ${path} returned non-JSON response`);
})) as T;
return { data, scopes: response.headers.get("x-oauth-scopes") };
} finally {
clearTimeout(timeout);
}
}
function parseGitRemote(): { owner: string; repo: string } {
let url: string;
try {
url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" }).trim();
} catch {
bail("not a git repository or no 'origin' remote found.");
}
const match = url.match(/github\.com(?::\d+)?[:/]+([^/]+)\/(.+?)(?:\.git)?(?:\/)?$/);
if (!match) bail(`could not parse github owner/repo from remote: ${url}`);
return { owner: match[1], repo: match[2] };
}
function openBrowser(url: string) {
try {
const platform = process.platform;
if (platform === "darwin") execFileSync("open", [url], { stdio: "ignore" });
else if (platform === "win32")
execFileSync("cmd", ["/c", "start", "", url], { stdio: "ignore" });
else execFileSync("xdg-open", [url], { stdio: "ignore" });
} catch {
// headless/SSH — user will open the URL manually
}
}
// ── Pullfrog API ──
type SecretsApiData = {
error?: string;
appSlug?: string;
ownerHasInstallation?: boolean;
installationId?: number | null;
repositorySelection?: string | null;
isOrg?: boolean;
accessible?: boolean;
repoSecrets?: string[];
orgSecrets?: string[];
pullfrogSecrets?: string[];
repoStatus?: string | null;
repoModel?: string | null;
hasRuns?: boolean;
};
type SecretsInfo = {
isOrg: boolean;
installationId: number | null;
secretsAccessible: boolean;
repoSecrets: string[];
orgSecrets: string[];
pullfrogSecrets: string[];
model: string | null;
hasRuns: boolean;
};
type InstallationNotFound = {
appSlug: string;
ownerHasInstallation: boolean;
installationId: number | null;
repositorySelection: "all" | "selected" | null;
isOrg: boolean;
};
type StatusResult =
| ({ installed: true } & SecretsInfo)
| ({ installed: false } & InstallationNotFound);
type SessionApiData = {
id?: string;
installed?: boolean;
error?: string;
};
type SetupApiData = {
error?: string;
success?: boolean;
already_existed?: boolean;
pull_request_url?: string;
commit_url?: string;
hash?: string;
};
type DispatchApiData = {
error?: string;
url?: string;
};
type ApiResult<T = Record<string, unknown>> = { ok: boolean; status: number; data: T };
async function pullfrogApi<T = Record<string, unknown>>(ctx: {
path: string;
token: string;
method?: string;
body?: Record<string, unknown>;
}): Promise<ApiResult<T>> {
const headers: Record<string, string> = { authorization: `Bearer ${ctx.token}` };
if (ctx.body) headers["content-type"] = "application/json";
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30_000);
try {
const response = await fetch(`${PULLFROG_API_URL}${ctx.path}`, {
method: ctx.method || "GET",
headers,
body: ctx.body ? JSON.stringify(ctx.body) : null,
signal: controller.signal,
});
const data = (await response.json().catch(() => ({}))) as T;
return { ok: response.ok, status: response.status, data };
} finally {
clearTimeout(timeout);
}
}
async function fetchStatus(ctx: {
token: string;
owner: string;
repo: string;
}): Promise<StatusResult> {
const result = await pullfrogApi<SecretsApiData>({
path: `/api/cli/secrets?owner=${encodeURIComponent(ctx.owner)}&repo=${encodeURIComponent(ctx.repo)}`,
token: ctx.token,
});
if (!result.ok) {
const errorMsg = result.data.error || "";
if (result.status === 401) bail("invalid or expired github token.");
if (result.status === 404) {
const sel = result.data.repositorySelection;
if (!result.data.appSlug) bail("server did not return appSlug");
return {
installed: false,
appSlug: result.data.appSlug,
ownerHasInstallation: result.data.ownerHasInstallation === true,
installationId:
typeof result.data.installationId === "number" ? result.data.installationId : null,
repositorySelection: sel === "all" || sel === "selected" ? sel : null,
isOrg: result.data.isOrg === true,
};
}
bail(errorMsg || `secrets check failed (${result.status})`);
}
return {
installed: true,
isOrg: result.data.isOrg === true,
installationId:
typeof result.data.installationId === "number" ? result.data.installationId : null,
secretsAccessible: result.data.accessible !== false,
repoSecrets: result.data.repoSecrets || [],
orgSecrets: result.data.orgSecrets || [],
pullfrogSecrets: result.data.pullfrogSecrets || [],
model: result.data.repoModel ?? null,
hasRuns: result.data.hasRuns === true,
};
}
// ── sessions ──
async function createSession(ctx: {
token: string;
owner: string;
repo: string;
}): Promise<string | null> {
try {
const result = await pullfrogApi<SessionApiData>({
path: "/api/cli/session",
token: ctx.token,
method: "POST",
body: { owner: ctx.owner.toLowerCase(), repo: ctx.repo.toLowerCase() },
});
if (!result.ok || !result.data.id) return null;
return result.data.id;
} catch {
return null;
}
}
type PollResult = "installed" | "pending" | "expired";
async function pollSession(ctx: { token: string; sessionId: string }): Promise<PollResult> {
const result = await pullfrogApi<SessionApiData>({
path: `/api/cli/session/${ctx.sessionId}`,
token: ctx.token,
});
if (result.status === 410) return "expired";
if (!result.ok) return "pending";
return result.data.installed === true ? "installed" : "pending";
}
function cleanupSession(ctx: { token: string; sessionId: string }) {
void pullfrogApi({
path: `/api/cli/session/${ctx.sessionId}`,
token: ctx.token,
method: "DELETE",
}).catch(() => {});
}
// ── installation ──
const SESSION_POLL_MS = 750;
const FALLBACK_POLL_MS = 5_000;
const HINT_AFTER_MS = 10_000;
const TIMEOUT_MS = 3 * 60 * 1000;
function listenForKey(key: string) {
let triggered = false;
const onData = (data: Buffer) => {
if (data.toString().toLowerCase() === key) triggered = true;
};
process.stdin.setRawMode?.(true);
process.stdin.resume();
process.stdin.on("data", onData);
return {
consume() {
if (!triggered) return false;
triggered = false;
return true;
},
stop() {
process.stdin.removeListener("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
},
};
}
function installationConfigUrl(ctx: { owner: string; installationId: number; isOrg: boolean }) {
return ctx.isOrg
? `https://github.com/organizations/${ctx.owner}/settings/installations/${ctx.installationId}`
: `https://github.com/settings/installations/${ctx.installationId}`;
}
async function ensureInstallation(ctx: {
token: string;
owner: string;
repo: string;
}): Promise<SecretsInfo> {
activeSpin!.start("checking pullfrog app installation");
const initial = await fetchStatus(ctx);
if (initial.installed) {
activeSpin!.stop(`pullfrog app is installed on ${pc.cyan(`@${ctx.owner}`)}`);
if (initial.installationId) {
const configUrl = installationConfigUrl({
owner: ctx.owner,
installationId: initial.installationId,
isOrg: initial.isOrg,
});
process.stdout.write(`${pc.gray(p.S_BAR)} ${link(pc.dim(configUrl), configUrl)}\n`);
}
return initial;
}
const sessionId = await createSession(ctx);
if (initial.ownerHasInstallation && initial.installationId) {
const repoRef = pc.bold(`${ctx.owner}/${ctx.repo}`);
const configUrl = installationConfigUrl({
owner: ctx.owner,
installationId: initial.installationId,
isOrg: initial.isOrg,
});
if (initial.repositorySelection === "selected") {
activeSpin!.stop("checked installation");
const proceed = await p.confirm({
message: `pullfrog is installed on selected repos only\n${repoRef} is not in the list — you'll need to add it\n${pc.dim(configUrl)}`,
active: "open",
inactive: "cancel",
});
handleCancel(proceed);
if (!proceed) bail("canceled.");
} else {
activeSpin!.stop(`pullfrog app doesn't have access to ${repoRef}`);
p.log.info(`update your pullfrog installation to include this repo:\n ${configUrl}`);
}
openBrowser(configUrl);
} else {
activeSpin!.stop("pullfrog app not installed");
const installUrl = `https://github.com/apps/${initial.appSlug}/installations/select_target?state=cli`;
p.log.info(`opening browser to install...\n ${pc.dim(installUrl)}`);
openBrowser(installUrl);
}
const baseMsg = "once you've installed the app, onboarding will proceed automatically";
activeSpin!.start(baseMsg);
let activeSessionId = sessionId;
let pollMs = activeSessionId ? SESSION_POLL_MS : FALLBACK_POLL_MS;
const listener = listenForKey("r");
const startedAt = Date.now();
let hintShown = false;
try {
while (Date.now() - startedAt < TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, pollMs));
if (!hintShown && Date.now() - startedAt > HINT_AFTER_MS) {
activeSpin!.message(`${baseMsg} ${pc.dim("(press r to recheck manually)")}`);
hintShown = true;
}
if (listener.consume()) {
activeSpin!.message("rechecking via GitHub API");
try {
const status = await fetchStatus(ctx);
if (status.installed) {
if (activeSessionId) cleanupSession({ token: ctx.token, sessionId: activeSessionId });
activeSpin!.stop("pullfrog app installed");
return status;
}
} catch {
// network error — keep going
}
activeSpin!.message(`${baseMsg} ${pc.dim("(press r to recheck manually)")}`);
continue;
}
if (activeSessionId) {
// fast path: lightweight DB session poll (no GitHub API calls)
try {
const result = await pollSession({ token: ctx.token, sessionId: activeSessionId });
if (result === "expired") {
activeSessionId = null;
pollMs = FALLBACK_POLL_MS;
continue;
}
if (result === "installed") {
const status = await fetchStatus(ctx);
if (status.installed) {
cleanupSession({ token: ctx.token, sessionId: activeSessionId });
activeSpin!.stop("pullfrog app installed");
return status;
}
}
} catch {
// transient error — keep polling
}
} else {
// no session available — poll fetchStatus directly at slower interval
try {
const status = await fetchStatus(ctx);
if (status.installed) {
activeSpin!.stop("pullfrog app installed");
return status;
}
} catch {
// transient error — keep polling
}
}
}
} finally {
listener.stop();
}
if (activeSessionId) cleanupSession({ token: ctx.token, sessionId: activeSessionId });
bail(
"timed out waiting for app installation.\n" +
` ${pc.dim("if your org requires admin approval, ask an admin to approve,")}\n` +
` ${pc.dim("then re-run:")} npx pullfrog init`
);
}
// ── secret management ──
type StorageMethod = "pullfrog" | "github";
type SecretScope = "account" | "repo";
type SecretSetResult = { saved: boolean; orgFailed: boolean };
function setGhSecret(ctx: {
name: string;
value: string;
org: string | null;
repoSlug: string;
}): SecretSetResult {
let orgFailed = false;
if (ctx.org) {
try {
execFileSync("gh", ["secret", "set", ctx.name, "--org", ctx.org, "--visibility", "all"], {
input: ctx.value,
stdio: ["pipe", "ignore", "pipe"],
encoding: "utf-8",
});
return { saved: true, orgFailed: false };
} catch {
orgFailed = true;
}
}
try {
execFileSync("gh", ["secret", "set", ctx.name, "--repo", ctx.repoSlug], {
input: ctx.value,
stdio: ["pipe", "ignore", "pipe"],
encoding: "utf-8",
});
return { saved: true, orgFailed };
} catch {
return { saved: false, orgFailed };
}
}
type PullfrogSecretResult = { saved: boolean; error: string };
async function setPullfrogSecret(ctx: {
token: string;
owner: string;
repo: string;
name: string;
value: string;
scope: SecretScope;
}): Promise<PullfrogSecretResult> {
const result = await pullfrogApi<{ success?: boolean; error?: string }>({
path: "/api/cli/secrets",
token: ctx.token,
method: "POST",
body: {
owner: ctx.owner,
repo: ctx.repo,
name: ctx.name,
value: ctx.value,
scope: ctx.scope,
},
});
if (result.ok && result.data.success === true) {
return { saved: true, error: "" };
}
return { saved: false, error: result.data.error || `api returned ${result.status}` };
}
async function promptScope(ctx: { owner: string; repo: string }): Promise<SecretScope> {
const scope = await p.select<SecretScope>({
message: "secret scope",
options: [
{ value: "account", label: `${ctx.owner} organization`, hint: "shared across repos" },
{ value: "repo", label: `${ctx.owner}/${ctx.repo} only` },
],
});
handleCancel(scope);
return scope;
}
async function handleSecret(ctx: {
token: string;
owner: string;
repo: string;
provider: CliProvider;
secrets: SecretsInfo;
}): Promise<void> {
const repoSecretsUrl = `https://github.com/${ctx.owner}/${ctx.repo}/settings/secrets/actions`;
const matches: { name: string; source: string }[] = [];
for (const v of ctx.provider.envVars) {
if (ctx.secrets.pullfrogSecrets.includes(v)) matches.push({ name: v, source: "pullfrog" });
else if (ctx.secrets.secretsAccessible && ctx.secrets.orgSecrets.includes(v))
matches.push({ name: v, source: "org secret" });
else if (ctx.secrets.secretsAccessible && ctx.secrets.repoSecrets.includes(v))
matches.push({ name: v, source: "repo secret" });
}
if (matches.length > 0) {
activeSpin!.start("");
activeSpin!.stop("found secrets");
for (const m of matches) {
process.stdout.write(
`${pc.gray(p.S_BAR)} ${pc.cyan(m.name)} ${pc.dim(`(${m.source})`)}\n`
);
}
return;
}
if (!ctx.secrets.secretsAccessible) {
p.log.info(`could not verify GitHub secrets (app lacks permission)`);
}
const hasOAuthOption = ctx.provider.envVars.includes("CLAUDE_CODE_OAUTH_TOKEN");
let envVar = ctx.provider.envVars[0];
if (hasOAuthOption) {
const authMethod = await p.select({
message: "which credential do you want to use?",
options: [
{
value: "oauth",
label: "Claude Code OAuth token",
hint: `run ${pc.cyan("claude setup-token")} — works with Pro/Max subscriptions`,
},
{
value: "api",
label: "Anthropic API key",
hint: "from console.anthropic.com",
},
],
});
handleCancel(authMethod);
if (authMethod === "oauth") envVar = "CLAUDE_CODE_OAUTH_TOKEN";
}
const method = await p.select<StorageMethod>({
message: `where should ${pc.cyan(envVar)} be stored?`,
options: [
{
value: "pullfrog",
label: "Pullfrog",
hint: "recommended — auto-injected, no workflow changes",
},
{
value: "github",
label: "GitHub Actions secret",
hint: "requires env block in pullfrog.yml",
},
],
});
handleCancel(method);
const pasteLabel =
envVar === "CLAUDE_CODE_OAUTH_TOKEN" ? "OAuth token" : `${ctx.provider.name} API key`;
const apiKey = await p.password({
message: `paste your ${pasteLabel} ${pc.dim("(Enter to skip)")}`,
mask: "*",
validate: () => undefined,
});
handleCancel(apiKey);
if (!apiKey) {
p.log.info(
`skipped — set it manually at:\n ${pc.dim(method === "pullfrog" ? `${PULLFROG_API_URL}/console/${ctx.owner}` : repoSecretsUrl)}`
);
return;
}
if (method === "pullfrog") {
const scope: SecretScope = ctx.secrets.isOrg ? await promptScope(ctx) : "account";
activeSpin!.start(`saving ${envVar}`);
let saveResult: PullfrogSecretResult;
try {
saveResult = await setPullfrogSecret({
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
name: envVar,
value: apiKey,
scope,
});
} catch (error) {
activeSpin!.stop(pc.red("could not save secret"));
p.log.warn(
`${error instanceof Error ? error.message : "network error"}\n ${pc.dim("set it manually at:")} ${PULLFROG_API_URL}/console/${ctx.owner}`
);
return;
}
if (saveResult.saved) {
const scopeLabel = ctx.secrets.isOrg ? (scope === "repo" ? "repo secret" : "org secret") : "";
activeSpin!.stop(`${pc.cyan(envVar)} saved${scopeLabel ? ` as ${scopeLabel}` : ""}`);
} else {
activeSpin!.stop(pc.red("could not save secret"));
p.log.warn(
`${saveResult.error}\n ${pc.dim("set it manually at:")} ${PULLFROG_API_URL}/console/${ctx.owner}`
);
}
return;
}
// github actions secret path
let org: string | null = null;
if (ctx.secrets.isOrg) {
const scope = await promptScope(ctx);
org = scope === "account" ? ctx.owner : null;
}
const secretsUrl = org
? `https://github.com/organizations/${org}/settings/secrets/actions`
: repoSecretsUrl;
activeSpin!.start(`saving ${envVar}`);
const secretResult = setGhSecret({
name: envVar,
value: apiKey,
org,
repoSlug: `${ctx.owner}/${ctx.repo}`,
});
if (secretResult.saved) {
activeSpin!.stop(
`${pc.cyan(envVar)} saved${org && !secretResult.orgFailed ? ` to ${pc.dim(ctx.owner)} org` : ""}`
);
if (secretResult.orgFailed) {
p.log.warn("org secret failed (admin access required) — saved as repo secret instead");
}
} else {
activeSpin!.stop(pc.red("could not set secret"));
p.log.warn(`set it manually at:\n ${pc.dim(secretsUrl)}`);
}
}
async function promptTestRun(ctx: { token: string; owner: string; repo: string }): Promise<void> {
const proceed = await p.select({
message: "test your installation?",
options: [
{ value: true, label: "yes", hint: `runs "Tell me a joke"` },
{ value: false, label: "skip" },
],
});
handleCancel(proceed);
if (!proceed) return;
activeSpin!.start("dispatching test run");
const result = await pullfrogApi<DispatchApiData>({
path: "/api/cli/dispatch",
token: ctx.token,
method: "POST",
body: { owner: ctx.owner, repo: ctx.repo, prompt: "Tell me a joke" },
});
if (!result.ok) {
activeSpin!.stop(pc.red("could not dispatch"));
p.log.warn(result.data.error || `dispatch failed (${result.status})`);
return;
}
activeSpin!.stop("test run dispatched");
if (result.data.url) {
activeSpin!.start("");
activeSpin!.stop(link(result.data.url, result.data.url));
openBrowser(result.data.url);
}
}
// ── main ──
async function main() {
p.intro(pc.bgGreen(pc.black(" pullfrog ")));
const spin = p.spinner();
activeSpin = spin;
// 1. authenticate
spin.start("authenticating with github");
const token = getGhToken();
const userResult = await ghApi<{ login: string }>("/user", token);
const user = userResult.data;
// gho_ tokens from `gh auth login` expose scopes via x-oauth-scopes header.
// fine-grained PATs (github_pat_) don't return scopes — they pass this check.
// split on ", " and match exact scope — .includes("repo") would false-positive on "public_repo"
const scopeSet = userResult.scopes !== null ? new Set(userResult.scopes.split(", ")) : null;
if (scopeSet !== null && !scopeSet.has("repo")) {
bail(
`your token is missing the ${pc.bold('"repo"')} scope.\n` +
` ${pc.dim("run:")} gh auth refresh --scopes repo\n` +
` ${pc.dim("then:")} npx pullfrog init`
);
}
spin.stop(`hello, ${pc.cyan(`@${user.login}`)}`);
// 2. detect repo
spin.start("detecting repository");
const remote = parseGitRemote();
spin.stop(`detected repo ${pc.cyan(`${remote.owner}/${remote.repo}`)}`);
// 3. ensure app installation + check secrets
const secrets = await ensureInstallation({ token, owner: remote.owner, repo: remote.repo });
// 4. select provider + model (skip if already set)
let model: string;
let provider: CliProvider;
if (secrets.model) {
model = secrets.model;
const resolved = resolveModelProvider(secrets.model);
if (!resolved) bail(`unknown model provider: ${secrets.model}`);
provider = resolved;
spin.start("");
spin.stop(`selected model is ${pc.cyan(secrets.model)}`);
} else {
const providerId = await p.select({
message: "select your preferred model provider",
options: CLI_PROVIDERS.map((cp) => ({
value: cp.id,
label: cp.name,
})),
});
handleCancel(providerId);
const found = CLI_PROVIDERS.find((cp) => cp.id === providerId);
if (!found) bail(`unknown provider: ${providerId}`);
provider = found;
if (provider.models.length === 1) {
model = provider.models[0].value;
spin.start("");
spin.stop(`using ${pc.bold(provider.models[0].label)}`);
} else {
const recommendedModel = provider.models.find((m) => m.hint === "recommended");
const options = provider.models.map((m) => {
if (m.hint) return { value: m.value, label: m.label, hint: m.hint };
return { value: m.value, label: m.label };
});
const selected = await p.select(
recommendedModel
? { message: "select model", initialValue: recommendedModel.value, options }
: { message: "select model", options }
);
handleCancel(selected);
model = selected;
}
}
// 5. check/set secret
await handleSecret({ token, owner: remote.owner, repo: remote.repo, provider, secrets });
// 6. create workflow
spin.start("creating pullfrog.yml workflow");
const result = await pullfrogApi<SetupApiData>({
path: "/api/cli/setup",
token,
method: "POST",
body: { owner: remote.owner, repo: remote.repo, model },
});
if (!result.ok) {
bail(result.data.error || `api returned ${result.status}`);
}
let skipTestRun = false;
if (result.data.already_existed) {
spin.stop("pullfrog.yml already exists");
} else if (result.data.pull_request_url) {
spin.stop("created pull request with pullfrog.yml");
process.stdout.write(
`${pc.gray(p.S_BAR)} ${link(pc.dim(result.data.pull_request_url), result.data.pull_request_url)}\n`
);
openBrowser(result.data.pull_request_url);
const merged = await p.select({
message: "merge the PR to activate pullfrog, then continue",
options: [
{ value: true, label: "continue", hint: "PR has been merged" },
{ value: false, label: "skip" },
],
});
handleCancel(merged);
if (!merged) skipTestRun = true;
} else {
const short = result.data.hash?.slice(0, 7);
spin.stop(short ? `pullfrog.yml committed ${pc.dim(short)}` : "pullfrog.yml committed");
}
if (!skipTestRun && !secrets.hasRuns) {
await promptTestRun({ token, owner: remote.owner, repo: remote.repo });
}
const consoleUrl = `${PULLFROG_API_URL}/console/${remote.owner}/${remote.repo}`;
spin.start("");
spin.stop("repo is configurable via the Pullfrog dashboard");
process.stdout.write(`${pc.gray(p.S_BAR)} ${link(pc.dim(consoleUrl), consoleUrl)}\n`);
activeSpin = null;
p.outro("done.");
}
interface InitCliParams {
args: string[];
prog: string;
showHelp?: boolean;
}
function printInitUsage(params: { stream: typeof console.log; prog: string }): void {
params.stream(`usage: ${params.prog} init\n`);
params.stream("set up pullfrog on the current repository.");
params.stream("");
params.stream("options:");
params.stream(" -h, --help show help");
}
function parseInitArgs(args: string[]) {
return arg(
{
"--help": Boolean,
"-h": "--help",
},
{
argv: args,
}
);
}
export async function runCli(params: InitCliParams): Promise<void> {
if (params.showHelp) {
printInitUsage({ stream: console.log, prog: params.prog });
return;
}
let parsed: ReturnType<typeof parseInitArgs>;
try {
parsed = parseInitArgs(params.args);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${message}\n`);
printInitUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
if (parsed["--help"]) {
printInitUsage({ stream: console.log, prog: params.prog });
return;
}
if (parsed._.length > 0) {
console.error(`unexpected positional arguments for init: ${parsed._.join(" ")}\n`);
printInitUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
await run();
}
export async function run() {
try {
await main();
} catch (error) {
if (activeSpin) {
activeSpin.stop(pc.red("failed"));
activeSpin = null;
}
const msg =
error instanceof Error && error.name === "AbortError"
? "request timed out — check your network connection and try again"
: error instanceof Error
? error.message
: String(error);
p.log.error(msg);
process.exit(1);
}
}
+1853 -2150
View File
File diff suppressed because one or more lines are too long
+4 -26
View File
@@ -1,29 +1,7 @@
#!/usr/bin/env node
/**
* entry point for pullfrog/pullfrog - unified action
*/
import { runPullfrogCli } from "./runCli.ts";
import { dirname } from "node:path";
import * as core from "@actions/core";
import { main } from "./main.ts";
// GitHub Actions runs the action entry point with the node24 binary specified
// in action.yml, but doesn't add that binary's directory to PATH. Without this,
// spawned processes (pnpm, npm, etc.) resolve to the runner's default node (v20).
process.env.PATH = `${dirname(process.execPath)}:${process.env.PATH}`;
async function run(): Promise<void> {
try {
const result = await main();
if (!result.success) {
throw new Error(result.error || "Agent execution failed");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
core.setFailed(`Action failed: ${errorMessage}`);
}
}
await run();
runPullfrogCli({
cliArgs: ["gha"],
});
+12 -21
View File
@@ -3,7 +3,7 @@
import { build } from "esbuild";
import { readFileSync, writeFileSync } from "fs";
const isMainOnlyBuild = process.argv.includes("--main-only");
const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
// Plugin to strip shebangs from output files
/**
@@ -61,30 +61,21 @@ const sharedConfig = {
drop: [],
};
// Build the main entry bundle
// Build the CLI bundle (published to npm, used by npx)
await build({
...sharedConfig,
entryPoints: ["./entry.ts"],
outfile: "./entry",
entryPoints: ["./cli.ts"],
outfile: "./dist/cli.mjs",
target: "node20",
plugins: [stripShebangPlugin],
define: {
"process.env.CLI_VERSION": JSON.stringify(pkg.version),
},
});
if (!isMainOnlyBuild) {
// Build the post cleanup entry bundle
await build({
...sharedConfig,
entryPoints: ["./post.ts"],
outfile: "./post",
plugins: [stripShebangPlugin],
});
// Build the get-installation-token action
await build({
...sharedConfig,
entryPoints: ["./get-installation-token/entry.ts"],
outfile: "./get-installation-token/entry",
plugins: [stripShebangPlugin],
})
}
// prepend shebang after strip (esbuild banner can't guarantee line 1 placement)
const cliPath = "./dist/cli.mjs";
const cliContent = readFileSync(cliPath, "utf8");
writeFileSync(cliPath, `#!/usr/bin/env node\n${cliContent}`);
console.log("» build completed successfully");
+2 -2
View File
@@ -13,8 +13,8 @@ outputs:
runs:
using: "node24"
main: "entry"
post: "entry"
main: "entry.ts"
post: "post.ts"
branding:
icon: "key"
File diff suppressed because one or more lines are too long
+4 -68
View File
@@ -1,69 +1,5 @@
#!/usr/bin/env node
import { runPullfrogCli } from "../runCli.ts";
/**
* entry point for get-installation-token action.
* handles both main and post execution using the isPost state pattern.
*/
import * as core from "@actions/core";
import { acquireInstallationToken, revokeInstallationToken } from "../utils/token.ts";
const STATE_TOKEN = "token";
const STATE_IS_POST = "isPost";
async function main(): Promise<void> {
core.saveState(STATE_IS_POST, "true");
const reposInput = core.getInput("repos");
const additionalRepos = reposInput
? reposInput
.split(",")
.map((r) => r.trim())
.filter(Boolean)
: [];
const token = await acquireInstallationToken({ repos: additionalRepos });
// mask the token in logs
core.setSecret(token);
// save token to state for post cleanup
core.saveState(STATE_TOKEN, token);
// set as output
core.setOutput("token", token);
const scope = additionalRepos.length
? `current repo + ${additionalRepos.join(", ")}`
: "current repo only";
core.info(`» installation token acquired (${scope})`);
}
async function post(): Promise<void> {
const token = core.getState(STATE_TOKEN);
if (!token) {
core.debug("no token found in state, skipping revocation");
return;
}
await revokeInstallationToken(token);
core.info("» installation token revoked");
}
async function run(): Promise<void> {
try {
const isPost = core.getState(STATE_IS_POST) === "true";
if (isPost) {
await post();
} else {
await main();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
core.setFailed(message);
}
}
await run();
runPullfrogCli({
cliArgs: ["gha", "token"],
});
+6
View File
@@ -0,0 +1,6 @@
import { runPullfrogCli } from "../runCli.ts";
runPullfrogCli({
cliArgs: ["gha", "token", "--post"],
swallowErrors: true,
});
+28 -28
View File
@@ -1,21 +1,20 @@
{
"name": "@pullfrog/pullfrog",
"name": "pullfrog",
"version": "0.0.194",
"type": "module",
"bin": {
"pullfrog": "./dist/cli.mjs",
"pullfrog-dev": "./dist/cli.mjs",
"pf": "./dist/cli.mjs"
},
"files": [
"index.js",
"index.cjs",
"index.d.ts",
"index.d.cts",
"agents",
"utils",
"main.js",
"main.d.ts"
"dist/"
],
"scripts": {
"test": "vitest",
"typecheck": "tsc --noEmit",
"build": "node esbuild.config.js",
"check:entrypoints": "node scripts/check-entrypoint-imports.ts",
"play": "node play.ts",
"runtest": "node test/run.ts",
"scratch": "node scratch.ts",
@@ -24,38 +23,39 @@
"postinstall": "node scripts/generate-proxies.ts",
"prepare": "cd .. && husky action/.husky"
},
"dependencies": {
"devDependencies": {
"@actions/core": "^1.11.1",
"@anthropic-ai/claude-code": "2.1.85",
"@ark/fs": "0.56.0",
"@ark/util": "0.56.0",
"@clack/prompts": "^1.2.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"@octokit/plugin-throttling": "^11.0.3",
"@octokit/rest": "^22.0.0",
"@octokit/webhooks-types": "^7.6.1",
"@standard-schema/spec": "1.1.0",
"@toon-format/toon": "^1.0.0",
"ajv": "^8.18.0",
"arkregex": "0.0.5",
"arktype": "2.2.0",
"dotenv": "^17.2.3",
"execa": "^9.6.0",
"fastmcp": "^3.34.0",
"file-type": "^21.3.0",
"package-manager-detector": "^1.6.0",
"semver": "^7.7.3",
"table": "^6.9.0",
"turndown": "^7.2.0"
},
"devDependencies": {
"@anthropic-ai/claude-code": "2.1.85",
"agent-browser": "0.21.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"@types/node": "^24.7.2",
"@types/semver": "^7.7.1",
"@types/turndown": "^5.0.5",
"agent-browser": "0.21.0",
"ajv": "^8.18.0",
"arg": "^5.0.2",
"arkregex": "0.0.5",
"arktype": "2.2.0",
"dotenv": "^17.2.3",
"esbuild": "^0.25.9",
"execa": "^9.6.0",
"fastmcp": "^3.34.0",
"file-type": "^21.3.0",
"husky": "^9.0.0",
"opencode-ai": "1.1.56",
"package-manager-detector": "^1.6.0",
"picocolors": "^1.1.1",
"semver": "^7.7.3",
"skills": "1.4.9",
"table": "^6.9.0",
"turndown": "^7.2.0",
"typescript": "^5.9.3",
"vitest": "^4.0.17",
"yaml": "^2.8.2"
@@ -78,9 +78,9 @@
"zshy": {
"exports": "./index.ts"
},
"main": "./dist/index.cjs",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.cts",
"types": "./dist/index.d.ts",
"exports": {
".": {
"@pullfrog/source": "./index.ts",
+107 -41
View File
@@ -7,16 +7,25 @@ settings:
importers:
.:
dependencies:
devDependencies:
'@actions/core':
specifier: ^1.11.1
version: 1.11.1
'@anthropic-ai/claude-code':
specifier: 2.1.85
version: 2.1.85
'@ark/fs':
specifier: 0.56.0
version: 0.56.0
'@ark/util':
specifier: 0.56.0
version: 0.56.0
'@clack/prompts':
specifier: ^1.2.0
version: 1.2.0
'@modelcontextprotocol/sdk':
specifier: ^1.26.0
version: 1.26.0(zod@4.3.6)
'@octokit/plugin-throttling':
specifier: ^11.0.3
version: 11.0.3(@octokit/core@7.0.5)
@@ -32,46 +41,6 @@ importers:
'@toon-format/toon':
specifier: ^1.0.0
version: 1.4.0
ajv:
specifier: ^8.18.0
version: 8.18.0
arkregex:
specifier: 0.0.5
version: 0.0.5
arktype:
specifier: 2.2.0
version: 2.2.0
dotenv:
specifier: ^17.2.3
version: 17.2.3
execa:
specifier: ^9.6.0
version: 9.6.0
fastmcp:
specifier: ^3.34.0
version: 3.34.0(arktype@2.2.0)
file-type:
specifier: ^21.3.0
version: 21.3.0
package-manager-detector:
specifier: ^1.6.0
version: 1.6.0
semver:
specifier: ^7.7.3
version: 7.7.3
table:
specifier: ^6.9.0
version: 6.9.0
turndown:
specifier: ^7.2.0
version: 7.2.2
devDependencies:
'@anthropic-ai/claude-code':
specifier: 2.1.85
version: 2.1.85
'@modelcontextprotocol/sdk':
specifier: ^1.26.0
version: 1.26.0(zod@4.3.6)
'@types/node':
specifier: ^24.7.2
version: 24.7.2
@@ -84,18 +53,57 @@ importers:
agent-browser:
specifier: 0.21.0
version: 0.21.0
ajv:
specifier: ^8.18.0
version: 8.18.0
arg:
specifier: ^5.0.2
version: 5.0.2
arkregex:
specifier: 0.0.5
version: 0.0.5
arktype:
specifier: 2.2.0
version: 2.2.0
dotenv:
specifier: ^17.2.3
version: 17.2.3
esbuild:
specifier: ^0.25.9
version: 0.25.12
execa:
specifier: ^9.6.0
version: 9.6.0
fastmcp:
specifier: ^3.34.0
version: 3.34.0(arktype@2.2.0)
file-type:
specifier: ^21.3.0
version: 21.3.0
husky:
specifier: ^9.0.0
version: 9.1.7
opencode-ai:
specifier: 1.1.56
version: 1.1.56
package-manager-detector:
specifier: ^1.6.0
version: 1.6.0
picocolors:
specifier: ^1.1.1
version: 1.1.1
semver:
specifier: ^7.7.3
version: 7.7.3
skills:
specifier: 1.4.9
version: 1.4.9
table:
specifier: ^6.9.0
version: 6.9.0
turndown:
specifier: ^7.2.0
version: 7.2.2
typescript:
specifier: ^5.9.3
version: 5.9.3
@@ -137,6 +145,12 @@ packages:
'@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
'@clack/core@1.2.0':
resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==}
'@clack/prompts@1.2.0':
resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==}
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
@@ -1120,9 +1134,18 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-string-truncated-width@1.2.1:
resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==}
fast-string-width@1.1.0:
resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==}
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fast-wrap-ansi@0.1.6:
resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==}
fastmcp@3.34.0:
resolution: {integrity: sha512-xKOXjU+MK7OZy91BY3FS5aenSiclJBCRMaZtXb3HYaKZVFbq4qYvAlFu6xYI3UU1NGLtv+h8izoStnOQ1By0BA==}
hasBin: true
@@ -1626,6 +1649,14 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
skills@1.4.9:
resolution: {integrity: sha512-BTh7kfSkGPirsLgvg5vvALjDlgNImm9HRn937yAfESFzmShQEZWWTYJQbN34qjlwxOBO7Me4E9Lh6Ot5AE29zA==}
engines: {node: '>=18'}
hasBin: true
slice-ansi@4.0.0:
resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
engines: {node: '>=10'}
@@ -1886,6 +1917,11 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yaml@2.8.3:
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@22.0.0:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
@@ -1946,6 +1982,18 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
'@clack/core@1.2.0':
dependencies:
fast-wrap-ansi: 0.1.6
sisteransi: 1.0.5
'@clack/prompts@1.2.0':
dependencies:
'@clack/core': 1.2.0
fast-string-width: 1.1.0
fast-wrap-ansi: 0.1.6
sisteransi: 1.0.5
'@esbuild/aix-ppc64@0.25.12':
optional: true
@@ -2801,8 +2849,18 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-string-truncated-width@1.2.1: {}
fast-string-width@1.1.0:
dependencies:
fast-string-truncated-width: 1.2.1
fast-uri@3.1.0: {}
fast-wrap-ansi@0.1.6:
dependencies:
fast-string-width: 1.1.0
fastmcp@3.34.0(arktype@2.2.0):
dependencies:
'@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6)
@@ -3334,6 +3392,12 @@ snapshots:
signal-exit@4.1.0: {}
sisteransi@1.0.5: {}
skills@1.4.9:
dependencies:
yaml: 2.8.3
slice-ansi@4.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -3525,6 +3589,8 @@ snapshots:
yaml@2.8.2: {}
yaml@2.8.3: {}
yargs-parser@22.0.0: {}
yargs@18.0.0:
+535 -535
View File
File diff suppressed because one or more lines are too long
+5 -16
View File
@@ -1,19 +1,8 @@
#!/usr/bin/env node
/**
* Post cleanup entry point for pullfrog/pullfrog action.
* Runs independently after workflow failure or cancellation.
* Searches for Pullfrog comment via GitHub API and updates if stuck on "Leaping into action".
*/
import { runPullfrogCli } from "./runCli.ts";
import { log } from "./utils/cli.ts";
import { runPostCleanup } from "./utils/postCleanup.ts";
log.debug(`[post] script started at ${new Date().toISOString()}`);
try {
await runPostCleanup();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`[post] unexpected error: ${message}`);
// don't fail the post script - best effort cleanup
}
runPullfrogCli({
cliArgs: ["gha", "--post"],
swallowErrors: true,
});
+101
View File
@@ -0,0 +1,101 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import actionPackageJson from "./package.json" with { type: "json" };
interface RunPullfrogCliParams {
cliArgs: string[];
swallowErrors?: boolean;
}
interface RuntimeContext {
actionRef: string | undefined;
actionRepository: string | undefined;
actionRoot: string;
nodeBinDir: string;
env: NodeJS.ProcessEnv;
}
const NPM_REGISTRY = "https://registry.npmjs.org";
const FALLBACK_PACKAGE_SPEC = `pullfrog@^${actionPackageJson.version}`;
function createRuntimeContext(): RuntimeContext {
const actionRoot = dirname(fileURLToPath(import.meta.url));
const nodeBinDir = dirname(process.execPath);
const env: NodeJS.ProcessEnv = { ...process.env };
env.npm_config_registry = NPM_REGISTRY;
env.COREPACK_NPM_REGISTRY = NPM_REGISTRY;
const currentPath = process.env.PATH ?? "";
env.PATH = currentPath ? `${nodeBinDir}${delimiter}${currentPath}` : nodeBinDir;
return {
actionRef: process.env.GITHUB_ACTION_REF,
actionRepository: process.env.GITHUB_ACTION_REPOSITORY,
actionRoot,
nodeBinDir,
env,
};
}
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: context.actionRoot,
stdio: "inherit",
env: context.env,
});
}
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");
execFileSync(corepackPath, ["pnpm", "install", "--frozen-lockfile", "--ignore-scripts"], {
cwd: context.actionRoot,
stdio: "inherit",
env: context.env,
});
}
function runLocalCli(context: RuntimeContext, cliArgs: string[]): void {
ensureActionDependencies(context);
execFileSync(process.execPath, ["cli.ts", ...cliArgs], {
cwd: context.actionRoot,
stdio: "inherit",
env: context.env,
});
}
function runPullfrogCliInner(context: RuntimeContext, cliArgs: string[]): void {
if (context.actionRef === "main" && context.actionRepository === "pullfrog/pullfrog") {
runLocalCli(context, cliArgs);
return;
}
runNpx(context, FALLBACK_PACKAGE_SPEC, cliArgs);
}
export function runPullfrogCli(params: RunPullfrogCliParams): void {
const context = createRuntimeContext();
if (params.swallowErrors) {
try {
runPullfrogCliInner(context, params.cliArgs);
} catch {
// best-effort cleanup
}
return;
}
runPullfrogCliInner(context, params.cliArgs);
}
+71
View File
@@ -0,0 +1,71 @@
import { isBuiltin } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const entryPoints = [
resolve(scriptDir, "../entry.ts"),
resolve(scriptDir, "../post.ts"),
resolve(scriptDir, "../get-installation-token/entry.ts"),
resolve(scriptDir, "../get-installation-token/post.ts"),
];
function isPathImport(specifier: string): boolean {
return (
specifier.startsWith("./") ||
specifier.startsWith("../") ||
specifier.startsWith("/") ||
specifier.startsWith("file:")
);
}
async function checkEntrypointImports(): Promise<void> {
const result = await build({
entryPoints,
outdir: resolve(scriptDir, "../.tmp/entrypoint-imports"),
bundle: true,
write: false,
metafile: true,
platform: "node",
format: "esm",
packages: "external",
logLevel: "silent",
});
if (!result.metafile) {
throw new Error("expected esbuild metafile output");
}
const violations: string[] = [];
const inputPaths = Object.keys(result.metafile.inputs);
for (const inputPath of inputPaths) {
const input = result.metafile.inputs[inputPath];
for (const imported of input.imports) {
if (!imported.external) {
continue;
}
if (isPathImport(imported.path)) {
continue;
}
if (isBuiltin(imported.path)) {
continue;
}
violations.push(`${inputPath} -> ${imported.path}`);
}
}
if (violations.length === 0) {
console.log("entrypoint import guard passed");
return;
}
console.error("entrypoint import guard failed. non-builtin package imports detected:");
for (const violation of violations.sort()) {
console.error(`- ${violation}`);
}
process.exit(1);
}
await checkEntrypointImports();
+2 -1
View File
@@ -20,5 +20,6 @@
"stripInternal": true,
"moduleDetection": "force",
"useUnknownInCatchVariables": true
}
},
"exclude": []
}
+5 -2
View File
@@ -1,6 +1,9 @@
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { log } from "./cli.ts";
const skillsBin = resolve(import.meta.dirname, "../node_modules/.bin/skills");
export function addSkill(params: {
ref: string;
skill: string;
@@ -8,8 +11,8 @@ export function addSkill(params: {
agent: string;
}): void {
const result = spawnSync(
"npx",
["skills", "add", params.ref, "--skill", params.skill, "-g", "-a", params.agent, "-y"],
skillsBin,
["add", params.ref, "--skill", params.skill, "-g", "-a", params.agent, "-y"],
{
env: { ...process.env, ...params.env },
stdio: "pipe",
+1 -1
View File
@@ -6,7 +6,7 @@ import { onExitSignal } from "./exitHandler.ts";
import { acquireNewToken } from "./github.ts";
import { isGitHubActions } from "./globals.ts";
// re-export for get-installation-token action
// re-export for `pullfrog gha token` subcommand
export { acquireNewToken as acquireInstallationToken };
export { revokeGitHubInstallationToken as revokeInstallationToken };