anhedral 0.10.0 → 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 +78 -39
- 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/assets/security/audit-deps.mjs +6 -1
- package/dist/bin.js +271 -154
- package/dist/bounded-file.d.ts +1 -0
- package/dist/bounded-file.js +1 -0
- package/dist/capabilities.d.ts +4 -4
- package/dist/capabilities.js +1 -1
- 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/dependencies.d.ts +32 -0
- package/dist/dependencies.js +47 -1
- 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/desktop.js +4 -5
- package/dist/platforms/extension.js +9 -73
- package/dist/platforms/mobile.js +14 -52
- 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/readiness.js +11 -3
- package/dist/registry/add.d.ts +6 -0
- package/dist/registry/add.js +35 -4
- 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/receipt.js +1 -0
- package/dist/registry/resolve.js +19 -13
- package/dist/registry/types.d.ts +1 -0
- package/dist/registry/usage.d.ts +10 -0
- package/dist/registry/usage.js +22 -37
- package/dist/render.d.ts +2 -0
- package/dist/render.js +10 -0
- package/dist/scaffold/api.js +20 -3
- package/dist/scaffold/auth.js +68 -28
- package/dist/scaffold/bootstrap.d.ts +2 -0
- package/dist/scaffold/bootstrap.js +9 -0
- package/dist/scaffold/database.js +12 -6
- package/dist/scaffold/documentation.js +11 -8
- package/dist/scaffold/environment.d.ts +2 -0
- package/dist/scaffold/environment.js +18 -1
- package/dist/scaffold/files.d.ts +1 -0
- package/dist/scaffold/files.js +15 -4
- package/dist/scaffold/services.js +37 -13
- package/dist/scaffold/stack-documentation.d.ts +3 -0
- package/dist/scaffold/stack-documentation.js +52 -0
- package/dist/scaffold/styling.d.ts +3 -0
- package/dist/scaffold/styling.js +84 -0
- package/dist/scaffold/web.js +31 -11
- package/dist/scaffold/workspace.js +49 -22
- 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 +44 -11
- package/dist/standard.d.ts +2 -1
- package/dist/standard.js +13 -12
- 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/dist/vinext.js +9 -18
- package/dist/workspace-graph.js +3 -2
- 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/readiness.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readBoundedFile, MAX_PROJECT_JSON_BYTES } from './bounded-file.js';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { isDeepStrictEqual } from 'node:util';
|
|
4
5
|
import { inspectWorkspaceGraph } from './workspace-graph.js';
|
|
@@ -8,11 +9,16 @@ import { STANDARD_PRODUCTS } from './stack-registry.js';
|
|
|
8
9
|
export function inspectProject(root) {
|
|
9
10
|
const checks = [];
|
|
10
11
|
const record = (id, ok, detail) => checks.push({ id, ok, detail });
|
|
11
|
-
const read = (file) => JSON.parse(
|
|
12
|
+
const read = (file) => JSON.parse(readBoundedFile(path.join(root, file), MAX_PROJECT_JSON_BYTES).toString('utf8'));
|
|
12
13
|
record('workspace', existsSync(path.join(root, 'pnpm-workspace.yaml')), 'pnpm workspace manifest');
|
|
13
14
|
record('lockfile', existsSync(path.join(root, 'pnpm-lock.yaml')), 'Committed dependency resolution required');
|
|
14
15
|
try {
|
|
15
16
|
const plan = read('anhedral.setup.json');
|
|
17
|
+
const recordedVersion = plan?.cliVersion;
|
|
18
|
+
const compatible = recordedVersion === CAPABILITY_REGISTRY.cliVersion;
|
|
19
|
+
record('cli-compatibility', compatible, /^\d+\.\d+\.\d+$/.test(recordedVersion ?? '')
|
|
20
|
+
? `Use anhedral@${recordedVersion} for this project or review upgrade differences`
|
|
21
|
+
: 'Use the recorded project CLI version or review upgrade differences');
|
|
16
22
|
validateSetupPlan(plan);
|
|
17
23
|
const standard = read('anhedral.standard.json');
|
|
18
24
|
record('setup-plan', true, `Requirements recorded by CLI ${plan.cliVersion}`);
|
|
@@ -40,7 +46,6 @@ export function inspectProject(root) {
|
|
|
40
46
|
catch {
|
|
41
47
|
record('workspace-graph', false, 'Invalid workspace manifests, references or dependency cycles');
|
|
42
48
|
}
|
|
43
|
-
record('cli-compatibility', plan.cliVersion === CAPABILITY_REGISTRY.cliVersion, 'Use the project CLI version or review upgrade differences');
|
|
44
49
|
record('selection', standard.schemaVersion === 1 &&
|
|
45
50
|
JSON.stringify(standard.products) === JSON.stringify(plan.products) &&
|
|
46
51
|
standard.hosting ===
|
|
@@ -68,6 +73,9 @@ export function validateSetupPlan(plan) {
|
|
|
68
73
|
value.registryVersion !== 1 ||
|
|
69
74
|
typeof value.cliVersion !== 'string')
|
|
70
75
|
throw new Error('Unsupported setup plan version.');
|
|
76
|
+
if (/^\d+\.\d+\.\d+$/.test(value.cliVersion) &&
|
|
77
|
+
value.cliVersion !== CAPABILITY_REGISTRY.cliVersion)
|
|
78
|
+
throw new Error(`Use anhedral@${value.cliVersion} for this project or review upgrade differences.`);
|
|
71
79
|
if (!Array.isArray(value.products) ||
|
|
72
80
|
!value.products.length ||
|
|
73
81
|
!value.products.every((id) => STANDARD_PRODUCTS.includes(id)))
|
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;
|