@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.
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 +44 -4
  59. package/skills/provision/SKILL.md +172 -0
@@ -0,0 +1,70 @@
1
+ import { Console, Effect, Option, Redacted } from 'effect';
2
+ import { decide, envGroups, factoryError, groupHasKeys, mutate, requireProject, writeGroup } from '../context.js';
3
+ import { bucketCorsMatches, bucketExists, createBucket, createBucketToken, deleteToken, findToken, putBucketCors, r2Endpoint } from '../providers/cloudflare.js';
4
+ import { ensureScopedKey } from '../scoped-key.js';
5
+ import { listEnvRecords } from '../vercel-api.js';
6
+ export const r2VariableKeys = [
7
+ 'R2_API_URL',
8
+ 'R2_ACCESS_KEY_ID',
9
+ 'R2_SECRET_ACCESS_KEY',
10
+ 'R2_BUCKET_NAME'
11
+ ];
12
+ const ensureBucket = (context, admin, handle, records, target) => Effect.gen(function* () {
13
+ if (yield* bucketExists(admin, target.bucket)) {
14
+ yield* decide(context, 'data', `R2 bucket ${target.bucket}`);
15
+ }
16
+ else {
17
+ yield* mutate(context, `create private R2 bucket ${target.bucket}`, createBucket(admin, target.bucket));
18
+ }
19
+ if (!(yield* bucketCorsMatches(admin, target.bucket, target.origin))) {
20
+ yield* mutate(context, `set ${target.bucket} CORS to PUT from ${target.origin}`, putBucketCors(admin, target.bucket, target.origin));
21
+ }
22
+ const tokenName = `${target.bucket}-app-rw`;
23
+ yield* ensureScopedKey(context, {
24
+ description: `R2 token ${tokenName}`,
25
+ existing: yield* findToken(admin, tokenName),
26
+ vercelComplete: groupHasKeys(records, target.group, r2VariableKeys),
27
+ createAndWrite: Effect.gen(function* () {
28
+ const token = yield* createBucketToken(admin, tokenName, target.bucket);
29
+ const [url, accessKey, secretKey, bucketName] = r2VariableKeys;
30
+ yield* writeGroup(context, handle, target.group, [
31
+ { key: url, value: Redacted.make(r2Endpoint(admin.accountId)), type: 'encrypted' },
32
+ { key: accessKey, value: Redacted.make(token.accessKeyId), type: target.secretType },
33
+ { key: secretKey, value: token.secretAccessKey, type: target.secretType },
34
+ { key: bucketName, value: Redacted.make(target.bucket), type: 'encrypted' }
35
+ ]).pipe(Effect.tapError(() => deleteToken(admin, token.id).pipe(Effect.ignore)));
36
+ }),
37
+ remove: id => deleteToken(admin, id)
38
+ });
39
+ });
40
+ /**
41
+ * Private `<repo>-dev` and `<repo>-prod` buckets with upload CORS (`*` for development, the exact
42
+ * production origin for production) and one bucket-scoped object read/write token each.
43
+ */
44
+ export const r2Step = (context) => Effect.gen(function* () {
45
+ const admin = context.credentials.cloudflare;
46
+ if (admin === undefined)
47
+ return yield* factoryError('the factory item has no cloudflare section');
48
+ if (context.domain === undefined) {
49
+ return yield* factoryError('the r2 step needs --domain for the production CORS origin');
50
+ }
51
+ const handle = yield* requireProject(context);
52
+ if (Option.isNone(handle)) {
53
+ yield* Console.log(' would run after the vercel step creates the project');
54
+ return;
55
+ }
56
+ const groups = envGroups(handle.value);
57
+ const records = yield* listEnvRecords(context.credentials.vercel.teamId, handle.value.project.id);
58
+ yield* ensureBucket(context, admin, handle.value, records, {
59
+ bucket: context.names.development,
60
+ origin: '*',
61
+ group: groups.preproduction,
62
+ secretType: 'encrypted'
63
+ });
64
+ yield* ensureBucket(context, admin, handle.value, records, {
65
+ bucket: context.names.production,
66
+ origin: `https://${context.domain}`,
67
+ group: groups.production,
68
+ secretType: 'sensitive'
69
+ });
70
+ });
@@ -0,0 +1,44 @@
1
+ import { Console, Effect, Option, Redacted } from 'effect';
2
+ import { envGroups, groupHasKeys, requireProject, writeGroup } from '../context.js';
3
+ import { createReportToken, findReportToken, revokeReportToken } from '../providers/report-receiver.js';
4
+ import { ensureScopedKey } from '../scoped-key.js';
5
+ import { listEnvRecords } from '../vercel-api.js';
6
+ export const reportVariableKeys = ['REPORT_RECEIVER_URL', 'REPORT_RECEIVER_TOKEN'];
7
+ /**
8
+ * One report token per environment group from the factory's report receiver (its factory API).
9
+ * Factories without a `report_receiver` section skip this step.
10
+ */
11
+ export const reportsStep = (context) => Effect.gen(function* () {
12
+ const receiver = context.credentials.reportReceiver;
13
+ if (receiver === undefined) {
14
+ yield* Console.log(' skipped: the factory has no report receiver');
15
+ return;
16
+ }
17
+ const handle = yield* requireProject(context);
18
+ if (Option.isNone(handle)) {
19
+ yield* Console.log(' would run after the vercel step creates the project');
20
+ return;
21
+ }
22
+ const groups = envGroups(handle.value);
23
+ const records = yield* listEnvRecords(context.credentials.vercel.teamId, handle.value.project.id);
24
+ const [urlKey, tokenKey] = reportVariableKeys;
25
+ const targets = [
26
+ { name: context.names.development, group: groups.preproduction, type: 'encrypted' },
27
+ { name: context.names.production, group: groups.production, type: 'sensitive' }
28
+ ];
29
+ for (const target of targets) {
30
+ yield* ensureScopedKey(context, {
31
+ description: `report token ${target.name}`,
32
+ existing: yield* findReportToken(receiver, target.name),
33
+ vercelComplete: groupHasKeys(records, target.group, reportVariableKeys),
34
+ createAndWrite: Effect.gen(function* () {
35
+ const created = yield* createReportToken(receiver, target.name);
36
+ yield* writeGroup(context, handle.value, target.group, [
37
+ { key: urlKey, value: Redacted.make(receiver.url), type: target.type },
38
+ { key: tokenKey, value: created.token, type: target.type }
39
+ ]).pipe(Effect.tapError(() => revokeReportToken(receiver, created.id).pipe(Effect.ignore)));
40
+ }),
41
+ remove: id => revokeReportToken(receiver, id)
42
+ });
43
+ }
44
+ });
@@ -0,0 +1,37 @@
1
+ import { Console, Effect, Option } from 'effect';
2
+ import { envGroups, factoryError, groupHasKeys, requireProject, writeGroup } from '../context.js';
3
+ import { createSendingKey, deleteApiKey, findApiKey } from '../providers/resend.js';
4
+ import { ensureScopedKey } from '../scoped-key.js';
5
+ import { listEnvRecords } from '../vercel-api.js';
6
+ const variableKey = 'RESEND_API_KEY';
7
+ /** One sending-only key per environment group (not restricted to a domain). */
8
+ export const resendStep = (context) => Effect.gen(function* () {
9
+ const resend = context.credentials.resend;
10
+ if (resend === undefined)
11
+ return yield* factoryError('the factory item has no resend section');
12
+ const handle = yield* requireProject(context);
13
+ if (Option.isNone(handle)) {
14
+ yield* Console.log(' would run after the vercel step creates the project');
15
+ return;
16
+ }
17
+ const groups = envGroups(handle.value);
18
+ const records = yield* listEnvRecords(context.credentials.vercel.teamId, handle.value.project.id);
19
+ const targets = [
20
+ { name: context.names.development, group: groups.preproduction, type: 'encrypted' },
21
+ { name: context.names.production, group: groups.production, type: 'sensitive' }
22
+ ];
23
+ for (const target of targets) {
24
+ yield* ensureScopedKey(context, {
25
+ description: `Resend key ${target.name}`,
26
+ existing: yield* findApiKey(resend.apiKey, target.name),
27
+ vercelComplete: groupHasKeys(records, target.group, [variableKey]),
28
+ createAndWrite: Effect.gen(function* () {
29
+ const key = yield* createSendingKey(resend.apiKey, target.name);
30
+ yield* writeGroup(context, handle.value, target.group, [
31
+ { key: variableKey, value: key.token, type: target.type }
32
+ ]).pipe(Effect.tapError(() => deleteApiKey(resend.apiKey, key.id).pipe(Effect.ignore)));
33
+ }),
34
+ remove: id => deleteApiKey(resend.apiKey, id)
35
+ });
36
+ }
37
+ });
@@ -0,0 +1,104 @@
1
+ import { generateKeyPairSync, randomBytes } from 'node:crypto';
2
+ import { Console, Effect, Option, Redacted } from 'effect';
3
+ import { decide, envGroups, factoryError, groupHasKeys, requireProject, writeGroup } from '../context.js';
4
+ import { requireVerifiedDomain } from '../providers/resend.js';
5
+ import { listEnvRecords } from '../vercel-api.js';
6
+ export const usageAlertThresholds = '50,80,95';
7
+ const base64 = (bytes) => Redacted.make(randomBytes(bytes).toString('base64'));
8
+ /** A web-push VAPID pair: uncompressed P-256 public point and raw private scalar, base64url. */
9
+ export const generateVapidKeys = () => {
10
+ const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
11
+ const publicJwk = publicKey.export({ format: 'jwk' });
12
+ const privateJwk = privateKey.export({ format: 'jwk' });
13
+ const x = Buffer.from(publicJwk.x ?? '', 'base64url');
14
+ const y = Buffer.from(publicJwk.y ?? '', 'base64url');
15
+ return {
16
+ publicKey: Buffer.concat([Buffer.from([4]), x, y]).toString('base64url'),
17
+ privateKey: Redacted.make(privateJwk.d ?? '')
18
+ };
19
+ };
20
+ /** Generated per group: a separate value for pre-production and production. */
21
+ const generatedSets = [
22
+ {
23
+ description: 'BETTER_AUTH_SECRET',
24
+ keys: ['BETTER_AUTH_SECRET'],
25
+ kind: 'secret',
26
+ entries: type => [{ key: 'BETTER_AUTH_SECRET', value: base64(32), type }]
27
+ },
28
+ {
29
+ description: 'INTEGRATION_CREDENTIAL_ENCRYPTION_KEY',
30
+ keys: ['INTEGRATION_CREDENTIAL_ENCRYPTION_KEY'],
31
+ kind: 'secret',
32
+ entries: type => [{ key: 'INTEGRATION_CREDENTIAL_ENCRYPTION_KEY', value: base64(32), type }]
33
+ },
34
+ {
35
+ description: 'CRON_SECRET',
36
+ keys: ['CRON_SECRET'],
37
+ kind: 'key',
38
+ entries: type => [
39
+ { key: 'CRON_SECRET', value: Redacted.make(randomBytes(32).toString('hex')), type }
40
+ ]
41
+ },
42
+ {
43
+ description: 'VAPID key pair',
44
+ keys: ['VAPID_PUBLIC_KEY', 'VAPID_PRIVATE_KEY'],
45
+ kind: 'secret',
46
+ entries: type => {
47
+ const pair = generateVapidKeys();
48
+ return [
49
+ { key: 'VAPID_PUBLIC_KEY', value: Redacted.make(pair.publicKey), type: 'plain' },
50
+ { key: 'VAPID_PRIVATE_KEY', value: pair.privateKey, type }
51
+ ];
52
+ }
53
+ }
54
+ ];
55
+ const ensureSet = (context, handle, records, group, set, entries) => Effect.gen(function* () {
56
+ if (groupHasKeys(records, group, set.keys)) {
57
+ const decision = yield* decide(context, set.kind, `${set.description} (${group.label})`);
58
+ if (decision === 'reuse')
59
+ return;
60
+ }
61
+ yield* writeGroup(context, handle, group, entries());
62
+ });
63
+ /**
64
+ * App secrets the CLI generates (never shown or stored elsewhere), plus plain settings shared by
65
+ * all environments: `AUTH_EMAIL_FROM` and `AI_PROVIDER_USAGE_ALERT_THRESHOLDS`.
66
+ */
67
+ export const secretsStep = (context) => Effect.gen(function* () {
68
+ const resend = context.credentials.resend;
69
+ if (resend === undefined) {
70
+ return yield* factoryError('the secrets step needs the resend section for AUTH_EMAIL_FROM');
71
+ }
72
+ const fromAddress = context.emailFrom ??
73
+ (resend.sendingDomain === undefined ? undefined : `noreply@${resend.sendingDomain}`);
74
+ if (fromAddress === undefined) {
75
+ return yield* factoryError('the secrets step needs a sender: pass --email-from or set resend.sending_domain in the factory item');
76
+ }
77
+ yield* requireVerifiedDomain(resend.apiKey, fromAddress.slice(fromAddress.indexOf('@') + 1));
78
+ const handle = yield* requireProject(context);
79
+ if (Option.isNone(handle)) {
80
+ yield* Console.log(' would run after the vercel step creates the project');
81
+ return;
82
+ }
83
+ const groups = envGroups(handle.value);
84
+ const records = yield* listEnvRecords(context.credentials.vercel.teamId, handle.value.project.id);
85
+ for (const set of generatedSets) {
86
+ yield* ensureSet(context, handle.value, records, groups.preproduction, set, () => set.entries('encrypted'));
87
+ yield* ensureSet(context, handle.value, records, groups.production, set, () => set.entries('sensitive'));
88
+ }
89
+ const plain = [
90
+ {
91
+ key: 'AUTH_EMAIL_FROM',
92
+ value: Redacted.make(`${context.senderName} <${fromAddress}>`),
93
+ type: 'plain'
94
+ },
95
+ {
96
+ key: 'AI_PROVIDER_USAGE_ALERT_THRESHOLDS',
97
+ value: Redacted.make(usageAlertThresholds),
98
+ type: 'plain'
99
+ }
100
+ ];
101
+ for (const entry of plain) {
102
+ yield* ensureSet(context, handle.value, records, groups.all, { description: entry.key, keys: [entry.key], kind: 'key', entries: () => [entry] }, () => [entry]);
103
+ }
104
+ });
@@ -0,0 +1,92 @@
1
+ import { Console, Effect, Option, Redacted } from 'effect';
2
+ import { decide, envGroups, factoryError, groupHasKeys, mutate, requireProject, withTerminal, writeGroup } from '../context.js';
3
+ import { SecretInput } from '../secret-input.js';
4
+ import { createTeam, findTeam } from '../providers/upstash.js';
5
+ import { listEnvRecords } from '../vercel-api.js';
6
+ export const qstashVariableKeys = [
7
+ 'QSTASH_URL',
8
+ 'QSTASH_TOKEN',
9
+ 'QSTASH_CURRENT_SIGNING_KEY',
10
+ 'QSTASH_NEXT_SIGNING_KEY'
11
+ ];
12
+ /**
13
+ * Retained `<repo>-dev` and `<repo>-prod` teams (QStash has one tenancy per team, so separate
14
+ * teams are the only isolation boundary). A team's QStash credentials are only readable with a key
15
+ * created inside that team, so the operator pastes them from the console and the step writes them
16
+ * to Vercel; without a terminal (or when skipped) it reports the manual transfer until Vercel has
17
+ * them.
18
+ */
19
+ export const upstashStep = (context) => Effect.gen(function* () {
20
+ const upstash = context.credentials.upstash;
21
+ if (upstash === undefined)
22
+ return yield* factoryError('the factory item has no upstash section');
23
+ for (const name of [context.names.development, context.names.production]) {
24
+ const team = yield* findTeam(upstash.email, upstash.apiKey, name);
25
+ if (Option.isSome(team))
26
+ yield* decide(context, 'data', `Upstash team ${name}`);
27
+ else
28
+ yield* mutate(context, `create Upstash team ${name}`, createTeam(upstash.email, upstash.apiKey, name));
29
+ }
30
+ const handle = yield* requireProject(context);
31
+ if (Option.isNone(handle)) {
32
+ for (const name of [context.names.development, context.names.production]) {
33
+ yield* Console.log(` would ask for ${name}'s QStash credentials (or copy them by hand)`);
34
+ }
35
+ return;
36
+ }
37
+ const groups = envGroups(handle.value);
38
+ const records = yield* listEnvRecords(context.credentials.vercel.teamId, handle.value.project.id);
39
+ const targets = [
40
+ { team: context.names.development, group: groups.preproduction, type: 'encrypted' },
41
+ { team: context.names.production, group: groups.production, type: 'sensitive' }
42
+ ];
43
+ for (const target of targets) {
44
+ if (groupHasKeys(records, target.group, qstashVariableKeys))
45
+ continue;
46
+ const values = context.dryRun ? Option.none() : yield* askQStash(target.team, target.group);
47
+ if (Option.isNone(values)) {
48
+ yield* Console.log(` manual: copy ${target.team}'s EU QStash ${qstashVariableKeys.join(', ')} into Vercel (${target.group.label})`);
49
+ continue;
50
+ }
51
+ const { url, token, current, next } = values.value;
52
+ yield* writeGroup(context, handle.value, target.group, [
53
+ { key: 'QSTASH_URL', value: url, type: 'encrypted' },
54
+ { key: 'QSTASH_TOKEN', value: token, type: target.type },
55
+ { key: 'QSTASH_CURRENT_SIGNING_KEY', value: current, type: target.type },
56
+ { key: 'QSTASH_NEXT_SIGNING_KEY', value: next, type: target.type }
57
+ ]);
58
+ }
59
+ });
60
+ /**
61
+ * Asks the operator to paste a team's QStash `.env` block from the Upstash console (a team's
62
+ * QStash is only readable with a key created inside that team). An empty line skips.
63
+ */
64
+ const askQStash = (team, group) => withTerminal(Effect.gen(function* () {
65
+ const secrets = yield* SecretInput;
66
+ let problem = '';
67
+ for (;;) {
68
+ const pasted = yield* secrets.readEnv(`\nQStash for ${team} → Vercel (${group.label})\n` +
69
+ ` 1. Open https://console.upstash.com/qstash and switch to team ${team}\n` +
70
+ ` (team menu, top left), EU region (eu-central-1).\n` +
71
+ ` 2. In Quickstart, copy the .env block (all 4 lines) and paste it here, then Enter.\n` +
72
+ ` Press Enter on an empty line to skip and copy them into Vercel later.\n` +
73
+ `${problem}QStash .env: `, qstashVariableKeys);
74
+ if (Option.isNone(pasted))
75
+ return Option.none();
76
+ const [url, token, current, next] = qstashVariableKeys.map(key => pasted.value.get(key));
77
+ const missing = qstashVariableKeys.filter(key => !pasted.value.has(key));
78
+ if (url === undefined ||
79
+ token === undefined ||
80
+ current === undefined ||
81
+ next === undefined) {
82
+ problem = ` Missing ${missing.join(', ')}; paste the whole block again.\n`;
83
+ }
84
+ else if (!isHttpsUrl(Redacted.value(url))) {
85
+ problem = ' QSTASH_URL must be an https URL; paste the whole block again.\n';
86
+ }
87
+ else {
88
+ return Option.some({ url, token, current, next });
89
+ }
90
+ }
91
+ })).pipe(Effect.mapError(error => factoryError(error.message)));
92
+ const isHttpsUrl = (value) => URL.canParse(value) && new URL(value).protocol === 'https:';
@@ -0,0 +1,68 @@
1
+ import { join } from 'node:path';
2
+ import { Console, Effect, Option, Redacted, Schema } from 'effect';
3
+ import { readPrivately } from '../../shared/private-file.js';
4
+ import { decide, envGroups, factoryError, groupHasKeys, mutate, writeGroup } from '../context.js';
5
+ import { createCustomEnvironment, createProject, linkCheckout, linkedRepository, listEnvRecords, setRootDirectory } from '../vercel-api.js';
6
+ import { findProject } from '../context.js';
7
+ /** Destructive E2E reset consent, present only in the custom `test` environment. */
8
+ const resetConsentKey = 'ALLOW_E2E_DATABASE_RESET';
9
+ const LinkedProject = Schema.fromJsonString(Schema.Struct({ projectId: Schema.String }));
10
+ /**
11
+ * Vercel project `<repo>` linked to the GitHub repository (Root Directory = `provision.appDir`),
12
+ * its custom `test` environment, the `test`-only reset consent, and the checkout's link.
13
+ */
14
+ export const vercelStep = (context) => Effect.gen(function* () {
15
+ const teamId = context.credentials.vercel.teamId;
16
+ const found = yield* findProject(context);
17
+ let handle = found;
18
+ if (Option.isSome(found)) {
19
+ const linked = linkedRepository(found.value.project);
20
+ if (linked !== undefined && linked !== context.checkout.repository) {
21
+ return yield* factoryError(`Vercel project ${context.names.project} is linked to ${linked}, not ${context.checkout.repository}`);
22
+ }
23
+ yield* decide(context, 'data', `Vercel project ${context.names.project}`);
24
+ const rootDirectory = found.value.project.rootDirectory ?? '.';
25
+ if (rootDirectory !== context.repoConfig.appDir) {
26
+ yield* mutate(context, `set the project Root Directory to ${context.repoConfig.appDir}`, setRootDirectory(teamId, found.value.project.id, context.repoConfig.appDir));
27
+ }
28
+ }
29
+ else {
30
+ const created = yield* mutate(context, `create Vercel project ${context.names.project} linked to ${context.checkout.repository}`, createProject(teamId, {
31
+ name: context.names.project,
32
+ repository: context.checkout.repository,
33
+ rootDirectory: context.repoConfig.appDir
34
+ }));
35
+ handle = Option.map(created, project => ({ project, testEnvironmentId: undefined }));
36
+ }
37
+ if (Option.isNone(handle)) {
38
+ yield* Console.log(' would create the test environment and its reset consent');
39
+ return;
40
+ }
41
+ let project = handle.value;
42
+ if (project.testEnvironmentId === undefined) {
43
+ const environment = yield* mutate(context, 'create the custom test environment', createCustomEnvironment(teamId, project.project.id, 'test'));
44
+ if (Option.isSome(environment)) {
45
+ project = { ...project, testEnvironmentId: environment.value.id };
46
+ }
47
+ }
48
+ if (project.testEnvironmentId !== undefined) {
49
+ const records = yield* listEnvRecords(teamId, project.project.id);
50
+ const test = envGroups(project).test;
51
+ if (!groupHasKeys(records, test, [resetConsentKey])) {
52
+ yield* writeGroup(context, project, test, [
53
+ { key: resetConsentKey, value: Redacted.make('1'), type: 'plain' }
54
+ ]);
55
+ }
56
+ }
57
+ const projectFile = join(context.appDirectory, '.vercel', 'project.json');
58
+ const existingLink = yield* readPrivately(projectFile).pipe(Effect.mapError(error => factoryError(error.message)));
59
+ const linkedProjectId = existingLink === undefined
60
+ ? undefined
61
+ : Option.getOrUndefined(Schema.decodeUnknownOption(LinkedProject)(existingLink))?.projectId;
62
+ if (linkedProjectId !== project.project.id) {
63
+ if (linkedProjectId !== undefined) {
64
+ return yield* factoryError(`${projectFile} links a different Vercel project; remove it and rerun`);
65
+ }
66
+ yield* mutate(context, `link ${context.appDirectory} to ${context.names.project}`, linkCheckout(teamId, context.appDirectory, context.names.project));
67
+ }
68
+ });
@@ -0,0 +1,165 @@
1
+ import { Effect, Option, Predicate, Redacted, Schema } from 'effect';
2
+ import { capture, inherit } from '../shared/process.js';
3
+ import { ProviderError } from './http.js';
4
+ /**
5
+ * Vercel REST calls through the operator's authenticated `vercel` CLI (`vercel api`), always
6
+ * scoped to the factory's immutable `team_*` id. Request bodies go through stdin, so secret values
7
+ * never appear in argv; responses and diagnostics are never printed.
8
+ */
9
+ const vercelApi = (teamId, label, path, options = {}) => Effect.gen(function* () {
10
+ const method = options.method ?? 'GET';
11
+ const separator = path.includes('?') ? '&' : '?';
12
+ const scopedPath = `${path}${separator}teamId=${encodeURIComponent(teamId)}`;
13
+ const args = ['api', scopedPath, '--raw', '--non-interactive', '--no-color', '--scope', teamId];
14
+ if (method !== 'GET')
15
+ args.push('-X', method);
16
+ if (method === 'DELETE')
17
+ args.push('--dangerously-skip-permissions');
18
+ if (options.body !== undefined)
19
+ args.push('--input', '-');
20
+ const result = yield* capture('vercel', args, options.body === undefined ? {} : { input: JSON.stringify(options.body) }).pipe(Effect.mapError(() => new ProviderError({ message: 'Vercel CLI (vercel) could not be started' })));
21
+ if (result.exitCode !== 0) {
22
+ const status = /\(\s*(\d{3})\s*\)\s*$/u.exec(result.stderr.trim())?.[1];
23
+ if (status === '404')
24
+ return Option.none();
25
+ return yield* new ProviderError({
26
+ message: `${label} failed${status === undefined ? '' : ` (${status})`}; Vercel response suppressed`,
27
+ ...(status === undefined ? {} : { status: Number(status) })
28
+ });
29
+ }
30
+ if (result.stdout.trim() === '')
31
+ return Option.some(undefined);
32
+ return yield* Effect.try({
33
+ try: () => JSON.parse(result.stdout),
34
+ catch: () => new ProviderError({ message: `${label}: Vercel returned invalid JSON` })
35
+ }).pipe(Effect.map(Option.some));
36
+ });
37
+ const decode = (label, schema, value) => Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => new ProviderError({ message: `${label}: unexpected Vercel response shape` })));
38
+ const required = (label, value) => Option.match(value, {
39
+ onNone: () => Effect.fail(new ProviderError({ message: `${label}: not found` })),
40
+ onSome: Effect.succeed
41
+ });
42
+ // ---------------------------------------------------------------------------
43
+ // projects
44
+ // ---------------------------------------------------------------------------
45
+ export const VercelProject = Schema.Struct({
46
+ id: Schema.String,
47
+ name: Schema.String,
48
+ rootDirectory: Schema.optional(Schema.NullOr(Schema.String)),
49
+ link: Schema.optional(Schema.NullOr(Schema.Struct({
50
+ type: Schema.optional(Schema.String),
51
+ org: Schema.optional(Schema.String),
52
+ repo: Schema.optional(Schema.String)
53
+ })))
54
+ });
55
+ /** `owner/repo` of a project's Git link, when linked to GitHub. */
56
+ export const linkedRepository = (project) => {
57
+ const link = project.link;
58
+ if (link === undefined || link === null || link.type !== 'github')
59
+ return undefined;
60
+ return link.org !== undefined && link.repo !== undefined ? `${link.org}/${link.repo}` : link.repo;
61
+ };
62
+ export const getProject = (teamId, name) => vercelApi(teamId, 'project lookup', `/v9/projects/${encodeURIComponent(name)}`).pipe(Effect.flatMap(Option.match({
63
+ onNone: () => Effect.succeed(Option.none()),
64
+ onSome: value => decode('project lookup', VercelProject, value).pipe(Effect.map(Option.some))
65
+ })));
66
+ export const createProject = (teamId, options) => vercelApi(teamId, 'project creation', '/v11/projects', {
67
+ method: 'POST',
68
+ body: {
69
+ name: options.name,
70
+ framework: 'nextjs',
71
+ gitRepository: { type: 'github', repo: options.repository },
72
+ ...(options.rootDirectory === '.' ? {} : { rootDirectory: options.rootDirectory })
73
+ }
74
+ }).pipe(Effect.flatMap(value => required('project creation', value)), Effect.flatMap(value => decode('project creation', VercelProject, value)));
75
+ export const setRootDirectory = (teamId, projectId, rootDirectory) => vercelApi(teamId, 'project update', `/v9/projects/${encodeURIComponent(projectId)}`, {
76
+ method: 'PATCH',
77
+ body: { rootDirectory: rootDirectory === '.' ? null : rootDirectory }
78
+ }).pipe(Effect.asVoid);
79
+ // ---------------------------------------------------------------------------
80
+ // custom environments
81
+ // ---------------------------------------------------------------------------
82
+ const CustomEnvironment = Schema.Struct({ id: Schema.String, slug: Schema.String });
83
+ const CustomEnvironmentList = Schema.Struct({ environments: Schema.Array(CustomEnvironment) });
84
+ export const listCustomEnvironments = (teamId, projectId) => vercelApi(teamId, 'custom environment listing', `/v9/projects/${encodeURIComponent(projectId)}/custom-environments`).pipe(Effect.flatMap(value => required('custom environment listing', value)), Effect.flatMap(value => decode('custom environment listing', CustomEnvironmentList, value)), Effect.map(list => list.environments));
85
+ export const createCustomEnvironment = (teamId, projectId, slug) => vercelApi(teamId, 'custom environment creation', `/v9/projects/${encodeURIComponent(projectId)}/custom-environments`, { method: 'POST', body: { slug, description: 'Disposable end-to-end test configuration' } }).pipe(Effect.flatMap(value => required('custom environment creation', value)), Effect.flatMap(value => decode('custom environment creation', CustomEnvironment, value)));
86
+ export const EnvRecord = Schema.Struct({
87
+ id: Schema.String,
88
+ key: Schema.String,
89
+ type: Schema.String,
90
+ target: Schema.optional(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
91
+ customEnvironmentIds: Schema.optional(Schema.Array(Schema.String))
92
+ });
93
+ const EnvList = Schema.Struct({
94
+ envs: Schema.Array(EnvRecord),
95
+ pagination: Schema.optional(Schema.NullOr(Schema.Struct({
96
+ next: Schema.optional(Schema.NullOr(Schema.Union([Schema.String, Schema.Number])))
97
+ })))
98
+ });
99
+ export const recordTargets = (record) => record.target === undefined
100
+ ? []
101
+ : Predicate.isString(record.target)
102
+ ? [record.target]
103
+ : record.target;
104
+ export const listEnvRecords = (teamId, projectId) => Effect.gen(function* () {
105
+ const records = [];
106
+ const seen = new Set();
107
+ let until;
108
+ do {
109
+ const query = new URLSearchParams({ limit: '100' });
110
+ if (until !== undefined)
111
+ query.set('until', until);
112
+ const value = yield* vercelApi(teamId, 'environment variable listing', `/v10/projects/${encodeURIComponent(projectId)}/env?${query.toString()}`).pipe(Effect.flatMap(found => required('environment variable listing', found)));
113
+ const page = yield* decode('environment variable listing', EnvList, value);
114
+ records.push(...page.envs);
115
+ const next = page.pagination?.next;
116
+ until = next === undefined || next === null ? undefined : String(next);
117
+ if (until !== undefined && seen.has(until)) {
118
+ return yield* new ProviderError({ message: 'Vercel environment pagination cycled' });
119
+ }
120
+ if (until !== undefined)
121
+ seen.add(until);
122
+ } while (until !== undefined);
123
+ return records;
124
+ });
125
+ export const createEnvRecord = (teamId, projectId, record) => vercelApi(teamId, `environment variable ${record.key}`, `/v10/projects/${encodeURIComponent(projectId)}/env`, {
126
+ method: 'POST',
127
+ body: {
128
+ key: record.key,
129
+ value: Redacted.value(record.value),
130
+ type: record.type,
131
+ ...(record.target.length > 0 ? { target: record.target } : {}),
132
+ ...(record.customEnvironmentIds.length > 0
133
+ ? { customEnvironmentIds: record.customEnvironmentIds }
134
+ : {}),
135
+ comment: 'Managed by provision factory'
136
+ }
137
+ }).pipe(Effect.asVoid);
138
+ export const deleteEnvRecord = (teamId, projectId, recordId) => vercelApi(teamId, 'environment variable deletion', `/v9/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(recordId)}`, { method: 'DELETE' }).pipe(Effect.asVoid);
139
+ // ---------------------------------------------------------------------------
140
+ // domains and checkout link
141
+ // ---------------------------------------------------------------------------
142
+ const ProjectDomain = Schema.Struct({
143
+ name: Schema.String,
144
+ verified: Schema.optional(Schema.Boolean)
145
+ });
146
+ export const getProjectDomain = (teamId, projectId, domain) => vercelApi(teamId, 'domain lookup', `/v9/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domain)}`).pipe(Effect.flatMap(Option.match({
147
+ onNone: () => Effect.succeed(Option.none()),
148
+ onSome: value => decode('domain lookup', ProjectDomain, value).pipe(Effect.map(Option.some))
149
+ })));
150
+ export const addProjectDomain = (teamId, projectId, domain) => vercelApi(teamId, 'domain creation', `/v10/projects/${encodeURIComponent(projectId)}/domains`, {
151
+ method: 'POST',
152
+ body: { name: domain }
153
+ }).pipe(Effect.flatMap(value => required('domain creation', value)), Effect.flatMap(value => decode('domain creation', ProjectDomain, value)));
154
+ /** Links an app directory to the project (writes `.vercel/project.json`). */
155
+ export const linkCheckout = (teamId, directory, project) => inherit('vercel', [
156
+ 'link',
157
+ '--cwd',
158
+ directory,
159
+ '--project',
160
+ project,
161
+ '--scope',
162
+ teamId,
163
+ '--yes',
164
+ '--no-color'
165
+ ]).pipe(Effect.mapError(error => new ProviderError({ message: error.message })));
@@ -0,0 +1,15 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { Data, Effect, FileSystem, Schema } from 'effect';
3
+ const PackageInfo = Schema.Struct({
4
+ name: Schema.String,
5
+ version: Schema.String
6
+ });
7
+ export class PackageInfoError extends Data.TaggedError('PackageInfoError') {
8
+ }
9
+ // `src/` (development) and `dist/` (published) both sit one level below the package root.
10
+ const packageJsonPath = fileURLToPath(new URL('../package.json', import.meta.url));
11
+ export const readPackageInfo = Effect.gen(function* () {
12
+ const fs = yield* FileSystem.FileSystem;
13
+ const text = yield* fs.readFileString(packageJsonPath);
14
+ return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PackageInfo))(text);
15
+ }).pipe(Effect.mapError(cause => new PackageInfoError({ message: `cannot read provision package.json: ${cause.message}` })));
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Detects a coding agent from the environment it sets for the commands it runs (the same signals
3
+ * as `@vercel/detect-agent`). This only shapes the experience: what keeps agents from approving
4
+ * factory runs is the passphrase read from the terminal, not this check.
5
+ */
6
+ const agentVariables = [
7
+ ['CURSOR_TRACE_ID', 'cursor'],
8
+ ['CURSOR_AGENT', 'cursor-cli'],
9
+ ['GEMINI_CLI', 'gemini'],
10
+ ['CODEX_SANDBOX', 'codex'],
11
+ ['CODEX_CI', 'codex'],
12
+ ['CODEX_THREAD_ID', 'codex'],
13
+ ['ANTIGRAVITY_AGENT', 'antigravity'],
14
+ ['AUGMENT_AGENT', 'augment-cli'],
15
+ ['OPENCODE', 'opencode'],
16
+ ['OPENCODE_CLIENT', 'opencode'],
17
+ ['CLAUDECODE', 'claude'],
18
+ ['CLAUDE_CODE', 'claude'],
19
+ ['COPILOT_MODEL', 'github-copilot'],
20
+ ['COPILOT_ALLOW_ALL', 'github-copilot']
21
+ ];
22
+ /** The agent running this process, or `undefined` for a human. `AI_AGENT` names it explicitly. */
23
+ export const detectAgent = (env = process.env) => {
24
+ const named = env['AI_AGENT']?.trim();
25
+ if (named !== undefined && named !== '')
26
+ return named;
27
+ return agentVariables.find(([variable]) => (env[variable] ?? '') !== '')?.[1];
28
+ };