#!/usr/bin/env npx tsx /** * provision-starter-fleet.ts — Committed, reproducible Railway provisioner * for the "starter container fleet" in the showcase STAGING environment. * * Creates (or idempotently updates) one always-on Railway service per * starter template, pulling the per-starter image from GHCR: * * service name : starter- (RAW starter slug, e.g. * starter-langgraph-js, NOT the * remapped dashboard column slug) * image : ghcr.io/copilotkit/starter-:latest * env : STAGING ONLY (railway-envs STAGING_ENV_ID) * sleep : sleepApplication = false (always-on, so the starters are * staging-probed like every other managed showcase service) * healthcheck : "/" (the starters' single deployable image EXPOSEs 3000 * running the Next.js frontend; the frontend serves "/" and * "/api/copilotkit" but has NO "/api/health" route — that * path 404s and would wedge Railway healthchecks forever. * The agent's "/health" lives on the internal agent port * 8123, which Railway does not expose. So "/" is the only * correct, reachable healthcheck for the exposed surface.) * region : us-west1 (matches every existing showcase service) * GHCR creds : registryCredentials from GITHUB_TOKEN + GHCR username * (same mechanism deploy-to-railway.ts uses) * domain : a generated Railway domain per service (serviceDomainCreate) * * The 12 starter slugs are the keys of STARTER_TO_COLUMN in * showcase/harness/src/probes/helpers/starter-mapping.ts. That list is NOT * literally shared with the smoke matrix (showcase/tests/e2e/starter-smoke.spec.ts) * or the CI build matrix (.github/workflows/showcase_build.yml) — those are * INDEPENDENT lists. The drift test * (showcase/harness/src/probes/helpers/starter-mapping-drift.test.ts) keeps * STARTER_TO_COLUMN in lockstep with the smoke matrix + the on-disk column set * ONLY (it does NOT check showcase_build.yml — the CI build matrix is independent * and not covered by that test). This script derives its target list from * STARTER_TO_COLUMN, so the drift-synced map keeps the fleet aligned with the * smoke/column set. * * SSOT relationship (S2): the 12 starter- services are now FULL * railway-envs SSOT entries (SERVICES in showcase/scripts/railway-envs.ts), * gateValidated + ciBuilt exactly like a showcase-* agent. This script is NOT * a competing source of truth: it is the STAGING PROVISIONER, deriving its 12 * targets from STARTER_TO_COLUMN (the canonical starter-slug map; the smoke * matrix and CI build matrix are INDEPENDENT lists, per the note above). The railway-envs SSOT owns the * image-ref gate + the cluster-promote closure (prod @sha256 pinning); this * script owns idempotent staging service creation. They are complementary, not * double-managing — the slug set is the single shared input, so they cannot * drift. The starter_smoke probe still auto-discovers starter-* services at * runtime (railway-services source, namePrefix "starter-") for verification. * * NOTE: prod pinning + image-ref drift for starters is handled by the * railway-envs gate (verify-railway-image-refs.ts) and bin/railway lint-prod, * NOT by this staging-only provisioner. * * IDEMPOTENT: a starter-* service that already exists in staging is UPDATED * (instance settings re-applied; a domain created only if none exists, and an * "already exists" rejection on re-create is absorbed as a no-op) rather than * duplicated or errored. The pinned image is always (re)deployed via * serviceInstanceRedeploy so it actually runs — serviceInstanceUpdate alone * only pins the image ref. * * Usage: * npx tsx showcase/scripts/provision-starter-fleet.ts # provision all 12 * npx tsx showcase/scripts/provision-starter-fleet.ts --list # list starter-* services in staging * npx tsx showcase/scripts/provision-starter-fleet.ts --dry-run # plan only, no mutations * * Requires: RAILWAY_TOKEN env var or ~/.railway/config.json, plus GITHUB_TOKEN * (+ GHCR_USERNAME or GITHUB_ACTOR) so the private GHCR images can be pulled. */ import { fileURLToPath } from "url"; import { STARTER_TO_COLUMN } from "../harness/src/probes/helpers/starter-mapping"; import { PROJECT_ID, STAGING_ENV_ID } from "./railway-envs"; import { RAILWAY_GRAPHQL_ENDPOINT } from "./lib/railway-graphql"; import { RailwayTokenError, resolveRailwayToken } from "./lib/railway-token"; const RAILWAY_API = RAILWAY_GRAPHQL_ENDPOINT; /** Railway service-name prefix for the starter fleet (RAW starter slug). */ export const STARTER_FLEET_PREFIX = "starter-"; /** * The healthcheck path Railway probes against the exposed container port. * "/" — see the file header for why this is NOT "/api/health". */ export const STARTER_HEALTHCHECK_PATH = "/"; /** Region every existing showcase service runs in; match it. */ export const STARTER_REGION = "us-west1"; // ── Target derivation (from the starter-mapping SSOT) ─────────────────── export interface StarterTarget { /** RAW starter slug, e.g. "langgraph-js", "adk". */ slug: string; /** Railway service name, e.g. "starter-langgraph-js". */ serviceName: string; /** GHCR image ref, e.g. "ghcr.io/copilotkit/starter-langgraph-js:latest". */ image: string; } /** * Derive the 12 provisioning targets from STARTER_TO_COLUMN (the SSOT). The * service name and image both use the RAW starter slug (the map KEY), never * the remapped dashboard column slug (the map VALUE). Sorted for stable, * reproducible output. * * Exported pure for unit testing. */ export function deriveStarterTargets( mapping: Readonly> = STARTER_TO_COLUMN, ): StarterTarget[] { return Object.keys(mapping) .sort() .map((slug) => ({ slug, serviceName: `${STARTER_FLEET_PREFIX}${slug}`, image: `ghcr.io/copilotkit/${STARTER_FLEET_PREFIX}${slug}:latest`, })); } // ── GHCR registry credentials ─────────────────────────────────────────── export interface RegistryCredentials { username: string; password: string; } /** * Resolve GHCR registry credentials the same way deploy-to-railway.ts does: * GITHUB_TOKEN as the password, and GHCR_USERNAME (preferred) or GITHUB_ACTOR * (CI) as the username. Returns undefined when GITHUB_TOKEN is unset (caller * warns and proceeds without creds). THROWS when a token is present but no * username is available — fail loud rather than baking a personal handle in. * * Exported pure for unit testing. */ export function resolveRegistryCredentials( env: NodeJS.ProcessEnv = process.env, ): RegistryCredentials | undefined { const githubToken = env.GITHUB_TOKEN; if (!githubToken) return undefined; const ghcrUser = (env.GHCR_USERNAME || env.GITHUB_ACTOR || "").trim(); if (!ghcrUser) { throw new Error( "GITHUB_TOKEN is set but no GHCR username is available. Set GHCR_USERNAME (or GITHUB_ACTOR in CI) to the username the token is issued to.", ); } return { username: ghcrUser, password: githubToken }; } // ── Injectable Railway GraphQL boundary ───────────────────────────────── /** Minimal GraphQL caller signature — injected so the core is unit-testable. */ export type RailwayGqlFn = ( query: string, variables?: Record, ) => Promise; interface ProjectServicesResult { project: { services: { edges: Array<{ node: { id: string; name: string; // A transitional service can return a null serviceInstances // connection (or null .edges) — both fields are optional/nullable // so the drain loop can coalesce instead of throwing a TypeError // that would abort the entire fetch. serviceInstances?: { edges?: Array<{ node: { environmentId: string; domains?: { serviceDomains?: Array<{ domain: string }>; } | null; }; }> | null; } | null; }; }>; pageInfo?: { hasNextPage: boolean; endCursor?: string | null; } | null; }; } | null; } interface ServiceCreateResult { serviceCreate: { id: string; name: string }; } interface ServiceDomainResult { serviceDomainCreate: { domain: string }; } /** * `serviceInstanceRedeploy` returns a Boolean! in Railway's schema — `true` * once the redeploy is enqueued. VERIFIED against the two in-repo consumers * that read this mutation's result: * - showcase/scripts/redeploy-env.ts types it `serviceInstanceRedeploy?: * boolean` and gates on `!== true`; * - showcase/bin/railway (RestoreCommand P5) requires * `redeployed["serviceInstanceRedeploy"]` truthy. * Both treat it as a bare boolean, and bin/railway's REDEPLOY_MUTATION selects * NO subfields on the result (confirming a scalar, not an object/deployment). */ interface ServiceInstanceRedeployResult { serviceInstanceRedeploy: boolean | null; } /** * `serviceInstanceUpdate` returns a Boolean! in Railway's schema — `true` * once the instance config (sleepApplication / healthcheck / image / region / * registryCredentials) is applied. Same precedent as serviceInstanceRedeploy * (redeploy-env.ts / bin/railway treat the scalar as a bare boolean). A * `false`/`null` return means the config was NOT applied — e.g. the service * would run WITHOUT the intended always-on/healthcheck config — so it must be * asserted, never discarded. */ interface ServiceInstanceUpdateResult { serviceInstanceUpdate: boolean | null; } /** * Uniform Railway-mutation-result guard: a single chokepoint so no mutation's * result can be silently discarded (the "mutation result not validated" defect * class). `ok` extracts the meaningful field from the result; when it is * falsy (empty string, false, null, undefined) we throw, naming the mutation * and service so the failure is forensic rather than a silent success. * * Returns the (now-verified) result for fluent use at the call site. */ function assertMutationOk( result: T, ok: (r: T) => unknown, mutation: string, serviceName: string, serviceId: string, note = "mutation did not take effect; refusing to report success.", ): T { const value = ok(result); if (!value) { throw new Error( `${mutation} returned ${JSON.stringify( value, )} for ${serviceName} (${serviceId}) — ${note}`, ); } return result; } /** Existing-service lookup result: id + whether it already has a staging domain. */ export interface ExistingService { id: string; hasStagingDomain: boolean; } /** * Fetch the project's services (with their staging-env domains) and index * them by name. Returns a map of serviceName -> ExistingService so the * provisioner can decide create-vs-update and skip redundant domain creation. * * Exported for unit testing against an injected RailwayGqlFn. */ export async function fetchExistingServices( gql: RailwayGqlFn, projectId: string, stagingEnvId: string, ): Promise> { const byName = new Map(); // `project.services` is a Relay ServiceConnection that PAGE-LIMITS. The // showcase project holds ~27 SSOT + 12 starter services, comfortably more // than one page. A single un-paginated query returns a TRUNCATED snapshot — // a starter that lands on a later page then looks ABSENT, the provisioner // takes the CREATE path, and serviceCreate rejects with a non-transient // "already exists" (NOT retried) which ABORTS the whole run. So drain every // page via `pageInfo.hasNextPage`/`endCursor` (standard Relay cursor loop — // bin/railway's SERVICES_LIST_QUERY predates this hazard and is unpaginated, // so there is no in-repo precedent to mirror), accumulating into byName. let after: string | null = null; // Defensive upper bound so a malformed `pageInfo` (always hasNextPage with a // stuck cursor) can't spin forever — far above any realistic project size. for (let page = 0; page < 1000; page++) { const data: ProjectServicesResult = await gql( `query project($id: String!, $after: String) { project(id: $id) { services(first: 100, after: $after) { edges { node { id name serviceInstances { edges { node { environmentId domains { serviceDomains { domain } } } } } } } pageInfo { hasNextPage endCursor } } } }`, { id: projectId, after }, ); if (!data.project) { throw new Error( `Railway project ${projectId} returned null — check PROJECT_ID and that the Railway token has access to this project.`, ); } for (const edge of data.project.services.edges) { const svc = edge.node; // A transitional service can surface a null serviceInstances connection // (or null .edges); coalesce so an unguarded `.find` can't throw a // TypeError that aborts the entire fetch before any starter is touched. const stagingInstance = (svc.serviceInstances?.edges ?? []).find( (e) => e.node.environmentId === stagingEnvId, ); const hasStagingDomain = (stagingInstance?.node.domains?.serviceDomains?.length ?? 0) > 0; byName.set(svc.name, { id: svc.id, hasStagingDomain }); } const pageInfo = data.project.services.pageInfo; if (!pageInfo?.hasNextPage || !pageInfo.endCursor) { return byName; } after = pageInfo.endCursor; } // Reaching the defensive page bound while pageInfo.hasNextPage is STILL true // means the snapshot is TRUNCATED — services on undrained pages look absent, // which would feed erroneous CREATE decisions (and a non-transient "already // exists" abort). Refuse to provision against a partial snapshot: fail loud // rather than return the truncated map. throw new Error( `fetchExistingServices drained ${byName.size} services across the page bound but Railway still reports more pages (hasNextPage) — refusing to provision against a truncated service snapshot.`, ); } export interface ProvisionOptions { gql: RailwayGqlFn; projectId: string; stagingEnvId: string; targets: StarterTarget[]; registryCredentials?: RegistryCredentials; /** When true, no mutations are sent — plan only. */ dryRun?: boolean; log?: (line: string) => void; /** * Sleep implementation between retries of the post-create * serviceInstanceUpdate / serviceDomainCreate (Railway materializes the * env-scoped instance asynchronously). Injected so tests don't actually * wait. Defaults to a real setTimeout-backed delay. */ sleepMs?: (ms: number) => Promise; } /** Default inter-retry delay schedule (ms). */ const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000]; /** * The Railway eventual-consistency error classes that warrant a retry: the * env-scoped serviceInstance is materialized asynchronously after * serviceCreate, so both serviceInstanceUpdate AND serviceDomainCreate / * serviceInstanceRedeploy issued too eagerly can race it. The instance is * surfaced either as "ServiceInstance not found" or (for the domain create) * as a generic "Service ... not found" while the instance is still settling. * NOTE: an "already exists" domain error is NON-transient and is handled * separately (caught as a benign no-op) — it must NOT match here. * * Railway INTERPOLATES the service id into the second form — the real message * is "Service not found" (e.g. "Service abc-123 not found"), NOT the * contiguous "Service not found". So the pattern matches "Service" followed by * "not found" with the id in between; the `(Instance)?` alternation still * covers the literal "ServiceInstance not found" form. The bridge is `[^\n]*?` * (NOT `[\s\S]*?`) — the interpolated id never contains a newline, so keeping * the match SINGLE-LINE prevents a multi-error newline-joined GraphQL blob * from bridging an unrelated "Service ..." line to a "... not found" line and * mis-classifying a non-transient failure as the eventual-consistency signal. */ const TRANSIENT_ERROR_RE = /Service(Instance)?\b[^\n]*?not found/i; /** * Retry a Railway mutation that can transiently fail while the env-scoped * instance is still materializing after serviceCreate. Re-throws * non-transient errors immediately and re-throws the last transient error * once the schedule is exhausted. The transient predicate is overridable * per-call so callers with a different eventual-consistency signature can * supply their own. */ async function withRetry( fn: () => Promise, sleep: (ms: number) => Promise, isTransient: (msg: string) => boolean = (msg) => TRANSIENT_ERROR_RE.test(msg), ): Promise { let lastErr: unknown; for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) { try { return await fn(); } catch (e) { lastErr = e; const msg = e instanceof Error ? e.message : String(e); // Only retry the known eventual-consistency errors. if (!isTransient(msg)) throw e; if (attempt === RETRY_DELAYS_MS.length) break; await sleep(RETRY_DELAYS_MS[attempt]); } } // Schedule exhausted on a transient error: wrap the rethrow with context // (how many retries, that it was transient) so the operator sees WHY the // run failed, preserving the original error as `cause`. const lastMsg = lastErr instanceof Error ? lastErr.message : String(lastErr); throw new Error( `Exhausted ${RETRY_DELAYS_MS.length} retries for transient Railway error: ${lastMsg}`, { cause: lastErr }, ); } /** * An "already exists"-class rejection. Two call sites converge on it rather * than abort, on a partial-failure re-run (the start-of-run snapshot missed * the resource due to Railway eventual consistency, or a prior run died * mid-fleet): * - serviceDomainCreate: treat as the domain already existing (benign no-op); * - serviceCreate: re-fetch the now-visible service by name and fall through * to the UPDATE path instead of aborting the whole fleet. * This is NOT a transient/retryable error — matching it in the retry predicate * would waste the retry schedule on a permanent condition. */ const ALREADY_EXISTS_RE = /(already\s+exists|duplicate)/i; export interface ProvisionRecord { slug: string; serviceName: string; image: string; serviceId: string; action: "created" | "updated"; domain?: string; domainAction: "created" | "existing" | "skipped" | "would-create"; } export interface ProvisionSummary { records: ProvisionRecord[]; } /** * The idempotent provisioning core. For each target: * - create the service (source.image = GHCR ref) if it does not exist, * otherwise reuse the existing service id (UPDATE path); * - serviceInstanceUpdate against the STAGING env with * sleepApplication: false + healthcheckPath + region + (optional) GHCR * registryCredentials — applied on BOTH the create and update paths so a * re-run converges drifted settings; * - serviceInstanceRedeploy so the pinned image ACTUALLY RUNS. A * serviceCreate + serviceInstanceUpdate(source.image) only pins the image * ref; it does NOT start a deployment. Railway's image auto-updates fire * only when a NEW digest is pushed to the tag, so a freshly-provisioned * service whose :latest digest already exists would sit with no running * deployment (and the starter_smoke probe would never find it up) without * this explicit redeploy. This mirrors the documented update+redeploy * pattern in bin/railway (RestoreCommand / pin_and_verify) and the * explicit serviceInstanceRedeploy that showcase_deploy.yml issues after * every GHCR push (see showcase/RAILWAY.md). The update path re-asserts * source.image too, so it also redeploys to run the (re-)pinned image. * - serviceDomainCreate for the staging env, but ONLY when the service has * no staging domain yet (so re-runs don't pile up domains). A * serviceDomainCreate that rejects with an "already exists" error (the * snapshot missed the domain due to eventual consistency, or a prior run * died mid-fleet) is caught as a benign no-op so the re-run converges * instead of aborting the remaining fleet. * * Pure w.r.t. I/O: every Railway interaction goes through the injected `gql`. */ export async function provisionStarterFleet( opts: ProvisionOptions, ): Promise { const { gql, projectId, stagingEnvId, targets, registryCredentials, dryRun = false, log = () => {}, sleepMs = (ms: number) => new Promise((r) => setTimeout(r, ms)), } = opts; const existing = await fetchExistingServices(gql, projectId, stagingEnvId); const records: ProvisionRecord[] = []; for (const target of targets) { const prior = existing.get(target.serviceName); let serviceId: string; let action: "created" | "updated"; if (prior) { serviceId = prior.id; action = "updated"; log(`↻ ${target.serviceName} exists (${serviceId}) — updating`); } else if (dryRun) { serviceId = ""; action = "created"; log(`+ ${target.serviceName} would be created (${target.image})`); } else { // CRITICAL: pass `environmentId` so the new service instance is // scoped to STAGING only. Without it, Railway materializes the // instance in the project's DEFAULT (production) environment — which // would leak a prod instance for every starter. `registryCredentials` // is also supplied here so the staging instance can pull the private // GHCR image from birth (before the serviceInstanceUpdate below // re-asserts the rest of the config). const createInput: Record = { projectId, environmentId: stagingEnvId, name: target.serviceName, source: { image: target.image }, }; if (registryCredentials) { createInput.registryCredentials = registryCredentials; } try { const created = await gql( `mutation serviceCreate($input: ServiceCreateInput!) { serviceCreate(input: $input) { id name } }`, { input: createInput }, ); assertMutationOk( created, (r) => r.serviceCreate?.id, "serviceCreate", target.serviceName, "", ); serviceId = created.serviceCreate.id; action = "created"; log(`+ ${target.serviceName} created (${serviceId})`); } catch (e) { // A snapshot-miss (Railway eventual consistency) can make a service // look absent → CREATE path → a non-transient "already exists" // rejection. That must NOT abort the whole fleet: re-fetch the now- // visible service id by name and fall through to the UPDATE path so // the run converges. Any other error still fails loud. const msg = e instanceof Error ? e.message : String(e); if (!ALREADY_EXISTS_RE.test(msg)) throw e; const refetched = await fetchExistingServices( gql, projectId, stagingEnvId, ); const found = refetched.get(target.serviceName); if (!found) { // The create said "already exists" but a re-fetch can't find it — // genuinely inconsistent; fail loud rather than guess. throw new Error( `serviceCreate for ${target.serviceName} rejected as already-existing, but a re-fetch could not find it by name — refusing to proceed against an inconsistent snapshot.`, { cause: e }, ); } serviceId = found.id; action = "updated"; log( `↻ ${target.serviceName} already exists (re-fetched ${serviceId}) — updating: ${msg}`, ); } } // Apply sleep + healthcheck + region (+ creds) against staging on BOTH // paths. The image source is also re-asserted on the update path so a // drifted existing service converges back to the canonical GHCR ref. const instanceInput: Record = { source: { image: target.image }, sleepApplication: false, healthcheckPath: STARTER_HEALTHCHECK_PATH, region: STARTER_REGION, }; if (registryCredentials) { instanceInput.registryCredentials = registryCredentials; } if (!dryRun && serviceId !== "") { // Railway materializes the env-scoped serviceInstance asynchronously // after serviceCreate; a serviceInstanceUpdate issued too eagerly can // race it ("ServiceInstance not found"). Retry briefly so a fresh // create converges. Existing services (update path) hit this on the // first try. const updated = await withRetry( () => gql( `mutation serviceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) { serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input) }`, { serviceId, environmentId: stagingEnvId, input: instanceInput, }, ), sleepMs, ); // serviceInstanceUpdate returns Boolean! — a `false`/`null` return means // sleepApplication/healthcheck/image/creds were NOT applied (the service // would run WITHOUT the intended always-on/healthcheck config) while the // script reports success. Assert the result and only log "configured ..." // AFTER it is verified. assertMutationOk( updated, (r) => r.serviceInstanceUpdate, "serviceInstanceUpdate", target.serviceName, serviceId, ); log( ` configured sleep=false, healthcheck=${STARTER_HEALTHCHECK_PATH}, region=${STARTER_REGION}${ registryCredentials ? ", registry creds" : "" }`, ); // Redeploy so the pinned image ACTUALLY RUNS. serviceInstanceUpdate // only pins source.image; without this the service has no running // deployment and starter_smoke would never find it up. Wrapped in // withRetry for the same instance-materialization race as the update. const redeployed = await withRetry( () => gql( `mutation serviceInstanceRedeploy($serviceId: String!, $environmentId: String!) { serviceInstanceRedeploy(serviceId: $serviceId, environmentId: $environmentId) }`, { serviceId, environmentId: stagingEnvId }, ), sleepMs, ); // serviceInstanceRedeploy returns Boolean! (see the result-type comment // for the verified contract + sources). A `false`/`null` return means // Railway did NOT enqueue a deployment, so the image would never run — // fail loud rather than report success. Routed through the same uniform // mutation-result guard as serviceCreate / serviceInstanceUpdate; any // truthy value is accepted defensively in case the contract ever widens. assertMutationOk( redeployed, (r) => r.serviceInstanceRedeploy, "serviceInstanceRedeploy", target.serviceName, serviceId, "image will not run.", ); log(` redeployed — image now running`); } // Domain: create only when none exists yet (idempotent). let domain: string | undefined; let domainAction: "created" | "existing" | "skipped" | "would-create"; if (prior?.hasStagingDomain) { domainAction = "existing"; log(` staging domain already present — skipping create`); } else if (dryRun) { // Faithful preview: a real run WOULD create a domain here (the service // has none yet), so report "would-create" rather than the misleading // "skipped". domainAction = "would-create"; log(` domain: would create (none present)`); } else { try { const domainResult = await withRetry( () => gql( `mutation serviceDomainCreate($input: ServiceDomainCreateInput!) { serviceDomainCreate(input: $input) { domain } }`, { input: { serviceId, environmentId: stagingEnvId }, }, ), sleepMs, ); // Assert the create actually yielded a domain — a null/empty domain // is a silent-success the script must not report as created. assertMutationOk( domainResult, (r) => r.serviceDomainCreate?.domain, "serviceDomainCreate", target.serviceName, serviceId, ); domain = domainResult.serviceDomainCreate.domain; domainAction = "created"; log(` domain: https://${domain}`); } catch (e) { // Idempotent convergence on a partial-failure re-run: if the domain // already exists (the start-of-run snapshot missed it, or a prior run // died mid-fleet), Railway rejects the create with a non-transient // "already exists" error. That is benign — treat it as existing and // KEEP GOING rather than aborting the remaining fleet. Any other // error still aborts (fail loud). const msg = e instanceof Error ? e.message : String(e); if (!ALREADY_EXISTS_RE.test(msg)) throw e; domainAction = "existing"; // Include the ACTUAL matched Railway message for a forensic trail — // so a re-run log shows exactly which "already exists" wording was // absorbed, not just a generic note. log( ` staging domain already exists (per Railway) — treating as no-op: ${msg}`, ); } } records.push({ slug: target.slug, serviceName: target.serviceName, image: target.image, serviceId, action, domain, domainAction, }); } return { records }; } // ── Live GraphQL caller ───────────────────────────────────────────────── /** * Resolve the Railway token, normalizing the typed RailwayTokenError into a * plain Error so main().catch owns the process exit (rather than a deep * process.exit(1) inside the GraphQL boundary). main() validates this UP * FRONT — before any provisioning — so a missing token fails fast instead of * mid-mutation. */ function resolveTokenOrThrow(): string { try { return resolveRailwayToken().token; } catch (e) { if (e instanceof RailwayTokenError) { throw new Error(e.message, { cause: e }); } throw e; } } /** Build a live GraphQL caller bound to a single, already-resolved token. */ function makeLiveGql(token: string): RailwayGqlFn { return async function railwayGql( query: string, variables: Record = {}, ): Promise { const res = await fetch(RAILWAY_API, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ query, variables }), }); if (!res.ok) { const text = await res.text(); throw new Error(`Railway API error: ${res.status} ${text}`); } const json = (await res.json()) as { data?: T; errors?: Array<{ message: string }>; }; if (json.errors?.length) { throw new Error( `Railway GraphQL errors:\n${json.errors .map((e) => ` - ${e.message}`) .join("\n")}`, ); } return json.data as T; }; } // ── Subcommands ───────────────────────────────────────────────────────── async function listStarterServices(gql: RailwayGqlFn): Promise { const existing = await fetchExistingServices(gql, PROJECT_ID, STAGING_ENV_ID); const starters = [...existing.entries()] .filter(([name]) => name.startsWith(STARTER_FLEET_PREFIX)) .sort(([a], [b]) => a.localeCompare(b)); console.log(`Starter-fleet services in staging (${starters.length}):\n`); for (const [name, svc] of starters) { console.log( ` ${name.padEnd(40)} ${svc.id} ${ svc.hasStagingDomain ? "[domain]" : "[no domain]" }`, ); } } const USAGE = `Usage: npx tsx showcase/scripts/provision-starter-fleet.ts Provision all 12 starter-* services in staging npx tsx showcase/scripts/provision-starter-fleet.ts --list List starter-* services in staging npx tsx showcase/scripts/provision-starter-fleet.ts --dry-run Plan only (no mutations) `; export interface ParsedArgs { help: boolean; list: boolean; dryRun: boolean; } /** The flags this script recognizes. */ const KNOWN_FLAGS = new Set(["--help", "--list", "--dry-run"]); /** * Parse argv into the recognized flags, REJECTING any unrecognized argument. * Without this, a mistyped flag (e.g. `--dry-rn`) is silently ignored and — * because dryRun stays false — the script proceeds to REAL live provisioning, * the exact opposite of the operator's intent. Throws on any unknown arg so * main().catch aborts before any mutation. * * Exported pure for unit testing. */ export function parseArgs(args: string[]): ParsedArgs { for (const arg of args) { if (!KNOWN_FLAGS.has(arg)) { throw new Error(`Unknown argument: ${arg}\n${USAGE}`); } } return { help: args.includes("--help"), list: args.includes("--list"), dryRun: args.includes("--dry-run"), }; } async function main(): Promise { const parsed = parseArgs(process.argv.slice(2)); if (parsed.help) { console.log(USAGE); return; } // Validate the Railway token UP FRONT — before any provisioning — so a // missing/unreadable token fails fast (and main().catch owns the exit) // rather than blowing up mid-mutation on the first GraphQL call. const token = resolveTokenOrThrow(); const gql = makeLiveGql(token); if (parsed.list) { await listStarterServices(gql); return; } const dryRun = parsed.dryRun; const targets = deriveStarterTargets(); // Validate registry credentials UP FRONT too (mirrors the existing // resolveRegistryCredentials validation) so a token-without-username // misconfig fails before any mutation. The throw propagates to // main().catch — no deep process.exit here. const registryCredentials = resolveRegistryCredentials(); if (!registryCredentials) { if (dryRun) { console.warn( "\n WARNING: GITHUB_TOKEN not set. Registry credentials will NOT be configured — Railway cannot pull the private GHCR starter images until creds are added.\n", ); } else { // ABORT on the LIVE path: creating services that point at a PRIVATE // GHCR image with no pull credentials yields a perpetual // image-pull-backoff while the script reports "success". That is worse // than failing — fail loud up front. warn-and-continue is only // tolerable under --dry-run, where no service is actually created. throw new Error( "GITHUB_TOKEN is not set. The starter images are PRIVATE GHCR images; provisioning live services without registry credentials would leave every service in image-pull-backoff while reporting success. Set GITHUB_TOKEN (+ GHCR_USERNAME / GITHUB_ACTOR) and re-run, or use --dry-run to plan without mutations.", ); } } console.log( `Provisioning ${targets.length} always-on starter-* services in STAGING (env ${STAGING_ENV_ID})${ dryRun ? " [DRY RUN]" : "" }\n`, ); const summary = await provisionStarterFleet({ gql, projectId: PROJECT_ID, stagingEnvId: STAGING_ENV_ID, targets, registryCredentials, dryRun, log: (line) => console.log(line), }); console.log(`\nDone. ${summary.records.length} services processed:\n`); for (const r of summary.records) { console.log( ` ${r.serviceName.padEnd(40)} ${r.serviceId} (${r.action}${ r.domain ? `, https://${r.domain}` : `, domain:${r.domainAction}` })`, ); } } if (process.argv[1] === fileURLToPath(import.meta.url)) { main().catch((e) => { console.error(e); process.exit(1); }); }