@glash/cli 0.2.19 → 0.2.21

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
@@ -8,18 +8,18 @@ Pick whichever install style you prefer — all four produce the same `glash` co
8
8
 
9
9
  ```bash
10
10
  # Short-name (recommended)
11
- npm install -g glash
11
+ npm install -g glashdb
12
12
 
13
13
  # Scoped package
14
14
  npm install -g @glash/cli@latest
15
15
 
16
16
  # pnpm / yarn / bun
17
- pnpm add -g glash
18
- yarn global add glash
19
- bun add -g glash
17
+ pnpm add -g glashdb
18
+ yarn global add glashdb
19
+ bun add -g glashdb
20
20
 
21
21
  # No install — run on demand
22
- npx -y glash deploy
22
+ npx -y glashdb deploy
23
23
  ```
24
24
 
25
25
  Then:
@@ -35,7 +35,7 @@ If npm says the install completed but your shell can't find `glash`, your npm
35
35
  global `bin` folder isn't on `PATH`. Fix it once:
36
36
 
37
37
  ```bash
38
- npm list -g glash --depth=0 # confirm install
38
+ npm list -g glashdb --depth=0 # confirm install
39
39
  ls -la "$(npm prefix -g)/bin/glash" # confirm bin exists
40
40
  echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> ~/.zshrc
41
41
  source ~/.zshrc
@@ -57,7 +57,7 @@ mkdir -p ~/.npm-global
57
57
  npm config set prefix ~/.npm-global
58
58
  echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc
59
59
  source ~/.zshrc
60
- npm install -g glash
60
+ npm install -g glashdb
61
61
  ```
62
62
 
63
63
  ## Commands
@@ -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;
@@ -1,11 +1,47 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { ok, fail, info, color } from '../lib/ui.mjs';
2
+ import { ok, fail, info, warn, color } from '../lib/ui.mjs';
3
+ import { VERSION } from '../lib/version.mjs';
3
4
 
4
5
  export async function update() {
5
- info(color.dim('→ updating @glash/cli to latest...'));
6
- const child = spawnSync('npm', ['install', '-g', '@glash/cli@latest'], { stdio: 'inherit' });
6
+ info(color.dim('→ updating @glash/cli to the latest version...'));
7
+
8
+ // Quiet npm's funding/audit chatter so the only output is our own clean
9
+ // status line. --loglevel=error keeps real failures visible.
10
+ const child = spawnSync(
11
+ 'npm',
12
+ ['install', '-g', '@glash/cli@latest', '--no-fund', '--no-audit', '--loglevel=error'],
13
+ { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' },
14
+ );
15
+
7
16
  if (child.status !== 0) {
8
- return fail(`Update failed with exit code ${child.status ?? 'unknown'}. Please run \`npm install -g @glash/cli@latest\` manually.`);
17
+ const stderr = (child.stderr || '').trim();
18
+ if (/EACCES|permission denied|EPERM/i.test(stderr)) {
19
+ warn('npm needs elevated permissions to write the global package directory.');
20
+ return fail('Run `sudo npm install -g @glash/cli@latest`, or set a user-writable npm prefix (`npm config set prefix ~/.npm-global`).');
21
+ }
22
+ if (stderr) info(color.dim(stderr));
23
+ return fail(`Update failed with exit code ${child.status ?? 'unknown'}. Run \`npm install -g @glash/cli@latest\` manually.`);
24
+ }
25
+
26
+ const installed = resolveInstalledVersion();
27
+ if (installed && installed === VERSION) {
28
+ ok(`Already on the latest version (${color.bold(`v${installed}`)}).`);
29
+ } else if (installed) {
30
+ ok(`Updated @glash/cli → ${color.bold(`v${installed}`)}.`);
31
+ } else {
32
+ ok('Updated @glash/cli to the latest version.');
33
+ }
34
+ }
35
+
36
+ function resolveInstalledVersion() {
37
+ try {
38
+ const res = spawnSync('npm', ['ls', '-g', '@glash/cli', '--depth=0', '--json'], {
39
+ stdio: ['ignore', 'pipe', 'ignore'],
40
+ encoding: 'utf8',
41
+ });
42
+ const parsed = JSON.parse(res.stdout || '{}');
43
+ return parsed?.dependencies?.['@glash/cli']?.version || null;
44
+ } catch {
45
+ return null;
9
46
  }
10
- ok('Successfully updated @glash/cli to the latest version.');
11
47
  }
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.19';
1
+ export const VERSION = '0.2.21';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glash/cli",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
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",