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.
- package/README.md +140 -34
- package/assets/anhedral-cli-init-technical.svg +8 -8
- package/assets/anhedral-cli-init.svg +9 -9
- package/assets/security/LICENSES.txt +63 -0
- package/assets/security/audit-deps.mjs +295 -0
- package/assets/security/braces-regression.mjs +102 -0
- package/assets/security/braces@3.0.3.patch +285 -0
- package/assets/security/decode-uri-component@0.2.2.patch +149 -0
- package/assets/security/decoder-regression.mjs +37 -0
- package/assets/security/forge-regression.mjs +46 -0
- package/assets/security/manifest.json +53 -0
- package/assets/security/node-forge@1.4.0.patch +26 -0
- package/dist/access.d.ts +10 -0
- package/dist/access.js +93 -0
- package/dist/auth-schema-snapshot.d.ts +346 -0
- package/dist/auth-schema-snapshot.js +347 -0
- package/dist/auth-schema.d.ts +1 -0
- package/dist/auth-schema.js +57 -0
- package/dist/bin.js +353 -10
- package/dist/bounded-file.d.ts +2 -0
- package/dist/bounded-file.js +30 -0
- package/dist/capabilities.d.ts +19 -33
- package/dist/capabilities.js +73 -71
- package/dist/cloudflare.d.ts +14 -0
- package/dist/cloudflare.js +66 -0
- package/dist/command.js +12 -3
- package/dist/delivery.d.ts +118 -0
- package/dist/delivery.js +611 -0
- package/dist/dependencies.d.ts +22 -0
- package/dist/dependencies.js +109 -67
- package/dist/dependency-order.d.ts +2 -0
- package/dist/dependency-order.js +24 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +12 -1
- package/dist/managed-hosting.d.ts +49 -0
- package/dist/managed-hosting.js +373 -0
- package/dist/management.d.ts +3 -0
- package/dist/management.js +47 -0
- package/dist/platforms/desktop.d.ts +2 -2
- package/dist/platforms/desktop.js +22 -12
- package/dist/platforms/extension.d.ts +2 -2
- package/dist/platforms/extension.js +12 -4
- package/dist/platforms/mobile.d.ts +2 -2
- package/dist/platforms/mobile.js +13 -7
- package/dist/pnpm-policy.d.ts +4 -0
- package/dist/pnpm-policy.js +66 -0
- package/dist/project-state.d.ts +28 -0
- package/dist/project-state.js +226 -0
- package/dist/provider-readiness.d.ts +30 -0
- package/dist/provider-readiness.js +87 -0
- package/dist/provision.d.ts +46 -0
- package/dist/provision.js +197 -0
- package/dist/readiness.d.ts +4 -1
- package/dist/readiness.js +80 -8
- package/dist/registry/add.d.ts +22 -0
- package/dist/registry/add.js +198 -0
- package/dist/registry/bootstrap.d.ts +2 -0
- package/dist/registry/bootstrap.js +14 -0
- package/dist/registry/commands.d.ts +2 -0
- package/dist/registry/commands.js +99 -0
- package/dist/registry/context.d.ts +3 -0
- package/dist/registry/context.js +21 -0
- package/dist/registry/options.d.ts +2 -0
- package/dist/registry/options.js +65 -0
- package/dist/registry/receipt.d.ts +3 -0
- package/dist/registry/receipt.js +73 -0
- package/dist/registry/remote.d.ts +25 -0
- package/dist/registry/remote.js +168 -0
- package/dist/registry/resolve.d.ts +10 -0
- package/dist/registry/resolve.js +98 -0
- package/dist/registry/types.d.ts +64 -0
- package/dist/registry/usage.d.ts +1 -0
- package/dist/registry/usage.js +38 -0
- package/dist/render.js +3 -3
- package/dist/repository.d.ts +24 -0
- package/dist/repository.js +96 -0
- package/dist/scaffold/api.d.ts +2 -0
- package/dist/scaffold/api.js +13 -0
- package/dist/scaffold/auth.d.ts +3 -0
- package/dist/scaffold/auth.js +95 -0
- package/dist/scaffold/bootstrap.d.ts +2 -0
- package/dist/scaffold/bootstrap.js +34 -0
- package/dist/scaffold/compose.d.ts +4 -0
- package/dist/scaffold/compose.js +34 -0
- package/dist/scaffold/database.d.ts +2 -0
- package/dist/scaffold/database.js +43 -0
- package/dist/scaffold/documentation.d.ts +3 -0
- package/dist/scaffold/documentation.js +91 -0
- package/dist/scaffold/environment.d.ts +2 -0
- package/dist/scaffold/environment.js +5 -0
- package/dist/scaffold/files.d.ts +25 -0
- package/dist/scaffold/files.js +108 -0
- package/dist/scaffold/integrations.d.ts +3 -0
- package/dist/scaffold/integrations.js +7 -0
- package/dist/scaffold/services.d.ts +5 -0
- package/dist/scaffold/services.js +84 -0
- package/dist/scaffold/web.d.ts +2 -0
- package/dist/scaffold/web.js +102 -0
- package/dist/scaffold/workspace.d.ts +3 -0
- package/dist/scaffold/workspace.js +150 -0
- package/dist/security-backports.d.ts +3 -0
- package/dist/security-backports.js +26 -0
- package/dist/stack-registry.d.ts +80 -0
- package/dist/stack-registry.js +424 -0
- package/dist/standard.d.ts +6 -17
- package/dist/standard.js +41 -585
- package/dist/transaction.js +46 -26
- package/dist/util.js +12 -2
- package/dist/vercel.d.ts +98 -0
- package/dist/vercel.js +796 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/vinext.d.ts +1 -0
- package/dist/vinext.js +83 -0
- package/dist/workspace-graph.d.ts +7 -0
- package/dist/workspace-graph.js +73 -0
- package/docs/application-stack-standard.md +75 -674
- package/docs/delivery-workflow.md +83 -0
- package/docs/managed-hosting-architecture.md +87 -0
- package/docs/sponsorships.md +25 -0
- package/package.json +23 -28
- package/dist/project.d.ts +0 -4
- package/dist/standard-products.d.ts +0 -2
- package/dist/standard-products.js +0 -6
- /package/assets/{images/svg/logo-white-subtract.svg → anhedral-mark.svg} +0 -0
- /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
|
+
}
|
package/dist/readiness.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
4
|
-
import {
|
|
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 &&
|
|
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 {
|
|
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 ||
|
|
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) ||
|
|
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 ||
|
|
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' ||
|
|
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,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
|
+
}
|