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:
committed by
pullfrog[bot]
parent
61bbfb932e
commit
421607cf97
@@ -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
@@ -35,8 +35,8 @@ outputs:
|
||||
|
||||
runs:
|
||||
using: "node24"
|
||||
main: "entry"
|
||||
post: "post"
|
||||
main: "entry.ts"
|
||||
post: "post.ts"
|
||||
post-if: "failure() || cancelled()"
|
||||
|
||||
branding:
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -13,8 +13,8 @@ outputs:
|
||||
|
||||
runs:
|
||||
using: "node24"
|
||||
main: "entry"
|
||||
post: "entry"
|
||||
main: "entry.ts"
|
||||
post: "post.ts"
|
||||
|
||||
branding:
|
||||
icon: "key"
|
||||
|
||||
+316
-316
File diff suppressed because one or more lines are too long
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { runPullfrogCli } from "../runCli.ts";
|
||||
|
||||
runPullfrogCli({
|
||||
cliArgs: ["gha", "token", "--post"],
|
||||
swallowErrors: true,
|
||||
});
|
||||
+28
-28
@@ -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",
|
||||
|
||||
Generated
+107
-41
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -20,5 +20,6 @@
|
||||
"stripInternal": true,
|
||||
"moduleDetection": "force",
|
||||
"useUnknownInCatchVariables": true
|
||||
}
|
||||
},
|
||||
"exclude": []
|
||||
}
|
||||
|
||||
+5
-2
@@ -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
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user