anhedral 0.8.6 → 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 +141 -38
- package/assets/anhedral-cli-init-technical.svg +15 -15
- package/assets/anhedral-cli-init.svg +16 -16
- 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 +345 -39
- package/dist/bounded-file.d.ts +2 -0
- package/dist/bounded-file.js +30 -0
- package/dist/capabilities.d.ts +24 -42
- package/dist/capabilities.js +73 -75
- 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 +24 -3
- package/dist/dependencies.js +112 -74
- package/dist/dependency-order.d.ts +2 -0
- package/dist/dependency-order.js +24 -0
- package/dist/index.d.ts +18 -3
- package/dist/index.js +12 -2
- 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 +25 -15
- package/dist/platforms/extension.d.ts +2 -2
- package/dist/platforms/extension.js +104 -66
- package/dist/platforms/mobile.d.ts +2 -2
- package/dist/platforms/mobile.js +13 -7
- package/dist/pnpm-policy.d.ts +0 -2
- package/dist/pnpm-policy.js +18 -13
- 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 -2
- package/dist/readiness.js +81 -14
- 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.d.ts +0 -4
- package/dist/render.js +3 -16
- 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.js +7 -2
- package/dist/stack-registry.d.ts +80 -0
- package/dist/stack-registry.js +424 -0
- package/dist/standard.d.ts +6 -19
- package/dist/standard.js +46 -653
- package/dist/transaction.js +47 -56
- 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 +76 -91
- 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 -29
- package/dist/evidence-validation.d.ts +0 -2
- package/dist/evidence-validation.js +0 -18
- package/dist/foundation.d.ts +0 -6
- package/dist/foundation.js +0 -49
- package/dist/progress-view.d.ts +0 -57
- package/dist/progress-view.js +0 -69
- package/dist/progress.d.ts +0 -95
- package/dist/progress.js +0 -327
- package/dist/project.d.ts +0 -5
- 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,373 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { atomicJson, withApplicationLock } from './project-state.js';
|
|
6
|
+
const manifest = 'anhedral.hosting.json';
|
|
7
|
+
const identifier = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
8
|
+
const maxBytes = 64 * 1024 * 1024;
|
|
9
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
function origin() {
|
|
11
|
+
const raw = process.env.ANHEDRAL_HOSTING_ORIGIN;
|
|
12
|
+
if (!raw)
|
|
13
|
+
throw new Error('Managed hosting backend is not configured. Set ANHEDRAL_HOSTING_ORIGIN to an operated Anhedral service; no hosting service is implied by the CLI.');
|
|
14
|
+
const url = new URL(raw);
|
|
15
|
+
if (url.protocol !== 'https:' ||
|
|
16
|
+
url.username ||
|
|
17
|
+
url.password ||
|
|
18
|
+
url.port ||
|
|
19
|
+
url.search ||
|
|
20
|
+
url.hash ||
|
|
21
|
+
url.pathname !== '/' ||
|
|
22
|
+
['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
|
|
23
|
+
throw new Error('Managed hosting requires an HTTPS origin without credentials, path, query or port.');
|
|
24
|
+
return url.origin;
|
|
25
|
+
}
|
|
26
|
+
function readProject(root) {
|
|
27
|
+
const project = JSON.parse(readFileSync(path.join(root, manifest), 'utf8'));
|
|
28
|
+
if (project.schemaVersion !== 1 ||
|
|
29
|
+
project.provider !== 'anhedral' ||
|
|
30
|
+
!identifier.test(project.id) ||
|
|
31
|
+
typeof project.name !== 'string' ||
|
|
32
|
+
!project.name.trim() ||
|
|
33
|
+
!['private', 'public'].includes(project.audience) ||
|
|
34
|
+
!(project.ownerId === null || identifier.test(project.ownerId)) ||
|
|
35
|
+
!(project.projectId === null || identifier.test(project.projectId)))
|
|
36
|
+
throw new Error('Invalid managed hosting manifest.');
|
|
37
|
+
return project;
|
|
38
|
+
}
|
|
39
|
+
export function registerManagedApplication(root, audience = 'private') {
|
|
40
|
+
root = realpathSync(root);
|
|
41
|
+
if (existsSync(path.join(root, manifest)))
|
|
42
|
+
return readProject(root);
|
|
43
|
+
if (!['private', 'public'].includes(audience))
|
|
44
|
+
throw new Error('Select private or public hosting.');
|
|
45
|
+
const name = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')).name;
|
|
46
|
+
if (typeof name !== 'string' || !name.trim())
|
|
47
|
+
throw new Error('Application requires a package name.');
|
|
48
|
+
const project = {
|
|
49
|
+
schemaVersion: 1,
|
|
50
|
+
id: randomUUID(),
|
|
51
|
+
name,
|
|
52
|
+
provider: 'anhedral',
|
|
53
|
+
ownerId: null,
|
|
54
|
+
projectId: null,
|
|
55
|
+
audience,
|
|
56
|
+
};
|
|
57
|
+
const ignore = path.join(root, '.gitignore');
|
|
58
|
+
const previous = existsSync(ignore) ? readFileSync(ignore, 'utf8') : '';
|
|
59
|
+
if (!previous.split(/\r?\n/).includes('.anhedral/'))
|
|
60
|
+
writeFileSync(ignore, previous + (previous && !previous.endsWith('\n') ? '\n' : '') + '.anhedral/\n');
|
|
61
|
+
atomicJson(path.join(root, manifest), project);
|
|
62
|
+
return project;
|
|
63
|
+
}
|
|
64
|
+
function git(root, args) {
|
|
65
|
+
return execFileSync('git', args, {
|
|
66
|
+
cwd: root,
|
|
67
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
68
|
+
maxBuffer: maxBytes,
|
|
69
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function source(root) {
|
|
73
|
+
if (realpathSync(git(root, ['rev-parse', '--show-toplevel']).toString().trim()) !== root ||
|
|
74
|
+
git(root, ['status', '--porcelain']).toString().trim())
|
|
75
|
+
throw new Error('Managed versions require a clean application repository.');
|
|
76
|
+
const commit = git(root, ['rev-parse', 'HEAD']).toString().trim();
|
|
77
|
+
if (!/^[a-f0-9]{40,64}$/.test(commit))
|
|
78
|
+
throw new Error('Invalid source commit.');
|
|
79
|
+
return commit;
|
|
80
|
+
}
|
|
81
|
+
function files(root, directory) {
|
|
82
|
+
const entries = [];
|
|
83
|
+
let size = 0;
|
|
84
|
+
const visit = (current) => {
|
|
85
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
86
|
+
const file = path.join(current, entry.name);
|
|
87
|
+
const stat = lstatSync(file);
|
|
88
|
+
if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory()))
|
|
89
|
+
throw new Error('Build artifacts must contain only regular files and directories.');
|
|
90
|
+
if (entry.name === '.git' || entry.name.startsWith('.env'))
|
|
91
|
+
throw new Error('Build artifacts must not contain source credentials, Git state or dependencies.');
|
|
92
|
+
if (stat.isDirectory())
|
|
93
|
+
visit(file);
|
|
94
|
+
else {
|
|
95
|
+
size += stat.size;
|
|
96
|
+
if (size > maxBytes || entries.length >= 10000)
|
|
97
|
+
throw new Error('Managed artifact exceeds staging limits.');
|
|
98
|
+
entries.push(`${path.relative(root, file)}:${digest(readFileSync(file))}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
visit(directory);
|
|
103
|
+
if (!entries.length)
|
|
104
|
+
throw new Error('Build artifact directory is empty.');
|
|
105
|
+
return digest(Buffer.from(entries.sort().join('\n')));
|
|
106
|
+
}
|
|
107
|
+
export async function stageManagedVersion(root, artifactDirectory) {
|
|
108
|
+
root = realpathSync(root);
|
|
109
|
+
return withApplicationLock(root, async () => {
|
|
110
|
+
const project = readProject(root);
|
|
111
|
+
const commit = source(root);
|
|
112
|
+
const artifact = realpathSync(path.resolve(root, artifactDirectory));
|
|
113
|
+
if (!artifact.startsWith(root + path.sep) ||
|
|
114
|
+
!lstatSync(artifact).isDirectory() ||
|
|
115
|
+
artifact.startsWith(path.join(root, '.anhedral') + path.sep))
|
|
116
|
+
throw new Error('Select an application build directory within the repository.');
|
|
117
|
+
const fingerprint = files(root, artifact);
|
|
118
|
+
for (const file of git(root, ['ls-files', '-z']).toString().split('\0').filter(Boolean)) {
|
|
119
|
+
if (path.basename(file).startsWith('.env') && path.basename(file) !== '.env.example')
|
|
120
|
+
throw new Error('Tracked environment credentials cannot be uploaded as source.');
|
|
121
|
+
if (lstatSync(path.join(root, file)).isSymbolicLink() ||
|
|
122
|
+
!lstatSync(path.join(root, file)).isFile())
|
|
123
|
+
throw new Error('Managed source archives do not support symlinks or submodules.');
|
|
124
|
+
}
|
|
125
|
+
if (!git(root, ['show', `HEAD:${manifest}`]).equals(readFileSync(path.join(root, manifest))))
|
|
126
|
+
throw new Error('Commit the exact managed manifest before staging.');
|
|
127
|
+
const archive = git(root, ['archive', '--format=tar', commit]);
|
|
128
|
+
const staging = path.join(root, '.anhedral', 'managed');
|
|
129
|
+
mkdirSync(staging, { recursive: true, mode: 0o700 });
|
|
130
|
+
if (realpathSync(staging) !== staging)
|
|
131
|
+
throw new Error('Managed staging must not be a symbolic link.');
|
|
132
|
+
const stageId = randomUUID();
|
|
133
|
+
const directory = path.join(staging, stageId);
|
|
134
|
+
mkdirSync(directory, { mode: 0o700 });
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(path.join(directory, 'source.tar'), archive, { mode: 0o600 });
|
|
137
|
+
execFileSync('tar', ['-cf', path.join(directory, 'artifact.tar'), '-C', artifact, '.'], {
|
|
138
|
+
stdio: 'ignore',
|
|
139
|
+
});
|
|
140
|
+
const build = readFileSync(path.join(directory, 'artifact.tar'));
|
|
141
|
+
if (build.length > maxBytes || archive.length > maxBytes)
|
|
142
|
+
throw new Error('Managed archives exceed staging limits.');
|
|
143
|
+
if (files(root, artifact) !== fingerprint || source(root) !== commit)
|
|
144
|
+
throw new Error('Source or build artifact changed during staging.');
|
|
145
|
+
const version = {
|
|
146
|
+
schemaVersion: 1,
|
|
147
|
+
projectId: project.id,
|
|
148
|
+
commit,
|
|
149
|
+
sourceSha256: digest(archive),
|
|
150
|
+
artifactSha256: digest(build),
|
|
151
|
+
sourceBytes: archive.length,
|
|
152
|
+
artifactBytes: build.length,
|
|
153
|
+
stageId,
|
|
154
|
+
state: 'staged',
|
|
155
|
+
};
|
|
156
|
+
atomicJson(path.join(directory, 'version.json'), version);
|
|
157
|
+
return version;
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
rmSync(directory, { recursive: true, force: true });
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async function request(send, method, route, key, body) {
|
|
166
|
+
const token = process.env.ANHEDRAL_HOSTING_TOKEN;
|
|
167
|
+
if (!token || /[\r\n]/.test(token))
|
|
168
|
+
throw new Error('Sign in to the configured managed hosting service; ANHEDRAL_HOSTING_TOKEN is required and never stored.');
|
|
169
|
+
const endpoint = `${origin()}/v1${route}`;
|
|
170
|
+
let response;
|
|
171
|
+
try {
|
|
172
|
+
response = await send(endpoint, {
|
|
173
|
+
method,
|
|
174
|
+
redirect: 'error',
|
|
175
|
+
signal: AbortSignal.timeout(30000),
|
|
176
|
+
headers: {
|
|
177
|
+
Authorization: `Bearer ${token}`,
|
|
178
|
+
...(key ? { 'Idempotency-Key': key } : {}),
|
|
179
|
+
'Content-Type': Buffer.isBuffer(body) ? 'application/x-tar' : 'application/json',
|
|
180
|
+
},
|
|
181
|
+
body: body === undefined
|
|
182
|
+
? undefined
|
|
183
|
+
: Buffer.isBuffer(body)
|
|
184
|
+
? new Uint8Array(body)
|
|
185
|
+
: JSON.stringify(body),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
throw new Error('Managed hosting request failed; inspect connectivity and authentication.');
|
|
190
|
+
}
|
|
191
|
+
let value;
|
|
192
|
+
try {
|
|
193
|
+
value = (await response.json());
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
throw new Error('Managed hosting returned unsupported output.');
|
|
197
|
+
}
|
|
198
|
+
if (!response.ok || !value || typeof value !== 'object' || Array.isArray(value))
|
|
199
|
+
throw new Error(`Managed hosting operation failed (HTTP ${response.status}); no deployment success was recorded.`);
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
async function context(root, send) {
|
|
203
|
+
const project = readProject(root);
|
|
204
|
+
if (project.hostingOrigin && project.hostingOrigin !== origin())
|
|
205
|
+
throw new Error('Managed hosting origin differs from the recorded service.');
|
|
206
|
+
const session = await request(send, 'GET', '/session');
|
|
207
|
+
if (typeof session.ownerId !== 'string' ||
|
|
208
|
+
!identifier.test(session.ownerId) ||
|
|
209
|
+
(project.ownerId && session.ownerId !== project.ownerId))
|
|
210
|
+
throw new Error('Managed hosting session differs from the application owner.');
|
|
211
|
+
return { project, ownerId: session.ownerId };
|
|
212
|
+
}
|
|
213
|
+
function identity(value, projectId, ownerId, audience) {
|
|
214
|
+
if (value.projectId !== projectId || value.ownerId !== ownerId || value.audience !== audience)
|
|
215
|
+
throw new Error('Managed hosting receipt differs from project ownership or audience.');
|
|
216
|
+
}
|
|
217
|
+
function staged(root, stageId, project) {
|
|
218
|
+
if (!identifier.test(stageId))
|
|
219
|
+
throw new Error('Invalid stage identity.');
|
|
220
|
+
const directory = path.join(root, '.anhedral', 'managed', stageId);
|
|
221
|
+
if (realpathSync(directory) !== directory)
|
|
222
|
+
throw new Error('Managed staging directory must not be a symbolic link.');
|
|
223
|
+
if (!lstatSync(path.join(directory, 'version.json')).isFile() ||
|
|
224
|
+
lstatSync(path.join(directory, 'version.json')).isSymbolicLink())
|
|
225
|
+
throw new Error('Invalid staged metadata file.');
|
|
226
|
+
const version = JSON.parse(readFileSync(path.join(directory, 'version.json'), 'utf8'));
|
|
227
|
+
if (version.schemaVersion !== 1 ||
|
|
228
|
+
version.projectId !== project.id ||
|
|
229
|
+
version.stageId !== stageId ||
|
|
230
|
+
!/^[a-f0-9]{40,64}$/.test(version.commit))
|
|
231
|
+
throw new Error('Managed stage belongs to another project.');
|
|
232
|
+
for (const [file, expected, bytes] of [
|
|
233
|
+
['source.tar', version.sourceSha256, version.sourceBytes],
|
|
234
|
+
['artifact.tar', version.artifactSha256, version.artifactBytes],
|
|
235
|
+
]) {
|
|
236
|
+
if (!lstatSync(path.join(directory, file)).isFile() ||
|
|
237
|
+
lstatSync(path.join(directory, file)).isSymbolicLink())
|
|
238
|
+
throw new Error('Invalid staged archive file.');
|
|
239
|
+
const archive = readFileSync(path.join(directory, file));
|
|
240
|
+
if (archive.length !== bytes ||
|
|
241
|
+
archive.length > maxBytes ||
|
|
242
|
+
digest(archive) !== expected ||
|
|
243
|
+
lstatSync(path.join(directory, file)).isSymbolicLink())
|
|
244
|
+
throw new Error('Managed staged archive changed.');
|
|
245
|
+
}
|
|
246
|
+
return { version, directory };
|
|
247
|
+
}
|
|
248
|
+
export async function saveManagedVersion(root, stageId, send = fetch) {
|
|
249
|
+
root = realpathSync(root);
|
|
250
|
+
return withApplicationLock(root, async () => {
|
|
251
|
+
const { project, ownerId } = await context(root, send);
|
|
252
|
+
const { version, directory } = staged(root, stageId, project);
|
|
253
|
+
const key = digest(Buffer.from(`${project.id}:${version.commit}:${version.sourceSha256}:${version.artifactSha256}`));
|
|
254
|
+
const created = await request(send, 'POST', '/projects', project.id, {
|
|
255
|
+
clientProjectId: project.id,
|
|
256
|
+
name: project.name,
|
|
257
|
+
audience: project.audience,
|
|
258
|
+
});
|
|
259
|
+
if (typeof created.projectId !== 'string' ||
|
|
260
|
+
!identifier.test(created.projectId) ||
|
|
261
|
+
(project.projectId && created.projectId !== project.projectId))
|
|
262
|
+
throw new Error('Managed project identity changed.');
|
|
263
|
+
identity(created, created.projectId, ownerId, project.audience);
|
|
264
|
+
if (created.clientProjectId !== project.id)
|
|
265
|
+
throw new Error('Backend project differs from the registered client identity.');
|
|
266
|
+
const connected = {
|
|
267
|
+
...project,
|
|
268
|
+
projectId: created.projectId,
|
|
269
|
+
ownerId,
|
|
270
|
+
hostingOrigin: origin(),
|
|
271
|
+
};
|
|
272
|
+
atomicJson(path.join(root, manifest), connected);
|
|
273
|
+
const base = `/projects/${created.projectId}`;
|
|
274
|
+
const receipt = await request(send, 'POST', `${base}/versions`, key, {
|
|
275
|
+
commit: version.commit,
|
|
276
|
+
sourceSha256: version.sourceSha256,
|
|
277
|
+
artifactSha256: version.artifactSha256,
|
|
278
|
+
sourceBytes: version.sourceBytes,
|
|
279
|
+
artifactBytes: version.artifactBytes,
|
|
280
|
+
});
|
|
281
|
+
identity(receipt, created.projectId, ownerId, project.audience);
|
|
282
|
+
if (typeof receipt.versionId !== 'string' || !identifier.test(receipt.versionId))
|
|
283
|
+
throw new Error('Managed version identity is missing.');
|
|
284
|
+
for (const kind of ['source', 'artifact']) {
|
|
285
|
+
const bytes = readFileSync(path.join(directory, `${kind}.tar`));
|
|
286
|
+
const uploaded = await request(send, 'PUT', `${base}/versions/${receipt.versionId}/${kind}`, `${key}:${kind}`, bytes);
|
|
287
|
+
identity(uploaded, created.projectId, ownerId, project.audience);
|
|
288
|
+
if (uploaded.versionId !== receipt.versionId ||
|
|
289
|
+
uploaded.sha256 !== digest(bytes) ||
|
|
290
|
+
uploaded.bytes !== bytes.length)
|
|
291
|
+
throw new Error('Managed upload receipt differs from the staged archive.');
|
|
292
|
+
}
|
|
293
|
+
const saved = await request(send, 'GET', `${base}/versions/${receipt.versionId}`);
|
|
294
|
+
identity(saved, created.projectId, ownerId, project.audience);
|
|
295
|
+
if (saved.versionId !== receipt.versionId ||
|
|
296
|
+
saved.commit !== version.commit ||
|
|
297
|
+
saved.sourceSha256 !== version.sourceSha256 ||
|
|
298
|
+
saved.artifactSha256 !== version.artifactSha256 ||
|
|
299
|
+
saved.state !== 'saved')
|
|
300
|
+
throw new Error('Backend has not confirmed an exact saved source/build version.');
|
|
301
|
+
const result = { ...version, state: 'saved', versionId: receipt.versionId };
|
|
302
|
+
atomicJson(path.join(directory, 'version.json'), result);
|
|
303
|
+
return result;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
export async function deployManagedVersion(root, stageId, send = fetch) {
|
|
307
|
+
root = realpathSync(root);
|
|
308
|
+
return withApplicationLock(root, async () => {
|
|
309
|
+
const { project, ownerId } = await context(root, send);
|
|
310
|
+
const { version } = staged(root, stageId, project);
|
|
311
|
+
if (!project.projectId ||
|
|
312
|
+
version.state !== 'saved' ||
|
|
313
|
+
!version.versionId ||
|
|
314
|
+
!identifier.test(version.versionId))
|
|
315
|
+
throw new Error('Save the exact managed version before requesting deployment.');
|
|
316
|
+
const base = `/projects/${project.projectId}`;
|
|
317
|
+
identity(await request(send, 'GET', base), project.projectId, ownerId, project.audience);
|
|
318
|
+
const receipt = await request(send, 'POST', `${base}/deployments`, `${version.versionId}:${project.audience}`, {
|
|
319
|
+
versionId: version.versionId,
|
|
320
|
+
audience: project.audience,
|
|
321
|
+
commit: version.commit,
|
|
322
|
+
artifactSha256: version.artifactSha256,
|
|
323
|
+
});
|
|
324
|
+
if (typeof receipt.deploymentId !== 'string' || !identifier.test(receipt.deploymentId))
|
|
325
|
+
throw new Error('Backend did not return a deployment identity.');
|
|
326
|
+
const result = await request(send, 'GET', `${base}/deployments/${receipt.deploymentId}`);
|
|
327
|
+
identity(result, project.projectId, ownerId, project.audience);
|
|
328
|
+
if (result.deploymentId !== receipt.deploymentId ||
|
|
329
|
+
result.versionId !== version.versionId ||
|
|
330
|
+
result.commit !== version.commit ||
|
|
331
|
+
result.artifactSha256 !== version.artifactSha256 ||
|
|
332
|
+
!['queued', 'building', 'deployed', 'failed'].includes(String(result.state)))
|
|
333
|
+
throw new Error('Managed deployment evidence differs from the saved version.');
|
|
334
|
+
if (result.state === 'deployed') {
|
|
335
|
+
if (typeof result.url !== 'string')
|
|
336
|
+
throw new Error('A deployed result requires a confirmed URL.');
|
|
337
|
+
const url = new URL(result.url);
|
|
338
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.hash || url.search)
|
|
339
|
+
throw new Error('Invalid managed deployment URL.');
|
|
340
|
+
}
|
|
341
|
+
else
|
|
342
|
+
delete result.url;
|
|
343
|
+
const confirmed = {
|
|
344
|
+
projectId: project.projectId,
|
|
345
|
+
ownerId,
|
|
346
|
+
audience: project.audience,
|
|
347
|
+
deploymentId: result.deploymentId,
|
|
348
|
+
versionId: version.versionId,
|
|
349
|
+
commit: version.commit,
|
|
350
|
+
artifactSha256: version.artifactSha256,
|
|
351
|
+
state: result.state,
|
|
352
|
+
...(result.state === 'deployed' ? { url: result.url } : {}),
|
|
353
|
+
};
|
|
354
|
+
atomicJson(path.join(root, '.anhedral', 'managed-deployment.json'), confirmed);
|
|
355
|
+
return { ...confirmed, productVerification: 'required' };
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
export async function managedApplicationStatus(root, send = fetch) {
|
|
359
|
+
root = realpathSync(root);
|
|
360
|
+
const { project, ownerId } = await context(root, send);
|
|
361
|
+
if (!project.projectId)
|
|
362
|
+
return { project, state: 'not-connected', productVerification: 'unverified' };
|
|
363
|
+
const result = await request(send, 'GET', `/projects/${project.projectId}`);
|
|
364
|
+
identity(result, project.projectId, ownerId, project.audience);
|
|
365
|
+
return {
|
|
366
|
+
project,
|
|
367
|
+
evidence: 'provider-refresh',
|
|
368
|
+
state: ['registered', 'queued', 'building', 'deployed', 'failed'].includes(String(result.state))
|
|
369
|
+
? result.state
|
|
370
|
+
: 'unknown',
|
|
371
|
+
productVerification: 'unverified',
|
|
372
|
+
};
|
|
373
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { applicationStatus } from './delivery.js';
|
|
2
|
+
/** A disposable projection of project/provider evidence; no manually maintained UI state. */
|
|
3
|
+
export declare function writeManagementOverview(root: string, snapshot: Awaited<ReturnType<typeof applicationStatus>>): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { mkdirSync, realpathSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const escape = (value) => String(value ?? '').replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]);
|
|
5
|
+
const link = (url, label) => {
|
|
6
|
+
try {
|
|
7
|
+
const value = new URL(url);
|
|
8
|
+
if (value.protocol === 'https:' && !value.username && !value.password)
|
|
9
|
+
return `<a href="${escape(value.href)}" target="_blank" rel="noreferrer">${escape(label)}</a>`;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
/* Omit invalid links. */
|
|
13
|
+
}
|
|
14
|
+
return escape(label);
|
|
15
|
+
};
|
|
16
|
+
/** A disposable projection of project/provider evidence; no manually maintained UI state. */
|
|
17
|
+
export function writeManagementOverview(root, snapshot) {
|
|
18
|
+
root = realpathSync(root);
|
|
19
|
+
const directory = path.join(root, '.anhedral');
|
|
20
|
+
mkdirSync(directory, { recursive: true });
|
|
21
|
+
if (realpathSync(directory) !== directory)
|
|
22
|
+
throw new Error('Management directory must not be a symbolic link.');
|
|
23
|
+
const latest = snapshot.releases.at(-1);
|
|
24
|
+
const targets = snapshot.architecture
|
|
25
|
+
.map((target) => {
|
|
26
|
+
const receipt = latest?.targets.find((item) => item.target === target.target);
|
|
27
|
+
const provider = snapshot.provider.find((item) => item.target === target.target);
|
|
28
|
+
return `<article><h3>${escape(target.workerName)}</h3><p>${escape(target.directory)}</p><p>${provider ? escape(provider.versions.map((version) => `${version.versionId}: ${version.percentage}%`).join(', ')) : 'Provider not refreshed'}</p><p>${(receipt?.urls ?? []).map((url) => link(url, url)).join('<br>') || 'No recorded URL'}</p><ul>${target.bindings.map((binding) => `<li>${escape(binding.binding)} — ${escape(binding.kind)}</li>`).join('')}</ul><p>Schedules: ${target.scheduled.map(escape).join(', ') || 'None configured'}</p></article>`;
|
|
29
|
+
})
|
|
30
|
+
.join('');
|
|
31
|
+
const history = [...snapshot.releases]
|
|
32
|
+
.reverse()
|
|
33
|
+
.map((release) => `<tr><td>${escape(release.startedAt)}</td><td>${escape(release.state)}</td><td>${escape(release.commit)}</td><td>${escape(release.id)}</td></tr>`)
|
|
34
|
+
.join('');
|
|
35
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><title>${escape(snapshot.project.name)} · Anhedral</title><style>body{font:16px system-ui;background:#111318;color:#e7e9ef;max-width:1100px;margin:3rem auto;padding:0 1.5rem}a{color:#a9c7ff}h1{font-size:2rem}h2{margin-top:2rem}article{border:1px solid #363c49;border-radius:12px;padding:1rem;margin:1rem 0}p,li{line-height:1.6}table{width:100%;border-collapse:collapse;font-size:.85rem}td,th{text-align:left;padding:.6rem;border-bottom:1px solid #363c49;overflow-wrap:anywhere}code{overflow-wrap:anywhere}.muted{color:#a5adbd}</style></head><body><h1>${escape(snapshot.project.name)}</h1><p>Customer-owned · ${escape(snapshot.project.provider)} · ${escape(snapshot.project.audience)}</p><p class="muted">Snapshot ${escape(new Date().toISOString())} · ${escape(snapshot.evidence)}. Run <code>anhedral manage</code> to refresh. This file is local, not an access control or a hosted registry.</p><p>Account: <code>${escape(snapshot.project.accountId ?? 'Not connected')}</code></p><p>${snapshot.management ? link(snapshot.management, 'Open provider dashboard') : 'Connect an account to manage hosting.'}</p><p>Product acceptance: ${escape(snapshot.productVerification)}. Deployment evidence does not establish working user flows.</p><h2>Applications and resources</h2>${targets || '<p>No Cloudflare targets.</p>'}<h2>Release history</h2><table><thead><tr><th>Started</th><th>State</th><th>Source commit</th><th>Release</th></tr></thead><tbody>${history}</tbody></table></body></html>`;
|
|
36
|
+
const file = path.join(directory, 'overview.html');
|
|
37
|
+
// Refuse a pre-existing symlink through realpath; use a fresh temporary file and atomic rename in the caller's state directory.
|
|
38
|
+
const temporary = path.join(directory, `overview-${randomUUID()}.tmp`);
|
|
39
|
+
writeFileSync(temporary, html, { mode: 0o600, flag: 'wx' });
|
|
40
|
+
try {
|
|
41
|
+
renameSync(temporary, file);
|
|
42
|
+
return file;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
rmSync(temporary, { force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare function
|
|
1
|
+
import type { RecipeContext } from '../registry/types.js';
|
|
2
|
+
export declare function scaffoldElectron({ root, options }: RecipeContext): Promise<void>;
|
|
@@ -3,8 +3,9 @@ import { writeReactLintConfig } from './lint.js';
|
|
|
3
3
|
import { writeFile } from '../util.js';
|
|
4
4
|
import { DESKTOP_DEPENDENCIES } from '../dependencies.js';
|
|
5
5
|
import { childPackageName, htmlText, identifierSegment, jsString } from '../render.js';
|
|
6
|
-
export async function
|
|
7
|
-
const
|
|
6
|
+
export async function scaffoldElectron({ root, options }) {
|
|
7
|
+
const projectName = options.name;
|
|
8
|
+
const displayName = options.name;
|
|
8
9
|
const dir = path.join(root, 'apps/desktop');
|
|
9
10
|
writePackageJson(dir, projectName);
|
|
10
11
|
writeReactLintConfig(dir);
|
|
@@ -39,10 +40,7 @@ function writePackageJson(dir, projectName) {
|
|
|
39
40
|
directories: {
|
|
40
41
|
output: 'release',
|
|
41
42
|
},
|
|
42
|
-
files: [
|
|
43
|
-
'dist/**/*',
|
|
44
|
-
'package.json',
|
|
45
|
-
],
|
|
43
|
+
files: ['dist/**/*', 'package.json'],
|
|
46
44
|
mac: {
|
|
47
45
|
target: ['dmg', 'zip'],
|
|
48
46
|
},
|
|
@@ -55,15 +53,27 @@ function writePackageJson(dir, projectName) {
|
|
|
55
53
|
},
|
|
56
54
|
},
|
|
57
55
|
dependencies: DESKTOP_DEPENDENCIES.dependencies,
|
|
58
|
-
devDependencies: {
|
|
56
|
+
devDependencies: {
|
|
57
|
+
...DESKTOP_DEPENDENCIES.devDependencies,
|
|
58
|
+
'@workspace/eslint-config': 'workspace:*',
|
|
59
|
+
},
|
|
59
60
|
}, null, 2) + '\n');
|
|
60
61
|
}
|
|
61
62
|
function writeTsConfig(dir) {
|
|
62
63
|
writeFile(path.join(dir, 'tsconfig.json'), JSON.stringify({
|
|
63
|
-
compilerOptions: {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
compilerOptions: {
|
|
65
|
+
target: 'ES2022',
|
|
66
|
+
lib: ['ES2022', 'DOM'],
|
|
67
|
+
module: 'ESNext',
|
|
68
|
+
moduleResolution: 'Bundler',
|
|
69
|
+
jsx: 'react-jsx',
|
|
70
|
+
strict: true,
|
|
71
|
+
skipLibCheck: true,
|
|
72
|
+
esModuleInterop: true,
|
|
73
|
+
noEmit: true,
|
|
74
|
+
types: ['node', 'vite/client'],
|
|
75
|
+
paths: { '@/*': ['./src/renderer/*'] },
|
|
76
|
+
},
|
|
67
77
|
include: ['src/renderer/**/*', 'vite.config.ts'],
|
|
68
78
|
}, null, 2) + '\n');
|
|
69
79
|
writeFile(path.join(dir, 'tsconfig.main.json'), JSON.stringify({
|
|
@@ -142,8 +152,8 @@ function stop(child) {
|
|
|
142
152
|
if (child && child.exitCode === null && !child.killed) child.kill('SIGTERM');
|
|
143
153
|
}
|
|
144
154
|
|
|
145
|
-
async function isServerReady(url
|
|
146
|
-
try { return (await fetch(url
|
|
155
|
+
async function isServerReady(url) {
|
|
156
|
+
try { return (await fetch(url)).ok; }
|
|
147
157
|
catch { return false; }
|
|
148
158
|
}
|
|
149
159
|
|
|
@@ -151,7 +161,7 @@ async function waitForServer(server, url, timeoutMs = 30_000) {
|
|
|
151
161
|
const deadline = Date.now() + timeoutMs;
|
|
152
162
|
while (Date.now() < deadline) {
|
|
153
163
|
if (server.exitCode !== null) throw new Error('Vite exited before the dev server was ready.');
|
|
154
|
-
if (await isServerReady(url
|
|
164
|
+
if (await isServerReady(url)) return;
|
|
155
165
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
156
166
|
}
|
|
157
167
|
throw new Error('Timed out waiting for Vite at ' + url + '.');
|
|
@@ -360,7 +370,7 @@ function App() {
|
|
|
360
370
|
<section className="flex flex-col gap-3">
|
|
361
371
|
<h1 className="text-3xl font-semibold">{${displayNameLiteral}}</h1>
|
|
362
372
|
<p className="max-w-2xl text-muted-foreground">
|
|
363
|
-
${
|
|
373
|
+
${'Electron + shadcn/ui desktop client ready for your application.'}
|
|
364
374
|
</p>
|
|
365
375
|
</section>
|
|
366
376
|
<Button className="self-start" onClick={() => window.location.reload()}>Reload</Button>
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export declare function
|
|
1
|
+
import type { RecipeContext } from '../registry/types.js';
|
|
2
|
+
export declare function scaffoldWxt({ root, options }: RecipeContext): Promise<void>;
|