@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
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { Cause, Console, Effect, Exit, FileSystem, Option, Path } from 'effect';
|
|
2
|
+
import { Prompt } from 'effect/cli';
|
|
3
|
+
import { readLease, writeLease } from '../db/lease.js';
|
|
4
|
+
import { createLease, releaseLease } from '../db/operations.js';
|
|
5
|
+
import { gitOutput } from '../shared/git.js';
|
|
6
|
+
import { readPrivately, writePrivately } from '../shared/private-file.js';
|
|
7
|
+
import { inherit } from '../shared/process.js';
|
|
8
|
+
import { readRepoConfig } from '../shared/repo-config.js';
|
|
9
|
+
import { defaultDatabaseUrlKeys } from '../shared/sandbox-profile.js';
|
|
10
|
+
import { fail } from './domain.js';
|
|
11
|
+
import { installDependencies } from './install.js';
|
|
12
|
+
import { ensureLocalDirectory, ensurePrivateMode, ensureProvisionPaths, ensureSecretPath, resolveAppDirectory, resolveCheckout } from './paths.js';
|
|
13
|
+
import { ensureNoLocalResetConsent, keepExistingDatabaseUrls, stripPulledEnvironment } from './sanitize.js';
|
|
14
|
+
import { ensureVercelLink, listExplicitVercelKeys, pullVercelEnvironment, readVercelIdentity } from './vercel.js';
|
|
15
|
+
const io = (message) => (effect) => Effect.mapError(effect, () => fail(message));
|
|
16
|
+
const resolveEnvConflict = (policy, nonInteractive, localExists, testExists) => Effect.gen(function* () {
|
|
17
|
+
if (!localExists && !testExists)
|
|
18
|
+
return 'overwrite';
|
|
19
|
+
const existing = [localExists ? '.env.local' : undefined, testExists ? '.env.test' : undefined]
|
|
20
|
+
.filter(file => file !== undefined)
|
|
21
|
+
.join(' and ');
|
|
22
|
+
const bothExist = localExists && testExists;
|
|
23
|
+
const existence = bothExist ? 'already exist' : 'already exists';
|
|
24
|
+
if (policy === 'error') {
|
|
25
|
+
return yield* fail(`${existing} ${existence}; use --env-conflict=preserve or --env-conflict=overwrite`);
|
|
26
|
+
}
|
|
27
|
+
if (policy === 'preserve') {
|
|
28
|
+
if (!bothExist) {
|
|
29
|
+
return yield* fail(`cannot preserve a partial env pair (${existing}); use --env-conflict=overwrite or restore both files`);
|
|
30
|
+
}
|
|
31
|
+
return 'preserve';
|
|
32
|
+
}
|
|
33
|
+
if (policy === 'overwrite')
|
|
34
|
+
return 'overwrite';
|
|
35
|
+
if (nonInteractive || process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
|
|
36
|
+
return yield* fail(`${existing} ${existence} and prompting is unavailable; pass --env-conflict=preserve or --env-conflict=overwrite explicitly`);
|
|
37
|
+
}
|
|
38
|
+
const decision = yield* Prompt.run(Prompt.Select({
|
|
39
|
+
message: `${existing} ${existence}. How should provision env continue?`,
|
|
40
|
+
choices: [
|
|
41
|
+
{
|
|
42
|
+
title: 'Preserve existing env files',
|
|
43
|
+
value: 'preserve',
|
|
44
|
+
description: 'Skip both Vercel pulls and continue with the existing files',
|
|
45
|
+
disabled: !bothExist
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
title: 'Overwrite from Vercel',
|
|
49
|
+
value: 'overwrite',
|
|
50
|
+
description: 'Snapshot both files, pull fresh values, and restore them if setup fails'
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
title: 'Cancel',
|
|
54
|
+
value: 'cancel',
|
|
55
|
+
description: 'Exit without changing env files or database leases'
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
})).pipe(Effect.mapError(() => fail('cancelled by user')));
|
|
59
|
+
if (decision === 'cancel')
|
|
60
|
+
return yield* fail('cancelled by user');
|
|
61
|
+
return decision;
|
|
62
|
+
});
|
|
63
|
+
const ensureEnvUnchanged = (file, existed, snapshot) => Effect.gen(function* () {
|
|
64
|
+
const current = yield* readPrivately(file).pipe(io(`cannot revalidate ${file}`));
|
|
65
|
+
if ((current !== undefined) !== existed || (current !== undefined && current !== snapshot)) {
|
|
66
|
+
return yield* fail(`${file} changed while provisioning; refusing to overwrite it`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
const replayExit = (exit) => Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause);
|
|
70
|
+
const isUnexpected = (cause) => Cause.hasInterrupts(cause) || Cause.hasDies(cause);
|
|
71
|
+
/**
|
|
72
|
+
* Holds the checkout lock around `operation`; on failure runs `rollback` before releasing the
|
|
73
|
+
* lock. Cleanup problems never hide the original cause, and defects or interruption are never
|
|
74
|
+
* normalized into typed errors.
|
|
75
|
+
*/
|
|
76
|
+
export const withProvisionLifecycleUsing = (acquire, operation, rollback, release) => Effect.suspend(() => {
|
|
77
|
+
let rollbackExit;
|
|
78
|
+
let releaseExit;
|
|
79
|
+
return Effect.acquireUseRelease(acquire, () => operation, (lock, operationExit) => Effect.gen(function* () {
|
|
80
|
+
if (Exit.isFailure(operationExit))
|
|
81
|
+
rollbackExit = yield* Effect.exit(rollback);
|
|
82
|
+
releaseExit = yield* Effect.exit(release(lock));
|
|
83
|
+
})).pipe(Effect.exit, Effect.flatMap((operationExit) => {
|
|
84
|
+
const cleanupExits = [rollbackExit, releaseExit].filter(exit => exit !== undefined);
|
|
85
|
+
const failedCleanup = cleanupExits.find(Exit.isFailure);
|
|
86
|
+
if (failedCleanup === undefined)
|
|
87
|
+
return replayExit(operationExit);
|
|
88
|
+
if (Exit.isSuccess(operationExit))
|
|
89
|
+
return Effect.failCause(failedCleanup.cause);
|
|
90
|
+
if (isUnexpected(operationExit.cause))
|
|
91
|
+
return replayExit(operationExit);
|
|
92
|
+
const unexpectedCleanup = cleanupExits.find(exit => Exit.isFailure(exit) && isUnexpected(exit.cause));
|
|
93
|
+
if (unexpectedCleanup !== undefined && Exit.isFailure(unexpectedCleanup)) {
|
|
94
|
+
return Effect.failCause(unexpectedCleanup.cause);
|
|
95
|
+
}
|
|
96
|
+
if (Option.isSome(Cause.findErrorOption(operationExit.cause)) &&
|
|
97
|
+
Option.isSome(Cause.findErrorOption(failedCleanup.cause))) {
|
|
98
|
+
return Effect.fail(fail('setup failed and lifecycle cleanup also failed; manual review is required'));
|
|
99
|
+
}
|
|
100
|
+
return Effect.failCause(failedCleanup.cause);
|
|
101
|
+
}));
|
|
102
|
+
});
|
|
103
|
+
/** Release only a lease this invocation created, and restore the record it replaced. */
|
|
104
|
+
const rollBackLease = (repo, slot, before) => Effect.gen(function* () {
|
|
105
|
+
if (before === undefined)
|
|
106
|
+
return;
|
|
107
|
+
const current = yield* readLease(repo, slot);
|
|
108
|
+
const createdHere = Option.isSome(current) &&
|
|
109
|
+
(Option.isNone(before) || current.value.branchId !== before.value.branchId);
|
|
110
|
+
if (createdHere) {
|
|
111
|
+
yield* releaseLease({ worktree: Option.some(repo), leaseName: slot, keepEnv: true });
|
|
112
|
+
if (Option.isSome(before))
|
|
113
|
+
yield* writeLease(before.value);
|
|
114
|
+
}
|
|
115
|
+
else if (Option.isNone(current) && Option.isSome(before)) {
|
|
116
|
+
yield* writeLease(before.value);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
/** Restores a file to its snapshot, or removes it when it did not exist before. */
|
|
120
|
+
const restoreFile = (file, label, snapshot) => Effect.gen(function* () {
|
|
121
|
+
const fs = yield* FileSystem.FileSystem;
|
|
122
|
+
if (snapshot === undefined)
|
|
123
|
+
yield* fs.remove(file, { force: true });
|
|
124
|
+
else
|
|
125
|
+
yield* writePrivately(file, snapshot);
|
|
126
|
+
yield* Console.error(`provision env: restored ${label} after setup failure`);
|
|
127
|
+
});
|
|
128
|
+
/** Runs repository-declared setup commands from the checkout root through the user's shell. */
|
|
129
|
+
export const runSetup = (repo, commands) => Effect.forEach(commands, command => Console.log(`provision env: setup: ${command}`).pipe(Effect.andThen(inherit(process.env.SHELL ?? '/bin/sh', ['-lc', command], { cwd: repo })), Effect.mapError(error => fail(`setup command failed (${error.message}): ${command}`))), { discard: true });
|
|
130
|
+
export const provisionEnvironment = (options) => Effect.gen(function* () {
|
|
131
|
+
const fs = yield* FileSystem.FileSystem;
|
|
132
|
+
const path = yield* Path.Path;
|
|
133
|
+
const repo = yield* resolveCheckout(options.repo);
|
|
134
|
+
// Read and validate repository configuration (including setup) before any mutation.
|
|
135
|
+
const config = yield* readRepoConfig(repo).pipe(Effect.mapError(error => fail(error.message)));
|
|
136
|
+
const { appDir, directory } = yield* resolveAppDirectory(repo, options.appDir ?? config.appDir);
|
|
137
|
+
const vercelLinkRelative = path.join(appDir, '.vercel', 'project.json');
|
|
138
|
+
// Preflight for callers about to create a worktree: validate or reuse a local link only.
|
|
139
|
+
if (options.checkVercelLink) {
|
|
140
|
+
yield* ensureProvisionPaths(directory, ['.vercel/project.json']);
|
|
141
|
+
yield* ensureSecretPath(repo, vercelLinkRelative);
|
|
142
|
+
yield* ensureVercelLink({
|
|
143
|
+
directory,
|
|
144
|
+
appDir,
|
|
145
|
+
checkout: repo,
|
|
146
|
+
source: options.source,
|
|
147
|
+
vercelProject: undefined
|
|
148
|
+
});
|
|
149
|
+
yield* readVercelIdentity(path.join(directory, '.vercel', 'project.json'));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const localRelative = path.join(appDir, '.env.local');
|
|
153
|
+
const testRelative = path.join(appDir, '.env.test');
|
|
154
|
+
const localEnvFile = path.join(directory, '.env.local');
|
|
155
|
+
const testEnvFile = path.join(directory, '.env.test');
|
|
156
|
+
const vercelDirectory = path.join(directory, '.vercel');
|
|
157
|
+
const lockPath = yield* gitOutput(repo, ['rev-parse', '--git-path', 'provision-env.lock']);
|
|
158
|
+
if (Option.isNone(lockPath))
|
|
159
|
+
return yield* fail('cannot resolve the checkout lock path');
|
|
160
|
+
const lockDirectory = path.resolve(repo, lockPath.value);
|
|
161
|
+
const acquireLock = fs.makeDirectory(lockDirectory).pipe(Effect.mapError(() => fail(`another provision env process is already using ${repo}; if none is running, remove stale lock ${lockDirectory}`)), Effect.as(lockDirectory));
|
|
162
|
+
const state = {
|
|
163
|
+
createdLocalEnv: false,
|
|
164
|
+
createdTestEnv: false,
|
|
165
|
+
createdVercelLink: false
|
|
166
|
+
};
|
|
167
|
+
const createDatabaseLease = (slot, envRelative, label) => Effect.gen(function* () {
|
|
168
|
+
const result = yield* createLease({
|
|
169
|
+
worktree: Option.some(repo),
|
|
170
|
+
ttl: options.ttl,
|
|
171
|
+
label: Option.some(label),
|
|
172
|
+
envFile: envRelative,
|
|
173
|
+
configEnvFile: Option.some(localRelative),
|
|
174
|
+
leaseName: slot,
|
|
175
|
+
keys: defaultDatabaseUrlKeys.join(','),
|
|
176
|
+
forceNew: false,
|
|
177
|
+
noWait: false
|
|
178
|
+
});
|
|
179
|
+
yield* Console.log(`provision env: ${result.status} ${slot} lease ${result.lease.branchName} (${result.lease.branchId})`);
|
|
180
|
+
});
|
|
181
|
+
const pullEnvironments = (localExists, testExists) => Effect.gen(function* () {
|
|
182
|
+
state.createdVercelLink = yield* ensureVercelLink({
|
|
183
|
+
directory,
|
|
184
|
+
appDir,
|
|
185
|
+
checkout: repo,
|
|
186
|
+
source: options.source,
|
|
187
|
+
vercelProject: options.vercelProject
|
|
188
|
+
});
|
|
189
|
+
yield* ensureLocalDirectory(vercelDirectory, 'checkout .vercel path');
|
|
190
|
+
yield* fs.chmod(vercelDirectory, 0o700).pipe(io('cannot protect Vercel staging directory'));
|
|
191
|
+
yield* ensurePrivateMode(vercelDirectory, 0o700, 'Vercel staging directory');
|
|
192
|
+
const stage = (name) => path.join(vercelDirectory, `${name}.provision-env.tmp`);
|
|
193
|
+
const localStage = stage('.env.local');
|
|
194
|
+
const testStage = stage('.env.test');
|
|
195
|
+
state.localPullTemporary = localStage;
|
|
196
|
+
state.testPullTemporary = testStage;
|
|
197
|
+
for (const file of [localStage, testStage]) {
|
|
198
|
+
yield* fs.remove(file, { force: true }).pipe(io(`cannot clear staging path ${file}`));
|
|
199
|
+
yield* writePrivately(file, '').pipe(io(`cannot create staging file ${file}`));
|
|
200
|
+
yield* ensurePrivateMode(file, 0o600, 'staging file');
|
|
201
|
+
}
|
|
202
|
+
const developmentKeys = yield* listExplicitVercelKeys(directory, 'development');
|
|
203
|
+
const testKeys = yield* listExplicitVercelKeys(directory, options.testEnvironment);
|
|
204
|
+
const pull = (stageFile, environment, label, explicitKeys, snapshot, removeResetConsent) => Effect.gen(function* () {
|
|
205
|
+
yield* Console.log(`provision env: pulling Vercel ${environment} variables into ${label}`);
|
|
206
|
+
yield* pullVercelEnvironment(directory, stageFile, environment);
|
|
207
|
+
const pulled = yield* readPrivately(stageFile).pipe(io(`cannot inspect pulled ${environment} environment`));
|
|
208
|
+
const sanitized = stripPulledEnvironment(pulled ?? '', {
|
|
209
|
+
explicitKeys,
|
|
210
|
+
replaceDatabaseUrls: options.database,
|
|
211
|
+
removeResetConsent
|
|
212
|
+
});
|
|
213
|
+
const content = options.database || snapshot === undefined
|
|
214
|
+
? sanitized.content
|
|
215
|
+
: keepExistingDatabaseUrls(sanitized.content, snapshot);
|
|
216
|
+
if (content !== pulled) {
|
|
217
|
+
yield* writePrivately(stageFile, content).pipe(io(`cannot sanitize pulled ${environment} environment`));
|
|
218
|
+
}
|
|
219
|
+
yield* ensurePrivateMode(stageFile, 0o600, `pulled ${environment} environment`);
|
|
220
|
+
if (sanitized.metadataRemoved > 0) {
|
|
221
|
+
yield* Console.log(`provision env: removed ${sanitized.metadataRemoved} deployment-only variables from ${environment}`);
|
|
222
|
+
}
|
|
223
|
+
if (sanitized.databaseUrlsRemoved > 0) {
|
|
224
|
+
yield* Console.log(`provision env: removed ${sanitized.databaseUrlsRemoved} Vercel database variables from ${environment}`);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
yield* pull(localStage, 'development', '.env.local', developmentKeys, state.localEnvSnapshot, true);
|
|
228
|
+
yield* pull(testStage, options.testEnvironment, '.env.test', testKeys, state.testEnvSnapshot, false);
|
|
229
|
+
yield* ensureEnvUnchanged(localEnvFile, localExists, state.localEnvSnapshot);
|
|
230
|
+
yield* ensureEnvUnchanged(testEnvFile, testExists, state.testEnvSnapshot);
|
|
231
|
+
yield* Effect.uninterruptible(Effect.gen(function* () {
|
|
232
|
+
yield* fs.rename(localStage, localEnvFile).pipe(io('cannot publish .env.local'));
|
|
233
|
+
state.localPullTemporary = undefined;
|
|
234
|
+
state.createdLocalEnv = true;
|
|
235
|
+
yield* fs.rename(testStage, testEnvFile).pipe(io('cannot publish .env.test'));
|
|
236
|
+
state.testPullTemporary = undefined;
|
|
237
|
+
state.createdTestEnv = true;
|
|
238
|
+
}));
|
|
239
|
+
});
|
|
240
|
+
const operation = Effect.gen(function* () {
|
|
241
|
+
if (!options.skipVercel || options.database) {
|
|
242
|
+
yield* ensureProvisionPaths(directory, ['.env.local', '.env.test', '.vercel/project.json']);
|
|
243
|
+
yield* ensureSecretPath(repo, localRelative);
|
|
244
|
+
yield* ensureSecretPath(repo, testRelative);
|
|
245
|
+
}
|
|
246
|
+
const localBefore = yield* readPrivately(localEnvFile).pipe(io('cannot read .env.local'));
|
|
247
|
+
const testBefore = yield* readPrivately(testEnvFile).pipe(io('cannot read .env.test'));
|
|
248
|
+
const localExists = localBefore !== undefined;
|
|
249
|
+
const testExists = testBefore !== undefined;
|
|
250
|
+
const decision = options.skipVercel
|
|
251
|
+
? 'preserve'
|
|
252
|
+
: yield* resolveEnvConflict(options.envConflict, options.nonInteractive, localExists, testExists);
|
|
253
|
+
const pullVercel = !options.skipVercel && decision === 'overwrite';
|
|
254
|
+
state.localEnvSnapshot = localBefore;
|
|
255
|
+
state.testEnvSnapshot = testBefore;
|
|
256
|
+
if (!options.skipInstall)
|
|
257
|
+
yield* installDependencies(repo);
|
|
258
|
+
if (pullVercel)
|
|
259
|
+
yield* pullEnvironments(localExists, testExists);
|
|
260
|
+
const currentLocal = yield* readPrivately(localEnvFile).pipe(io('cannot read .env.local'));
|
|
261
|
+
yield* ensureNoLocalResetConsent(currentLocal ?? '');
|
|
262
|
+
if (options.database) {
|
|
263
|
+
const label = options.label ??
|
|
264
|
+
Option.getOrElse(Option.filter(yield* gitOutput(repo, ['branch', '--show-current']), branch => branch !== ''), () => path.basename(repo));
|
|
265
|
+
yield* Console.log('provision env: allocating isolated development PostgreSQL');
|
|
266
|
+
state.defaultLeaseBefore = yield* readLease(repo, 'default').pipe(io('cannot read lease'));
|
|
267
|
+
state.createdLocalEnv = true;
|
|
268
|
+
yield* createDatabaseLease('default', localRelative, `${label}-development`);
|
|
269
|
+
yield* Console.log('provision env: allocating isolated test PostgreSQL');
|
|
270
|
+
state.testLeaseBefore = yield* readLease(repo, 'test').pipe(io('cannot read lease'));
|
|
271
|
+
state.createdTestEnv = true;
|
|
272
|
+
yield* createDatabaseLease('test', testRelative, `${label}-test`);
|
|
273
|
+
}
|
|
274
|
+
return pullVercel;
|
|
275
|
+
});
|
|
276
|
+
const rollback = Effect.gen(function* () {
|
|
277
|
+
const failures = [];
|
|
278
|
+
const attempt = (label, effect) => effect.pipe(Effect.matchEffect({
|
|
279
|
+
onFailure: () => Effect.sync(() => {
|
|
280
|
+
failures.push(label);
|
|
281
|
+
}),
|
|
282
|
+
onSuccess: () => Effect.void
|
|
283
|
+
}));
|
|
284
|
+
yield* attempt('cannot release test lease', rollBackLease(repo, 'test', state.testLeaseBefore));
|
|
285
|
+
yield* attempt('cannot release default lease', rollBackLease(repo, 'default', state.defaultLeaseBefore));
|
|
286
|
+
if (state.createdTestEnv) {
|
|
287
|
+
yield* attempt('cannot restore .env.test', restoreFile(testEnvFile, '.env.test', state.testEnvSnapshot));
|
|
288
|
+
}
|
|
289
|
+
if (state.createdLocalEnv) {
|
|
290
|
+
yield* attempt('cannot restore .env.local', restoreFile(localEnvFile, '.env.local', state.localEnvSnapshot));
|
|
291
|
+
}
|
|
292
|
+
for (const temporary of [state.testPullTemporary, state.localPullTemporary]) {
|
|
293
|
+
if (temporary !== undefined) {
|
|
294
|
+
yield* attempt(`cannot remove staged environment ${temporary}`, fs.remove(temporary, { force: true }));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (state.createdVercelLink) {
|
|
298
|
+
yield* attempt('cannot remove invocation-created Vercel project identity', fs.remove(path.join(vercelDirectory, 'project.json'), { force: true }));
|
|
299
|
+
}
|
|
300
|
+
if (failures.length > 0) {
|
|
301
|
+
return yield* fail(`rollback could not complete: ${failures.join('; ')}`);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
const pulled = yield* withProvisionLifecycleUsing(acquireLock, operation, rollback, lock => fs.remove(lock, { recursive: true }).pipe(io(`cannot release provision lock ${lock}`)));
|
|
305
|
+
// Setup runs after the lock is released and never rolls back provisioned env or databases:
|
|
306
|
+
// they are valid, and setup commands are idempotent, so a re-run resumes.
|
|
307
|
+
const runsSetup = !options.skipSetup && (!options.skipVercel || options.database);
|
|
308
|
+
if (runsSetup)
|
|
309
|
+
yield* runSetup(repo, config.setup);
|
|
310
|
+
yield* Console.log([
|
|
311
|
+
'',
|
|
312
|
+
'provision env: ready',
|
|
313
|
+
` repo: ${repo}`,
|
|
314
|
+
` app: ${directory}`,
|
|
315
|
+
` install: ${options.skipInstall ? 'skipped' : 'complete'}`,
|
|
316
|
+
` vercel env: ${pulled ? '.env.local + .env.test' : options.skipVercel ? 'skipped' : 'preserved'}`,
|
|
317
|
+
` databases: ${options.database ? 'development + test ready' : 'skipped'}`,
|
|
318
|
+
` setup: ${runsSetup ? `${config.setup.length} command(s)` : 'skipped'}`
|
|
319
|
+
].join('\n'));
|
|
320
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { parseEnvFile } from '../shared/env-file.js';
|
|
3
|
+
import { fail } from './domain.js';
|
|
4
|
+
/** Variables Vercel adds to pulls of deployment targets that do not belong in local env files. */
|
|
5
|
+
const deploymentMetadataKeys = new Set([
|
|
6
|
+
'CI',
|
|
7
|
+
'NX_DAEMON',
|
|
8
|
+
'TURBO_CACHE',
|
|
9
|
+
'TURBO_DOWNLOAD_LOCAL_ENABLED',
|
|
10
|
+
'TURBO_REMOTE_ONLY',
|
|
11
|
+
'TURBO_RUN_SUMMARY',
|
|
12
|
+
'VERCEL',
|
|
13
|
+
'VERCEL_AUTOMATION_BYPASS_SECRET',
|
|
14
|
+
'VERCEL_BRANCH_URL',
|
|
15
|
+
'VERCEL_DEPLOYMENT_ID',
|
|
16
|
+
'VERCEL_ENV',
|
|
17
|
+
'VERCEL_GIT_COMMIT_AUTHOR_LOGIN',
|
|
18
|
+
'VERCEL_GIT_COMMIT_AUTHOR_NAME',
|
|
19
|
+
'VERCEL_GIT_COMMIT_MESSAGE',
|
|
20
|
+
'VERCEL_GIT_COMMIT_REF',
|
|
21
|
+
'VERCEL_GIT_COMMIT_SHA',
|
|
22
|
+
'VERCEL_GIT_PREVIOUS_SHA',
|
|
23
|
+
'VERCEL_GIT_PROVIDER',
|
|
24
|
+
'VERCEL_GIT_PULL_REQUEST_ID',
|
|
25
|
+
'VERCEL_GIT_REPO_ID',
|
|
26
|
+
'VERCEL_GIT_REPO_OWNER',
|
|
27
|
+
'VERCEL_GIT_REPO_SLUG',
|
|
28
|
+
'VERCEL_HASH_SALT',
|
|
29
|
+
'VERCEL_OIDC_TOKEN',
|
|
30
|
+
'VERCEL_PROJECT_ID',
|
|
31
|
+
'VERCEL_PROJECT_PRODUCTION_URL',
|
|
32
|
+
'VERCEL_REGION',
|
|
33
|
+
'VERCEL_SKEW_PROTECTION_ENABLED',
|
|
34
|
+
'VERCEL_TARGET_ENV',
|
|
35
|
+
'VERCEL_URL'
|
|
36
|
+
]);
|
|
37
|
+
export const databaseUrlKeys = new Set(['DATABASE_URL', 'DATABASE_URL_UNPOOLED']);
|
|
38
|
+
/** Destructive E2E reset consent: allowed only in `.env.test`, never in `.env.local`. */
|
|
39
|
+
export const resetConsentKey = 'ALLOW_E2E_DATABASE_RESET';
|
|
40
|
+
const keyOf = (line) => /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(line)?.[1];
|
|
41
|
+
export const stripPulledEnvironment = (content, options) => {
|
|
42
|
+
let metadataRemoved = 0;
|
|
43
|
+
let databaseUrlsRemoved = 0;
|
|
44
|
+
const lines = content.split('\n').filter(line => {
|
|
45
|
+
const key = keyOf(line);
|
|
46
|
+
if (key === undefined)
|
|
47
|
+
return true;
|
|
48
|
+
if (options.removeResetConsent && key === resetConsentKey) {
|
|
49
|
+
metadataRemoved += 1;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if (options.replaceDatabaseUrls && databaseUrlKeys.has(key)) {
|
|
53
|
+
databaseUrlsRemoved += 1;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (options.explicitKeys.has(key) || !deploymentMetadataKeys.has(key))
|
|
57
|
+
return true;
|
|
58
|
+
metadataRemoved += 1;
|
|
59
|
+
return false;
|
|
60
|
+
});
|
|
61
|
+
return { content: lines.join('\n'), databaseUrlsRemoved, metadataRemoved };
|
|
62
|
+
};
|
|
63
|
+
/** Carries database URL lines from `existing` into `pulled` (when databases are not reallocated). */
|
|
64
|
+
export const keepExistingDatabaseUrls = (pulled, existing) => {
|
|
65
|
+
const retained = new Map();
|
|
66
|
+
for (const line of existing.split('\n')) {
|
|
67
|
+
const key = keyOf(line);
|
|
68
|
+
if (key !== undefined && databaseUrlKeys.has(key))
|
|
69
|
+
retained.set(key, line);
|
|
70
|
+
}
|
|
71
|
+
if (retained.size === 0)
|
|
72
|
+
return pulled;
|
|
73
|
+
const trailingNewline = pulled.endsWith('\n');
|
|
74
|
+
const lines = pulled.split('\n');
|
|
75
|
+
if (trailingNewline)
|
|
76
|
+
lines.pop();
|
|
77
|
+
for (const [key, line] of retained) {
|
|
78
|
+
const index = lines.findIndex(candidate => candidate.startsWith(`${key}=`));
|
|
79
|
+
if (index >= 0)
|
|
80
|
+
lines[index] = line;
|
|
81
|
+
else
|
|
82
|
+
lines.push(line);
|
|
83
|
+
}
|
|
84
|
+
return `${lines.join('\n')}${trailingNewline ? '\n' : ''}`;
|
|
85
|
+
};
|
|
86
|
+
export const ensureNoLocalResetConsent = (localContent) => parseEnvFile(localContent).has(resetConsentKey)
|
|
87
|
+
? Effect.fail(fail(`${resetConsentKey} is forbidden in .env.local`))
|
|
88
|
+
: Effect.void;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Console, Effect, FileSystem, Option, Path, Schema } from 'effect';
|
|
2
|
+
import { gitOutput, gitSucceeds } from '../shared/git.js';
|
|
3
|
+
import { capture, inherit } from '../shared/process.js';
|
|
4
|
+
import { fail, VercelLinkRequired } from './domain.js';
|
|
5
|
+
import { ensureLocalDirectory, ensureProvisionPaths, ensureRegularFile, resolveAppDirectory, resolveCheckout } from './paths.js';
|
|
6
|
+
const VercelProjectIdentity = Schema.fromJsonString(Schema.Struct({ orgId: Schema.String, projectId: Schema.String }));
|
|
7
|
+
/** `orgId:projectId` from a `.vercel/project.json`, which must be a regular local file. */
|
|
8
|
+
export const readVercelIdentity = (projectFile) => Effect.gen(function* () {
|
|
9
|
+
const fs = yield* FileSystem.FileSystem;
|
|
10
|
+
yield* ensureRegularFile(projectFile, 'Vercel project identity');
|
|
11
|
+
const content = yield* fs
|
|
12
|
+
.readFileString(projectFile)
|
|
13
|
+
.pipe(Effect.mapError(() => fail(`cannot read Vercel project identity from ${projectFile}`)));
|
|
14
|
+
const parsed = yield* Schema.decodeUnknownEffect(VercelProjectIdentity)(content).pipe(Effect.mapError(() => fail(`invalid Vercel project identity in ${projectFile}`)));
|
|
15
|
+
return `${parsed.orgId}:${parsed.projectId}`;
|
|
16
|
+
});
|
|
17
|
+
const originUrl = (cwd) => gitOutput(cwd, ['remote', 'get-url', 'origin']).pipe(Effect.map(Option.getOrElse(() => '')));
|
|
18
|
+
/**
|
|
19
|
+
* Copies the selected app's `.vercel/project.json` from another checkout of the same repository.
|
|
20
|
+
* Returns `false` when the source app is not linked. A failed copy removes the partial link.
|
|
21
|
+
*/
|
|
22
|
+
export const copyProjectIdentity = (directory, source, appDir) => Effect.gen(function* () {
|
|
23
|
+
const fs = yield* FileSystem.FileSystem;
|
|
24
|
+
const path = yield* Path.Path;
|
|
25
|
+
const sourceRoot = yield* resolveCheckout(source);
|
|
26
|
+
const from = (yield* resolveAppDirectory(sourceRoot, appDir)).directory;
|
|
27
|
+
yield* ensureProvisionPaths(from, ['.vercel/project.json']);
|
|
28
|
+
const sourceVercelDirectory = path.join(from, '.vercel');
|
|
29
|
+
const sourceProjectFile = path.join(sourceVercelDirectory, 'project.json');
|
|
30
|
+
const sourceLinked = yield* fs.exists(sourceProjectFile).pipe(Effect.orElseSucceed(() => false));
|
|
31
|
+
if (!sourceLinked)
|
|
32
|
+
return false;
|
|
33
|
+
yield* ensureLocalDirectory(sourceVercelDirectory, 'source .vercel path');
|
|
34
|
+
const sourceIdentity = yield* readVercelIdentity(sourceProjectFile);
|
|
35
|
+
const [targetRemote, sourceRemote] = yield* Effect.all([originUrl(directory), originUrl(from)]);
|
|
36
|
+
if (targetRemote.length > 0 && sourceRemote.length > 0 && targetRemote !== sourceRemote) {
|
|
37
|
+
return yield* fail(`refusing Vercel identity from a different Git remote: ${from}`);
|
|
38
|
+
}
|
|
39
|
+
if (!(yield* gitSucceeds(directory, ['check-ignore', '-q', '--', '.vercel/project.json']))) {
|
|
40
|
+
return yield* fail('refusing to create .vercel/project.json because .vercel is not ignored');
|
|
41
|
+
}
|
|
42
|
+
const targetDirectory = path.join(directory, '.vercel');
|
|
43
|
+
const targetProjectFile = path.join(targetDirectory, 'project.json');
|
|
44
|
+
yield* Effect.gen(function* () {
|
|
45
|
+
if (!(yield* fs.exists(targetDirectory).pipe(Effect.orElseSucceed(() => false)))) {
|
|
46
|
+
yield* fs
|
|
47
|
+
.makeDirectory(targetDirectory, { recursive: true })
|
|
48
|
+
.pipe(Effect.mapError(() => fail('cannot create .vercel directory')));
|
|
49
|
+
}
|
|
50
|
+
yield* ensureLocalDirectory(targetDirectory, 'checkout .vercel path');
|
|
51
|
+
yield* fs
|
|
52
|
+
.chmod(targetDirectory, 0o700)
|
|
53
|
+
.pipe(Effect.mapError(() => fail('cannot protect .vercel directory')));
|
|
54
|
+
yield* fs
|
|
55
|
+
.copyFile(sourceProjectFile, targetProjectFile)
|
|
56
|
+
.pipe(Effect.mapError(() => fail('cannot copy Vercel project identity')));
|
|
57
|
+
yield* fs
|
|
58
|
+
.chmod(targetProjectFile, 0o600)
|
|
59
|
+
.pipe(Effect.mapError(() => fail('cannot protect Vercel project identity')));
|
|
60
|
+
const [sourceAfterCopy, targetIdentity] = yield* Effect.all([
|
|
61
|
+
readVercelIdentity(sourceProjectFile),
|
|
62
|
+
readVercelIdentity(targetProjectFile)
|
|
63
|
+
]);
|
|
64
|
+
if (sourceAfterCopy !== sourceIdentity || targetIdentity !== sourceIdentity) {
|
|
65
|
+
return yield* fail('Vercel project identity changed while it was being copied');
|
|
66
|
+
}
|
|
67
|
+
yield* Console.log(`provision env: reused Vercel project identity from ${from}`);
|
|
68
|
+
}).pipe(Effect.catch(operationError => fs.remove(targetProjectFile, { force: true }).pipe(Effect.matchEffect({
|
|
69
|
+
onFailure: () => Effect.fail(fail('Vercel project identity copy and cleanup both failed; manual review is required')),
|
|
70
|
+
onSuccess: () => Effect.fail(operationError)
|
|
71
|
+
}))));
|
|
72
|
+
return true;
|
|
73
|
+
});
|
|
74
|
+
/**
|
|
75
|
+
* Ensures the app directory is linked to Vercel. Order: existing link, explicit `--source`, explicit
|
|
76
|
+
* `--vercel-project`, then the single identity shared by every linked sibling worktree. Returns
|
|
77
|
+
* whether this invocation created the link.
|
|
78
|
+
*/
|
|
79
|
+
export const ensureVercelLink = (request) => Effect.gen(function* () {
|
|
80
|
+
const fs = yield* FileSystem.FileSystem;
|
|
81
|
+
const path = yield* Path.Path;
|
|
82
|
+
const vercelDirectory = path.join(request.directory, '.vercel');
|
|
83
|
+
const projectFile = path.join(vercelDirectory, 'project.json');
|
|
84
|
+
if (yield* fs.exists(vercelDirectory).pipe(Effect.orElseSucceed(() => false))) {
|
|
85
|
+
yield* ensureLocalDirectory(vercelDirectory, 'checkout .vercel path');
|
|
86
|
+
}
|
|
87
|
+
if (yield* fs.exists(projectFile).pipe(Effect.orElseSucceed(() => false))) {
|
|
88
|
+
yield* readVercelIdentity(projectFile);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
// An explicit but unlinked source is not a dead end: try sibling identities next.
|
|
92
|
+
if (request.source !== undefined &&
|
|
93
|
+
(yield* copyProjectIdentity(request.directory, request.source, request.appDir))) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (request.vercelProject !== undefined) {
|
|
97
|
+
if (!(yield* gitSucceeds(request.directory, [
|
|
98
|
+
'check-ignore',
|
|
99
|
+
'-q',
|
|
100
|
+
'--',
|
|
101
|
+
'.vercel/project.json'
|
|
102
|
+
]))) {
|
|
103
|
+
return yield* fail('.vercel must be git-ignored before linking');
|
|
104
|
+
}
|
|
105
|
+
yield* Console.log(`provision env: linking to Vercel project ${request.vercelProject}`);
|
|
106
|
+
yield* inherit('vercel', [
|
|
107
|
+
'link',
|
|
108
|
+
'--cwd',
|
|
109
|
+
request.directory,
|
|
110
|
+
'--project',
|
|
111
|
+
request.vercelProject,
|
|
112
|
+
'--yes',
|
|
113
|
+
'--no-color'
|
|
114
|
+
]).pipe(Effect.mapError(error => fail(error.message)));
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
const listed = yield* gitOutput(request.checkout, ['worktree', 'list', '--porcelain']);
|
|
118
|
+
const candidates = [];
|
|
119
|
+
for (const line of Option.getOrElse(listed, () => '').split('\n')) {
|
|
120
|
+
if (!line.startsWith('worktree '))
|
|
121
|
+
continue;
|
|
122
|
+
const candidate = line.slice('worktree '.length);
|
|
123
|
+
if (path.resolve(candidate) === request.checkout)
|
|
124
|
+
continue;
|
|
125
|
+
const linkedFile = path.join(candidate, request.appDir, '.vercel', 'project.json');
|
|
126
|
+
if (yield* fs.exists(linkedFile).pipe(Effect.orElseSucceed(() => false))) {
|
|
127
|
+
yield* resolveAppDirectory(candidate, request.appDir);
|
|
128
|
+
yield* ensureProvisionPaths(path.join(candidate, request.appDir), ['.vercel/project.json']);
|
|
129
|
+
candidates.push(candidate);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const first = candidates[0];
|
|
133
|
+
if (first !== undefined) {
|
|
134
|
+
const identities = yield* Effect.forEach(candidates, candidate => readVercelIdentity(path.join(candidate, request.appDir, '.vercel', 'project.json')));
|
|
135
|
+
if (new Set(identities).size === 1 &&
|
|
136
|
+
(yield* copyProjectIdentity(request.directory, first, request.appDir))) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
return yield* new VercelLinkRequired({
|
|
140
|
+
directory: request.directory,
|
|
141
|
+
reason: 'linked sibling checkouts use different Vercel projects; choose a linked --source explicitly'
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return yield* new VercelLinkRequired({
|
|
145
|
+
directory: request.source === undefined
|
|
146
|
+
? request.directory
|
|
147
|
+
: (yield* resolveAppDirectory(yield* resolveCheckout(request.source), request.appDir))
|
|
148
|
+
.directory,
|
|
149
|
+
reason: 'no Vercel project link found for the selected app'
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
/** Keys explicitly configured for `environment` (so pulled values with those names are kept). */
|
|
153
|
+
export const listExplicitVercelKeys = (directory, environment) => Effect.gen(function* () {
|
|
154
|
+
const listed = yield* capture('vercel', [
|
|
155
|
+
'env',
|
|
156
|
+
'list',
|
|
157
|
+
environment,
|
|
158
|
+
'--cwd',
|
|
159
|
+
directory,
|
|
160
|
+
'--no-color'
|
|
161
|
+
]);
|
|
162
|
+
if (listed.exitCode !== 0) {
|
|
163
|
+
return yield* fail(`vercel env list ${environment} failed (exit ${listed.exitCode})`);
|
|
164
|
+
}
|
|
165
|
+
return new Set(listed.stdout.split('\n').flatMap(line => {
|
|
166
|
+
const key = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s+/.exec(line)?.[1];
|
|
167
|
+
return key === undefined ? [] : [key];
|
|
168
|
+
}));
|
|
169
|
+
}).pipe(Effect.catchTag('ProcessError', error => Effect.fail(fail(error.message))));
|
|
170
|
+
export const pullVercelEnvironment = (directory, file, environment) => inherit('vercel', [
|
|
171
|
+
'env',
|
|
172
|
+
'pull',
|
|
173
|
+
file,
|
|
174
|
+
'--environment',
|
|
175
|
+
environment,
|
|
176
|
+
'--yes',
|
|
177
|
+
'--cwd',
|
|
178
|
+
directory,
|
|
179
|
+
'--no-color'
|
|
180
|
+
]).pipe(Effect.mapError(error => fail(error.message)));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { Console, Effect } from 'effect';
|
|
3
|
+
import { detectAgent } from '../shared/agent.js';
|
|
4
|
+
/** Where the operator installs the root-owned binary that approved commands must run. */
|
|
5
|
+
export const rootOwnedBinary = '/usr/local/bin/provision';
|
|
6
|
+
const shellSafe = /^[\w/.,:=@%+-]+$/;
|
|
7
|
+
export const shellQuote = (value) => shellSafe.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
|
|
8
|
+
export const approvalRequired = (input) => ({
|
|
9
|
+
status: 'approval_required',
|
|
10
|
+
reason: `this command reads factory admin keys and needs the provision passphrase, typed by a human in a terminal; ${input.agent} cannot approve it`,
|
|
11
|
+
hint: 'ask the human to run the next command in their own terminal and paste its output; never ask for the passphrase',
|
|
12
|
+
next: [
|
|
13
|
+
`cd ${shellQuote(input.cwd)} && ${[input.binary, ...input.args].map(shellQuote).join(' ')}`
|
|
14
|
+
]
|
|
15
|
+
});
|
|
16
|
+
/**
|
|
17
|
+
* Runs `effect` only for humans. Under a detected agent it changes nothing and prints, as JSON on
|
|
18
|
+
* stderr, the exact command for the human to run instead (exit 4).
|
|
19
|
+
*/
|
|
20
|
+
export const humanOnly = (effect) => Effect.suspend(() => {
|
|
21
|
+
const agent = detectAgent();
|
|
22
|
+
if (agent === undefined)
|
|
23
|
+
return effect;
|
|
24
|
+
const payload = approvalRequired({
|
|
25
|
+
agent,
|
|
26
|
+
cwd: process.cwd(),
|
|
27
|
+
binary: existsSync(rootOwnedBinary) ? rootOwnedBinary : 'provision',
|
|
28
|
+
args: process.argv.slice(2)
|
|
29
|
+
});
|
|
30
|
+
return Console.error(JSON.stringify(payload)).pipe(Effect.andThen(Effect.sync(() => {
|
|
31
|
+
process.exitCode = 4;
|
|
32
|
+
})));
|
|
33
|
+
});
|