@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +498 -2
  3. package/dist/cli.js +29 -0
  4. package/dist/contract.js +13 -0
  5. package/dist/db/branch-lifecycle.js +23 -0
  6. package/dist/db/commands.js +303 -0
  7. package/dist/db/config.js +75 -0
  8. package/dist/db/credentials.js +177 -0
  9. package/dist/db/domain.js +106 -0
  10. package/dist/db/environment.js +14 -0
  11. package/dist/db/lease.js +132 -0
  12. package/dist/db/neon.js +350 -0
  13. package/dist/db/operations.js +246 -0
  14. package/dist/db/policy.js +59 -0
  15. package/dist/env/commands.js +39 -0
  16. package/dist/env/domain.js +14 -0
  17. package/dist/env/install.js +25 -0
  18. package/dist/env/paths.js +105 -0
  19. package/dist/env/provision.js +320 -0
  20. package/dist/env/sanitize.js +88 -0
  21. package/dist/env/vercel.js +180 -0
  22. package/dist/factory/approval.js +33 -0
  23. package/dist/factory/commands.js +173 -0
  24. package/dist/factory/config-commands.js +215 -0
  25. package/dist/factory/context.js +129 -0
  26. package/dist/factory/http.js +37 -0
  27. package/dist/factory/onboarding.js +155 -0
  28. package/dist/factory/onepassword.js +140 -0
  29. package/dist/factory/op-credential.js +154 -0
  30. package/dist/factory/passphrase.js +145 -0
  31. package/dist/factory/providers/cloudflare.js +126 -0
  32. package/dist/factory/providers/neon.js +81 -0
  33. package/dist/factory/providers/report-receiver.js +37 -0
  34. package/dist/factory/providers/resend.js +32 -0
  35. package/dist/factory/providers/upstash.js +21 -0
  36. package/dist/factory/registry.js +50 -0
  37. package/dist/factory/scoped-key.js +30 -0
  38. package/dist/factory/sealed.js +64 -0
  39. package/dist/factory/secret-input.js +43 -0
  40. package/dist/factory/steps/domain.js +23 -0
  41. package/dist/factory/steps/neon.js +101 -0
  42. package/dist/factory/steps/r2.js +70 -0
  43. package/dist/factory/steps/reports.js +44 -0
  44. package/dist/factory/steps/resend.js +37 -0
  45. package/dist/factory/steps/secrets.js +104 -0
  46. package/dist/factory/steps/upstash.js +92 -0
  47. package/dist/factory/steps/vercel.js +68 -0
  48. package/dist/factory/vercel-api.js +165 -0
  49. package/dist/package-info.js +15 -0
  50. package/dist/shared/agent.js +28 -0
  51. package/dist/shared/env-file.js +54 -0
  52. package/dist/shared/git.js +48 -0
  53. package/dist/shared/output.js +10 -0
  54. package/dist/shared/private-file.js +63 -0
  55. package/dist/shared/process.js +55 -0
  56. package/dist/shared/repo-config.js +104 -0
  57. package/dist/shared/sandbox-profile.js +12 -0
  58. package/package.json +45 -4
  59. 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));