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
@@ -0,0 +1,197 @@
1
+ import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { applyEdits, modify } from 'jsonc-parser';
4
+ import { atomicJson, authenticate, defaultRunner, environment, readApplication, readConfig, targetDirectory, withApplicationLock, } from './project-state.js';
5
+ import { cloudflareRequest, listCloudflare } from './cloudflare.js';
6
+ const resourceTypes = {
7
+ d1: { endpoint: '/d1/database', nameField: 'name', idField: 'uuid' },
8
+ r2: { endpoint: '/r2/buckets', nameField: 'name', idField: 'name' },
9
+ kv: { endpoint: '/storage/kv/namespaces', nameField: 'title', idField: 'id' },
10
+ queue: { endpoint: '/queues', nameField: 'queue_name', idField: 'queue_id' },
11
+ };
12
+ async function listResources(request, kind) {
13
+ const { endpoint } = resourceTypes[kind];
14
+ if (kind !== 'r2')
15
+ return listCloudflare(request, endpoint);
16
+ const value = (await request('GET', endpoint)).result;
17
+ if (!Array.isArray(value?.buckets) || value.truncated)
18
+ throw new Error('Unsupported or truncated R2 bucket listing; select resources explicitly with provider tooling.');
19
+ return value.buckets;
20
+ }
21
+ /** Provisioning and recovery use the same independently verified private-storage policy. */
22
+ async function assertPrivateBucket(request, name) {
23
+ const endpoint = `${resourceTypes.r2.endpoint}/${encodeURIComponent(name)}/domains`;
24
+ const managed = (await request('GET', `${endpoint}/managed`)).result;
25
+ const custom = (await request('GET', `${endpoint}/custom`)).result;
26
+ if (managed?.enabled !== false ||
27
+ !Array.isArray(custom?.domains) ||
28
+ custom.domains.some((domain) => domain.enabled !== false))
29
+ throw new Error('Selected R2 bucket has public or unknown domain access; resolve it before using private storage.');
30
+ }
31
+ function resourceName(value) {
32
+ if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value))
33
+ throw new Error('Resources require explicit lowercase names of at most 63 characters.');
34
+ return value;
35
+ }
36
+ export function planProvisioning(root) {
37
+ const project = readApplication(root);
38
+ const resources = [];
39
+ for (const target of project.targets) {
40
+ const config = readConfig(targetDirectory(root, target));
41
+ if ((config.env && Object.keys(config.env).length) ||
42
+ (config.account_id && config.account_id !== project.accountId))
43
+ throw new Error('Reconcile target account/environment before provisioning.');
44
+ for (const [kind, field] of [
45
+ ['d1', 'd1_databases'],
46
+ ['r2', 'r2_buckets'],
47
+ ['kv', 'kv_namespaces'],
48
+ ]) {
49
+ const entries = config[field];
50
+ if (entries === undefined)
51
+ continue;
52
+ if (!Array.isArray(entries))
53
+ throw new Error(`Invalid ${field} configuration.`);
54
+ entries.forEach((entry, index) => {
55
+ if (!entry || typeof entry.binding !== 'string' || entry.jurisdiction)
56
+ throw new Error('Use an explicit provider workflow for jurisdiction-specific resources.');
57
+ const name = resourceName(kind === 'd1'
58
+ ? entry.database_name
59
+ : kind === 'r2'
60
+ ? entry.bucket_name
61
+ : (entry.anhedral_name ?? `${project.name}-${entry.binding.toLowerCase()}`));
62
+ const id = kind === 'd1' ? entry.database_id : kind === 'kv' ? entry.id : undefined;
63
+ resources.push({
64
+ kind,
65
+ name,
66
+ target: target.id,
67
+ id: typeof id === 'string' && !id.startsWith('REPLACE_WITH_') ? id : undefined,
68
+ field: kind === 'r2' ? undefined : [field, index, kind === 'd1' ? 'database_id' : 'id'],
69
+ });
70
+ });
71
+ }
72
+ const queues = config.queues;
73
+ for (const entry of [...(queues?.producers ?? []), ...(queues?.consumers ?? [])]) {
74
+ resources.push({ kind: 'queue', name: resourceName(entry.queue), target: target.id });
75
+ if ('dead_letter_queue' in entry && entry.dead_letter_queue)
76
+ resources.push({
77
+ kind: 'queue',
78
+ name: resourceName(entry.dead_letter_queue),
79
+ target: target.id,
80
+ });
81
+ }
82
+ }
83
+ return {
84
+ projectId: project.id,
85
+ accountId: project.accountId,
86
+ resources,
87
+ externalIntegrations: ['hyperdrive', 'pipelines'].filter((key) => project.targets.some((target) => key in readConfig(targetDirectory(root, target)))),
88
+ };
89
+ }
90
+ export async function provisionApplication(root, dryRun = false, run = defaultRunner, suppliedRequest) {
91
+ root = realpathSync(root);
92
+ const plan = planProvisioning(root);
93
+ if (dryRun)
94
+ return { ...plan, mutation: false };
95
+ return withApplicationLock(root, async () => {
96
+ const project = readApplication(root);
97
+ const plan = planProvisioning(root);
98
+ const configured = new Map(project.targets.map((target) => {
99
+ const file = path.join(targetDirectory(root, target), 'wrangler.jsonc');
100
+ return [file, readFileSync(file, 'utf8')];
101
+ }));
102
+ const assertConfiguration = () => {
103
+ if (JSON.stringify(readApplication(root)) !== JSON.stringify(project) ||
104
+ [...configured].some(([file, content]) => readFileSync(file, 'utf8') !== content))
105
+ throw new Error('Configuration changed during provisioning; inspect resource receipts before retrying.');
106
+ };
107
+ await authenticate(root, project, run);
108
+ const request = suppliedRequest ??
109
+ (await cloudflareRequest(project, targetDirectory(root, project.targets[0]), run, environment(project)));
110
+ const receipts = [];
111
+ const resources = new Map();
112
+ for (const resource of plan.resources) {
113
+ assertConfiguration();
114
+ const key = `${resource.kind}:${resource.name}`;
115
+ let receipt = resources.get(key);
116
+ if (!receipt) {
117
+ const { endpoint, nameField, idField } = resourceTypes[resource.kind];
118
+ const existing = await listResources(request, resource.kind);
119
+ const matches = existing.filter((item) => item[nameField] === resource.name);
120
+ if (matches.length > 1)
121
+ throw new Error('Ambiguous provider resource name.');
122
+ let value = matches[0];
123
+ const reused = !!value;
124
+ if (resource.id && (!value || value[idField] !== resource.id))
125
+ throw new Error('Configured resource ID does not match its account/name; no replacement was created.');
126
+ assertConfiguration();
127
+ if (!value)
128
+ value = (await request('POST', endpoint, { [nameField]: resource.name }))
129
+ .result;
130
+ if (!value || typeof value[idField] !== 'string' || !value[idField])
131
+ throw new Error('Provider did not return a resource identity; inspect provider state before retrying.');
132
+ receipt = {
133
+ kind: resource.kind,
134
+ name: resource.name,
135
+ id: value[idField],
136
+ reused,
137
+ };
138
+ resources.set(key, receipt);
139
+ receipts.push(receipt);
140
+ // Save identities immediately: a subsequent operation can fail without losing creation evidence.
141
+ atomicJson(path.join(root, '.anhedral/resources.json'), {
142
+ schemaVersion: 1,
143
+ projectId: project.id,
144
+ accountId: project.accountId,
145
+ checkedAt: new Date().toISOString(),
146
+ resources: receipts,
147
+ state: 'reconciling',
148
+ });
149
+ if (resource.kind === 'r2')
150
+ await assertPrivateBucket(request, resource.name);
151
+ }
152
+ assertConfiguration();
153
+ if (resource.id && receipt.id !== resource.id)
154
+ throw new Error('Targets disagree about a shared resource ID.');
155
+ if (resource.field) {
156
+ const target = project.targets.find((item) => item.id === resource.target);
157
+ const file = path.join(targetDirectory(root, target), 'wrangler.jsonc');
158
+ const before = readFileSync(file, 'utf8');
159
+ const after = applyEdits(before, modify(before, resource.field, receipt.id, {
160
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
161
+ }));
162
+ writeFileSync(file, after);
163
+ configured.set(file, after);
164
+ }
165
+ }
166
+ const result = {
167
+ schemaVersion: 1,
168
+ projectId: project.id,
169
+ accountId: project.accountId,
170
+ checkedAt: new Date().toISOString(),
171
+ resources: receipts,
172
+ state: 'reconciled',
173
+ externalIntegrations: plan.externalIntegrations,
174
+ };
175
+ atomicJson(path.join(root, '.anhedral/resources.json'), result);
176
+ return result;
177
+ });
178
+ }
179
+ /** Reconcile committed identities against the customer account without creating resources or rewriting configuration. */
180
+ export async function inspectApplicationResources(root, request) {
181
+ const plan = planProvisioning(root);
182
+ const receipts = [];
183
+ for (const resource of plan.resources) {
184
+ const { nameField, idField } = resourceTypes[resource.kind];
185
+ const existing = await listResources(request, resource.kind);
186
+ const matches = existing.filter((item) => item[nameField] === resource.name);
187
+ if (matches.length !== 1 || typeof matches[0][idField] !== 'string' || !matches[0][idField])
188
+ throw new Error('Committed resource is missing or ambiguous in the registered account.');
189
+ const id = matches[0][idField];
190
+ if ((resource.field && !resource.id) || (resource.id && resource.id !== id))
191
+ throw new Error('Committed resource identity differs from the registered account; recovery will not replace it.');
192
+ if (resource.kind === 'r2')
193
+ await assertPrivateBucket(request, resource.name);
194
+ receipts.push({ kind: resource.kind, name: resource.name, id, target: resource.target });
195
+ }
196
+ return { resources: receipts, externalIntegrations: plan.externalIntegrations };
197
+ }
@@ -1,4 +1,5 @@
1
- import { type StandardProduct } from './standard-products.js';
1
+ import type { ResolvedStackRegistry } from './registry/resolve.js';
2
+ import { type StandardProduct } from './stack-registry.js';
2
3
  export declare function inspectProject(root: string): {
3
4
  schemaVersion: number;
4
5
  root: string;
@@ -17,4 +18,6 @@ export declare function validateSetupPlan(plan: unknown): asserts plan is {
17
18
  registryVersion: 1;
18
19
  products: StandardProduct[];
19
20
  hosting: 'cloudflare' | 'vercel';
21
+ name?: string;
22
+ registry: ResolvedStackRegistry;
20
23
  };
package/dist/readiness.js CHANGED
@@ -1,7 +1,11 @@
1
+ import { assertSafeTarget } from './registry/receipt.js';
1
2
  import { existsSync, readFileSync } from 'node:fs';
2
3
  import path from 'node:path';
3
- import { CAPABILITY_REGISTRY, resolveStandardProducts } from './capabilities.js';
4
- import { STANDARD_PRODUCTS } from './standard-products.js';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+ import { inspectWorkspaceGraph } from './workspace-graph.js';
6
+ import { resolveStackRegistry, resolveStandardProducts } from './registry/resolve.js';
7
+ import { CAPABILITY_REGISTRY } from './capabilities.js';
8
+ import { STANDARD_PRODUCTS } from './stack-registry.js';
5
9
  export function inspectProject(root) {
6
10
  const checks = [];
7
11
  const record = (id, ok, detail) => checks.push({ id, ok, detail });
@@ -13,21 +17,77 @@ export function inspectProject(root) {
13
17
  validateSetupPlan(plan);
14
18
  const standard = read('anhedral.standard.json');
15
19
  record('setup-plan', true, `Requirements recorded by CLI ${plan.cliVersion}`);
20
+ try {
21
+ const installed = read('anhedral.registry.json');
22
+ const { status, workspaces: _workspaces, files: _files, remoteItems, resourcesProvisioned, ...snapshot } = installed;
23
+ if (status !== 'scaffolded' ||
24
+ resourcesProvisioned !== false ||
25
+ !isDeepStrictEqual(snapshot, plan.registry))
26
+ throw new Error('Registry provenance differs from the setup plan.');
27
+ for (const item of plan.registry.items)
28
+ for (const target of item.targets) {
29
+ if (!existsSync(path.join(root, target)))
30
+ throw new Error(`Registry target missing: ${item.name}.`);
31
+ }
32
+ if (remoteItems !== undefined) {
33
+ if (!Array.isArray(remoteItems))
34
+ throw new Error('Invalid remote registry receipt.');
35
+ for (const item of remoteItems) {
36
+ if (!item ||
37
+ typeof item.source !== 'string' ||
38
+ typeof item.integrity !== 'string' ||
39
+ !Array.isArray(item.targets))
40
+ throw new Error('Invalid remote registry provenance.');
41
+ for (const target of item.targets) {
42
+ assertSafeTarget(root, target);
43
+ if (!existsSync(path.join(root, target)))
44
+ throw new Error('Missing remote registry target.');
45
+ }
46
+ }
47
+ }
48
+ record('stack-registry', true, 'Registry provenance and resolved targets match the setup plan');
49
+ }
50
+ catch {
51
+ record('stack-registry', false, 'Missing registry provenance or targets; inspect before regenerating');
52
+ }
53
+ try {
54
+ const workspaces = inspectWorkspaceGraph(root);
55
+ record('workspace-graph', workspaces.length > 0, 'Workspace names and dependencies resolve without cycles');
56
+ }
57
+ catch {
58
+ record('workspace-graph', false, 'Invalid workspace manifests, references or dependency cycles');
59
+ }
16
60
  record('cli-compatibility', plan.cliVersion === CAPABILITY_REGISTRY.cliVersion, 'Use the project CLI version or review upgrade differences');
17
- record('selection', standard.schemaVersion === 1 && JSON.stringify(standard.products) === JSON.stringify(plan.products) && standard.hosting === (plan.products.includes('next') ? plan.hosting : null), 'Setup plan matches project selection');
61
+ record('selection', standard.schemaVersion === 1 &&
62
+ JSON.stringify(standard.products) === JSON.stringify(plan.products) &&
63
+ standard.hosting ===
64
+ (plan.products.includes('next') || plan.products.includes('vinext')
65
+ ? plan.hosting
66
+ : null), 'Setup plan matches project selection');
18
67
  }
19
68
  catch {
20
69
  record('setup-plan', false, 'Missing, invalid or unsupported setup plan; inspect the project before changing it');
21
70
  }
22
- return { schemaVersion: 1, root, checks, localReady: checks.every((check) => check.ok), providerVerification: 'unverified', productionReady: false };
71
+ return {
72
+ schemaVersion: 1,
73
+ root,
74
+ checks,
75
+ localReady: checks.every((check) => check.ok),
76
+ providerVerification: 'unverified',
77
+ productionReady: false,
78
+ };
23
79
  }
24
80
  export function validateSetupPlan(plan) {
25
81
  if (!plan || typeof plan !== 'object')
26
82
  throw new Error('Invalid setup plan.');
27
83
  const value = plan;
28
- if (value.schemaVersion !== 1 || value.registryVersion !== 1 || typeof value.cliVersion !== 'string')
84
+ if (value.schemaVersion !== 1 ||
85
+ value.registryVersion !== 1 ||
86
+ typeof value.cliVersion !== 'string')
29
87
  throw new Error('Unsupported setup plan version.');
30
- if (!Array.isArray(value.products) || !value.products.length || !value.products.every((id) => STANDARD_PRODUCTS.includes(id)))
88
+ if (!Array.isArray(value.products) ||
89
+ !value.products.length ||
90
+ !value.products.every((id) => STANDARD_PRODUCTS.includes(id)))
31
91
  throw new Error('Unknown setup capabilities.');
32
92
  if (value.hosting !== 'cloudflare' && value.hosting !== 'vercel')
33
93
  throw new Error('Invalid hosting.');
@@ -36,12 +96,22 @@ export function validateSetupPlan(plan) {
36
96
  resolveStandardProducts(selected, value.hosting, false);
37
97
  if (selected.size !== value.products.length)
38
98
  throw new Error('Invalid or incomplete setup selection.');
99
+ if (value.name !== undefined && typeof value.name !== 'string')
100
+ throw new Error('Invalid setup project name.');
101
+ if (!isDeepStrictEqual(value.registry, resolveStackRegistry({
102
+ products: value.products,
103
+ hosting: value.hosting,
104
+ name: value.name,
105
+ })))
106
+ throw new Error('Invalid or inconsistent stack registry.');
39
107
  }
40
108
  function validateRequirements(setup, products) {
41
109
  if (!setup || typeof setup !== 'object')
42
110
  throw new Error('Missing setup requirements.');
43
111
  const value = setup;
44
- if (value.resourcesProvisioned !== false || !Array.isArray(value.capabilities) || value.capabilities.length !== products.length)
112
+ if (value.resourcesProvisioned !== false ||
113
+ !Array.isArray(value.capabilities) ||
114
+ value.capabilities.length !== products.length)
45
115
  throw new Error('Invalid capability requirements.');
46
116
  const ids = value.capabilities.map((item) => validateCapability(item));
47
117
  if (new Set(ids).size !== products.length || !products.every((id) => ids.includes(id)))
@@ -51,7 +121,9 @@ function validateCapability(item) {
51
121
  if (!item || typeof item !== 'object')
52
122
  throw new Error('Invalid capability.');
53
123
  const value = item;
54
- if (typeof value.id !== 'string' || typeof value.purpose !== 'string' || !['planned', 'starter'].includes(String(value.status)))
124
+ if (typeof value.id !== 'string' ||
125
+ typeof value.purpose !== 'string' ||
126
+ !['planned', 'starter'].includes(String(value.status)))
55
127
  throw new Error('Invalid capability metadata.');
56
128
  for (const key of ['tools', 'access', 'credentials', 'resources', 'verify']) {
57
129
  if (!Array.isArray(value[key]) || !value[key].every((entry) => typeof entry === 'string'))
@@ -0,0 +1,22 @@
1
+ import type { StandardRunner } from './types.js';
2
+ export interface AddRequest {
3
+ items: string[];
4
+ registry?: string;
5
+ integrity?: string;
6
+ dryRun?: boolean;
7
+ skipInstall?: boolean;
8
+ }
9
+ /** Add recipes to an initialized workspace, refusing edits to customer-owned source. */
10
+ export declare function addStackItems(root: string, request: AddRequest, run?: StandardRunner): Promise<{
11
+ files: string[];
12
+ conflicts: string[];
13
+ registry?: {
14
+ source: string;
15
+ integrity: string;
16
+ } | undefined;
17
+ schemaVersion: number;
18
+ root: string;
19
+ dryRun: boolean;
20
+ items: string[];
21
+ remoteItems: string[];
22
+ }>;
@@ -0,0 +1,198 @@
1
+ import { configureSecurityBackports } from '../security-backports.js';
2
+ import { bootstrapWorkspace } from '../scaffold/bootstrap.js';
3
+ import { registerApplication } from '../project-state.js';
4
+ import { registerVercelApplication } from '../vercel.js';
5
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { isDeepStrictEqual } from 'node:util';
8
+ import { applyEdits, modify, parse } from 'jsonc-parser';
9
+ import { STANDARD_PRODUCTS, installStackItem } from '../stack-registry.js';
10
+ import { inspectProject, validateSetupPlan } from '../readiness.js';
11
+ import { GENERATOR_VERSION } from '../version.js';
12
+ import { runStagedTransaction } from '../transaction.js';
13
+ import { inspectWorkspaceGraph } from '../workspace-graph.js';
14
+ import { execFile } from '../util.js';
15
+ import { json, put, readJson } from '../scaffold/files.js';
16
+ import { writeStandardRoot } from '../scaffold/workspace.js';
17
+ import { createRecipeContext } from './context.js';
18
+ import { resolveStackRegistry, resolveStandardProducts } from './resolve.js';
19
+ import { sourceFiles, assertSafeTarget } from './receipt.js';
20
+ import { loadSourceRegistry, resolveSourceItems } from './remote.js';
21
+ /** Add recipes to an initialized workspace, refusing edits to customer-owned source. */
22
+ export async function addStackItems(root, request, run = execFile) {
23
+ root = realpathSync(root);
24
+ if (!request.items.length)
25
+ throw new Error('Select at least one registry item.');
26
+ const plan = readJson(root, 'anhedral.setup.json');
27
+ validateSetupPlan(plan);
28
+ if (plan.cliVersion !== GENERATOR_VERSION)
29
+ throw new Error('Use the project CLI version before adding recipes.');
30
+ if (!inspectProject(root).localReady)
31
+ throw new Error('Run doctor and resolve project provenance or workspace errors before add.');
32
+ const receipt = readJson(root, 'anhedral.registry.json');
33
+ if (!receipt.files || typeof receipt.files !== 'object' || Array.isArray(receipt.files))
34
+ throw new Error('This project has no scaffold fingerprints; migrate it deliberately before using add.');
35
+ const baseline = receipt.files;
36
+ const selected = new Set(plan.products);
37
+ let remote;
38
+ if (request.registry)
39
+ remote = await loadSourceRegistry(request.registry);
40
+ else
41
+ for (const id of request.items) {
42
+ if (!STANDARD_PRODUCTS.includes(id))
43
+ throw new Error(`Unknown stack product: ${id}.`);
44
+ selected.add(id);
45
+ }
46
+ if (request.integrity && (!remote || remote.integrity !== request.integrity))
47
+ throw new Error('Source registry integrity differs from the reviewed payload.');
48
+ resolveStandardProducts(selected, plan.hosting, false);
49
+ const options = {
50
+ root,
51
+ name: plan.name ?? path.basename(root),
52
+ products: [...selected].sort(),
53
+ hosting: plan.hosting,
54
+ skipInstall: !!request.skipInstall,
55
+ dryRun: false,
56
+ json: true,
57
+ initializeGit: false,
58
+ };
59
+ const registry = resolveStackRegistry(options);
60
+ const oldItems = new Map(plan.registry.items.map((item) => [item.name, item]));
61
+ const recipes = registry.items.filter((item) => item.installer === 'recipe' && !isDeepStrictEqual(oldItems.get(item.name), item));
62
+ // Relocating auth, database or service ownership requires a migration, not an additive scaffold.
63
+ for (const item of registry.items) {
64
+ const before = oldItems.get(item.name);
65
+ if (before && before.targets.some((target) => !item.targets.includes(target)))
66
+ throw new Error(`Adding this selection relocates ${item.name}; migrate its existing source explicitly.`);
67
+ }
68
+ const remoteItems = remote ? resolveSourceItems(remote.registry, request.items) : [];
69
+ if (remote) {
70
+ const previous = Array.isArray(receipt.remoteItems) ? receipt.remoteItems : [];
71
+ for (const item of remoteItems) {
72
+ const identity = `${remote.source}#${item.name}`;
73
+ const targets = item.files.map((file) => file.target);
74
+ for (const installed of previous) {
75
+ if (installed.identity === identity &&
76
+ installed.targets.some((target) => !targets.includes(target)))
77
+ throw new Error(`Remote update relocates ${item.name}; migrate its source explicitly.`);
78
+ if (installed.identity !== identity &&
79
+ installed.targets.some((target) => targets.includes(target)))
80
+ throw new Error(`Remote target is already owned by ${installed.name}.`);
81
+ }
82
+ }
83
+ }
84
+ const before = sourceFiles(root);
85
+ const receiptText = readFileSync(path.join(root, 'anhedral.registry.json'), 'utf8');
86
+ const commitPaths = [];
87
+ const conflicts = [];
88
+ const result = {
89
+ schemaVersion: 1,
90
+ root,
91
+ dryRun: !!request.dryRun,
92
+ items: registry.items
93
+ .filter((item) => !isDeepStrictEqual(oldItems.get(item.name), item))
94
+ .map((item) => item.name),
95
+ remoteItems: remoteItems.map((item) => item.name),
96
+ ...(remote ? { registry: { source: remote.source, integrity: remote.integrity } } : {}),
97
+ files: commitPaths,
98
+ conflicts,
99
+ };
100
+ await runStagedTransaction(root, {
101
+ dryRun: !!request.dryRun,
102
+ commitPaths,
103
+ seedPaths: [...before.keys(), 'anhedral.registry.json'],
104
+ build: async (stage) => {
105
+ const context = createRecipeContext(stage, options);
106
+ for (const item of registry.items)
107
+ Object.assign(context.bindings, item.bindings);
108
+ if (recipes.some((item) => ['vinext', 'next'].includes(item.name)) &&
109
+ !existsSync(path.join(stage, 'apps/web/package.json')))
110
+ bootstrapWorkspace(stage, options.name, run, true);
111
+ for (const item of recipes)
112
+ await installStackItem(item, context);
113
+ // Configuration items affect the existing runtime without regenerating its application source.
114
+ const configurationItems = registry.items.filter((item) => !isDeepStrictEqual(oldItems.get(item.name), item) && item.bindings);
115
+ if (configurationItems.length) {
116
+ const file = `apps/${context.runtimeApp}/wrangler.jsonc`;
117
+ let content = readFileSync(path.join(stage, file), 'utf8');
118
+ const config = parse(content);
119
+ for (const item of configurationItems)
120
+ for (const [key, value] of Object.entries(item.bindings)) {
121
+ if (config[key] !== undefined && !isDeepStrictEqual(config[key], value))
122
+ throw new Error(`Existing ${key} configuration conflicts with ${item.name}; configure it explicitly.`);
123
+ content = applyEdits(content, modify(content, [key], value, {
124
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
125
+ }));
126
+ }
127
+ put(stage, file, content);
128
+ }
129
+ for (const item of remoteItems)
130
+ for (const file of item.files) {
131
+ assertSafeTarget(stage, file.target);
132
+ put(stage, file.target, file.content);
133
+ }
134
+ writeStandardRoot(stage, options, registry, true);
135
+ if (options.products.includes('next') && options.hosting === 'vercel')
136
+ registerVercelApplication(stage);
137
+ if ([...sourceFiles(stage).keys()].some((file) => /^apps\/[^/]+\/wrangler\.jsonc$/.test(file)))
138
+ registerApplication(stage, true);
139
+ if (!request.dryRun)
140
+ run('pnpm', ['install', '--lockfile-only', '--no-frozen-lockfile', '--ignore-scripts'], stage);
141
+ if (!request.dryRun && configureSecurityBackports(stage))
142
+ run('pnpm', ['install', '--lockfile-only', '--no-frozen-lockfile', '--ignore-scripts'], stage);
143
+ const after = sourceFiles(stage);
144
+ const files = { ...baseline };
145
+ for (const [file, hash] of after) {
146
+ if (before.get(file) === hash)
147
+ continue;
148
+ if (before.has(file) &&
149
+ baseline[file] !== before.get(file) &&
150
+ file !== 'anhedral.project.json')
151
+ conflicts.push(file);
152
+ commitPaths.push(file);
153
+ files[file] = hash;
154
+ }
155
+ // Never delete preexisting source as a side effect of an addition.
156
+ for (const file of before.keys())
157
+ if (!after.has(file))
158
+ conflicts.push(file);
159
+ if (conflicts.length && !request.dryRun)
160
+ throw new Error(`Addition conflicts with edited or untracked source: ${conflicts.join(', ')}. No files were installed.`);
161
+ const remoteReceipts = Array.isArray(receipt.remoteItems) ? [...receipt.remoteItems] : [];
162
+ for (const item of remoteItems) {
163
+ const identity = `${remote.source}#${item.name}`;
164
+ const previous = remoteReceipts.findIndex((entry) => entry.identity === identity);
165
+ const entry = {
166
+ identity,
167
+ name: item.name,
168
+ version: item.version,
169
+ source: remote.source,
170
+ integrity: remote.integrity,
171
+ targets: item.files.map((file) => file.target),
172
+ };
173
+ if (previous >= 0)
174
+ remoteReceipts[previous] = entry;
175
+ else
176
+ remoteReceipts.push(entry);
177
+ }
178
+ json(stage, 'anhedral.registry.json', {
179
+ ...registry,
180
+ status: 'scaffolded',
181
+ resourcesProvisioned: false,
182
+ workspaces: inspectWorkspaceGraph(stage),
183
+ files,
184
+ ...(remoteReceipts.length ? { remoteItems: remoteReceipts } : {}),
185
+ });
186
+ commitPaths.push('anhedral.registry.json');
187
+ // Detect concurrent changes even to source that this addition would otherwise preserve.
188
+ if (!isDeepStrictEqual(sourceFiles(root), before) ||
189
+ readFileSync(path.join(root, 'anhedral.registry.json'), 'utf8') !== receiptText)
190
+ throw new Error('Workspace changed during addition; retry after reviewing source.');
191
+ },
192
+ afterCommit: () => {
193
+ if (!request.skipInstall)
194
+ run('pnpm', ['install', '--frozen-lockfile', '--ignore-scripts'], root);
195
+ },
196
+ });
197
+ return result;
198
+ }
@@ -0,0 +1,2 @@
1
+ export declare const BOOTSTRAP_COMMAND: readonly ["pnpm", "dlx", "shadcn@4.21.4", "init", "--monorepo", "--template", "next"];
2
+ export declare function bootstrapArguments(name: string, cwd: string): string[];
@@ -0,0 +1,14 @@
1
+ import { SHADCN_VERSION } from '../dependencies.js';
2
+ const SHADCN_PACKAGE = `shadcn@${SHADCN_VERSION}`;
3
+ export const BOOTSTRAP_COMMAND = [
4
+ 'pnpm',
5
+ 'dlx',
6
+ SHADCN_PACKAGE,
7
+ 'init',
8
+ '--monorepo',
9
+ '--template',
10
+ 'next',
11
+ ];
12
+ export function bootstrapArguments(name, cwd) {
13
+ return [...BOOTSTRAP_COMMAND.slice(1), '--defaults', '--name', name, '--cwd', cwd];
14
+ }
@@ -0,0 +1,2 @@
1
+ export declare function runRegistry(args: string[], json: boolean): Promise<void>;
2
+ export declare function runAdd(args: string[]): Promise<void>;