@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/dist/cli.js ADDED
@@ -0,0 +1,443 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { realpathSync } from 'node:fs';
4
+ import { access } from 'node:fs/promises';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { CliError, EXIT } from './errors.js';
7
+ import { SUPPORTED_FRONTENDS, SUPPORTED_MANAGERS, SUPPORTED_MODULES, } from './manifest.js';
8
+ import { detectManifest } from './detect.js';
9
+ import { readProjectManifest, resolveLocalPath, validateProject, writeManifest, writeScaffoldAtomically } from './project.js';
10
+ import { createScaffold } from './templates.js';
11
+ import { ApiClient } from './api.js';
12
+ import { login, logout } from './auth.js';
13
+ import { activeProfile, readConfig, validateProfileName, writeConfig } from './config.js';
14
+ import { openPreview, projectStatus, rawApi, readProjectLink, runRemote, runSecrets, syncProject, unlinkProject, uploadAsset, waitForRemote, writeProjectLink } from './remote.js';
15
+ import { checkForUpdate, ciCheck, completion, observability, runDev, runPlugins, supportBundle, testProject } from './workflows.js';
16
+ const VERSION = '2.0.0';
17
+ const HELP = `BlinkHost CLI ${VERSION}
18
+
19
+ Usage:
20
+ blinkhost create <project-name> [--template react] [--package-manager npm]
21
+ [--module name:python] [--database APP_DB] [--no-install]
22
+ blinkhost init [path] [--force]
23
+ blinkhost validate [path]
24
+ blinkhost manifest [path]
25
+ blinkhost doctor [path]
26
+ blinkhost test [path]
27
+ blinkhost auth login|logout|status|sessions|revoke
28
+ blinkhost projects list|get|create|update|delete|action|link|current
29
+ blinkhost repositories|connections|previews|builds|deployments <action>
30
+ blinkhost modules|databases|bindings|assets|secrets <action>
31
+ blinkhost organizations|templates|approvals|handoffs|policies|workloads <action>
32
+ blinkhost dev [path] [--host HOST] [--port PORT]
33
+ blinkhost logs|metrics|analytics --project PROJECT_ID
34
+ blinkhost support bundle [--output PATH]
35
+ blinkhost completion bash|zsh|fish
36
+ blinkhost update check
37
+ blinkhost ci check
38
+ blinkhost plugins list|add|remove|verify|run
39
+ blinkhost api METHOD /api/customer/path/ [--data JSON_OR_@FILE]
40
+
41
+ Global options:
42
+ --json Return machine-readable output
43
+ --profile Use a named account and API profile
44
+ --quiet Suppress successful human-readable output
45
+ --verbose Include safe diagnostic detail in errors
46
+ --no-color Disable terminal colour (accepted for portable scripts)
47
+ --non-interactive Never open a browser or prompt
48
+ --help Show command help
49
+ --version Show the CLI version
50
+
51
+ Refresh credentials are stored only by the operating-system credential service.
52
+ BlinkHost remains authoritative for roles, plan limits, approvals, builds, releases,
53
+ deployments, and audit records. Secret values are accepted only through standard input.
54
+ `;
55
+ let quietOutput = false;
56
+ let verboseOutput = false;
57
+ function terminalText(value) {
58
+ return value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '');
59
+ }
60
+ function emit(output, json) {
61
+ if (json)
62
+ process.stdout.write(`${JSON.stringify(output)}\n`);
63
+ else {
64
+ if (quietOutput)
65
+ return;
66
+ process.stdout.write(`${terminalText(output.message)}\n`);
67
+ if (output.data !== undefined && output.data !== null) {
68
+ process.stdout.write(`${terminalText(JSON.stringify(output.data, null, 2))}\n`);
69
+ }
70
+ for (const warning of output.warnings ?? [])
71
+ process.stderr.write(`Warning: ${terminalText(warning)}\n`);
72
+ }
73
+ }
74
+ function takeOption(args, name) {
75
+ const index = args.indexOf(name);
76
+ if (index < 0)
77
+ return undefined;
78
+ const value = args[index + 1];
79
+ if (!value || value.startsWith('--'))
80
+ throw new CliError(`${name} requires a value.`, EXIT.usage, 'missing_option_value');
81
+ args.splice(index, 2);
82
+ return value;
83
+ }
84
+ function takeRepeatedOption(args, name) {
85
+ const values = [];
86
+ let index = args.indexOf(name);
87
+ while (index >= 0) {
88
+ const value = args[index + 1];
89
+ if (!value || value.startsWith('--'))
90
+ throw new CliError(`${name} requires a value.`, EXIT.usage, 'missing_option_value');
91
+ values.push(value);
92
+ args.splice(index, 2);
93
+ index = args.indexOf(name);
94
+ }
95
+ return values;
96
+ }
97
+ function takeFlag(args, name) {
98
+ const index = args.indexOf(name);
99
+ if (index < 0)
100
+ return false;
101
+ args.splice(index, 1);
102
+ return true;
103
+ }
104
+ function assertNoUnknown(args) {
105
+ if (args.length)
106
+ throw new CliError(`Unexpected argument: ${args[0]}`, EXIT.usage, 'unexpected_argument');
107
+ }
108
+ function parseModule(value) {
109
+ const [name, language, extra] = value.split(':');
110
+ if (extra || !name || !language || !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(name) || !SUPPORTED_MODULES.includes(language)) {
111
+ throw new CliError('Use --module name:language with a safe name and python, go or rust.', EXIT.usage, 'invalid_module');
112
+ }
113
+ return { name, language: language };
114
+ }
115
+ async function runProcess(command, args, cwd) {
116
+ return new Promise((resolve, reject) => {
117
+ const child = spawn(command, args, { cwd, shell: false, stdio: 'inherit', env: { ...process.env, npm_config_ignore_scripts: 'true' } });
118
+ child.once('error', () => reject(new CliError(`The ${command} executable is not available.`, EXIT.filesystem, 'package_manager_unavailable')));
119
+ child.once('exit', (code) => resolve(code ?? 1));
120
+ });
121
+ }
122
+ async function commandAvailable(command, args) {
123
+ return new Promise((resolve) => {
124
+ const child = spawn(command, args, { shell: false, stdio: 'ignore' });
125
+ child.once('error', () => resolve(false));
126
+ child.once('exit', (code) => resolve(code === 0));
127
+ });
128
+ }
129
+ async function commandCreate(args, json) {
130
+ const name = args.shift();
131
+ if (!name || !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(name))
132
+ throw new CliError('Project name must use lowercase letters, numbers and internal hyphens.', EXIT.usage, 'invalid_project_name');
133
+ const framework = (takeOption(args, '--template') ?? 'react');
134
+ const packageManager = (takeOption(args, '--package-manager') ?? 'npm');
135
+ const moduleValues = takeRepeatedOption(args, '--module');
136
+ const database = takeOption(args, '--database');
137
+ const noInstall = takeFlag(args, '--no-install');
138
+ const explicitInstall = takeFlag(args, '--install');
139
+ if (noInstall && explicitInstall)
140
+ throw new CliError('Choose either --install or --no-install.', EXIT.usage, 'conflicting_options');
141
+ const install = !noInstall;
142
+ assertNoUnknown(args);
143
+ if (!SUPPORTED_FRONTENDS.includes(framework))
144
+ throw new CliError(`Unsupported template: ${framework}.`, EXIT.usage, 'invalid_template');
145
+ if (!SUPPORTED_MANAGERS.includes(packageManager))
146
+ throw new CliError(`Unsupported package manager: ${packageManager}.`, EXIT.usage, 'invalid_package_manager');
147
+ if (database && !/^[A-Z][A-Z0-9_]{0,127}$/.test(database))
148
+ throw new CliError('Database bindings use uppercase letters, numbers and underscores.', EXIT.usage, 'invalid_database');
149
+ const modules = moduleValues.map(parseModule);
150
+ if (new Set(modules.map((item) => item.name)).size !== modules.length)
151
+ throw new CliError('Backend module names must be unique.', EXIT.usage, 'duplicate_module');
152
+ const target = resolveLocalPath(name);
153
+ const scaffold = createScaffold({ name, framework, packageManager, modules, ...(database ? { database } : {}) });
154
+ await writeScaffoldAtomically(target, scaffold.files, scaffold.manifest);
155
+ if (install && framework !== 'html') {
156
+ const installArgs = {
157
+ npm: ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline', '--no-progress'],
158
+ pnpm: ['install', '--ignore-scripts', '--prefer-offline'],
159
+ yarn: ['install', '--mode=skip-build'],
160
+ bun: ['install', '--ignore-scripts', '--no-progress'],
161
+ };
162
+ const code = await runProcess(packageManager, installArgs[packageManager], target);
163
+ if (code !== 0)
164
+ throw new CliError(`Dependency installation exited with code ${code}. The project files were kept.`, EXIT.filesystem, 'install_failed');
165
+ }
166
+ emit({ ok: true, command: 'create', message: `Created ${name} at ${target}.`, data: { path: target, framework, modules: modules.length, installed: install && framework !== 'html' } }, json);
167
+ }
168
+ async function commandInit(args, json) {
169
+ const force = takeFlag(args, '--force');
170
+ const path = args.shift();
171
+ assertNoUnknown(args);
172
+ const root = resolveLocalPath(path);
173
+ const manifest = await detectManifest(root);
174
+ await writeManifest(root, manifest, force);
175
+ emit({ ok: true, command: 'init', message: `Created blinkhost.yaml in ${root}.`, data: { path: root, framework: manifest.frontend.framework } }, json);
176
+ }
177
+ async function commandValidate(args, json) {
178
+ const path = args.shift();
179
+ assertNoUnknown(args);
180
+ const root = resolveLocalPath(path);
181
+ const result = await validateProject(root);
182
+ if (result.errors.length)
183
+ throw new CliError('Project validation failed.', EXIT.validation, 'project_invalid', result.errors);
184
+ emit({ ok: true, command: 'validate', message: `Project is compatible with ${result.manifest.schema}.`, data: { framework: result.manifest.frontend.framework, modules: result.manifest.modules.length, databases: result.manifest.resources.databases.length }, warnings: result.warnings }, json);
185
+ }
186
+ async function commandManifest(args, json) {
187
+ const path = args.shift();
188
+ assertNoUnknown(args);
189
+ const manifest = await readProjectManifest(resolveLocalPath(path));
190
+ if (json)
191
+ emit({ ok: true, command: 'manifest', message: 'Manifest parsed.', data: manifest }, true);
192
+ else
193
+ process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
194
+ }
195
+ async function commandDoctor(args, json) {
196
+ const path = args.shift();
197
+ assertNoUnknown(args);
198
+ const checks = [];
199
+ const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number);
200
+ const supportedNode = (nodeMajor ?? 0) > 22 || nodeMajor === 22 && (nodeMinor ?? 0) >= 12;
201
+ checks.push({ name: 'node', ok: supportedNode, detail: process.versions.node });
202
+ let gitOk = false;
203
+ try {
204
+ gitOk = await commandAvailable('git', ['--version']);
205
+ }
206
+ catch { }
207
+ checks.push({ name: 'git', ok: gitOk, detail: gitOk ? 'available' : 'not found' });
208
+ const root = resolveLocalPath(path);
209
+ try {
210
+ const validation = await validateProject(root);
211
+ checks.push({ name: 'project', ok: validation.errors.length === 0, detail: validation.errors.join('; ') || 'manifest and declared paths are valid' });
212
+ }
213
+ catch (error) {
214
+ checks.push({ name: 'project', ok: false, detail: error instanceof Error ? error.message : String(error) });
215
+ }
216
+ try {
217
+ await access(root);
218
+ checks.push({ name: 'directory', ok: true, detail: root });
219
+ }
220
+ catch {
221
+ checks.push({ name: 'directory', ok: false, detail: 'not accessible' });
222
+ }
223
+ const failed = checks.filter((check) => !check.ok);
224
+ if (failed.length)
225
+ throw new CliError('One or more environment checks failed.', EXIT.validation, 'doctor_failed', failed.map((check) => `${check.name}: ${check.detail}`));
226
+ emit({ ok: true, command: 'doctor', message: 'BlinkHost project environment is ready.', data: { checks } }, json);
227
+ }
228
+ export async function main(argv = process.argv.slice(2)) {
229
+ const args = [...argv];
230
+ const json = takeFlag(args, '--json');
231
+ quietOutput = takeFlag(args, '--quiet');
232
+ verboseOutput = takeFlag(args, '--verbose');
233
+ takeFlag(args, '--no-color');
234
+ const nonInteractive = takeFlag(args, '--non-interactive');
235
+ const profile = takeOption(args, '--profile');
236
+ const command = args.shift();
237
+ try {
238
+ if (!command || command === '--help' || command === 'help') {
239
+ process.stdout.write(HELP);
240
+ return EXIT.success;
241
+ }
242
+ if (command === '--version' || command === 'version') {
243
+ process.stdout.write(`${VERSION}\n`);
244
+ return EXIT.success;
245
+ }
246
+ if (takeFlag(args, '--help')) {
247
+ process.stdout.write(HELP);
248
+ return EXIT.success;
249
+ }
250
+ if (profile)
251
+ validateProfileName(profile);
252
+ if (command === 'create')
253
+ await commandCreate(args, json);
254
+ else if (command === 'init')
255
+ await commandInit(args, json);
256
+ else if (command === 'validate')
257
+ await commandValidate(args, json);
258
+ else if (command === 'manifest')
259
+ await commandManifest(args, json);
260
+ else if (command === 'doctor')
261
+ await commandDoctor(args, json);
262
+ else if (command === 'test') {
263
+ const data = await testProject(args);
264
+ emit({ ok: true, command, message: 'Project checks passed.', data }, json);
265
+ }
266
+ else if (command === 'auth') {
267
+ const action = args.shift() || 'status';
268
+ if (action === 'login') {
269
+ const apiOrigin = takeOption(args, '--api-origin');
270
+ const noBrowser = takeFlag(args, '--no-browser');
271
+ assertNoUnknown(args);
272
+ if (nonInteractive)
273
+ throw new CliError('Interactive account authorization is disabled. Use an approved workload identity in automation.', EXIT.auth, 'interaction_required');
274
+ const data = await login({ ...(profile ? { profile } : {}), ...(apiOrigin ? { apiOrigin } : {}), openBrowser: !noBrowser, ...(!json && !quietOutput ? { progress: (line) => { process.stderr.write(`${terminalText(line)}\n`); } } : {}) });
275
+ emit({ ok: true, command: 'auth login', message: 'This device is connected to BlinkHost.', data }, json);
276
+ }
277
+ else if (action === 'logout') {
278
+ assertNoUnknown(args);
279
+ const data = await logout(profile);
280
+ emit({ ok: true, command: 'auth logout', message: `Signed out CLI profile ${data.profile}.`, data }, json);
281
+ }
282
+ else if (action === 'status') {
283
+ assertNoUnknown(args);
284
+ const client = await ApiClient.create(profile);
285
+ const data = await client.request('/api/cli/v2/capabilities/');
286
+ emit({ ok: true, command: 'auth status', message: 'The CLI session is active.', data }, json);
287
+ }
288
+ else if (action === 'sessions') {
289
+ assertNoUnknown(args);
290
+ const client = await ApiClient.create(profile);
291
+ const data = await client.request('/api/cli/v2/sessions/');
292
+ emit({ ok: true, command: 'auth sessions', message: 'CLI sessions loaded.', data }, json);
293
+ }
294
+ else if (action === 'revoke') {
295
+ const id = args.shift();
296
+ assertNoUnknown(args);
297
+ if (!id || !/^[0-9a-f-]{36}$/i.test(id))
298
+ throw new CliError('Provide a valid CLI session ID.', EXIT.usage, 'invalid_session_id');
299
+ const client = await ApiClient.create(profile);
300
+ await client.request(`/api/cli/v2/sessions/${id}/`, { method: 'DELETE' });
301
+ emit({ ok: true, command: 'auth revoke', message: 'CLI session revoked.', data: { id } }, json);
302
+ }
303
+ else
304
+ throw new CliError(`Unknown auth action: ${action}.`, EXIT.usage, 'unknown_action');
305
+ }
306
+ else if (command === 'profile') {
307
+ const action = args.shift() || 'list';
308
+ const config = await readConfig();
309
+ if (action === 'list') {
310
+ assertNoUnknown(args);
311
+ emit({ ok: true, command: 'profile list', message: `Active profile: ${config.activeProfile}.`, data: { active: config.activeProfile, profiles: config.profiles } }, json);
312
+ }
313
+ else if (action === 'use') {
314
+ const name = validateProfileName(args.shift() || '');
315
+ assertNoUnknown(args);
316
+ if (!config.profiles[name])
317
+ throw new CliError('Profile not found. Sign in with that profile first.', EXIT.usage, 'profile_not_found');
318
+ config.activeProfile = name;
319
+ await writeConfig(config);
320
+ emit({ ok: true, command: 'profile use', message: `Using profile ${name}.`, data: { active: name } }, json);
321
+ }
322
+ else
323
+ throw new CliError(`Unknown profile action: ${action}.`, EXIT.usage, 'unknown_action');
324
+ }
325
+ else if (command === 'projects' && args[0] === 'link') {
326
+ args.shift();
327
+ const projectId = args.shift();
328
+ const root = args.shift();
329
+ assertNoUnknown(args);
330
+ if (!projectId)
331
+ throw new CliError('Provide a project ID.', EXIT.usage, 'project_required');
332
+ const selected = await activeProfile(profile);
333
+ const data = await writeProjectLink(projectId, selected.name, root);
334
+ emit({ ok: true, command: 'projects link', message: 'This directory is linked to the BlinkHost project.', data }, json);
335
+ }
336
+ else if (command === 'projects' && args[0] === 'current') {
337
+ args.shift();
338
+ const root = args.shift();
339
+ assertNoUnknown(args);
340
+ const data = await readProjectLink(root);
341
+ emit({ ok: true, command: 'projects current', message: `Linked project: ${data.project_id}.`, data }, json);
342
+ }
343
+ else if (command === 'projects' && args[0] === 'unlink') {
344
+ args.shift();
345
+ const root = args.shift();
346
+ assertNoUnknown(args);
347
+ const data = await unlinkProject(root);
348
+ emit({ ok: true, command: 'projects unlink', message: 'Removed the local BlinkHost project link. The remote project was not changed.', data }, json);
349
+ }
350
+ else if (command === 'projects' && args[0] === 'status') {
351
+ args.shift();
352
+ assertNoUnknown(args);
353
+ const data = await projectStatus(profile);
354
+ emit({ ok: true, command: 'projects status', message: 'Project and source connection status loaded.', data }, json);
355
+ }
356
+ else if (command === 'projects' && (args[0] === 'pull' || args[0] === 'push')) {
357
+ const action = args.shift();
358
+ const data = await syncProject(action, args, profile);
359
+ emit({ ok: true, command: `projects ${action}`, message: `${action === 'pull' ? 'Pulled repository changes into BlinkHost.' : 'Pushed BlinkHost changes to the connected repository.'}`, data }, json);
360
+ }
361
+ else if (command === 'previews' && args[0] === 'open') {
362
+ args.shift();
363
+ const data = await openPreview(args, profile);
364
+ emit({ ok: true, command: 'previews open', message: 'Opened the preview in your browser.', data }, json);
365
+ }
366
+ else if ((command === 'builds' || command === 'deployments' || command === 'previews') && args[0] === 'wait') {
367
+ args.shift();
368
+ const data = await waitForRemote(command, args, profile);
369
+ emit({ ok: true, command: `${command} wait`, message: `${command.slice(0, -1)} is ready.`, data }, json);
370
+ }
371
+ else if (command === 'assets' && args[0] === 'upload') {
372
+ args.shift();
373
+ const data = await uploadAsset(args, profile);
374
+ emit({ ok: true, command: 'assets upload', message: 'Asset uploaded and verified.', data }, json);
375
+ }
376
+ else if (['projects', 'repositories', 'connections', 'previews', 'builds', 'deployments', 'modules', 'databases', 'bindings', 'assets', 'organizations', 'templates', 'approvals', 'handoffs', 'policies', 'workloads'].includes(command)) {
377
+ const data = await runRemote(command, args, profile);
378
+ emit({ ok: true, command, message: `${command[0]?.toUpperCase()}${command.slice(1)} request completed.`, data }, json);
379
+ }
380
+ else if (command === 'secrets') {
381
+ const data = await runSecrets(args, profile);
382
+ emit({ ok: true, command, message: 'Secret operation completed.', data }, json);
383
+ }
384
+ else if (command === 'dev') {
385
+ const data = await runDev(args);
386
+ emit({ ok: true, command, message: 'Local development process finished.', data }, json);
387
+ }
388
+ else if (command === 'logs' || command === 'metrics' || command === 'analytics') {
389
+ const data = await observability(command, args, profile);
390
+ emit({ ok: true, command, message: `${command} loaded.`, data }, json);
391
+ }
392
+ else if (command === 'support' && args.shift() === 'bundle') {
393
+ const data = await supportBundle(args, profile);
394
+ emit({ ok: true, command: 'support bundle', message: `Created redacted support bundle at ${data.output}.`, data }, json);
395
+ }
396
+ else if (command === 'completion') {
397
+ const output = completion(args.shift());
398
+ assertNoUnknown(args);
399
+ if (json)
400
+ emit({ ok: true, command, message: 'Shell completion generated.', data: { script: output } }, true);
401
+ else
402
+ process.stdout.write(output);
403
+ }
404
+ else if (command === 'update' && args.shift() === 'check') {
405
+ assertNoUnknown(args);
406
+ const data = await checkForUpdate();
407
+ emit({ ok: true, command: 'update check', message: 'Release check completed.', data }, json);
408
+ }
409
+ else if (command === 'ci' && args.shift() === 'check') {
410
+ assertNoUnknown(args);
411
+ const data = await ciCheck(profile);
412
+ emit({ ok: true, command: 'ci check', message: 'CI identity and API capabilities are ready.', data }, json);
413
+ }
414
+ else if (command === 'plugins') {
415
+ const data = await runPlugins(args);
416
+ emit({ ok: true, command, message: 'Plugin operation completed.', data }, json);
417
+ }
418
+ else if (command === 'api') {
419
+ const data = await rawApi(args, profile);
420
+ emit({ ok: true, command, message: 'API request completed.', data }, json);
421
+ }
422
+ else
423
+ throw new CliError(`Unknown command: ${command}.`, EXIT.usage, 'unknown_command');
424
+ return EXIT.success;
425
+ }
426
+ catch (error) {
427
+ const failure = error instanceof CliError ? error : new CliError(error instanceof Error ? error.message : String(error), EXIT.internal, 'internal_error');
428
+ if (json)
429
+ process.stderr.write(`${JSON.stringify({ ok: false, command: command ?? '', error: { code: failure.code, message: failure.message, details: failure.details } })}\n`);
430
+ else {
431
+ process.stderr.write(`Error: ${terminalText(failure.message)}\n`);
432
+ for (const detail of failure.details)
433
+ process.stderr.write(` - ${terminalText(detail)}\n`);
434
+ if (verboseOutput)
435
+ process.stderr.write(`Code: ${failure.code}; exit: ${failure.exitCode}\n`);
436
+ }
437
+ return failure.exitCode;
438
+ }
439
+ }
440
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
441
+ process.exitCode = await main();
442
+ }
443
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,28 @@
1
+ export declare const DEFAULT_API_ORIGIN = "https://api.blinkhost.me";
2
+ export interface Profile {
3
+ apiOrigin: string;
4
+ username?: string;
5
+ userId?: number;
6
+ }
7
+ export interface PluginRecord {
8
+ executable: string;
9
+ sha256: string;
10
+ addedAt: string;
11
+ }
12
+ export interface CliConfig {
13
+ activeProfile: string;
14
+ profiles: Record<string, Profile>;
15
+ plugins?: Record<string, PluginRecord>;
16
+ updateCheckedAt?: string;
17
+ latestVersion?: string;
18
+ }
19
+ export declare function configPath(): string;
20
+ export declare function validateProfileName(value: string): string;
21
+ export declare function validateApiOrigin(value: string): string;
22
+ export declare function readConfig(): Promise<CliConfig>;
23
+ export declare function writeConfig(config: CliConfig): Promise<void>;
24
+ export declare function activeProfile(explicit?: string): Promise<{
25
+ name: string;
26
+ profile: Profile;
27
+ config: CliConfig;
28
+ }>;
package/dist/config.js ADDED
@@ -0,0 +1,67 @@
1
+ import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { CliError, EXIT } from './errors.js';
5
+ export const DEFAULT_API_ORIGIN = 'https://api.blinkhost.me';
6
+ function configRoot() {
7
+ const override = process.env.BLINKHOST_CONFIG_HOME;
8
+ if (override)
9
+ return override;
10
+ if (process.platform === 'win32' && process.env.APPDATA)
11
+ return join(process.env.APPDATA, 'BlinkHost');
12
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'blinkhost');
13
+ }
14
+ export function configPath() { return join(configRoot(), 'config.json'); }
15
+ export function validateProfileName(value) {
16
+ if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(value))
17
+ throw new CliError('Profile names use lowercase letters, numbers, hyphens, and underscores.', EXIT.usage, 'invalid_profile');
18
+ return value;
19
+ }
20
+ export function validateApiOrigin(value) {
21
+ let parsed;
22
+ try {
23
+ parsed = new URL(value);
24
+ }
25
+ catch {
26
+ throw new CliError('The API origin is not a valid URL.', EXIT.usage, 'invalid_api_origin');
27
+ }
28
+ const local = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
29
+ if ((parsed.protocol !== 'https:' && !(local && parsed.protocol === 'http:')) || parsed.username || parsed.password || parsed.search || parsed.hash || !['', '/'].includes(parsed.pathname)) {
30
+ throw new CliError('Use an HTTPS API origin without credentials, paths, queries, or fragments.', EXIT.usage, 'invalid_api_origin');
31
+ }
32
+ return parsed.origin;
33
+ }
34
+ export async function readConfig() {
35
+ try {
36
+ const parsed = JSON.parse(await readFile(configPath(), 'utf8'));
37
+ const profiles = {};
38
+ for (const [name, profile] of Object.entries(parsed.profiles || {})) {
39
+ validateProfileName(name);
40
+ if (!profile || typeof profile !== 'object')
41
+ continue;
42
+ profiles[name] = { ...profile, apiOrigin: validateApiOrigin(profile.apiOrigin) };
43
+ }
44
+ return { activeProfile: validateProfileName(parsed.activeProfile || 'default'), profiles, plugins: parsed.plugins || {} };
45
+ }
46
+ catch (error) {
47
+ if (error.code === 'ENOENT')
48
+ return { activeProfile: 'default', profiles: {} };
49
+ if (error instanceof CliError)
50
+ throw error;
51
+ throw new CliError('BlinkHost CLI configuration could not be read.', EXIT.filesystem, 'config_invalid');
52
+ }
53
+ }
54
+ export async function writeConfig(config) {
55
+ const path = configPath();
56
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
57
+ const temporary = `${path}.${process.pid}.tmp`;
58
+ await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
59
+ await chmod(temporary, 0o600);
60
+ await rename(temporary, path);
61
+ }
62
+ export async function activeProfile(explicit) {
63
+ const config = await readConfig();
64
+ const name = validateProfileName(explicit || process.env.BLINKHOST_PROFILE || config.activeProfile || 'default');
65
+ return { name, profile: config.profiles[name] || { apiOrigin: DEFAULT_API_ORIGIN }, config };
66
+ }
67
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,3 @@
1
+ export declare function getRefreshCredential(profile: string): Promise<string | null>;
2
+ export declare function setRefreshCredential(profile: string, token: string): Promise<void>;
3
+ export declare function deleteRefreshCredential(profile: string): Promise<void>;
@@ -0,0 +1,66 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { CliError, EXIT } from './errors.js';
3
+ const SERVICE = 'blinkhost-cli';
4
+ async function run(command, args, input, acceptMissing = false) {
5
+ return new Promise((resolve, reject) => {
6
+ const child = spawn(command, args, { shell: false, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
7
+ let stdout = '';
8
+ let stderr = '';
9
+ child.stdout.setEncoding('utf8').on('data', (chunk) => { stdout += chunk; });
10
+ child.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk; });
11
+ child.on('error', () => reject(new CliError('No supported operating-system credential service is available.', EXIT.auth, 'credential_store_unavailable')));
12
+ child.on('close', (code) => {
13
+ if (code === 0)
14
+ resolve(stdout.trim());
15
+ else if (acceptMissing)
16
+ resolve('');
17
+ else
18
+ reject(new CliError(`The operating-system credential service rejected the request${stderr.trim() ? `: ${stderr.trim()}` : '.'}`, EXIT.auth, 'credential_store_failed'));
19
+ });
20
+ if (input !== undefined)
21
+ child.stdin.end(input);
22
+ else
23
+ child.stdin.end();
24
+ });
25
+ }
26
+ function windowsScript(action, profile) {
27
+ const safeProfile = profile.replace(/'/g, "''");
28
+ const prefix = `$r='${SERVICE}';$u='${safeProfile}';$v=[Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime];`;
29
+ if (action === 'get')
30
+ return `${prefix}try{$c=(New-Object Windows.Security.Credentials.PasswordVault).Retrieve($r,$u);$c.RetrievePassword();[Console]::Out.Write($c.Password)}catch{exit 2}`;
31
+ if (action === 'delete')
32
+ return `${prefix}try{$p=New-Object Windows.Security.Credentials.PasswordVault;$c=$p.Retrieve($r,$u);$p.Remove($c)}catch{};`;
33
+ return `${prefix}$s=[Console]::In.ReadToEnd();$p=New-Object Windows.Security.Credentials.PasswordVault;try{$c=$p.Retrieve($r,$u);$p.Remove($c)}catch{};$p.Add((New-Object Windows.Security.Credentials.PasswordCredential($r,$u,$s)));`;
34
+ }
35
+ export async function getRefreshCredential(profile) {
36
+ if (process.platform === 'darwin')
37
+ return (await run('security', ['find-generic-password', '-s', SERVICE, '-a', profile, '-w'], undefined, true)) || null;
38
+ if (process.platform === 'win32')
39
+ return (await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScript('get', profile)], undefined, true)) || null;
40
+ return (await run('secret-tool', ['lookup', 'service', SERVICE, 'profile', profile], undefined, true)) || null;
41
+ }
42
+ export async function setRefreshCredential(profile, token) {
43
+ if (!token.startsWith('bhr_'))
44
+ throw new CliError('The server returned an invalid refresh credential.', EXIT.auth, 'invalid_refresh_credential');
45
+ if (process.platform === 'darwin') {
46
+ await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', profile, '-w', token]);
47
+ return;
48
+ }
49
+ if (process.platform === 'win32') {
50
+ await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScript('set', profile)], token);
51
+ return;
52
+ }
53
+ await run('secret-tool', ['store', '--label=BlinkHost CLI', 'service', SERVICE, 'profile', profile], token);
54
+ }
55
+ export async function deleteRefreshCredential(profile) {
56
+ if (process.platform === 'darwin') {
57
+ await run('security', ['delete-generic-password', '-s', SERVICE, '-a', profile], undefined, true);
58
+ return;
59
+ }
60
+ if (process.platform === 'win32') {
61
+ await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScript('delete', profile)], undefined, true);
62
+ return;
63
+ }
64
+ await run('secret-tool', ['clear', 'service', SERVICE, 'profile', profile], undefined, true);
65
+ }
66
+ //# sourceMappingURL=credentials.js.map
@@ -0,0 +1,2 @@
1
+ import type { BlinkHostManifest } from './manifest.js';
2
+ export declare function detectManifest(root: string): Promise<BlinkHostManifest>;