@blinkhost/cli 2.0.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/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/api.d.ts +20 -0
- package/dist/api.js +135 -0
- package/dist/auth.d.ts +9 -0
- package/dist/auth.js +71 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +443 -0
- package/dist/config.d.ts +28 -0
- package/dist/config.js +67 -0
- package/dist/credentials.d.ts +3 -0
- package/dist/credentials.js +66 -0
- package/dist/detect.d.ts +2 -0
- package/dist/detect.js +61 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +20 -0
- package/dist/manifest.d.ts +50 -0
- package/dist/manifest.js +193 -0
- package/dist/project.d.ts +13 -0
- package/dist/project.js +177 -0
- package/dist/remote.d.ts +19 -0
- package/dist/remote.js +366 -0
- package/dist/templates.d.ts +17 -0
- package/dist/templates.js +109 -0
- package/dist/workflows.d.ts +8 -0
- package/dist/workflows.js +209 -0
- package/package.json +18 -0
package/dist/detect.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
async function readable(path) {
|
|
4
|
+
try {
|
|
5
|
+
await readFile(path);
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export async function detectManifest(root) {
|
|
13
|
+
const rootStat = await lstat(root);
|
|
14
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
15
|
+
throw new Error('Project path must be a regular directory.');
|
|
16
|
+
let packageJson = {};
|
|
17
|
+
try {
|
|
18
|
+
const packagePath = join(root, 'package.json');
|
|
19
|
+
if ((await lstat(packagePath)).isSymbolicLink())
|
|
20
|
+
throw new Error('package.json cannot be a symbolic link.');
|
|
21
|
+
packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error.code !== 'ENOENT')
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies };
|
|
28
|
+
let framework = 'html';
|
|
29
|
+
if ('astro' in dependencies)
|
|
30
|
+
framework = 'astro';
|
|
31
|
+
else if ('react' in dependencies)
|
|
32
|
+
framework = 'react';
|
|
33
|
+
else if ('vue' in dependencies)
|
|
34
|
+
framework = 'vue';
|
|
35
|
+
else if ('svelte' in dependencies)
|
|
36
|
+
framework = 'svelte';
|
|
37
|
+
else if ('solid-js' in dependencies)
|
|
38
|
+
framework = 'solid';
|
|
39
|
+
let packageManager = 'npm';
|
|
40
|
+
if (await readable(join(root, 'pnpm-lock.yaml')))
|
|
41
|
+
packageManager = 'pnpm';
|
|
42
|
+
else if (await readable(join(root, 'yarn.lock')))
|
|
43
|
+
packageManager = 'yarn';
|
|
44
|
+
else if (await readable(join(root, 'bun.lock')) || await readable(join(root, 'bun.lockb')))
|
|
45
|
+
packageManager = 'bun';
|
|
46
|
+
const install = packageManager === 'npm' ? 'npm ci' : `${packageManager} install --frozen-lockfile`;
|
|
47
|
+
return {
|
|
48
|
+
schema: 'blinkhost/v1', application: { root: '.' },
|
|
49
|
+
frontend: {
|
|
50
|
+
root: '.', dependency_root: '.', framework, package_manager: packageManager,
|
|
51
|
+
install: framework === 'html' ? '' : install,
|
|
52
|
+
build: framework === 'html' ? '' : `${packageManager} run build`,
|
|
53
|
+
dev: framework === 'html' ? '' : `${packageManager} run dev`,
|
|
54
|
+
output: framework === 'html' ? '.' : 'dist',
|
|
55
|
+
},
|
|
56
|
+
modules: [], resources: { databases: [], secrets: [] },
|
|
57
|
+
preview: { enabled: true, database_mode: 'none' },
|
|
58
|
+
ignore: ['.blinkhost', 'node_modules', 'dist'],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=detect.js.map
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const EXIT: {
|
|
2
|
+
readonly success: 0;
|
|
3
|
+
readonly usage: 2;
|
|
4
|
+
readonly validation: 3;
|
|
5
|
+
readonly filesystem: 4;
|
|
6
|
+
readonly internal: 5;
|
|
7
|
+
readonly auth: 6;
|
|
8
|
+
readonly network: 7;
|
|
9
|
+
readonly remote: 8;
|
|
10
|
+
readonly conflict: 9;
|
|
11
|
+
};
|
|
12
|
+
export declare class CliError extends Error {
|
|
13
|
+
readonly exitCode: number;
|
|
14
|
+
readonly code: string;
|
|
15
|
+
readonly details: string[];
|
|
16
|
+
constructor(message: string, exitCode: number, code: string, details?: string[]);
|
|
17
|
+
}
|
|
18
|
+
export declare class ManifestError extends CliError {
|
|
19
|
+
constructor(message: string, details?: string[]);
|
|
20
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const EXIT = { success: 0, usage: 2, validation: 3, filesystem: 4, internal: 5, auth: 6, network: 7, remote: 8, conflict: 9 };
|
|
2
|
+
export class CliError extends Error {
|
|
3
|
+
exitCode;
|
|
4
|
+
code;
|
|
5
|
+
details;
|
|
6
|
+
constructor(message, exitCode, code, details = []) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.exitCode = exitCode;
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.details = details;
|
|
11
|
+
this.name = 'CliError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class ManifestError extends CliError {
|
|
15
|
+
constructor(message, details = []) {
|
|
16
|
+
super(message, EXIT.validation, 'manifest_invalid', details);
|
|
17
|
+
this.name = 'ManifestError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export declare const MANIFEST_SCHEMA = "blinkhost/v1";
|
|
2
|
+
export declare const MANIFEST_FILENAME = "blinkhost.yaml";
|
|
3
|
+
export declare const MAX_MANIFEST_BYTES: number;
|
|
4
|
+
export declare const SUPPORTED_FRONTENDS: readonly ["astro", "html", "react", "solid", "svelte", "vue"];
|
|
5
|
+
export declare const SUPPORTED_MANAGERS: readonly ["bun", "npm", "pnpm", "yarn"];
|
|
6
|
+
export declare const SUPPORTED_MODULES: readonly ["go", "python", "rust"];
|
|
7
|
+
export type FrontendFramework = typeof SUPPORTED_FRONTENDS[number];
|
|
8
|
+
export type PackageManager = typeof SUPPORTED_MANAGERS[number];
|
|
9
|
+
export type ModuleLanguage = typeof SUPPORTED_MODULES[number];
|
|
10
|
+
export interface BlinkHostManifest {
|
|
11
|
+
schema: typeof MANIFEST_SCHEMA;
|
|
12
|
+
application: {
|
|
13
|
+
root: string;
|
|
14
|
+
};
|
|
15
|
+
frontend: {
|
|
16
|
+
root: string;
|
|
17
|
+
dependency_root: string;
|
|
18
|
+
framework: FrontendFramework;
|
|
19
|
+
package_manager: PackageManager;
|
|
20
|
+
install: string;
|
|
21
|
+
build: string;
|
|
22
|
+
dev: string;
|
|
23
|
+
output: string;
|
|
24
|
+
};
|
|
25
|
+
modules: Array<{
|
|
26
|
+
name: string;
|
|
27
|
+
path: string;
|
|
28
|
+
language: ModuleLanguage;
|
|
29
|
+
entrypoint: string;
|
|
30
|
+
abi: string;
|
|
31
|
+
sdk: string;
|
|
32
|
+
}>;
|
|
33
|
+
resources: {
|
|
34
|
+
databases: Array<{
|
|
35
|
+
binding: string;
|
|
36
|
+
schema?: string;
|
|
37
|
+
migrations?: string;
|
|
38
|
+
seeds?: string;
|
|
39
|
+
}>;
|
|
40
|
+
secrets: string[];
|
|
41
|
+
};
|
|
42
|
+
preview: {
|
|
43
|
+
enabled: boolean;
|
|
44
|
+
database_mode: 'none' | 'isolated_branch';
|
|
45
|
+
};
|
|
46
|
+
ignore: string[];
|
|
47
|
+
}
|
|
48
|
+
export declare function normalizeRepositoryPath(value: unknown, field: string, allowRoot?: boolean): string;
|
|
49
|
+
export declare function parseManifest(raw: string | Uint8Array): BlinkHostManifest;
|
|
50
|
+
export declare function serializeManifest(manifest: BlinkHostManifest): string;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { parseDocument, stringify, visit } from 'yaml';
|
|
2
|
+
import { ManifestError } from './errors.js';
|
|
3
|
+
export const MANIFEST_SCHEMA = 'blinkhost/v1';
|
|
4
|
+
export const MANIFEST_FILENAME = 'blinkhost.yaml';
|
|
5
|
+
export const MAX_MANIFEST_BYTES = 128 * 1024;
|
|
6
|
+
export const SUPPORTED_FRONTENDS = ['astro', 'html', 'react', 'solid', 'svelte', 'vue'];
|
|
7
|
+
export const SUPPORTED_MANAGERS = ['bun', 'npm', 'pnpm', 'yarn'];
|
|
8
|
+
export const SUPPORTED_MODULES = ['go', 'python', 'rust'];
|
|
9
|
+
const TOP_LEVEL = new Set(['schema', 'application', 'frontend', 'modules', 'resources', 'preview', 'ignore']);
|
|
10
|
+
const APPLICATION_FIELDS = new Set(['root']);
|
|
11
|
+
const FRONTEND_FIELDS = new Set(['root', 'dependency_root', 'framework', 'package_manager', 'install', 'build', 'dev', 'output']);
|
|
12
|
+
const MODULE_FIELDS = new Set(['name', 'path', 'language', 'entrypoint', 'abi', 'sdk']);
|
|
13
|
+
const RESOURCE_FIELDS = new Set(['databases', 'secrets']);
|
|
14
|
+
const DATABASE_FIELDS = new Set(['binding', 'schema', 'migrations', 'seeds']);
|
|
15
|
+
const PREVIEW_FIELDS = new Set(['enabled', 'database_mode']);
|
|
16
|
+
const WINDOWS_NAMES = new Set(['con', 'prn', 'aux', 'nul', ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`), ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`)]);
|
|
17
|
+
const MODULE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
18
|
+
const BINDING_NAME = /^[A-Z][A-Z0-9_]{0,127}$/;
|
|
19
|
+
function fail(field, message) {
|
|
20
|
+
throw new ManifestError(`${field}: ${message}`);
|
|
21
|
+
}
|
|
22
|
+
function object(value, field, allowed) {
|
|
23
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
24
|
+
fail(field, 'Must be an object.');
|
|
25
|
+
const result = value;
|
|
26
|
+
const unknown = Object.keys(result).filter((key) => !allowed.has(key)).sort();
|
|
27
|
+
if (unknown.length)
|
|
28
|
+
fail(field, `Unsupported fields: ${unknown.join(', ')}.`);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
function stringValue(value, field, maximum = 512, required = true) {
|
|
32
|
+
if (value === undefined || value === null) {
|
|
33
|
+
if (!required)
|
|
34
|
+
return '';
|
|
35
|
+
fail(field, 'Must be a non-empty string.');
|
|
36
|
+
}
|
|
37
|
+
if (typeof value !== 'string' || (required && !value.trim()))
|
|
38
|
+
fail(field, 'Must be a non-empty string.');
|
|
39
|
+
const normalized = value.trim();
|
|
40
|
+
if (Buffer.byteLength(normalized, 'utf8') > maximum)
|
|
41
|
+
fail(field, `Must not exceed ${maximum} UTF-8 bytes.`);
|
|
42
|
+
if (/[\u0000-\u001f\u007f]/.test(normalized))
|
|
43
|
+
fail(field, 'Control characters are not supported.');
|
|
44
|
+
return normalized;
|
|
45
|
+
}
|
|
46
|
+
export function normalizeRepositoryPath(value, field, allowRoot = false) {
|
|
47
|
+
const path = stringValue(value, field, 1024);
|
|
48
|
+
if (path === '.' && allowRoot)
|
|
49
|
+
return path;
|
|
50
|
+
if (path.startsWith('/') || path.startsWith('\\') || path.includes('\\') || path.includes('\0'))
|
|
51
|
+
fail(field, 'Must be a repository-relative POSIX path.');
|
|
52
|
+
if (path.endsWith('/') || path === '.' || path === '..' || path.includes('//'))
|
|
53
|
+
fail(field, 'Must be a normalized repository-relative path.');
|
|
54
|
+
const parts = path.split('/');
|
|
55
|
+
if (parts.length > 64)
|
|
56
|
+
fail(field, 'Must not exceed 64 path segments.');
|
|
57
|
+
for (const part of parts) {
|
|
58
|
+
const folded = part.toLowerCase();
|
|
59
|
+
const stem = folded.split('.', 1)[0] ?? folded;
|
|
60
|
+
if (!part || part === '.' || part === '..' || folded === '.git' || WINDOWS_NAMES.has(stem))
|
|
61
|
+
fail(field, `Contains reserved path segment: ${part}.`);
|
|
62
|
+
if (part.endsWith(' ') || part.endsWith('.'))
|
|
63
|
+
fail(field, 'Path segments cannot end with a space or period.');
|
|
64
|
+
if (/[\u0000-\u001f\u007f]/.test(part))
|
|
65
|
+
fail(field, 'Path contains control characters.');
|
|
66
|
+
}
|
|
67
|
+
return parts.join('/');
|
|
68
|
+
}
|
|
69
|
+
function command(value, field, required = true) {
|
|
70
|
+
const result = stringValue(value, field, 1024, required);
|
|
71
|
+
if (/[\r\n]/.test(result))
|
|
72
|
+
fail(field, 'Build commands must be single-line values.');
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
export function parseManifest(raw) {
|
|
76
|
+
const bytes = typeof raw === 'string' ? Buffer.from(raw, 'utf8') : Buffer.from(raw);
|
|
77
|
+
if (bytes.byteLength > MAX_MANIFEST_BYTES)
|
|
78
|
+
fail('manifest', `Manifest exceeds ${MAX_MANIFEST_BYTES} bytes.`);
|
|
79
|
+
const text = bytes.toString('utf8');
|
|
80
|
+
if (!Buffer.from(text, 'utf8').equals(bytes))
|
|
81
|
+
fail('manifest', 'Manifest must be valid UTF-8.');
|
|
82
|
+
const document = parseDocument(text, { strict: true, uniqueKeys: true });
|
|
83
|
+
let hasAlias = false;
|
|
84
|
+
visit(document, { Alias: () => { hasAlias = true; } });
|
|
85
|
+
if (hasAlias)
|
|
86
|
+
fail('manifest', 'YAML aliases are not supported.');
|
|
87
|
+
if (document.errors.length)
|
|
88
|
+
fail('manifest', `Manifest YAML is invalid: ${document.errors[0]?.message ?? 'unknown error'}`);
|
|
89
|
+
const rootObject = object(document.toJS({ maxAliasCount: 0 }), 'manifest', TOP_LEVEL);
|
|
90
|
+
if (rootObject.schema !== MANIFEST_SCHEMA)
|
|
91
|
+
fail('schema', `Only ${MANIFEST_SCHEMA} is supported.`);
|
|
92
|
+
const application = object(rootObject.application ?? {}, 'application', APPLICATION_FIELDS);
|
|
93
|
+
const applicationRoot = normalizeRepositoryPath(application.root ?? '.', 'application.root', true);
|
|
94
|
+
const frontend = object(rootObject.frontend, 'frontend', FRONTEND_FIELDS);
|
|
95
|
+
const frontendRoot = normalizeRepositoryPath(frontend.root ?? '.', 'frontend.root', true);
|
|
96
|
+
const dependencyRoot = normalizeRepositoryPath(frontend.dependency_root ?? frontendRoot, 'frontend.dependency_root', true);
|
|
97
|
+
if (dependencyRoot !== '.' && frontendRoot !== dependencyRoot && !frontendRoot.startsWith(`${dependencyRoot}/`))
|
|
98
|
+
fail('frontend.dependency_root', 'Must be the frontend directory or one of its parent directories.');
|
|
99
|
+
const framework = stringValue(frontend.framework, 'frontend.framework', 32).toLowerCase();
|
|
100
|
+
if (!SUPPORTED_FRONTENDS.includes(framework))
|
|
101
|
+
fail('frontend.framework', 'Unsupported frontend framework.');
|
|
102
|
+
const packageManager = stringValue(frontend.package_manager, 'frontend.package_manager', 16).toLowerCase();
|
|
103
|
+
if (!SUPPORTED_MANAGERS.includes(packageManager))
|
|
104
|
+
fail('frontend.package_manager', 'Unsupported package manager.');
|
|
105
|
+
const normalizedFrontend = {
|
|
106
|
+
root: frontendRoot,
|
|
107
|
+
dependency_root: dependencyRoot,
|
|
108
|
+
framework: framework,
|
|
109
|
+
package_manager: packageManager,
|
|
110
|
+
install: command(frontend.install ?? '', 'frontend.install', framework !== 'html'),
|
|
111
|
+
build: command(frontend.build ?? '', 'frontend.build', framework !== 'html'),
|
|
112
|
+
dev: command(frontend.dev, 'frontend.dev', false),
|
|
113
|
+
output: normalizeRepositoryPath(frontend.output, 'frontend.output', framework === 'html'),
|
|
114
|
+
};
|
|
115
|
+
const rawModules = rootObject.modules ?? [];
|
|
116
|
+
if (!Array.isArray(rawModules) || rawModules.length > 100)
|
|
117
|
+
fail('modules', 'Must be an array containing at most 100 modules.');
|
|
118
|
+
const moduleNames = new Set();
|
|
119
|
+
const modulePaths = new Set();
|
|
120
|
+
const modules = rawModules.map((rawModule, index) => {
|
|
121
|
+
const field = `modules.${index}`;
|
|
122
|
+
const module = object(rawModule, field, MODULE_FIELDS);
|
|
123
|
+
const name = stringValue(module.name, `${field}.name`, 64).toLowerCase();
|
|
124
|
+
if (!MODULE_NAME.test(name))
|
|
125
|
+
fail(`${field}.name`, 'Use lowercase letters, numbers and internal hyphens.');
|
|
126
|
+
const path = normalizeRepositoryPath(module.path, `${field}.path`);
|
|
127
|
+
const absolutePath = applicationRoot === '.' ? path : `${applicationRoot}/${path}`;
|
|
128
|
+
const language = stringValue(module.language, `${field}.language`, 16).toLowerCase();
|
|
129
|
+
if (!SUPPORTED_MODULES.includes(language))
|
|
130
|
+
fail(`${field}.language`, 'Only Rust, Go and Python modules are supported.');
|
|
131
|
+
if (moduleNames.has(name))
|
|
132
|
+
fail(`${field}.name`, 'Module names must be unique.');
|
|
133
|
+
if (modulePaths.has(absolutePath.toLowerCase()))
|
|
134
|
+
fail(`${field}.path`, 'Module paths must be unique, including case.');
|
|
135
|
+
moduleNames.add(name);
|
|
136
|
+
modulePaths.add(absolutePath.toLowerCase());
|
|
137
|
+
return {
|
|
138
|
+
name,
|
|
139
|
+
path,
|
|
140
|
+
language: language,
|
|
141
|
+
entrypoint: normalizeRepositoryPath(module.entrypoint, `${field}.entrypoint`),
|
|
142
|
+
abi: stringValue(module.abi ?? 'blinkhost-wasi-1', `${field}.abi`, 64),
|
|
143
|
+
sdk: stringValue(module.sdk ?? '1.1', `${field}.sdk`, 32),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
const resources = object(rootObject.resources ?? {}, 'resources', RESOURCE_FIELDS);
|
|
147
|
+
const rawDatabases = resources.databases ?? [];
|
|
148
|
+
if (!Array.isArray(rawDatabases) || rawDatabases.length > 32)
|
|
149
|
+
fail('resources.databases', 'Must be an array containing at most 32 bindings.');
|
|
150
|
+
const bindings = new Set();
|
|
151
|
+
const databases = rawDatabases.map((rawDatabase, index) => {
|
|
152
|
+
const field = `resources.databases.${index}`;
|
|
153
|
+
const database = object(rawDatabase, field, DATABASE_FIELDS);
|
|
154
|
+
const binding = stringValue(database.binding, `${field}.binding`, 128);
|
|
155
|
+
if (!BINDING_NAME.test(binding) || bindings.has(binding))
|
|
156
|
+
fail(`${field}.binding`, 'Bindings must be unique uppercase environment names.');
|
|
157
|
+
bindings.add(binding);
|
|
158
|
+
const result = { binding };
|
|
159
|
+
for (const key of ['schema', 'migrations', 'seeds'])
|
|
160
|
+
if (database[key] !== undefined)
|
|
161
|
+
result[key] = normalizeRepositoryPath(database[key], `${field}.${key}`);
|
|
162
|
+
return result;
|
|
163
|
+
});
|
|
164
|
+
const rawSecrets = resources.secrets ?? [];
|
|
165
|
+
if (!Array.isArray(rawSecrets) || rawSecrets.length > 256)
|
|
166
|
+
fail('resources.secrets', 'Must be an array containing at most 256 names.');
|
|
167
|
+
const secrets = rawSecrets.map((value, index) => stringValue(value, `resources.secrets.${index}`, 128));
|
|
168
|
+
if (new Set(secrets).size !== secrets.length || secrets.some((secret) => !BINDING_NAME.test(secret)))
|
|
169
|
+
fail('resources.secrets', 'Secret names must be unique uppercase environment names.');
|
|
170
|
+
const preview = object(rootObject.preview ?? {}, 'preview', PREVIEW_FIELDS);
|
|
171
|
+
const enabled = preview.enabled ?? true;
|
|
172
|
+
if (typeof enabled !== 'boolean')
|
|
173
|
+
fail('preview.enabled', 'Must be a boolean.');
|
|
174
|
+
const databaseMode = stringValue(preview.database_mode ?? 'none', 'preview.database_mode', 32).toLowerCase();
|
|
175
|
+
if (databaseMode !== 'none' && databaseMode !== 'isolated_branch')
|
|
176
|
+
fail('preview.database_mode', 'Unsupported preview database mode.');
|
|
177
|
+
const rawIgnore = rootObject.ignore ?? [];
|
|
178
|
+
if (!Array.isArray(rawIgnore) || rawIgnore.length > 256)
|
|
179
|
+
fail('ignore', 'Must be an array containing at most 256 paths.');
|
|
180
|
+
return {
|
|
181
|
+
schema: MANIFEST_SCHEMA,
|
|
182
|
+
application: { root: applicationRoot },
|
|
183
|
+
frontend: normalizedFrontend,
|
|
184
|
+
modules,
|
|
185
|
+
resources: { databases, secrets },
|
|
186
|
+
preview: { enabled, database_mode: databaseMode },
|
|
187
|
+
ignore: rawIgnore.map((value, index) => normalizeRepositoryPath(value, `ignore.${index}`)),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
export function serializeManifest(manifest) {
|
|
191
|
+
return stringify(manifest, { lineWidth: 100, aliasDuplicateObjects: false });
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=manifest.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type BlinkHostManifest } from './manifest.js';
|
|
2
|
+
export interface ValidationResult {
|
|
3
|
+
manifest: BlinkHostManifest;
|
|
4
|
+
errors: string[];
|
|
5
|
+
warnings: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare function resolveLocalPath(input: string | undefined): string;
|
|
8
|
+
export declare function readProjectManifest(root: string): Promise<BlinkHostManifest>;
|
|
9
|
+
export declare function validateProject(root: string): Promise<ValidationResult>;
|
|
10
|
+
export declare function writeScaffoldAtomically(target: string, files: Map<string, string>, manifest: BlinkHostManifest): Promise<void>;
|
|
11
|
+
export declare function writeManifest(root: string, manifest: BlinkHostManifest, force: boolean): Promise<void>;
|
|
12
|
+
export declare function directoryEntries(root: string): Promise<string[]>;
|
|
13
|
+
export declare function temporaryProjectRoot(): string;
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { access, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { CliError, EXIT } from './errors.js';
|
|
6
|
+
import { MANIFEST_FILENAME, parseManifest, serializeManifest } from './manifest.js';
|
|
7
|
+
export function resolveLocalPath(input) {
|
|
8
|
+
const value = input ?? '.';
|
|
9
|
+
if (/[\u0000-\u001f\u007f]/.test(value))
|
|
10
|
+
throw new CliError('Project paths cannot contain control characters.', EXIT.usage, 'unsafe_path');
|
|
11
|
+
return isAbsolute(value) ? resolve(value) : resolve(process.cwd(), value);
|
|
12
|
+
}
|
|
13
|
+
async function safeDeclaredPath(root, declared) {
|
|
14
|
+
const target = resolve(root, declared === '.' ? '' : declared);
|
|
15
|
+
const offset = relative(root, target);
|
|
16
|
+
if (offset.startsWith('..') || isAbsolute(offset))
|
|
17
|
+
throw new CliError(`Declared path escapes the project: ${declared}`, EXIT.validation, 'unsafe_path');
|
|
18
|
+
let cursor = root;
|
|
19
|
+
for (const segment of offset.split(/[\\/]/).filter(Boolean)) {
|
|
20
|
+
cursor = join(cursor, segment);
|
|
21
|
+
try {
|
|
22
|
+
if ((await lstat(cursor)).isSymbolicLink())
|
|
23
|
+
throw new CliError(`Declared path uses a symbolic link: ${declared}`, EXIT.validation, 'symlink_rejected');
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code === 'ENOENT')
|
|
27
|
+
break;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return target;
|
|
32
|
+
}
|
|
33
|
+
async function exists(path) {
|
|
34
|
+
try {
|
|
35
|
+
await access(path, constants.F_OK);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function regularFile(path) {
|
|
43
|
+
try {
|
|
44
|
+
const stat = await lstat(path);
|
|
45
|
+
return stat.isFile() && !stat.isSymbolicLink();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function regularDirectory(path) {
|
|
52
|
+
try {
|
|
53
|
+
const stat = await lstat(path);
|
|
54
|
+
return stat.isDirectory() && !stat.isSymbolicLink();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function readProjectManifest(root) {
|
|
61
|
+
const manifestPath = join(root, MANIFEST_FILENAME);
|
|
62
|
+
let stat;
|
|
63
|
+
try {
|
|
64
|
+
stat = await lstat(manifestPath);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code === 'ENOENT')
|
|
68
|
+
throw new CliError(`No ${MANIFEST_FILENAME} was found.`, EXIT.validation, 'manifest_missing');
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
72
|
+
throw new CliError(`${MANIFEST_FILENAME} must be a regular file, not a symbolic link.`, EXIT.validation, 'manifest_unsafe');
|
|
73
|
+
return parseManifest(await readFile(manifestPath));
|
|
74
|
+
}
|
|
75
|
+
export async function validateProject(root) {
|
|
76
|
+
const rootStat = await lstat(root);
|
|
77
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
78
|
+
throw new CliError('Project path must be a regular directory.', EXIT.validation, 'unsafe_project');
|
|
79
|
+
const manifest = await readProjectManifest(root);
|
|
80
|
+
const errors = [];
|
|
81
|
+
const warnings = [];
|
|
82
|
+
const frontendRoot = await safeDeclaredPath(root, manifest.frontend.root);
|
|
83
|
+
if (!await regularDirectory(frontendRoot))
|
|
84
|
+
errors.push(`Frontend directory does not exist or is not a regular directory: ${manifest.frontend.root}`);
|
|
85
|
+
if (manifest.frontend.framework !== 'html') {
|
|
86
|
+
const dependencyRoot = await safeDeclaredPath(root, manifest.frontend.dependency_root);
|
|
87
|
+
if (!await regularDirectory(dependencyRoot))
|
|
88
|
+
errors.push(`Dependency directory does not exist or is not a regular directory: ${manifest.frontend.dependency_root}`);
|
|
89
|
+
const dependencyPrefix = manifest.frontend.dependency_root === '.' ? '' : `${manifest.frontend.dependency_root}/`;
|
|
90
|
+
const packagePath = await safeDeclaredPath(root, `${dependencyPrefix}package.json`);
|
|
91
|
+
if (!await regularFile(packagePath)) {
|
|
92
|
+
errors.push(`Missing regular package.json in ${manifest.frontend.dependency_root}.`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
try {
|
|
96
|
+
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
|
|
97
|
+
if (!packageJson || typeof packageJson !== 'object' || Array.isArray(packageJson))
|
|
98
|
+
throw new Error('not an object');
|
|
99
|
+
if (typeof packageJson.packageManager === 'string' && !packageJson.packageManager.startsWith(`${manifest.frontend.package_manager}@`)) {
|
|
100
|
+
errors.push('package.json and blinkhost.yaml select different package managers.');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
errors.push('package.json must contain a valid JSON object.');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const locks = { npm: ['package-lock.json', 'npm-shrinkwrap.json'], pnpm: ['pnpm-lock.yaml'], yarn: ['yarn.lock'], bun: ['bun.lock', 'bun.lockb'] };
|
|
108
|
+
const lockResults = await Promise.all(locks[manifest.frontend.package_manager].map(async (name) => {
|
|
109
|
+
const lockPath = await safeDeclaredPath(root, `${dependencyPrefix}${name}`);
|
|
110
|
+
return regularFile(lockPath);
|
|
111
|
+
}));
|
|
112
|
+
if (!lockResults.some(Boolean)) {
|
|
113
|
+
warnings.push(`Add and commit the ${manifest.frontend.package_manager} lockfile for reproducible, faster dependency installation.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const module of manifest.modules) {
|
|
117
|
+
const moduleRoot = await safeDeclaredPath(root, module.path);
|
|
118
|
+
const entrypoint = await safeDeclaredPath(moduleRoot, module.entrypoint);
|
|
119
|
+
if (!await regularDirectory(moduleRoot))
|
|
120
|
+
errors.push(`Backend module directory does not exist or is not a regular directory: ${module.path}`);
|
|
121
|
+
if (!await regularFile(entrypoint))
|
|
122
|
+
errors.push(`Backend module entrypoint does not exist or is not a regular file: ${module.path}/${module.entrypoint}`);
|
|
123
|
+
}
|
|
124
|
+
for (const database of manifest.resources.databases) {
|
|
125
|
+
for (const key of ['schema', 'migrations', 'seeds']) {
|
|
126
|
+
const path = database[key];
|
|
127
|
+
if (path) {
|
|
128
|
+
const target = await safeDeclaredPath(root, path);
|
|
129
|
+
const valid = key === 'schema' ? await regularFile(target) : await regularDirectory(target);
|
|
130
|
+
if (!valid)
|
|
131
|
+
errors.push(`Database ${database.binding} ${key} path has the wrong type or does not exist: ${path}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { manifest, errors, warnings };
|
|
136
|
+
}
|
|
137
|
+
export async function writeScaffoldAtomically(target, files, manifest) {
|
|
138
|
+
if (await exists(target))
|
|
139
|
+
throw new CliError(`Refusing to overwrite existing path: ${target}`, EXIT.filesystem, 'target_exists');
|
|
140
|
+
const parent = dirname(target);
|
|
141
|
+
await mkdir(parent, { recursive: true });
|
|
142
|
+
const staging = await mkdtemp(join(parent, `.${basename(target)}.blinkhost-`));
|
|
143
|
+
try {
|
|
144
|
+
const all = new Map(files);
|
|
145
|
+
all.set(MANIFEST_FILENAME, serializeManifest(manifest));
|
|
146
|
+
for (const [declaredPath, contents] of all) {
|
|
147
|
+
const destination = resolve(staging, declaredPath);
|
|
148
|
+
if (relative(staging, destination).startsWith('..'))
|
|
149
|
+
throw new CliError(`Unsafe generated path: ${declaredPath}`, EXIT.internal, 'unsafe_template');
|
|
150
|
+
await mkdir(dirname(destination), { recursive: true, mode: 0o755 });
|
|
151
|
+
await writeFile(destination, contents, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
|
|
152
|
+
}
|
|
153
|
+
await rename(staging, target);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
await rm(staging, { recursive: true, force: true });
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export async function writeManifest(root, manifest, force) {
|
|
161
|
+
const path = join(root, MANIFEST_FILENAME);
|
|
162
|
+
const stat = await lstat(root);
|
|
163
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
164
|
+
throw new CliError('Project path must be a regular directory.', EXIT.filesystem, 'unsafe_project');
|
|
165
|
+
if (!force && await exists(path))
|
|
166
|
+
throw new CliError(`${MANIFEST_FILENAME} already exists. Use --force only after reviewing the generated manifest.`, EXIT.filesystem, 'manifest_exists');
|
|
167
|
+
if (await exists(path) && (await lstat(path)).isSymbolicLink())
|
|
168
|
+
throw new CliError(`Refusing to replace a symbolic-link ${MANIFEST_FILENAME}.`, EXIT.filesystem, 'manifest_unsafe');
|
|
169
|
+
await writeFile(path, serializeManifest(manifest), { encoding: 'utf8', mode: 0o644 });
|
|
170
|
+
}
|
|
171
|
+
export async function directoryEntries(root) {
|
|
172
|
+
return readdir(root);
|
|
173
|
+
}
|
|
174
|
+
export function temporaryProjectRoot() {
|
|
175
|
+
return tmpdir();
|
|
176
|
+
}
|
|
177
|
+
//# sourceMappingURL=project.js.map
|
package/dist/remote.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare function runRemote(group: string, input: string[], profile?: string): Promise<unknown>;
|
|
2
|
+
interface ProjectLink {
|
|
3
|
+
schema: 'blinkhost/project-link/v1';
|
|
4
|
+
project_id: string;
|
|
5
|
+
profile: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function writeProjectLink(projectId: string, profile: string, root?: string): Promise<ProjectLink>;
|
|
8
|
+
export declare function readProjectLink(root?: string): Promise<ProjectLink>;
|
|
9
|
+
export declare function unlinkProject(root?: string): Promise<{
|
|
10
|
+
unlinked: boolean;
|
|
11
|
+
}>;
|
|
12
|
+
export declare function projectStatus(profile?: string): Promise<unknown>;
|
|
13
|
+
export declare function syncProject(kind: 'pull' | 'push', input: string[], profile?: string): Promise<unknown>;
|
|
14
|
+
export declare function runSecrets(input: string[], profile?: string): Promise<unknown>;
|
|
15
|
+
export declare function rawApi(input: string[], profile?: string): Promise<unknown>;
|
|
16
|
+
export declare function waitForRemote(group: 'builds' | 'deployments' | 'previews', input: string[], profile?: string): Promise<unknown>;
|
|
17
|
+
export declare function openPreview(input: string[], profile?: string): Promise<unknown>;
|
|
18
|
+
export declare function uploadAsset(input: string[], profile?: string): Promise<unknown>;
|
|
19
|
+
export {};
|