Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
stripe-bootstrap.ts7.2 KB · 205 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
/**
 * Stripe bootstrap — idempotent setup of products, prices, and webhook.
 *
 * Run via `.github/workflows/stripe-bootstrap.yml` (manual dispatch). Reads
 * STRIPE_SECRET_KEY + APP_BASE_URL from env. Creates the 4 plan tiers
 * (free is a no-op; pro/team/enterprise each get a product + monthly price
 * with a lookup_key matching `gluecron_${slug}_monthly`). Then creates/
 * updates the webhook endpoint at `${APP_BASE_URL}/api/webhooks/stripe`.
 *
 * Idempotent: re-running is safe. Prices are matched by lookup_key, products
 * by name, webhooks by URL.
 *
 * Outputs (printed to stdout, masked in GH Actions):
 *   - The webhook signing secret (whsec_...) — must be stored as a
 *     Fly secret STRIPE_WEBHOOK_SECRET.
 *
 * This script uses only fetch + URLSearchParams — no Stripe SDK dependency.
 */

const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const APP_BASE_URL = process.env.APP_BASE_URL || "https://gluecron.fly.dev";

if (!STRIPE_SECRET_KEY) {
  console.error("STRIPE_SECRET_KEY not set — aborting.");
  process.exit(1);
}

const PLANS = [
  { slug: "pro", name: "Gluecron Pro", priceCents: 900 },
  { slug: "team", name: "Gluecron Team", priceCents: 2900 },
  { slug: "enterprise", name: "Gluecron Enterprise", priceCents: 9900 },
] as const;

const WEBHOOK_URL = `${APP_BASE_URL.replace(/\/$/, "")}/api/webhooks/stripe`;
const WEBHOOK_EVENTS = [
  "checkout.session.completed",
  "customer.subscription.created",
  "customer.subscription.updated",
  "customer.subscription.deleted",
  "invoice.payment_failed",
  "invoice.payment_succeeded",
];

type StripeResult<T> = { ok: true; data: T } | { ok: false; error: string };

async function stripe<T = any>(
  path: string,
  body?: Record<string, string | string[]>,
  method: "GET" | "POST" | "DELETE" = body ? "POST" : "GET"
): Promise<StripeResult<T>> {
  const url = `https://api.stripe.com/v1${path}`;
  const init: RequestInit = {
    method,
    headers: {
      Authorization: `Bearer ${STRIPE_SECRET_KEY}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
  };
  if (body) {
    const params = new URLSearchParams();
    for (const [k, v] of Object.entries(body)) {
      if (Array.isArray(v)) {
        for (const item of v) params.append(`${k}[]`, item);
      } else {
        params.append(k, v);
      }
    }
    init.body = params.toString();
  }
  try {
    const res = await fetch(url, init);
    const json = await res.json();
    if (!res.ok) {
      return { ok: false, error: `${res.status} ${json.error?.message || JSON.stringify(json)}` };
    }
    return { ok: true, data: json as T };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : String(err) };
  }
}

async function findProductByName(name: string): Promise<string | null> {
  const res = await stripe<{ data: Array<{ id: string; name: string; active: boolean }> }>(
    `/products?limit=100&active=true`
  );
  if (!res.ok) return null;
  const match = res.data.data.find((p) => p.name === name);
  return match?.id ?? null;
}

async function findPriceByLookupKey(lookupKey: string): Promise<string | null> {
  const res = await stripe<{ data: Array<{ id: string; lookup_key: string }> }>(
    `/prices?limit=100&lookup_keys[]=${encodeURIComponent(lookupKey)}`
  );
  if (!res.ok) return null;
  return res.data.data[0]?.id ?? null;
}

async function findWebhookByUrl(url: string): Promise<string | null> {
  const res = await stripe<{ data: Array<{ id: string; url: string }> }>(
    `/webhook_endpoints?limit=100`
  );
  if (!res.ok) return null;
  return res.data.data.find((w) => w.url === url)?.id ?? null;
}

async function ensureProduct(plan: (typeof PLANS)[number]): Promise<string> {
  const existing = await findProductByName(plan.name);
  if (existing) {
    console.log(`  ✓ Product already exists: ${plan.name} (${existing})`);
    return existing;
  }
  const res = await stripe<{ id: string }>(`/products`, {
    name: plan.name,
    "metadata[gluecron_plan_slug]": plan.slug,
  });
  if (!res.ok) throw new Error(`Failed to create product ${plan.name}: ${res.error}`);
  console.log(`  + Created product: ${plan.name} (${res.data.id})`);
  return res.data.id;
}

async function ensurePrice(
  productId: string,
  plan: (typeof PLANS)[number]
): Promise<string> {
  const lookupKey = `gluecron_${plan.slug}_monthly`;
  const existing = await findPriceByLookupKey(lookupKey);
  if (existing) {
    console.log(`  ✓ Price already exists: ${lookupKey} (${existing})`);
    return existing;
  }
  const res = await stripe<{ id: string }>(`/prices`, {
    product: productId,
    unit_amount: String(plan.priceCents),
    currency: "usd",
    "recurring[interval]": "month",
    lookup_key: lookupKey,
    "metadata[gluecron_plan_slug]": plan.slug,
  });
  if (!res.ok) throw new Error(`Failed to create price ${lookupKey}: ${res.error}`);
  console.log(`  + Created price: ${lookupKey} (${res.data.id})`);
  return res.data.id;
}

async function ensureWebhook(): Promise<{ id: string; secret: string | null }> {
  const existing = await findWebhookByUrl(WEBHOOK_URL);
  if (existing) {
    console.log(`  ✓ Webhook already exists for ${WEBHOOK_URL} (${existing})`);
    console.log(`    (signing secret is only shown at creation; not re-retrievable)`);
    return { id: existing, secret: null };
  }
  const res = await stripe<{ id: string; secret: string }>(`/webhook_endpoints`, {
    url: WEBHOOK_URL,
    enabled_events: WEBHOOK_EVENTS,
    description: "gluecron billing webhook (auto-created by stripe-bootstrap.ts)",
  });
  if (!res.ok) throw new Error(`Failed to create webhook: ${res.error}`);
  console.log(`  + Created webhook: ${WEBHOOK_URL} (${res.data.id})`);
  return { id: res.data.id, secret: res.data.secret };
}

async function main() {
  const mode = STRIPE_SECRET_KEY.startsWith("sk_live_") ? "LIVE" : "TEST";
  console.log(`\nStripe bootstrap — ${mode} mode`);
  console.log(`App base URL: ${APP_BASE_URL}`);
  console.log(`Webhook URL:  ${WEBHOOK_URL}\n`);

  console.log("== Products + prices ==");
  for (const plan of PLANS) {
    const productId = await ensureProduct(plan);
    await ensurePrice(productId, plan);
  }

  console.log("\n== Webhook endpoint ==");
  const webhook = await ensureWebhook();

  console.log("\n== Summary ==");
  console.log(`Mode: ${mode}`);
  console.log(`Products: ${PLANS.length} (pro/team/enterprise)`);
  console.log(`Webhook: ${webhook.id}`);

  if (webhook.secret) {
    console.log("\n⚠️  WEBHOOK SIGNING SECRET (save this — only shown once):");
    console.log(`STRIPE_WEBHOOK_SECRET=${webhook.secret}`);
    // GitHub Actions masking so it doesn't land in logs
    console.log(`::add-mask::${webhook.secret}`);
    // Emit for workflow step to capture
    if (process.env.GITHUB_OUTPUT) {
      const fs = await import("fs");
      fs.appendFileSync(
        process.env.GITHUB_OUTPUT,
        `webhook_secret=${webhook.secret}\n`
      );
    }
  } else {
    console.log("\n(Webhook already existed — signing secret not re-retrievable.)");
    console.log("If you need to rotate it, delete the webhook in the Stripe dashboard");
    console.log("and re-run this script.");
  }
}

main().catch((err) => {
  console.error("Bootstrap failed:", err instanceof Error ? err.message : err);
  process.exit(1);
});