@magoz/provision 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -220,7 +220,8 @@ Revoke immediately if the host may be compromised.
220
220
  provision factory [--checkout <repo>] --domain <production-host>
221
221
  [--sender-name <name>] [--email-from <address>]
222
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]
223
+ [--on-existing ask|reuse|overwrite|abort] [--neon-parent-branch <id>]
224
+ [--secret NAME[=base64-<bytes>|hex-<bytes>|vapid]]... [--dry-run]
224
225
  ```
225
226
 
226
227
  Creates what the repository declares (`provision.factory.steps`, narrowed by `--only`) for the
@@ -235,15 +236,26 @@ checkout's GitHub repository `<owner>/<repo>`. Resources are named `<repo>` (Ver
235
236
  | `upstash` | `<repo>-dev`/`-prod` teams | `QSTASH_*`, pasted by you (see [Manual follow-ups](#manual-follow-ups)) |
236
237
  | `resend` | sending-only keys (any verified domain) | `RESEND_API_KEY` |
237
238
  | `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
+ | `secrets` | the secrets named with `--secret` (generated, never shown) | those, plus `AUTH_EMAIL_FROM` when a sender is known |
239
240
  | `domain` | attaches `--domain` to the project | — |
240
241
 
241
242
  - **Environments.** Pre-production values go to Development, Preview, and `test`; production values
242
243
  go to Production and are Sensitive. Each side gets its own keys and secrets.
243
244
  - **`--domain`** is the production host. The `r2` step allows browser uploads to the production
244
245
  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.
246
+ - **`--email-from`** sets `AUTH_EMAIL_FROM` (`"<sender-name> <address>"`), defaulting to
247
+ `noreply@<resend.sending_domain>`. The address's domain must be verified in the factory's Resend
248
+ account. Without either, `AUTH_EMAIL_FROM` is not written.
249
+ - **`--secret`** (repeatable) names a secret the repository's code reads; nothing is generated
250
+ that isn't asked for. `NAME` alone is 32 random bytes in base64; `NAME=hex-32` is hex;
251
+ `NAME=vapid` is a web-push pair written as `NAME_PUBLIC_KEY` (plain) and `NAME_PRIVATE_KEY`.
252
+ Pre-production and production get separate values. A secret already in Vercel, even in only
253
+ part of an environment group, is replaced only when `--on-existing` says so.
254
+ - **Which flags.** The repository's code decides: the variables it reads (`.env.example`, its
255
+ config reads) minus what `vercel env ls` already lists. Provider keys come from their steps
256
+ (`--only`), generated secrets from `--secret`; third-party keys and plain settings are added
257
+ with `vercel env add`. The [agent skill](#agents) does this and prepares the command.
258
+ - **R2 uploads** allow the headers a signed upload binds: `content-type` and `if-none-match`.
247
259
  - **Order.** `vercel` runs first; the other steps then run concurrently. Each step prints one block
248
260
  when it finishes, and a failing step lets the others finish before the run fails.
249
261
 
@@ -252,11 +264,13 @@ checkout's GitHub repository `<owner>/<repo>`. Resources are named `<repo>` (Ver
252
264
  ```bash
253
265
  # 1. Plan: reads every provider, changes nothing
254
266
  provision factory --checkout ~/src/acme-app --domain app.acme.com \
255
- --sender-name Acme --email-from noreply@acme.com --dry-run
267
+ --sender-name Acme --email-from noreply@acme.com \
268
+ --secret BETTER_AUTH_SECRET --secret CRON_SECRET=hex-32 --dry-run
256
269
 
257
270
  # 2. Apply
258
271
  provision factory --checkout ~/src/acme-app --domain app.acme.com \
259
- --sender-name Acme --email-from noreply@acme.com
272
+ --sender-name Acme --email-from noreply@acme.com \
273
+ --secret BETTER_AUTH_SECRET --secret CRON_SECRET=hex-32
260
274
 
261
275
  # 3. Rerun: should only report "reusing"
262
276
  provision factory --checkout ~/src/acme-app --domain app.acme.com \
@@ -456,7 +470,7 @@ passphrase read from the terminal: an agent that hides its variables still canno
456
470
  ```json
457
471
  {
458
472
  "contractVersion": 1,
459
- "provisionVersion": "0.1.1",
473
+ "provisionVersion": "0.2.0",
460
474
  "commands": ["contract", "config", "factory", "env", "db"]
461
475
  }
462
476
  ```
@@ -14,7 +14,7 @@ import { neonStep } from './steps/neon.js';
14
14
  import { r2Step } from './steps/r2.js';
15
15
  import { reportsStep } from './steps/reports.js';
16
16
  import { resendStep } from './steps/resend.js';
17
- import { secretsStep } from './steps/secrets.js';
17
+ import { parseSecretSpecs, secretsStep } from './steps/secrets.js';
18
18
  import { upstashStep } from './steps/upstash.js';
19
19
  import { vercelStep } from './steps/vercel.js';
20
20
  const steps = {
@@ -123,6 +123,7 @@ export const runFactory = (options) => Effect.gen(function* () {
123
123
  if (emailFrom !== undefined && !/^[^\s@<>]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(emailFrom)) {
124
124
  return yield* factoryError('--email-from must be an address such as noreply@acme.com');
125
125
  }
126
+ const secrets = yield* parseSecretSpecs(options.secret);
126
127
  const factory = yield* selectFactory(yield* readRegistry, options.factory, owner);
127
128
  if (factory.credentials.githubOwner !== owner) {
128
129
  return yield* factoryError(`factory ${factory.name} serves GitHub owner ${factory.credentials.githubOwner}, not ${owner}`);
@@ -138,6 +139,7 @@ export const runFactory = (options) => Effect.gen(function* () {
138
139
  senderName: Option.getOrElse(options.senderName, () => name),
139
140
  emailFrom,
140
141
  neonParentBranch: Option.getOrUndefined(options.neonParentBranch),
142
+ secrets,
141
143
  dryRun: options.dryRun,
142
144
  onExisting: options.onExisting,
143
145
  names: { project: name, development: `${name}-dev`, production: `${name}-prod` }
@@ -167,6 +169,7 @@ export const factoryCommand = Command.make('factory', {
167
169
  only: Flag.String('only').pipe(Flag.withDescription(`comma-separated subset of: ${factorySteps.join(', ')}`), Flag.optional),
168
170
  onExisting: Flag.Literals('on-existing', onExistingPolicies).pipe(Flag.withDescription('what to do when a resource exists (data is only ever reused)'), Flag.withDefault('ask')),
169
171
  neonParentBranch: Flag.String('neon-parent-branch').pipe(Flag.withDescription('existing-branch mode: sandbox parent branch id'), Flag.optional),
172
+ secret: Flag.String('secret').pipe(Flag.withDescription('repeatable: a secret the code reads, NAME[=base64-<bytes>|hex-<bytes>|vapid] (default base64-32), generated per environment group'), Flag.atLeast(0)),
170
173
  dryRun: Flag.Boolean('dry-run').pipe(Flag.withDescription('inspect and print planned changes without making them'), Flag.withDefault(false))
171
174
  }, options => humanOnly(runFactory(options).pipe(Effect.catch(error => Console.error(`provision factory: ${error.message}`).pipe(Effect.andThen(Effect.sync(() => {
172
175
  process.exitCode = 2;
@@ -103,6 +103,12 @@ const containedIn = (record, group) => recordTargets(record).every(target => gro
103
103
  (record.customEnvironmentIds ?? []).every(id => group.customEnvironmentIds.includes(id));
104
104
  /** Whether every key has a record covering the whole group. */
105
105
  export const groupHasKeys = (records, group, keys) => keys.every(key => records.some(record => record.key === key && covers(record, group)));
106
+ /** Whether the group has every key everywhere (`complete`), some of it (`partial`), or none. */
107
+ export const groupKeyState = (records, group, keys) => groupHasKeys(records, group, keys)
108
+ ? 'complete'
109
+ : keys.some(key => records.some(record => record.key === key && overlaps(record, group)))
110
+ ? 'partial'
111
+ : 'none';
106
112
  /**
107
113
  * Replaces the group's records for each key: overlapping records are deleted, then one record per
108
114
  * key is created. A record that spans environments outside the group is never touched.
@@ -13,11 +13,14 @@ export const r2Endpoint = (accountId) => `https://${accountId}.r2.cloudflarestor
13
13
  // ---------------------------------------------------------------------------
14
14
  export const bucketExists = (admin, name) => requestJson('R2 bucket lookup', `${account(admin)}/r2/buckets/${encodeURIComponent(name)}`, envelope(Schema.Struct({ name: Schema.String })), { headers: headers(admin.apiToken), allowNotFound: true }).pipe(Effect.map(Option.isSome));
15
15
  export const createBucket = (admin, name) => requestJsonRequired('R2 bucket creation', `${account(admin)}/r2/buckets`, envelope(Schema.Struct({ name: Schema.String })), { method: 'POST', headers: headers(admin.apiToken), body: { name } }).pipe(Effect.asVoid);
16
- /** Browser uploads use presigned PUTs; CORS allows exactly that. */
16
+ /**
17
+ * Browser uploads use presigned PUTs; CORS allows exactly that, with the headers a signed upload
18
+ * binds (`content-type`, and `if-none-match` for create-only uploads).
19
+ */
17
20
  const corsRules = (origin) => [
18
21
  {
19
22
  id: 'browser-uploads',
20
- allowed: { methods: ['PUT'], origins: [origin], headers: ['content-type'] },
23
+ allowed: { methods: ['PUT'], origins: [origin], headers: ['content-type', 'if-none-match'] },
21
24
  exposeHeaders: ['etag'],
22
25
  maxAgeSeconds: 3600
23
26
  }
@@ -1,10 +1,12 @@
1
1
  import { generateKeyPairSync, randomBytes } from 'node:crypto';
2
2
  import { Console, Effect, Option, Redacted } from 'effect';
3
- import { decide, envGroups, factoryError, groupHasKeys, requireProject, writeGroup } from '../context.js';
3
+ import { decide, envGroups, factoryError, groupKeyState, requireProject, writeGroup } from '../context.js';
4
4
  import { requireVerifiedDomain } from '../providers/resend.js';
5
5
  import { listEnvRecords } from '../vercel-api.js';
6
- export const usageAlertThresholds = '50,80,95';
7
- const base64 = (bytes) => Redacted.make(randomBytes(bytes).toString('base64'));
6
+ const generators = {
7
+ base64: (bytes) => Redacted.make(randomBytes(bytes).toString('base64')),
8
+ hex: (bytes) => Redacted.make(randomBytes(bytes).toString('hex'))
9
+ };
8
10
  /** A web-push VAPID pair: uncompressed P-256 public point and raw private scalar, base64url. */
9
11
  export const generateVapidKeys = () => {
10
12
  const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
@@ -17,64 +19,79 @@ export const generateVapidKeys = () => {
17
19
  privateKey: Redacted.make(privateJwk.d ?? '')
18
20
  };
19
21
  };
20
- /** Generated per group: a separate value for pre-production and production. */
21
- const generatedSets = [
22
- {
23
- description: 'BETTER_AUTH_SECRET',
24
- keys: ['BETTER_AUTH_SECRET'],
25
- kind: 'secret',
26
- entries: type => [{ key: 'BETTER_AUTH_SECRET', value: base64(32), type }]
27
- },
28
- {
29
- description: 'INTEGRATION_CREDENTIAL_ENCRYPTION_KEY',
30
- keys: ['INTEGRATION_CREDENTIAL_ENCRYPTION_KEY'],
31
- kind: 'secret',
32
- entries: type => [{ key: 'INTEGRATION_CREDENTIAL_ENCRYPTION_KEY', value: base64(32), type }]
33
- },
34
- {
35
- description: 'CRON_SECRET',
36
- keys: ['CRON_SECRET'],
37
- kind: 'key',
38
- entries: type => [
39
- { key: 'CRON_SECRET', value: Redacted.make(randomBytes(32).toString('hex')), type }
40
- ]
41
- },
42
- {
43
- description: 'VAPID key pair',
44
- keys: ['VAPID_PUBLIC_KEY', 'VAPID_PRIVATE_KEY'],
45
- kind: 'secret',
46
- entries: type => {
47
- const pair = generateVapidKeys();
48
- return [
49
- { key: 'VAPID_PUBLIC_KEY', value: Redacted.make(pair.publicKey), type: 'plain' },
50
- { key: 'VAPID_PRIVATE_KEY', value: pair.privateKey, type }
51
- ];
22
+ const secretName = /^[A-Z][A-Z0-9_]*$/;
23
+ export const parseSecretSpecs = (values) => Effect.gen(function* () {
24
+ const specs = [];
25
+ for (const value of values) {
26
+ const [name = '', format = 'base64-32', ...rest] = value.split('=');
27
+ const sized = /^(base64|hex)-(\d+)$/.exec(format);
28
+ const bytes = Number(sized?.[2]);
29
+ if (!secretName.test(name) || rest.length > 0) {
30
+ return yield* factoryError(`--secret ${value}: expected NAME[=FORMAT] with an upper-case NAME, e.g. BETTER_AUTH_SECRET=base64-32`);
31
+ }
32
+ if (format === 'vapid')
33
+ specs.push({ name, format: { kind: 'vapid' } });
34
+ else if ((sized?.[1] === 'base64' || sized?.[1] === 'hex') && bytes >= 16 && bytes <= 128) {
35
+ specs.push({ name, format: { kind: sized[1], bytes } });
52
36
  }
37
+ else {
38
+ return yield* factoryError(`--secret ${value}: FORMAT is base64-<bytes>, hex-<bytes> (16 to 128 bytes), or vapid`);
39
+ }
40
+ }
41
+ const keys = specs.flatMap(spec => specKeys(spec));
42
+ const duplicates = keys.filter((key, index) => keys.indexOf(key) !== index);
43
+ if (duplicates.length > 0) {
44
+ return yield* factoryError(`--secret names a variable twice: ${[...new Set(duplicates)].join(', ')}`);
53
45
  }
54
- ];
55
- const ensureSet = (context, handle, records, group, set, entries) => Effect.gen(function* () {
56
- if (groupHasKeys(records, group, set.keys)) {
57
- const decision = yield* decide(context, set.kind, `${set.description} (${group.label})`);
46
+ return specs;
47
+ });
48
+ const specKeys = (spec) => spec.format.kind === 'vapid'
49
+ ? [`${spec.name}_PUBLIC_KEY`, `${spec.name}_PRIVATE_KEY`]
50
+ : [spec.name];
51
+ const generate = (spec, type) => {
52
+ if (spec.format.kind === 'vapid') {
53
+ const pair = generateVapidKeys();
54
+ return [
55
+ { key: `${spec.name}_PUBLIC_KEY`, value: Redacted.make(pair.publicKey), type: 'plain' },
56
+ { key: `${spec.name}_PRIVATE_KEY`, value: pair.privateKey, type }
57
+ ];
58
+ }
59
+ return [{ key: spec.name, value: generators[spec.format.kind](spec.format.bytes), type }];
60
+ };
61
+ /**
62
+ * Writes `entries` unless the group already has any of `keys`: whole or partial, an existing value
63
+ * is only replaced when `--on-existing` says so (never silently).
64
+ */
65
+ const ensure = (context, handle, records, group, description, keys, entries) => Effect.gen(function* () {
66
+ const state = groupKeyState(records, group, keys);
67
+ if (state !== 'none') {
68
+ const partly = state === 'partial' ? ', set only in part of it' : '';
69
+ const decision = yield* decide(context, 'secret', `${description} (${group.label}${partly})`);
58
70
  if (decision === 'reuse')
59
71
  return;
60
72
  }
61
73
  yield* writeGroup(context, handle, group, entries());
62
74
  });
63
75
  /**
64
- * App secrets the CLI generates (never shown or stored elsewhere), plus plain settings shared by
65
- * all environments: `AUTH_EMAIL_FROM` and `AI_PROVIDER_USAGE_ALERT_THRESHOLDS`.
76
+ * The secrets the repository's code reads, named with `--secret` (generated here, never shown or
77
+ * stored elsewhere): a separate value for pre-production and for production (Sensitive). Plus
78
+ * `AUTH_EMAIL_FROM` for every environment when a sender is known (`--email-from`, else
79
+ * `noreply@<resend.sending_domain>`), on a domain verified in the factory's Resend account.
66
80
  */
67
81
  export const secretsStep = (context) => Effect.gen(function* () {
68
82
  const resend = context.credentials.resend;
69
- if (resend === undefined) {
70
- return yield* factoryError('the secrets step needs the resend section for AUTH_EMAIL_FROM');
71
- }
72
83
  const fromAddress = context.emailFrom ??
73
- (resend.sendingDomain === undefined ? undefined : `noreply@${resend.sendingDomain}`);
74
- if (fromAddress === undefined) {
75
- return yield* factoryError('the secrets step needs a sender: pass --email-from or set resend.sending_domain in the factory item');
84
+ (resend?.sendingDomain === undefined ? undefined : `noreply@${resend.sendingDomain}`);
85
+ if (context.secrets.length === 0 && fromAddress === undefined) {
86
+ yield* Console.log(' nothing to generate: pass --secret NAME=FORMAT or --email-from');
87
+ return;
88
+ }
89
+ if (fromAddress !== undefined) {
90
+ if (resend === undefined) {
91
+ return yield* factoryError('AUTH_EMAIL_FROM needs the resend section to verify its domain');
92
+ }
93
+ yield* requireVerifiedDomain(resend.apiKey, fromAddress.slice(fromAddress.indexOf('@') + 1));
76
94
  }
77
- yield* requireVerifiedDomain(resend.apiKey, fromAddress.slice(fromAddress.indexOf('@') + 1));
78
95
  const handle = yield* requireProject(context);
79
96
  if (Option.isNone(handle)) {
80
97
  yield* Console.log(' would run after the vercel step creates the project');
@@ -82,23 +99,23 @@ export const secretsStep = (context) => Effect.gen(function* () {
82
99
  }
83
100
  const groups = envGroups(handle.value);
84
101
  const records = yield* listEnvRecords(context.credentials.vercel.teamId, handle.value.project.id);
85
- for (const set of generatedSets) {
86
- yield* ensureSet(context, handle.value, records, groups.preproduction, set, () => set.entries('encrypted'));
87
- yield* ensureSet(context, handle.value, records, groups.production, set, () => set.entries('sensitive'));
102
+ for (const spec of context.secrets) {
103
+ const keys = specKeys(spec);
104
+ for (const [group, type] of [
105
+ [groups.preproduction, 'encrypted'],
106
+ [groups.production, 'sensitive']
107
+ ]) {
108
+ yield* ensure(context, handle.value, records, group, keys.join(', '), keys, () => generate(spec, type));
109
+ }
88
110
  }
89
- const plain = [
90
- {
111
+ if (fromAddress !== undefined) {
112
+ const entry = {
91
113
  key: 'AUTH_EMAIL_FROM',
92
114
  value: Redacted.make(`${context.senderName} <${fromAddress}>`),
93
115
  type: 'plain'
94
- },
95
- {
96
- key: 'AI_PROVIDER_USAGE_ALERT_THRESHOLDS',
97
- value: Redacted.make(usageAlertThresholds),
98
- type: 'plain'
99
- }
100
- ];
101
- for (const entry of plain) {
102
- yield* ensureSet(context, handle.value, records, groups.all, { description: entry.key, keys: [entry.key], kind: 'key', entries: () => [entry] }, () => [entry]);
116
+ };
117
+ yield* ensure(context, handle.value, records, groups.all, entry.key, [entry.key], () => [
118
+ entry
119
+ ]);
103
120
  }
104
121
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magoz/provision",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Provision projects (factory), local checkouts (env), and disposable databases (db).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -119,24 +119,63 @@ Release both leases before deleting a worktree. Branches expire on their own aft
119
119
  Prerequisites: the GitHub repository exists under the factory's GitHub owner, there is a local
120
120
  checkout with that `origin`, and the factory is registered (`provision config list`).
121
121
 
122
- Prepare these for the human, in order:
122
+ ### First, work out what the repository needs
123
+
124
+ Don't assume a template: read the code, then compare with Vercel.
125
+
126
+ 1. **Variables the code reads.** Search for config reads (`Config.String('…')`,
127
+ `Config.Redacted('…')`, `process.env.…`), `.env.example`, and any production or setup doc in
128
+ the repo. Note how the docs say each secret is generated (for example `openssl rand -base64 32`).
129
+ 2. **What Vercel already has** (names only, from the linked checkout):
130
+ `vercel env ls development`, `vercel env ls preview`, `vercel env ls production`,
131
+ `vercel env ls test`. Existing values are reused; never regenerate what exists unless the
132
+ human asks to rotate.
133
+ 3. **Sort each missing variable:**
134
+
135
+ | The code reads… | Comes from |
136
+ | ---------------------------------------------------------------------- | ------------------------------------------------------------ |
137
+ | `DATABASE_URL*` (Neon) | `neon` step |
138
+ | `R2_*` (S3-compatible storage) | `r2` step (needs `--domain` for the upload origin) |
139
+ | `QSTASH_*` | `upstash` step (the human pastes the console's `.env` block) |
140
+ | `RESEND_API_KEY` | `resend` step |
141
+ | `AUTH_EMAIL_FROM` | `--email-from` (domain verified in the factory's Resend) |
142
+ | `REPORT_RECEIVER_*` | `reports` step (factories with a receiver) |
143
+ | a random secret the app generates itself (auth, cron, encryption keys) | `--secret NAME[=FORMAT]` |
144
+ | a third-party key (AI Gateway, OAuth apps, data APIs) | the human, with `vercel env add` |
145
+ | plain config (URLs, limits, flags) | you, with `vercel env add … --value … --yes` |
146
+
147
+ `--secret` formats: `NAME` (32 random bytes, base64), `NAME=base64-<bytes>`,
148
+ `NAME=hex-<bytes>`, `NAME=vapid` (web-push pair: `NAME_PUBLIC_KEY` + `NAME_PRIVATE_KEY`).
149
+ Match the format the code or docs expect (a key decoded as 32 bytes of base64 needs
150
+ `base64-32`).
151
+
152
+ 4. **Pick the steps** with `--only`: `vercel` always, plus the steps whose variables the code reads,
153
+ plus `domain` when the production host is known. Leave out steps the app doesn't use.
154
+ 5. **Check conflicts** before handing over: a step stops if one Vercel record of its variable spans
155
+ pre-production and production (`… has a record that also targets environments outside …`).
156
+ Tell the human which record to delete or split first.
157
+
158
+ Then show the human what you found (variables, where each comes from, what already exists) and
159
+ the commands, in order:
123
160
 
124
161
  ```bash
125
162
  # 1. Plan: reads providers, changes nothing
126
- /usr/local/bin/provision factory --checkout <checkout> --domain <production-host> --dry-run
127
-
128
- # 2. Apply
129
163
  /usr/local/bin/provision factory --checkout <checkout> --domain <production-host> \
130
- --sender-name <App name> --email-from noreply@<verified-domain>
164
+ --only vercel,neon,resend,secrets,domain \
165
+ --sender-name <App name> --email-from login@<verified-domain> \
166
+ --secret BETTER_AUTH_SECRET --secret CRON_SECRET=hex-32 --dry-run
131
167
 
132
- # 3. Confirm idempotence: should print only "reusing" lines
133
- /usr/local/bin/provision factory --checkout <checkout> --domain <production-host> \
134
- --sender-name <App name> --email-from noreply@<verified-domain> --on-existing reuse
168
+ # 2. Apply: the same command without --dry-run
169
+
170
+ # 3. Confirm idempotence: the same command with --on-existing reuse; only "reusing" lines
135
171
 
136
172
  # 4. Local checkout (you can run this one)
137
173
  provision env --repo <checkout> --database
138
174
  ```
139
175
 
176
+ Keep `--only` and the `--secret` flags identical across the three runs. Afterwards, the
177
+ third-party keys and plain settings from step 3 still need `vercel env add`.
178
+
140
179
  Reading the output: each step prints one block. `would …` is a dry-run plan, `exists:` /
141
180
  `reusing …` means nothing changed, `skipped:` means the factory lacks that provider, and
142
181
  `manual: …` is a follow-up the human must do by hand. Relay `manual:` lines to the human.