@magoz/provision 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +44 -4
- package/skills/provision/SKILL.md +172 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Console, Effect, Option, Path, Predicate, Redacted, Result } from 'effect';
|
|
3
|
+
import { Command, Flag, Prompt } from 'effect/cli';
|
|
4
|
+
import { resolveWorkspace } from '../shared/git.js';
|
|
5
|
+
import { report } from '../shared/output.js';
|
|
6
|
+
import { defaultDatabaseUrlKeys } from '../shared/sandbox-profile.js';
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import { API_KEY_ENV, clearSecret, readSettings, settingsLocation, storeSecret, writeSettings } from './credentials.js';
|
|
9
|
+
import { ConfigurationError, PolicyError } from './domain.js';
|
|
10
|
+
import { allLeases, expiredBeyondGrace, readLease, removeLease, withLeaseLock, writeLease } from './lease.js';
|
|
11
|
+
import { describeProject, findDefaultBranch, getBranch, setExpiration } from './neon.js';
|
|
12
|
+
import { attestIfDeclared, createLease, releaseLease, timestamp } from './operations.js';
|
|
13
|
+
import { ensureLeaseProfile, parseTtl, validateDeletionTarget, validateLeaseName } from './policy.js';
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
const setExitCode = (code) => Effect.sync(() => {
|
|
18
|
+
process.exitCode = code;
|
|
19
|
+
});
|
|
20
|
+
/** Expected failures print one line on stderr and exit 2; defects stay defects. */
|
|
21
|
+
const handled = (effect) => effect.pipe(Effect.catch(error => Console.error(`provision db: ${error.message}`).pipe(Effect.andThen(setExitCode(2)))));
|
|
22
|
+
/** Identifies which credential is in use without revealing any of it. */
|
|
23
|
+
const fingerprint = (secret) => createHash('sha256').update(Redacted.value(secret)).digest('hex').slice(0, 8);
|
|
24
|
+
const readStdin = Effect.promise(async () => {
|
|
25
|
+
const chunks = [];
|
|
26
|
+
for await (const chunk of process.stdin) {
|
|
27
|
+
if (Predicate.isString(chunk))
|
|
28
|
+
chunks.push(Buffer.from(chunk));
|
|
29
|
+
else if (chunk instanceof Uint8Array)
|
|
30
|
+
chunks.push(chunk);
|
|
31
|
+
}
|
|
32
|
+
return Buffer.concat(chunks).toString('utf8').trim();
|
|
33
|
+
});
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// shared flags
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
const worktreeFlag = Flag.String('worktree').pipe(Flag.withDescription('worktree path (default: current directory)'), Flag.optional);
|
|
38
|
+
const jsonFlag = Flag.Boolean('json').pipe(Flag.withDescription('machine-readable output'), Flag.withDefault(false));
|
|
39
|
+
const ttlFlag = Flag.String('ttl').pipe(Flag.withDescription('lifetime such as 12h or 7d (default 7d)'), Flag.withDefault('7d'));
|
|
40
|
+
const leaseFlag = Flag.String('lease').pipe(Flag.withDescription('lease slot within the worktree (default: default)'), Flag.withDefault('default'));
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// auth
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
const authLogin = Command.make('login', {
|
|
45
|
+
json: jsonFlag,
|
|
46
|
+
projectId: Flag.String('project-id').pipe(Flag.withDescription('neon sandbox project id'), Flag.optional),
|
|
47
|
+
parentBranch: Flag.String('parent-branch').pipe(Flag.withDescription('parent branch id (default: project default branch)'), Flag.optional),
|
|
48
|
+
tokenStdin: Flag.Boolean('token-stdin').pipe(Flag.withDescription('read the api key from stdin instead of prompting'), Flag.withDefault(false))
|
|
49
|
+
}, ({ json, parentBranch, projectId, tokenStdin }) => handled(Effect.gen(function* () {
|
|
50
|
+
const existing = yield* readSettings;
|
|
51
|
+
// The credential is Redacted from the moment it is captured.
|
|
52
|
+
const apiKey = tokenStdin
|
|
53
|
+
? Redacted.make(yield* readStdin)
|
|
54
|
+
: yield* Prompt.run(Prompt.Password({ message: 'Neon API key (input hidden)' })).pipe(Effect.mapError(() => new ConfigurationError({ message: 'cancelled' })));
|
|
55
|
+
if (Redacted.value(apiKey).trim().length === 0) {
|
|
56
|
+
return yield* new ConfigurationError({ message: 'no api key provided' });
|
|
57
|
+
}
|
|
58
|
+
const resolvedProject = Option.isSome(projectId)
|
|
59
|
+
? projectId.value
|
|
60
|
+
: Option.isSome(existing.projectId)
|
|
61
|
+
? existing.projectId.value
|
|
62
|
+
: yield* Prompt.run(Prompt.String({ message: 'Neon sandbox project id' })).pipe(Effect.mapError(() => new ConfigurationError({ message: 'cancelled' })));
|
|
63
|
+
// Validate before persisting anything.
|
|
64
|
+
const access = { apiKey, projectId: resolvedProject };
|
|
65
|
+
const project = yield* describeProject(access);
|
|
66
|
+
const parent = Option.isSome(parentBranch)
|
|
67
|
+
? parentBranch.value
|
|
68
|
+
: (yield* findDefaultBranch(access)).id;
|
|
69
|
+
if (Option.isNone(yield* getBranch(access, parent))) {
|
|
70
|
+
return yield* new ConfigurationError({
|
|
71
|
+
message: `parent branch ${parent} does not exist in project ${resolvedProject}`
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const backend = yield* storeSecret(apiKey);
|
|
75
|
+
yield* writeSettings({ projectId: project.id, parentBranch: parent });
|
|
76
|
+
return yield* report(json, {
|
|
77
|
+
status: 'authenticated',
|
|
78
|
+
backend,
|
|
79
|
+
project: `${project.name} (${project.id})`,
|
|
80
|
+
parent_branch: parent,
|
|
81
|
+
fingerprint: fingerprint(apiKey),
|
|
82
|
+
note: 'credential stored; it is never printed'
|
|
83
|
+
});
|
|
84
|
+
}))).pipe(Command.withDescription('Store a global Neon key and sandbox project.'));
|
|
85
|
+
const authStatus = Command.make('status', { json: jsonFlag, worktree: worktreeFlag }, ({ json, worktree }) => handled(Effect.gen(function* () {
|
|
86
|
+
const path = yield* Path.Path;
|
|
87
|
+
const workspace = Option.isSome(worktree)
|
|
88
|
+
? Result.succeed(yield* resolveWorkspace(worktree))
|
|
89
|
+
: yield* Effect.result(resolveWorkspace(Option.none()));
|
|
90
|
+
const envFile = path.join(Result.isSuccess(workspace) ? workspace.success.root : path.resolve(process.cwd()), '.env.local');
|
|
91
|
+
const resolved = yield* Effect.result(loadConfig(envFile));
|
|
92
|
+
if (Result.isFailure(resolved)) {
|
|
93
|
+
yield* report(json, {
|
|
94
|
+
status: 'unauthenticated',
|
|
95
|
+
settings_file: envFile,
|
|
96
|
+
note: resolved.failure.message
|
|
97
|
+
});
|
|
98
|
+
return yield* setExitCode(1);
|
|
99
|
+
}
|
|
100
|
+
const config = resolved.success;
|
|
101
|
+
const settingsFile = config.backend === 'worktree-env' ? envFile : yield* settingsLocation;
|
|
102
|
+
const verification = yield* Effect.result(Effect.gen(function* () {
|
|
103
|
+
const project = yield* describeProject(config);
|
|
104
|
+
if (Option.isNone(yield* getBranch(config, config.parentBranch))) {
|
|
105
|
+
return yield* new ConfigurationError({
|
|
106
|
+
message: `parent branch ${config.parentBranch} does not exist in project ${config.projectId}`
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return project;
|
|
110
|
+
}));
|
|
111
|
+
if (Result.isFailure(verification)) {
|
|
112
|
+
yield* report(json, {
|
|
113
|
+
status: 'invalid',
|
|
114
|
+
backend: config.backend,
|
|
115
|
+
fingerprint: fingerprint(config.apiKey),
|
|
116
|
+
project: config.projectId,
|
|
117
|
+
parent_branch: `${config.parentBranch} (unverified)`,
|
|
118
|
+
settings_file: settingsFile,
|
|
119
|
+
note: verification.failure.message
|
|
120
|
+
});
|
|
121
|
+
return yield* setExitCode(1);
|
|
122
|
+
}
|
|
123
|
+
yield* report(json, {
|
|
124
|
+
status: 'authenticated',
|
|
125
|
+
backend: config.backend,
|
|
126
|
+
fingerprint: fingerprint(config.apiKey),
|
|
127
|
+
project: `${verification.success.name} (${verification.success.id})`,
|
|
128
|
+
parent_branch: config.parentBranch,
|
|
129
|
+
settings_file: settingsFile
|
|
130
|
+
});
|
|
131
|
+
}))).pipe(Command.withDescription('Show the resolved worktree or global auth.'));
|
|
132
|
+
const authLogout = Command.make('logout', { json: jsonFlag }, ({ json }) => handled(Effect.gen(function* () {
|
|
133
|
+
const removed = yield* clearSecret;
|
|
134
|
+
return yield* report(json, {
|
|
135
|
+
status: removed.length > 0 ? 'cleared' : 'nothing-stored',
|
|
136
|
+
backends: removed.join(', ') || 'none',
|
|
137
|
+
note: `an ${API_KEY_ENV} environment variable or complete worktree .env.local profile, if set, still applies`
|
|
138
|
+
});
|
|
139
|
+
}))).pipe(Command.withDescription('Remove the stored global Neon key.'));
|
|
140
|
+
const auth = Command.make('auth').pipe(Command.withDescription('Manage global Neon auth and inspect resolved worktree auth.'), Command.withSubcommands([authLogin, authStatus, authLogout]));
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// create
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
const create = Command.make('create', {
|
|
145
|
+
worktree: worktreeFlag,
|
|
146
|
+
json: jsonFlag,
|
|
147
|
+
ttl: ttlFlag,
|
|
148
|
+
label: Flag.String('label').pipe(Flag.withDescription('short label, e.g. issue-4'), Flag.optional),
|
|
149
|
+
envFile: Flag.String('env-file').pipe(Flag.withDescription('env file that receives database URLs'), Flag.withDefault('.env.local')),
|
|
150
|
+
configEnvFile: Flag.String('config-env-file').pipe(Flag.withDescription('env file containing the sandbox database profile (default: --env-file)'), Flag.optional),
|
|
151
|
+
leaseName: leaseFlag,
|
|
152
|
+
keys: Flag.String('keys').pipe(Flag.withDescription('comma-separated env keys to write'), Flag.withDefault(defaultDatabaseUrlKeys.join(','))),
|
|
153
|
+
forceNew: Flag.Boolean('force-new').pipe(Flag.withDescription('ignore any existing lease'), Flag.withDefault(false)),
|
|
154
|
+
noWait: Flag.Boolean('no-wait').pipe(Flag.withDescription('do not wait for the branch to become ready'), Flag.withDefault(false))
|
|
155
|
+
}, ({ json, ...options }) => handled(Effect.flatMap(createLease(options), result => report(json, result.report)))).pipe(Command.withDescription('Create or reuse a disposable database branch for a worktree.'));
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// status / renew / release
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
const status = Command.make('status', { worktree: worktreeFlag, json: jsonFlag, leaseName: leaseFlag }, ({ json, leaseName, worktree }) => handled(Effect.gen(function* () {
|
|
160
|
+
const workspace = yield* resolveWorkspace(worktree);
|
|
161
|
+
const name = yield* validateLeaseName(leaseName);
|
|
162
|
+
const lease = yield* readLease(workspace.root, name);
|
|
163
|
+
if (Option.isNone(lease)) {
|
|
164
|
+
yield* report(json, { status: 'none', lease: name, worktree: workspace.root });
|
|
165
|
+
return yield* setExitCode(1);
|
|
166
|
+
}
|
|
167
|
+
const config = yield* loadConfig(lease.value.configEnvFile);
|
|
168
|
+
yield* ensureLeaseProfile(config, lease.value);
|
|
169
|
+
const branch = yield* getBranch(config, lease.value.branchId);
|
|
170
|
+
yield* report(json, {
|
|
171
|
+
status: Option.isSome(branch) ? 'live' : 'missing',
|
|
172
|
+
lease: lease.value.leaseName,
|
|
173
|
+
branch_name: lease.value.branchName,
|
|
174
|
+
branch_id: lease.value.branchId,
|
|
175
|
+
state: Option.match(branch, {
|
|
176
|
+
onNone: () => 'gone',
|
|
177
|
+
onSome: value => value.current_state ?? 'unknown'
|
|
178
|
+
}),
|
|
179
|
+
expires_at: Option.match(branch, {
|
|
180
|
+
onNone: () => lease.value.expiresAt,
|
|
181
|
+
onSome: value => value.expires_at ?? lease.value.expiresAt
|
|
182
|
+
}),
|
|
183
|
+
repository: lease.value.repository,
|
|
184
|
+
parent_branch: lease.value.parentBranch,
|
|
185
|
+
config_source: config.backend,
|
|
186
|
+
config_env_file: lease.value.configEnvFile,
|
|
187
|
+
env_file: lease.value.envFile,
|
|
188
|
+
worktree: lease.value.worktree
|
|
189
|
+
});
|
|
190
|
+
if (Option.isNone(branch))
|
|
191
|
+
yield* setExitCode(1);
|
|
192
|
+
}))).pipe(Command.withDescription('Show a lease and its branch state.'));
|
|
193
|
+
const renew = Command.make('renew', { worktree: worktreeFlag, json: jsonFlag, leaseName: leaseFlag, ttl: ttlFlag }, ({ json, leaseName, ttl, worktree }) => handled(Effect.gen(function* () {
|
|
194
|
+
const workspace = yield* resolveWorkspace(worktree);
|
|
195
|
+
const name = yield* validateLeaseName(leaseName);
|
|
196
|
+
return yield* withLeaseLock(workspace.root, name, Effect.gen(function* () {
|
|
197
|
+
const lease = yield* readLease(workspace.root, name);
|
|
198
|
+
if (Option.isNone(lease)) {
|
|
199
|
+
return yield* new PolicyError({
|
|
200
|
+
message: `no ${name} lease recorded for ${workspace.root}`
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
const config = yield* loadConfig(lease.value.configEnvFile);
|
|
204
|
+
yield* ensureLeaseProfile(config, lease.value);
|
|
205
|
+
yield* attestIfDeclared(config, workspace);
|
|
206
|
+
const ttlSeconds = yield* parseTtl(ttl);
|
|
207
|
+
const recorded = yield* getBranch(config, lease.value.branchId);
|
|
208
|
+
if (Option.isNone(recorded)) {
|
|
209
|
+
return yield* new PolicyError({
|
|
210
|
+
message: `recorded branch ${lease.value.branchId} no longer exists`
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
yield* validateDeletionTarget(lease.value, recorded.value);
|
|
214
|
+
const expiresAt = timestamp(ttlSeconds);
|
|
215
|
+
yield* setExpiration(config, lease.value, expiresAt);
|
|
216
|
+
const updated = { ...lease.value, expiresAt };
|
|
217
|
+
yield* writeLease(updated);
|
|
218
|
+
return yield* report(json, {
|
|
219
|
+
status: 'renewed',
|
|
220
|
+
lease: updated.leaseName,
|
|
221
|
+
branch_name: updated.branchName,
|
|
222
|
+
branch_id: updated.branchId,
|
|
223
|
+
expires_at: updated.expiresAt
|
|
224
|
+
});
|
|
225
|
+
}));
|
|
226
|
+
}))).pipe(Command.withDescription("Extend a lease's TTL."));
|
|
227
|
+
const release = Command.make('release', {
|
|
228
|
+
worktree: worktreeFlag,
|
|
229
|
+
json: jsonFlag,
|
|
230
|
+
leaseName: leaseFlag,
|
|
231
|
+
keepEnv: Flag.Boolean('keep-env').pipe(Flag.withDescription('leave env keys in place'), Flag.withDefault(false))
|
|
232
|
+
}, ({ json, keepEnv, leaseName, worktree }) => handled(Effect.flatMap(releaseLease({ worktree, leaseName, keepEnv }), payload => report(json, payload)))).pipe(Command.withDescription('Delete a lease branch and remove its env keys.'));
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// list / gc
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
const list = Command.make('list', { json: jsonFlag }, ({ json }) => handled(Effect.gen(function* () {
|
|
237
|
+
const leases = yield* allLeases;
|
|
238
|
+
if (json)
|
|
239
|
+
return yield* Console.log(JSON.stringify(leases, null, 2));
|
|
240
|
+
if (leases.length === 0)
|
|
241
|
+
return yield* Console.log('no leases');
|
|
242
|
+
return yield* Console.log(leases
|
|
243
|
+
.map(lease => `${lease.leaseName} ${lease.branchName} ${lease.expiresAt} ${lease.worktree}`)
|
|
244
|
+
.join('\n'));
|
|
245
|
+
}))).pipe(Command.withDescription('List local lease records.'));
|
|
246
|
+
/** `gc --prune-expired`: how long past its expiry a record must be before it is pruned blind. */
|
|
247
|
+
const EXPIRED_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
248
|
+
const gc = Command.make('gc', {
|
|
249
|
+
json: jsonFlag,
|
|
250
|
+
dryRun: Flag.Boolean('dry-run').pipe(Flag.withDescription('report without removing lease records'), Flag.withDefault(false)),
|
|
251
|
+
pruneExpired: Flag.Boolean('prune-expired').pipe(Flag.withDescription('also remove records whose expiry passed over a day ago, without Neon credentials (Neon already deleted those branches)'), Flag.withDefault(false))
|
|
252
|
+
}, ({ dryRun, json, pruneExpired }) => handled(Effect.gen(function* () {
|
|
253
|
+
const leases = yield* allLeases;
|
|
254
|
+
const stale = [];
|
|
255
|
+
const expired = [];
|
|
256
|
+
const inaccessible = [];
|
|
257
|
+
let live = 0;
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
for (const lease of leases) {
|
|
260
|
+
if (pruneExpired && expiredBeyondGrace(lease, now, EXPIRED_GRACE_MS)) {
|
|
261
|
+
expired.push(lease.branchName);
|
|
262
|
+
if (!dryRun)
|
|
263
|
+
yield* removeLease(lease.worktree, lease.leaseName);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const resolved = yield* Effect.result(loadConfig(lease.configEnvFile));
|
|
267
|
+
if (Result.isFailure(resolved) ||
|
|
268
|
+
resolved.success.projectId !== lease.projectId ||
|
|
269
|
+
resolved.success.parentBranch !== lease.parentBranch) {
|
|
270
|
+
inaccessible.push(lease.branchName);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const lookedUp = yield* Effect.result(getBranch(resolved.success, lease.branchId));
|
|
274
|
+
if (Result.isFailure(lookedUp)) {
|
|
275
|
+
inaccessible.push(lease.branchName);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (Option.isSome(lookedUp.success)) {
|
|
279
|
+
live += 1;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
stale.push(lease.branchName);
|
|
283
|
+
if (!dryRun)
|
|
284
|
+
yield* removeLease(lease.worktree, lease.leaseName);
|
|
285
|
+
}
|
|
286
|
+
yield* report(json, {
|
|
287
|
+
status: inaccessible.length > 0 ? 'partial' : dryRun ? 'dry-run' : 'pruned',
|
|
288
|
+
live_leases: live,
|
|
289
|
+
stale_leases: stale.length,
|
|
290
|
+
expired_leases: expired.length,
|
|
291
|
+
inaccessible_leases: inaccessible.length,
|
|
292
|
+
pruned: [...stale, ...expired].join(', ') || 'none',
|
|
293
|
+
inaccessible: inaccessible.join(', ') || 'none'
|
|
294
|
+
});
|
|
295
|
+
// With --prune-expired every remaining unverifiable record expires within the TTL cap and
|
|
296
|
+
// is pruned by a later run, so it is reported but does not fail the run (daily timer).
|
|
297
|
+
if (inaccessible.length > 0 && !pruneExpired)
|
|
298
|
+
yield* setExitCode(1);
|
|
299
|
+
}))).pipe(Command.withDescription('Prune lease records whose branches are gone.'));
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// root
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
export const dbCommand = Command.make('db').pipe(Command.withDescription('Manage disposable Neon database branches for local checkouts.'), Command.withSubcommands([auth, create, status, renew, release, list, gc]));
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Effect, Option, Redacted } from 'effect';
|
|
2
|
+
import { parseEnvFile } from '../shared/env-file.js';
|
|
3
|
+
import { readPrivately } from '../shared/private-file.js';
|
|
4
|
+
import { sandboxProfileKeys } from '../shared/sandbox-profile.js';
|
|
5
|
+
import { readSecret, readSettings } from './credentials.js';
|
|
6
|
+
import { ConfigurationError } from './domain.js';
|
|
7
|
+
const missing = (what) => new ConfigurationError({
|
|
8
|
+
message: `${what}\n` +
|
|
9
|
+
"run: provision db auth login, or configure the worktree's Vercel Development environment"
|
|
10
|
+
});
|
|
11
|
+
/**
|
|
12
|
+
* A worktree profile is atomic: once any local key is present, all three must be present. This
|
|
13
|
+
* prevents accidentally combining one account's credential with another account's globally
|
|
14
|
+
* configured project.
|
|
15
|
+
*/
|
|
16
|
+
const readWorktreeConfig = (envFile) => Effect.gen(function* () {
|
|
17
|
+
const content = yield* readPrivately(envFile).pipe(Effect.mapError(error => new ConfigurationError({
|
|
18
|
+
message: error.reason === 'symbolic-link'
|
|
19
|
+
? `refusing symbolic-link environment path: ${envFile}`
|
|
20
|
+
: `cannot read local environment ${envFile}`
|
|
21
|
+
})));
|
|
22
|
+
if (content === undefined)
|
|
23
|
+
return Option.none();
|
|
24
|
+
const entries = parseEnvFile(content);
|
|
25
|
+
const declared = sandboxProfileKeys.filter(key => entries.has(key));
|
|
26
|
+
if (declared.length === 0)
|
|
27
|
+
return Option.none();
|
|
28
|
+
const absent = sandboxProfileKeys.filter(key => !entries.has(key));
|
|
29
|
+
const value = (key) => (entries.get(key) ?? '').trim();
|
|
30
|
+
const empty = sandboxProfileKeys.filter(key => entries.has(key) && value(key).length === 0);
|
|
31
|
+
if (absent.length > 0 || empty.length > 0) {
|
|
32
|
+
const details = [
|
|
33
|
+
absent.length > 0 ? `missing ${absent.join(', ')}` : '',
|
|
34
|
+
empty.length > 0 ? `empty ${empty.join(', ')}` : ''
|
|
35
|
+
]
|
|
36
|
+
.filter(detail => detail.length > 0)
|
|
37
|
+
.join('; ');
|
|
38
|
+
return yield* new ConfigurationError({
|
|
39
|
+
message: `incomplete sandbox database profile in ${envFile}; ${details}\n` +
|
|
40
|
+
'local profiles are not combined with global authentication'
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const [apiKey, projectId, parentBranch] = sandboxProfileKeys;
|
|
44
|
+
return Option.some({
|
|
45
|
+
apiKey: Redacted.make(value(apiKey)),
|
|
46
|
+
projectId: value(projectId),
|
|
47
|
+
parentBranch: value(parentBranch),
|
|
48
|
+
backend: 'worktree-env'
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* Resolves one complete credential/project/parent tuple. A complete profile in the worktree env
|
|
53
|
+
* takes precedence; otherwise global auth is used as a fallback.
|
|
54
|
+
*/
|
|
55
|
+
export const loadConfig = (envFile) => Effect.gen(function* () {
|
|
56
|
+
if (envFile !== undefined) {
|
|
57
|
+
const local = yield* readWorktreeConfig(envFile);
|
|
58
|
+
if (Option.isSome(local))
|
|
59
|
+
return local.value;
|
|
60
|
+
}
|
|
61
|
+
const secret = yield* readSecret;
|
|
62
|
+
if (Option.isNone(secret))
|
|
63
|
+
return yield* missing('no neon api key configured');
|
|
64
|
+
const settings = yield* readSettings;
|
|
65
|
+
if (Option.isNone(settings.projectId))
|
|
66
|
+
return yield* missing('no sandbox project configured');
|
|
67
|
+
if (Option.isNone(settings.parentBranch))
|
|
68
|
+
return yield* missing('no parent branch configured');
|
|
69
|
+
return {
|
|
70
|
+
apiKey: secret.value.value,
|
|
71
|
+
projectId: settings.projectId.value,
|
|
72
|
+
parentBranch: settings.parentBranch.value,
|
|
73
|
+
backend: secret.value.backend
|
|
74
|
+
};
|
|
75
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Effect, FileSystem, Option, Predicate, Redacted, Schema } from 'effect';
|
|
2
|
+
import { parseEnvFile } from '../shared/env-file.js';
|
|
3
|
+
import { readPrivately, writePrivately } from '../shared/private-file.js';
|
|
4
|
+
import { capture } from '../shared/process.js';
|
|
5
|
+
import { ConfigurationError } from './domain.js';
|
|
6
|
+
import { DbEnvironment } from './environment.js';
|
|
7
|
+
/**
|
|
8
|
+
* Global credential storage behind `provision db auth`. Backends, in read precedence order:
|
|
9
|
+
*
|
|
10
|
+
* 1. environment - explicit override, useful for CI or a parent process
|
|
11
|
+
* 2. keychain - encrypted at rest (macOS)
|
|
12
|
+
* 3. config file - `0600` fallback for machines without a keychain
|
|
13
|
+
*/
|
|
14
|
+
const SERVICE = 'sandbox-db';
|
|
15
|
+
const ACCOUNT = 'neon-api-key';
|
|
16
|
+
export const API_KEY_ENV = 'NEON_AGENTS_SANDBOX_API_KEY';
|
|
17
|
+
const PROJECT_ENV = 'NEON_AGENTS_SANDBOX_PROJECT_ID';
|
|
18
|
+
const PARENT_ENV = 'NEON_AGENTS_SANDBOX_PARENT_BRANCH';
|
|
19
|
+
export const settingsLocation = Effect.gen(function* () {
|
|
20
|
+
return `${(yield* DbEnvironment).configDirectory}/sandbox-db.json`;
|
|
21
|
+
});
|
|
22
|
+
export const secretFileLocation = Effect.gen(function* () {
|
|
23
|
+
return `${(yield* DbEnvironment).configDirectory}/sandbox-db.env`;
|
|
24
|
+
});
|
|
25
|
+
const ensureConfigDirectory = Effect.gen(function* () {
|
|
26
|
+
const fs = yield* FileSystem.FileSystem;
|
|
27
|
+
const environment = yield* DbEnvironment;
|
|
28
|
+
yield* fs
|
|
29
|
+
.makeDirectory(environment.configDirectory, { recursive: true })
|
|
30
|
+
.pipe(Effect.orElseSucceed(() => undefined));
|
|
31
|
+
});
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// keychain backend
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const keychainAvailable = Effect.gen(function* () {
|
|
36
|
+
const environment = yield* DbEnvironment;
|
|
37
|
+
if (!environment.keychain)
|
|
38
|
+
return false;
|
|
39
|
+
return yield* capture('which', ['security']).pipe(Effect.map(result => result.exitCode === 0), Effect.orElseSucceed(() => false));
|
|
40
|
+
});
|
|
41
|
+
const keychainRead = capture('security', [
|
|
42
|
+
'find-generic-password',
|
|
43
|
+
'-s',
|
|
44
|
+
SERVICE,
|
|
45
|
+
'-a',
|
|
46
|
+
ACCOUNT,
|
|
47
|
+
'-w'
|
|
48
|
+
]).pipe(Effect.map(result => {
|
|
49
|
+
const trimmed = result.stdout.trim();
|
|
50
|
+
return result.exitCode === 0 && trimmed.length > 0 ? Option.some(trimmed) : Option.none();
|
|
51
|
+
}), Effect.orElseSucceed(() => Option.none()));
|
|
52
|
+
/**
|
|
53
|
+
* `security` takes the secret as an argument, so it is briefly visible in the process table to
|
|
54
|
+
* this user. That is the same trust boundary as the `0600` file it replaces; the win is
|
|
55
|
+
* encryption at rest, not process isolation.
|
|
56
|
+
*/
|
|
57
|
+
const keychainWrite = (secret) => capture('security', [
|
|
58
|
+
'add-generic-password',
|
|
59
|
+
'-s',
|
|
60
|
+
SERVICE,
|
|
61
|
+
'-a',
|
|
62
|
+
ACCOUNT,
|
|
63
|
+
'-w',
|
|
64
|
+
Redacted.value(secret),
|
|
65
|
+
'-U',
|
|
66
|
+
'-D',
|
|
67
|
+
'sandbox-db neon api key'
|
|
68
|
+
]).pipe(Effect.mapError(() => new ConfigurationError({ message: 'keychain unavailable; provider output suppressed' })), Effect.flatMap(result => result.exitCode === 0
|
|
69
|
+
? Effect.void
|
|
70
|
+
: Effect.fail(new ConfigurationError({ message: `keychain write failed (exit ${result.exitCode})` }))));
|
|
71
|
+
const keychainDelete = capture('security', [
|
|
72
|
+
'delete-generic-password',
|
|
73
|
+
'-s',
|
|
74
|
+
SERVICE,
|
|
75
|
+
'-a',
|
|
76
|
+
ACCOUNT
|
|
77
|
+
]).pipe(Effect.map(result => result.exitCode === 0), Effect.orElseSucceed(() => false));
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// file backend
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
const readSecretFile = Effect.gen(function* () {
|
|
82
|
+
const file = yield* secretFileLocation;
|
|
83
|
+
const content = yield* readPrivately(file).pipe(Effect.orElseSucceed(() => undefined));
|
|
84
|
+
return new Map(parseEnvFile(content ?? ''));
|
|
85
|
+
});
|
|
86
|
+
const writeSecretFile = (entries) => Effect.gen(function* () {
|
|
87
|
+
yield* ensureConfigDirectory;
|
|
88
|
+
const file = yield* secretFileLocation;
|
|
89
|
+
const body = [...entries].map(([key, value]) => `${key}=${value}`).join('\n');
|
|
90
|
+
yield* writePrivately(file, body.length > 0 ? `${body}\n` : '').pipe(Effect.mapError(() => new ConfigurationError({ message: `private config write failed: ${file}` })));
|
|
91
|
+
});
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// secret api
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
const nonEmpty = (value) => {
|
|
96
|
+
const trimmed = value?.trim();
|
|
97
|
+
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
|
|
98
|
+
};
|
|
99
|
+
export const readSecret = Effect.gen(function* () {
|
|
100
|
+
const environment = yield* DbEnvironment;
|
|
101
|
+
const fromEnvironment = nonEmpty(environment.variable(API_KEY_ENV));
|
|
102
|
+
if (fromEnvironment !== undefined) {
|
|
103
|
+
return Option.some({
|
|
104
|
+
value: Redacted.make(fromEnvironment),
|
|
105
|
+
backend: 'environment'
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (yield* keychainAvailable) {
|
|
109
|
+
const stored = yield* keychainRead;
|
|
110
|
+
if (Option.isSome(stored)) {
|
|
111
|
+
return Option.some({
|
|
112
|
+
value: Redacted.make(stored.value),
|
|
113
|
+
backend: 'keychain'
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const fromFile = nonEmpty((yield* readSecretFile).get(API_KEY_ENV));
|
|
118
|
+
return fromFile === undefined
|
|
119
|
+
? Option.none()
|
|
120
|
+
: Option.some({ value: Redacted.make(fromFile), backend: 'file' });
|
|
121
|
+
});
|
|
122
|
+
export const storeSecret = (secret) => Effect.gen(function* () {
|
|
123
|
+
if (yield* keychainAvailable) {
|
|
124
|
+
yield* keychainWrite(secret);
|
|
125
|
+
// Do not leave an older plaintext copy behind once the keychain owns it.
|
|
126
|
+
const entries = yield* readSecretFile;
|
|
127
|
+
if (entries.delete(API_KEY_ENV))
|
|
128
|
+
yield* writeSecretFile(entries);
|
|
129
|
+
return 'keychain';
|
|
130
|
+
}
|
|
131
|
+
const entries = yield* readSecretFile;
|
|
132
|
+
entries.set(API_KEY_ENV, Redacted.value(secret));
|
|
133
|
+
yield* writeSecretFile(entries);
|
|
134
|
+
return 'file';
|
|
135
|
+
});
|
|
136
|
+
export const clearSecret = Effect.gen(function* () {
|
|
137
|
+
const removed = [];
|
|
138
|
+
if ((yield* keychainAvailable) && (yield* keychainDelete))
|
|
139
|
+
removed.push('keychain');
|
|
140
|
+
const entries = yield* readSecretFile;
|
|
141
|
+
if (entries.delete(API_KEY_ENV)) {
|
|
142
|
+
yield* writeSecretFile(entries);
|
|
143
|
+
removed.push('file');
|
|
144
|
+
}
|
|
145
|
+
return removed;
|
|
146
|
+
});
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// non-secret settings
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));
|
|
151
|
+
/** Only string fields count; anything else (including deep structures) is skipped, not decoded. */
|
|
152
|
+
const storedString = (stored, key) => Predicate.hasProperty(stored, key) && Predicate.isString(stored[key]) ? stored[key] : undefined;
|
|
153
|
+
export const readSettings = Effect.gen(function* () {
|
|
154
|
+
const environment = yield* DbEnvironment;
|
|
155
|
+
const file = yield* settingsLocation;
|
|
156
|
+
const content = yield* readPrivately(file).pipe(Effect.orElseSucceed(() => undefined));
|
|
157
|
+
const stored = content === undefined ? undefined : Option.getOrUndefined(decodeJson(content));
|
|
158
|
+
const legacy = yield* readSecretFile;
|
|
159
|
+
const pick = (variable, key) => Option.fromUndefinedOr(nonEmpty(environment.variable(variable)) ??
|
|
160
|
+
nonEmpty(storedString(stored, key)) ??
|
|
161
|
+
nonEmpty(legacy.get(variable)));
|
|
162
|
+
return {
|
|
163
|
+
projectId: pick(PROJECT_ENV, 'projectId'),
|
|
164
|
+
parentBranch: pick(PARENT_ENV, 'parentBranch')
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
export const writeSettings = (settings) => Effect.gen(function* () {
|
|
168
|
+
yield* ensureConfigDirectory;
|
|
169
|
+
const file = yield* settingsLocation;
|
|
170
|
+
yield* writePrivately(file, `${JSON.stringify(settings, null, 2)}\n`).pipe(Effect.mapError(() => new ConfigurationError({ message: `settings write failed: ${file}` })));
|
|
171
|
+
// Retire legacy plaintext settings now that the JSON store owns them.
|
|
172
|
+
const entries = yield* readSecretFile;
|
|
173
|
+
const hadProject = entries.delete(PROJECT_ENV);
|
|
174
|
+
const hadParent = entries.delete(PARENT_ENV);
|
|
175
|
+
if (hadProject || hadParent)
|
|
176
|
+
yield* writeSecretFile(entries);
|
|
177
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Data, Schema } from 'effect';
|
|
2
|
+
/** Policy: leases are short-lived by construction. */
|
|
3
|
+
export const BRANCH_PREFIX = 'agent/';
|
|
4
|
+
export const MAX_TTL_SECONDS = 7 * 24 * 3600;
|
|
5
|
+
export const SUSPEND_TIMEOUT_SECONDS = 300;
|
|
6
|
+
export const READY_TIMEOUT_SECONDS = 90;
|
|
7
|
+
export class ConfigurationError extends Data.TaggedError('ConfigurationError') {
|
|
8
|
+
}
|
|
9
|
+
export class PolicyError extends Data.TaggedError('PolicyError') {
|
|
10
|
+
}
|
|
11
|
+
export class NeonError extends Data.TaggedError('NeonError') {
|
|
12
|
+
}
|
|
13
|
+
export class LeaseError extends Data.TaggedError('LeaseError') {
|
|
14
|
+
}
|
|
15
|
+
export class LifecycleError extends Data.TaggedError('LifecycleError') {
|
|
16
|
+
}
|
|
17
|
+
/** Neon branch, narrowed to the fields this tool relies on. */
|
|
18
|
+
export const Branch = Schema.Struct({
|
|
19
|
+
id: Schema.String,
|
|
20
|
+
name: Schema.String,
|
|
21
|
+
parent_id: Schema.optional(Schema.String),
|
|
22
|
+
current_state: Schema.optional(Schema.String),
|
|
23
|
+
default: Schema.optional(Schema.Boolean),
|
|
24
|
+
protected: Schema.optional(Schema.Boolean),
|
|
25
|
+
expires_at: Schema.optional(Schema.String),
|
|
26
|
+
ttl_interval_seconds: Schema.optional(Schema.Number)
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* Connection material stays `Redacted` end to end. It is unwrapped only at the single boundary
|
|
30
|
+
* that writes the worktree env file.
|
|
31
|
+
*/
|
|
32
|
+
const RedactedString = Schema.RedactedFromValue(Schema.String);
|
|
33
|
+
export const BranchResponse = Schema.Struct({ branch: Branch });
|
|
34
|
+
export const Project = Schema.Struct({
|
|
35
|
+
id: Schema.String,
|
|
36
|
+
name: Schema.String
|
|
37
|
+
});
|
|
38
|
+
export const ProjectResponse = Schema.Struct({ project: Project });
|
|
39
|
+
export const CreateBranchResponse = Schema.Struct({ branch: Branch });
|
|
40
|
+
export const BranchListResponse = Schema.Struct({
|
|
41
|
+
branches: Schema.Array(Branch),
|
|
42
|
+
pagination: Schema.optional(Schema.Struct({ cursor: Schema.optional(Schema.String) }))
|
|
43
|
+
});
|
|
44
|
+
export const Database = Schema.Struct({
|
|
45
|
+
name: Schema.String,
|
|
46
|
+
owner_name: Schema.String
|
|
47
|
+
});
|
|
48
|
+
export const DatabaseListResponse = Schema.Struct({
|
|
49
|
+
databases: Schema.Array(Database)
|
|
50
|
+
});
|
|
51
|
+
export const ConnectionUriResponse = Schema.Struct({ uri: RedactedString });
|
|
52
|
+
/** Legacy single-lease records remain readable as the `default` lease. */
|
|
53
|
+
const LegacyLease = Schema.Struct({
|
|
54
|
+
version: Schema.Literal(1),
|
|
55
|
+
projectId: Schema.String,
|
|
56
|
+
parentBranch: Schema.String,
|
|
57
|
+
branchId: Schema.String,
|
|
58
|
+
branchName: Schema.String,
|
|
59
|
+
worktree: Schema.String,
|
|
60
|
+
repository: Schema.String,
|
|
61
|
+
label: Schema.String,
|
|
62
|
+
envFile: Schema.String,
|
|
63
|
+
envKeys: Schema.Array(Schema.String),
|
|
64
|
+
createdAt: Schema.String,
|
|
65
|
+
expiresAt: Schema.String
|
|
66
|
+
});
|
|
67
|
+
/** Lease records are persisted outside the repository and hold no secrets. */
|
|
68
|
+
export const Lease = Schema.Struct({
|
|
69
|
+
version: Schema.Literal(2),
|
|
70
|
+
leaseName: Schema.String,
|
|
71
|
+
projectId: Schema.String,
|
|
72
|
+
parentBranch: Schema.String,
|
|
73
|
+
branchId: Schema.String,
|
|
74
|
+
branchName: Schema.String,
|
|
75
|
+
worktree: Schema.String,
|
|
76
|
+
repository: Schema.String,
|
|
77
|
+
label: Schema.String,
|
|
78
|
+
configEnvFile: Schema.String,
|
|
79
|
+
envFile: Schema.String,
|
|
80
|
+
envKeys: Schema.Array(Schema.String),
|
|
81
|
+
databaseName: Schema.optionalKey(Schema.String),
|
|
82
|
+
roleName: Schema.optionalKey(Schema.String),
|
|
83
|
+
createdAt: Schema.String,
|
|
84
|
+
expiresAt: Schema.String
|
|
85
|
+
});
|
|
86
|
+
export const LeaseRecord = Schema.Union([LegacyLease, Lease]);
|
|
87
|
+
export const LeaseRecordFromJson = Schema.fromJsonString(LeaseRecord);
|
|
88
|
+
export const LeaseFromJson = Schema.fromJsonString(Lease);
|
|
89
|
+
export const normalizeLease = (lease) => lease.version === 2
|
|
90
|
+
? lease
|
|
91
|
+
: {
|
|
92
|
+
version: 2,
|
|
93
|
+
leaseName: 'default',
|
|
94
|
+
projectId: lease.projectId,
|
|
95
|
+
parentBranch: lease.parentBranch,
|
|
96
|
+
branchId: lease.branchId,
|
|
97
|
+
branchName: lease.branchName,
|
|
98
|
+
worktree: lease.worktree,
|
|
99
|
+
repository: lease.repository,
|
|
100
|
+
label: lease.label,
|
|
101
|
+
configEnvFile: lease.envFile,
|
|
102
|
+
envFile: lease.envFile,
|
|
103
|
+
envKeys: lease.envKeys,
|
|
104
|
+
createdAt: lease.createdAt,
|
|
105
|
+
expiresAt: lease.expiresAt
|
|
106
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { Context, Layer } from 'effect';
|
|
3
|
+
export class DbEnvironment extends Context.Service()('provision/db/DbEnvironment') {
|
|
4
|
+
}
|
|
5
|
+
export const makeDbEnvironment = (env, platform) => {
|
|
6
|
+
const home = env.HOME ?? '';
|
|
7
|
+
return {
|
|
8
|
+
configDirectory: join(env.XDG_CONFIG_HOME ?? join(home, '.config'), 'pi'),
|
|
9
|
+
stateDirectory: join(env.XDG_STATE_HOME ?? join(home, '.local', 'state'), 'pi', 'sandbox-db'),
|
|
10
|
+
variable: name => env[name],
|
|
11
|
+
keychain: platform === 'darwin'
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
export const DbEnvironmentLive = Layer.sync(DbEnvironment, () => makeDbEnvironment(process.env, process.platform));
|