anhedral 0.10.1 → 0.11.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 +77 -38
- package/assets/{anhedral-cli-init-technical.svg → anhedral-stack-technical.svg} +6 -6
- package/assets/{anhedral-cli-init.svg → anhedral-stack.svg} +7 -7
- package/dist/bin.js +271 -154
- package/dist/capabilities.d.ts +3 -3
- package/dist/capability-contract.d.ts +47 -0
- package/dist/capability-contract.js +136 -0
- package/dist/cli-arguments.d.ts +16 -0
- package/dist/cli-arguments.js +115 -0
- package/dist/cli-output.d.ts +8 -0
- package/dist/cli-output.js +211 -0
- package/dist/cloudflare.d.ts +7 -2
- package/dist/cloudflare.js +166 -36
- package/dist/command-schema.d.ts +21 -0
- package/dist/command-schema.js +374 -0
- package/dist/delivery.d.ts +2 -2
- package/dist/delivery.js +21 -4
- package/dist/index.d.ts +23 -1
- package/dist/index.js +15 -1
- package/dist/inspector-model.d.ts +115 -0
- package/dist/inspector-model.js +1 -0
- package/dist/lifecycle-model.d.ts +15 -0
- package/dist/lifecycle-model.js +20 -0
- package/dist/lifecycle.d.ts +12 -0
- package/dist/lifecycle.js +92 -0
- package/dist/mobile-delivery.d.ts +100 -0
- package/dist/mobile-delivery.js +319 -0
- package/dist/platforms/mobile.js +8 -5
- package/dist/project-inspector.d.ts +4 -0
- package/dist/project-inspector.js +646 -0
- package/dist/project-state.d.ts +17 -0
- package/dist/project-state.js +239 -11
- package/dist/provider-auth.d.ts +41 -0
- package/dist/provider-auth.js +210 -0
- package/dist/providers/adapters.d.ts +17 -0
- package/dist/providers/adapters.js +269 -0
- package/dist/providers/api.d.ts +28 -0
- package/dist/providers/api.js +252 -0
- package/dist/providers/config.d.ts +15 -0
- package/dist/providers/config.js +184 -0
- package/dist/providers/definitions.d.ts +318 -0
- package/dist/providers/definitions.js +127 -0
- package/dist/providers/provision.d.ts +24 -0
- package/dist/providers/provision.js +76 -0
- package/dist/providers/receipts.d.ts +20 -0
- package/dist/providers/receipts.js +66 -0
- package/dist/providers/recovery-verification.d.ts +7 -0
- package/dist/providers/recovery-verification.js +112 -0
- package/dist/providers/recovery.d.ts +30 -0
- package/dist/providers/recovery.js +34 -0
- package/dist/providers/runtime.d.ts +10 -0
- package/dist/providers/runtime.js +231 -0
- package/dist/providers/schema.d.ts +28 -0
- package/dist/providers/schema.js +49 -0
- package/dist/providers/state.d.ts +56 -0
- package/dist/providers/state.js +329 -0
- package/dist/provision.d.ts +1 -1
- package/dist/provision.js +61 -3
- package/dist/registry/add.d.ts +6 -0
- package/dist/registry/add.js +29 -1
- package/dist/registry/commands.d.ts +3 -2
- package/dist/registry/commands.js +53 -14
- package/dist/registry/options.js +3 -2
- package/dist/registry/preview.d.ts +11 -0
- package/dist/registry/preview.js +91 -0
- package/dist/registry/usage.d.ts +10 -0
- package/dist/registry/usage.js +22 -37
- package/dist/scaffold/auth.js +3 -19
- package/dist/scaffold/workspace.js +11 -0
- package/dist/ship.d.ts +320 -0
- package/dist/ship.js +211 -0
- package/dist/stack-registry.d.ts +2 -2
- package/dist/stack-registry.js +7 -1
- package/dist/standard.d.ts +2 -1
- package/dist/standard.js +11 -11
- package/dist/vercel.d.ts +3 -3
- package/dist/vercel.js +27 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/application-stack-standard.md +51 -80
- package/docs/cli-design.md +67 -0
- package/docs/delivery-workflow.md +29 -15
- package/docs/plugin-ui.md +61 -0
- package/docs/provider-configuration.md +117 -0
- package/package.json +16 -12
- package/dist/managed-hosting.d.ts +0 -49
- package/dist/managed-hosting.js +0 -373
- package/docs/managed-hosting-architecture.md +0 -87
- package/docs/sponsorships.md +0 -25
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { PROVIDER_DEFINITIONS, PROVIDER_EVENT_PATTERN, PROVIDER_IDENTITY_PATTERN, PROVIDER_TEXT_PATTERN, PROVIDER_WEBHOOK_PATTERN, } from './definitions.js';
|
|
2
|
+
function fieldSchema(field) {
|
|
3
|
+
if (field.kind === 'events')
|
|
4
|
+
return {
|
|
5
|
+
type: 'array',
|
|
6
|
+
minItems: 1,
|
|
7
|
+
maxItems: 100,
|
|
8
|
+
uniqueItems: true,
|
|
9
|
+
items: { type: 'string', pattern: PROVIDER_EVENT_PATTERN },
|
|
10
|
+
};
|
|
11
|
+
return {
|
|
12
|
+
type: 'string',
|
|
13
|
+
maxLength: 255,
|
|
14
|
+
...(field.choices
|
|
15
|
+
? { enum: field.choices }
|
|
16
|
+
: {
|
|
17
|
+
pattern: field.kind === 'identity'
|
|
18
|
+
? PROVIDER_IDENTITY_PATTERN
|
|
19
|
+
: field.kind === 'https-url'
|
|
20
|
+
? PROVIDER_WEBHOOK_PATTERN
|
|
21
|
+
: PROVIDER_TEXT_PATTERN,
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Editor schema validates syntax; selected-stack and provisioning readiness checks
|
|
26
|
+
* remain application rules, since templates deliberately contain empty choices. */
|
|
27
|
+
export const PROVIDER_CONFIGURATION_SCHEMA = {
|
|
28
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
29
|
+
title: 'Anhedral provider configuration',
|
|
30
|
+
description: 'Nonsecret resource choices. Supply credentials through the environment.',
|
|
31
|
+
type: 'object',
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
required: ['schemaVersion', 'providers'],
|
|
34
|
+
properties: {
|
|
35
|
+
schemaVersion: { const: 1 },
|
|
36
|
+
providers: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
properties: Object.fromEntries(Object.entries(PROVIDER_DEFINITIONS).map(([id, definition]) => [
|
|
40
|
+
id,
|
|
41
|
+
{
|
|
42
|
+
type: 'object',
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
properties: Object.fromEntries(Object.entries(definition.fields).map(([key, field]) => [key, fieldSchema(field)])),
|
|
45
|
+
},
|
|
46
|
+
])),
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { atomicJson, type DeliveryRunner } from '../project-state.js';
|
|
2
|
+
import { type ApiObject, type ApiRequest } from './api.js';
|
|
3
|
+
import { type MinimalReceipt } from './receipts.js';
|
|
4
|
+
import { type IntegrationProvider, type ProviderSettings } from './config.js';
|
|
5
|
+
export interface OperationDescriptor {
|
|
6
|
+
key: string;
|
|
7
|
+
route: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function privateStateDirectory(root: string, run: DeliveryRunner): Promise<string>;
|
|
10
|
+
export declare function readProviderStateStatus(directory: string): {
|
|
11
|
+
schemaVersion: number;
|
|
12
|
+
providers: {
|
|
13
|
+
provider: string;
|
|
14
|
+
operations: {
|
|
15
|
+
recovery: string;
|
|
16
|
+
descriptor?: OperationDescriptor | undefined;
|
|
17
|
+
resources?: MinimalReceipt | undefined;
|
|
18
|
+
operation: string;
|
|
19
|
+
status: "pending" | "complete";
|
|
20
|
+
}[];
|
|
21
|
+
}[];
|
|
22
|
+
};
|
|
23
|
+
/** Store credentials before completing a minimal receipt. A crash leaves a pending
|
|
24
|
+
* operation, never permission to replay a provider mutation with an unknown outcome. */
|
|
25
|
+
export declare class ProvisioningState {
|
|
26
|
+
private readonly write;
|
|
27
|
+
private readonly file;
|
|
28
|
+
private readonly credentialsFile;
|
|
29
|
+
private readonly state;
|
|
30
|
+
private readonly credentials;
|
|
31
|
+
constructor(directory: string, write?: typeof atomicJson);
|
|
32
|
+
private assertCredentials;
|
|
33
|
+
private retainCredentials;
|
|
34
|
+
private migrate;
|
|
35
|
+
/** No configurations, credentials, provider payloads or mutation bodies in status. */
|
|
36
|
+
status(): {
|
|
37
|
+
schemaVersion: number;
|
|
38
|
+
providers: {
|
|
39
|
+
provider: string;
|
|
40
|
+
operations: {
|
|
41
|
+
operation: string;
|
|
42
|
+
status: "pending" | "complete";
|
|
43
|
+
resources: MinimalReceipt;
|
|
44
|
+
}[];
|
|
45
|
+
}[];
|
|
46
|
+
};
|
|
47
|
+
context(id: IntegrationProvider, settings: ProviderSettings): void;
|
|
48
|
+
recoverResource(id: IntegrationProvider, operation: string, resource: string, settings: ProviderSettings, api: ApiRequest, beforeCommit?: () => void): Promise<{
|
|
49
|
+
provider: keyof import("./definitions.js").ProviderSettingsById;
|
|
50
|
+
operation: string;
|
|
51
|
+
status: string;
|
|
52
|
+
resources: MinimalReceipt;
|
|
53
|
+
mutation: string;
|
|
54
|
+
}>;
|
|
55
|
+
create(id: IntegrationProvider, key: string, api: ApiRequest, route: string, body: ApiObject): Promise<ApiObject>;
|
|
56
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { readBoundedFile, MAX_PROJECT_JSON_BYTES } from '../bounded-file.js';
|
|
4
|
+
import { atomicJson } from '../project-state.js';
|
|
5
|
+
import { digest, object, ProviderRequestError } from './api.js';
|
|
6
|
+
import { verifyRecoveredResource } from './recovery-verification.js';
|
|
7
|
+
import { splitReceipt, validReceipt, validCredentials, restoreReceipt, } from './receipts.js';
|
|
8
|
+
import { INTEGRATION_PROVIDERS, } from './config.js';
|
|
9
|
+
function validDescriptor(value) {
|
|
10
|
+
return (record(value) &&
|
|
11
|
+
Object.keys(value).every((key) => ['key', 'route'].includes(key)) &&
|
|
12
|
+
typeof value.key === 'string' &&
|
|
13
|
+
/^(project|application|domain|web-app|ios-app|android-app|sending-key|webhook|service-account|client-key)$/.test(value.key) &&
|
|
14
|
+
typeof value.route === 'string' &&
|
|
15
|
+
value.route.length <= 1024 &&
|
|
16
|
+
/^\/[a-zA-Z0-9_/-]+$/.test(value.route) &&
|
|
17
|
+
!value.route.includes('//'));
|
|
18
|
+
}
|
|
19
|
+
function record(value) {
|
|
20
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
/** Validate saved operations before trusting their replay protection or resource values. */
|
|
23
|
+
function validState(value) {
|
|
24
|
+
if (!record(value) ||
|
|
25
|
+
!(value.schemaVersion === 1 || value.schemaVersion === 2) ||
|
|
26
|
+
Object.keys(value).some((key) => !['schemaVersion', 'providers'].includes(key)) ||
|
|
27
|
+
!record(value.providers))
|
|
28
|
+
return false;
|
|
29
|
+
return Object.entries(value.providers).every(([id, provider]) => {
|
|
30
|
+
if (!INTEGRATION_PROVIDERS.includes(id) ||
|
|
31
|
+
!record(provider) ||
|
|
32
|
+
Object.keys(provider).some((key) => !['config', 'operations'].includes(key)) ||
|
|
33
|
+
typeof provider.config !== 'string' ||
|
|
34
|
+
!/^[a-f0-9]{64}$/.test(provider.config) ||
|
|
35
|
+
!record(provider.operations))
|
|
36
|
+
return false;
|
|
37
|
+
return Object.entries(provider.operations).every(([key, operation]) => {
|
|
38
|
+
if (!/^[a-f0-9]{64}$/.test(key) ||
|
|
39
|
+
!record(operation) ||
|
|
40
|
+
Object.keys(operation).some((field) => !(value.schemaVersion === 1
|
|
41
|
+
? ['status', 'value']
|
|
42
|
+
: ['status', 'value', 'credentialFields', 'descriptor']).includes(field)))
|
|
43
|
+
return false;
|
|
44
|
+
if (operation.descriptor !== undefined && !validDescriptor(operation.descriptor))
|
|
45
|
+
return false;
|
|
46
|
+
if (operation.status === 'pending')
|
|
47
|
+
return !Object.hasOwn(operation, 'value') && !Object.hasOwn(operation, 'credentialFields');
|
|
48
|
+
if (operation.status !== 'complete' || !record(operation.value))
|
|
49
|
+
return false;
|
|
50
|
+
if (value.schemaVersion === 1)
|
|
51
|
+
return true;
|
|
52
|
+
return (validReceipt(id, operation.value) &&
|
|
53
|
+
(operation.credentialFields === undefined ||
|
|
54
|
+
(Array.isArray(operation.credentialFields) &&
|
|
55
|
+
new Set(operation.credentialFields).size === operation.credentialFields.length &&
|
|
56
|
+
operation.credentialFields.every((field) => typeof field === 'string' &&
|
|
57
|
+
validCredentials(id, { [field]: 'validation-placeholder' })))));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export async function privateStateDirectory(root, run) {
|
|
62
|
+
root = realpathSync(root);
|
|
63
|
+
const directory = path.join(root, '.anhedral');
|
|
64
|
+
if (existsSync(directory) &&
|
|
65
|
+
(lstatSync(directory).isSymbolicLink() || !lstatSync(directory).isDirectory()))
|
|
66
|
+
throw new Error('Provider state directory must be a regular directory.');
|
|
67
|
+
const environment = { ...process.env, CI: '1', NO_COLOR: '1' };
|
|
68
|
+
if (realpathSync((await run('git', ['rev-parse', '--show-toplevel'], root, environment)).trim()) !==
|
|
69
|
+
root)
|
|
70
|
+
throw new Error('Provider configuration requires its own application repository.');
|
|
71
|
+
for (const file of [
|
|
72
|
+
'.anhedral/providers.json',
|
|
73
|
+
'.anhedral/provider-credentials.json',
|
|
74
|
+
'.anhedral/runtime.json',
|
|
75
|
+
]) {
|
|
76
|
+
if ((await run('git', ['ls-files', '--', file], root, environment)).trim())
|
|
77
|
+
throw new Error('Provider state must not be tracked.');
|
|
78
|
+
if ((await run('git', ['check-ignore', '--no-index', '--', file], root, environment)).trim() !==
|
|
79
|
+
file)
|
|
80
|
+
throw new Error('Ignore .anhedral/ before provisioning providers.');
|
|
81
|
+
}
|
|
82
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
83
|
+
if (realpathSync(directory) !== directory)
|
|
84
|
+
throw new Error('Provider state directory escapes the project.');
|
|
85
|
+
return directory;
|
|
86
|
+
}
|
|
87
|
+
function validCredentialsState(value) {
|
|
88
|
+
return (record(value) &&
|
|
89
|
+
value.schemaVersion === 1 &&
|
|
90
|
+
!Object.keys(value).some((key) => !['schemaVersion', 'providers'].includes(key)) &&
|
|
91
|
+
record(value.providers) &&
|
|
92
|
+
Object.entries(value.providers).every(([id, operations]) => INTEGRATION_PROVIDERS.includes(id) &&
|
|
93
|
+
record(operations) &&
|
|
94
|
+
Object.entries(operations).every(([key, entries]) => /^[a-f0-9]{64}$/.test(key) && validCredentials(id, entries))));
|
|
95
|
+
}
|
|
96
|
+
function privateJson(file) {
|
|
97
|
+
const stat = lstatSync(file);
|
|
98
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
99
|
+
throw new Error('Provider state must be a regular private file.');
|
|
100
|
+
if (process.platform !== 'win32' && stat.mode & 0o077)
|
|
101
|
+
throw new Error('Provider state contains credentials; restrict its permissions to 0600.');
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(readBoundedFile(file, MAX_PROJECT_JSON_BYTES).toString('utf8'));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw new Error('Invalid provider state.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function readProviderStateStatus(directory) {
|
|
110
|
+
const file = path.join(directory, 'providers.json');
|
|
111
|
+
if (!existsSync(file))
|
|
112
|
+
return { schemaVersion: 2, providers: [] };
|
|
113
|
+
if (lstatSync(directory).isSymbolicLink() || !lstatSync(directory).isDirectory())
|
|
114
|
+
throw new Error('Provider state directory must be a regular private directory.');
|
|
115
|
+
const state = privateJson(file);
|
|
116
|
+
if (!validState(state))
|
|
117
|
+
throw new Error('Invalid provider state.');
|
|
118
|
+
return {
|
|
119
|
+
schemaVersion: 2,
|
|
120
|
+
providers: Object.entries(state.providers).map(([id, provider]) => ({
|
|
121
|
+
provider: id,
|
|
122
|
+
operations: Object.entries(provider.operations).map(([operation, saved]) => ({
|
|
123
|
+
operation,
|
|
124
|
+
status: saved.status,
|
|
125
|
+
...(saved.value
|
|
126
|
+
? { resources: splitReceipt(id, saved.value).receipt }
|
|
127
|
+
: {}),
|
|
128
|
+
...(saved.descriptor ? { descriptor: saved.descriptor } : {}),
|
|
129
|
+
recovery: !saved.descriptor
|
|
130
|
+
? 'unknown-descriptor'
|
|
131
|
+
: ['sending-key', 'webhook', 'service-account'].includes(saved.descriptor.key)
|
|
132
|
+
? 'requires-one-time-credential'
|
|
133
|
+
: ['project', 'application', 'domain', 'web-app', 'ios-app', 'android-app'].includes(saved.descriptor.key)
|
|
134
|
+
? 'verified-resource'
|
|
135
|
+
: 'unsupported-operation',
|
|
136
|
+
})),
|
|
137
|
+
})),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** Store credentials before completing a minimal receipt. A crash leaves a pending
|
|
141
|
+
* operation, never permission to replay a provider mutation with an unknown outcome. */
|
|
142
|
+
export class ProvisioningState {
|
|
143
|
+
write;
|
|
144
|
+
file;
|
|
145
|
+
credentialsFile;
|
|
146
|
+
state;
|
|
147
|
+
credentials;
|
|
148
|
+
constructor(directory, write = atomicJson) {
|
|
149
|
+
this.write = write;
|
|
150
|
+
if (lstatSync(directory).isSymbolicLink() || !lstatSync(directory).isDirectory())
|
|
151
|
+
throw new Error('Provider state directory must be a regular private directory.');
|
|
152
|
+
directory = realpathSync(directory);
|
|
153
|
+
this.file = path.join(directory, 'providers.json');
|
|
154
|
+
this.credentialsFile = path.join(directory, 'provider-credentials.json');
|
|
155
|
+
const credentials = existsSync(this.credentialsFile)
|
|
156
|
+
? privateJson(this.credentialsFile)
|
|
157
|
+
: { schemaVersion: 1, providers: {} };
|
|
158
|
+
if (!validCredentialsState(credentials))
|
|
159
|
+
throw new Error('Invalid provider credential state.');
|
|
160
|
+
this.credentials = credentials;
|
|
161
|
+
const state = existsSync(this.file)
|
|
162
|
+
? privateJson(this.file)
|
|
163
|
+
: { schemaVersion: 2, providers: {} };
|
|
164
|
+
if (!validState(state))
|
|
165
|
+
throw new Error('Invalid provider state.');
|
|
166
|
+
this.state = state;
|
|
167
|
+
if (state.schemaVersion === 1)
|
|
168
|
+
this.migrate();
|
|
169
|
+
this.assertCredentials();
|
|
170
|
+
}
|
|
171
|
+
assertCredentials() {
|
|
172
|
+
for (const [id, provider] of Object.entries(this.state.providers)) {
|
|
173
|
+
for (const [operation, saved] of Object.entries(provider.operations)) {
|
|
174
|
+
const credentials = this.credentials.providers[id]?.[operation] ?? {};
|
|
175
|
+
if (saved.credentialFields?.some((field) => !credentials[field]))
|
|
176
|
+
throw new Error('Provider creation credentials are missing. Restore private state before retrying.');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
retainCredentials(id, operation, entries) {
|
|
181
|
+
if (!Object.keys(entries).length)
|
|
182
|
+
return;
|
|
183
|
+
const provider = (this.credentials.providers[id] ??= {});
|
|
184
|
+
const previous = provider[operation];
|
|
185
|
+
if (previous && digest(previous) !== digest(entries))
|
|
186
|
+
throw new Error('Provider credential state conflicts with the saved creation result.');
|
|
187
|
+
provider[operation] = entries;
|
|
188
|
+
}
|
|
189
|
+
migrate() {
|
|
190
|
+
const migrated = { schemaVersion: 2, providers: {} };
|
|
191
|
+
for (const id of INTEGRATION_PROVIDERS) {
|
|
192
|
+
const provider = this.state.providers[id];
|
|
193
|
+
if (!provider)
|
|
194
|
+
continue;
|
|
195
|
+
const operations = {};
|
|
196
|
+
for (const [key, saved] of Object.entries(provider.operations)) {
|
|
197
|
+
if (saved.status === 'pending')
|
|
198
|
+
operations[key] = { status: 'pending' };
|
|
199
|
+
else {
|
|
200
|
+
const { receipt, credentials } = splitReceipt(id, saved.value);
|
|
201
|
+
this.retainCredentials(id, key, credentials);
|
|
202
|
+
operations[key] = {
|
|
203
|
+
status: 'complete',
|
|
204
|
+
value: receipt,
|
|
205
|
+
...(Object.keys(credentials).length
|
|
206
|
+
? { credentialFields: Object.keys(credentials) }
|
|
207
|
+
: {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
migrated.providers[id] = { config: provider.config, operations };
|
|
212
|
+
}
|
|
213
|
+
// Credentials first: if the receipt write fails, the original schema 1 response
|
|
214
|
+
// remains intact and a retry can safely repeat this local migration.
|
|
215
|
+
this.write(this.credentialsFile, this.credentials);
|
|
216
|
+
this.write(this.file, migrated);
|
|
217
|
+
this.state.schemaVersion = 2;
|
|
218
|
+
this.state.providers = migrated.providers;
|
|
219
|
+
}
|
|
220
|
+
/** No configurations, credentials, provider payloads or mutation bodies in status. */
|
|
221
|
+
status() {
|
|
222
|
+
return {
|
|
223
|
+
schemaVersion: 2,
|
|
224
|
+
providers: Object.entries(this.state.providers).map(([id, provider]) => ({
|
|
225
|
+
provider: id,
|
|
226
|
+
operations: Object.entries(provider.operations).map(([operation, saved]) => ({
|
|
227
|
+
operation,
|
|
228
|
+
status: saved.status,
|
|
229
|
+
resources: saved.value ?? {},
|
|
230
|
+
})),
|
|
231
|
+
})),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
context(id, settings) {
|
|
235
|
+
const hash = digest(settings);
|
|
236
|
+
const saved = this.state.providers[id];
|
|
237
|
+
if (saved && saved.config !== hash)
|
|
238
|
+
throw new Error(`${id}: desired configuration changed. Reconcile saved resource IDs before changing provider state.`);
|
|
239
|
+
if (!saved)
|
|
240
|
+
this.state.providers[id] = { config: hash, operations: {} };
|
|
241
|
+
}
|
|
242
|
+
async recoverResource(id, operation, resource, settings, api, beforeCommit = () => { }) {
|
|
243
|
+
this.context(id, settings);
|
|
244
|
+
const provider = this.state.providers[id];
|
|
245
|
+
const saved = provider.operations[operation];
|
|
246
|
+
if (!saved || saved.status !== 'pending')
|
|
247
|
+
throw new Error('Only an explicitly identified pending provider operation can be reconciled.');
|
|
248
|
+
if (!saved.descriptor)
|
|
249
|
+
throw new Error('This legacy pending operation has no trusted descriptor. Inspect the provider and retain its journal; automatic recovery is unavailable.');
|
|
250
|
+
const receipt = await verifyRecoveredResource(id, saved.descriptor, resource, settings, api);
|
|
251
|
+
beforeCommit();
|
|
252
|
+
const complete = {
|
|
253
|
+
status: 'complete',
|
|
254
|
+
descriptor: saved.descriptor,
|
|
255
|
+
value: receipt,
|
|
256
|
+
};
|
|
257
|
+
provider.operations[operation] = complete;
|
|
258
|
+
try {
|
|
259
|
+
this.write(this.file, this.state);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
provider.operations[operation] = saved;
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
provider: id,
|
|
267
|
+
operation,
|
|
268
|
+
status: 'complete',
|
|
269
|
+
resources: receipt,
|
|
270
|
+
mutation: 'local-receipt-only',
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
async create(id, key, api, route, body) {
|
|
274
|
+
const state = this.state.providers[id];
|
|
275
|
+
if (!state)
|
|
276
|
+
throw new Error('Select a provider configuration before creating resources.');
|
|
277
|
+
const operation = digest({ key, route, body });
|
|
278
|
+
const saved = state.operations[operation];
|
|
279
|
+
if (saved?.status === 'complete')
|
|
280
|
+
return restoreReceipt(saved.value, this.credentials.providers[id]?.[operation] ?? {});
|
|
281
|
+
if (saved)
|
|
282
|
+
throw new Error(`${id}: interrupted ${key} creation has an unknown outcome. Inspect the provider and reconcile the saved operation before retrying.`);
|
|
283
|
+
const descriptor = { key, route };
|
|
284
|
+
if (!validDescriptor(descriptor))
|
|
285
|
+
throw new Error('Unsupported provider operation descriptor.');
|
|
286
|
+
state.operations[operation] = { status: 'pending', descriptor };
|
|
287
|
+
this.write(this.file, this.state);
|
|
288
|
+
let value;
|
|
289
|
+
try {
|
|
290
|
+
value = object(await api(route, 'POST', body, operation));
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (error instanceof ProviderRequestError && error.rejected) {
|
|
294
|
+
delete state.operations[operation];
|
|
295
|
+
this.write(this.file, this.state);
|
|
296
|
+
}
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
const { receipt, credentials } = splitReceipt(id, value);
|
|
300
|
+
const expected = id === 'resend' && key === 'sending-key'
|
|
301
|
+
? 'token'
|
|
302
|
+
: id === 'stripe' && key === 'webhook'
|
|
303
|
+
? 'secret'
|
|
304
|
+
: id === 'openai' && key === 'service-account'
|
|
305
|
+
? 'api_key'
|
|
306
|
+
: undefined;
|
|
307
|
+
if (expected && !credentials[expected])
|
|
308
|
+
throw new Error('Provider omitted the one-time creation credential. Inspect the pending operation before retrying.');
|
|
309
|
+
this.retainCredentials(id, operation, credentials);
|
|
310
|
+
if (Object.keys(credentials).length)
|
|
311
|
+
this.write(this.credentialsFile, this.credentials);
|
|
312
|
+
state.operations[operation] = {
|
|
313
|
+
status: 'complete',
|
|
314
|
+
descriptor,
|
|
315
|
+
value: receipt,
|
|
316
|
+
...(Object.keys(credentials).length
|
|
317
|
+
? { credentialFields: Object.keys(credentials) }
|
|
318
|
+
: {}),
|
|
319
|
+
};
|
|
320
|
+
try {
|
|
321
|
+
this.write(this.file, this.state);
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
state.operations[operation] = { status: 'pending', descriptor };
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
return restoreReceipt(receipt, credentials);
|
|
328
|
+
}
|
|
329
|
+
}
|
package/dist/provision.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type DeliveryRunner } from './project-state.js';
|
|
2
2
|
import { type CloudflareRequest } from './cloudflare.js';
|
|
3
3
|
interface Resource {
|
|
4
|
-
kind: 'd1' | 'r2' | 'kv' | 'queue';
|
|
4
|
+
kind: 'd1' | 'r2' | 'kv' | 'queue' | 'stream';
|
|
5
5
|
name: string;
|
|
6
6
|
id?: string;
|
|
7
7
|
target: string;
|
package/dist/provision.js
CHANGED
|
@@ -8,9 +8,28 @@ const resourceTypes = {
|
|
|
8
8
|
r2: { endpoint: '/r2/buckets', nameField: 'name', idField: 'name' },
|
|
9
9
|
kv: { endpoint: '/storage/kv/namespaces', nameField: 'title', idField: 'id' },
|
|
10
10
|
queue: { endpoint: '/queues', nameField: 'queue_name', idField: 'queue_id' },
|
|
11
|
+
stream: { endpoint: '/pipelines/v1/streams', nameField: 'name', idField: 'id' },
|
|
11
12
|
};
|
|
12
13
|
async function listResources(request, kind) {
|
|
13
14
|
const { endpoint } = resourceTypes[kind];
|
|
15
|
+
if (kind === 'stream') {
|
|
16
|
+
const streams = [];
|
|
17
|
+
let bytes = 0;
|
|
18
|
+
for (let page = 0; page < 1000; page++) {
|
|
19
|
+
const response = await request('GET', `${endpoint}?page=${page}&per_page=100`);
|
|
20
|
+
if (!Array.isArray(response.result))
|
|
21
|
+
throw new Error('Unsupported stream listing.');
|
|
22
|
+
if (response.result.some((item) => !item || typeof item !== 'object' || Array.isArray(item)))
|
|
23
|
+
throw new Error('Unsupported stream resource.');
|
|
24
|
+
streams.push(...response.result);
|
|
25
|
+
bytes += Buffer.byteLength(JSON.stringify(response.result));
|
|
26
|
+
if (streams.length > 10_000 || bytes > 10 * 1024 * 1024)
|
|
27
|
+
throw new Error('Stream listing exceeded collection limit; select resource IDs explicitly.');
|
|
28
|
+
if (response.result.length < 100)
|
|
29
|
+
return streams;
|
|
30
|
+
}
|
|
31
|
+
throw new Error('Stream listing exceeded pagination limit.');
|
|
32
|
+
}
|
|
14
33
|
if (kind !== 'r2')
|
|
15
34
|
return listCloudflare(request, endpoint);
|
|
16
35
|
const value = (await request('GET', endpoint)).result;
|
|
@@ -18,6 +37,15 @@ async function listResources(request, kind) {
|
|
|
18
37
|
throw new Error('Unsupported or truncated R2 bucket listing; select resources explicitly with provider tooling.');
|
|
19
38
|
return value.buckets;
|
|
20
39
|
}
|
|
40
|
+
async function assertPrivateStream(request, id) {
|
|
41
|
+
if (!/^[a-f0-9]{32}$/i.test(id))
|
|
42
|
+
throw new Error('Invalid Basin stream ID.');
|
|
43
|
+
const stream = (await request('GET', `${resourceTypes.stream.endpoint}/${id}`)).result;
|
|
44
|
+
if (stream?.http?.enabled !== false ||
|
|
45
|
+
stream.worker_binding?.enabled !== true ||
|
|
46
|
+
stream.format?.type !== 'json')
|
|
47
|
+
throw new Error('Basin stream must use a private Worker binding with JSON events and HTTP ingestion disabled.');
|
|
48
|
+
}
|
|
21
49
|
/** Provisioning and recovery use the same independently verified private-storage policy. */
|
|
22
50
|
async function assertPrivateBucket(request, name) {
|
|
23
51
|
const endpoint = `${resourceTypes.r2.endpoint}/${encodeURIComponent(name)}/domains`;
|
|
@@ -79,12 +107,30 @@ export function planProvisioning(root) {
|
|
|
79
107
|
target: target.id,
|
|
80
108
|
});
|
|
81
109
|
}
|
|
110
|
+
if (config.pipelines !== undefined) {
|
|
111
|
+
if (!Array.isArray(config.pipelines))
|
|
112
|
+
throw new Error('Invalid Basin stream bindings.');
|
|
113
|
+
config.pipelines.forEach((entry, index) => {
|
|
114
|
+
if (!entry ||
|
|
115
|
+
typeof entry.binding !== 'string' ||
|
|
116
|
+
typeof entry.stream !== 'string' ||
|
|
117
|
+
entry.pipeline)
|
|
118
|
+
throw new Error('Use explicit Basin stream bindings.');
|
|
119
|
+
resources.push({
|
|
120
|
+
kind: 'stream',
|
|
121
|
+
name: resourceName(`${project.name}-${entry.binding.toLowerCase()}`),
|
|
122
|
+
target: target.id,
|
|
123
|
+
id: entry.stream.startsWith('REPLACE_WITH_') ? undefined : entry.stream,
|
|
124
|
+
field: ['pipelines', index, 'stream'],
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
82
128
|
}
|
|
83
129
|
return {
|
|
84
130
|
projectId: project.id,
|
|
85
131
|
accountId: project.accountId,
|
|
86
132
|
resources,
|
|
87
|
-
externalIntegrations: ['hyperdrive'
|
|
133
|
+
externalIntegrations: ['hyperdrive'].filter((key) => project.targets.some((target) => key in readConfig(targetDirectory(root, target)))),
|
|
88
134
|
};
|
|
89
135
|
}
|
|
90
136
|
export async function provisionApplication(root, dryRun = false, run = defaultRunner, suppliedRequest) {
|
|
@@ -125,8 +171,16 @@ export async function provisionApplication(root, dryRun = false, run = defaultRu
|
|
|
125
171
|
throw new Error('Configured resource ID does not match its account/name; no replacement was created.');
|
|
126
172
|
assertConfiguration();
|
|
127
173
|
if (!value)
|
|
128
|
-
value = (await request('POST', endpoint, {
|
|
129
|
-
.
|
|
174
|
+
value = (await request('POST', endpoint, {
|
|
175
|
+
[nameField]: resource.name,
|
|
176
|
+
...(resource.kind === 'stream'
|
|
177
|
+
? {
|
|
178
|
+
http: { enabled: false, authentication: true },
|
|
179
|
+
worker_binding: { enabled: true },
|
|
180
|
+
format: { type: 'json', unstructured: true },
|
|
181
|
+
}
|
|
182
|
+
: {}),
|
|
183
|
+
})).result;
|
|
130
184
|
if (!value || typeof value[idField] !== 'string' || !value[idField])
|
|
131
185
|
throw new Error('Provider did not return a resource identity; inspect provider state before retrying.');
|
|
132
186
|
receipt = {
|
|
@@ -148,6 +202,8 @@ export async function provisionApplication(root, dryRun = false, run = defaultRu
|
|
|
148
202
|
});
|
|
149
203
|
if (resource.kind === 'r2')
|
|
150
204
|
await assertPrivateBucket(request, resource.name);
|
|
205
|
+
if (resource.kind === 'stream')
|
|
206
|
+
await assertPrivateStream(request, receipt.id);
|
|
151
207
|
}
|
|
152
208
|
assertConfiguration();
|
|
153
209
|
if (resource.id && receipt.id !== resource.id)
|
|
@@ -191,6 +247,8 @@ export async function inspectApplicationResources(root, request) {
|
|
|
191
247
|
throw new Error('Committed resource identity differs from the registered account; recovery will not replace it.');
|
|
192
248
|
if (resource.kind === 'r2')
|
|
193
249
|
await assertPrivateBucket(request, resource.name);
|
|
250
|
+
if (resource.kind === 'stream')
|
|
251
|
+
await assertPrivateStream(request, id);
|
|
194
252
|
receipts.push({ kind: resource.kind, name: resource.name, id, target: resource.target });
|
|
195
253
|
}
|
|
196
254
|
return { resources: receipts, externalIntegrations: plan.externalIntegrations };
|
package/dist/registry/add.d.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import type { StandardRunner } from './types.js';
|
|
2
|
+
import { type SourcePreview } from './preview.js';
|
|
2
3
|
export interface AddRequest {
|
|
3
4
|
items: string[];
|
|
4
5
|
dryRun?: boolean;
|
|
5
6
|
skipInstall?: boolean;
|
|
7
|
+
preview?: {
|
|
8
|
+
mode: 'diff' | 'view';
|
|
9
|
+
path?: string;
|
|
10
|
+
};
|
|
6
11
|
}
|
|
7
12
|
/** Add recipes to an initialized workspace, refusing edits to customer-owned source. */
|
|
8
13
|
export declare function addStackItems(root: string, request: AddRequest, run?: StandardRunner): Promise<{
|
|
14
|
+
previews?: SourcePreview[] | undefined;
|
|
9
15
|
schemaVersion: number;
|
|
10
16
|
root: string;
|
|
11
17
|
dryRun: boolean;
|
package/dist/registry/add.js
CHANGED
|
@@ -18,11 +18,20 @@ import { writeStandardRoot } from '../scaffold/workspace.js';
|
|
|
18
18
|
import { createRecipeContext } from './context.js';
|
|
19
19
|
import { resolveStackRegistry, resolveStandardProducts } from './resolve.js';
|
|
20
20
|
import { sourceFiles } from './receipt.js';
|
|
21
|
+
import { sourcePreview, validatePreviewPath, MAX_PREVIEW_TOTAL_BYTES, } from './preview.js';
|
|
21
22
|
/** Add recipes to an initialized workspace, refusing edits to customer-owned source. */
|
|
22
23
|
export async function addStackItems(root, request, run = execFile) {
|
|
23
24
|
for (const key of Object.keys(request))
|
|
24
|
-
if (!['items', 'dryRun', 'skipInstall'].includes(key))
|
|
25
|
+
if (!['items', 'dryRun', 'skipInstall', 'preview'].includes(key))
|
|
25
26
|
throw new Error(`Unknown add option: ${key}. Only bundled registry items are supported.`);
|
|
27
|
+
if (request.preview) {
|
|
28
|
+
if (!request.dryRun ||
|
|
29
|
+
!['diff', 'view'].includes(request.preview.mode) ||
|
|
30
|
+
Object.keys(request.preview).some((key) => !['mode', 'path'].includes(key)))
|
|
31
|
+
throw new Error('Source previews require dry-run and one mode: diff or view.');
|
|
32
|
+
if (request.preview.path !== undefined)
|
|
33
|
+
validatePreviewPath(request.preview.path);
|
|
34
|
+
}
|
|
26
35
|
if (!request.dryRun)
|
|
27
36
|
assertWorkspaceRuntime(run);
|
|
28
37
|
root = realpathSync(root);
|
|
@@ -81,11 +90,14 @@ export async function addStackItems(root, request, run = execFile) {
|
|
|
81
90
|
targets: recipes.flatMap((item) => item.targets),
|
|
82
91
|
files: commitPaths,
|
|
83
92
|
conflicts,
|
|
93
|
+
...(request.preview ? { previews: [] } : {}),
|
|
84
94
|
};
|
|
85
95
|
// The upstream web generator requires network access and executes package scripts.
|
|
86
96
|
// A preview reports the resolved selection without invoking that generator.
|
|
87
97
|
if (request.dryRun && result.bootstrapRequired) {
|
|
88
98
|
result.filesChecked = false;
|
|
99
|
+
if (request.preview)
|
|
100
|
+
throw new Error('Source preview requires a new web bootstrap and cannot run offline. Use add --dry-run for the selection plan.');
|
|
89
101
|
return result;
|
|
90
102
|
}
|
|
91
103
|
await runStagedTransaction(root, {
|
|
@@ -152,6 +164,22 @@ export async function addStackItems(root, request, run = execFile) {
|
|
|
152
164
|
files,
|
|
153
165
|
});
|
|
154
166
|
commitPaths.push('anhedral.registry.json');
|
|
167
|
+
if (request.preview && result.previews) {
|
|
168
|
+
const preview = request.preview;
|
|
169
|
+
const selected = [...commitPaths]
|
|
170
|
+
.filter((file) => !preview.path || preview.path === file)
|
|
171
|
+
.sort();
|
|
172
|
+
if (preview.path && !selected.length)
|
|
173
|
+
throw new Error('Requested path is not a changed generated source file.');
|
|
174
|
+
let total = 0;
|
|
175
|
+
for (const file of selected) {
|
|
176
|
+
const content = sourcePreview(root, stage, file, preview.mode);
|
|
177
|
+
total += Buffer.byteLength(JSON.stringify(content));
|
|
178
|
+
if (total > MAX_PREVIEW_TOTAL_BYTES)
|
|
179
|
+
throw new Error('Source preview exceeds 1 MiB. Select a single file with --diff <path> or --view <path>.');
|
|
180
|
+
result.previews.push(content);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
155
183
|
// Detect concurrent changes even to source that this addition would otherwise preserve.
|
|
156
184
|
if (!isDeepStrictEqual(sourceFiles(root), before) ||
|
|
157
185
|
readBoundedFile(path.join(root, 'anhedral.registry.json'), MAX_PROJECT_JSON_BYTES).toString('utf8') !== receiptText)
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function
|
|
1
|
+
import { type OutputOptions } from '../cli-output.js';
|
|
2
|
+
export declare function runRegistry(args: string[], json: boolean, output?: OutputOptions): Promise<void>;
|
|
3
|
+
export declare function runAdd(args: string[], output?: OutputOptions): Promise<void>;
|