@magoz/provision 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +498 -2
  3. package/dist/cli.js +29 -0
  4. package/dist/contract.js +13 -0
  5. package/dist/db/branch-lifecycle.js +23 -0
  6. package/dist/db/commands.js +303 -0
  7. package/dist/db/config.js +75 -0
  8. package/dist/db/credentials.js +177 -0
  9. package/dist/db/domain.js +106 -0
  10. package/dist/db/environment.js +14 -0
  11. package/dist/db/lease.js +132 -0
  12. package/dist/db/neon.js +350 -0
  13. package/dist/db/operations.js +246 -0
  14. package/dist/db/policy.js +59 -0
  15. package/dist/env/commands.js +39 -0
  16. package/dist/env/domain.js +14 -0
  17. package/dist/env/install.js +25 -0
  18. package/dist/env/paths.js +105 -0
  19. package/dist/env/provision.js +320 -0
  20. package/dist/env/sanitize.js +88 -0
  21. package/dist/env/vercel.js +180 -0
  22. package/dist/factory/approval.js +33 -0
  23. package/dist/factory/commands.js +173 -0
  24. package/dist/factory/config-commands.js +215 -0
  25. package/dist/factory/context.js +129 -0
  26. package/dist/factory/http.js +37 -0
  27. package/dist/factory/onboarding.js +155 -0
  28. package/dist/factory/onepassword.js +140 -0
  29. package/dist/factory/op-credential.js +154 -0
  30. package/dist/factory/passphrase.js +145 -0
  31. package/dist/factory/providers/cloudflare.js +126 -0
  32. package/dist/factory/providers/neon.js +81 -0
  33. package/dist/factory/providers/report-receiver.js +37 -0
  34. package/dist/factory/providers/resend.js +32 -0
  35. package/dist/factory/providers/upstash.js +21 -0
  36. package/dist/factory/registry.js +50 -0
  37. package/dist/factory/scoped-key.js +30 -0
  38. package/dist/factory/sealed.js +64 -0
  39. package/dist/factory/secret-input.js +43 -0
  40. package/dist/factory/steps/domain.js +23 -0
  41. package/dist/factory/steps/neon.js +101 -0
  42. package/dist/factory/steps/r2.js +70 -0
  43. package/dist/factory/steps/reports.js +44 -0
  44. package/dist/factory/steps/resend.js +37 -0
  45. package/dist/factory/steps/secrets.js +104 -0
  46. package/dist/factory/steps/upstash.js +92 -0
  47. package/dist/factory/steps/vercel.js +68 -0
  48. package/dist/factory/vercel-api.js +165 -0
  49. package/dist/package-info.js +15 -0
  50. package/dist/shared/agent.js +28 -0
  51. package/dist/shared/env-file.js +54 -0
  52. package/dist/shared/git.js +48 -0
  53. package/dist/shared/output.js +10 -0
  54. package/dist/shared/private-file.js +63 -0
  55. package/dist/shared/process.js +55 -0
  56. package/dist/shared/repo-config.js +104 -0
  57. package/dist/shared/sandbox-profile.js +12 -0
  58. package/package.json +44 -4
  59. package/skills/provision/SKILL.md +172 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 magoz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,499 @@
1
- # Temporary Holding Version
1
+ # provision
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One CLI to take a web project from an empty GitHub repository to a running local checkout:
4
+
5
+ - **`provision factory`** creates the project's provider resources (Vercel, Neon, Cloudflare R2,
6
+ Upstash, Resend) with admin keys kept in 1Password, mints keys scoped to the project, and writes
7
+ them to the project's Vercel environment variables.
8
+ - **`provision env`** makes a checkout runnable: installs dependencies, links Vercel, pulls env
9
+ files, creates disposable databases, and runs the repository's setup commands.
10
+ - **`provision db`** manages those disposable Neon databases.
11
+
12
+ It is built for a host shared with coding agents: agents can run `env` and `db` on their own, while
13
+ anything that touches admin keys needs a passphrase typed by a human in a terminal.
14
+
15
+ > **Status: pre-1.0.** Interfaces may still change; see [Contract](#contract-and-exit-codes).
16
+
17
+ - [Install](#install)
18
+ - [Quick start](#quick-start)
19
+ - [Repository configuration](#repository-configuration)
20
+ - [Factories](#factories)
21
+ - [`provision factory`](#provision-factory)
22
+ - [`provision env`](#provision-env)
23
+ - [`provision db`](#provision-db)
24
+ - [Agents](#agents)
25
+ - [Contract and exit codes](#contract-and-exit-codes)
26
+ - [Troubleshooting](#troubleshooting)
27
+
28
+ ## Install
29
+
30
+ Requires Node 24+, the [Vercel CLI](https://vercel.com/docs/cli) (logged in), and for factories the
31
+ [1Password CLI](https://developer.1password.com/docs/cli/) (`op`) and `systemd-creds` (Linux).
32
+
33
+ ```bash
34
+ npm install -g @magoz/provision
35
+ provision --help
36
+ ```
37
+
38
+ On a host where coding agents run as your user, install it **root-owned** and run factory commands
39
+ by absolute path, so an agent cannot replace the code that receives your passphrase:
40
+
41
+ ```bash
42
+ sudo npm install -g --prefix /usr/local @magoz/provision
43
+ /usr/local/bin/provision --version
44
+ ```
45
+
46
+ Install the agent skill so coding agents know how to use the CLI (see [Agents](#agents)):
47
+
48
+ ```bash
49
+ npx skills add magoz/provision
50
+ ```
51
+
52
+ ## Quick start
53
+
54
+ **Once per factory** (a set of provider accounts, see [Factories](#factories)):
55
+
56
+ ```bash
57
+ provision config add --name acme # guided: vault, item, service account, passphrase
58
+ # fill the item's fields in 1Password, then:
59
+ provision config add --name acme # registers the factory once every field decodes
60
+ provision config verify acme
61
+ ```
62
+
63
+ **Once per project** (the GitHub repository `acme/acme-app` exists and is checked out):
64
+
65
+ ```bash
66
+ provision factory --checkout ~/src/acme-app --domain app.acme.com --dry-run # plan
67
+ provision factory --checkout ~/src/acme-app --domain app.acme.com \
68
+ --sender-name "Acme" --email-from noreply@acme.com # apply
69
+ ```
70
+
71
+ **For every checkout or worktree:**
72
+
73
+ ```bash
74
+ provision env --repo ~/src/acme-app --database
75
+ ```
76
+
77
+ ## Repository configuration
78
+
79
+ Repositories declare their needs in the root `package.json`. Everything is optional:
80
+
81
+ ```jsonc
82
+ {
83
+ "provision": {
84
+ "appDir": "apps/web", // monorepo app directory; default "."
85
+ "setup": ["pnpm db:migrate"], // run by `provision env` after provisioning
86
+ "factory": {
87
+ "steps": ["vercel", "neon", "r2", "resend", "secrets"], // default: all steps
88
+ "neon": { "sandboxParent": "empty-baseline" } // or "existing-branch"
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ Unknown keys are rejected. `setup` commands run from the checkout root through `$SHELL -lc`; keep
95
+ them idempotent (migrations, not seeds), since `provision env` reruns them on every run. A common
96
+ setup migrates both databases while ignoring any inherited `DATABASE_URL`:
97
+
98
+ ```jsonc
99
+ "setup": [
100
+ "env -u DATABASE_URL NODE_ENV=development pnpm db:migrate",
101
+ "env -u DATABASE_URL NODE_ENV=test pnpm db:migrate"
102
+ ]
103
+ ```
104
+
105
+ ## Factories
106
+
107
+ A **factory** is one set of operator accounts: a GitHub owner, a Vercel team, a Neon organization,
108
+ a Cloudflare account, an Upstash account, a Resend account, and optionally a report receiver. Its
109
+ admin credentials live in **one 1Password item** (by default `provision`, in vault
110
+ `provision-<name>`). The CLI only records where that item is, in
111
+ `${XDG_CONFIG_HOME:-~/.config}/provision/config.json`.
112
+
113
+ A checkout picks its factory by GitHub owner: `acme/acme-app` uses the factory whose
114
+ `github.owner` is `acme` (or `--factory <name>`).
115
+
116
+ ```bash
117
+ provision config add [--name acme] [--vault provision-acme] [--item provision] [--account <op-account>] [--yes]
118
+ provision config list [--json]
119
+ provision config verify [<name>] [--json]
120
+ provision config remove <name>
121
+ provision config op-token set|remove --factory <name>
122
+ provision config op-token status
123
+ ```
124
+
125
+ ### Set up a factory
126
+
127
+ ```bash
128
+ provision config add --name acme
129
+ ```
130
+
131
+ The first run walks you through everything:
132
+
133
+ 1. signs you in to 1Password (the session lives only in this command and is signed out at the end);
134
+ 2. creates the vault and an empty `provision` item with every field, if missing (`--yes` skips the
135
+ confirmation);
136
+ 3. creates a read-only service account `provision-acme` (`read_items` on that vault only) and stores
137
+ its token, sealed with the **provision passphrase**. The first factory asks you to choose the
138
+ passphrase; all factories share it;
139
+ 4. lists the fields still empty.
140
+
141
+ Fill the fields in the 1Password app (see below), then run the same command again. Reruns only ask
142
+ for the passphrase, report each section, and register the factory once the item decodes:
143
+
144
+ ```text
145
+ github ✓ complete
146
+ vercel ✓ complete
147
+ neon ✓ complete
148
+ cloudflare ✓ complete
149
+ upstash ✓ complete
150
+ resend ✓ complete
151
+ report_receiver empty: url, factory_key
152
+ status added
153
+ factory acme
154
+ ```
155
+
156
+ ### Item fields
157
+
158
+ Fields are `section.label` in the item. A section counts only when all of its required fields are
159
+ filled; `github` and `vercel` are required, the others enable the steps that use them.
160
+
161
+ | Section | Fields | Used by |
162
+ | ----------------- | ------------------------------------ | ------------------------- |
163
+ | `github` | `owner` | factory selection, checks |
164
+ | `vercel` | `team_id` (`team_…`) | every step |
165
+ | `neon` | `api_key`, `org_id` | `neon` |
166
+ | `cloudflare` | `api_token`, `account_id` | `r2` |
167
+ | `upstash` | `email`, `api_key` | `upstash` |
168
+ | `resend` | `api_key`; optional `sending_domain` | `resend`, `secrets` |
169
+ | `report_receiver` | `url`, `factory_key` | `reports` |
170
+
171
+ Where to get each value:
172
+
173
+ - **`github.owner`**: the GitHub organization or user that owns the factory's repositories, e.g.
174
+ `acme`.
175
+ - **`vercel.team_id`**: the team's immutable ID, not its slug: Vercel dashboard → team Settings →
176
+ General → Team ID, or `vercel api /v2/teams`. It looks like `team_aBcDeFgH1234…`. Vercel calls use
177
+ your own `vercel login`, which must be a member of that team.
178
+ - **`neon.api_key`, `neon.org_id`**: Neon Console → Organization settings → API keys → create an
179
+ **organization** API key. The organization ID (`org-…`) is on the organization settings page.
180
+ - **`cloudflare.api_token`, `cloudflare.account_id`**: in the Cloudflare dashboard, enable R2
181
+ (R2 Object Storage; the free tier is enough). Then Manage Account → Account API Tokens → Create
182
+ Token → Custom token with exactly two permissions, **Account · Workers R2 Storage · Edit** and
183
+ **Account · Account API Tokens · Edit**, scoped to this account. This must be an
184
+ **account-owned** token, not a user token. The account ID is in the dashboard URL
185
+ (`dash.cloudflare.com/<account_id>`).
186
+ "Account API Tokens Edit" can mint tokens with any permission you hold on the account: treat
187
+ this token as account-level access.
188
+ - **`upstash.email`, `upstash.api_key`**: your Upstash account email and a Management API key
189
+ (Upstash Console → Account → Management API).
190
+ - **`resend.api_key`**: a **Full access** Resend API key (it creates the per-project sending keys).
191
+ `sending_domain` is optional: when set, `AUTH_EMAIL_FROM` defaults to `noreply@<sending_domain>`.
192
+ - **`report_receiver.url`, `report_receiver.factory_key`**: an optional service that issues
193
+ report tokens per project. Leave empty if you have none; the `reports` step is then skipped.
194
+
195
+ ### Passphrase and 1Password authentication
196
+
197
+ `provision` reads factory items with `op`, authenticated by the first of:
198
+
199
+ 1. `OP_SERVICE_ACCOUNT_TOKEN` in the environment;
200
+ 2. the factory's stored service-account token, sealed with the provision passphrase (scrypt +
201
+ AES-256-GCM) and then encrypted with `systemd-creds --user` (TPM2-bound where available), at
202
+ `~/.config/provision/op-service-account.<factory>.cred`;
203
+ 3. the current `op` session (`eval "$(op signin)"`) or the desktop app integration.
204
+
205
+ With stored tokens, **the passphrase is the human approval**. Every command that reads a factory
206
+ item (`config add`, `config verify`, `factory`) asks for it once, on the controlling terminal. It is
207
+ never read from arguments, the environment, or stdin, and these commands refuse to run without a
208
+ terminal. An agent can prepare a command, but only you can run it. One approval covers the whole
209
+ run, and each run unseals only the selected factory's token.
210
+
211
+ Service accounts cannot read Private vaults or the account's default Shared vault, and their
212
+ permissions are fixed at creation. Tokens created without `--expires-in` never expire. To rotate,
213
+ create a new service account, store it with `provision config op-token set --factory <name>`
214
+ (reads stdin or a hidden prompt), and revoke the old one under Developer → Service accounts.
215
+ Revoke immediately if the host may be compromised.
216
+
217
+ ## `provision factory`
218
+
219
+ ```bash
220
+ provision factory [--checkout <repo>] --domain <production-host>
221
+ [--sender-name <name>] [--email-from <address>]
222
+ [--factory <name>] [--only vercel,neon,r2,upstash,resend,reports,secrets,domain]
223
+ [--on-existing ask|reuse|overwrite|abort] [--neon-parent-branch <id>] [--dry-run]
224
+ ```
225
+
226
+ Creates what the repository declares (`provision.factory.steps`, narrowed by `--only`) for the
227
+ checkout's GitHub repository `<owner>/<repo>`. Resources are named `<repo>` (Vercel project),
228
+ `<repo>-dev` (pre-production), and `<repo>-prod` (production).
229
+
230
+ | Step | Creates | Vercel env vars |
231
+ | --------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
232
+ | `vercel` | project (Git link, Root Directory = `appDir`), custom `test` environment, checkout link | `ALLOW_E2E_DATABASE_RESET=1` (`test` only) |
233
+ | `neon` | `<repo>-dev` (protected `baseline`) + project-scoped key; `<repo>-prod` (protected, 7-day history) | `SANDBOX_DB_*` (Development); `DATABASE_URL(_UNPOOLED)` (Production) |
234
+ | `r2` | private `<repo>-dev`/`-prod` buckets with upload CORS, bucket-scoped tokens | `R2_*` |
235
+ | `upstash` | `<repo>-dev`/`-prod` teams | `QSTASH_*`, pasted by you (see [Manual follow-ups](#manual-follow-ups)) |
236
+ | `resend` | sending-only keys (any verified domain) | `RESEND_API_KEY` |
237
+ | `reports` | report tokens from the factory's receiver | `REPORT_RECEIVER_URL`, `REPORT_RECEIVER_TOKEN` |
238
+ | `secrets` | generated `BETTER_AUTH_SECRET`, `INTEGRATION_CREDENTIAL_ENCRYPTION_KEY`, `CRON_SECRET`, VAPID pair | those, plus `AUTH_EMAIL_FROM`, `AI_PROVIDER_USAGE_ALERT_THRESHOLDS` |
239
+ | `domain` | attaches `--domain` to the project | — |
240
+
241
+ - **Environments.** Pre-production values go to Development, Preview, and `test`; production values
242
+ go to Production and are Sensitive. Each side gets its own keys and secrets.
243
+ - **`--domain`** is the production host. The `r2` step allows browser uploads to the production
244
+ bucket only from `https://<domain>`; the development bucket accepts any origin.
245
+ - **`--email-from`** sets `AUTH_EMAIL_FROM` (`"<sender-name> <address>"`). The address's domain must
246
+ be verified in the factory's Resend account.
247
+ - **Order.** `vercel` runs first; the other steps then run concurrently. Each step prints one block
248
+ when it finishes, and a failing step lets the others finish before the run fails.
249
+
250
+ ### First run on a project
251
+
252
+ ```bash
253
+ # 1. Plan: reads every provider, changes nothing
254
+ provision factory --checkout ~/src/acme-app --domain app.acme.com \
255
+ --sender-name Acme --email-from noreply@acme.com --dry-run
256
+
257
+ # 2. Apply
258
+ provision factory --checkout ~/src/acme-app --domain app.acme.com \
259
+ --sender-name Acme --email-from noreply@acme.com
260
+
261
+ # 3. Rerun: should only report "reusing"
262
+ provision factory --checkout ~/src/acme-app --domain app.acme.com \
263
+ --sender-name Acme --email-from noreply@acme.com --on-existing reuse
264
+ ```
265
+
266
+ Reading the output: `would …` is a dry-run plan; `exists:` and `reusing …` mean nothing changed;
267
+ `skipped:` means the factory lacks that provider; `manual:` is a follow-up you do by hand.
268
+
269
+ ```text
270
+ [neon]
271
+ create Neon project acme-app-dev with protected branch baseline
272
+ create Neon key acme-app-dev-sandbox-branch-manager
273
+ write SANDBOX_DB_NEON_API_KEY, SANDBOX_DB_NEON_PROJECT_ID, SANDBOX_DB_PARENT_BRANCH_ID to Vercel (Development)
274
+ create Neon project acme-app-prod with protected branch production
275
+ write DATABASE_URL, DATABASE_URL_UNPOOLED to Vercel (Production)
276
+ ```
277
+
278
+ ### Existing resources
279
+
280
+ `--on-existing` decides what happens when something already exists (default `ask`, which prompts):
281
+
282
+ | Policy | Data (projects, buckets, teams) | Keys | Generated secrets |
283
+ | ----------- | ------------------------------- | ------------------------------------------------- | ----------------------------------------------------- |
284
+ | `reuse` | reused | reused | reused |
285
+ | `overwrite` | reused (never replaced) | rotated: new key, Vercel updated, old key deleted | regenerated (invalidates sessions and encrypted data) |
286
+ | `abort` | run fails | run fails | run fails |
287
+
288
+ `provision` never deletes data. A key whose value is missing from Vercel cannot be reused; rerun
289
+ with `--on-existing overwrite --only <step>` to rotate it.
290
+
291
+ ```bash
292
+ provision factory --checkout ~/src/acme-app --domain app.acme.com --only resend --on-existing overwrite
293
+ provision factory --checkout ~/src/acme-app --domain app.acme.com --only reports # after adding a receiver
294
+ ```
295
+
296
+ ### Manual follow-ups
297
+
298
+ - **QStash.** A team's QStash credentials can only be read from inside that team, so the
299
+ `upstash` step creates the teams and then asks you to paste each team's `.env` block
300
+ (`QSTASH_URL`, `QSTASH_TOKEN`, `QSTASH_CURRENT_SIGNING_KEY`, `QSTASH_NEXT_SIGNING_KEY`) from the
301
+ [Upstash console](https://console.upstash.com/qstash): switch to the team, choose the EU region,
302
+ and copy the block from Quickstart. Input is hidden; each recognized line is confirmed by name. It writes them to Vercel like every other key: `<repo>-dev` to
303
+ Development, Preview, and `test`; `<repo>-prod` to Production, Sensitive. Press Enter on an
304
+ empty line to skip; without a terminal, or when skipped, it prints a `manual:` line instead.
305
+ Runs ask only while Vercel is missing them:
306
+
307
+ ```bash
308
+ provision factory --checkout ~/src/acme-app --domain app.acme.com --only upstash --on-existing reuse
309
+ ```
310
+
311
+ ```text
312
+ QStash for acme-app-dev → Vercel (Development, Preview, test)
313
+ 1. Open https://console.upstash.com/qstash and switch to team acme-app-dev
314
+ (team menu, top left), EU region (eu-central-1).
315
+ 2. In Quickstart, copy the .env block (all 4 lines) and paste it here, then Enter.
316
+ Press Enter on an empty line to skip and copy them into Vercel later.
317
+ QStash .env:
318
+ ✓ QSTASH_URL
319
+ ✓ QSTASH_TOKEN
320
+ ✓ QSTASH_CURRENT_SIGNING_KEY
321
+ ✓ QSTASH_NEXT_SIGNING_KEY
322
+ ```
323
+
324
+ - **DNS.** If the domain is not yet pointed at Vercel, add the record shown under the project's
325
+ Domains settings at your DNS provider.
326
+
327
+ ## `provision env`
328
+
329
+ ```bash
330
+ provision env [--repo <checkout>] [--app-dir <dir>] [--source <linked-checkout>]
331
+ [--vercel-project <name>] [--database] [--label <l>] [--ttl 7d]
332
+ [--test-environment test] [--env-conflict overwrite|preserve|ask|error]
333
+ [--skip-install] [--skip-vercel] [--skip-setup] [--non-interactive]
334
+ provision env --check-vercel-link [--repo <checkout>] [--source <linked-checkout>]
335
+ ```
336
+
337
+ ```bash
338
+ provision env --database # current checkout, own databases
339
+ provision env --repo ../acme-app-feature --source ../acme-app --database # new worktree
340
+ provision env --env-conflict preserve --skip-install # keep local env edits
341
+ ```
342
+
343
+ Under a per-checkout lock, it:
344
+
345
+ 1. validates the checkout, app directory, and env paths (ignored, untracked, not symlinks);
346
+ 2. resolves existing env files (`--env-conflict`; default: refresh from Vercel);
347
+ 3. installs dependencies from the root lockfile (pnpm, bun, npm, or yarn; frozen);
348
+ 4. reuses or creates the app's Vercel link (existing link, `--source`, `--vercel-project`, or the
349
+ single project shared by linked sibling worktrees);
350
+ 5. pulls Vercel Development into `.env.local` and `test` into `.env.test` (mode `0600`), dropping
351
+ deployment-only metadata, `ALLOW_E2E_DATABASE_RESET` from `.env.local`, and, with `--database`,
352
+ Vercel's database URLs;
353
+ 6. with `--database`, creates the `default` and `test` database leases (see `provision db`);
354
+ 7. after releasing the lock, runs `package.json` `provision.setup` (skipped with `--skip-setup`).
355
+
356
+ A failure in steps 1–6 rolls back what the run created (leases, env files, a new Vercel link). A
357
+ setup failure keeps them, since they are valid; fix the cause and rerun to resume. A missing or
358
+ ambiguous Vercel link prints `{"status":"vercel_link_required","directory":…,"reason":…}` on stderr
359
+ and exits 3; rerun with `--source` or `--vercel-project`.
360
+
361
+ **Adding a variable.** Vercel is the source of truth: each run rewrites the env files from it, so
362
+ add new variables to the Vercel project and pull again rather than editing `.env.local`:
363
+
364
+ ```bash
365
+ vercel env add AI_GATEWAY_API_KEY development # prompts for the value
366
+ vercel env add SOME_FLAG test --value on --yes # the custom e2e environment
367
+ provision env --skip-install --skip-setup # refresh .env.local and .env.test
368
+ ```
369
+
370
+ ## `provision db`
371
+
372
+ Disposable Neon branches for local checkouts, cloned from the sandbox parent branch (by default
373
+ the protected `baseline` branch of the factory-created `<repo>-dev` project).
374
+
375
+ ```bash
376
+ provision db create [--worktree <path>] [--lease <slot>] [--label <l>] [--ttl 7d] [--env-file .env.local]
377
+ [--config-env-file <file>] [--keys DATABASE_URL,DATABASE_URL_UNPOOLED]
378
+ [--force-new] [--no-wait] [--json]
379
+ provision db status [--worktree <path>] [--lease <slot>] [--json]
380
+ provision db renew [--worktree <path>] [--lease <slot>] [--ttl 7d] [--json]
381
+ provision db release [--worktree <path>] [--lease <slot>] [--keep-env] [--json]
382
+ provision db list [--json]
383
+ provision db gc [--dry-run] [--prune-expired] [--json]
384
+ provision db auth login [--project-id <id>] [--parent-branch <id>] [--token-stdin] [--json]
385
+ provision db auth status [--worktree <path>] [--json]
386
+ provision db auth logout [--json]
387
+ ```
388
+
389
+ ```bash
390
+ provision db status --lease default # exit 1 when there is no lease
391
+ provision db renew --lease test --ttl 7d # keep a database alive longer
392
+ provision db release --lease default # before deleting a worktree; also --lease test
393
+ provision db gc --dry-run # find leftovers
394
+ ```
395
+
396
+ - **Profile.** The worktree's `.env.local` (or `--config-env-file`) provides
397
+ `SANDBOX_DB_NEON_API_KEY`, `SANDBOX_DB_NEON_PROJECT_ID`, and `SANDBOX_DB_PARENT_BRANCH_ID`
398
+ (written there by the factory's `neon` step), all three or none. Without them, global auth
399
+ (`db auth login`, or the `NEON_AGENTS_SANDBOX_*` environment variables) applies.
400
+ - **Branches** are named `agent/<repo>-<label>-<random>`, unprotected, with a mandatory TTL of at
401
+ most 7 days. Connection URLs are written only to git-ignored, untracked env files (mode `0600`)
402
+ and never printed.
403
+ - **Leases** (`default`, `test`, …) record identifiers and paths, never secrets. `create` reuses a
404
+ compatible live lease and refreshes its TTL; `release` re-verifies the exact branch before
405
+ deleting it.
406
+ - **Attestation.** When `package.json` declares `provision.factory.neon.sandboxParent:
407
+ "empty-baseline"`, `create`, `renew`, and `release` first verify that the profile points at the
408
+ `<repo>-dev` project and its protected default `baseline` branch.
409
+ - **Storage:** settings in `${XDG_CONFIG_HOME:-~/.config}/pi/sandbox-db.json`, the global key in the
410
+ macOS keychain or `sandbox-db.env` next to it, leases and locks in
411
+ `${XDG_STATE_HOME:-~/.local/state}/pi/sandbox-db/`.
412
+
413
+ ## Agents
414
+
415
+ The repository ships an [agent skill](skills/provision/SKILL.md) that teaches coding agents the
416
+ workflows above and the approval boundary:
417
+
418
+ ```bash
419
+ npx skills add magoz/provision # this project
420
+ npx skills add magoz/provision -g # all projects
421
+ ```
422
+
423
+ The skill is also included in the npm package, under `skills/provision/`.
424
+
425
+ | Agents may run | Only a human runs (needs the passphrase) |
426
+ | ---------------------------------------------------------------- | --------------------------------------------------------------- |
427
+ | `env`, `db`, `contract`, `config list`, `config op-token status` | `factory`, `config add`, `config verify`, `config op-token set` |
428
+
429
+ `provision` detects coding agents from the environment variables they set (`AI_AGENT`,
430
+ `CLAUDECODE`, `OPENCODE`, `CODEX_THREAD_ID`, `CURSOR_AGENT`, `GEMINI_CLI`, and others; the same
431
+ signals as `@vercel/detect-agent`). Under an agent:
432
+
433
+ - the human-only commands change nothing and exit 4 with the exact command for the human to run,
434
+ from the same directory, as JSON on stderr:
435
+
436
+ ```json
437
+ {
438
+ "status": "approval_required",
439
+ "reason": "this command reads factory admin keys and needs the provision passphrase, typed by a human in a terminal; claude cannot approve it",
440
+ "hint": "ask the human to run the next command in their own terminal and paste its output; never ask for the passphrase",
441
+ "next": [
442
+ "cd /home/acme/src && /usr/local/bin/provision factory --checkout acme-app --domain app.acme.com --dry-run"
443
+ ]
444
+ }
445
+ ```
446
+
447
+ - `provision env` never prompts, as with `--non-interactive`.
448
+
449
+ Detection only shapes the experience. What stops an agent from approving a factory run is the
450
+ passphrase read from the terminal: an agent that hides its variables still cannot type it.
451
+
452
+ ## Contract and exit codes
453
+
454
+ `provision contract` prints the machine-readable contract other tools rely on:
455
+
456
+ ```json
457
+ {
458
+ "contractVersion": 1,
459
+ "provisionVersion": "0.1.0",
460
+ "commands": ["contract", "config", "factory", "env", "db"]
461
+ }
462
+ ```
463
+
464
+ `contractVersion` changes only for breaking changes to commands, flags, JSON output, or exit codes.
465
+ Check `commands` for the capabilities you need.
466
+
467
+ | Code | Meaning |
468
+ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
469
+ | 0 | success |
470
+ | 1 | negative result: `db status` without a lease or branch, `db auth status` unauthenticated or invalid, `db gc` with inaccessible leases (unless `--prune-expired`), or a CLI usage error |
471
+ | 2 | expected failure, reported as one `provision <mode>: …` line on stderr |
472
+ | 3 | `provision env`: Vercel link required (JSON on stderr) |
473
+ | 4 | a coding agent ran a command only a human can approve; nothing changed (JSON with the command to run on stderr) |
474
+
475
+ ## Troubleshooting
476
+
477
+ | Message | Fix |
478
+ | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
479
+ | `vercel.team_id must be an immutable team_* identifier` | use the team ID (`team_…`), not the slug or URL |
480
+ | `partial: also fill <field>` in `config add` | fill the missing fields, or clear the whole section if the factory does not use it |
481
+ | `Resend domain … must be verified in the factory's Resend account` | verify the domain in Resend, or pass `--email-from` with an already verified domain |
482
+ | `factory … serves GitHub owner …, not …` | the checkout's `origin` belongs to another owner; pass `--factory` or fix the remote |
483
+ | `Vercel project … does not exist; run the vercel step first` | run without `--only`, or include `vercel` |
484
+ | `… already exists and prompting is unavailable; pass --on-existing` | run in a terminal, or choose `--on-existing reuse` |
485
+ | `systemd-creds is unavailable` | use `op signin` or `OP_SERVICE_ACCOUNT_TOKEN` instead of a stored token |
486
+ | `env` exits 3 | pass `--source <linked checkout>` or `--vercel-project <name>` |
487
+ | exit 4 `approval_required` in your own terminal | the shell inherited an agent's variables (for example started from an agent session); open a fresh terminal |
488
+
489
+ ## Development
490
+
491
+ ```bash
492
+ pnpm install
493
+ pnpm dev --help # run from source
494
+ pnpm verify # format, typecheck, lint, test, build
495
+ ```
496
+
497
+ ## Releasing
498
+
499
+ Pushing a `v*` tag publishes to npm from GitHub Actions with provenance (npm trusted publishing).
package/dist/cli.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { NodeRuntime, NodeServices } from '@effect/platform-node';
3
+ import { Console, Effect, Layer } from 'effect';
4
+ import { Command } from 'effect/cli';
5
+ import { FetchHttpClient } from 'effect/http';
6
+ import { contractFor } from './contract.js';
7
+ import { dbCommand } from './db/commands.js';
8
+ import { DbEnvironmentLive } from './db/environment.js';
9
+ import { envCommand } from './env/commands.js';
10
+ import { factoryCommand } from './factory/commands.js';
11
+ import { configCommand } from './factory/config-commands.js';
12
+ import { PassphraseLive } from './factory/passphrase.js';
13
+ import { RegistryLocationLive } from './factory/registry.js';
14
+ import { SecretInputLive } from './factory/secret-input.js';
15
+ import { readPackageInfo } from './package-info.js';
16
+ import { ProcessRunnerLive } from './shared/process.js';
17
+ // Private configuration, lease, and env files are protected from their first write.
18
+ process.umask(0o077);
19
+ const contract = Command.make('contract', {}, () => Effect.gen(function* () {
20
+ const info = yield* readPackageInfo;
21
+ yield* Console.log(JSON.stringify(contractFor(info.version)));
22
+ })).pipe(Command.withDescription('Print the machine-readable CLI contract as JSON'));
23
+ const provision = Command.make('provision').pipe(Command.withDescription('Provision projects (factory), local checkouts (env), and databases (db)'), Command.withSubcommands([contract, configCommand, factoryCommand, envCommand, dbCommand]));
24
+ const main = Effect.gen(function* () {
25
+ const info = yield* readPackageInfo;
26
+ yield* Command.run(provision, { version: info.version });
27
+ });
28
+ const AppLayer = Layer.mergeAll(FetchHttpClient.layer, DbEnvironmentLive, RegistryLocationLive, PassphraseLive, SecretInputLive, ProcessRunnerLive).pipe(Layer.provideMerge(NodeServices.layer));
29
+ main.pipe(Effect.provide(AppLayer), NodeRuntime.runMain);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The machine-readable contract other tools (for example `worktree`) rely on.
3
+ *
4
+ * Bump `contractVersion` only for breaking changes to command names, flags, JSON output shapes,
5
+ * or exit codes. Adding a command is not breaking: callers check `commands` for what they need.
6
+ */
7
+ export const contractVersion = 1;
8
+ export const contractCommands = ['contract', 'config', 'factory', 'env', 'db'];
9
+ export const contractFor = (provisionVersion) => ({
10
+ contractVersion,
11
+ provisionVersion,
12
+ commands: contractCommands
13
+ });
@@ -0,0 +1,23 @@
1
+ import { Cause, Effect, Exit } from 'effect';
2
+ import { LifecycleError } from './domain.js';
3
+ /**
4
+ * Transfers ownership from an uninterruptible acquisition to an interruptible use phase. Every
5
+ * unsuccessful use exit runs cleanup uninterruptibly before the original cause is replayed.
6
+ */
7
+ export const withBranchLifecycleUsing = (acquire, use, cleanup) => Effect.uninterruptibleMask(restore => Effect.gen(function* () {
8
+ const acquisitionExit = yield* Effect.exit(acquire);
9
+ if (Exit.isFailure(acquisitionExit))
10
+ return yield* Effect.failCause(acquisitionExit.cause);
11
+ const operationExit = yield* Effect.exit(restore(use(acquisitionExit.value)));
12
+ if (Exit.isSuccess(operationExit))
13
+ return operationExit.value;
14
+ const cleanupExit = yield* Effect.exit(cleanup(acquisitionExit.value));
15
+ if (Exit.isFailure(cleanupExit) &&
16
+ !Cause.hasInterrupts(operationExit.cause) &&
17
+ !Cause.hasDies(operationExit.cause)) {
18
+ return yield* new LifecycleError({
19
+ message: 'new branch setup failed and cleanup could not be fully verified; manual review is required'
20
+ });
21
+ }
22
+ return yield* Effect.failCause(operationExit.cause);
23
+ }));