{"_id":"@infracraft/pulumi","_rev":"152-8ec16d5b1cd90a907dd286c1e546f002","name":"@infracraft/pulumi","dist-tags":{"latest":"3.0.0"},"versions":{"2.7.1":{"name":"@infracraft/pulumi","version":"2.7.1","license":"MIT","_id":"@infracraft/pulumi@2.7.1","maintainers":[{"name":"andredezzy","email":"andrevcv1@gmail.com"}],"homepage":"https://github.com/andredezzy/infracraft#readme","bugs":{"url":"https://github.com/andredezzy/infracraft/issues"},"dist":{"shasum":"69dace3342793c3f930f1baf022bbc9838ff6e4c","tarball":"https://registry.npmjs.org/@infracraft/pulumi/-/pulumi-2.7.1.tgz","fileCount":385,"integrity":"sha512-cZ97NesPZIJDOwgkySTjuvDDhHrOkzxjEF2rERbq0ngwuiTnauyqyKz+EzZkrXYFzfqVUCnqXYZNSRDy0y6gTA==","signatures":[{"sig":"MEUCIQCrXzJrYqtjdjOeCnjdliTNdbKIm+lxW/HffVT+zFfvKQIgH7oNja1V5yhaviXdEbv4wn6k4b/8+Bsbs2gT8eHEFt0=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":1686483},"type":"module","exports":{"./fly":{"types":"./dist/fly/index.d.mts","default":"./dist/fly/index.mjs"},"./hash":{"types":"./dist/hash.d.mts","default":"./dist/hash.mjs"},"./neon":{"types":"./dist/neon/index.d.mts","default":"./dist/neon/index.mjs"},"./vercel":{"types":"./dist/vercel/index.d.mts","default":"./dist/vercel/index.mjs"},"./railway":{"types":"./dist/railway/index.d.mts","default":"./dist/railway/index.mjs"},"./sandbox":{"types":"./dist/sandbox.d.mts","default":"./dist/sandbox.mjs"},"./git-guard":{"types":"./dist/git-guard.d.mts","default":"./dist/git-guard.mjs"},"./preflight":{"types":"./dist/preflight/index.d.mts","default":"./dist/preflight/index.mjs"}},"gitHead":"5572733a37b6bd13dda0a3be9685e67bbd916d0e","scripts":{"dev":"tsdown --watch","lint":"biome check --write","test":"vitest run","build":"tsdown","test:live":"vitest run --config vitest.live.config.ts","typecheck":"tsc --noEmit","test:drift":"vitest run --config vitest.drift.config.ts"},"_npmUser":{"name":"andredezzy","email":"andrevcv1@gmail.com"},"repository":{"url":"git+https://github.com/andredezzy/infracraft.git","type":"git","directory":"packages/pulumi"},"_npmVersion":"11.12.1","description":"Native Pulumi providers for Railway, Neon, Vercel, and Fly.io with adopt-or-create semantics and deploy orchestration. No Terraform bridge.","directories":{},"_nodeVersion":"24.15.0","_hasShrinkwrap":false,"peerDependencies":{"@pulumi/pulumi":"^3","@pulumi/command":"^1"},"peerDependenciesMeta":{"@pulumi/command":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/pulumi_2.7.1_1783369848018_0.9547130040418443","host":"s3://npm-registry-packages-npm-production"}},"2.8.0":{"name":"@infracraft/pulumi","version":"2.8.0","license":"MIT","_id":"@infracraft/pulumi@2.8.0","maintainers":[{"name":"andredezzy","email":"andrevcv1@gmail.com"}],"homepage":"https://github.com/andredezzy/infracraft#readme","bugs":{"url":"https://github.com/andredezzy/infracraft/issues"},"dist":{"shasum":"b0c05201e49dac70a1e5446afa1702db6969b8c0","tarball":"https://registry.npmjs.org/@infracraft/pulumi/-/pulumi-2.8.0.tgz","fileCount":385,"integrity":"sha512-Ai0yUU8L32yKMjowDPtidqnbBJp8b4BpTmE8HrincMuGiYG/2WzmGl5DJQch3FJhGKp2EUHEVEITRCXHdoDrqw==","signatures":[{"sig":"MEUCIQCsdcvxEO8Tp8dBN/aPwYcu75aq04vbSsw/PUWgXUBjWQIgPlG96/G4GtXW/PERtnorUd+5gktd5P0Y+x9RVxJ9WSM=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@infracraft%2fpulumi@2.8.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":1699744},"type":"module","exports":{"./fly":{"types":"./dist/fly/index.d.mts","default":"./dist/fly/index.mjs"},"./hash":{"types":"./dist/hash.d.mts","default":"./dist/hash.mjs"},"./neon":{"types":"./dist/neon/index.d.mts","default":"./dist/neon/index.mjs"},"./vercel":{"types":"./dist/vercel/index.d.mts","default":"./dist/vercel/index.mjs"},"./railway":{"types":"./dist/railway/index.d.mts","default":"./dist/railway/index.mjs"},"./sandbox":{"types":"./dist/sandbox.d.mts","default":"./dist/sandbox.mjs"},"./git-guard":{"types":"./dist/git-guard.d.mts","default":"./dist/git-guard.mjs"},"./preflight":{"types":"./dist/preflight/index.d.mts","default":"./dist/preflight/index.mjs"}},"gitHead":"61e37e33c058e7b61e62cc50916aa94b8ffc7eb7","scripts":{"dev":"tsdown --watch","lint":"biome check --write","test":"vitest run","build":"tsdown","prepack":"node ../../scripts/strip-dev-deps-on-pack.mjs","postpack":"node ../../scripts/strip-dev-deps-on-pack.mjs --restore","test:live":"vitest run --config vitest.live.config.ts","typecheck":"tsc --noEmit","test:drift":"vitest run --config vitest.drift.config.ts"},"_npmUser":{"name":"andredezzy","email":"andrevcv1@gmail.com"},"repository":{"url":"git+https://github.com/andredezzy/infracraft.git","type":"git","directory":"packages/pulumi"},"_npmVersion":"10.9.8","description":"Native Pulumi providers for Railway, Neon, Vercel, and Fly.io with adopt-or-create semantics and deploy orchestration. No Terraform bridge.","directories":{},"_nodeVersion":"22.23.1","_hasShrinkwrap":false,"devDependencies":{"vitest":"4.1.7","typescript":"6.0.3","@types/node":"25.9.1","@pulumi/pulumi":"3.250.0","@pulumi/command":"1.2.1","@infracraft/sandbox":"workspace:*","@infracraft/config-test":"workspace:*","@infracraft/config-tsdown":"workspace:*","@infracraft/typescript-config":"workspace:*"},"peerDependencies":{"@pulumi/pulumi":"^3","@pulumi/command":"^1"},"peerDependenciesMeta":{"@pulumi/command":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/pulumi_2.8.0_1783518871878_0.33781231744483664","host":"s3://npm-registry-packages-npm-production"}},"2.9.0":{"name":"@infracraft/pulumi","version":"2.9.0","license":"MIT","_id":"@infracraft/pulumi@2.9.0","maintainers":[{"name":"andredezzy","email":"andrevcv1@gmail.com"}],"homepage":"https://github.com/andredezzy/infracraft#readme","bugs":{"url":"https://github.com/andredezzy/infracraft/issues"},"dist":{"shasum":"d32bda6c48bc092d762d9fcc161f7994b4ce5798","tarball":"https://registry.npmjs.org/@infracraft/pulumi/-/pulumi-2.9.0.tgz","fileCount":385,"integrity":"sha512-7YElIW2+Bzr4Cg9NXYnyz5pjiDC17bXEXEf/50JSxswHi5SAjMnGeeMq/M6dnS3vOeOl1Ss+9MwB2tqlvBcfRw==","signatures":[{"sig":"MEUCIQC7em4YvBKmvvC0YSJhNtxTZTWZaS6d8mu22PMSNKFfOgIgc5o9iLugz5CCB37JgfEGv4KVStCIdGydW8r63TVGoMY=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@infracraft%2fpulumi@2.9.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":1704646},"type":"module","exports":{"./fly":{"types":"./dist/fly/index.d.mts","default":"./dist/fly/index.mjs"},"./hash":{"types":"./dist/hash.d.mts","default":"./dist/hash.mjs"},"./neon":{"types":"./dist/neon/index.d.mts","default":"./dist/neon/index.mjs"},"./vercel":{"types":"./dist/vercel/index.d.mts","default":"./dist/vercel/index.mjs"},"./railway":{"types":"./dist/railway/index.d.mts","default":"./dist/railway/index.mjs"},"./sandbox":{"types":"./dist/sandbox.d.mts","default":"./dist/sandbox.mjs"},"./git-guard":{"types":"./dist/git-guard.d.mts","default":"./dist/git-guard.mjs"},"./preflight":{"types":"./dist/preflight/index.d.mts","default":"./dist/preflight/index.mjs"}},"gitHead":"070663814ad8c49b58503f10fb332e9268bdb707","scripts":{"dev":"tsdown --watch","lint":"biome check --write","test":"vitest run","build":"tsdown","prepack":"node ../../scripts/strip-dev-deps-on-pack.mjs","postpack":"node ../../scripts/strip-dev-deps-on-pack.mjs --restore","test:live":"vitest run --config vitest.live.config.ts","typecheck":"tsc --noEmit","test:drift":"vitest run --config vitest.drift.config.ts"},"_npmUser":{"name":"andredezzy","email":"andrevcv1@gmail.com"},"repository":{"url":"git+https://github.com/andredezzy/infracraft.git","type":"git","directory":"packages/pulumi"},"_npmVersion":"10.9.8","description":"Native Pulumi providers for Railway, Neon, Vercel, and Fly.io with adopt-or-create semantics and deploy orchestration. No Terraform bridge.","directories":{},"_nodeVersion":"22.23.1","_hasShrinkwrap":false,"devDependencies":{"vitest":"4.1.7","typescript":"6.0.3","@types/node":"25.9.1","@pulumi/pulumi":"3.250.0","@pulumi/command":"1.2.1","@infracraft/sandbox":"workspace:*","@infracraft/config-test":"workspace:*","@infracraft/config-tsdown":"workspace:*","@infracraft/typescript-config":"workspace:*"},"peerDependencies":{"@pulumi/pulumi":"^3","@pulumi/command":"^1"},"peerDependenciesMeta":{"@pulumi/command":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/pulumi_2.9.0_1783530339143_0.6271129273867162","host":"s3://npm-registry-packages-npm-production"}},"3.0.0":{"name":"@infracraft/pulumi","version":"3.0.0","type":"module","license":"MIT","description":"Native Pulumi providers for Railway, Neon, Vercel, and Fly.io with adopt-or-create semantics and deploy orchestration. No Terraform bridge.","repository":{"type":"git","url":"git+https://github.com/andredezzy/infracraft.git","directory":"packages/pulumi"},"exports":{"./railway":{"types":"./dist/railway/index.d.mts","default":"./dist/railway/index.mjs"},"./neon":{"types":"./dist/neon/index.d.mts","default":"./dist/neon/index.mjs"},"./vercel":{"types":"./dist/vercel/index.d.mts","default":"./dist/vercel/index.mjs"},"./fly":{"types":"./dist/fly/index.d.mts","default":"./dist/fly/index.mjs"},"./hash":{"types":"./dist/hash.d.mts","default":"./dist/hash.mjs"},"./git-guard":{"types":"./dist/git-guard.d.mts","default":"./dist/git-guard.mjs"},"./sandbox":{"types":"./dist/sandbox.d.mts","default":"./dist/sandbox.mjs"},"./preflight":{"types":"./dist/preflight/index.d.mts","default":"./dist/preflight/index.mjs"}},"scripts":{"build":"tsdown","dev":"tsdown --watch","typecheck":"tsc --noEmit","test":"vitest run","test:drift":"vitest run --config vitest.drift.config.ts","test:live":"vitest run --config vitest.live.config.ts","lint":"biome check --write","prepack":"node ../../scripts/strip-dev-deps-on-pack.mjs","postpack":"node ../../scripts/strip-dev-deps-on-pack.mjs --restore"},"peerDependencies":{"@pulumi/command":"^1","@pulumi/pulumi":"^3"},"peerDependenciesMeta":{"@pulumi/command":{"optional":true}},"devDependencies":{"@infracraft/config-test":"workspace:*","@infracraft/config-tsdown":"workspace:*","@infracraft/sandbox":"workspace:*","@infracraft/typescript-config":"workspace:*","@pulumi/command":"1.2.1","@pulumi/pulumi":"3.250.0","@types/node":"25.9.1","typescript":"6.0.3","vitest":"4.1.7"},"_id":"@infracraft/pulumi@3.0.0","gitHead":"7fe83c23994fc180e78506776550feaae80b0abf","bugs":{"url":"https://github.com/andredezzy/infracraft/issues"},"homepage":"https://github.com/andredezzy/infracraft#readme","_nodeVersion":"22.23.1","_npmVersion":"10.9.8","dist":{"integrity":"sha512-L9UMuQOh1biGgZ+VzKxHihG13kVLcV/Y3PKiZtwz59HYMSYpLDh+3IFi9eWD0eso9rGlQ7w51ondUXQy69R/IA==","shasum":"ab4199f0f7f9efe06fd5f1a1bea08e4c1af1aa60","tarball":"https://registry.npmjs.org/@infracraft/pulumi/-/pulumi-3.0.0.tgz","fileCount":385,"unpackedSize":1696042,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@infracraft%2fpulumi@3.0.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQDtOyd8Dv5Uut2aF8C6wADxjx5cNc4rPoCiuNcy//F7tQIhAP1V/vNop21Uw/zpvs6pHWZsTBTPt2mfpeIvFWKI6imK"}]},"_npmUser":{"name":"andredezzy","email":"andrevcv1@gmail.com"},"directories":{},"maintainers":[{"name":"andredezzy","email":"andrevcv1@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/pulumi_3.0.0_1784421809005_0.7934483268157868"},"_hasShrinkwrap":false}},"time":{"created":"2026-05-27T04:09:09.673Z","modified":"2026-07-19T00:43:29.496Z","0.1.0":"2026-05-27T04:09:10.001Z","0.1.2":"2026-05-27T05:09:22.362Z","0.1.3":"2026-05-27T05:30:08.781Z","0.2.0":"2026-05-27T12:20:21.449Z","0.2.1":"2026-05-27T12:24:22.081Z","1.0.0":"2026-05-27T16:01:44.174Z","1.0.1":"2026-05-27T16:08:36.059Z","1.1.0":"2026-05-27T16:35:18.602Z","1.2.0":"2026-05-27T17:06:03.609Z","1.5.0":"2026-05-29T14:24:33.478Z","1.5.1":"2026-05-29T18:56:04.450Z","1.5.2":"2026-05-31T01:33:42.976Z","1.6.0":"2026-06-01T14:05:28.033Z","1.6.1":"2026-06-01T15:19:31.998Z","1.6.2":"2026-06-01T15:42:35.268Z","1.6.3":"2026-06-01T16:23:21.120Z","1.6.4":"2026-06-01T17:25:37.486Z","1.6.5":"2026-06-02T03:46:30.398Z","1.6.6":"2026-06-02T05:11:50.217Z","1.7.0":"2026-06-02T17:15:44.078Z","1.7.1":"2026-06-02T17:52:27.246Z","1.8.0":"2026-06-02T19:47:03.460Z","1.9.0":"2026-06-02T21:08:28.922Z","1.10.0":"2026-06-02T21:45:45.136Z","1.11.0":"2026-06-02T21:52:06.461Z","1.12.0":"2026-06-02T23:45:45.529Z","1.13.0":"2026-06-03T17:18:10.107Z","1.13.1":"2026-06-03T21:27:04.758Z","1.14.0":"2026-06-04T01:28:43.200Z","1.15.0":"2026-06-05T01:49:11.238Z","1.15.1":"2026-06-05T02:09:01.643Z","1.16.0":"2026-06-05T16:07:41.544Z","1.16.1":"2026-06-05T17:19:48.816Z","1.16.2":"2026-06-05T18:10:49.941Z","1.16.3":"2026-06-05T18:26:14.859Z","1.16.4":"2026-06-05T19:34:34.867Z","1.16.5":"2026-06-05T19:48:38.395Z","1.16.6":"2026-06-05T21:01:35.942Z","1.16.7":"2026-06-05T21:06:21.573Z","1.16.8":"2026-06-05T21:13:00.726Z","1.17.0":"2026-06-09T03:02:24.468Z","1.17.1":"2026-06-09T18:57:08.759Z","1.17.2":"2026-06-09T22:16:43.101Z","1.17.3":"2026-06-10T02:39:29.787Z","1.17.4":"2026-06-10T16:59:01.239Z","1.18.0":"2026-07-04T23:09:51.245Z","1.19.0":"2026-07-04T23:25:59.915Z","1.20.0":"2026-07-05T00:10:12.421Z","1.21.0":"2026-07-05T00:46:04.837Z","1.22.0":"2026-07-05T07:12:54.100Z","1.22.1":"2026-07-05T07:49:18.714Z","1.23.0":"2026-07-05T15:52:14.843Z","1.24.0":"2026-07-05T15:59:22.473Z","1.25.0":"2026-07-05T16:15:44.635Z","1.26.0":"2026-07-05T17:01:23.360Z","1.27.0":"2026-07-05T17:40:26.322Z","1.28.0":"2026-07-05T18:05:16.739Z","1.28.1":"2026-07-05T18:09:50.533Z","1.29.0":"2026-07-05T18:25:43.621Z","1.29.1":"2026-07-05T18:33:25.821Z","1.29.2":"2026-07-05T18:40:15.345Z","1.30.0":"2026-07-05T20:17:26.595Z","1.30.1":"2026-07-05T20:40:57.590Z","1.30.2":"2026-07-05T20:52:10.977Z","1.30.3":"2026-07-05T21:14:02.753Z","1.31.0":"2026-07-05T22:07:48.760Z","2.0.0":"2026-07-05T22:14:18.430Z","2.1.0":"2026-07-06T02:39:43.806Z","2.2.0":"2026-07-06T03:02:04.810Z","2.3.0":"2026-07-06T04:57:33.422Z","2.4.0":"2026-07-06T08:10:13.439Z","2.5.0":"2026-07-06T16:55:59.998Z","2.6.0":"2026-07-06T17:06:42.772Z","2.7.0":"2026-07-06T19:44:50.847Z","2.7.1":"2026-07-06T20:30:48.205Z","2.8.0":"2026-07-08T13:54:32.113Z","2.9.0":"2026-07-08T17:05:39.410Z","3.0.0":"2026-07-19T00:43:29.167Z"},"bugs":{"url":"https://github.com/andredezzy/infracraft/issues"},"license":"MIT","homepage":"https://github.com/andredezzy/infracraft#readme","repository":{"type":"git","url":"git+https://github.com/andredezzy/infracraft.git","directory":"packages/pulumi"},"description":"Native Pulumi providers for Railway, Neon, Vercel, and Fly.io with adopt-or-create semantics and deploy orchestration. No Terraform bridge.","maintainers":[{"name":"andredezzy","email":"andrevcv1@gmail.com"}],"readme":"<p align=\"center\">\n  <b>@infracraft/pulumi</b>\n  <br />\n  <i>Pulumi providers for platforms that don't have one.</i>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://www.npmjs.com/package/@infracraft/pulumi\"><img src=\"https://img.shields.io/npm/v/@infracraft/pulumi?style=flat&colorA=18181b&colorB=18181b\" alt=\"npm\" /></a>\n  <a href=\"https://www.npmjs.com/package/@infracraft/pulumi\"><img src=\"https://img.shields.io/npm/dm/@infracraft/pulumi?style=flat&colorA=18181b&colorB=18181b\" alt=\"downloads\" /></a>\n  <a href=\"https://github.com/andredezzy/infracraft/blob/main/LICENSE\"><img src=\"https://img.shields.io/github/license/andredezzy/infracraft?style=flat&colorA=18181b&colorB=18181b\" alt=\"license\" /></a>\n</p>\n\n---\n\nNative Pulumi providers with adopt-or-create semantics and deploy orchestration. No Terraform bridge.\n\n## Design principles\n\n- **Resources model single API objects.** Each resource wraps exactly one platform API object, and argument names mirror the platform API's field names (where a name deviates, its JSDoc documents the mapped field).\n- **Context-based.** Resources inherit auth, project, and environment from their options (`{ provider, project, environment }`); no manual ID passing.\n- **Adopt-or-create IS the import principle.** `pulumi import` is unimplemented for dynamic providers, so `create()` looks the object up by name and adopts it before creating a new one. Run `pulumi up` against a pre-existing project and it just works.\n- **Reads reconcile drift.** A resource deleted out of band returns blank on `pulumi refresh` and gets recreated on the next `up`. The one deliberate pass-through left is `railway.ProjectToken.value` — Railway never re-exposes a minted token via its API, so the stored secret is the only source of truth (the token's continued *existence*, though, is re-checked via a live list, and a revoked-via-dashboard token is reconciled like any other out-of-band deletion).\n- **Deletes are conservative — and idempotent.** Shared containers (Railway/Neon projects, Railway services, Fly apps) and data stores (Vercel marketplace resources) are never deleted by Pulumi; deleting an already-gone resource succeeds instead of stranding state. Guard everything else that is shared or production-grade with `protect: true`; volumes honor `retainOnDelete`.\n- **Inputs fail at plan time.** `check()` rejects locally decidable mistakes during preview with the offending property named: `railway.Volume.mountPath` (must be absolute), `railway.Service.source.image` / `railway.ProjectToken.name` / `railway.Project.name` / `railway.Environment.name` / `neon.Branch.name` / `neon.Role.name` (non-empty), `neon.Endpoint` (`maxCu >= minCu`), `fly.Volume.sizeGb` (positive integer). Preview-unknown inputs are skipped, never failed.\n- **Previews stay faithful.** Identity outputs that provably survive an in-place update are declared stable (`railway.Project.id`, `railway.Service.id`, `neon.Project.id`, `neon.Endpoint.host`, `neon.Role`'s identity, `fly.Volume.id`), so dependents keep known values during preview instead of showing phantom replaces. `neon.Role.password` is deliberately not stable — a rotation must cascade.\n- **One resilient transport.** All HTTP goes through a single fetch wrapper with a per-attempt timeout, bounded retries on transient failures (network errors, 5xx, 429), and `Retry-After` support. See [Transport & errors](#transport--errors).\n- **Secrets stay secret.** Provider credentials and minted values are marked secret in Pulumi state, and deploy tokens travel via stdin — never in command text. Better yet, credentials can stay out of state entirely: every provider accepts the credential as an env var *name* instead of a value — see [Provider credentials](#provider-credentials).\n- **Consumer-controlled triggers and protection.** Deploy resources accept a `triggers` array — hash source directories, env values, or anything else; you decide what causes a redeploy. Use `protect: true` on shared/production resources to prevent accidental deletion.\n\n## Providers\n\n| | Provider | Import | What it does |\n|---|---|---|---|\n| 🚂 | **Railway** | `@infracraft/pulumi/railway` | The only Pulumi provider for Railway. Projects, environments, services, variables, volumes, domains, deploy tokens, deploys. |\n| 🐘 | **Neon** | `@infracraft/pulumi/neon` | Adopt-or-create layer for Neon Postgres. Projects, branches, endpoints, roles, databases. |\n| ▲ | **Vercel** | `@infracraft/pulumi/vercel` | CLI deploy orchestration and marketplace resources. Projects, domains, and env vars belong to the official `@pulumiverse/vercel` provider. |\n| 🎯 | **Fly.io** | `@infracraft/pulumi/fly` | App, Secret, Volume, Certificate, IP, and Deploy resources via the Machines REST API and Fly GraphQL API. |\n| #️⃣ | **Hash** | `@infracraft/pulumi/hash` | Deterministic directory/env-var/app hashing for deploy triggers. |\n| 📦 | **Sandbox** | `@infracraft/pulumi/sandbox` | Isolated `/tmp` working copies for CLI deploys. Opt in via `dependsOn`. |\n| 🔒 | **Git Guard** | `@infracraft/pulumi/git-guard` | Swaps a sandboxed deploy's `.git` for a fresh stub. Opt in via `dependsOn`. |\n\n## Install\n\n```bash\nnpm i @infracraft/pulumi\n# or\nbun add @infracraft/pulumi\n```\n\nPeer dependencies: `@pulumi/pulumi` ^3, `@pulumi/command` ^1 (optional)\n\n## Provider credentials\n\nEvery provider takes its API credential in one of two mutually exclusive forms — the constructor throws unless exactly one is set:\n\n- **Env-var-first (recommended)** — `tokenEnvVar` (Neon: `apiKeyEnvVar`): the *name* of an environment variable holding the credential. Resources carry only the plain name; each dynamic-provider operation reads the value from the environment at execution time and fails loudly — naming the variable — when it is unset or has leading/trailing whitespace (a common symptom of a secret piped into `pulumi env set -f -` via a shell here-string, which bakes in a trailing newline).\n- **Direct** — `token` (Neon: `apiKey`): a secret `Input<string>`, marked secret in per-resource state via `additionalSecretOutputs`.\n\n```typescript\nconst railwayProvider = new railway.Provider(\"railway\", { tokenEnvVar: \"RAILWAY_TOKEN\" })\nconst neonProvider = new neon.Provider(\"neon\", { apiKeyEnvVar: \"NEON_API_KEY\" })\nconst vercelProvider = new vercel.Provider(\"vercel\", { tokenEnvVar: \"VERCEL_TOKEN\", teamId: \"team_xxx\" })\nconst flyProvider = new fly.Provider(\"fly\", { tokenEnvVar: \"FLY_API_TOKEN\" })\n```\n\nPrefer the env-var form. It keeps the credential out of dynamic-resource inputs and per-resource state entirely, which removes the substrate for [pulumi/pulumi#16041](https://github.com/pulumi/pulumi/issues/16041) (\"Unexpected struct type\": secret Outputs in dynamic-provider inputs intermittently fail engine serialization — closed not-planned upstream) and matches how first-class provider configuration handles credentials. Dynamic-provider operations execute in the Pulumi CLI's plugin process, which inherits the program's environment — so variables provided by the shell or by an ESC environment's `environmentVariables` block reach them.\n\nThe deploy components that feed the credential to a CLI (`vercel.Deploy`, `fly.Deploy`) resolve the env var at program runtime into a secret Output, so the command env still receives the actual value without it ever becoming a dynamic-resource input.\n\nNeither form of a provider's credential is ever compared in any resource's `diff()` — rotating a credential (or switching between `token`/`tokenEnvVar`) never triggers a replace or an in-place update on its own; it only changes which credential the next operation authenticates with.\n\n## Railway\n\n```typescript\nimport * as railway from \"@infracraft/pulumi/railway\"\nimport { hash } from \"@infracraft/pulumi/hash\"\n\nconst provider = new railway.Provider(\"railway\", {\n  tokenEnvVar: \"RAILWAY_TOKEN\",   // or token: config.requireSecret(\"railwayToken\")\n})\n\nconst project = new railway.Project(\"my-project\", {\n  name: \"my-app\",\n}, { provider })\n\nconst environment = new railway.Environment(\"production\", {\n  name: \"production\",\n}, { provider, project })\n\nconst service = new railway.Service(\"api\", {\n  name: \"api\",\n  builder: railway.Builder.RAILPACK,\n  startCommand: \"node dist/index.js\",\n}, { provider, project, environment })\n\n// Image-sourced service: the provider applies `source` to the target\n// environment's instance and owns its deploys (`serviceInstanceDeployV2`) —\n// on create and on config changes. No railway.Deploy needed; code services\n// (like `api` above) deploy via railway.Deploy instead. Secret-bearing start\n// commands (e.g. `redis-server --requirepass …`) belong in `startCommand`.\nnew railway.Service(\"redis\", {\n  name: \"redis\",\n  source: { image: \"redis:8-alpine\" },\n}, { provider, project, environment })\n\nconst env = { DATABASE_URL: dbUrl }\n\nnew railway.Variable(\"api-vars\", {\n  variables: env,\n}, { provider, project, environment, service })\n\nconst deployToken = new railway.ProjectToken(\"api-token\", {\n  name: \"api-deploy\",\n}, { provider, project, environment })\n\nnew railway.Deploy(\"api-deploy\", {\n  triggers: [hash(\"apps/api\"), hash(env)],   // hash(env): a non-secret digest, not raw secret values\n  healthcheckPath: \"/health\",                // applied post-deploy by the monitor (see Healthcheck config)\n}, { provider, project, environment, service, projectToken: deployToken.token })\n```\n\n### Railway API surface\n\n| Class | Key outputs | Notes |\n|---|---|---|\n| `railway.Provider` | — | Pass as `provider` option to every Railway resource. `token` or `tokenEnvVar` — see [Provider credentials](#provider-credentials) |\n| `railway.Project` | `.id` (project UUID) | Adopt-or-create by name |\n| `railway.Environment` | `.id` | Optional `source` env to fork from |\n| `railway.Service` | `.id` | Instance config (builder, commands, healthcheck, restart policy) applied per target environment; image services (`source.image`) are deployed by the provider itself. Healthcheck fields a fresh instance rejects are re-applied post-deploy — see [Healthcheck config](#railway-api-surface) below |\n| `railway.Domain` | `.fqdn`, `.cnameTarget`, `.verificationTxtName` / `.verificationTxtValue` | Omit `customDomain` for an auto-generated domain; custom domains expose the CNAME target and ownership-verification TXT record to write into DNS |\n| `railway.Variable` | — | Batch upsert; uses `skipDeploys` to avoid snapshot errors |\n| `railway.Volume` | — | Persistent volume; `mountPath` must be absolute. Adoption matches BOTH service and environment (never a sibling environment's volume); a newly attached volume redeploys its service so the mount lands |\n| `railway.ProjectToken` | `.token` (secret) | Environment-scoped deploy token; feed into `railway.Deploy`. Bump `tokenVersion` to rotate — see [Rotating credentials](#rotating-credentials) |\n| `railway.Deploy` | `.deploymentUrl` | Runs `railway up --detach` (retrying a transient upload failure), then monitors the deployment via the Railway API (the API, not the CLI exit code, decides pass/fail). Recovers deployments Railway wedges in `INITIALIZING` (see [Stuck-deploy recovery](#railway-api-surface)). Also accepts `excludePaths`, `railpackConfig`, and `healthcheckPath` / `healthcheckTimeout` (applied by the monitor post-deploy) |\n\n**Enums:** `railway.Builder` (`RAILPACK`, `NIXPACKS`, `DOCKERFILE`, `HEROKU`, `PAKETO`), `railway.RestartPolicy` (`ON_FAILURE`, `ALWAYS`, `NEVER`)\n\n**Deploy token security:** `railway.Deploy` pipes the project token to `railway up` via the command's stdin — never in the script text. pulumi-command embeds the executed command verbatim in its failure error and Pulumi does not scrub secrets from provider diagnostics, so an inlined token would print in plaintext exactly when a deploy fails.\n\n**Healthcheck config:** Railway rejects healthcheck fields on a fresh service instance with no deployment (`serviceInstanceUpdate` fails with \"Invalid input\"), so a from-zero `up` cannot set them at configure time. `railway.Service` still sends them on the first attempt — an instance with existing deployments accepts them in one call — and when the API rejects, drops them for the retry and guarantees they land later instead of silently losing them: for image services the provider re-applies them right after its own `serviceInstanceDeployV2`; for code services pass `healthcheckPath` / `healthcheckTimeout` to `railway.Deploy` too, and its monitor applies them once the deployment reaches a live status. Either re-apply failing fails the resource loudly — healthcheck config is never dropped silently.\n\n**Stuck-deploy recovery:** Railway occasionally wedges a deployment in `INITIALIZING` — it never advances to `BUILDING`. Rather than burn the whole poll timeout on a deployment that will never move, the monitor detects a continuous `INITIALIZING` stretch (default 5 minutes) and redeploys from the same source onto a fresh build slot, cancels the wedged deployment, and watches the new one — the recovery an operator would otherwise do by hand. Bounded (default one attempt) so a genuinely broken deploy still fails fast rather than looping.\n\n## Neon\n\n```typescript\nimport * as neon from \"@infracraft/pulumi/neon\"\n\nconst provider = new neon.Provider(\"neon\", {\n  apiKeyEnvVar: \"NEON_API_KEY\",   // or apiKey: config.requireSecret(\"neonApiKey\")\n})\n\nconst project = new neon.Project(\"db\", { name: \"my-app\" }, { provider })\n\n// Copy-on-write branch from \"main\"\nconst branch = new neon.Branch(\"prod\", {\n  name: \"production\",\n  parent: \"main\",\n}, { provider, project })\n\nconst role = new neon.Role(\"owner\", {\n  name: \"neondb_owner\",\n  resetPassword: true,   // isolate COW branch from parent's password\n}, { provider, project, branch })\n\nconst endpoint = new neon.Endpoint(\"prod\", {\n  minCu: 0.25,\n  maxCu: 1,\n  suspendTimeout: 300,\n}, { provider, project, branch })\n\nconst db = new neon.Database(\"app-db\", {\n  name: \"app\",\n  ownerName: \"neondb_owner\",\n}, { provider, project, branch })\n\nconst roleName = \"neondb_owner\"\nconst dbName = \"app\"\nconst connectionString = pulumi.interpolate`postgresql://${roleName}:${role.password}@${endpoint.host}/${dbName}`\n```\n\n### Neon API surface\n\n| Class | Key outputs | Notes |\n|---|---|---|\n| `neon.Provider` | — | `apiKey` or `apiKeyEnvVar` (see [Provider credentials](#provider-credentials)) + optional `orgId` |\n| `neon.Project` | `.id` | Adopt-or-create by name |\n| `neon.Branch` | `.id` | Optional `parent` for copy-on-write branching |\n| `neon.Endpoint` | `.host` | Read-write compute endpoint; use `.host` in connection strings |\n| `neon.Role` | `.password` (secret) | `resetPassword: true` isolates COW branch passwords from parent. Bump `passwordVersion` to rotate — see [Rotating credentials](#rotating-credentials) |\n| `neon.Database` | — | `name` + `ownerName` |\n\n## Rotating credentials\n\nMinted credentials rotate through a version-bump input — no target-replace URN archaeology, no manual revoke-then-recreate. Bump the number, run `up`, and everything consuming the credential (connection strings, env vars, dependent redeploys) cascades automatically:\n\n```typescript\n// Mints a fresh token BEFORE revoking the old one (create-before-delete),\n// so there is never a tokenless window.\nconst deployToken = new railway.ProjectToken(\"api-token\", {\n  name: \"api-deploy\",\n  tokenVersion: 2,   // bump to rotate\n}, { provider, project, environment })\n\n// Resets the password IN PLACE via Neon's reset_password endpoint — an\n// update, never a replace (a replace would try to delete the role, which\n// Neon refuses for default roles and which would drop grants for others).\nconst role = new neon.Role(\"owner\", {\n  name: \"neondb_owner\",\n  passwordVersion: 2,   // bump to rotate\n}, { provider, project, branch })\n```\n\nLeave the version unset until the first rotation is needed. Identity changes (name, project, environment) still replace normally.\n\n## Vercel\n\nUse the official [`@pulumiverse/vercel`](https://www.pulumi.com/registry/packages/vercel/) provider for the project itself (`vercel.Project`), its custom domains (`vercel.ProjectDomain`), and its environment variables (`vercel.ProjectEnvironmentVariables`). infracraft adds only what that provider does not cover: CLI-based production deploys with consumer-controlled triggers and sandbox isolation (`infravercel.Deploy`), and marketplace resource provisioning.\n\n```typescript\n// The official provider keeps the `vercel` alias (its ecosystem convention);\n// infracraft's module takes `infravercel` when both are in scope.\nimport * as vercel from \"@pulumiverse/vercel\"\nimport * as infravercel from \"@infracraft/pulumi/vercel\"\nimport { hash } from \"@infracraft/pulumi/hash\"\n\n// Project, custom domain, and env vars: the official provider.\nconst project = new vercel.Project(\"web\", {\n  name: \"my-web-app\",\n  framework: \"nextjs\",\n  rootDirectory: \"apps/web\",\n})\n\nnew vercel.ProjectDomain(\"web-domain\", {\n  projectId: project.id,\n  domain: \"app.example.com\",\n})\n\nnew vercel.ProjectEnvironmentVariables(\"web-env\", {\n  projectId: project.id,\n  variables: [\n    { key: \"NEXT_PUBLIC_API_URL\", value: apiUrl, target: [\"production\", \"preview\"] },\n  ],\n})\n\n// CLI deploy + marketplace resources: infracraft.\nconst provider = new infravercel.Provider(\"vercel\", {\n  tokenEnvVar: \"VERCEL_TOKEN\",   // or token: config.requireSecret(\"vercelToken\")\n  teamId: \"team_xxx\",\n})\n\n// project.id sources the deploy target; hash the source so edits redeploy.\nnew infravercel.Deploy(\"web-deploy\", {\n  projectId: project.id,\n  triggers: [hash(\"apps/web\")],\n}, { provider })\n\n// Marketplace example: provision an Upstash KV store\nconst integration = new infravercel.Integration(\"upstash\", {\n  slug: \"upstash\",\n}, { provider })\n\nconst store = new infravercel.MarketplaceResource(\"kv\", {\n  integrationConfigurationId: integration.configurationId,\n  name: \"my-kv\",\n  type: \"upstash-kv\",   // the integration's product ID or slug\n  externalId: \"my-kv\",\n}, { provider })\n\nnew infravercel.ResourceConnection(\"kv-conn\", {\n  storeId: store.id,\n  projectId: project.id,\n  targets: [\"production\", \"preview\"],\n}, { provider })\n```\n\n### Vercel API surface\n\n| Class | Key outputs | Notes |\n|---|---|---|\n| `infravercel.Provider` | — | `token` or `tokenEnvVar` (see [Provider credentials](#provider-credentials)) + `teamId` |\n| `infravercel.Deploy` | `.deploymentUrl` | Runs `vercel deploy --prod --yes` from an optional [sandbox](#sandbox--git-guard); `projectId` sources the deploy target (e.g. `@pulumiverse/vercel`'s `vercel.Project.id`). `.deploymentUrl` is the last http(s) URL token found in the CLI's stdout, after stripping any wrapping quotes/brackets/punctuation — so a URL that only ever appears quoted inside pretty-printed JSON is still found |\n| `infravercel.Integration` | `.configurationId` (`icfg_…`) | Resolves an installed marketplace integration by slug (install it once via the dashboard first) |\n| `infravercel.MarketplaceResource` | `.id`, `.externalResourceId`, `.status` | Provisions a marketplace store; `type` is the integration product ID or slug. `metadata` is updatable in place; `billingPlanId` is create-time-only |\n| `infravercel.ResourceConnection` | — | Wires a store to a project; injects env vars into target environments (`makeEnvVarsSensitive` defaults to `true` — then `targets` must not include `development`) |\n| `infravercel.Client` | — | Typed REST client behind the marketplace resources (`get` / `tryGet` / `post`); appends `teamId` to every request and rides the resilient transport |\n\n## Fly.io\n\n```typescript\nimport * as fly from \"@infracraft/pulumi/fly\"\nimport { hash } from \"@infracraft/pulumi/hash\"\n\n// Provider: auth context (token + optional default org)\nconst provider = new fly.Provider(\"fly\", {\n  tokenEnvVar: \"FLY_API_TOKEN\",   // or token: config.requireSecret(\"flyToken\")\n  organization: \"personal\",\n})\n\n// App: adopt-or-create; `.id` is the app name\nconst app = new fly.App(\"api\", { name: \"acme-api\" }, { provider })\n\n// Secrets: managed via the Machines REST secrets API.\n// `.version` changes only when the secret set changes.\nconst secrets = new fly.Secret(\"api-secrets\", {\n  secrets: { JWT_SECRET: jwt, DATABASE_URL: dbUrl },\n}, { provider, app })\n\n// Volume: persistent storage (grow-only)\nnew fly.Volume(\"api-data\", {\n  name: \"data\",\n  region: \"iad\",\n  sizeGb: 10,\n}, { provider, app })\n\n// Certificate: ACME cert for a custom hostname\nnew fly.Certificate(\"api-cert\", {\n  hostname: \"api.example.com\",\n}, { provider, app })\n\n// Dedicated/shared IP (Fly GraphQL API)\nnew fly.Ip(\"api-ip\", { type: fly.IpType.SHARED_V4 }, { provider, app })\n\n// Deploy: `fly deploy --remote-only` with consumer-controlled triggers.\n// The generated fly.toml content is included in the triggers automatically.\nnew fly.Deploy(\"api-deploy\", {\n  config: {\n    app: \"acme-api\",\n    primaryRegion: \"iad\",\n    build: { dockerfile: \"apps/api/Dockerfile\" },\n    httpService: {\n      internalPort: 3333,\n      forceHttps: true,\n      minMachinesRunning: 1,\n      checks: [{ method: \"GET\", path: \"/health\", interval: \"30s\", timeout: \"10s\" }],\n    },\n    deploy: { strategy: fly.DeployStrategy.ROLLING },\n    vm: [{ size: \"shared-cpu-1x\", memory: \"512mb\", cpus: 1 }],\n  },\n  triggers: [hash(\"apps/api\"), secrets.version],\n}, { provider, app, dependsOn: [secrets] })\n```\n\n**Requirements:** `flyctl` must be installed on the machine running `pulumi up` (used by `fly.Deploy`) — call `assertHostBinaries([\"fly\"])` from `@infracraft/pulumi/sandbox` at program start to fail fast with an install hint instead of mid-deploy. Generate a token with `fly tokens create deploy`. Dedicated IP allocation uses the Fly GraphQL API; everything else uses the Machines REST API.\n\n### Fly.io API surface\n\n| Class | Key outputs | Notes |\n|---|---|---|\n| `fly.Provider` | — | Pass as `provider` option to every Fly resource. `token` or `tokenEnvVar` — see [Provider credentials](#provider-credentials) |\n| `fly.App` | `.id` (app name) | Adopt-or-create; `organization` is create-time only — changing it after the app exists has no effect (Fly only supports moving an app between orgs via `fly apps move`/the dashboard, not this provider's REST API surface) |\n| `fly.Secret` | `.version` | Feed into `fly.Deploy` triggers to redeploy on secret changes |\n| `fly.Volume` | `.id` (`vol_…`) | `sizeGb` can only grow |\n| `fly.Certificate` | `.id` (hostname), `.configured`, `.dnsRequirements` | `.dnsRequirements` contains ACME validation records |\n| `fly.Ip` | `.id` (IP address) | `type`: `fly.IpType.V4`, `V6`, `SHARED_V4`, `PRIVATE_V6` |\n| `fly.Deploy` | `.deploymentUrl` | Writes fly.toml at deploy time; triggers on config + source hash. Optional `waitTimeout`, `releaseCommandTimeout`, `highAvailability` |\n\n**Enums:** `fly.IpType`, `fly.DeployStrategy` (`ROLLING`, `IMMEDIATE`, `CANARY`, `BLUEGREEN`), `fly.RestartPolicy` (`ALWAYS`, `ON_FAILURE`, `NEVER`), `fly.AutoStopMachines` (`OFF`, `STOP`, `SUSPEND`), `fly.ConcurrencyType` (`CONNECTIONS`, `REQUESTS`), `fly.ServiceProtocol` (`TCP`, `UDP`), `fly.PortHandler` (`HTTP`, `TLS`, `PG_TLS`, `PROXY_PROTO`, `EDGE_HTTP`), `fly.CpuKind` (`SHARED`, `PERFORMANCE`), `fly.CheckType` (`HTTP`, `TCP`)\n\n**Constants:** `FLY_REGIONS` (IATA codes array), `fly.Region` (derived type), `FLY_VM_SIZES` (size preset array), `fly.VmSize` (derived type)\n\n**fly.toml types:** `fly.TomlConfig`, `fly.BuildConfig`, `fly.HttpService`, `fly.Service`, `fly.ServicePort`, `fly.Mount`, `fly.Vm`, `fly.CpuCount`, `fly.DeployConfig`, `fly.RestartConfig`, `fly.Check`, `fly.Concurrency`, `fly.DnsRequirements`\n\n**Helper:** `generateFlyToml(config)` serializes a `fly.TomlConfig` to fly.toml text (camelCase to snake_case, deterministic output).\n\n\n## Hash\n\nProduce a stable digest to use as a deploy trigger element. Accepts a source directory path (synchronous, returns `string`), a key-value env var map (returns `Output<string>`), or, via `hashApp`, an app directory plus its transitive workspace dependencies.\n\n```typescript\nimport { hash, hashApp } from \"@infracraft/pulumi/hash\"\n\n// Hash a source directory\nconst sourceHash = hash(\"apps/api\")\n\n// Hash an app + every workspace package it depends on (transitively);\n// a change to a shared packages/* the app uses retriggers its deploy\nconst appHash = hashApp(monorepoRoot, \"apps/api\")\n\n// Hash an env var map into a single non-secret digest (Output<string>)\nconst envHash = hash({ DATABASE_URL: dbUrl, JWT_SECRET: jwt })\n\nnew railway.Deploy(\"api-deploy\", {\n  triggers: [appHash, envHash],\n}, { provider, project, environment, service, projectToken: deployToken.token })\n```\n\n### Hash API surface\n\n| Export | Kind | Notes |\n|---|---|---|\n| `hash(directory, options?)` | function | Recursive file name + content digest; skips build/VCS directories |\n| `hash(env)` | function | Sorted-key digest of resolved values; returned `Output<string>` is non-secret |\n| `hashApp(monorepoRoot, appDirectory, options?)` | function | Hashes the app and its transitive `apps/*`/`packages/*` workspace dependencies |\n\n## Sandbox & Git Guard\n\nDeploy isolation as `dependsOn` markers. Listing a `DeploySandbox` in a deploy's `dependsOn` runs that deploy's CLI from an isolated copy of the repo's tracked files under `/tmp/infracraft` (stale sandboxes are garbage-collected automatically). Adding a `GitGuard` swaps the copy's `.git` for a fresh stub (`git init` + `git add -A`, unborn HEAD).\n\nEvery deploy resource (`railway.Deploy`, `fly.Deploy`, `infravercel.Deploy`) REQUIRES a `DeploySandbox` in its own `dependsOn` — without one, a deploy would silently run against the LIVE working tree (uncommitted changes included) instead of a clean, git-tracked copy. Pass `allowUnsandboxed: true` in the deploy's args to opt into the live tree deliberately.\n\n```typescript\nimport { DeploySandbox } from \"@infracraft/pulumi/sandbox\"\nimport { GitGuard } from \"@infracraft/pulumi/git-guard\"\nimport { hash } from \"@infracraft/pulumi/hash\"\nimport * as infravercel from \"@infracraft/pulumi/vercel\"\n\nconst sandbox = new DeploySandbox(\"sandbox\")\nconst guard = new GitGuard(\"git-guard\")\n\n// Runs `vercel deploy` from an isolated copy with a stub `.git`\nnew infravercel.Deploy(\"web-deploy\", {\n  projectId: project.id,\n  triggers: [hash(\"apps/web\")],\n  excludePaths: [\"apps/docs\"],   // drop other apps from the upload (stub mode only)\n}, { provider, dependsOn: [sandbox, guard] })\n```\n\n| `dependsOn` markers | Working copy | `.git` sent to the platform |\n|---|---|---|\n| none | — | Throws: no `DeploySandbox` and `allowUnsandboxed` is not set |\n| none + `allowUnsandboxed: true` | Live repo tree | The real one; whatever the platform CLI picks up |\n| `DeploySandbox` | Isolated `/tmp/infracraft` copy | Real `.git` (copy-on-write copy) |\n| `DeploySandbox` + `GitGuard` | Isolated copy, `excludePaths` applied | A fresh stub (`git init` + `git add -A`, unborn HEAD) |\n| `GitGuard` alone | — | Throws: the guard needs a sandbox to act on |\n\n### Sandbox & Git Guard API surface\n\n| Export | Kind | Notes |\n|---|---|---|\n| `DeploySandbox` | ComponentResource | Isolation marker + workspace lifecycle; GCs sandboxes older than 3h |\n| `GitGuard` | ComponentResource | Stub-`.git` marker; requires a `DeploySandbox` alongside it |\n| `SandboxMode` | enum | `NONE`, `ORIGINAL`, `STUB`; derived from the markers by the deploy seam |\n| `buildSandboxScript(options)` | function | Builds the sandboxed shell a deploy command runs (used by the deploy resources) |\n| `buildSandboxFileFilter(excludePaths)` | function | Portable awk filter applied to `git ls-files` before the copy; keeps each excluded directory's `package.json` so the workspace graph survives |\n| `assertHostBinaries(binaries)` | function | Preflight doctor: throws one error naming ALL missing host binaries, with install hints. Re-exported from [`@infracraft/sandbox`](https://www.npmjs.com/package/@infracraft/sandbox) |\n| `isDeploySandbox(value)` / `isGitGuard(value)` | functions | Bundle-safe marker checks |\n\n`DeploySandbox` runs the preflight for the core POSIX set (`git`, `rsync`, `awk`, `mktemp`) automatically, so a broken host fails fast instead of midway through a deploy script. Call `assertHostBinaries([\"railway\"])` / `[\"vercel\"]` / `[\"fly\"]` at program start to preflight the platform CLIs your deploys use — see [`@infracraft/sandbox`](../sandbox) for the full doctor.\n\n## Preflight checks\n\nGuards that turn a failure that would otherwise surface mid-`up` into a clear, actionable message at program start. Two run themselves — zero ceremony:\n\n- **Host binaries** — `DeploySandbox` checks its own POSIX toolchain; `assertHostBinaries([\"fly\", \"vercel\"])` remains available for platform CLIs your program shells out to.\n- **Pulumi CLI/SDK version match** — every infracraft provider constructor runs the memoized `ensurePulumiVersionMatch()`, so any program constructing a provider gets the skew guard automatically. Call `assertPulumiVersionMatch()` directly only to check earlier than the first provider or to opt into `WARN` mode.\n\nThe Cloudflare guard stays explicit, for a structural reason: infracraft owns no Cloudflare resources (DNS goes through the official `@pulumi/cloudflare` provider), so no infracraft component can run it for you.\n\n```typescript\nimport { assertCloudflareZoneAccess } from \"@infracraft/pulumi/preflight\"\n\n// The Cloudflare token can read the target zone before any DNS/zone change.\nawait assertCloudflareZoneAccess({\n  token: process.env.CLOUDFLARE_API_TOKEN ?? \"\",\n  zoneId: process.env.CLOUDFLARE_ZONE_ID ?? \"\",\n})\n```\n\n| Guard | Import | Catches | On failure |\n|---|---|---|---|\n| `assertHostBinaries(binaries)` | `@infracraft/pulumi/sandbox` | A platform CLI (`fly` / `vercel` / `railway` …) missing from `PATH` | Throws one error naming ALL missing binaries, with an install hint for each known one |\n| `ensurePulumiVersionMatch()` / `assertPulumiVersionMatch(options?)` | `@infracraft/pulumi/preflight` (`ensure` runs automatically in every provider constructor) | A skew between the running Pulumi CLI (Go engine) and the installed `@pulumi/pulumi` SDK (Node serializer) — the cause of intermittent \"Unexpected struct type\" marshal failures on dynamic resources | Throws (or warns, with `mode: PulumiVersionMismatchMode.WARN`) naming both versions and the pin-the-CLI fix. Best-effort: warns and skips when the SDK can't be resolved from the program's working directory, or (`ensure` only) when the `pulumi` binary itself can't run |\n| `assertCloudflareZoneAccess(options)` | `@infracraft/pulumi/preflight` | A Cloudflare API token that cannot read the target zone (invalid, revoked, or not scoped to it) — so a mid-`up` 403/404 becomes a plan-time error | Throws naming the status (401/403/404/other) and the fix, when the zone read is not 2xx |\n\n**`assertPulumiVersionMatch` options:** `mode: PulumiVersionMismatchMode.THROW` (default) or `.WARN`, plus injectable `readCliVersion` / `readSdkVersion` readers for testing. It compares major.minor (patch and pre-release suffixes are ignored).\n\n**`assertCloudflareZoneAccess` — a zone read, not the token verify endpoint.** This guard calls `GET /zones/{zone_id}`, deliberately NOT `GET /user/tokens/verify`: the verify endpoint only accepts USER-owned tokens and 401s on an ACCOUNT-owned token (the kind minted for scoped automation) even when that token is perfectly valid — proven live 2026-07-06 against a token that returned 200 on the zone read and 401 on verify. A zone read also proves the more relevant capability: that the token can reach the SPECIFIC zone this program mutates, not merely that it's valid somewhere on the account. LIMITATION: a successful read proves `Zone:Read`, not `Zone Settings:Edit` or `DNS:Edit` — Cloudflare has no read-only endpoint that exercises those without a real mutation, so a read-only token can still 403 mid-`up` on an actual write.\n\n## Transport & errors\n\nEvery provider client (`railway.Client`, `neon.Client`, `vercel.Client`, `fly.Client`) routes its HTTP through one resilient fetch wrapper: a 15s per-attempt timeout, up to 3 attempts on transient failures (network errors, 5xx, 429), a numeric `Retry-After` honored on 429 (capped at 30s), and exponential backoff otherwise (1s/2s/4s, capped at 20s) — everything else, including non-429 4xx, returns to the caller untouched. The REST clients turn a 404 into a typed `ApiNotFoundError` (carrying the provider and path), and catch sites test `instanceof` rather than matching message strings: adopt-or-create lookups turn it into \"create\", `read()` turns it into a blank result so `pulumi refresh` reconciles out-of-band deletions, and `delete()` turns it into an idempotent no-op.\n\n## Live integration tests\n\nThe mocked unit tests (`bun run test`) prove the providers' logic in isolation, but they cannot catch the truths that only the real platform APIs reveal — a mutation that returns success while silently doing nothing, adoption that is or isn't scoped to an environment, a password rotation that must not trigger a resource replace. Those are the exact bug class that has cost real incidents. The **live tier** runs the resource providers against the real Railway and Neon APIs, creating and then **tearing down** throwaway resources.\n\nIt is **opt-in and inert by default.** Every `*.live.test.ts` file self-skips unless `INFRACRAFT_LIVE_TEST=1` **and** its platform credentials are present, using `describe.skipIf` so a missing credential reports as **skipped, never failed**. The tier is excluded from the default `test` script (via `vitest.config.ts`) and from CI, so it never runs — and costs nothing — unless you explicitly provide credentials.\n\nRun it with the platform(s) you have credentials for:\n\n```bash\n# Railway coverage (service + volume)\nINFRACRAFT_LIVE_TEST=1 \\\n  RAILWAY_TOKEN=… \\\n  RAILWAY_TEST_PROJECT_ID=… \\\n  RAILWAY_TEST_ENV_ID=… \\\n  bun run test:live\n\n# Neon coverage (role + branch)\nINFRACRAFT_LIVE_TEST=1 \\\n  NEON_API_KEY=… \\\n  NEON_TEST_PROJECT_ID=… \\\n  bun run test:live\n```\n\nWith no credentials, `bun run test:live` exits `0` with every file skipped.\n\n### Required environment variables\n\n| Variable | Required by | Purpose |\n|---|---|---|\n| `INFRACRAFT_LIVE_TEST` | all live tests | Master opt-in switch; must equal `1`. |\n| `RAILWAY_TOKEN` | `railway/*.live.test.ts` | Railway account/team API token (not a project token). |\n| `RAILWAY_TEST_PROJECT_ID` | `railway/*.live.test.ts` | A throwaway Railway project the tests may freely mutate. |\n| `RAILWAY_TEST_ENV_ID` | `railway/*.live.test.ts` | A non-default environment UUID in that project. |\n| `NEON_API_KEY` | `neon/*.live.test.ts` | Neon account- or project-scoped API key. |\n| `NEON_TEST_PROJECT_ID` | `neon/*.live.test.ts` | A throwaway Neon project the tests may freely mutate. |\n\n### Coverage\n\n| File | Asserts against the live API |\n|---|---|\n| `railway/service.live.test.ts` | Adopt-or-create is idempotent (second create by name adopts the same service, no duplicate); `ensureServiceInstance` materializes an instance in a non-default environment via the config-patch commit; an image service deploys via `serviceInstanceDeployV2`; `environmentUnskipService` is rejected in a named environment — documenting **why** the patch-commit path exists. |\n| `railway/volume.live.test.ts` | Adoption is environment-scoped — a volume attached to a service in environment A is **not** adopted for the same service in environment B (each gets its own), while re-creating within the same environment adopts the existing one. |\n| `neon/role.live.test.ts` | Adopt-or-create is idempotent; a `passwordVersion` bump rotates the password in place via `reset_password` — an update that returns a fresh secret with **no** replace. Runs on a throwaway branch. |\n| `neon/branch.live.test.ts` | A `parent`-supplied branch is a copy-on-write fork: the created branch's `parent_id` is exactly the resolved parent branch. |\n\n### Throwaway-resource and teardown model\n\nEach test creates uniquely-named resources and registers them for cleanup in an `afterAll` hook. Cleanup is **idempotent and tolerant of partial state**: it deletes what it created, and a cleanup failure is logged loudly (`[live cleanup] …`, naming the resource id to remove manually) but never fails the suite — a botched teardown must not mask the assertion result. Because a Railway service is project-level (its provider `delete()` is intentionally a no-op), the tests tear services down with a raw `serviceDelete`; Neon roles are removed by deleting their throwaway branch, which cascades. Point the `*_TEST_PROJECT_ID` variables at disposable projects only.\n\n## Why\n\n| Provider | Existing options | Gap |\n|---|---|---|\n| Railway | Nothing. Zero Pulumi providers exist. | **We are the Railway Pulumi provider.** |\n| Neon | Bridged TF provider; fails on pre-existing resources | Adopt-or-create without manual `import` blocks |\n| Vercel | `@pulumiverse/vercel` covers projects, domains, and env vars — but has no CLI deploy or marketplace provisioning | CLI deploys with consumer-controlled triggers and sandbox isolation, plus marketplace resource provisioning |\n| Fly.io | `@ediri/pulumi-fly`; bridges a Terraform provider Fly archived March 2024, no secrets support | Hand-rolled `dynamic` resources matching every other provider: secrets, adopt-or-create, consumer-controlled deploys; no unmaintained upstream |\n\n## Release history\n\nSee [CHANGELOG.md](CHANGELOG.md) for release history.\n\n## License\n\nMIT\n","readmeFilename":"README.md"}