anhedral 0.8.5 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -34
- package/assets/anhedral-cli-init-technical.svg +8 -8
- package/assets/anhedral-cli-init.svg +9 -9
- package/assets/security/LICENSES.txt +63 -0
- package/assets/security/audit-deps.mjs +295 -0
- package/assets/security/braces-regression.mjs +102 -0
- package/assets/security/braces@3.0.3.patch +285 -0
- package/assets/security/decode-uri-component@0.2.2.patch +149 -0
- package/assets/security/decoder-regression.mjs +37 -0
- package/assets/security/forge-regression.mjs +46 -0
- package/assets/security/manifest.json +53 -0
- package/assets/security/node-forge@1.4.0.patch +26 -0
- package/dist/access.d.ts +10 -0
- package/dist/access.js +93 -0
- package/dist/auth-schema-snapshot.d.ts +346 -0
- package/dist/auth-schema-snapshot.js +347 -0
- package/dist/auth-schema.d.ts +1 -0
- package/dist/auth-schema.js +57 -0
- package/dist/bin.js +353 -10
- package/dist/bounded-file.d.ts +2 -0
- package/dist/bounded-file.js +30 -0
- package/dist/capabilities.d.ts +19 -33
- package/dist/capabilities.js +73 -71
- package/dist/cloudflare.d.ts +14 -0
- package/dist/cloudflare.js +66 -0
- package/dist/command.js +12 -3
- package/dist/delivery.d.ts +118 -0
- package/dist/delivery.js +611 -0
- package/dist/dependencies.d.ts +22 -0
- package/dist/dependencies.js +109 -67
- package/dist/dependency-order.d.ts +2 -0
- package/dist/dependency-order.js +24 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +12 -1
- package/dist/managed-hosting.d.ts +49 -0
- package/dist/managed-hosting.js +373 -0
- package/dist/management.d.ts +3 -0
- package/dist/management.js +47 -0
- package/dist/platforms/desktop.d.ts +2 -2
- package/dist/platforms/desktop.js +22 -12
- package/dist/platforms/extension.d.ts +2 -2
- package/dist/platforms/extension.js +12 -4
- package/dist/platforms/mobile.d.ts +2 -2
- package/dist/platforms/mobile.js +13 -7
- package/dist/pnpm-policy.d.ts +4 -0
- package/dist/pnpm-policy.js +66 -0
- package/dist/project-state.d.ts +28 -0
- package/dist/project-state.js +226 -0
- package/dist/provider-readiness.d.ts +30 -0
- package/dist/provider-readiness.js +87 -0
- package/dist/provision.d.ts +46 -0
- package/dist/provision.js +197 -0
- package/dist/readiness.d.ts +4 -1
- package/dist/readiness.js +80 -8
- package/dist/registry/add.d.ts +22 -0
- package/dist/registry/add.js +198 -0
- package/dist/registry/bootstrap.d.ts +2 -0
- package/dist/registry/bootstrap.js +14 -0
- package/dist/registry/commands.d.ts +2 -0
- package/dist/registry/commands.js +99 -0
- package/dist/registry/context.d.ts +3 -0
- package/dist/registry/context.js +21 -0
- package/dist/registry/options.d.ts +2 -0
- package/dist/registry/options.js +65 -0
- package/dist/registry/receipt.d.ts +3 -0
- package/dist/registry/receipt.js +73 -0
- package/dist/registry/remote.d.ts +25 -0
- package/dist/registry/remote.js +168 -0
- package/dist/registry/resolve.d.ts +10 -0
- package/dist/registry/resolve.js +98 -0
- package/dist/registry/types.d.ts +64 -0
- package/dist/registry/usage.d.ts +1 -0
- package/dist/registry/usage.js +38 -0
- package/dist/render.js +3 -3
- package/dist/repository.d.ts +24 -0
- package/dist/repository.js +96 -0
- package/dist/scaffold/api.d.ts +2 -0
- package/dist/scaffold/api.js +13 -0
- package/dist/scaffold/auth.d.ts +3 -0
- package/dist/scaffold/auth.js +95 -0
- package/dist/scaffold/bootstrap.d.ts +2 -0
- package/dist/scaffold/bootstrap.js +34 -0
- package/dist/scaffold/compose.d.ts +4 -0
- package/dist/scaffold/compose.js +34 -0
- package/dist/scaffold/database.d.ts +2 -0
- package/dist/scaffold/database.js +43 -0
- package/dist/scaffold/documentation.d.ts +3 -0
- package/dist/scaffold/documentation.js +91 -0
- package/dist/scaffold/environment.d.ts +2 -0
- package/dist/scaffold/environment.js +5 -0
- package/dist/scaffold/files.d.ts +25 -0
- package/dist/scaffold/files.js +108 -0
- package/dist/scaffold/integrations.d.ts +3 -0
- package/dist/scaffold/integrations.js +7 -0
- package/dist/scaffold/services.d.ts +5 -0
- package/dist/scaffold/services.js +84 -0
- package/dist/scaffold/web.d.ts +2 -0
- package/dist/scaffold/web.js +102 -0
- package/dist/scaffold/workspace.d.ts +3 -0
- package/dist/scaffold/workspace.js +150 -0
- package/dist/security-backports.d.ts +3 -0
- package/dist/security-backports.js +26 -0
- package/dist/stack-registry.d.ts +80 -0
- package/dist/stack-registry.js +424 -0
- package/dist/standard.d.ts +6 -17
- package/dist/standard.js +41 -585
- package/dist/transaction.js +46 -26
- package/dist/util.js +12 -2
- package/dist/vercel.d.ts +98 -0
- package/dist/vercel.js +796 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/vinext.d.ts +1 -0
- package/dist/vinext.js +83 -0
- package/dist/workspace-graph.d.ts +7 -0
- package/dist/workspace-graph.js +73 -0
- package/docs/application-stack-standard.md +75 -674
- package/docs/delivery-workflow.md +83 -0
- package/docs/managed-hosting-architecture.md +87 -0
- package/docs/sponsorships.md +25 -0
- package/package.json +23 -28
- package/dist/project.d.ts +0 -4
- package/dist/standard-products.d.ts +0 -2
- package/dist/standard-products.js +0 -6
- /package/assets/{images/svg/logo-white-subtract.svg → anhedral-mark.svg} +0 -0
- /package/dist/{project.js → registry/types.js} +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { addStackItems } from './add.js';
|
|
4
|
+
import { buildSourceRegistry, loadSourceRegistry } from './remote.js';
|
|
5
|
+
import { STACK_REGISTRY, getStackItem } from '../stack-registry.js';
|
|
6
|
+
export async function runRegistry(args, json) {
|
|
7
|
+
if (args[0] === 'build') {
|
|
8
|
+
args = args.filter((arg) => arg !== '--json');
|
|
9
|
+
if (args.length !== 4 || args[2] !== '--output' || !args[1] || !args[3])
|
|
10
|
+
throw new Error('Use registry build <manifest> --output <registry.json>.');
|
|
11
|
+
const source = path.resolve(args[1]);
|
|
12
|
+
const output = path.resolve(args[3]);
|
|
13
|
+
if (source === output)
|
|
14
|
+
throw new Error('Registry output must differ from its authored manifest.');
|
|
15
|
+
const registry = buildSourceRegistry(source);
|
|
16
|
+
mkdirSync(path.dirname(output), { recursive: true });
|
|
17
|
+
writeFileSync(output, JSON.stringify(registry, null, 2) + '\n');
|
|
18
|
+
console.log(JSON.stringify({ output, items: registry.items.length }));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const registryFlag = args.indexOf('--registry');
|
|
22
|
+
if (registryFlag >= 0) {
|
|
23
|
+
const source = args[registryFlag + 1];
|
|
24
|
+
if (!source || source.startsWith('-'))
|
|
25
|
+
throw new Error('Missing registry source.');
|
|
26
|
+
const positional = args.filter((arg, index) => index !== registryFlag && index !== registryFlag + 1 && arg !== '--json');
|
|
27
|
+
const registry = await loadSourceRegistry(source);
|
|
28
|
+
if (positional.length === 1 && positional[0] === 'list')
|
|
29
|
+
console.log(JSON.stringify(registry, null, 2));
|
|
30
|
+
else if (positional.length === 2 && positional[0] === 'view') {
|
|
31
|
+
const item = registry.registry.items.find((item) => item.name === positional[1]);
|
|
32
|
+
if (!item)
|
|
33
|
+
throw new Error('Unknown remote registry item.');
|
|
34
|
+
console.log(JSON.stringify({ source: registry.source, integrity: registry.integrity, item }, null, 2));
|
|
35
|
+
}
|
|
36
|
+
else
|
|
37
|
+
throw new Error('Use registry list|view [item] --registry <file|https-url>.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const positional = args.filter((arg) => arg !== '--json');
|
|
41
|
+
if (positional.includes('--help') || positional.includes('-h')) {
|
|
42
|
+
console.log('anhedral registry list|view [item] [--json]');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (positional.some((arg) => arg.startsWith('-')))
|
|
46
|
+
throw new Error('Unknown registry option.');
|
|
47
|
+
const [action = 'list', name, ...extra] = positional;
|
|
48
|
+
if (action === 'list' && !name && !extra.length) {
|
|
49
|
+
console.log(json
|
|
50
|
+
? JSON.stringify(STACK_REGISTRY, null, 2)
|
|
51
|
+
: STACK_REGISTRY.items
|
|
52
|
+
.map((item) => `${item.name} (${item.type}) — ${item.description}`)
|
|
53
|
+
.join('\n'));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (action === 'view' && name && !extra.length) {
|
|
57
|
+
console.log(JSON.stringify(getStackItem(name), null, 2));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
throw new Error('Use registry list or registry view <item>.');
|
|
61
|
+
}
|
|
62
|
+
export async function runAdd(args) {
|
|
63
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
64
|
+
console.log('anhedral add <directory> <items...> [--registry <file|https-url>] [--integrity sha256-...] [--dry-run] [--skip-install] [--json]');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
let source;
|
|
68
|
+
let integrity;
|
|
69
|
+
const positional = [];
|
|
70
|
+
for (let index = 0; index < args.length; index++) {
|
|
71
|
+
const arg = args[index];
|
|
72
|
+
if (['--dry-run', '--skip-install', '--json'].includes(arg))
|
|
73
|
+
continue;
|
|
74
|
+
if (arg === '--integrity') {
|
|
75
|
+
if (integrity || !args[index + 1] || !/^sha256-[A-Za-z0-9+/]{43}=$/.test(args[index + 1]))
|
|
76
|
+
throw new Error('Specify one SHA-256 registry integrity.');
|
|
77
|
+
integrity = args[++index];
|
|
78
|
+
}
|
|
79
|
+
else if (arg === '--registry') {
|
|
80
|
+
if (source || !args[index + 1] || args[index + 1].startsWith('-'))
|
|
81
|
+
throw new Error('Specify one registry source.');
|
|
82
|
+
source = args[++index];
|
|
83
|
+
}
|
|
84
|
+
else if (arg.startsWith('-'))
|
|
85
|
+
throw new Error('Unknown add option.');
|
|
86
|
+
else
|
|
87
|
+
positional.push(arg);
|
|
88
|
+
}
|
|
89
|
+
const [directory, ...items] = positional;
|
|
90
|
+
if (!directory || !items.length)
|
|
91
|
+
throw new Error('Use add <directory> <items...>.');
|
|
92
|
+
console.log(JSON.stringify(await addStackItems(path.resolve(directory), {
|
|
93
|
+
items,
|
|
94
|
+
registry: source,
|
|
95
|
+
integrity,
|
|
96
|
+
dryRun: args.includes('--dry-run'),
|
|
97
|
+
skipInstall: args.includes('--skip-install'),
|
|
98
|
+
}), null, 2));
|
|
99
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { RecipeContext, StandardOptions } from './types.js';
|
|
2
|
+
export declare function createRecipeContext(root: string, options: StandardOptions): RecipeContext;
|
|
3
|
+
export declare function createResolutionContext(selection: Pick<StandardOptions, 'products' | 'hosting'> & Partial<Pick<StandardOptions, 'name'>>): RecipeContext;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function createRecipeContext(root, options) {
|
|
2
|
+
const selected = new Set(options.products);
|
|
3
|
+
return {
|
|
4
|
+
root,
|
|
5
|
+
options,
|
|
6
|
+
has: (product) => selected.has(product),
|
|
7
|
+
runtimeApp: selected.has('hono') ? 'api' : 'web',
|
|
8
|
+
bindings: {},
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function createResolutionContext(selection) {
|
|
12
|
+
return createRecipeContext('', {
|
|
13
|
+
root: '',
|
|
14
|
+
name: selection.name ?? 'app',
|
|
15
|
+
skipInstall: true,
|
|
16
|
+
dryRun: true,
|
|
17
|
+
json: false,
|
|
18
|
+
initializeGit: false,
|
|
19
|
+
...selection,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { resolveStandardProducts } from './resolve.js';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { packageNameFromText } from '../render.js';
|
|
4
|
+
import { STANDARD_PRODUCTS } from '../stack-registry.js';
|
|
5
|
+
function readGitFlag(token, previous) {
|
|
6
|
+
const value = token === '--git';
|
|
7
|
+
if (previous !== undefined && value !== previous)
|
|
8
|
+
throw new Error('Conflicting Git options.');
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function readHostingFlag(value, previous, specified) {
|
|
12
|
+
if (value !== 'cloudflare' && value !== 'vercel')
|
|
13
|
+
throw new Error('hosting must be cloudflare or vercel.');
|
|
14
|
+
if (specified && previous !== value)
|
|
15
|
+
throw new Error('Conflicting hosting options.');
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function parseStandardFlags(remaining) {
|
|
19
|
+
const products = new Set();
|
|
20
|
+
let hosting = 'cloudflare';
|
|
21
|
+
let hostingSpecified = false;
|
|
22
|
+
let gitSpecified;
|
|
23
|
+
for (let index = 0; index < remaining.length; index++) {
|
|
24
|
+
const token = remaining[index];
|
|
25
|
+
if (['--skip-install', '--dry-run', '--json', '--verbose'].includes(token))
|
|
26
|
+
continue;
|
|
27
|
+
if (token === '--git' || token === '--no-git') {
|
|
28
|
+
gitSpecified = readGitFlag(token, gitSpecified);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (token === '--hosting' || token.startsWith('--hosting=')) {
|
|
32
|
+
const value = token === '--hosting' ? remaining[++index] : token.slice('--hosting='.length);
|
|
33
|
+
hosting = readHostingFlag(value, hosting, hostingSpecified);
|
|
34
|
+
hostingSpecified = true;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const value = token.replace(/^--/, '');
|
|
38
|
+
if (!STANDARD_PRODUCTS.includes(value)) {
|
|
39
|
+
throw new Error(`Unknown standard product: ${token}. Select only required products.`);
|
|
40
|
+
}
|
|
41
|
+
products.add(value);
|
|
42
|
+
}
|
|
43
|
+
if (!hostingSpecified && products.has('next'))
|
|
44
|
+
hosting = 'vercel';
|
|
45
|
+
return { products, hosting, hostingSpecified, gitSpecified };
|
|
46
|
+
}
|
|
47
|
+
export function parseStandardOptions(command, args) {
|
|
48
|
+
const remaining = [...args];
|
|
49
|
+
const directory = command === 'new' ? remaining.shift() : process.cwd();
|
|
50
|
+
if (!directory || directory.startsWith('-'))
|
|
51
|
+
throw new Error('new requires a destination directory.');
|
|
52
|
+
const root = path.resolve(directory);
|
|
53
|
+
const { products, hosting, hostingSpecified, gitSpecified } = parseStandardFlags(remaining);
|
|
54
|
+
resolveStandardProducts(products, hosting, hostingSpecified);
|
|
55
|
+
return {
|
|
56
|
+
root,
|
|
57
|
+
name: packageNameFromText(path.basename(root)),
|
|
58
|
+
products: [...products].sort(),
|
|
59
|
+
hosting,
|
|
60
|
+
skipInstall: remaining.includes('--skip-install'),
|
|
61
|
+
dryRun: remaining.includes('--dry-run'),
|
|
62
|
+
json: remaining.includes('--json'),
|
|
63
|
+
initializeGit: gitSpecified ?? command === 'new',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { json, readJson } from '../scaffold/files.js';
|
|
5
|
+
const excluded = new Set([
|
|
6
|
+
'.pnpm-store',
|
|
7
|
+
'.artifacts',
|
|
8
|
+
'.git',
|
|
9
|
+
'node_modules',
|
|
10
|
+
'.next',
|
|
11
|
+
'.open-next',
|
|
12
|
+
'.wrangler',
|
|
13
|
+
'.turbo',
|
|
14
|
+
'dist',
|
|
15
|
+
'.output',
|
|
16
|
+
'.expo',
|
|
17
|
+
'.fallow',
|
|
18
|
+
'.anhedral',
|
|
19
|
+
'.anhedral.lock',
|
|
20
|
+
'.anhedral-txn',
|
|
21
|
+
'anhedral.registry.json',
|
|
22
|
+
]);
|
|
23
|
+
export function sourceFiles(root) {
|
|
24
|
+
const files = new Map();
|
|
25
|
+
const visit = (directory) => {
|
|
26
|
+
for (const entry of readdirSync(path.join(root, directory), { withFileTypes: true })) {
|
|
27
|
+
if (excluded.has(entry.name))
|
|
28
|
+
continue;
|
|
29
|
+
const relative = directory ? `${directory}/${entry.name}` : entry.name;
|
|
30
|
+
// Local credentials are never seeded into an addition transaction or fingerprint receipt.
|
|
31
|
+
if (/^(?:\.env|\.dev\.vars)(?:\.|$)/.test(entry.name) && !entry.name.endsWith('.example'))
|
|
32
|
+
continue;
|
|
33
|
+
if (entry.isSymbolicLink())
|
|
34
|
+
throw new Error(`Source symlinks are unsupported: ${relative}.`);
|
|
35
|
+
if (entry.isDirectory())
|
|
36
|
+
visit(relative);
|
|
37
|
+
else if (entry.isFile())
|
|
38
|
+
files.set(relative, createHash('sha256')
|
|
39
|
+
.update(readFileSync(path.join(root, relative)))
|
|
40
|
+
.digest('hex'));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
visit('');
|
|
44
|
+
return files;
|
|
45
|
+
}
|
|
46
|
+
export function recordScaffoldFiles(root) {
|
|
47
|
+
const receipt = readJson(root, 'anhedral.registry.json');
|
|
48
|
+
json(root, 'anhedral.registry.json', {
|
|
49
|
+
...receipt,
|
|
50
|
+
files: Object.fromEntries(sourceFiles(root)),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function assertSafeTarget(root, relative) {
|
|
54
|
+
if (!relative ||
|
|
55
|
+
path.isAbsolute(relative) ||
|
|
56
|
+
relative.includes('\\') ||
|
|
57
|
+
relative
|
|
58
|
+
.split('/')
|
|
59
|
+
.some((part) => !part ||
|
|
60
|
+
part === '.' ||
|
|
61
|
+
part === '..' ||
|
|
62
|
+
part.includes(':') ||
|
|
63
|
+
/[\x00-\x1f]/.test(part) ||
|
|
64
|
+
/[. ]$/.test(part) ||
|
|
65
|
+
/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(part)))
|
|
66
|
+
throw new Error(`Unsafe registry target: ${relative}.`);
|
|
67
|
+
let current = root;
|
|
68
|
+
for (const part of relative.split('/')) {
|
|
69
|
+
current = path.join(current, part);
|
|
70
|
+
if (existsSync(current) && lstatSync(current).isSymbolicLink())
|
|
71
|
+
throw new Error(`Registry target traverses a symlink: ${relative}.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface SourceItem {
|
|
2
|
+
name: string;
|
|
3
|
+
version: string;
|
|
4
|
+
registryDependencies: string[];
|
|
5
|
+
files: {
|
|
6
|
+
target: string;
|
|
7
|
+
content: string;
|
|
8
|
+
}[];
|
|
9
|
+
}
|
|
10
|
+
export interface SourceRegistry {
|
|
11
|
+
schemaVersion: 1;
|
|
12
|
+
name: string;
|
|
13
|
+
items: SourceItem[];
|
|
14
|
+
}
|
|
15
|
+
export interface RemoteRegistry {
|
|
16
|
+
source: string;
|
|
17
|
+
integrity: string;
|
|
18
|
+
registry: SourceRegistry;
|
|
19
|
+
}
|
|
20
|
+
/** Declarative source only: no executable recipe callbacks, hooks or destination templates. */
|
|
21
|
+
export declare function validateSourceRegistry(value: unknown): SourceRegistry;
|
|
22
|
+
export declare function loadSourceRegistry(source: string, fetcher?: typeof fetch): Promise<RemoteRegistry>;
|
|
23
|
+
export declare function resolveSourceItems(registry: SourceRegistry, selected: readonly string[]): SourceItem[];
|
|
24
|
+
/** Compile repository-relative source paths into a distributable JSON payload. */
|
|
25
|
+
export declare function buildSourceRegistry(manifestFile: string): SourceRegistry;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readBoundedFile } from '../bounded-file.js';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { dependencyOrder } from '../dependency-order.js';
|
|
5
|
+
import { assertSafeTarget } from './receipt.js';
|
|
6
|
+
const identifier = /^[a-z][a-z0-9-]{0,62}$/;
|
|
7
|
+
const limit = 2 * 1024 * 1024;
|
|
8
|
+
/** Declarative source only: no executable recipe callbacks, hooks or destination templates. */
|
|
9
|
+
export function validateSourceRegistry(value) {
|
|
10
|
+
if (!value || typeof value !== 'object')
|
|
11
|
+
throw new Error('Invalid source registry.');
|
|
12
|
+
const registry = value;
|
|
13
|
+
if (registry.schemaVersion !== 1 ||
|
|
14
|
+
typeof registry.name !== 'string' ||
|
|
15
|
+
!identifier.test(registry.name) ||
|
|
16
|
+
!Array.isArray(registry.items) ||
|
|
17
|
+
!registry.items.length ||
|
|
18
|
+
registry.items.length > 100)
|
|
19
|
+
throw new Error('Unsupported source registry schema.');
|
|
20
|
+
const names = new Set();
|
|
21
|
+
const files = new Set();
|
|
22
|
+
for (const item of registry.items) {
|
|
23
|
+
if (!item ||
|
|
24
|
+
typeof item.name !== 'string' ||
|
|
25
|
+
!identifier.test(item.name) ||
|
|
26
|
+
names.has(item.name) ||
|
|
27
|
+
typeof item.version !== 'string' ||
|
|
28
|
+
!/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(item.version) ||
|
|
29
|
+
!Array.isArray(item.registryDependencies) ||
|
|
30
|
+
!item.registryDependencies.every((id) => typeof id === 'string' && identifier.test(id)) ||
|
|
31
|
+
!Array.isArray(item.files) ||
|
|
32
|
+
!item.files.length)
|
|
33
|
+
throw new Error('Invalid or duplicate source registry item.');
|
|
34
|
+
names.add(item.name);
|
|
35
|
+
for (const file of item.files) {
|
|
36
|
+
if (!file || typeof file.target !== 'string' || typeof file.content !== 'string')
|
|
37
|
+
throw new Error('Invalid registry file.');
|
|
38
|
+
assertSafeTarget('/registry-validation', file.target);
|
|
39
|
+
if (!/^(?:apps|packages)\/[a-z][a-z0-9-]*\//.test(file.target) ||
|
|
40
|
+
file.target
|
|
41
|
+
.split('/')
|
|
42
|
+
.some((part) => part.startsWith('.') ||
|
|
43
|
+
['node_modules', 'dist', 'release', 'anhedral.registry.json'].includes(part)) ||
|
|
44
|
+
files.has(file.target))
|
|
45
|
+
throw new Error(`Unsafe or duplicate source target: ${file.target}.`);
|
|
46
|
+
files.add(file.target);
|
|
47
|
+
if (path.basename(file.target) === 'package.json') {
|
|
48
|
+
const manifest = JSON.parse(file.content);
|
|
49
|
+
if (!manifest ||
|
|
50
|
+
typeof manifest !== 'object' ||
|
|
51
|
+
Array.isArray(manifest) ||
|
|
52
|
+
manifest.private !== true ||
|
|
53
|
+
typeof manifest.name !== 'string' ||
|
|
54
|
+
!/^@workspace\/[a-z][a-z0-9-]*$/.test(manifest.name))
|
|
55
|
+
throw new Error('Remote workspace manifests must be private @workspace packages.');
|
|
56
|
+
if (manifest.scripts &&
|
|
57
|
+
Object.keys(manifest.scripts).some((key) => /^(?:preinstall|install|postinstall|preprepare|prepare|postprepare|prepublish|prepublishOnly|pnpm:devPreinstall)$/.test(key)))
|
|
58
|
+
throw new Error('Remote installation hooks are unsupported.');
|
|
59
|
+
for (const field of [
|
|
60
|
+
'dependencies',
|
|
61
|
+
'devDependencies',
|
|
62
|
+
'optionalDependencies',
|
|
63
|
+
'peerDependencies',
|
|
64
|
+
]) {
|
|
65
|
+
if (manifest[field] &&
|
|
66
|
+
(typeof manifest[field] !== 'object' ||
|
|
67
|
+
Array.isArray(manifest[field]) ||
|
|
68
|
+
Object.values(manifest[field]).some((version) => typeof version !== 'string' ||
|
|
69
|
+
!/^(?:workspace:[*^~]|[~^]?\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)$/.test(version))))
|
|
70
|
+
throw new Error('Remote dependencies require versioned npm or workspace references.');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
dependencyOrder(new Map(registry.items.map((item) => [item.name, item.registryDependencies])), 'Remote registry');
|
|
76
|
+
return structuredClone(registry);
|
|
77
|
+
}
|
|
78
|
+
export async function loadSourceRegistry(source, fetcher = fetch) {
|
|
79
|
+
let bytes;
|
|
80
|
+
if (/^https?:/.test(source)) {
|
|
81
|
+
const url = new URL(source);
|
|
82
|
+
if (url.protocol !== 'https:' ||
|
|
83
|
+
url.username ||
|
|
84
|
+
url.password ||
|
|
85
|
+
url.hash ||
|
|
86
|
+
url.search ||
|
|
87
|
+
!/^[a-z0-9.-]+$/i.test(url.hostname) ||
|
|
88
|
+
url.hostname === 'localhost' ||
|
|
89
|
+
!url.hostname.includes('.') ||
|
|
90
|
+
/^\d+(?:\.\d+){3}$/.test(url.hostname))
|
|
91
|
+
throw new Error('Remote registries require a public HTTPS URL without credentials or query parameters.');
|
|
92
|
+
const response = await fetcher(url, { redirect: 'error', signal: AbortSignal.timeout(15_000) });
|
|
93
|
+
if (!response.ok || !response.body)
|
|
94
|
+
throw new Error('Cannot retrieve source registry.');
|
|
95
|
+
const reader = response.body.getReader();
|
|
96
|
+
const chunks = [];
|
|
97
|
+
let size = 0;
|
|
98
|
+
try {
|
|
99
|
+
while (true) {
|
|
100
|
+
const result = await reader.read();
|
|
101
|
+
if (result.done)
|
|
102
|
+
break;
|
|
103
|
+
size += result.value.byteLength;
|
|
104
|
+
if (size > limit)
|
|
105
|
+
throw new Error('Source registry exceeds 2 MiB.');
|
|
106
|
+
chunks.push(result.value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await reader.cancel();
|
|
111
|
+
}
|
|
112
|
+
bytes = Buffer.concat(chunks);
|
|
113
|
+
source = url.href;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
source = path.resolve(source);
|
|
117
|
+
bytes = readBoundedFile(source, limit);
|
|
118
|
+
}
|
|
119
|
+
if (bytes.length > limit)
|
|
120
|
+
throw new Error('Source registry exceeds 2 MiB.');
|
|
121
|
+
return {
|
|
122
|
+
source,
|
|
123
|
+
integrity: `sha256-${createHash('sha256').update(bytes).digest('base64')}`,
|
|
124
|
+
registry: validateSourceRegistry(JSON.parse(bytes.toString('utf8'))),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export function resolveSourceItems(registry, selected) {
|
|
128
|
+
const catalog = new Map(registry.items.map((item) => [item.name, item]));
|
|
129
|
+
const graph = new Map();
|
|
130
|
+
const collect = (name) => {
|
|
131
|
+
if (graph.has(name))
|
|
132
|
+
return;
|
|
133
|
+
const item = catalog.get(name);
|
|
134
|
+
if (!item)
|
|
135
|
+
throw new Error(`Unknown remote registry item: ${name}.`);
|
|
136
|
+
graph.set(name, item.registryDependencies);
|
|
137
|
+
item.registryDependencies.forEach(collect);
|
|
138
|
+
};
|
|
139
|
+
selected.forEach(collect);
|
|
140
|
+
return dependencyOrder(graph, 'Remote registry').map((name) => catalog.get(name));
|
|
141
|
+
}
|
|
142
|
+
/** Compile repository-relative source paths into a distributable JSON payload. */
|
|
143
|
+
export function buildSourceRegistry(manifestFile) {
|
|
144
|
+
const root = path.dirname(path.resolve(manifestFile));
|
|
145
|
+
const registry = JSON.parse(readBoundedFile(manifestFile, limit).toString('utf8'));
|
|
146
|
+
if (!Array.isArray(registry.items))
|
|
147
|
+
throw new Error('Invalid source registry manifest.');
|
|
148
|
+
for (const item of registry.items) {
|
|
149
|
+
if (!Array.isArray(item.files))
|
|
150
|
+
throw new Error('Invalid registry file list.');
|
|
151
|
+
for (const file of item.files) {
|
|
152
|
+
if (file.path === undefined)
|
|
153
|
+
continue;
|
|
154
|
+
if (typeof file.path !== 'string' || file.content !== undefined)
|
|
155
|
+
throw new Error('Declare one source path or embedded content.');
|
|
156
|
+
assertSafeTarget(root, file.path);
|
|
157
|
+
if (!/^(?:apps|packages)\/[a-z][a-z0-9-]*\//.test(file.path) ||
|
|
158
|
+
file.path.split('/').some((part) => part.startsWith('.') || part === 'node_modules'))
|
|
159
|
+
throw new Error('Registry source paths must stay in application or package source.');
|
|
160
|
+
const source = path.join(root, file.path);
|
|
161
|
+
file.content = readBoundedFile(source, limit).toString('utf8');
|
|
162
|
+
delete file.path;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (Buffer.byteLength(JSON.stringify(registry)) > limit)
|
|
166
|
+
throw new Error('Source registry exceeds 2 MiB.');
|
|
167
|
+
return validateSourceRegistry(registry);
|
|
168
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Hosting, StandardOptions, StandardProduct, StackRegistryItem } from './types.js';
|
|
2
|
+
/** Apply default selections once. All callers then use the same strict graph resolver. */
|
|
3
|
+
export declare function resolveStandardProducts(selected: Set<StandardProduct>, hosting: Hosting, hostingSpecified: boolean): void;
|
|
4
|
+
export interface ResolvedStackRegistry {
|
|
5
|
+
schemaVersion: number;
|
|
6
|
+
name: string;
|
|
7
|
+
cliVersion: string;
|
|
8
|
+
items: StackRegistryItem[];
|
|
9
|
+
}
|
|
10
|
+
export declare function resolveStackRegistry(options: Pick<StandardOptions, 'products' | 'hosting'> & Partial<Pick<StandardOptions, 'name'>>): ResolvedStackRegistry;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { dependencyOrder } from '../dependency-order.js';
|
|
2
|
+
import { STANDARD_PRODUCTS, STACK_REGISTRY, getStackDefinition, getStackItem, } from '../stack-registry.js';
|
|
3
|
+
import { createResolutionContext } from './context.js';
|
|
4
|
+
function hasServer(context) {
|
|
5
|
+
return context.has('hono') || context.has('vinext') || context.has('next');
|
|
6
|
+
}
|
|
7
|
+
function needsWorker(name, context) {
|
|
8
|
+
const definition = getStackDefinition(name);
|
|
9
|
+
return (definition.runtime === 'worker' ||
|
|
10
|
+
(definition.runtime === 'optional-worker' && hasServer(context)));
|
|
11
|
+
}
|
|
12
|
+
function validateProducts(selected, hosting) {
|
|
13
|
+
if (!['cloudflare', 'vercel'].includes(hosting))
|
|
14
|
+
throw new Error('hosting must be cloudflare or vercel.');
|
|
15
|
+
for (const id of selected) {
|
|
16
|
+
if (!STANDARD_PRODUCTS.includes(id))
|
|
17
|
+
throw new Error(`Unknown stack product: ${id}.`);
|
|
18
|
+
const { conflicts = [], requiresAny = [], requiresAll = [] } = getStackDefinition(id).selection;
|
|
19
|
+
const conflict = conflicts.find((other) => selected.has(other));
|
|
20
|
+
if (conflict)
|
|
21
|
+
throw new Error(`Choose one of ${id} / ${conflict}.`);
|
|
22
|
+
if (requiresAny.length && !requiresAny.some((other) => selected.has(other)))
|
|
23
|
+
throw new Error(`${id} requires an explicitly selected ${requiresAny.join(' or ')} capability.`);
|
|
24
|
+
const missing = requiresAll.filter((other) => !selected.has(other));
|
|
25
|
+
if (missing.length)
|
|
26
|
+
throw new Error(`${id} requires ${missing.join(', ')}; select it explicitly.`);
|
|
27
|
+
}
|
|
28
|
+
if (selected.has('vinext') && hosting !== 'cloudflare')
|
|
29
|
+
throw new Error('Vinext requires Cloudflare hosting; select --next for Vercel.');
|
|
30
|
+
}
|
|
31
|
+
/** Apply default selections once. All callers then use the same strict graph resolver. */
|
|
32
|
+
export function resolveStandardProducts(selected, hosting, hostingSpecified) {
|
|
33
|
+
for (const id of selected)
|
|
34
|
+
getStackDefinition(id);
|
|
35
|
+
if (![...selected].some((id) => getStackDefinition(id).type === 'application'))
|
|
36
|
+
selected.add(hosting === 'vercel' ? 'next' : 'vinext');
|
|
37
|
+
for (const id of selected) {
|
|
38
|
+
const definition = getStackDefinition(id);
|
|
39
|
+
const alternatives = definition.selection.requiresAny ?? [];
|
|
40
|
+
if (definition.fallbackDependency &&
|
|
41
|
+
!alternatives.some((other) => selected.has(other)))
|
|
42
|
+
selected.add(definition.fallbackDependency);
|
|
43
|
+
}
|
|
44
|
+
const context = createResolutionContext({ products: [...selected], hosting });
|
|
45
|
+
if (context.has('next') &&
|
|
46
|
+
hosting === 'vercel' &&
|
|
47
|
+
[...selected].some((id) => needsWorker(id, context)))
|
|
48
|
+
selected.add('hono');
|
|
49
|
+
if (hostingSpecified && !selected.has('next') && !selected.has('vinext'))
|
|
50
|
+
throw new Error('--hosting applies to the web application.');
|
|
51
|
+
resolveStackRegistry({ products: [...selected], hosting });
|
|
52
|
+
}
|
|
53
|
+
export function resolveStackRegistry(options) {
|
|
54
|
+
const selected = new Set(options.products);
|
|
55
|
+
validateProducts(selected, options.hosting);
|
|
56
|
+
if (![...selected].some((id) => getStackDefinition(id).type === 'application'))
|
|
57
|
+
throw new Error('Stack registry requires an application.');
|
|
58
|
+
const context = createResolutionContext(options);
|
|
59
|
+
const resolved = new Map();
|
|
60
|
+
const collect = (name) => {
|
|
61
|
+
if (resolved.has(name))
|
|
62
|
+
return;
|
|
63
|
+
const definition = getStackDefinition(name);
|
|
64
|
+
const metadata = getStackItem(name);
|
|
65
|
+
const dependencies = new Set(definition.registryDependencies);
|
|
66
|
+
if (definition.runtime) {
|
|
67
|
+
if (!hasServer(context) && definition.runtime !== 'optional-worker')
|
|
68
|
+
throw new Error(`${name} requires a server runtime.`);
|
|
69
|
+
if (needsWorker(name, context) &&
|
|
70
|
+
!context.has('hono') &&
|
|
71
|
+
!(options.hosting === 'cloudflare' && (context.has('vinext') || context.has('next'))))
|
|
72
|
+
throw new Error(`${name} requires a Cloudflare runtime.`);
|
|
73
|
+
if (hasServer(context))
|
|
74
|
+
dependencies.add(context.has('hono') ? 'hono' : context.has('vinext') ? 'vinext' : 'next');
|
|
75
|
+
}
|
|
76
|
+
for (const dependency of definition.resolveDependencies?.(context) ?? [])
|
|
77
|
+
dependencies.add(dependency);
|
|
78
|
+
metadata.registryDependencies = [...dependencies];
|
|
79
|
+
metadata.targets = definition.resolveTargets?.(context) ?? metadata.targets;
|
|
80
|
+
if (definition.configure)
|
|
81
|
+
metadata.bindings = definition.configure(context);
|
|
82
|
+
if (definition.package)
|
|
83
|
+
metadata.package =
|
|
84
|
+
definition.resolvePackage?.(context, definition.package) ?? definition.package;
|
|
85
|
+
resolved.set(name, metadata);
|
|
86
|
+
for (const dependency of dependencies)
|
|
87
|
+
collect(dependency);
|
|
88
|
+
};
|
|
89
|
+
for (const name of ['workspace', 'typescript', 'eslint', ...[...selected].sort()])
|
|
90
|
+
collect(name);
|
|
91
|
+
const graph = new Map([...resolved].map(([name, item]) => [name, item.registryDependencies]));
|
|
92
|
+
return {
|
|
93
|
+
schemaVersion: STACK_REGISTRY.schemaVersion,
|
|
94
|
+
name: STACK_REGISTRY.name,
|
|
95
|
+
cliVersion: STACK_REGISTRY.cliVersion,
|
|
96
|
+
items: dependencyOrder(graph, 'Stack registry').map((name) => resolved.get(name)),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { StandardProduct } from '../stack-registry.js';
|
|
2
|
+
import type { Json } from '../scaffold/files.js';
|
|
3
|
+
export type { StandardProduct } from '../stack-registry.js';
|
|
4
|
+
export type Hosting = 'cloudflare' | 'vercel';
|
|
5
|
+
export interface StandardOptions {
|
|
6
|
+
root: string;
|
|
7
|
+
name: string;
|
|
8
|
+
products: StandardProduct[];
|
|
9
|
+
hosting: Hosting;
|
|
10
|
+
skipInstall: boolean;
|
|
11
|
+
dryRun: boolean;
|
|
12
|
+
json: boolean;
|
|
13
|
+
initializeGit: boolean;
|
|
14
|
+
}
|
|
15
|
+
export type StandardRunner = (command: string, args: string[], cwd: string) => void;
|
|
16
|
+
export interface StackSelection {
|
|
17
|
+
conflicts?: readonly string[];
|
|
18
|
+
requiresAny?: readonly string[];
|
|
19
|
+
requiresAll?: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
export interface SetupRequirements {
|
|
22
|
+
purpose: string;
|
|
23
|
+
tools: readonly string[];
|
|
24
|
+
access: readonly string[];
|
|
25
|
+
credentials: readonly string[];
|
|
26
|
+
resources: readonly string[];
|
|
27
|
+
verify: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
export interface RecipeContext {
|
|
30
|
+
root: string;
|
|
31
|
+
options: StandardOptions;
|
|
32
|
+
runtimeApp: 'api' | 'web';
|
|
33
|
+
bindings: Json;
|
|
34
|
+
has: (product: string) => boolean;
|
|
35
|
+
}
|
|
36
|
+
export type Recipe = (context: RecipeContext) => void | Promise<void>;
|
|
37
|
+
export interface PackageRecipe {
|
|
38
|
+
name: string;
|
|
39
|
+
source: string;
|
|
40
|
+
dependencies: Record<string, string>;
|
|
41
|
+
}
|
|
42
|
+
export interface StackRegistryItem {
|
|
43
|
+
name: string;
|
|
44
|
+
version: string;
|
|
45
|
+
type: 'foundation' | 'application' | 'service' | 'package' | 'integration';
|
|
46
|
+
description: string;
|
|
47
|
+
registryDependencies: readonly string[];
|
|
48
|
+
targets: readonly string[];
|
|
49
|
+
selection: StackSelection;
|
|
50
|
+
installer: 'bootstrap' | 'recipe' | 'configuration';
|
|
51
|
+
runtime?: 'server' | 'worker' | 'optional-worker';
|
|
52
|
+
environment: readonly string[];
|
|
53
|
+
setup?: SetupRequirements;
|
|
54
|
+
package?: PackageRecipe;
|
|
55
|
+
bindings?: Json;
|
|
56
|
+
}
|
|
57
|
+
export interface ItemDefinition extends Omit<StackRegistryItem, 'bindings'> {
|
|
58
|
+
install?: Recipe;
|
|
59
|
+
configure?: (context: RecipeContext) => Json;
|
|
60
|
+
resolveTargets?: (context: RecipeContext) => string[];
|
|
61
|
+
resolvePackage?: (context: RecipeContext, recipe: PackageRecipe) => PackageRecipe;
|
|
62
|
+
resolveDependencies?: (context: RecipeContext) => string[];
|
|
63
|
+
fallbackDependency?: string;
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const STANDARD_USAGE: string;
|