@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BLINKHOST LTD
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # BlinkHost CLI
2
+
3
+ The BlinkHost CLI brings project setup, local development, source control, previews, builds, releases, resources, and diagnostics into the terminal while BlinkHost continues to enforce workspace roles, plan limits, protected environments, verified artifacts, and audit history.
4
+
5
+ ## Install
6
+
7
+ Node.js 22.12 or newer is required.
8
+
9
+ Install the signed release archive directly from BlinkHost's public repository:
10
+
11
+ ```bash
12
+ npm install --global https://github.com/blinkhost-ltd/blinkhost-cli/releases/download/v2.0.0/blinkhost-cli-2.0.0.tgz
13
+ blinkhost --version
14
+ ```
15
+
16
+ Checksums, the CycloneDX SBOM, and Sigstore verification bundle are published with the archive at <https://github.com/blinkhost-ltd/blinkhost-cli/releases/tag/v2.0.0>.
17
+
18
+ ## Connect an account
19
+
20
+ ```bash
21
+ blinkhost auth login
22
+ blinkhost auth status
23
+ blinkhost auth sessions
24
+ ```
25
+
26
+ The CLI opens a BlinkHost approval page in your browser. It never accepts your BlinkHost password. Access tokens last 15 minutes; rotating refresh credentials are stored only in the operating-system credential service. Use named profiles for separate accounts:
27
+
28
+ ```bash
29
+ blinkhost auth login --profile work
30
+ blinkhost profile use work
31
+ ```
32
+
33
+ Revoke a device with `blinkhost auth revoke SESSION_ID` or disconnect all CLI sessions for the active profile with `blinkhost auth logout`.
34
+
35
+ ## Create or adopt a project
36
+
37
+ ```bash
38
+ blinkhost create study-circle --template astro --module search:rust --module reminders:python --database PRIMARY_DB
39
+ cd study-circle
40
+ blinkhost validate
41
+ blinkhost projects link PROJECT_ID
42
+ blinkhost dev
43
+ ```
44
+
45
+ Supported frontends are Astro, HTML, React, Solid, Svelte, and Vue. Backend modules may use Go, Python, or Rust. `blinkhost init` detects supported metadata in an existing repository and creates `blinkhost.yaml` for review.
46
+
47
+ Creation is atomic and refuses to replace an existing path. Dependency lifecycle scripts are disabled. Validation rejects unknown manifest fields, duplicate YAML keys, aliases, traversal, unsafe symbolic links, invalid cross-platform paths, duplicate module names, and missing declared inputs.
48
+
49
+ ## Remote workflows
50
+
51
+ Resource commands use the same pattern:
52
+
53
+ ```bash
54
+ blinkhost projects list
55
+ blinkhost projects get PROJECT_ID
56
+ blinkhost previews list --query project=PROJECT_ID
57
+ blinkhost builds list --query project=PROJECT_ID
58
+ blinkhost deployments get DEPLOYMENT_ID
59
+ blinkhost modules list --query site_id=PROJECT_ID
60
+ blinkhost databases list --query project=PROJECT_ID
61
+ blinkhost organizations get ORGANIZATION_ID
62
+ ```
63
+
64
+ Create and update operations accept a JSON object or `@path` to a JSON file:
65
+
66
+ ```bash
67
+ blinkhost projects create --data @project.json
68
+ blinkhost deployments action DEPLOYMENT_ID rollback --data @rollback.json
69
+ ```
70
+
71
+ Destructive operations require the exact resource ID twice:
72
+
73
+ ```bash
74
+ blinkhost previews delete PREVIEW_ID --confirm PREVIEW_ID
75
+ ```
76
+
77
+ Connected repository, release, approval, agency handoff, enterprise policy, and template APIs are available through `repositories`, `connections`, `builds`, `approvals`, `handoffs`, `policies`, and `templates`. Advanced customer API operations can use `blinkhost api METHOD /api/path/`; internal, staff, and authentication routes are blocked.
78
+
79
+ ## Secrets and resources
80
+
81
+ Secret values never appear in command arguments or `blinkhost.yaml`. Pipe them over standard input:
82
+
83
+ ```bash
84
+ printf '%s' "$MY_SECRET" | blinkhost secrets set API_TOKEN --project PROJECT_ID --environment production
85
+ printf '%s' "$NEW_VALUE" | blinkhost secrets rotate SECRET_ID
86
+ ```
87
+
88
+ Database, binding, module, and asset operations remain subject to workspace permissions and plan storage limits. Upload reservations and final integrity checks use the project asset API.
89
+
90
+ ## Observability and support
91
+
92
+ ```bash
93
+ blinkhost logs --project PROJECT_ID --since 1h --limit 100
94
+ blinkhost metrics --project PROJECT_ID --since 24h
95
+ blinkhost analytics --project PROJECT_ID
96
+ blinkhost doctor
97
+ blinkhost support bundle
98
+ ```
99
+
100
+ Support bundles are local, redacted JSON files with mode `0600`. They exclude tokens, secret values, source code, filenames, and repository URLs.
101
+
102
+ ## CI and automation
103
+
104
+ All commands support `--json`. Register one exact GitHub branch, tag, or protected environment as a workload identity, then grant the workflow `id-token: write`. The CLI exchanges GitHub's signed OIDC assertion for a ten-minute BlinkHost token automatically; no BlinkHost secret is stored in GitHub.
105
+
106
+ ```bash
107
+ blinkhost workloads create --data @workload.json
108
+ ```
109
+
110
+ `workload.json` identifies the workspace, exact GitHub OIDC subject, and narrow permissions. For non-GitHub systems, an externally issued short-lived credential may be provided as `BLINKHOST_ACCESS_TOKEN`; do not persist it in the repository or pass it as an argument.
111
+
112
+ ```bash
113
+ blinkhost ci check --json
114
+ blinkhost validate --json
115
+ blinkhost builds create --data @build.json --json
116
+ ```
117
+
118
+ Stable exit codes distinguish usage, validation, filesystem, authentication, network, remote-service, and conflict failures. Mutations include request identifiers and idempotency keys.
119
+
120
+ ## Shell completion, updates, and plugins
121
+
122
+ ```bash
123
+ blinkhost completion bash # also zsh, fish, or powershell
124
+ blinkhost update check
125
+ blinkhost plugins add /absolute/path/to/plugin --name example
126
+ blinkhost plugins verify example
127
+ blinkhost plugins run example -- arguments
128
+ ```
129
+
130
+ Update checks never install software. Plugins require explicit local approval, are pinned to their executable SHA-256 digest, stop when the executable changes, and receive neither BlinkHost credentials nor the parent environment. A plugin is still third-party code running with your operating-system account; review it before adding it.
131
+
132
+ ## Exit codes
133
+
134
+ | Code | Meaning |
135
+ | --- | --- |
136
+ | 0 | Success |
137
+ | 2 | Invalid command or option |
138
+ | 3 | Project or input validation failed |
139
+ | 4 | Filesystem or local executable failure |
140
+ | 5 | Unexpected internal failure |
141
+ | 6 | Authentication or authorization failure |
142
+ | 7 | Network or timeout failure |
143
+ | 8 | Remote service failure |
144
+ | 9 | State conflict or approval required |
145
+
146
+ Run `blinkhost --help` for the complete command index.
package/dist/api.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { type Profile } from './config.js';
2
+ export declare function publicRequest(apiOrigin: string, path: string, init?: RequestInit): Promise<{
3
+ response: Response;
4
+ data: unknown;
5
+ }>;
6
+ export declare class ApiClient {
7
+ readonly profileName: string;
8
+ readonly profile: Profile;
9
+ private readonly ephemeral;
10
+ private accessToken;
11
+ private constructor();
12
+ static create(profileName?: string): Promise<ApiClient>;
13
+ static fromAccessToken(profileName: string, profile: Profile, accessToken: string): ApiClient;
14
+ request(path: string, init?: RequestInit): Promise<unknown>;
15
+ rememberIdentity(identity: {
16
+ id?: number;
17
+ username?: string;
18
+ }): Promise<void>;
19
+ }
20
+ export declare function encodeQuery(values: Record<string, string | undefined>): string;
package/dist/api.js ADDED
@@ -0,0 +1,135 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { activeProfile, writeConfig } from './config.js';
3
+ import { deleteRefreshCredential, getRefreshCredential, setRefreshCredential } from './credentials.js';
4
+ import { CliError, EXIT } from './errors.js';
5
+ const REQUEST_TIMEOUT_MS = 30_000;
6
+ function messageFrom(payload, fallback) {
7
+ if (payload && typeof payload === 'object') {
8
+ const candidate = payload;
9
+ for (const key of ['detail', 'error', 'message'])
10
+ if (typeof candidate[key] === 'string')
11
+ return candidate[key];
12
+ const first = Object.values(candidate)[0];
13
+ if (Array.isArray(first) && typeof first[0] === 'string')
14
+ return first[0];
15
+ }
16
+ return fallback;
17
+ }
18
+ async function parseResponse(response) {
19
+ if (response.status === 204)
20
+ return null;
21
+ const type = response.headers.get('content-type') || '';
22
+ if (type.includes('application/json'))
23
+ return response.json();
24
+ const text = await response.text();
25
+ return text.length <= 1024 * 1024 ? text : `${text.slice(0, 1024 * 1024)}\n[truncated]`;
26
+ }
27
+ export async function publicRequest(apiOrigin, path, init = {}) {
28
+ const controller = new AbortController();
29
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
30
+ try {
31
+ const response = await fetch(new URL(path, `${apiOrigin}/`), {
32
+ ...init,
33
+ redirect: 'error',
34
+ signal: controller.signal,
35
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': 'BlinkHost-CLI/2.0.0', ...Object.fromEntries(new Headers(init.headers).entries()) },
36
+ });
37
+ return { response, data: await parseResponse(response) };
38
+ }
39
+ catch (error) {
40
+ if (error instanceof CliError)
41
+ throw error;
42
+ const timeoutMessage = error instanceof DOMException && error.name === 'AbortError';
43
+ throw new CliError(timeoutMessage ? 'The BlinkHost API request timed out.' : 'The BlinkHost API could not be reached.', EXIT.network, timeoutMessage ? 'request_timeout' : 'network_error');
44
+ }
45
+ finally {
46
+ clearTimeout(timeout);
47
+ }
48
+ }
49
+ export class ApiClient {
50
+ profileName;
51
+ profile;
52
+ ephemeral;
53
+ accessToken;
54
+ constructor(profileName, profile, accessToken, ephemeral) {
55
+ this.profileName = profileName;
56
+ this.profile = profile;
57
+ this.ephemeral = ephemeral;
58
+ this.accessToken = accessToken;
59
+ }
60
+ static async create(profileName) {
61
+ const selected = await activeProfile(profileName);
62
+ const workload = process.env.BLINKHOST_ACCESS_TOKEN;
63
+ if (workload)
64
+ return new ApiClient(selected.name, selected.profile, workload, true);
65
+ if (process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) {
66
+ const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
67
+ const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
68
+ let oidcUrl;
69
+ try {
70
+ oidcUrl = new URL(requestUrl);
71
+ }
72
+ catch {
73
+ throw new CliError('GitHub Actions supplied an invalid OIDC endpoint.', EXIT.auth, 'ci_oidc_invalid');
74
+ }
75
+ if (oidcUrl.protocol !== 'https:' || !(oidcUrl.hostname === 'actions.githubusercontent.com' || oidcUrl.hostname.endsWith('.actions.githubusercontent.com')))
76
+ throw new CliError('GitHub Actions supplied an untrusted OIDC endpoint.', EXIT.auth, 'ci_oidc_invalid');
77
+ oidcUrl.searchParams.set('audience', 'https://api.blinkhost.me/api/cli/v2/workload/exchange/');
78
+ const oidcResponse = await fetch(oidcUrl, { headers: { Authorization: `Bearer ${requestToken}`, Accept: 'application/json' }, redirect: 'error' });
79
+ const oidc = await oidcResponse.json();
80
+ if (!oidcResponse.ok || !oidc.value)
81
+ throw new CliError('GitHub Actions could not issue a workload identity token.', EXIT.auth, 'ci_oidc_failed');
82
+ const exchanged = await publicRequest(selected.profile.apiOrigin, '/api/cli/v2/workload/exchange/', { method: 'POST', body: JSON.stringify({ assertion: oidc.value }) });
83
+ const payload = exchanged.data;
84
+ if (!exchanged.response.ok || !payload.access_token)
85
+ throw new CliError(payload.detail || 'BlinkHost rejected this workload identity.', EXIT.auth, 'ci_exchange_failed');
86
+ return new ApiClient(selected.name, selected.profile, payload.access_token, true);
87
+ }
88
+ const refresh = process.env.BLINKHOST_REFRESH_TOKEN || await getRefreshCredential(selected.name);
89
+ if (!refresh)
90
+ throw new CliError('Sign in with `blinkhost auth login` or provide BLINKHOST_ACCESS_TOKEN for CI.', EXIT.auth, 'not_authenticated');
91
+ const result = await publicRequest(selected.profile.apiOrigin, '/api/cli/v2/token/refresh/', { method: 'POST', body: JSON.stringify({ refresh_token: refresh }) });
92
+ if (!result.response.ok) {
93
+ if (!process.env.BLINKHOST_REFRESH_TOKEN)
94
+ await deleteRefreshCredential(selected.name);
95
+ throw new CliError(messageFrom(result.data, 'Your CLI session has expired. Sign in again.'), EXIT.auth, 'session_expired');
96
+ }
97
+ const tokens = result.data;
98
+ if (!process.env.BLINKHOST_REFRESH_TOKEN)
99
+ await setRefreshCredential(selected.name, tokens.refresh_token);
100
+ return new ApiClient(selected.name, selected.profile, tokens.access_token, Boolean(process.env.BLINKHOST_REFRESH_TOKEN));
101
+ }
102
+ static fromAccessToken(profileName, profile, accessToken) {
103
+ return new ApiClient(profileName, profile, accessToken, true);
104
+ }
105
+ async request(path, init = {}) {
106
+ const headers = new Headers(init.headers);
107
+ headers.set('Authorization', `Bearer ${this.accessToken}`);
108
+ headers.set('X-Request-ID', headers.get('X-Request-ID') || randomUUID());
109
+ if (init.method && init.method !== 'GET' && !headers.has('Idempotency-Key'))
110
+ headers.set('Idempotency-Key', randomUUID());
111
+ const result = await publicRequest(this.profile.apiOrigin, path, { ...init, headers });
112
+ if (!result.response.ok) {
113
+ const exit = result.response.status === 401 || result.response.status === 403 ? EXIT.auth : result.response.status === 409 ? EXIT.conflict : EXIT.remote;
114
+ throw new CliError(messageFrom(result.data, `BlinkHost returned HTTP ${result.response.status}.`), exit, `api_${result.response.status}`);
115
+ }
116
+ return result.data;
117
+ }
118
+ async rememberIdentity(identity) {
119
+ if (this.ephemeral)
120
+ return;
121
+ const selected = await activeProfile(this.profileName);
122
+ selected.config.profiles[this.profileName] = { ...this.profile, ...(identity.id ? { userId: identity.id } : {}), ...(identity.username ? { username: identity.username } : {}) };
123
+ selected.config.activeProfile = this.profileName;
124
+ await writeConfig(selected.config);
125
+ }
126
+ }
127
+ export function encodeQuery(values) {
128
+ const query = new URLSearchParams();
129
+ for (const [key, value] of Object.entries(values))
130
+ if (value !== undefined)
131
+ query.set(key, value);
132
+ const text = query.toString();
133
+ return text ? `?${text}` : '';
134
+ }
135
+ //# sourceMappingURL=api.js.map
package/dist/auth.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export declare function login(options: {
2
+ profile?: string;
3
+ apiOrigin?: string;
4
+ openBrowser?: boolean;
5
+ progress?: (line: string) => void;
6
+ }): Promise<unknown>;
7
+ export declare function logout(profile?: string): Promise<{
8
+ profile: string;
9
+ }>;
package/dist/auth.js ADDED
@@ -0,0 +1,71 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { hostname } from 'node:os';
3
+ import { spawn } from 'node:child_process';
4
+ import { activeProfile, validateApiOrigin, validateProfileName, writeConfig } from './config.js';
5
+ import { deleteRefreshCredential, setRefreshCredential } from './credentials.js';
6
+ import { ApiClient, publicRequest } from './api.js';
7
+ import { CliError, EXIT } from './errors.js';
8
+ const VERSION = '2.0.0';
9
+ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
10
+ function openBrowser(url) {
11
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd.exe' : 'xdg-open';
12
+ const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'start', '', url] : [url];
13
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', shell: false });
14
+ child.on('error', () => { });
15
+ child.unref();
16
+ }
17
+ function errorCode(data) {
18
+ return data && typeof data === 'object' && typeof data.error === 'string' ? data.error : '';
19
+ }
20
+ export async function login(options) {
21
+ const selected = await activeProfile(options.profile);
22
+ const profileName = validateProfileName(options.profile || selected.name);
23
+ const apiOrigin = validateApiOrigin(options.apiOrigin || selected.profile.apiOrigin);
24
+ const verifier = randomBytes(48).toString('base64url');
25
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
26
+ const started = await publicRequest(apiOrigin, '/api/cli/v2/device/start/', {
27
+ method: 'POST',
28
+ body: JSON.stringify({ code_challenge: challenge, client_name: 'BlinkHost CLI', client_version: VERSION, device_name: hostname().slice(0, 120) }),
29
+ });
30
+ if (!started.response.ok)
31
+ throw new CliError('BlinkHost could not start CLI authorization.', EXIT.auth, errorCode(started.data) || 'authorization_start_failed');
32
+ const device = started.data;
33
+ options.progress?.(`Open ${device.verification_uri_complete}`);
34
+ options.progress?.(`Confirm code ${device.user_code}`);
35
+ if (options.openBrowser !== false)
36
+ openBrowser(device.verification_uri_complete);
37
+ const deadline = Date.now() + device.expires_in * 1000;
38
+ while (Date.now() < deadline) {
39
+ await sleep(Math.max(device.interval, 5) * 1000);
40
+ const polled = await publicRequest(apiOrigin, '/api/cli/v2/device/poll/', { method: 'POST', body: JSON.stringify({ device_code: device.device_code, code_verifier: verifier }) });
41
+ const code = errorCode(polled.data);
42
+ if (code === 'authorization_pending' || code === 'slow_down')
43
+ continue;
44
+ if (!polled.response.ok)
45
+ throw new CliError(code === 'access_denied' ? 'CLI authorization was denied.' : 'CLI authorization could not be completed.', EXIT.auth, code || 'authorization_failed');
46
+ const tokens = polled.data;
47
+ await setRefreshCredential(profileName, tokens.refresh_token);
48
+ selected.config.profiles[profileName] = { apiOrigin };
49
+ selected.config.activeProfile = profileName;
50
+ await writeConfig(selected.config);
51
+ const client = await ApiClient.create(profileName);
52
+ const capabilities = await client.request('/api/cli/v2/capabilities/');
53
+ await client.rememberIdentity(capabilities.actor || {});
54
+ return { profile: profileName, api_origin: apiOrigin, account: capabilities.actor, permissions: capabilities.features };
55
+ }
56
+ throw new CliError('CLI authorization expired before it was approved.', EXIT.auth, 'authorization_expired');
57
+ }
58
+ export async function logout(profile) {
59
+ const selected = await activeProfile(profile);
60
+ try {
61
+ const client = await ApiClient.create(selected.name);
62
+ await client.request('/api/cli/v2/sessions/current/', { method: 'DELETE' });
63
+ }
64
+ catch (error) {
65
+ if (!(error instanceof CliError) || error.code !== 'session_expired')
66
+ throw error;
67
+ }
68
+ await deleteRefreshCredential(selected.name);
69
+ return { profile: selected.name };
70
+ }
71
+ //# sourceMappingURL=auth.js.map
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(argv?: string[]): Promise<number>;