Rename Foundry handoffs to tasks (#239)

* Restore foundry onboarding stack

* Consolidate foundry rename

* Create foundry tasks without prompts

* Rename Foundry handoffs to tasks
This commit is contained in:
Nathan Flurry 2026-03-11 13:23:54 -07:00 committed by GitHub
parent d30cc0bcc8
commit d75e8c31d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
281 changed files with 9242 additions and 4356 deletions

View file

@ -0,0 +1,489 @@
import { createHmac, createPrivateKey, createSign, timingSafeEqual } from "node:crypto";
export class GitHubAppError extends Error {
readonly status: number;
constructor(message: string, status = 500) {
super(message);
this.name = "GitHubAppError";
this.status = status;
}
}
export interface GitHubOAuthSession {
accessToken: string;
scopes: string[];
}
export interface GitHubViewerIdentity {
id: string;
login: string;
name: string;
email: string | null;
}
export interface GitHubOrgIdentity {
id: string;
login: string;
name: string | null;
}
export interface GitHubInstallationRecord {
id: number;
accountLogin: string;
}
export interface GitHubRepositoryRecord {
fullName: string;
cloneUrl: string;
private: boolean;
}
interface GitHubTokenResponse {
access_token?: string;
scope?: string;
error?: string;
error_description?: string;
}
interface GitHubPageResponse<T> {
items: T[];
nextUrl: string | null;
}
export interface GitHubWebhookEvent {
action?: string;
installation?: { id: number; account?: { login?: string; type?: string; id?: number } | null };
repositories_added?: Array<{ id: number; full_name: string; private: boolean }>;
repositories_removed?: Array<{ id: number; full_name: string }>;
repository?: { id: number; full_name: string; clone_url?: string; private?: boolean; owner?: { login?: string } };
pull_request?: { number: number; title?: string; state?: string; head?: { ref?: string }; base?: { ref?: string } };
sender?: { login?: string; id?: number };
[key: string]: unknown;
}
export interface GitHubAppClientOptions {
apiBaseUrl?: string;
authBaseUrl?: string;
clientId?: string;
clientSecret?: string;
redirectUri?: string;
appId?: string;
appPrivateKey?: string;
webhookSecret?: string;
}
export class GitHubAppClient {
private readonly apiBaseUrl: string;
private readonly authBaseUrl: string;
private readonly clientId?: string;
private readonly clientSecret?: string;
private readonly redirectUri?: string;
private readonly appId?: string;
private readonly appPrivateKey?: string;
private readonly webhookSecret?: string;
constructor(options: GitHubAppClientOptions = {}) {
this.apiBaseUrl = (options.apiBaseUrl ?? "https://api.github.com").replace(/\/$/, "");
this.authBaseUrl = (options.authBaseUrl ?? "https://github.com").replace(/\/$/, "");
this.clientId = options.clientId ?? process.env.GITHUB_CLIENT_ID;
this.clientSecret = options.clientSecret ?? process.env.GITHUB_CLIENT_SECRET;
this.redirectUri = options.redirectUri ?? process.env.GITHUB_REDIRECT_URI;
this.appId = options.appId ?? process.env.GITHUB_APP_ID;
this.appPrivateKey = options.appPrivateKey ?? process.env.GITHUB_APP_PRIVATE_KEY;
this.webhookSecret = options.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET;
}
isOauthConfigured(): boolean {
return Boolean(this.clientId && this.clientSecret && this.redirectUri);
}
isAppConfigured(): boolean {
return Boolean(this.appId && this.appPrivateKey);
}
isWebhookConfigured(): boolean {
return Boolean(this.webhookSecret);
}
verifyWebhookEvent(payload: string, signatureHeader: string | null, eventHeader: string | null): { event: string; body: GitHubWebhookEvent } {
if (!this.webhookSecret) {
throw new GitHubAppError("GitHub webhook secret is not configured", 500);
}
if (!signatureHeader) {
throw new GitHubAppError("Missing GitHub signature header", 400);
}
if (!eventHeader) {
throw new GitHubAppError("Missing GitHub event header", 400);
}
const expectedSignature = signatureHeader.startsWith("sha256=") ? signatureHeader.slice(7) : null;
if (!expectedSignature) {
throw new GitHubAppError("Malformed GitHub signature header", 400);
}
const computed = createHmac("sha256", this.webhookSecret).update(payload).digest("hex");
const computedBuffer = Buffer.from(computed, "utf8");
const expectedBuffer = Buffer.from(expectedSignature, "utf8");
if (computedBuffer.length !== expectedBuffer.length || !timingSafeEqual(computedBuffer, expectedBuffer)) {
throw new GitHubAppError("GitHub webhook signature verification failed", 400);
}
return {
event: eventHeader,
body: JSON.parse(payload) as GitHubWebhookEvent,
};
}
buildAuthorizeUrl(state: string): string {
if (!this.clientId || !this.redirectUri) {
throw new GitHubAppError("GitHub OAuth is not configured", 500);
}
const url = new URL(`${this.authBaseUrl}/login/oauth/authorize`);
url.searchParams.set("client_id", this.clientId);
url.searchParams.set("redirect_uri", this.redirectUri);
url.searchParams.set("scope", "read:user user:email read:org");
url.searchParams.set("state", state);
return url.toString();
}
async exchangeCode(code: string): Promise<GitHubOAuthSession> {
if (!this.clientId || !this.clientSecret || !this.redirectUri) {
throw new GitHubAppError("GitHub OAuth is not configured", 500);
}
const response = await fetch(`${this.authBaseUrl}/login/oauth/access_token`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: this.clientId,
client_secret: this.clientSecret,
code,
redirect_uri: this.redirectUri,
}),
});
const responseText = await response.text();
let payload: GitHubTokenResponse;
try {
payload = JSON.parse(responseText) as GitHubTokenResponse;
} catch {
// GitHub may return URL-encoded responses despite Accept: application/json
const params = new URLSearchParams(responseText);
if (params.has("access_token")) {
payload = {
access_token: params.get("access_token")!,
scope: params.get("scope") ?? "",
};
} else {
throw new GitHubAppError(
params.get("error_description") ?? params.get("error") ?? `GitHub token exchange failed: ${responseText.slice(0, 200)}`,
response.status || 502,
);
}
}
if (!response.ok || !payload.access_token) {
throw new GitHubAppError(payload.error_description ?? payload.error ?? `GitHub token exchange failed with ${response.status}`, response.status);
}
return {
accessToken: payload.access_token,
scopes:
payload.scope
?.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0) ?? [],
};
}
async getViewer(accessToken: string): Promise<GitHubViewerIdentity> {
const user = await this.requestJson<{
id: number;
login: string;
name?: string | null;
email?: string | null;
}>("/user", accessToken);
let email = user.email ?? null;
if (!email) {
try {
const emails = await this.requestJson<Array<{ email: string; primary?: boolean; verified?: boolean }>>("/user/emails", accessToken);
const primary = emails.find((candidate) => candidate.primary && candidate.verified) ?? emails[0] ?? null;
email = primary?.email ?? null;
} catch (error) {
if (!(error instanceof GitHubAppError) || error.status !== 404) {
throw error;
}
}
}
return {
id: String(user.id),
login: user.login,
name: user.name?.trim() || user.login,
email,
};
}
async listOrganizations(accessToken: string): Promise<GitHubOrgIdentity[]> {
const organizations = await this.paginate<{ id: number; login: string; description?: string | null }>("/user/orgs?per_page=100", accessToken);
return organizations.map((organization) => ({
id: String(organization.id),
login: organization.login,
name: organization.description?.trim() || organization.login,
}));
}
async listInstallations(accessToken: string): Promise<GitHubInstallationRecord[]> {
if (!this.isAppConfigured()) {
return [];
}
try {
const payload = await this.requestJson<{
installations?: Array<{ id: number; account?: { login?: string } | null }>;
}>("/user/installations", accessToken);
return (payload.installations ?? [])
.map((installation) => ({
id: installation.id,
accountLogin: installation.account?.login?.trim() ?? "",
}))
.filter((installation) => installation.accountLogin.length > 0);
} catch (error) {
if (!(error instanceof GitHubAppError) || (error.status !== 401 && error.status !== 403)) {
throw error;
}
}
const installations = await this.paginateApp<{ id: number; account?: { login?: string } | null }>("/app/installations?per_page=100");
return installations
.map((installation) => ({
id: installation.id,
accountLogin: installation.account?.login?.trim() ?? "",
}))
.filter((installation) => installation.accountLogin.length > 0);
}
async listUserRepositories(accessToken: string): Promise<GitHubRepositoryRecord[]> {
const repositories = await this.paginate<{
full_name: string;
clone_url: string;
private: boolean;
}>("/user/repos?per_page=100&affiliation=owner&sort=updated", accessToken);
return repositories.map((repository) => ({
fullName: repository.full_name,
cloneUrl: repository.clone_url,
private: repository.private,
}));
}
async listInstallationRepositories(installationId: number): Promise<GitHubRepositoryRecord[]> {
const accessToken = await this.createInstallationAccessToken(installationId);
const repositories = await this.paginate<{
full_name: string;
clone_url: string;
private: boolean;
}>("/installation/repositories?per_page=100", accessToken);
return repositories.map((repository) => ({
fullName: repository.full_name,
cloneUrl: repository.clone_url,
private: repository.private,
}));
}
async buildInstallationUrl(organizationLogin: string, state: string): Promise<string> {
if (!this.isAppConfigured()) {
throw new GitHubAppError("GitHub App is not configured", 500);
}
const app = await this.requestAppJson<{ slug?: string }>("/app");
if (!app.slug) {
throw new GitHubAppError("GitHub App slug is unavailable", 500);
}
const url = new URL(`${this.authBaseUrl}/apps/${app.slug}/installations/new`);
url.searchParams.set("state", state);
void organizationLogin;
return url.toString();
}
private async createInstallationAccessToken(installationId: number): Promise<string> {
if (!this.appId || !this.appPrivateKey) {
throw new GitHubAppError("GitHub App is not configured", 500);
}
const response = await fetch(`${this.apiBaseUrl}/app/installations/${installationId}/access_tokens`, {
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${this.createAppJwt()}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
const payload = (await response.json()) as { token?: string; message?: string };
if (!response.ok || !payload.token) {
throw new GitHubAppError(payload.message ?? "Unable to mint GitHub installation token", response.status);
}
return payload.token;
}
private createAppJwt(): string {
if (!this.appId || !this.appPrivateKey) {
throw new GitHubAppError("GitHub App is not configured", 500);
}
const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const now = Math.floor(Date.now() / 1000);
const payload = base64UrlEncode(
JSON.stringify({
iat: now - 60,
exp: now + 540,
iss: this.appId,
}),
);
const signer = createSign("RSA-SHA256");
signer.update(`${header}.${payload}`);
signer.end();
const key = createPrivateKey(this.appPrivateKey);
const signature = signer.sign(key);
return `${header}.${payload}.${base64UrlEncode(signature)}`;
}
private async requestAppJson<T>(path: string): Promise<T> {
const response = await fetch(`${this.apiBaseUrl}${path}`, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${this.createAppJwt()}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
const payload = (await response.json()) as T | { message?: string };
if (!response.ok) {
throw new GitHubAppError(
typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed",
response.status,
);
}
return payload as T;
}
private async paginateApp<T>(path: string): Promise<T[]> {
let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`;
const items: T[] = [];
while (nextUrl) {
const page = await this.requestAppPage<T>(nextUrl);
items.push(...page.items);
nextUrl = page.nextUrl ?? "";
}
return items;
}
private async requestJson<T>(path: string, accessToken: string): Promise<T> {
const response = await fetch(`${this.apiBaseUrl}${path}`, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${accessToken}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
const payload = (await response.json()) as T | { message?: string };
if (!response.ok) {
throw new GitHubAppError(
typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed",
response.status,
);
}
return payload as T;
}
private async paginate<T>(path: string, accessToken: string): Promise<T[]> {
let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`;
const items: T[] = [];
while (nextUrl) {
const page = await this.requestPage<T>(nextUrl, accessToken);
items.push(...page.items);
nextUrl = page.nextUrl ?? "";
}
return items;
}
private async requestPage<T>(url: string, accessToken: string): Promise<GitHubPageResponse<T>> {
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${accessToken}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
const payload = (await response.json()) as T[] | { repositories?: T[]; message?: string };
if (!response.ok) {
throw new GitHubAppError(
typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed",
response.status,
);
}
const items = Array.isArray(payload) ? payload : (payload.repositories ?? []);
return {
items,
nextUrl: parseNextLink(response.headers.get("link")),
};
}
private async requestAppPage<T>(url: string): Promise<GitHubPageResponse<T>> {
const response = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${this.createAppJwt()}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
const payload = (await response.json()) as T[] | { installations?: T[]; message?: string };
if (!response.ok) {
throw new GitHubAppError(
typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed",
response.status,
);
}
const items = Array.isArray(payload) ? payload : (payload.installations ?? []);
return {
items,
nextUrl: parseNextLink(response.headers.get("link")),
};
}
}
function parseNextLink(linkHeader: string | null): string | null {
if (!linkHeader) {
return null;
}
for (const part of linkHeader.split(",")) {
const [urlPart, relPart] = part.split(";").map((value) => value.trim());
if (!urlPart || !relPart || !relPart.includes('rel="next"')) {
continue;
}
return urlPart.replace(/^<|>$/g, "");
}
return null;
}
function base64UrlEncode(value: string | Buffer): string {
const source = typeof value === "string" ? Buffer.from(value, "utf8") : value;
return source.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}

View file

@ -0,0 +1,81 @@
import {
GitHubAppClient,
type GitHubInstallationRecord,
type GitHubOAuthSession,
type GitHubOrgIdentity,
type GitHubRepositoryRecord,
type GitHubViewerIdentity,
type GitHubWebhookEvent,
} from "./app-github.js";
import {
StripeAppClient,
type StripeCheckoutCompletion,
type StripeCheckoutSession,
type StripePortalSession,
type StripeSubscriptionSnapshot,
type StripeWebhookEvent,
} from "./app-stripe.js";
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
export type AppShellGithubClient = Pick<
GitHubAppClient,
| "isAppConfigured"
| "isWebhookConfigured"
| "buildAuthorizeUrl"
| "exchangeCode"
| "getViewer"
| "listOrganizations"
| "listInstallations"
| "listUserRepositories"
| "listInstallationRepositories"
| "buildInstallationUrl"
| "verifyWebhookEvent"
>;
export type AppShellStripeClient = Pick<
StripeAppClient,
| "isConfigured"
| "createCustomer"
| "createCheckoutSession"
| "retrieveCheckoutCompletion"
| "retrieveSubscription"
| "createPortalSession"
| "updateSubscriptionCancellation"
| "verifyWebhookEvent"
| "planIdForPriceId"
>;
export interface AppShellServices {
appUrl: string;
github: AppShellGithubClient;
stripe: AppShellStripeClient;
}
export interface CreateAppShellServicesOptions {
appUrl?: string;
github?: AppShellGithubClient;
stripe?: AppShellStripeClient;
}
export function createDefaultAppShellServices(options: CreateAppShellServicesOptions = {}): AppShellServices {
return {
appUrl: (options.appUrl ?? process.env.APP_URL ?? "http://localhost:4173").replace(/\/$/, ""),
github: options.github ?? new GitHubAppClient(),
stripe: options.stripe ?? new StripeAppClient(),
};
}
export type {
GitHubInstallationRecord,
GitHubOAuthSession,
GitHubOrgIdentity,
GitHubRepositoryRecord,
GitHubViewerIdentity,
GitHubWebhookEvent,
StripeCheckoutCompletion,
StripeCheckoutSession,
StripePortalSession,
StripeSubscriptionSnapshot,
StripeWebhookEvent,
FoundryBillingPlanId,
};

View file

@ -0,0 +1,284 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
export class StripeAppError extends Error {
readonly status: number;
constructor(message: string, status = 500) {
super(message);
this.name = "StripeAppError";
this.status = status;
}
}
export interface StripeCheckoutSession {
id: string;
url: string;
}
export interface StripePortalSession {
url: string;
}
export interface StripeSubscriptionSnapshot {
id: string;
customerId: string;
priceId: string | null;
status: string;
cancelAtPeriodEnd: boolean;
currentPeriodEnd: number | null;
trialEnd: number | null;
defaultPaymentMethodLabel: string;
}
export interface StripeCheckoutCompletion {
customerId: string | null;
subscriptionId: string | null;
planId: FoundryBillingPlanId | null;
paymentMethodLabel: string;
}
export interface StripeWebhookEvent<T = unknown> {
id: string;
type: string;
data: {
object: T;
};
}
export interface StripeAppClientOptions {
apiBaseUrl?: string;
secretKey?: string;
webhookSecret?: string;
teamPriceId?: string;
}
export class StripeAppClient {
private readonly apiBaseUrl: string;
private readonly secretKey?: string;
private readonly webhookSecret?: string;
private readonly teamPriceId?: string;
constructor(options: StripeAppClientOptions = {}) {
this.apiBaseUrl = (options.apiBaseUrl ?? "https://api.stripe.com").replace(/\/$/, "");
this.secretKey = options.secretKey ?? process.env.STRIPE_SECRET_KEY;
this.webhookSecret = options.webhookSecret ?? process.env.STRIPE_WEBHOOK_SECRET;
this.teamPriceId = options.teamPriceId ?? process.env.STRIPE_PRICE_TEAM;
}
isConfigured(): boolean {
return Boolean(this.secretKey);
}
createCheckoutSession(input: {
organizationId: string;
customerId: string;
customerEmail: string | null;
planId: Exclude<FoundryBillingPlanId, "free">;
successUrl: string;
cancelUrl: string;
}): Promise<StripeCheckoutSession> {
const priceId = this.priceIdForPlan(input.planId);
return this.formRequest<StripeCheckoutSession>("/v1/checkout/sessions", {
mode: "subscription",
success_url: input.successUrl,
cancel_url: input.cancelUrl,
customer: input.customerId,
"line_items[0][price]": priceId,
"line_items[0][quantity]": "1",
"metadata[organizationId]": input.organizationId,
"metadata[planId]": input.planId,
"subscription_data[metadata][organizationId]": input.organizationId,
"subscription_data[metadata][planId]": input.planId,
});
}
createPortalSession(input: { customerId: string; returnUrl: string }): Promise<StripePortalSession> {
return this.formRequest<StripePortalSession>("/v1/billing_portal/sessions", {
customer: input.customerId,
return_url: input.returnUrl,
});
}
createCustomer(input: { organizationId: string; displayName: string; email: string | null }): Promise<{ id: string }> {
return this.formRequest<{ id: string }>("/v1/customers", {
name: input.displayName,
...(input.email ? { email: input.email } : {}),
"metadata[organizationId]": input.organizationId,
});
}
async updateSubscriptionCancellation(subscriptionId: string, cancelAtPeriodEnd: boolean): Promise<StripeSubscriptionSnapshot> {
const payload = await this.formRequest<Record<string, unknown>>(`/v1/subscriptions/${subscriptionId}`, {
cancel_at_period_end: cancelAtPeriodEnd ? "true" : "false",
});
return stripeSubscriptionSnapshot(payload);
}
async retrieveCheckoutCompletion(sessionId: string): Promise<StripeCheckoutCompletion> {
const payload = await this.requestJson<Record<string, unknown>>(`/v1/checkout/sessions/${sessionId}?expand[]=subscription.default_payment_method`);
const subscription = typeof payload.subscription === "object" && payload.subscription ? (payload.subscription as Record<string, unknown>) : null;
const subscriptionId =
typeof payload.subscription === "string" ? payload.subscription : subscription && typeof subscription.id === "string" ? subscription.id : null;
const priceId = firstStripePriceId(subscription);
return {
customerId: typeof payload.customer === "string" ? payload.customer : null,
subscriptionId,
planId: priceId ? this.planIdForPriceId(priceId) : planIdFromMetadata(payload.metadata),
paymentMethodLabel: subscription ? paymentMethodLabelFromObject(subscription.default_payment_method) : "Card on file",
};
}
async retrieveSubscription(subscriptionId: string): Promise<StripeSubscriptionSnapshot> {
const payload = await this.requestJson<Record<string, unknown>>(`/v1/subscriptions/${subscriptionId}?expand[]=default_payment_method`);
return stripeSubscriptionSnapshot(payload);
}
verifyWebhookEvent(payload: string, signatureHeader: string | null): StripeWebhookEvent {
if (!this.webhookSecret) {
throw new StripeAppError("Stripe webhook secret is not configured", 500);
}
if (!signatureHeader) {
throw new StripeAppError("Missing Stripe signature header", 400);
}
const parts = Object.fromEntries(
signatureHeader
.split(",")
.map((entry) => entry.split("="))
.filter((entry): entry is [string, string] => entry.length === 2),
);
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) {
throw new StripeAppError("Malformed Stripe signature header", 400);
}
const expected = createHmac("sha256", this.webhookSecret).update(`${timestamp}.${payload}`).digest("hex");
const expectedBuffer = Buffer.from(expected, "utf8");
const actualBuffer = Buffer.from(signature, "utf8");
if (expectedBuffer.length !== actualBuffer.length || !timingSafeEqual(expectedBuffer, actualBuffer)) {
throw new StripeAppError("Stripe signature verification failed", 400);
}
return JSON.parse(payload) as StripeWebhookEvent;
}
planIdForPriceId(priceId: string): FoundryBillingPlanId | null {
if (priceId === this.teamPriceId) {
return "team";
}
return null;
}
priceIdForPlan(planId: Exclude<FoundryBillingPlanId, "free">): string {
const priceId = this.teamPriceId;
if (!priceId) {
throw new StripeAppError(`Stripe price ID is not configured for ${planId}`, 500);
}
return priceId;
}
private async requestJson<T>(path: string): Promise<T> {
if (!this.secretKey) {
throw new StripeAppError("Stripe is not configured", 500);
}
const response = await fetch(`${this.apiBaseUrl}${path}`, {
headers: {
Authorization: `Bearer ${this.secretKey}`,
},
});
const payload = (await response.json()) as T | { error?: { message?: string } };
if (!response.ok) {
throw new StripeAppError(
typeof payload === "object" && payload && "error" in payload ? (payload.error?.message ?? "Stripe request failed") : "Stripe request failed",
response.status,
);
}
return payload as T;
}
private async formRequest<T>(path: string, body: Record<string, string>): Promise<T> {
if (!this.secretKey) {
throw new StripeAppError("Stripe is not configured", 500);
}
const form = new URLSearchParams();
for (const [key, value] of Object.entries(body)) {
form.set(key, value);
}
const response = await fetch(`${this.apiBaseUrl}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.secretKey}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: form,
});
const payload = (await response.json()) as T | { error?: { message?: string } };
if (!response.ok) {
throw new StripeAppError(
typeof payload === "object" && payload && "error" in payload ? (payload.error?.message ?? "Stripe request failed") : "Stripe request failed",
response.status,
);
}
return payload as T;
}
}
function planIdFromMetadata(metadata: unknown): FoundryBillingPlanId | null {
if (!metadata || typeof metadata !== "object") {
return null;
}
const planId = (metadata as Record<string, unknown>).planId;
return planId === "team" || planId === "free" ? planId : null;
}
function firstStripePriceId(subscription: Record<string, unknown> | null): string | null {
if (!subscription || typeof subscription.items !== "object" || !subscription.items) {
return null;
}
const data = (subscription.items as { data?: Array<Record<string, unknown>> }).data;
const first = data?.[0];
if (!first || typeof first.price !== "object" || !first.price) {
return null;
}
return typeof (first.price as Record<string, unknown>).id === "string" ? ((first.price as Record<string, unknown>).id as string) : null;
}
function paymentMethodLabelFromObject(paymentMethod: unknown): string {
if (!paymentMethod || typeof paymentMethod !== "object") {
return "Card on file";
}
const card = (paymentMethod as Record<string, unknown>).card;
if (card && typeof card === "object") {
const brand = typeof (card as Record<string, unknown>).brand === "string" ? ((card as Record<string, unknown>).brand as string) : "Card";
const last4 = typeof (card as Record<string, unknown>).last4 === "string" ? ((card as Record<string, unknown>).last4 as string) : "file";
return `${capitalize(brand)} ending in ${last4}`;
}
return "Payment method on file";
}
function stripeSubscriptionSnapshot(payload: Record<string, unknown>): StripeSubscriptionSnapshot {
return {
id: typeof payload.id === "string" ? payload.id : "",
customerId: typeof payload.customer === "string" ? payload.customer : "",
priceId: firstStripePriceId(payload),
status: typeof payload.status === "string" ? payload.status : "active",
cancelAtPeriodEnd: payload.cancel_at_period_end === true,
currentPeriodEnd: typeof payload.current_period_end === "number" ? payload.current_period_end : null,
trialEnd: typeof payload.trial_end === "number" ? payload.trial_end : null,
defaultPaymentMethodLabel: paymentMethodLabelFromObject(payload.default_payment_method),
};
}
function capitalize(value: string): string {
return value.length > 0 ? `${value[0]!.toUpperCase()}${value.slice(1)}` : value;
}

View file

@ -0,0 +1,122 @@
export interface ResolveCreateFlowDecisionInput {
task: string;
explicitTitle?: string;
explicitBranchName?: string;
localBranches: string[];
taskBranches: string[];
}
export interface ResolveCreateFlowDecisionResult {
title: string;
branchName: string;
}
function firstNonEmptyLine(input: string): string {
const lines = input
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
return lines[0] ?? "";
}
export function deriveFallbackTitle(task: string, explicitTitle?: string): string {
const source = (explicitTitle && explicitTitle.trim()) || firstNonEmptyLine(task) || "update task";
const explicitPrefixMatch = source.match(/^\s*(feat|fix|docs|refactor):\s+(.+)$/i);
if (explicitPrefixMatch) {
const explicitTypePrefix = explicitPrefixMatch[1]!.toLowerCase();
const explicitSummary = explicitPrefixMatch[2]!
.split("")
.map((char) => (/^[a-zA-Z0-9 -]$/.test(char) ? char : " "))
.join("")
.split(/\s+/)
.filter((token) => token.length > 0)
.join(" ")
.slice(0, 62)
.trim();
return `${explicitTypePrefix}: ${explicitSummary || "update task"}`;
}
const lowered = source.toLowerCase();
const typePrefix =
lowered.includes("fix") || lowered.includes("bug")
? "fix"
: lowered.includes("doc") || lowered.includes("readme")
? "docs"
: lowered.includes("refactor")
? "refactor"
: "feat";
const cleaned = source
.split("")
.map((char) => (/^[a-zA-Z0-9 -]$/.test(char) ? char : " "))
.join("")
.split(/\s+/)
.filter((token) => token.length > 0)
.join(" ");
const summary = (cleaned || "update task").slice(0, 62).trim();
return `${typePrefix}: ${summary}`.trim();
}
export function sanitizeBranchName(input: string): string {
const normalized = input
.toLowerCase()
.split("")
.map((char) => (/^[a-z0-9]$/.test(char) ? char : "-"))
.join("");
let result = "";
let previousDash = false;
for (const char of normalized) {
if (char === "-") {
if (!previousDash && result.length > 0) {
result += char;
}
previousDash = true;
continue;
}
result += char;
previousDash = false;
}
const trimmed = result.replace(/-+$/g, "");
if (trimmed.length <= 50) {
return trimmed;
}
return trimmed.slice(0, 50).replace(/-+$/g, "");
}
export function resolveCreateFlowDecision(input: ResolveCreateFlowDecisionInput): ResolveCreateFlowDecisionResult {
const explicitBranch = input.explicitBranchName?.trim();
const title = deriveFallbackTitle(input.task, input.explicitTitle);
const generatedBase = sanitizeBranchName(title) || "task";
const branchBase = explicitBranch && explicitBranch.length > 0 ? explicitBranch : generatedBase;
const existingBranches = new Set(input.localBranches.map((value) => value.trim()).filter((value) => value.length > 0));
const existingTaskBranches = new Set(input.taskBranches.map((value) => value.trim()).filter((value) => value.length > 0));
const conflicts = (name: string): boolean => existingBranches.has(name) || existingTaskBranches.has(name);
if (explicitBranch && conflicts(branchBase)) {
throw new Error(`Branch '${branchBase}' already exists. Choose a different --name/--branch value.`);
}
if (explicitBranch) {
return { title, branchName: branchBase };
}
let candidate = branchBase;
let index = 2;
while (conflicts(candidate)) {
candidate = `${branchBase}-${index}`;
index += 1;
}
return {
title,
branchName: candidate,
};
}

View file

@ -0,0 +1,20 @@
import type { AppConfig } from "@sandbox-agent/foundry-shared";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
function expandPath(input: string): string {
if (input.startsWith("~/")) {
return `${homedir()}/${input.slice(2)}`;
}
return input;
}
export function foundryDataDir(config: AppConfig): string {
// Keep data collocated with the backend DB by default.
const dbPath = expandPath(config.backend.dbPath);
return resolve(dirname(dbPath));
}
export function foundryRepoClonePath(config: AppConfig, workspaceId: string, repoId: string): string {
return resolve(join(foundryDataDir(config), "repos", workspaceId, repoId));
}

View file

@ -0,0 +1,16 @@
interface QueueSendResult {
status: "completed" | "timedOut";
response?: unknown;
}
export function expectQueueResponse<T>(result: QueueSendResult | void): T {
if (!result || result.status === "timedOut") {
throw new Error("Queue command timed out");
}
return result.response as T;
}
export function normalizeMessages<T>(input: T | T[] | null | undefined): T[] {
if (!input) return [];
return Array.isArray(input) ? input : [input];
}

View file

@ -0,0 +1,45 @@
interface RepoLockState {
locked: boolean;
waiters: Array<() => void>;
}
const repoLocks = new Map<string, RepoLockState>();
async function acquireRepoLock(repoPath: string): Promise<() => void> {
let state = repoLocks.get(repoPath);
if (!state) {
state = { locked: false, waiters: [] };
repoLocks.set(repoPath, state);
}
if (!state.locked) {
state.locked = true;
return () => releaseRepoLock(repoPath, state);
}
await new Promise<void>((resolve) => {
state!.waiters.push(resolve);
});
return () => releaseRepoLock(repoPath, state!);
}
function releaseRepoLock(repoPath: string, state: RepoLockState): void {
const next = state.waiters.shift();
if (next) {
next();
return;
}
state.locked = false;
repoLocks.delete(repoPath);
}
export async function withRepoGitLock<T>(repoPath: string, fn: () => Promise<T>): Promise<T> {
const release = await acquireRepoLock(repoPath);
try {
return await fn();
} finally {
release();
}
}

View file

@ -0,0 +1,84 @@
import { createHash } from "node:crypto";
import { basename, sep } from "node:path";
export function normalizeRemoteUrl(remoteUrl: string): string {
let value = remoteUrl.trim();
if (!value) return "";
// Strip trailing slashes to make hashing stable.
value = value.replace(/\/+$/, "");
// GitHub shorthand: owner/repo -> https://github.com/owner/repo.git
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) {
return `https://github.com/${value}.git`;
}
// If a user pastes "github.com/owner/repo", treat it as HTTPS.
if (/^(?:www\.)?github\.com\/.+/i.test(value)) {
value = `https://${value.replace(/^www\./i, "")}`;
}
// Canonicalize GitHub URLs to the repo clone URL (drop /tree/*, issues, etc).
// This makes "https://github.com/owner/repo" and ".../tree/main" map to the same repoId.
try {
if (/^https?:\/\//i.test(value)) {
const url = new URL(value);
const hostname = url.hostname.replace(/^www\./i, "");
if (hostname.toLowerCase() === "github.com") {
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length >= 2) {
const owner = parts[0]!;
const repo = parts[1]!;
const base = `${url.protocol}//${hostname}/${owner}/${repo.replace(/\.git$/i, "")}.git`;
return base;
}
}
// Drop query/fragment for stability.
url.search = "";
url.hash = "";
return url.toString().replace(/\/+$/, "");
}
} catch {
// ignore parse failures; fall through to raw value
}
return value;
}
export function repoIdFromRemote(remoteUrl: string): string {
const normalized = normalizeRemoteUrl(remoteUrl);
return createHash("sha1").update(normalized).digest("hex").slice(0, 16);
}
export function repoLabelFromRemote(remoteUrl: string): string {
const trimmed = remoteUrl.trim();
if (!trimmed) {
return "";
}
try {
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) || trimmed.startsWith("file:")) {
const url = new URL(trimmed);
const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
if (parts.length >= 2) {
return `${parts[parts.length - 2]}/${(parts[parts.length - 1] ?? "").replace(/\.git$/i, "")}`;
}
} else {
const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/i, "")}`;
}
}
} catch {
// Fall through to path-based parsing.
}
const normalizedPath = trimmed.replace(/\\/g, sep);
const segments = normalizedPath.split(sep).filter(Boolean);
if (segments.length >= 2) {
return `${segments[segments.length - 2]}/${segments[segments.length - 1]!.replace(/\.git$/i, "")}`;
}
return basename(trimmed.replace(/\.git$/i, ""));
}

View file

@ -0,0 +1,59 @@
import { execFileSync, spawnSync } from "node:child_process";
const SYMBOL_RUNNING = "▶";
const SYMBOL_IDLE = "✓";
function stripStatusPrefix(windowName: string): string {
return windowName
.trimStart()
.replace(new RegExp(`^${SYMBOL_RUNNING}\\s+`), "")
.replace(new RegExp(`^${SYMBOL_IDLE}\\s+`), "")
.trim();
}
export function setWindowStatus(branchName: string, status: string): number {
let symbol: string;
if (status === "running") {
symbol = SYMBOL_RUNNING;
} else if (status === "idle") {
symbol = SYMBOL_IDLE;
} else {
return 0;
}
let stdout: string;
try {
stdout = execFileSync("tmux", ["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
} catch {
return 0;
}
const lines = stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
let count = 0;
for (const line of lines) {
const parts = line.split(":", 3);
if (parts.length !== 3) {
continue;
}
const sessionName = parts[0] ?? "";
const windowId = parts[1] ?? "";
const windowName = parts[2] ?? "";
const clean = stripStatusPrefix(windowName);
if (clean !== branchName) {
continue;
}
const newName = `${symbol} ${branchName}`;
spawnSync("tmux", ["rename-window", "-t", `${sessionName}:${windowId}`, newName], {
stdio: "ignore",
});
count += 1;
}
return count;
}