@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/remote.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { basename, extname } from 'node:path';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { stdin } from 'node:process';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { ApiClient, encodeQuery } from './api.js';
|
|
8
|
+
import { CliError, EXIT } from './errors.js';
|
|
9
|
+
import { resolveLocalPath } from './project.js';
|
|
10
|
+
const COLLECTIONS = {
|
|
11
|
+
projects: '/api/sites/',
|
|
12
|
+
repositories: '/api/source-control/repositories/',
|
|
13
|
+
connections: '/api/source-control/connections/',
|
|
14
|
+
previews: '/api/source-control/previews/',
|
|
15
|
+
builds: '/api/source-control/builds/',
|
|
16
|
+
deployments: '/api/deployments/',
|
|
17
|
+
modules: '/api/backend-modules/',
|
|
18
|
+
databases: '/api/databases/',
|
|
19
|
+
bindings: '/api/bindings/',
|
|
20
|
+
assets: '/api/sites/{project}/assets/',
|
|
21
|
+
secrets: '/api/project-secrets/',
|
|
22
|
+
organizations: '/api/organizations/',
|
|
23
|
+
templates: '/api/source-control/template-releases/',
|
|
24
|
+
approvals: '/api/source-control/deployment-approvals/',
|
|
25
|
+
handoffs: '/api/source-control/agency-handoffs/',
|
|
26
|
+
policies: '/api/source-control/enterprise/policies/',
|
|
27
|
+
workloads: '/api/cli/v2/workload-identities/',
|
|
28
|
+
};
|
|
29
|
+
function takeOption(args, name) {
|
|
30
|
+
const index = args.indexOf(name);
|
|
31
|
+
if (index < 0)
|
|
32
|
+
return undefined;
|
|
33
|
+
const value = args[index + 1];
|
|
34
|
+
if (!value || value.startsWith('--'))
|
|
35
|
+
throw new CliError(`${name} requires a value.`, EXIT.usage, 'missing_option_value');
|
|
36
|
+
args.splice(index, 2);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function takeRepeated(args, name) {
|
|
40
|
+
const result = [];
|
|
41
|
+
while (args.includes(name))
|
|
42
|
+
result.push(takeOption(args, name));
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
function takeFlag(args, name) {
|
|
46
|
+
const index = args.indexOf(name);
|
|
47
|
+
if (index < 0)
|
|
48
|
+
return false;
|
|
49
|
+
args.splice(index, 1);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
function noExtra(args) {
|
|
53
|
+
if (args.length)
|
|
54
|
+
throw new CliError(`Unexpected argument: ${args[0]}`, EXIT.usage, 'unexpected_argument');
|
|
55
|
+
}
|
|
56
|
+
function safeIdentifier(value, label = 'ID') {
|
|
57
|
+
if (!value || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(value))
|
|
58
|
+
throw new CliError(`${label} is missing or invalid.`, EXIT.usage, 'invalid_identifier');
|
|
59
|
+
return encodeURIComponent(value);
|
|
60
|
+
}
|
|
61
|
+
async function readPayload(value) {
|
|
62
|
+
if (!value)
|
|
63
|
+
return {};
|
|
64
|
+
let raw = value;
|
|
65
|
+
if (value.startsWith('@')) {
|
|
66
|
+
const path = resolveLocalPath(value.slice(1));
|
|
67
|
+
const metadata = await lstat(path);
|
|
68
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 1024 * 1024)
|
|
69
|
+
throw new CliError('Payload files must be regular files no larger than 1 MiB.', EXIT.validation, 'invalid_payload_file');
|
|
70
|
+
raw = await readFile(path, 'utf8');
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(raw);
|
|
74
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
75
|
+
throw new Error();
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new CliError('Request data must be a JSON object or @path to a JSON file.', EXIT.usage, 'invalid_request_data');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function queryString(pairs) {
|
|
83
|
+
const query = new URLSearchParams();
|
|
84
|
+
for (const pair of pairs) {
|
|
85
|
+
const separator = pair.indexOf('=');
|
|
86
|
+
if (separator < 1)
|
|
87
|
+
throw new CliError('Use --query name=value.', EXIT.usage, 'invalid_query');
|
|
88
|
+
query.append(pair.slice(0, separator), pair.slice(separator + 1));
|
|
89
|
+
}
|
|
90
|
+
const value = query.toString();
|
|
91
|
+
return value ? `?${value}` : '';
|
|
92
|
+
}
|
|
93
|
+
function collectionPath(group, project) {
|
|
94
|
+
const template = COLLECTIONS[group];
|
|
95
|
+
if (!template)
|
|
96
|
+
throw new CliError(`Unknown remote resource: ${group}.`, EXIT.usage, 'unknown_resource');
|
|
97
|
+
if (template.includes('{project}'))
|
|
98
|
+
return template.replace('{project}', safeIdentifier(project, 'Project ID'));
|
|
99
|
+
return template;
|
|
100
|
+
}
|
|
101
|
+
export async function runRemote(group, input, profile) {
|
|
102
|
+
const args = [...input];
|
|
103
|
+
const action = args.shift() || 'list';
|
|
104
|
+
const project = takeOption(args, '--project');
|
|
105
|
+
const query = takeRepeated(args, '--query');
|
|
106
|
+
const dataOption = takeOption(args, '--data');
|
|
107
|
+
const client = await ApiClient.create(profile);
|
|
108
|
+
const base = collectionPath(group, project);
|
|
109
|
+
const data = await readPayload(dataOption);
|
|
110
|
+
if (action === 'list') {
|
|
111
|
+
noExtra(args);
|
|
112
|
+
return client.request(`${base}${queryString(query)}`);
|
|
113
|
+
}
|
|
114
|
+
if (action === 'get') {
|
|
115
|
+
const id = safeIdentifier(args.shift());
|
|
116
|
+
noExtra(args);
|
|
117
|
+
return client.request(`${base}${id}/${queryString(query)}`);
|
|
118
|
+
}
|
|
119
|
+
if (action === 'create') {
|
|
120
|
+
noExtra(args);
|
|
121
|
+
return client.request(base, { method: 'POST', body: JSON.stringify(data) });
|
|
122
|
+
}
|
|
123
|
+
if (action === 'update') {
|
|
124
|
+
const id = safeIdentifier(args.shift());
|
|
125
|
+
noExtra(args);
|
|
126
|
+
return client.request(`${base}${id}/`, { method: 'PATCH', body: JSON.stringify(data) });
|
|
127
|
+
}
|
|
128
|
+
if (action === 'delete') {
|
|
129
|
+
const idRaw = args.shift();
|
|
130
|
+
const id = safeIdentifier(idRaw);
|
|
131
|
+
const confirmed = takeOption(args, '--confirm');
|
|
132
|
+
noExtra(args);
|
|
133
|
+
if (confirmed !== idRaw)
|
|
134
|
+
throw new CliError('Repeat the resource ID with --confirm before deleting it.', EXIT.usage, 'confirmation_required');
|
|
135
|
+
return client.request(`${base}${id}/`, { method: 'DELETE' });
|
|
136
|
+
}
|
|
137
|
+
if (action === 'action') {
|
|
138
|
+
const id = safeIdentifier(args.shift());
|
|
139
|
+
const operation = safeIdentifier(args.shift(), 'Action');
|
|
140
|
+
noExtra(args);
|
|
141
|
+
return client.request(`${base}${id}/${operation}/`, { method: 'POST', body: JSON.stringify(data) });
|
|
142
|
+
}
|
|
143
|
+
throw new CliError(`Unknown ${group} action: ${action}.`, EXIT.usage, 'unknown_action');
|
|
144
|
+
}
|
|
145
|
+
export async function writeProjectLink(projectId, profile, root) {
|
|
146
|
+
safeIdentifier(projectId, 'Project ID');
|
|
147
|
+
const directory = join(resolveLocalPath(root), '.blinkhost');
|
|
148
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
149
|
+
const target = join(directory, 'project.json');
|
|
150
|
+
try {
|
|
151
|
+
if ((await lstat(target)).isSymbolicLink())
|
|
152
|
+
throw new CliError('Refusing to replace a symbolic project link.', EXIT.filesystem, 'unsafe_project_link');
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error.code !== 'ENOENT')
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
const record = { schema: 'blinkhost/project-link/v1', project_id: projectId, profile };
|
|
159
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
160
|
+
await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
161
|
+
await rename(temporary, target);
|
|
162
|
+
return record;
|
|
163
|
+
}
|
|
164
|
+
export async function readProjectLink(root) {
|
|
165
|
+
const path = join(resolveLocalPath(root), '.blinkhost', 'project.json');
|
|
166
|
+
const metadata = await lstat(path);
|
|
167
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 4096)
|
|
168
|
+
throw new CliError('The local BlinkHost project link is unsafe or invalid.', EXIT.validation, 'invalid_project_link');
|
|
169
|
+
const record = JSON.parse(await readFile(path, 'utf8'));
|
|
170
|
+
if (record.schema !== 'blinkhost/project-link/v1')
|
|
171
|
+
throw new CliError('The local BlinkHost project link uses an unsupported schema.', EXIT.validation, 'invalid_project_link');
|
|
172
|
+
safeIdentifier(record.project_id, 'Project ID');
|
|
173
|
+
return record;
|
|
174
|
+
}
|
|
175
|
+
export async function unlinkProject(root) {
|
|
176
|
+
const path = join(resolveLocalPath(root), '.blinkhost', 'project.json');
|
|
177
|
+
const metadata = await lstat(path);
|
|
178
|
+
if (!metadata.isFile() || metadata.isSymbolicLink())
|
|
179
|
+
throw new CliError('The local BlinkHost project link is unsafe or invalid.', EXIT.validation, 'invalid_project_link');
|
|
180
|
+
await unlink(path);
|
|
181
|
+
return { unlinked: true };
|
|
182
|
+
}
|
|
183
|
+
export async function projectStatus(profile) {
|
|
184
|
+
const link = await readProjectLink();
|
|
185
|
+
const client = await ApiClient.create(profile || link.profile);
|
|
186
|
+
const [project, connections] = await Promise.all([
|
|
187
|
+
client.request(`/api/sites/${encodeURIComponent(link.project_id)}/`),
|
|
188
|
+
client.request(`/api/source-control/connections/?project=${encodeURIComponent(link.project_id)}`),
|
|
189
|
+
]);
|
|
190
|
+
return { link, project, connections };
|
|
191
|
+
}
|
|
192
|
+
export async function syncProject(kind, input, profile) {
|
|
193
|
+
const args = [...input];
|
|
194
|
+
const connection = safeIdentifier(args.shift(), 'Connection ID');
|
|
195
|
+
const data = await readPayload(takeOption(args, '--data'));
|
|
196
|
+
noExtra(args);
|
|
197
|
+
const client = await ApiClient.create(profile);
|
|
198
|
+
return client.request(`/api/source-control/connections/${connection}/${kind}/`, { method: 'POST', body: JSON.stringify(data) });
|
|
199
|
+
}
|
|
200
|
+
async function readStdin(limit = 64 * 1024) {
|
|
201
|
+
if (stdin.isTTY)
|
|
202
|
+
throw new CliError('Pipe the secret value through standard input; values are never accepted as command arguments.', EXIT.usage, 'secret_stdin_required');
|
|
203
|
+
const chunks = [];
|
|
204
|
+
let size = 0;
|
|
205
|
+
for await (const chunk of stdin) {
|
|
206
|
+
const buffer = Buffer.from(chunk);
|
|
207
|
+
size += buffer.length;
|
|
208
|
+
if (size > limit)
|
|
209
|
+
throw new CliError('Secret values must not exceed 64 KiB.', EXIT.validation, 'secret_too_large');
|
|
210
|
+
chunks.push(buffer);
|
|
211
|
+
}
|
|
212
|
+
const value = Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '');
|
|
213
|
+
if (!value)
|
|
214
|
+
throw new CliError('The secret value is empty.', EXIT.validation, 'secret_empty');
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
export async function runSecrets(input, profile) {
|
|
218
|
+
const args = [...input];
|
|
219
|
+
const action = args.shift() || 'list';
|
|
220
|
+
const client = await ApiClient.create(profile);
|
|
221
|
+
if (action === 'list') {
|
|
222
|
+
const project = takeOption(args, '--project');
|
|
223
|
+
noExtra(args);
|
|
224
|
+
return client.request(`/api/project-secrets/${encodeQuery({ site_id: project })}`);
|
|
225
|
+
}
|
|
226
|
+
if (action === 'set') {
|
|
227
|
+
const key = args.shift();
|
|
228
|
+
const project = takeOption(args, '--project');
|
|
229
|
+
const scope = takeOption(args, '--environment') || 'production';
|
|
230
|
+
const expires = takeOption(args, '--expires-in-days');
|
|
231
|
+
noExtra(args);
|
|
232
|
+
if (!key || !/^[A-Z][A-Z0-9_]{0,127}$/.test(key) || !project)
|
|
233
|
+
throw new CliError('Use `secrets set NAME --project ID` with an uppercase secret name.', EXIT.usage, 'invalid_secret');
|
|
234
|
+
const value = await readStdin();
|
|
235
|
+
return client.request('/api/project-secrets/', { method: 'POST', body: JSON.stringify({ site: project, key, scope, value, ...(expires ? { expires_in_days: Number(expires) } : {}) }) });
|
|
236
|
+
}
|
|
237
|
+
if (action === 'rotate') {
|
|
238
|
+
const id = safeIdentifier(args.shift(), 'Secret ID');
|
|
239
|
+
noExtra(args);
|
|
240
|
+
const value = await readStdin();
|
|
241
|
+
return client.request(`/api/project-secrets/${id}/rotate/`, { method: 'POST', body: JSON.stringify({ value, reason: 'CLI rotation' }) });
|
|
242
|
+
}
|
|
243
|
+
if (action === 'delete') {
|
|
244
|
+
const raw = args.shift();
|
|
245
|
+
const id = safeIdentifier(raw, 'Secret ID');
|
|
246
|
+
const confirmed = takeOption(args, '--confirm');
|
|
247
|
+
noExtra(args);
|
|
248
|
+
if (confirmed !== raw)
|
|
249
|
+
throw new CliError('Repeat the secret ID with --confirm before deleting it.', EXIT.usage, 'confirmation_required');
|
|
250
|
+
return client.request(`/api/project-secrets/${id}/`, { method: 'DELETE' });
|
|
251
|
+
}
|
|
252
|
+
throw new CliError(`Unknown secrets action: ${action}.`, EXIT.usage, 'unknown_action');
|
|
253
|
+
}
|
|
254
|
+
export async function rawApi(input, profile) {
|
|
255
|
+
const args = [...input];
|
|
256
|
+
const method = (args.shift() || 'GET').toUpperCase();
|
|
257
|
+
const path = args.shift();
|
|
258
|
+
const data = await readPayload(takeOption(args, '--data'));
|
|
259
|
+
noExtra(args);
|
|
260
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method))
|
|
261
|
+
throw new CliError('API method must be GET, POST, PUT, PATCH, or DELETE.', EXIT.usage, 'invalid_method');
|
|
262
|
+
if (!path || path.includes('://') || /[%\\\r\n]/.test(path))
|
|
263
|
+
throw new CliError('Use a normalized customer API path beginning with /api/.', EXIT.usage, 'invalid_api_path');
|
|
264
|
+
const parsed = new URL(path, 'https://api.blinkhost.me');
|
|
265
|
+
if (!parsed.pathname.startsWith('/api/') || parsed.pathname.includes('/../') || parsed.pathname.startsWith('/api/internal/') || parsed.pathname.startsWith('/api/ops/') || parsed.pathname.startsWith('/api/auth/'))
|
|
266
|
+
throw new CliError('Internal, staff, and authentication API paths are unavailable.', EXIT.usage, 'invalid_api_path');
|
|
267
|
+
if (method !== 'GET' && parsed.pathname.startsWith('/api/project-secrets/'))
|
|
268
|
+
throw new CliError('Use the dedicated `blinkhost secrets` commands so values never enter shell history.', EXIT.usage, 'unsafe_secret_command');
|
|
269
|
+
const client = await ApiClient.create(profile);
|
|
270
|
+
return client.request(path, { method, ...(method === 'GET' ? {} : { body: JSON.stringify(data) }) });
|
|
271
|
+
}
|
|
272
|
+
export async function waitForRemote(group, input, profile) {
|
|
273
|
+
const args = [...input];
|
|
274
|
+
const id = safeIdentifier(args.shift());
|
|
275
|
+
const timeoutRaw = takeOption(args, '--timeout') || '1200';
|
|
276
|
+
noExtra(args);
|
|
277
|
+
const timeout = Number(timeoutRaw);
|
|
278
|
+
if (!Number.isInteger(timeout) || timeout < 10 || timeout > 3600)
|
|
279
|
+
throw new CliError('Timeout must be an integer from 10 to 3600 seconds.', EXIT.usage, 'invalid_timeout');
|
|
280
|
+
const client = await ApiClient.create(profile);
|
|
281
|
+
const base = collectionPath(group);
|
|
282
|
+
const deadline = Date.now() + timeout * 1000;
|
|
283
|
+
const success = new Set(['ready', 'succeeded', 'success', 'completed', 'active', 'deployed']);
|
|
284
|
+
const failure = new Set(['failed', 'cancelled', 'canceled', 'expired', 'revoked']);
|
|
285
|
+
while (Date.now() < deadline) {
|
|
286
|
+
const data = await client.request(`${base}${id}/`);
|
|
287
|
+
const state = String(data.status || data.state || data.phase || '').toLowerCase();
|
|
288
|
+
if (success.has(state))
|
|
289
|
+
return data;
|
|
290
|
+
if (failure.has(state))
|
|
291
|
+
throw new CliError(`${group.slice(0, -1)} ${id} finished with status ${state}.`, EXIT.remote, `${group.slice(0, -1)}_failed`);
|
|
292
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
293
|
+
}
|
|
294
|
+
throw new CliError(`Timed out waiting for ${group.slice(0, -1)} ${id}.`, EXIT.network, 'wait_timeout');
|
|
295
|
+
}
|
|
296
|
+
export async function openPreview(input, profile) {
|
|
297
|
+
const args = [...input];
|
|
298
|
+
const id = safeIdentifier(args.shift());
|
|
299
|
+
noExtra(args);
|
|
300
|
+
const client = await ApiClient.create(profile);
|
|
301
|
+
const data = await client.request(`${collectionPath('previews')}${id}/`);
|
|
302
|
+
const value = data.url || data.preview_url || data.public_url;
|
|
303
|
+
if (typeof value !== 'string')
|
|
304
|
+
throw new CliError('This preview does not have a ready URL.', EXIT.conflict, 'preview_not_ready');
|
|
305
|
+
const url = new URL(value);
|
|
306
|
+
const trusted = url.protocol === 'https:' && (url.hostname === 'preview.blinkhost.me' || url.hostname.endsWith('.preview.blinkhost.me') || url.hostname.endsWith('.blinkhost.website'));
|
|
307
|
+
if (!trusted || url.username || url.password)
|
|
308
|
+
throw new CliError('BlinkHost returned an untrusted preview URL.', EXIT.remote, 'preview_url_untrusted');
|
|
309
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd.exe' : 'xdg-open';
|
|
310
|
+
const commandArgs = process.platform === 'win32' ? ['/d', '/s', '/c', 'start', '', url.toString()] : [url.toString()];
|
|
311
|
+
const child = spawn(command, commandArgs, { detached: true, shell: false, stdio: 'ignore' });
|
|
312
|
+
child.on('error', () => { });
|
|
313
|
+
child.unref();
|
|
314
|
+
return { id: decodeURIComponent(id), url: url.toString() };
|
|
315
|
+
}
|
|
316
|
+
const ASSET_MEDIA_TYPES = {
|
|
317
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
318
|
+
'.webp': 'image/webp', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2',
|
|
319
|
+
'.mp3': 'audio/mpeg', '.mp4': 'video/mp4',
|
|
320
|
+
};
|
|
321
|
+
export async function uploadAsset(input, profile) {
|
|
322
|
+
const args = [...input];
|
|
323
|
+
const source = args.shift();
|
|
324
|
+
const project = takeOption(args, '--project');
|
|
325
|
+
const parentId = takeOption(args, '--parent');
|
|
326
|
+
const revision = takeOption(args, '--revision');
|
|
327
|
+
const replaceAssetId = takeOption(args, '--replace');
|
|
328
|
+
noExtra(args);
|
|
329
|
+
if (!source || !project)
|
|
330
|
+
throw new CliError('Use `assets upload FILE --project PROJECT_ID`.', EXIT.usage, 'asset_arguments_required');
|
|
331
|
+
const path = resolveLocalPath(source);
|
|
332
|
+
const metadata = await lstat(path);
|
|
333
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size < 1)
|
|
334
|
+
throw new CliError('Assets must be non-empty regular files, not symbolic links.', EXIT.validation, 'invalid_asset_file');
|
|
335
|
+
const mediaType = ASSET_MEDIA_TYPES[extname(path).toLowerCase()];
|
|
336
|
+
if (!mediaType)
|
|
337
|
+
throw new CliError('Upload PNG, JPEG, GIF, WebP, ICO, WOFF, WOFF2, MP3, or MP4 files.', EXIT.validation, 'asset_type_not_supported');
|
|
338
|
+
const body = await readFile(path);
|
|
339
|
+
const checksum = createHash('sha256').update(body).digest('hex');
|
|
340
|
+
const client = await ApiClient.create(profile);
|
|
341
|
+
const reservation = await client.request(`/api/sites/${encodeURIComponent(project)}/assets/`, { method: 'POST', body: JSON.stringify({ name: basename(path), media_type: mediaType, size_bytes: body.length, checksum_sha256: checksum, ...(parentId ? { parent_id: parentId } : {}), ...(revision ? { revision } : {}), ...(replaceAssetId ? { replace_asset_id: replaceAssetId } : {}) }) });
|
|
342
|
+
let uploadUrl;
|
|
343
|
+
try {
|
|
344
|
+
uploadUrl = new URL(reservation.upload_url);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
throw new CliError('BlinkHost returned an invalid upload address.', EXIT.remote, 'asset_upload_url_invalid');
|
|
348
|
+
}
|
|
349
|
+
if (uploadUrl.protocol !== 'https:' || !uploadUrl.hostname.endsWith('.blob.core.windows.net'))
|
|
350
|
+
throw new CliError('BlinkHost returned an untrusted upload address.', EXIT.remote, 'asset_upload_url_untrusted');
|
|
351
|
+
const uploaded = await fetch(uploadUrl, { method: 'PUT', redirect: 'error', headers: { ...reservation.required_headers, 'Content-Length': String(body.length) }, body });
|
|
352
|
+
if (!uploaded.ok)
|
|
353
|
+
throw new CliError(`Asset storage rejected the upload with HTTP ${uploaded.status}.`, EXIT.remote, 'asset_upload_failed');
|
|
354
|
+
for (let attempt = 0; attempt < 15; attempt += 1) {
|
|
355
|
+
try {
|
|
356
|
+
return await client.request(`/api/sites/${encodeURIComponent(project)}/assets/${encodeURIComponent(reservation.asset_id)}/complete/`, { method: 'POST', body: JSON.stringify({ ...(revision ? { revision } : {}) }) });
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (!(error instanceof CliError) || error.code !== 'api_409' || attempt === 14)
|
|
360
|
+
throw error;
|
|
361
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
throw new CliError('Asset validation did not complete in time.', EXIT.remote, 'asset_validation_timeout');
|
|
365
|
+
}
|
|
366
|
+
//# sourceMappingURL=remote.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BlinkHostManifest, FrontendFramework, ModuleLanguage, PackageManager } from './manifest.js';
|
|
2
|
+
export interface ScaffoldModule {
|
|
3
|
+
name: string;
|
|
4
|
+
language: ModuleLanguage;
|
|
5
|
+
}
|
|
6
|
+
export interface ScaffoldOptions {
|
|
7
|
+
name: string;
|
|
8
|
+
framework: FrontendFramework;
|
|
9
|
+
packageManager: PackageManager;
|
|
10
|
+
modules: ScaffoldModule[];
|
|
11
|
+
database?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Scaffold {
|
|
14
|
+
manifest: BlinkHostManifest;
|
|
15
|
+
files: Map<string, string>;
|
|
16
|
+
}
|
|
17
|
+
export declare function createScaffold(options: ScaffoldOptions): Scaffold;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const VERSIONS = {
|
|
2
|
+
vite: '6.4.3', typescript: '5.9.3', react: '19.2.8', vue: '3.5.40',
|
|
3
|
+
svelte: '5.56.8', solid: '1.9.14', astro: '7.1.6',
|
|
4
|
+
};
|
|
5
|
+
function commands(manager) {
|
|
6
|
+
return {
|
|
7
|
+
install: manager === 'npm' ? 'npm ci' : `${manager} install --frozen-lockfile`,
|
|
8
|
+
build: `${manager} run build`,
|
|
9
|
+
dev: `${manager} run dev`,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function frontendFiles(name, framework) {
|
|
13
|
+
const files = new Map();
|
|
14
|
+
files.set('.npmrc', 'engine-strict=true\nignore-scripts=true\naudit=false\nfund=false\nprefer-offline=true\n');
|
|
15
|
+
if (framework === 'html') {
|
|
16
|
+
files.set('index.html', '<!doctype html>\n<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>BlinkHost project</title><link rel="stylesheet" href="style.css"></head><body><main><h1>Ready to build on BlinkHost</h1></main><script type="module" src="script.js"></script></body></html>\n');
|
|
17
|
+
files.set('style.css', ':root { font-family: Inter, system-ui, sans-serif; color-scheme: dark; }\nbody { margin: 0; padding: 3rem; background: #071113; color: #f8fafc; }\n');
|
|
18
|
+
files.set('script.js', "console.info('BlinkHost project ready');\n");
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
if (framework === 'astro') {
|
|
22
|
+
files.set('package.json', JSON.stringify({ name, private: true, version: '0.1.0', type: 'module', scripts: { dev: 'astro dev --host 0.0.0.0 --port 3000', build: 'astro build' }, dependencies: { astro: VERSIONS.astro } }, null, 2) + '\n');
|
|
23
|
+
files.set('astro.config.mjs', "import { defineConfig } from 'astro/config';\nexport default defineConfig({ server: { host: '0.0.0.0', port: 3000 } });\n");
|
|
24
|
+
files.set('src/pages/index.astro', '---\nconst title = "Ready to build on BlinkHost";\n---\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>{title}</title></head><body><main><h1>{title}</h1></main></body></html>\n');
|
|
25
|
+
return files;
|
|
26
|
+
}
|
|
27
|
+
const base = { name, private: true, version: '0.1.0', type: 'module', scripts: { dev: 'vite --host 0.0.0.0 --port 3000', build: 'vite build' } };
|
|
28
|
+
files.set('index.html', '<!doctype html>\n<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>BlinkHost project</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>\n');
|
|
29
|
+
if (framework === 'react') {
|
|
30
|
+
files.set('package.json', JSON.stringify({ ...base, dependencies: { react: VERSIONS.react, 'react-dom': VERSIONS.react }, devDependencies: { '@vitejs/plugin-react': '4.7.0', '@types/react': '19.2.17', '@types/react-dom': '19.2.3', typescript: VERSIONS.typescript, vite: VERSIONS.vite } }, null, 2) + '\n');
|
|
31
|
+
files.set('vite.config.ts', "import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\nexport default defineConfig({ plugins: [react()], server: { host: '0.0.0.0', port: 3000 } });\n");
|
|
32
|
+
files.set('src/main.tsx', "import React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport './style.css';\ncreateRoot(document.getElementById('root')!).render(<React.StrictMode><main><h1>Ready to build on BlinkHost</h1></main></React.StrictMode>);\n");
|
|
33
|
+
}
|
|
34
|
+
else if (framework === 'vue') {
|
|
35
|
+
files.set('package.json', JSON.stringify({ ...base, dependencies: { vue: VERSIONS.vue }, devDependencies: { '@vitejs/plugin-vue': '5.2.4', typescript: VERSIONS.typescript, vite: VERSIONS.vite, 'vue-tsc': '3.3.8' } }, null, 2) + '\n');
|
|
36
|
+
files.set('vite.config.ts', "import { defineConfig } from 'vite';\nimport vue from '@vitejs/plugin-vue';\nexport default defineConfig({ plugins: [vue()], server: { host: '0.0.0.0', port: 3000 } });\n");
|
|
37
|
+
files.set('index.html', files.get('index.html').replace('/src/main.tsx', '/src/main.ts'));
|
|
38
|
+
files.set('src/main.ts', "import { createApp } from 'vue';\nimport App from './App.vue';\ncreateApp(App).mount('#root');\n");
|
|
39
|
+
files.set('src/App.vue', '<template><main><h1>Ready to build on BlinkHost</h1></main></template>\n');
|
|
40
|
+
}
|
|
41
|
+
else if (framework === 'svelte') {
|
|
42
|
+
files.set('package.json', JSON.stringify({ ...base, devDependencies: { '@sveltejs/vite-plugin-svelte': '5.1.1', svelte: VERSIONS.svelte, vite: VERSIONS.vite } }, null, 2) + '\n');
|
|
43
|
+
files.set('vite.config.ts', "import { defineConfig } from 'vite';\nimport { svelte } from '@sveltejs/vite-plugin-svelte';\nexport default defineConfig({ plugins: [svelte()], server: { host: '0.0.0.0', port: 3000 } });\n");
|
|
44
|
+
files.set('index.html', files.get('index.html').replace('/src/main.tsx', '/src/main.ts'));
|
|
45
|
+
files.set('src/main.ts', "import { mount } from 'svelte';\nimport App from './App.svelte';\nmount(App, { target: document.getElementById('root')! });\n");
|
|
46
|
+
files.set('src/App.svelte', '<main><h1>Ready to build on BlinkHost</h1></main>\n');
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
files.set('package.json', JSON.stringify({ ...base, dependencies: { 'solid-js': VERSIONS.solid }, devDependencies: { 'vite-plugin-solid': '2.11.14', vite: VERSIONS.vite } }, null, 2) + '\n');
|
|
50
|
+
files.set('vite.config.ts', "import { defineConfig } from 'vite';\nimport solid from 'vite-plugin-solid';\nexport default defineConfig({ plugins: [solid()], server: { host: '0.0.0.0', port: 3000 } });\n");
|
|
51
|
+
files.set('src/main.tsx', "import { render } from 'solid-js/web';\nrender(() => <main><h1>Ready to build on BlinkHost</h1></main>, document.getElementById('root')!);\n");
|
|
52
|
+
}
|
|
53
|
+
files.set('src/style.css', ':root { font-family: Inter, system-ui, sans-serif; color-scheme: dark; }\nbody { margin: 0; padding: 3rem; background: #071113; color: #f8fafc; }\n');
|
|
54
|
+
files.set('tsconfig.json', JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'ESNext', moduleResolution: 'bundler', strict: true, noEmit: true, jsx: framework === 'solid' ? 'preserve' : 'react-jsx' }, include: ['src'] }, null, 2) + '\n');
|
|
55
|
+
return files;
|
|
56
|
+
}
|
|
57
|
+
function moduleFiles(module) {
|
|
58
|
+
const root = `_server_islands/${module.name}`;
|
|
59
|
+
const files = new Map();
|
|
60
|
+
if (module.language === 'python') {
|
|
61
|
+
files.set(`${root}/main.py`, 'def handler(request):\n return {"status": 200, "body": {"ok": True}}\n');
|
|
62
|
+
files.set(`${root}/requirements.txt`, '# Add pinned runtime dependencies here.\n');
|
|
63
|
+
return { entrypoint: 'main.py', files };
|
|
64
|
+
}
|
|
65
|
+
if (module.language === 'go') {
|
|
66
|
+
files.set(`${root}/go.mod`, `module blinkhost/${module.name}\n\ngo 1.23\n`);
|
|
67
|
+
files.set(`${root}/main.go`, 'package main\n\nfunc main() {}\n');
|
|
68
|
+
return { entrypoint: 'main.go', files };
|
|
69
|
+
}
|
|
70
|
+
files.set(`${root}/Cargo.toml`, `[package]\nname = "${module.name}"\nversion = "0.1.0"\nedition = "2021"\n\n[lib]\ncrate-type = ["cdylib"]\n`);
|
|
71
|
+
files.set(`${root}/src/lib.rs`, '#[no_mangle]\npub extern "C" fn blinkhost_module_version() -> u32 { 1 }\n');
|
|
72
|
+
return { entrypoint: 'src/lib.rs', files };
|
|
73
|
+
}
|
|
74
|
+
export function createScaffold(options) {
|
|
75
|
+
const packageCommands = commands(options.packageManager);
|
|
76
|
+
const files = frontendFiles(options.name, options.framework);
|
|
77
|
+
const modules = [];
|
|
78
|
+
for (const item of options.modules) {
|
|
79
|
+
const generated = moduleFiles(item);
|
|
80
|
+
for (const [path, contents] of generated.files)
|
|
81
|
+
files.set(path, contents);
|
|
82
|
+
modules.push({ name: item.name, path: `_server_islands/${item.name}`, language: item.language, entrypoint: generated.entrypoint, abi: 'blinkhost-wasi-1', sdk: '1.1' });
|
|
83
|
+
}
|
|
84
|
+
const databases = options.database ? [{ binding: options.database, schema: `database/${options.database}/schema.sql`, migrations: `database/${options.database}/migrations` }] : [];
|
|
85
|
+
if (options.database) {
|
|
86
|
+
files.set(`database/${options.database}/schema.sql`, '-- Keep the current reviewable schema here.\n');
|
|
87
|
+
files.set(`database/${options.database}/migrations/README.md`, 'Add ordered, forward-only SQL migrations in this directory.\n');
|
|
88
|
+
}
|
|
89
|
+
files.set('.gitignore', 'node_modules/\ndist/\n.env\n.env.*\n!.env.example\n.blinkhost/\n');
|
|
90
|
+
files.set('README.md', `# ${options.name}\n\nCreated with the BlinkHost CLI. Run \`blinkhost validate\` before connecting this repository.\n`);
|
|
91
|
+
return {
|
|
92
|
+
manifest: {
|
|
93
|
+
schema: 'blinkhost/v1', application: { root: '.' },
|
|
94
|
+
frontend: {
|
|
95
|
+
root: '.', dependency_root: '.', framework: options.framework, package_manager: options.packageManager,
|
|
96
|
+
install: options.framework === 'html' ? '' : packageCommands.install,
|
|
97
|
+
build: options.framework === 'html' ? '' : packageCommands.build,
|
|
98
|
+
dev: options.framework === 'html' ? '' : packageCommands.dev,
|
|
99
|
+
output: options.framework === 'html' ? '.' : 'dist',
|
|
100
|
+
},
|
|
101
|
+
modules,
|
|
102
|
+
resources: { databases, secrets: [] },
|
|
103
|
+
preview: { enabled: true, database_mode: options.database ? 'isolated_branch' : 'none' },
|
|
104
|
+
ignore: ['.blinkhost', 'node_modules', 'dist'],
|
|
105
|
+
},
|
|
106
|
+
files,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=templates.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function runDev(input: string[]): Promise<unknown>;
|
|
2
|
+
export declare function testProject(input: string[]): Promise<unknown>;
|
|
3
|
+
export declare function observability(kind: 'logs' | 'metrics' | 'analytics', input: string[], profile?: string): Promise<unknown>;
|
|
4
|
+
export declare function runPlugins(input: string[]): Promise<unknown>;
|
|
5
|
+
export declare function completion(shell: string | undefined): string;
|
|
6
|
+
export declare function checkForUpdate(): Promise<unknown>;
|
|
7
|
+
export declare function ciCheck(profile?: string): Promise<unknown>;
|
|
8
|
+
export declare function supportBundle(input: string[], profile?: string): Promise<unknown>;
|