@magoz/provision 0.0.0-stage → 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +498 -2
- package/dist/cli.js +29 -0
- package/dist/contract.js +13 -0
- package/dist/db/branch-lifecycle.js +23 -0
- package/dist/db/commands.js +303 -0
- package/dist/db/config.js +75 -0
- package/dist/db/credentials.js +177 -0
- package/dist/db/domain.js +106 -0
- package/dist/db/environment.js +14 -0
- package/dist/db/lease.js +132 -0
- package/dist/db/neon.js +350 -0
- package/dist/db/operations.js +246 -0
- package/dist/db/policy.js +59 -0
- package/dist/env/commands.js +39 -0
- package/dist/env/domain.js +14 -0
- package/dist/env/install.js +25 -0
- package/dist/env/paths.js +105 -0
- package/dist/env/provision.js +320 -0
- package/dist/env/sanitize.js +88 -0
- package/dist/env/vercel.js +180 -0
- package/dist/factory/approval.js +33 -0
- package/dist/factory/commands.js +173 -0
- package/dist/factory/config-commands.js +215 -0
- package/dist/factory/context.js +129 -0
- package/dist/factory/http.js +37 -0
- package/dist/factory/onboarding.js +155 -0
- package/dist/factory/onepassword.js +140 -0
- package/dist/factory/op-credential.js +154 -0
- package/dist/factory/passphrase.js +145 -0
- package/dist/factory/providers/cloudflare.js +126 -0
- package/dist/factory/providers/neon.js +81 -0
- package/dist/factory/providers/report-receiver.js +37 -0
- package/dist/factory/providers/resend.js +32 -0
- package/dist/factory/providers/upstash.js +21 -0
- package/dist/factory/registry.js +50 -0
- package/dist/factory/scoped-key.js +30 -0
- package/dist/factory/sealed.js +64 -0
- package/dist/factory/secret-input.js +43 -0
- package/dist/factory/steps/domain.js +23 -0
- package/dist/factory/steps/neon.js +101 -0
- package/dist/factory/steps/r2.js +70 -0
- package/dist/factory/steps/reports.js +44 -0
- package/dist/factory/steps/resend.js +37 -0
- package/dist/factory/steps/secrets.js +104 -0
- package/dist/factory/steps/upstash.js +92 -0
- package/dist/factory/steps/vercel.js +68 -0
- package/dist/factory/vercel-api.js +165 -0
- package/dist/package-info.js +15 -0
- package/dist/shared/agent.js +28 -0
- package/dist/shared/env-file.js +54 -0
- package/dist/shared/git.js +48 -0
- package/dist/shared/output.js +10 -0
- package/dist/shared/private-file.js +63 -0
- package/dist/shared/process.js +55 -0
- package/dist/shared/repo-config.js +104 -0
- package/dist/shared/sandbox-profile.js +12 -0
- package/package.json +45 -4
- 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
|
-
#
|
|
1
|
+
# provision
|
|
2
2
|
|
|
3
|
-
|
|
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.1",
|
|
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);
|
package/dist/contract.js
ADDED
|
@@ -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
|
+
}));
|