@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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { hostname, platform, release } from 'node:os';
|
|
4
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { ApiClient } from './api.js';
|
|
7
|
+
import { readConfig, writeConfig } from './config.js';
|
|
8
|
+
import { CliError, EXIT } from './errors.js';
|
|
9
|
+
import { readProjectManifest, resolveLocalPath, validateProject } from './project.js';
|
|
10
|
+
import { readProjectLink } from './remote.js';
|
|
11
|
+
function takeOption(args, name) {
|
|
12
|
+
const index = args.indexOf(name);
|
|
13
|
+
if (index < 0)
|
|
14
|
+
return undefined;
|
|
15
|
+
const value = args[index + 1];
|
|
16
|
+
if (!value || value.startsWith('--'))
|
|
17
|
+
throw new CliError(`${name} requires a value.`, EXIT.usage, 'missing_option_value');
|
|
18
|
+
args.splice(index, 2);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function takeFlag(args, name) { const i = args.indexOf(name); if (i < 0)
|
|
22
|
+
return false; args.splice(i, 1); return true; }
|
|
23
|
+
function noExtra(args) { if (args.length)
|
|
24
|
+
throw new CliError(`Unexpected argument: ${args[0]}`, EXIT.usage, 'unexpected_argument'); }
|
|
25
|
+
async function spawnInherited(command, args, cwd, env) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const child = spawn(command, args, { cwd, env: env || process.env, shell: false, stdio: 'inherit', windowsHide: true });
|
|
28
|
+
const forward = (signal) => child.kill(signal);
|
|
29
|
+
process.once('SIGINT', forward);
|
|
30
|
+
process.once('SIGTERM', forward);
|
|
31
|
+
child.once('error', () => reject(new CliError(`The ${command} executable is unavailable.`, EXIT.filesystem, 'executable_unavailable')));
|
|
32
|
+
child.once('exit', (code) => { process.off('SIGINT', forward); process.off('SIGTERM', forward); resolve(code ?? 1); });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export async function runDev(input) {
|
|
36
|
+
const args = [...input];
|
|
37
|
+
const root = resolveLocalPath(args.shift());
|
|
38
|
+
const host = takeOption(args, '--host');
|
|
39
|
+
const port = takeOption(args, '--port');
|
|
40
|
+
noExtra(args);
|
|
41
|
+
const validation = await validateProject(root);
|
|
42
|
+
if (validation.errors.length)
|
|
43
|
+
throw new CliError('Project validation failed before local development started.', EXIT.validation, 'project_invalid', validation.errors);
|
|
44
|
+
const manifest = validation.manifest;
|
|
45
|
+
if (manifest.frontend.framework === 'html')
|
|
46
|
+
throw new CliError('Static HTML projects do not define a development process. Use a local static-file server.', EXIT.validation, 'dev_command_unavailable');
|
|
47
|
+
const manager = manifest.frontend.package_manager;
|
|
48
|
+
const managerArgs = manager === 'npm' ? ['run', 'dev', '--'] : ['run', 'dev'];
|
|
49
|
+
if (host)
|
|
50
|
+
managerArgs.push('--host', host);
|
|
51
|
+
if (port)
|
|
52
|
+
managerArgs.push('--port', port);
|
|
53
|
+
const dependencyRoot = manifest.frontend.dependency_root === '.' ? root : join(root, manifest.frontend.dependency_root);
|
|
54
|
+
const exitCode = await spawnInherited(manager, managerArgs, dependencyRoot, { ...process.env, BLINKHOST_LOCAL: '1' });
|
|
55
|
+
if (exitCode !== 0)
|
|
56
|
+
throw new CliError(`The local development process exited with code ${exitCode}.`, EXIT.remote, 'dev_process_failed');
|
|
57
|
+
return { exit_code: exitCode };
|
|
58
|
+
}
|
|
59
|
+
export async function testProject(input) {
|
|
60
|
+
const args = [...input];
|
|
61
|
+
const root = resolveLocalPath(args.shift());
|
|
62
|
+
noExtra(args);
|
|
63
|
+
const validation = await validateProject(root);
|
|
64
|
+
if (validation.errors.length)
|
|
65
|
+
throw new CliError('Project validation failed.', EXIT.validation, 'project_invalid', validation.errors);
|
|
66
|
+
const manifest = validation.manifest;
|
|
67
|
+
if (manifest.frontend.framework === 'html')
|
|
68
|
+
return { validated: true, build: 'not_required' };
|
|
69
|
+
const dependencyRoot = manifest.frontend.dependency_root === '.' ? root : join(root, manifest.frontend.dependency_root);
|
|
70
|
+
const packageJson = JSON.parse(await readFile(join(dependencyRoot, 'package.json'), 'utf8'));
|
|
71
|
+
const script = packageJson.scripts?.test ? 'test' : 'build';
|
|
72
|
+
const code = await spawnInherited(manifest.frontend.package_manager, ['run', script], dependencyRoot, { ...process.env, CI: '1' });
|
|
73
|
+
if (code !== 0)
|
|
74
|
+
throw new CliError(`Project ${script} exited with code ${code}.`, EXIT.validation, 'project_test_failed');
|
|
75
|
+
return { validated: true, command: `${manifest.frontend.package_manager} run ${script}`, exit_code: code };
|
|
76
|
+
}
|
|
77
|
+
export async function observability(kind, input, profile) {
|
|
78
|
+
const args = [...input];
|
|
79
|
+
let project = takeOption(args, '--project');
|
|
80
|
+
const since = takeOption(args, '--since');
|
|
81
|
+
const limit = takeOption(args, '--limit');
|
|
82
|
+
if (!project) {
|
|
83
|
+
try {
|
|
84
|
+
project = (await readProjectLink()).project_id;
|
|
85
|
+
}
|
|
86
|
+
catch { }
|
|
87
|
+
}
|
|
88
|
+
noExtra(args);
|
|
89
|
+
if (!project)
|
|
90
|
+
throw new CliError('Provide --project or link this directory with `blinkhost projects link`.', EXIT.usage, 'project_required');
|
|
91
|
+
const query = new URLSearchParams();
|
|
92
|
+
if (since)
|
|
93
|
+
query.set('since', since);
|
|
94
|
+
if (limit)
|
|
95
|
+
query.set('limit', limit);
|
|
96
|
+
const suffix = query.size ? `?${query}` : '';
|
|
97
|
+
const client = await ApiClient.create(profile);
|
|
98
|
+
return client.request(`/api/sites/${encodeURIComponent(project)}/observability/${kind}/${suffix}`);
|
|
99
|
+
}
|
|
100
|
+
async function sha256File(path) { return createHash('sha256').update(await readFile(path)).digest('hex'); }
|
|
101
|
+
export async function runPlugins(input) {
|
|
102
|
+
const args = [...input];
|
|
103
|
+
const action = args.shift() || 'list';
|
|
104
|
+
const config = await readConfig();
|
|
105
|
+
config.plugins ||= {};
|
|
106
|
+
if (action === 'list') {
|
|
107
|
+
noExtra(args);
|
|
108
|
+
return { plugins: Object.entries(config.plugins).map(([name, value]) => ({ name, ...value })) };
|
|
109
|
+
}
|
|
110
|
+
if (action === 'add') {
|
|
111
|
+
const source = args.shift();
|
|
112
|
+
const requestedName = takeOption(args, '--name');
|
|
113
|
+
noExtra(args);
|
|
114
|
+
if (!source || !isAbsolute(source))
|
|
115
|
+
throw new CliError('Plugin executables must use an absolute path.', EXIT.usage, 'plugin_absolute_path_required');
|
|
116
|
+
const sourceInfo = await lstat(source);
|
|
117
|
+
if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink())
|
|
118
|
+
throw new CliError('Plugin executables must be regular files and cannot be symbolic links.', EXIT.validation, 'invalid_plugin');
|
|
119
|
+
const path = await realpath(source);
|
|
120
|
+
const info = await lstat(path);
|
|
121
|
+
if (!info.isFile())
|
|
122
|
+
throw new CliError('Plugin executables must be regular files.', EXIT.validation, 'invalid_plugin');
|
|
123
|
+
const name = requestedName || basename(path).replace(/[^a-z0-9_-]/gi, '-').toLowerCase();
|
|
124
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name))
|
|
125
|
+
throw new CliError('Plugin names use lowercase letters, numbers, hyphens, and underscores.', EXIT.usage, 'invalid_plugin_name');
|
|
126
|
+
config.plugins[name] = { executable: path, sha256: await sha256File(path), addedAt: new Date().toISOString() };
|
|
127
|
+
await writeConfig(config);
|
|
128
|
+
return { name, ...config.plugins[name] };
|
|
129
|
+
}
|
|
130
|
+
if (action === 'remove') {
|
|
131
|
+
const name = args.shift();
|
|
132
|
+
noExtra(args);
|
|
133
|
+
if (!name || !config.plugins[name])
|
|
134
|
+
throw new CliError('Plugin not found.', EXIT.usage, 'plugin_not_found');
|
|
135
|
+
delete config.plugins[name];
|
|
136
|
+
await writeConfig(config);
|
|
137
|
+
return { removed: name };
|
|
138
|
+
}
|
|
139
|
+
if (action === 'verify' || action === 'run') {
|
|
140
|
+
const name = args.shift();
|
|
141
|
+
if (!name || !config.plugins[name])
|
|
142
|
+
throw new CliError('Plugin not found.', EXIT.usage, 'plugin_not_found');
|
|
143
|
+
const plugin = config.plugins[name];
|
|
144
|
+
const current = await sha256File(plugin.executable);
|
|
145
|
+
if (current !== plugin.sha256)
|
|
146
|
+
throw new CliError('The plugin changed after approval. Remove and add it again only after reviewing the change.', EXIT.validation, 'plugin_integrity_failed');
|
|
147
|
+
if (action === 'verify') {
|
|
148
|
+
noExtra(args);
|
|
149
|
+
return { name, verified: true, sha256: current };
|
|
150
|
+
}
|
|
151
|
+
const passthrough = args[0] === '--' ? args.slice(1) : args;
|
|
152
|
+
const allowed = ['PATH', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'TEMP', 'TMP'];
|
|
153
|
+
const env = { BLINKHOST_PLUGIN_PROTOCOL: '1' };
|
|
154
|
+
for (const key of allowed)
|
|
155
|
+
if (process.env[key])
|
|
156
|
+
env[key] = process.env[key];
|
|
157
|
+
const code = await spawnInherited(plugin.executable, passthrough, process.cwd(), env);
|
|
158
|
+
if (code !== 0)
|
|
159
|
+
throw new CliError(`Plugin ${name} exited with code ${code}.`, EXIT.remote, 'plugin_failed');
|
|
160
|
+
return { name, exit_code: code };
|
|
161
|
+
}
|
|
162
|
+
throw new CliError(`Unknown plugins action: ${action}.`, EXIT.usage, 'unknown_action');
|
|
163
|
+
}
|
|
164
|
+
const COMPLETIONS = {
|
|
165
|
+
bash: `complete -W "auth projects repositories connections dev previews builds deployments modules databases bindings assets secrets organizations templates approvals handoffs policies logs metrics analytics doctor support completion update ci plugins api" blinkhost`,
|
|
166
|
+
zsh: `#compdef blinkhost\n_arguments '1:command:(auth projects repositories connections dev previews builds deployments modules databases bindings assets secrets organizations templates approvals handoffs policies logs metrics analytics doctor support completion update ci plugins api)'`,
|
|
167
|
+
fish: `complete -c blinkhost -f -a "auth projects repositories connections dev previews builds deployments modules databases bindings assets secrets organizations templates approvals handoffs policies logs metrics analytics doctor support completion update ci plugins api"`,
|
|
168
|
+
powershell: `Register-ArgumentCompleter -Native -CommandName blinkhost -ScriptBlock { param($wordToComplete) 'auth','projects','repositories','connections','dev','previews','builds','deployments','modules','databases','bindings','assets','secrets','organizations','templates','approvals','handoffs','policies','logs','metrics','analytics','doctor','support','completion','update','ci','plugins','api' | Where-Object { $_ -like "$wordToComplete*" } }`,
|
|
169
|
+
};
|
|
170
|
+
export function completion(shell) { if (!shell || !COMPLETIONS[shell])
|
|
171
|
+
throw new CliError('Choose bash, zsh, fish, or powershell.', EXIT.usage, 'invalid_shell'); return `${COMPLETIONS[shell]}\n`; }
|
|
172
|
+
export async function checkForUpdate() {
|
|
173
|
+
const response = await fetch('https://api.github.com/repos/blinkhost-ltd/blinkhost-cli/releases/latest', { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'BlinkHost-CLI/2.0.0' }, redirect: 'error' });
|
|
174
|
+
if (!response.ok)
|
|
175
|
+
throw new CliError('The release service could not be reached.', EXIT.network, 'update_check_failed');
|
|
176
|
+
const data = await response.json();
|
|
177
|
+
return { current_version: '2.0.0', latest_version: data.tag_name?.replace(/^v/, '') || null, release_url: data.html_url || null, automatic_install: false };
|
|
178
|
+
}
|
|
179
|
+
export async function ciCheck(profile) {
|
|
180
|
+
const client = await ApiClient.create(profile);
|
|
181
|
+
const capabilities = await client.request('/api/cli/v2/capabilities/');
|
|
182
|
+
return { ready: true, token_source: process.env.BLINKHOST_ACCESS_TOKEN ? 'environment' : process.env.ACTIONS_ID_TOKEN_REQUEST_URL ? 'github_oidc' : 'profile', capabilities };
|
|
183
|
+
}
|
|
184
|
+
export async function supportBundle(input, profile) {
|
|
185
|
+
const args = [...input];
|
|
186
|
+
const output = resolveLocalPath(takeOption(args, '--output') || `blinkhost-support-${Date.now()}.json`);
|
|
187
|
+
noExtra(args);
|
|
188
|
+
const root = process.cwd();
|
|
189
|
+
let project = null;
|
|
190
|
+
try {
|
|
191
|
+
const manifest = await readProjectManifest(root);
|
|
192
|
+
project = { schema: manifest.schema, framework: manifest.frontend.framework, package_manager: manifest.frontend.package_manager, module_languages: manifest.modules.map((item) => item.language), module_count: manifest.modules.length, database_count: manifest.resources.databases.length };
|
|
193
|
+
}
|
|
194
|
+
catch { }
|
|
195
|
+
let api = { reachable: false };
|
|
196
|
+
try {
|
|
197
|
+
const client = await ApiClient.create(profile);
|
|
198
|
+
const capabilities = await client.request('/api/cli/v2/capabilities/');
|
|
199
|
+
api = { reachable: true, capabilities };
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
api = { reachable: false, error_code: error instanceof CliError ? error.code : 'unknown' };
|
|
203
|
+
}
|
|
204
|
+
const payload = { schema: 'blinkhost/support-bundle/v1', created_at: new Date().toISOString(), cli_version: '2.0.0', system: { platform: platform(), release: release(), node: process.versions.node, device_hash: createHash('sha256').update(hostname()).digest('hex').slice(0, 16) }, project, api };
|
|
205
|
+
await mkdir(dirname(output), { recursive: true });
|
|
206
|
+
await writeFile(output, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
207
|
+
return { output, redacted: true };
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=workflows.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blinkhost/cli",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Secure local and remote developer workflows for BlinkHost",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "blinkhost": "dist/cli.js" },
|
|
7
|
+
"files": ["dist/*.js", "dist/*.d.ts", "README.md", "LICENSE"],
|
|
8
|
+
"engines": { "node": ">=22.12.0" },
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -p tsconfig.json",
|
|
11
|
+
"test": "npm run build && node --test dist/test/*.test.js",
|
|
12
|
+
"prepack": "npm test"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": { "yaml": "2.9.0" },
|
|
15
|
+
"devDependencies": { "@types/node": "20.19.24", "typescript": "5.9.3" },
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"publishConfig": { "access": "public", "provenance": true }
|
|
18
|
+
}
|