@glash/cli 0.2.19 → 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 +7 -7
- package/commands/deploy.mjs +18 -1
- package/lib/api.mjs +66 -27
- package/lib/version.mjs +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
18
|
-
yarn global add
|
|
19
|
-
bun add -g
|
|
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
|
|
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
|
|
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
|
|
60
|
+
npm install -g glashdb
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
## Commands
|
package/commands/deploy.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
55
|
-
|
|
66
|
+
let attempt = 0;
|
|
67
|
+
// eslint-disable-next-line no-constant-condition
|
|
68
|
+
while (true) {
|
|
69
|
+
let res;
|
|
56
70
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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.
|
|
1
|
+
export const VERSION = '0.2.20';
|
package/package.json
CHANGED