anhedral 0.8.5 → 0.9.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 (126) hide show
  1. package/README.md +140 -34
  2. package/assets/anhedral-cli-init-technical.svg +8 -8
  3. package/assets/anhedral-cli-init.svg +9 -9
  4. package/assets/security/LICENSES.txt +63 -0
  5. package/assets/security/audit-deps.mjs +295 -0
  6. package/assets/security/braces-regression.mjs +102 -0
  7. package/assets/security/braces@3.0.3.patch +285 -0
  8. package/assets/security/decode-uri-component@0.2.2.patch +149 -0
  9. package/assets/security/decoder-regression.mjs +37 -0
  10. package/assets/security/forge-regression.mjs +46 -0
  11. package/assets/security/manifest.json +53 -0
  12. package/assets/security/node-forge@1.4.0.patch +26 -0
  13. package/dist/access.d.ts +10 -0
  14. package/dist/access.js +93 -0
  15. package/dist/auth-schema-snapshot.d.ts +346 -0
  16. package/dist/auth-schema-snapshot.js +347 -0
  17. package/dist/auth-schema.d.ts +1 -0
  18. package/dist/auth-schema.js +57 -0
  19. package/dist/bin.js +353 -10
  20. package/dist/bounded-file.d.ts +2 -0
  21. package/dist/bounded-file.js +30 -0
  22. package/dist/capabilities.d.ts +19 -33
  23. package/dist/capabilities.js +73 -71
  24. package/dist/cloudflare.d.ts +14 -0
  25. package/dist/cloudflare.js +66 -0
  26. package/dist/command.js +12 -3
  27. package/dist/delivery.d.ts +118 -0
  28. package/dist/delivery.js +611 -0
  29. package/dist/dependencies.d.ts +22 -0
  30. package/dist/dependencies.js +109 -67
  31. package/dist/dependency-order.d.ts +2 -0
  32. package/dist/dependency-order.js +24 -0
  33. package/dist/index.d.ts +18 -1
  34. package/dist/index.js +12 -1
  35. package/dist/managed-hosting.d.ts +49 -0
  36. package/dist/managed-hosting.js +373 -0
  37. package/dist/management.d.ts +3 -0
  38. package/dist/management.js +47 -0
  39. package/dist/platforms/desktop.d.ts +2 -2
  40. package/dist/platforms/desktop.js +22 -12
  41. package/dist/platforms/extension.d.ts +2 -2
  42. package/dist/platforms/extension.js +12 -4
  43. package/dist/platforms/mobile.d.ts +2 -2
  44. package/dist/platforms/mobile.js +13 -7
  45. package/dist/pnpm-policy.d.ts +4 -0
  46. package/dist/pnpm-policy.js +66 -0
  47. package/dist/project-state.d.ts +28 -0
  48. package/dist/project-state.js +226 -0
  49. package/dist/provider-readiness.d.ts +30 -0
  50. package/dist/provider-readiness.js +87 -0
  51. package/dist/provision.d.ts +46 -0
  52. package/dist/provision.js +197 -0
  53. package/dist/readiness.d.ts +4 -1
  54. package/dist/readiness.js +80 -8
  55. package/dist/registry/add.d.ts +22 -0
  56. package/dist/registry/add.js +198 -0
  57. package/dist/registry/bootstrap.d.ts +2 -0
  58. package/dist/registry/bootstrap.js +14 -0
  59. package/dist/registry/commands.d.ts +2 -0
  60. package/dist/registry/commands.js +99 -0
  61. package/dist/registry/context.d.ts +3 -0
  62. package/dist/registry/context.js +21 -0
  63. package/dist/registry/options.d.ts +2 -0
  64. package/dist/registry/options.js +65 -0
  65. package/dist/registry/receipt.d.ts +3 -0
  66. package/dist/registry/receipt.js +73 -0
  67. package/dist/registry/remote.d.ts +25 -0
  68. package/dist/registry/remote.js +168 -0
  69. package/dist/registry/resolve.d.ts +10 -0
  70. package/dist/registry/resolve.js +98 -0
  71. package/dist/registry/types.d.ts +64 -0
  72. package/dist/registry/usage.d.ts +1 -0
  73. package/dist/registry/usage.js +38 -0
  74. package/dist/render.js +3 -3
  75. package/dist/repository.d.ts +24 -0
  76. package/dist/repository.js +96 -0
  77. package/dist/scaffold/api.d.ts +2 -0
  78. package/dist/scaffold/api.js +13 -0
  79. package/dist/scaffold/auth.d.ts +3 -0
  80. package/dist/scaffold/auth.js +95 -0
  81. package/dist/scaffold/bootstrap.d.ts +2 -0
  82. package/dist/scaffold/bootstrap.js +34 -0
  83. package/dist/scaffold/compose.d.ts +4 -0
  84. package/dist/scaffold/compose.js +34 -0
  85. package/dist/scaffold/database.d.ts +2 -0
  86. package/dist/scaffold/database.js +43 -0
  87. package/dist/scaffold/documentation.d.ts +3 -0
  88. package/dist/scaffold/documentation.js +91 -0
  89. package/dist/scaffold/environment.d.ts +2 -0
  90. package/dist/scaffold/environment.js +5 -0
  91. package/dist/scaffold/files.d.ts +25 -0
  92. package/dist/scaffold/files.js +108 -0
  93. package/dist/scaffold/integrations.d.ts +3 -0
  94. package/dist/scaffold/integrations.js +7 -0
  95. package/dist/scaffold/services.d.ts +5 -0
  96. package/dist/scaffold/services.js +84 -0
  97. package/dist/scaffold/web.d.ts +2 -0
  98. package/dist/scaffold/web.js +102 -0
  99. package/dist/scaffold/workspace.d.ts +3 -0
  100. package/dist/scaffold/workspace.js +150 -0
  101. package/dist/security-backports.d.ts +3 -0
  102. package/dist/security-backports.js +26 -0
  103. package/dist/stack-registry.d.ts +80 -0
  104. package/dist/stack-registry.js +424 -0
  105. package/dist/standard.d.ts +6 -17
  106. package/dist/standard.js +41 -585
  107. package/dist/transaction.js +46 -26
  108. package/dist/util.js +12 -2
  109. package/dist/vercel.d.ts +98 -0
  110. package/dist/vercel.js +796 -0
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. package/dist/vinext.d.ts +1 -0
  114. package/dist/vinext.js +83 -0
  115. package/dist/workspace-graph.d.ts +7 -0
  116. package/dist/workspace-graph.js +73 -0
  117. package/docs/application-stack-standard.md +75 -674
  118. package/docs/delivery-workflow.md +83 -0
  119. package/docs/managed-hosting-architecture.md +87 -0
  120. package/docs/sponsorships.md +25 -0
  121. package/package.json +23 -28
  122. package/dist/project.d.ts +0 -4
  123. package/dist/standard-products.d.ts +0 -2
  124. package/dist/standard-products.js +0 -6
  125. /package/assets/{images/svg/logo-white-subtract.svg → anhedral-mark.svg} +0 -0
  126. /package/dist/{project.js → registry/types.js} +0 -0
package/dist/vercel.js ADDED
@@ -0,0 +1,796 @@
1
+ import { githubRepository } from './repository.js';
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
3
+ import { randomUUID } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { atomicJson, defaultRunner, withApplicationLock, } from './project-state.js';
7
+ // Keep provider commands reproducible independently of globally installed tooling.
8
+ const cli = ['dlx', 'vercel@62.7.0'];
9
+ const manifest = 'anhedral.web.json';
10
+ function webDirectory(root) {
11
+ root = realpathSync(root);
12
+ const directory = realpathSync(path.join(root, 'apps/web'));
13
+ if (!directory.startsWith(root + path.sep))
14
+ throw new Error('Web directory escapes the application.');
15
+ return directory;
16
+ }
17
+ export function registerVercelApplication(root) {
18
+ root = realpathSync(root);
19
+ webDirectory(root);
20
+ if (existsSync(path.join(root, manifest)))
21
+ return readWeb(root);
22
+ const setup = JSON.parse(readFileSync(path.join(root, 'anhedral.setup.json'), 'utf8'));
23
+ if (setup.hosting !== 'vercel')
24
+ throw new Error('This application did not select Vercel web hosting.');
25
+ const name = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')).name;
26
+ if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(name))
27
+ throw new Error('Invalid Vercel project name.');
28
+ const project = {
29
+ schemaVersion: 1,
30
+ id: randomUUID(),
31
+ name,
32
+ ownership: 'customer',
33
+ provider: 'vercel',
34
+ directory: 'apps/web',
35
+ teamId: null,
36
+ projectId: null,
37
+ scope: null,
38
+ audience: 'unconfigured',
39
+ use: 'unconfigured',
40
+ };
41
+ const ignore = path.join(root, '.gitignore');
42
+ let source = existsSync(ignore) ? readFileSync(ignore, 'utf8') : '';
43
+ for (const entry of ['.anhedral/', '.vercel/'])
44
+ if (!source.split(/\r?\n/).includes(entry))
45
+ source += (source && !source.endsWith('\n') ? '\n' : '') + entry + '\n';
46
+ writeFileSync(ignore, source);
47
+ atomicJson(path.join(root, manifest), project);
48
+ return project;
49
+ }
50
+ function readWeb(root) {
51
+ webDirectory(root);
52
+ const project = JSON.parse(readFileSync(path.join(root, manifest), 'utf8'));
53
+ if (project.schemaVersion !== 1 ||
54
+ !/^[a-f0-9-]{36}$/.test(project.id) ||
55
+ project.provider !== 'vercel' ||
56
+ project.ownership !== 'customer' ||
57
+ project.directory !== 'apps/web' ||
58
+ !/^[a-z0-9][a-z0-9-]{0,62}$/.test(project.name) ||
59
+ !['unconfigured', 'public', 'private'].includes(project.audience) ||
60
+ !['unconfigured', 'personal', 'commercial'].includes(project.use) ||
61
+ !(project.teamId === null || /^team_[a-zA-Z0-9]+$/.test(project.teamId)) ||
62
+ !(project.projectId === null || /^prj_[a-zA-Z0-9]+$/.test(project.projectId)) ||
63
+ !(project.scope === null || /^[a-zA-Z0-9_-]+$/.test(project.scope)) ||
64
+ (project.repository !== undefined &&
65
+ (!Number.isSafeInteger(project.repository?.repoId) ||
66
+ project.repository.repoId <= 0 ||
67
+ !/^[a-zA-Z0-9-]+\/[a-zA-Z0-9_.-]+$/.test(project.repository.nameWithOwner) ||
68
+ (project.repository.productionBranch !== undefined &&
69
+ !validBranch(project.repository.productionBranch)))))
70
+ throw new Error('Invalid Vercel application manifest.');
71
+ return project;
72
+ }
73
+ function env(project) {
74
+ if (project &&
75
+ ((process.env.VERCEL_PROJECT_ID && process.env.VERCEL_PROJECT_ID !== project.projectId) ||
76
+ (process.env.VERCEL_ORG_ID && process.env.VERCEL_ORG_ID !== project.teamId)))
77
+ throw new Error('Vercel environment selects a different project/account.');
78
+ return {
79
+ ...process.env,
80
+ CI: '1',
81
+ NO_COLOR: '1',
82
+ ...(project?.projectId
83
+ ? { VERCEL_PROJECT_ID: project.projectId, VERCEL_ORG_ID: project.teamId }
84
+ : {}),
85
+ };
86
+ }
87
+ function context(root, project) {
88
+ const file = path.join(root, '.vercel/project.json');
89
+ if (existsSync(path.join(root, '.vercel/repo.json')))
90
+ throw new Error('Reconcile existing Vercel repository links explicitly before using automatic delivery.');
91
+ if (!existsSync(file))
92
+ throw new Error('Run anhedral connect to link the selected Vercel project.');
93
+ if (realpathSync(path.dirname(file)) !== path.join(realpathSync(root), '.vercel'))
94
+ throw new Error('Vercel link directory must not be a symbolic link.');
95
+ const link = JSON.parse(readFileSync(file, 'utf8'));
96
+ if (link.orgId !== project.teamId || link.projectId !== project.projectId)
97
+ throw new Error('Local Vercel link differs from the recorded customer account/project.');
98
+ }
99
+ async function api(root, run, endpoint, scope, body, method = 'POST') {
100
+ if (!/^\/v\d+\//.test(endpoint) || endpoint.includes('..') || endpoint.includes('#'))
101
+ throw new Error('Invalid Vercel API path.');
102
+ const temporary = path.join(tmpdir(), `anhedral-vercel-${randomUUID()}.json`);
103
+ const args = [
104
+ ...cli,
105
+ 'api',
106
+ endpoint,
107
+ '--raw',
108
+ '--non-interactive',
109
+ ...(scope ? ['--scope', scope] : []),
110
+ ];
111
+ try {
112
+ if (body) {
113
+ writeFileSync(temporary, JSON.stringify(body), { mode: 0o600, flag: 'wx' });
114
+ args.push('-X', method, '--input', temporary);
115
+ }
116
+ const output = await run('pnpm', args, root, env());
117
+ let value;
118
+ try {
119
+ value = JSON.parse(output);
120
+ }
121
+ catch {
122
+ throw new Error('Vercel did not return valid provider JSON.');
123
+ }
124
+ if (!value || typeof value !== 'object' || value.error || value.status === 'error')
125
+ throw new Error('Vercel rejected the request; verify login, scope and project permissions.');
126
+ return value;
127
+ }
128
+ finally {
129
+ rmSync(temporary, { force: true });
130
+ }
131
+ }
132
+ async function paginated(root, run, endpoint, key, scope) {
133
+ const result = [];
134
+ let cursor;
135
+ for (let page = 0; page < 1000; page++) {
136
+ const data = await api(root, run, `${endpoint}${endpoint.includes('?') ? '&' : '?'}limit=100${cursor ? `&until=${cursor}` : ''}`, scope);
137
+ if (!Array.isArray(data[key]))
138
+ throw new Error('Invalid Vercel provider listing.');
139
+ result.push(...data[key]);
140
+ if (data[key].length === 0)
141
+ return result;
142
+ const next = data.pagination?.next;
143
+ if (next === null || next === undefined)
144
+ return result;
145
+ if (typeof next !== 'number' || !Number.isFinite(next) || next === cursor)
146
+ throw new Error('Invalid Vercel pagination cursor.');
147
+ cursor = next;
148
+ }
149
+ throw new Error('Vercel listing exceeded pagination bounds.');
150
+ }
151
+ function protection(project, provider) {
152
+ const all = provider.ssoProtection?.deploymentType === 'all';
153
+ if (project.audience === 'private' && !all)
154
+ throw new Error('Private Vercel hosting requires verified protection for all deployments, including production. Configure Vercel Authentication for every deployment in the selected project; protection was not disabled or purchased.');
155
+ if (project.audience === 'public' && (all || provider.passwordProtection))
156
+ throw new Error('Provider production protection differs from the requested public audience. Reconcile it explicitly.');
157
+ }
158
+ function validateProvider(project, value) {
159
+ if (value.id !== project.projectId ||
160
+ value.accountId !== project.teamId ||
161
+ value.name !== project.name ||
162
+ value.framework !== 'nextjs' ||
163
+ value.rootDirectory !== project.directory ||
164
+ value.sourceFilesOutsideRootDirectory !== true)
165
+ throw new Error('Vercel provider account, framework or monorepo root differs from the manifest.');
166
+ protection(project, value);
167
+ if (project.repository &&
168
+ (!matchesRepository(value.link, project.repository) ||
169
+ (project.repository.productionBranch !== undefined &&
170
+ value.link?.productionBranch !== project.repository.productionBranch)))
171
+ throw new Error('Vercel Git repository differs from the recorded source repository. Reconcile it explicitly.');
172
+ }
173
+ function matchesRepository(link, repository) {
174
+ const [owner, name] = repository.nameWithOwner.split('/');
175
+ return (link?.type === 'github' &&
176
+ link.repoId === repository.repoId &&
177
+ link.org?.toLowerCase() === owner.toLowerCase() &&
178
+ link.repo?.toLowerCase() === name.toLowerCase());
179
+ }
180
+ async function inspect(root, project, run) {
181
+ context(root, project);
182
+ env(project);
183
+ const team = await api(root, run, `/v2/teams/${project.teamId}`, project.teamId);
184
+ if (team.id !== project.teamId ||
185
+ !['hobby', 'pro', 'enterprise'].includes(team.billing?.plan) ||
186
+ (project.use === 'commercial' && team.billing.plan === 'hobby'))
187
+ throw new Error('Selected Vercel account plan no longer supports the recorded application use.');
188
+ const linked = JSON.parse(await run('pnpm', [...cli, 'project', 'inspect', '--json', '--non-interactive', '--scope', project.teamId], root, env(project)));
189
+ if (linked.id !== project.projectId || linked.owner?.slug !== project.scope)
190
+ throw new Error('Vercel CLI resolved a different project/account.');
191
+ const provider = await api(root, run, `/v9/projects/${project.projectId}?teamId=${project.teamId}`, project.teamId);
192
+ validateProvider(project, provider);
193
+ if (project.repository) {
194
+ const repository = await githubRepository(root, run);
195
+ if (repository.repoId !== project.repository.repoId ||
196
+ repository.nameWithOwner.toLowerCase() !== project.repository.nameWithOwner.toLowerCase())
197
+ throw new Error('GitHub origin differs from the recorded source repository. Reconcile it explicitly.');
198
+ }
199
+ return provider;
200
+ }
201
+ export async function connectVercelApplication(root, options, run = defaultRunner) {
202
+ root = realpathSync(root);
203
+ return withApplicationLock(root, async () => {
204
+ const project = readWeb(root);
205
+ const repository = options.git ? await githubRepository(root, run) : undefined;
206
+ if (project.repository &&
207
+ repository &&
208
+ (project.repository.repoId !== repository.repoId ||
209
+ project.repository.nameWithOwner.toLowerCase() !== repository.nameWithOwner.toLowerCase()))
210
+ throw new Error('GitHub origin differs from the recorded source repository. Reconcile it explicitly.');
211
+ if (existsSync(path.join(root, '.vercel/repo.json')))
212
+ throw new Error('Existing repository links require explicit reconciliation.');
213
+ if (existsSync(path.join(root, '.vercel/project.json')) && !project.projectId)
214
+ throw new Error('Reconcile an existing Vercel project link before automatic connection.');
215
+ if (project.audience !== 'unconfigured' &&
216
+ (project.audience !== options.audience || project.use !== options.use))
217
+ throw new Error('Reconcile an established Vercel audience/use explicitly.');
218
+ const teams = await paginated(root, run, '/v2/teams', 'teams');
219
+ const matches = options.scope
220
+ ? teams.filter((team) => team.id === options.scope || team.slug === options.scope)
221
+ : teams;
222
+ if (matches.length !== 1 || !/^team_[a-zA-Z0-9]+$/.test(matches[0].id))
223
+ throw new Error('Select one authorized Vercel team with --scope; account selection is ambiguous or unavailable.');
224
+ const team = await api(root, run, `/v2/teams/${matches[0].id}`, matches[0].id);
225
+ if (team.id !== matches[0].id || typeof team.slug !== 'string')
226
+ throw new Error('Invalid Vercel account identity.');
227
+ const plan = team.billing?.plan;
228
+ if (!['hobby', 'pro', 'enterprise'].includes(plan))
229
+ throw new Error('Cannot verify the selected Vercel plan.');
230
+ if (options.use === 'commercial' && plan === 'hobby')
231
+ throw new Error('Vercel Hobby is for personal non-commercial use. Select an existing suitable Pro/Enterprise account; no paid upgrade was activated.');
232
+ if (project.teamId && project.teamId !== team.id)
233
+ throw new Error('Cannot silently rebind a Vercel application to another account.');
234
+ const projects = await paginated(root, run, `/v9/projects?teamId=${team.id}`, 'projects', team.id);
235
+ const candidates = projects.filter((item) => project.projectId ? item.id === project.projectId : item.name === project.name);
236
+ if (candidates.length > 1)
237
+ throw new Error('Ambiguous Vercel project identity.');
238
+ let provider = candidates[0];
239
+ if (!provider) {
240
+ if (project.projectId || !options.create)
241
+ throw new Error('Selected Vercel project is missing; use --create only for an authorized new project.');
242
+ // First-class project creation does not expose monorepo root/framework settings.
243
+ provider = await api(root, run, `/v11/projects?teamId=${team.id}`, team.id, {
244
+ name: project.name,
245
+ framework: 'nextjs',
246
+ rootDirectory: 'apps/web',
247
+ });
248
+ if (!/^prj_[a-zA-Z0-9]+$/.test(provider.id))
249
+ throw new Error('Provider did not return a Vercel project identity.');
250
+ atomicJson(path.join(root, '.anhedral/vercel-creation.json'), {
251
+ projectId: provider.id,
252
+ teamId: team.id,
253
+ name: project.name,
254
+ });
255
+ provider = await api(root, run, `/v9/projects/${provider.id}?teamId=${team.id}`, team.id, {
256
+ sourceFilesOutsideRootDirectory: true,
257
+ ...(options.audience === 'private' ? { ssoProtection: { deploymentType: 'all' } } : {}),
258
+ }, 'PATCH');
259
+ }
260
+ const connected = {
261
+ ...project,
262
+ projectId: provider.id,
263
+ teamId: team.id,
264
+ scope: team.slug,
265
+ audience: options.audience,
266
+ use: options.use,
267
+ };
268
+ validateProvider(connected, provider);
269
+ if (existsSync(path.join(root, '.vercel/project.json')))
270
+ context(root, connected);
271
+ if (existsSync(path.join(root, '.vercel/repo.json')))
272
+ throw new Error('Existing repository links require explicit reconciliation.');
273
+ // Environment pins make Vercel skip writing its local link. Use explicit CLI targets during link, then pin subsequent commands.
274
+ const linkingEnv = env(connected);
275
+ delete linkingEnv.VERCEL_PROJECT_ID;
276
+ delete linkingEnv.VERCEL_ORG_ID;
277
+ atomicJson(path.join(root, manifest), connected);
278
+ await run('pnpm', [
279
+ ...cli,
280
+ 'link',
281
+ '--yes',
282
+ '--project',
283
+ connected.projectId,
284
+ '--scope',
285
+ connected.teamId,
286
+ '--non-interactive',
287
+ ], root, linkingEnv);
288
+ const inspected = await inspect(root, connected, run);
289
+ atomicJson(path.join(root, manifest), connected);
290
+ if (repository) {
291
+ const matches = (link) => matchesRepository(link, repository);
292
+ if (inspected.link && !matches(inspected.link))
293
+ throw new Error('Vercel already connects a different Git repository; reconcile it explicitly.');
294
+ if (!inspected.link) {
295
+ try {
296
+ await run('pnpm', [
297
+ ...cli,
298
+ 'git',
299
+ 'connect',
300
+ repository.url,
301
+ '--yes',
302
+ '--project',
303
+ connected.projectId,
304
+ '--scope',
305
+ connected.teamId,
306
+ '--non-interactive',
307
+ ], root, env(connected));
308
+ }
309
+ catch {
310
+ throw new Error('Vercel could not connect the GitHub repository. Authorize the Vercel GitHub installation for this repository and retry connect --git; the verified project connection was retained.');
311
+ }
312
+ }
313
+ if (!matches((await inspect(root, connected, run)).link))
314
+ throw new Error('Vercel did not confirm the selected GitHub repository connection. Check the Vercel GitHub installation access.');
315
+ const linked = (await inspect(root, connected, run)).link;
316
+ if (linked?.productionBranch !== undefined && !validBranch(linked.productionBranch))
317
+ throw new Error('Invalid provider production branch.');
318
+ connected.repository = {
319
+ repoId: repository.repoId,
320
+ nameWithOwner: repository.nameWithOwner,
321
+ ...(linked?.productionBranch ? { productionBranch: linked.productionBranch } : {}),
322
+ };
323
+ }
324
+ atomicJson(path.join(root, manifest), connected);
325
+ return { project: connected, plan, planActivated: false };
326
+ });
327
+ }
328
+ function history(root, project) {
329
+ const file = path.join(root, '.anhedral/web-releases.json');
330
+ const value = existsSync(file)
331
+ ? JSON.parse(readFileSync(file, 'utf8'))
332
+ : { schemaVersion: 1, projectId: project.id, releases: [] };
333
+ if (value.schemaVersion !== 1 || value.projectId !== project.id || !Array.isArray(value.releases))
334
+ throw new Error('Vercel release history differs from project identity.');
335
+ return value;
336
+ }
337
+ async function source(root, run) {
338
+ if (realpathSync((await run('git', ['rev-parse', '--show-toplevel'], root, env())).trim()) !==
339
+ root ||
340
+ (await run('git', ['status', '--porcelain'], root, env())).trim())
341
+ throw new Error('Commit the application and manifest in their own repository before delivery/verification.');
342
+ const commit = (await run('git', ['rev-parse', 'HEAD'], root, env())).trim();
343
+ if (!/^[a-f0-9]{40,64}$/.test(commit))
344
+ throw new Error('Invalid source commit.');
345
+ return commit;
346
+ }
347
+ function https(value) {
348
+ const url = new URL(value.startsWith('https://') ? value : `https://${value}`);
349
+ if (url.protocol !== 'https:' ||
350
+ url.username ||
351
+ url.password ||
352
+ url.port ||
353
+ url.search ||
354
+ url.hash ||
355
+ url.pathname !== '/')
356
+ throw new Error('Invalid provider deployment URL.');
357
+ return url.origin;
358
+ }
359
+ function validBranch(branch) {
360
+ return (typeof branch === 'string' &&
361
+ branch.length > 0 &&
362
+ branch.length < 256 &&
363
+ !branch.startsWith('-') &&
364
+ !branch.startsWith('/') &&
365
+ !branch.endsWith('/') &&
366
+ !branch.endsWith('.') &&
367
+ !branch.includes('..') &&
368
+ !branch.includes('@{') &&
369
+ !/[\x00-\x20\x7f~^:?*\\[\]]/.test(branch) &&
370
+ !branch.split('/').some((part) => !part || part.startsWith('.') || part.endsWith('.lock')));
371
+ }
372
+ function deployment(project, value, commit, provenance = 'cli', branch) {
373
+ if (!/^[a-f0-9]{40,64}$/.test(commit) ||
374
+ !/^dpl_[a-zA-Z0-9]+$/.test(value.id) ||
375
+ value.projectId !== project.projectId ||
376
+ value.ownerId !== project.teamId ||
377
+ value.readyState !== 'READY' ||
378
+ value.target !== 'production')
379
+ throw new Error('Vercel deployment is not ready or differs from account/project/source provenance.');
380
+ if (provenance === 'git') {
381
+ const repository = project.repository;
382
+ const git = value.gitSource;
383
+ const identity = git?.repoId !== undefined && String(git.repoId) === String(repository?.repoId);
384
+ if (!repository ||
385
+ !branch ||
386
+ !validBranch(branch) ||
387
+ git?.type !== 'github' ||
388
+ !identity ||
389
+ git.sha !== commit ||
390
+ git.ref !== branch ||
391
+ git.prId != null)
392
+ throw new Error('Git deployment differs from the recorded repository, production branch or source commit.');
393
+ }
394
+ else if (value.meta?.anhedralProject !== project.id || value.meta?.anhedralCommit !== commit)
395
+ throw new Error('Vercel deployment differs from recorded CLI provenance.');
396
+ https(value.url);
397
+ }
398
+ function validateRelease(project, value, release) {
399
+ deployment(project, value, release.commit, release.provenance ?? 'cli', release.branch);
400
+ if (value.id !== release.deploymentId ||
401
+ !release.deploymentUrl ||
402
+ https(value.url) !== https(release.deploymentUrl))
403
+ throw new Error('Recorded deployment identity or URL differs from provider evidence.');
404
+ }
405
+ async function committedManifest(root, project, run) {
406
+ const commit = await source(root, run);
407
+ let recorded;
408
+ try {
409
+ recorded = JSON.parse(await run('git', ['show', `HEAD:${manifest}`], root, env(project)));
410
+ }
411
+ catch {
412
+ throw new Error('Commit the Vercel identity manifest before adoption or recovery.');
413
+ }
414
+ if (JSON.stringify(recorded) !== JSON.stringify(project))
415
+ throw new Error('Committed Vercel identity differs from the application manifest.');
416
+ return commit;
417
+ }
418
+ async function remoteBranch(root, branch, commit, run) {
419
+ if (!validBranch(branch))
420
+ throw new Error('Record an explicit valid production branch before Git adoption.');
421
+ const ref = `refs/heads/${branch}`;
422
+ const output = (await run('git', ['ls-remote', '--heads', 'origin', ref], root, env())).trim();
423
+ const rows = output.split(/\r?\n/).filter(Boolean);
424
+ if (rows.length !== 1 || rows[0] !== `${commit}\t${ref}`)
425
+ throw new Error('Remote production branch does not match the exact clean source commit.');
426
+ }
427
+ async function adoptActive(root, project, run, operation) {
428
+ const commit = await committedManifest(root, project, run);
429
+ const original = JSON.stringify(project);
430
+ const provider = await inspect(root, project, run);
431
+ const id = provider.targets?.production?.id;
432
+ if (!id || !/^dpl_[a-zA-Z0-9]+$/.test(id))
433
+ throw new Error('No active production deployment is available to recover.');
434
+ const value = await api(root, run, `/v13/deployments/${id}?teamId=${project.teamId}`, project.teamId);
435
+ if (value.id !== id)
436
+ throw new Error('Provider returned a different active deployment identity.');
437
+ const provenance = value.gitSource?.type === 'github' ? 'git' : 'cli';
438
+ const branch = project.repository?.productionBranch;
439
+ if (operation === 'adopt' && provenance !== 'git')
440
+ throw new Error('Adoption requires a Git-driven deployment. Use recovery for CLI provenance.');
441
+ deployment(project, value, commit, provenance, branch);
442
+ if (provenance === 'git')
443
+ await remoteBranch(root, branch, commit, run);
444
+ const records = history(root, project);
445
+ const latest = records.releases.at(-1);
446
+ if (latest?.state === 'deployed' && latest.deploymentId === id && latest.commit === commit) {
447
+ validateRelease(project, value, latest);
448
+ if ((await committedManifest(root, project, run)) !== commit ||
449
+ JSON.stringify(readWeb(root)) !== original ||
450
+ (await inspect(root, project, run)).targets?.production?.id !== id)
451
+ throw new Error('Source or production changed during adoption/recovery refresh.');
452
+ const refreshed = await api(root, run, `/v13/deployments/${id}?teamId=${project.teamId}`, project.teamId);
453
+ validateRelease(project, refreshed, latest);
454
+ if (provenance === 'git')
455
+ await remoteBranch(root, branch, commit, run);
456
+ return {
457
+ projectId: project.id,
458
+ release: latest,
459
+ adopted: false,
460
+ productVerification: latest.verification?.state ?? 'required',
461
+ };
462
+ }
463
+ await run('pnpm', ['run', 'check'], root, env(project));
464
+ if ((await committedManifest(root, project, run)) !== commit ||
465
+ JSON.stringify(readWeb(root)) !== original ||
466
+ (await inspect(root, project, run)).targets?.production?.id !== id)
467
+ throw new Error('Source or production changed during adoption/recovery checks.');
468
+ const confirmed = await api(root, run, `/v13/deployments/${id}?teamId=${project.teamId}`, project.teamId);
469
+ if (confirmed.id !== id)
470
+ throw new Error('Provider returned a different deployment during adoption/recovery.');
471
+ deployment(project, confirmed, commit, provenance, branch);
472
+ if (provenance === 'git')
473
+ await remoteBranch(root, branch, commit, run);
474
+ const url = https(confirmed.url);
475
+ const release = {
476
+ id: randomUUID(),
477
+ commit,
478
+ operation,
479
+ provenance,
480
+ ...(provenance === 'git' ? { branch } : {}),
481
+ state: 'deployed',
482
+ startedAt: new Date().toISOString(),
483
+ deploymentId: id,
484
+ deploymentUrl: url,
485
+ url,
486
+ };
487
+ records.releases.push(release);
488
+ atomicJson(path.join(root, '.anhedral/web-releases.json'), records);
489
+ return { projectId: project.id, release, adopted: true, productVerification: 'required' };
490
+ }
491
+ export async function adoptVercelApplication(root, run = defaultRunner) {
492
+ root = realpathSync(root);
493
+ const result = await withApplicationLock(root, async () => adoptActive(root, readWeb(root), run, 'adopt'));
494
+ writeVercelOverview(root, await vercelApplicationStatus(root));
495
+ return result;
496
+ }
497
+ export async function recoverVercelApplication(root, run = defaultRunner) {
498
+ root = realpathSync(root);
499
+ const result = await withApplicationLock(root, async () => {
500
+ const project = readWeb(root);
501
+ if (!project.projectId ||
502
+ !project.teamId ||
503
+ !project.scope ||
504
+ project.audience === 'unconfigured' ||
505
+ project.use === 'unconfigured')
506
+ throw new Error('Recovery requires a committed connected Vercel manifest. Retry connect for interrupted initial setup.');
507
+ await committedManifest(root, project, run);
508
+ env(project);
509
+ // Validate ownership/configuration from explicit API targets before repairing only the ignored local link.
510
+ const team = await api(root, run, `/v2/teams/${project.teamId}`, project.teamId);
511
+ if (team.id !== project.teamId ||
512
+ team.slug !== project.scope ||
513
+ !['hobby', 'pro', 'enterprise'].includes(team.billing?.plan) ||
514
+ (project.use === 'commercial' && team.billing.plan === 'hobby'))
515
+ throw new Error('Recovery account identity or plan differs from committed intent.');
516
+ const provider = await api(root, run, `/v9/projects/${project.projectId}?teamId=${project.teamId}`, project.teamId);
517
+ validateProvider(project, provider);
518
+ if (!existsSync(path.join(root, '.vercel/project.json'))) {
519
+ if (existsSync(path.join(root, '.vercel/repo.json')))
520
+ throw new Error('Reconcile existing Vercel repository links explicitly before recovery.');
521
+ const linkingEnv = env(project);
522
+ delete linkingEnv.VERCEL_PROJECT_ID;
523
+ delete linkingEnv.VERCEL_ORG_ID;
524
+ await run('pnpm', [
525
+ ...cli,
526
+ 'link',
527
+ '--yes',
528
+ '--project',
529
+ project.projectId,
530
+ '--scope',
531
+ project.teamId,
532
+ '--non-interactive',
533
+ ], root, linkingEnv);
534
+ }
535
+ return adoptActive(root, project, run, 'recover');
536
+ });
537
+ writeVercelOverview(root, await vercelApplicationStatus(root));
538
+ return result;
539
+ }
540
+ export function planVercelDelivery(root) {
541
+ const project = readWeb(root);
542
+ return {
543
+ project,
544
+ checks: ['pnpm run check'],
545
+ deployment: ['pnpm', ...cli, 'deploy', '--prod', '--yes'],
546
+ cloudflareServices: existsSync(path.join(root, 'anhedral.project.json'))
547
+ ? 'Deploy and verify separately with --provider cloudflare before shipping the web app.'
548
+ : 'none',
549
+ mutation: false,
550
+ };
551
+ }
552
+ export async function vercelApplicationStatus(root, refresh = false, run = defaultRunner) {
553
+ const project = readWeb(root);
554
+ const records = history(root, project);
555
+ const latest = records.releases.at(-1);
556
+ let activeRecordedVersion;
557
+ if (refresh) {
558
+ const provider = await inspect(root, project, run);
559
+ if (latest?.deploymentId) {
560
+ const value = await api(root, run, `/v13/deployments/${latest.deploymentId}?teamId=${project.teamId}`, project.teamId);
561
+ validateRelease(project, value, latest);
562
+ activeRecordedVersion = provider.targets?.production?.id === latest.deploymentId;
563
+ }
564
+ else
565
+ activeRecordedVersion = false;
566
+ }
567
+ return {
568
+ project,
569
+ releases: records.releases,
570
+ activeRecordedVersion,
571
+ evidence: refresh ? 'provider-refresh' : 'local-history',
572
+ productVerification: activeRecordedVersion === false && latest?.verification
573
+ ? 'stale'
574
+ : (latest?.verification?.state ?? 'unverified'),
575
+ management: project.scope ? `https://vercel.com/${project.scope}/${project.name}` : null,
576
+ };
577
+ }
578
+ export async function shipVercelApplication(root, run = defaultRunner) {
579
+ root = realpathSync(root);
580
+ const result = await withApplicationLock(root, async () => {
581
+ const project = readWeb(root);
582
+ if (!project.projectId || !project.teamId || project.audience === 'unconfigured')
583
+ throw new Error('Connect the intended Vercel account, plan and audience before shipping.');
584
+ await inspect(root, project, run);
585
+ const commit = await source(root, run);
586
+ const original = JSON.stringify(project);
587
+ const records = history(root, project);
588
+ const release = {
589
+ id: randomUUID(),
590
+ commit,
591
+ operation: 'ship',
592
+ provenance: 'cli',
593
+ state: 'running',
594
+ startedAt: new Date().toISOString(),
595
+ };
596
+ records.releases.push(release);
597
+ const save = () => atomicJson(path.join(root, '.anhedral/web-releases.json'), records);
598
+ save();
599
+ try {
600
+ await run('pnpm', ['run', 'check'], root, env(project));
601
+ if ((await source(root, run)) !== commit || JSON.stringify(readWeb(root)) !== original)
602
+ throw new Error('Source changed during Vercel checks.');
603
+ await inspect(root, project, run);
604
+ const output = await run('pnpm', [
605
+ ...cli,
606
+ 'deploy',
607
+ '--prod',
608
+ '--yes',
609
+ '--json',
610
+ '--non-interactive',
611
+ '--scope',
612
+ project.teamId,
613
+ '--meta',
614
+ `anhedralProject=${project.id}`,
615
+ '--meta',
616
+ `anhedralCommit=${commit}`,
617
+ ], root, env(project));
618
+ let receipt;
619
+ try {
620
+ receipt = JSON.parse(output);
621
+ }
622
+ catch {
623
+ throw new Error('Vercel CLI did not return a structured deployment receipt.');
624
+ }
625
+ if (receipt.status !== 'ok' ||
626
+ !receipt.deployment ||
627
+ receipt.deployment.readyState !== 'READY' ||
628
+ receipt.deployment.target !== 'production' ||
629
+ typeof receipt.deployment.url !== 'string')
630
+ throw new Error('Vercel CLI did not confirm a ready production deployment.');
631
+ const url = https(receipt.deployment.url);
632
+ if (!new URL(url).hostname.endsWith('.vercel.app'))
633
+ throw new Error('Vercel CLI did not return a deployment URL.');
634
+ const value = await api(root, run, `/v13/deployments/${encodeURIComponent(new URL(url).hostname)}?teamId=${project.teamId}`, project.teamId);
635
+ deployment(project, value, commit);
636
+ if (value.id !== receipt.deployment.id || https(value.url) !== url)
637
+ throw new Error('Deployment URL differs from the provider receipt.');
638
+ const productionUrl = typeof receipt.deployment.productionUrl === 'string'
639
+ ? https(receipt.deployment.productionUrl)
640
+ : undefined;
641
+ if (productionUrl && !value.alias?.some((alias) => https(alias) === productionUrl))
642
+ throw new Error('Production URL differs from the provider aliases.');
643
+ release.deploymentId = value.id;
644
+ release.deploymentUrl = url;
645
+ release.url = productionUrl ?? (value.alias?.length ? https(value.alias[0]) : url);
646
+ save();
647
+ const provider = await inspect(root, project, run);
648
+ if (provider.targets?.production?.id !== value.id ||
649
+ (await source(root, run)) !== commit ||
650
+ JSON.stringify(readWeb(root)) !== original)
651
+ throw new Error('Source or active Vercel production deployment changed during delivery.');
652
+ release.state = 'deployed';
653
+ save();
654
+ return { projectId: project.id, release, productVerification: 'required' };
655
+ }
656
+ catch (error) {
657
+ release.state = 'failed';
658
+ save();
659
+ throw error;
660
+ }
661
+ });
662
+ writeVercelOverview(root, await vercelApplicationStatus(root));
663
+ return result;
664
+ }
665
+ export async function verifyVercelApplication(root, run = defaultRunner) {
666
+ root = realpathSync(root);
667
+ const result = await withApplicationLock(root, async () => {
668
+ const snapshot = await vercelApplicationStatus(root, true, run);
669
+ const latest = snapshot.releases.at(-1);
670
+ if (!latest ||
671
+ latest.state !== 'deployed' ||
672
+ !snapshot.activeRecordedVersion ||
673
+ !latest.url ||
674
+ (await source(root, run)) !== latest.commit)
675
+ throw new Error('Live verification needs the clean source of the active recorded Vercel release.');
676
+ const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
677
+ if (typeof pkg.scripts?.['test:live'] !== 'string')
678
+ throw new Error('Implement meaningful application test:live checks before verification.');
679
+ const records = history(root, snapshot.project);
680
+ const release = records.releases.at(-1);
681
+ try {
682
+ await run('pnpm', ['run', 'test:live'], root, {
683
+ ...env(snapshot.project),
684
+ ANHEDRAL_APP_URL: latest.url,
685
+ ANHEDRAL_RELEASE_ID: latest.id,
686
+ });
687
+ if (!(await vercelApplicationStatus(root, true, run)).activeRecordedVersion ||
688
+ (await source(root, run)) !== latest.commit)
689
+ throw new Error('Source or production changed during live verification.');
690
+ release.verification = { state: 'passed', checkedAt: new Date().toISOString() };
691
+ }
692
+ catch (error) {
693
+ release.verification = { state: 'failed', checkedAt: new Date().toISOString() };
694
+ atomicJson(path.join(root, '.anhedral/web-releases.json'), records);
695
+ throw error;
696
+ }
697
+ atomicJson(path.join(root, '.anhedral/web-releases.json'), records);
698
+ return {
699
+ projectId: snapshot.project.id,
700
+ releaseId: latest.id,
701
+ verification: release.verification,
702
+ };
703
+ });
704
+ writeVercelOverview(root, await vercelApplicationStatus(root));
705
+ return result;
706
+ }
707
+ export async function rollbackVercelApplication(root, releaseId, dataCompatible, run = defaultRunner) {
708
+ root = realpathSync(root);
709
+ if (!dataCompatible)
710
+ throw new Error('Review schema/resource compatibility and pass --data-compatible. Vercel rollback restores code, not data.');
711
+ const result = await withApplicationLock(root, async () => {
712
+ const project = readWeb(root);
713
+ await inspect(root, project, run);
714
+ const records = history(root, project);
715
+ const desired = records.releases.find((item) => item.id === releaseId);
716
+ if (!desired ||
717
+ desired.state !== 'deployed' ||
718
+ !desired.deploymentId ||
719
+ !desired.deploymentUrl ||
720
+ !desired.url)
721
+ throw new Error('Choose a complete recorded Vercel release.');
722
+ validateRelease(project, await api(root, run, `/v13/deployments/${desired.deploymentId}?teamId=${project.teamId}`, project.teamId), desired);
723
+ const release = {
724
+ id: randomUUID(),
725
+ commit: desired.commit,
726
+ operation: 'rollback',
727
+ provenance: desired.provenance ?? 'cli',
728
+ ...(desired.branch ? { branch: desired.branch } : {}),
729
+ state: 'running',
730
+ startedAt: new Date().toISOString(),
731
+ restoredReleaseId: desired.id,
732
+ deploymentId: desired.deploymentId,
733
+ url: desired.url,
734
+ deploymentUrl: desired.deploymentUrl,
735
+ };
736
+ records.releases.push(release);
737
+ const save = () => atomicJson(path.join(root, '.anhedral/web-releases.json'), records);
738
+ save();
739
+ try {
740
+ await run('pnpm', [
741
+ ...cli,
742
+ 'rollback',
743
+ desired.deploymentUrl,
744
+ '--yes',
745
+ '--non-interactive',
746
+ '--scope',
747
+ project.teamId,
748
+ ], root, env(project));
749
+ if ((await inspect(root, project, run)).targets?.production?.id !== desired.deploymentId)
750
+ throw new Error('Vercel did not confirm the restored production version.');
751
+ release.state = 'deployed';
752
+ save();
753
+ return {
754
+ projectId: project.id,
755
+ release,
756
+ dataRestored: false,
757
+ productVerification: 'required',
758
+ automaticProductionUpdates: 'paused-by-provider-rollback',
759
+ nextAction: 'Review and promote a different deployment explicitly to resume automatic production domain assignment.',
760
+ };
761
+ }
762
+ catch (error) {
763
+ release.state = 'failed';
764
+ save();
765
+ throw error;
766
+ }
767
+ });
768
+ writeVercelOverview(root, await vercelApplicationStatus(root));
769
+ return result;
770
+ }
771
+ export function writeVercelOverview(root, snapshot) {
772
+ root = realpathSync(root);
773
+ const directory = path.join(root, '.anhedral');
774
+ mkdirSync(directory, { recursive: true });
775
+ if (realpathSync(directory) !== directory)
776
+ throw new Error('Management directory must not be a symbolic link.');
777
+ const escape = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
778
+ const rows = [...snapshot.releases]
779
+ .reverse()
780
+ .map((release) => `<tr><td>${escape(release.state)} · ${escape(release.operation)} · ${escape(release.provenance ?? 'cli')}</td><td>${escape(release.commit)}</td><td>${release.url ? `<a href="${escape(https(release.url))}" rel="noreferrer">Open app</a>` : 'No confirmed URL'}</td></tr>`)
781
+ .join('');
782
+ const file = path.join(directory, 'web-overview.html');
783
+ const temporary = `${file}.${randomUUID()}.tmp`;
784
+ const html = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><title>${escape(snapshot.project.name)} · Anhedral</title><style>body{font:16px system-ui;max-width:1000px;margin:3rem auto;padding:1rem}td{padding:1rem;overflow-wrap:anywhere}</style><h1>${escape(snapshot.project.name)}</h1><p>Customer-owned Vercel · ${escape(snapshot.project.audience)} · ${escape(snapshot.evidence)} · ${escape(new Date().toISOString())}</p><p>Active recorded production: ${escape(snapshot.activeRecordedVersion ?? 'Not refreshed')}. Product checks: ${escape(snapshot.productVerification)}.</p><p>Native Next.js web app. Cloudflare services, if selected, have separate provider release records.</p><table><tr><th>State</th><th>Source</th><th>App</th></tr>${rows}</table></html>`;
785
+ try {
786
+ writeFileSync(temporary, html, { mode: 0o600, flag: 'wx' });
787
+ return renameOverview(temporary, file);
788
+ }
789
+ finally {
790
+ rmSync(temporary, { force: true });
791
+ }
792
+ }
793
+ function renameOverview(temporary, file) {
794
+ renameSync(temporary, file);
795
+ return file;
796
+ }