@glash/cli 0.2.17 → 0.2.20

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/README.md CHANGED
@@ -2,12 +2,64 @@
2
2
 
3
3
  The official command-line interface for [glashDB](https://glashdb.com).
4
4
 
5
+ ## Install
6
+
7
+ Pick whichever install style you prefer — all four produce the same `glash` command:
8
+
9
+ ```bash
10
+ # Short-name (recommended)
11
+ npm install -g glashdb
12
+
13
+ # Scoped package
14
+ npm install -g @glash/cli@latest
15
+
16
+ # pnpm / yarn / bun
17
+ pnpm add -g glashdb
18
+ yarn global add glashdb
19
+ bun add -g glashdb
20
+
21
+ # No install — run on demand
22
+ npx -y glashdb deploy
5
23
  ```
6
- npm install -g @glash/cli
24
+
25
+ Then:
26
+
27
+ ```bash
7
28
  glash login
8
29
  glash deploy
9
30
  ```
10
31
 
32
+ ### `glash: command not found`
33
+
34
+ If npm says the install completed but your shell can't find `glash`, your npm
35
+ global `bin` folder isn't on `PATH`. Fix it once:
36
+
37
+ ```bash
38
+ npm list -g glashdb --depth=0 # confirm install
39
+ ls -la "$(npm prefix -g)/bin/glash" # confirm bin exists
40
+ echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> ~/.zshrc
41
+ source ~/.zshrc
42
+ hash -r
43
+ glash --version
44
+ ```
45
+
46
+ On macOS with Homebrew Node, the global prefix is usually
47
+ `/opt/homebrew/bin` (Apple Silicon) or `/usr/local/bin` (Intel) — both are
48
+ typically already on `PATH`. With `nvm`, re-run `nvm use` in new shells or
49
+ add `nvm use --default` to your shell init.
50
+
51
+ ### EACCES / permission errors
52
+
53
+ Don't `sudo npm install -g`. Instead, point npm at a user-owned prefix:
54
+
55
+ ```bash
56
+ mkdir -p ~/.npm-global
57
+ npm config set prefix ~/.npm-global
58
+ echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc
59
+ source ~/.zshrc
60
+ npm install -g glashdb
61
+ ```
62
+
11
63
  ## Commands
12
64
 
13
65
  | Command | Purpose |
@@ -224,10 +224,17 @@ export async function deploy({ flags }) {
224
224
  ok(`Live${url ? ` → ${color.cyan(`https://${url.replace(/^https?:\/\//, '')}`)}` : ''}`);
225
225
  return;
226
226
  }
227
+ if (status === 'placeholder') {
228
+ indicator.stop();
229
+ const lastPlaceholder = [...(pipe.events ?? [])].reverse().find((ev) => ev.status === 'placeholder' || ev.step === 'deploy');
230
+ const detail = lastPlaceholder?.message ? `: ${lastPlaceholder.message}` : '';
231
+ return fail(`Deployment reached the glashDB parked page, not your app${detail}. Run \`glash logs ${dep.id}\` for details.`);
232
+ }
227
233
  if (['error', 'failed'].includes(status)) {
228
234
  indicator.stop();
229
235
  const lastError = [...(pipe.events ?? [])].reverse().find((ev) => ev.status === 'error' || ev.status === 'failed');
230
- const detail = lastError?.message ? `: ${lastError.message}` : '';
236
+ const rawDetail = lastError?.message ? `: ${lastError.message}` : '';
237
+ const detail = `${rawDetail}${deploymentFailureHint(lastError?.message)}`;
231
238
  return fail(`Deployment failed (${status})${detail}. Run \`glash logs ${dep.id}\` for details.`);
232
239
  }
233
240
  currentPhase = phaseFromPipeline(pipe, currentPhase);
@@ -351,6 +358,16 @@ function phaseFromPipeline(pipe, fallback) {
351
358
  return last ? phaseFromEvent(last) : fallback;
352
359
  }
353
360
 
361
+ function deploymentFailureHint(message = '') {
362
+ if (/agent .*timeout|agent .*ECONN|host-agent|No healthy host-agent|Waiting for a healthy build host/i.test(message)) {
363
+ return `\n${color.yellow('glashDB build host issue:')} your source uploaded, but the platform host agent was unavailable. Re-run \`glash deploy\`; glashDB will skip unhealthy hosts or use the SSH build worker when configured.`;
364
+ }
365
+ if (/GLASH_BUILD_SSH_KEY|Permission denied|invalid format|libcrypto/i.test(message)) {
366
+ return `\n${color.yellow('glashDB build worker issue:')} the platform SSH build worker rejected its key. This is not an app-code error.`;
367
+ }
368
+ return '';
369
+ }
370
+
354
371
  function createDeployIndicator({ start, getPhase }) {
355
372
  const interactive = Boolean(stdout.isTTY);
356
373
  let frame = 0;
package/lib/api.mjs CHANGED
@@ -19,7 +19,25 @@ function retryAfterMs(headers) {
19
19
  return undefined;
20
20
  }
21
21
 
22
- export async function api(method, path, { body, token, query } = {}) {
22
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
23
+
24
+ // Transient HTTP statuses worth retrying. These mean the request almost
25
+ // certainly never reached app logic (gateway/edge hiccup, cold start,
26
+ // rate-limit), so retrying is safe even for non-idempotent POSTs and is the
27
+ // main reason `glash deploy` survives flaky networks, VPNs and corporate
28
+ // proxies across machines.
29
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504, 522, 524]);
30
+ const DEFAULT_RETRIES = 4;
31
+
32
+ function backoffMs(attempt, retryAfter) {
33
+ if (retryAfter && Number.isFinite(retryAfter)) return Math.min(Math.max(retryAfter, 1000), 60000);
34
+ // Exponential backoff with jitter: ~1s, 2s, 4s, 8s (+/- 30%), capped at 30s.
35
+ const base = Math.min(1000 * 2 ** attempt, 30000);
36
+ const jitter = base * (0.7 + Math.random() * 0.6);
37
+ return Math.round(jitter);
38
+ }
39
+
40
+ export async function api(method, path, { body, token, query, retries = DEFAULT_RETRIES, signal } = {}) {
23
41
  const headers = {
24
42
  Accept: 'application/json',
25
43
  'User-Agent': `glash-cli/${VERSION}`,
@@ -27,7 +45,8 @@ export async function api(method, path, { body, token, query } = {}) {
27
45
  };
28
46
  const t = token ?? (await getToken());
29
47
  if (t) headers.Authorization = `Bearer ${t}`;
30
- if (body !== undefined && !(body instanceof FormData)) {
48
+ const isForm = body instanceof FormData;
49
+ if (body !== undefined && !isForm) {
31
50
  headers['Content-Type'] = 'application/json';
32
51
  }
33
52
 
@@ -39,35 +58,55 @@ export async function api(method, path, { body, token, query } = {}) {
39
58
  if (qs) url += (url.includes('?') ? '&' : '?') + qs;
40
59
  }
41
60
 
42
- let res;
43
- try {
44
- res = await fetch(url, {
45
- method,
46
- headers,
47
- body: body instanceof FormData ? body : (body === undefined ? undefined : JSON.stringify(body)),
48
- });
49
- } catch (e) {
50
- const detail = e?.cause?.message || e?.message || String(e);
51
- throw new GlashApiError(0, `Network request failed while calling ${method} ${path}: ${detail}`);
52
- }
61
+ // FormData bodies carry file Blobs backed by the on-disk zip, so they are
62
+ // re-streamable — fetch reads them fresh on each attempt, which lets us
63
+ // retry uploads without re-zipping.
64
+ const requestBody = isForm ? body : (body === undefined ? undefined : JSON.stringify(body));
53
65
 
54
- if (!res.ok) {
55
- let message = `HTTP ${res.status}`;
66
+ let attempt = 0;
67
+ // eslint-disable-next-line no-constant-condition
68
+ while (true) {
69
+ let res;
56
70
  try {
57
- const data = await res.json();
58
- if (typeof data?.message === 'string') message = data.message;
59
- else if (Array.isArray(data?.message)) message = data.message.join(', ');
60
- } catch {}
61
- if (res.status === 401 && !path.includes('/auth/login') && !path.includes('/auth/cli/login')) {
62
- message = `${message}. Run \`glash login\` again, then retry the command.`;
71
+ res = await fetch(url, { method, headers, body: requestBody, signal });
72
+ } catch (e) {
73
+ if (signal?.aborted) throw e;
74
+ const detail = e?.cause?.message || e?.message || String(e);
75
+ // Network-level failure: the request did not complete, so retrying is
76
+ // always safe. This covers DNS, TLS, connection-reset and timeout errors.
77
+ if (attempt < retries) {
78
+ await sleep(backoffMs(attempt));
79
+ attempt += 1;
80
+ continue;
81
+ }
82
+ throw new GlashApiError(0, `Network request failed while calling ${method} ${path}: ${detail}`);
83
+ }
84
+
85
+ if (!res.ok) {
86
+ const retryAfter = retryAfterMs(res.headers);
87
+ if (RETRYABLE_STATUS.has(res.status) && attempt < retries) {
88
+ await res.body?.cancel?.().catch(() => {});
89
+ await sleep(backoffMs(attempt, retryAfter));
90
+ attempt += 1;
91
+ continue;
92
+ }
93
+ let message = `HTTP ${res.status}`;
94
+ try {
95
+ const data = await res.json();
96
+ if (typeof data?.message === 'string') message = data.message;
97
+ else if (Array.isArray(data?.message)) message = data.message.join(', ');
98
+ } catch {}
99
+ if (res.status === 401 && !path.includes('/auth/login') && !path.includes('/auth/cli/login')) {
100
+ message = `${message}. Run \`glash login\` again, then retry the command.`;
101
+ }
102
+ throw new GlashApiError(res.status, message, { retryAfterMs: retryAfter });
63
103
  }
64
- throw new GlashApiError(res.status, message, { retryAfterMs: retryAfterMs(res.headers) });
65
- }
66
104
 
67
- if (res.status === 204) return undefined;
68
- const text = await res.text();
69
- if (!text) return undefined;
70
- try { return JSON.parse(text); } catch { return text; }
105
+ if (res.status === 204) return undefined;
106
+ const text = await res.text();
107
+ if (!text) return undefined;
108
+ try { return JSON.parse(text); } catch { return text; }
109
+ }
71
110
  }
72
111
 
73
112
  export const apiGet = (p, opts) => api('GET', p, opts);
package/lib/version.mjs CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.2.17';
1
+ export const VERSION = '0.2.20';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glash/cli",
3
- "version": "0.2.17",
3
+ "version": "0.2.20",
4
4
  "description": "glashDB command-line interface — deploy projects, pull source + env, manage env vars.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://glashdb.com",