@magoz/provision 0.1.0 → 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>]
|
|
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` |
|
|
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>"`)
|
|
246
|
-
be verified in the factory's Resend
|
|
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
|
|
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.
|
|
473
|
+
"provisionVersion": "0.2.0",
|
|
460
474
|
"commands": ["contract", "config", "factory", "env", "db"]
|
|
461
475
|
}
|
|
462
476
|
```
|
package/dist/factory/commands.js
CHANGED
|
@@ -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;
|
package/dist/factory/context.js
CHANGED
|
@@ -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
|
-
/**
|
|
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,
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
*
|
|
65
|
-
*
|
|
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
|
|
74
|
-
if (fromAddress === undefined) {
|
|
75
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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.
|
|
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": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@effect/platform-node": "4.0.2",
|
|
32
|
+
"@effect/platform-node-shared": "4.0.2",
|
|
32
33
|
"effect": "4.0.2"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
@@ -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
|
-
|
|
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
|
-
--
|
|
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
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
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.
|