@glash/cli 0.2.21 → 0.2.23

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.
@@ -129,22 +129,28 @@ export async function deploy({ flags }) {
129
129
  const target = flags.prod || flags.production ? 'production' : (flags.target || 'production');
130
130
 
131
131
  let dep;
132
+ let envSummary = { count: 0, skipped: Boolean(flags['no-env']) };
133
+ let currentPhase = 'doing';
134
+ const start = Date.now();
135
+ const indicator = createDeployIndicator();
136
+ indicator.start(currentPhase);
132
137
 
133
138
  if (!flags['no-env']) {
139
+ indicator.setPhase('syncing');
134
140
  try {
135
- await syncEnvBeforeDeploy(projectId, target, flags);
141
+ envSummary = await syncEnvBeforeDeploy(projectId, target, flags);
136
142
  } catch (e) {
143
+ indicator.stop();
137
144
  return fail(`Env sync failed: ${e.message}`);
138
145
  }
139
146
  }
140
147
 
141
148
  if (!remoteDeploy) {
142
- info(color.dim(`→ uploading local source for project ${projectId} (${target})`));
149
+ indicator.setPhase('packaging');
143
150
  const zipPath = join(tmpdir(), `glash-deploy-${Date.now()}.zip`);
144
151
  try {
145
152
  await zipDirectory(process.cwd(), zipPath);
146
- const zipStat = await stat(zipPath);
147
- info(color.dim(`→ source bundle ${(zipStat.size / 1024 / 1024).toFixed(1)} MB`));
153
+ await stat(zipPath);
148
154
  const formData = new FormData();
149
155
  formData.append('projectId', projectId);
150
156
  formData.append('file', await openAsBlob(zipPath, { type: 'application/zip' }), 'project.zip');
@@ -153,38 +159,37 @@ export async function deploy({ flags }) {
153
159
  formData.append('gitBranch', gitBranch || '');
154
160
  formData.append('target', target);
155
161
 
162
+ indicator.setPhase('glashing');
156
163
  dep = await apiPost('/deployments/upload', formData);
157
164
  await rm(zipPath, { force: true }).catch(() => {});
158
165
  } catch (e) {
159
166
  await rm(zipPath, { force: true }).catch(() => {});
167
+ indicator.stop();
160
168
  return fail(`Upload failed: ${e.message}`);
161
169
  }
162
170
  } else {
163
- info(color.dim(`→ deploying connected repo for project ${projectId}${commitHash ? ` @ ${commitHash.slice(0, 7)}` : ''} (${target})`));
171
+ indicator.setPhase('hatching');
164
172
  try {
165
173
  dep = await apiPost('/deployments', { projectId, commitHash, gitBranch, target });
166
174
  } catch (e) {
175
+ indicator.stop();
167
176
  return fail(e.message);
168
177
  }
169
178
  }
170
179
 
171
- ok(`Deployment ${color.bold(dep.id)} queued`);
172
-
173
180
  if (flags.detach || flags.D) {
181
+ indicator.stop();
182
+ info(`Queued: ${color.dim(dep.id)}`);
174
183
  info(`Track it with: ${color.cyan(`glash status ${dep.id}`)}`);
175
184
  return;
176
185
  }
177
186
 
178
187
  // Poll the pipeline until it terminates.
179
188
  const seen = new Set();
180
- let lastStatus = '';
181
- const start = Date.now();
182
189
  const timeoutMs = deployTimeoutMs(flags);
183
190
  let pollDelayMs = 5000;
184
191
  let lastProgressAt = Date.now();
185
- let currentPhase = 'queued';
186
- const indicator = createDeployIndicator({ start, getPhase: () => currentPhase });
187
- indicator.start();
192
+ indicator.setPhase('hatching');
188
193
  while (Date.now() - start < timeoutMs) {
189
194
  let pipe;
190
195
  try {
@@ -197,7 +202,7 @@ export async function deploy({ flags }) {
197
202
  continue;
198
203
  }
199
204
  if (Date.now() - lastProgressAt > 60_000) {
200
- info(color.dim(` • deploy Still working; waiting for the next deployment update…`));
205
+ indicator.setPhase('waiting');
201
206
  lastProgressAt = Date.now();
202
207
  }
203
208
  pollDelayMs = Math.min(Math.max(pollDelayMs * 1.5, 5000), 30000);
@@ -209,19 +214,22 @@ export async function deploy({ flags }) {
209
214
  const key = `${ev.id ?? ev.createdAt}:${ev.step}:${ev.status}`;
210
215
  if (seen.has(key)) continue;
211
216
  seen.add(key);
212
- indicator.clear();
213
- const tag = stepBadge(ev.status);
214
- info(` ${tag} ${color.dim(ev.step.padEnd(8))} ${ev.message ?? ''}`);
215
217
  lastProgressAt = Date.now();
216
218
  currentPhase = phaseFromEvent(ev);
219
+ indicator.setPhase(currentPhase);
217
220
  }
218
221
 
219
222
  const status = pipe.deployStatus ?? pipe.buildStatus ?? '';
220
- if (status && status !== lastStatus) lastStatus = status;
221
223
  if (['ready', 'live', 'success'].includes(status)) {
222
224
  indicator.stop();
223
225
  const url = pipe.hostings?.[0]?.domains?.[0]?.domain;
224
- ok(`Live${url ? ` → ${color.cyan(`https://${url.replace(/^https?:\/\//, '')}`)}` : ''}`);
226
+ if (url) {
227
+ const problem = await detectLiveUrlProblem(`https://${url.replace(/^https?:\/\//, '')}`);
228
+ if (problem) {
229
+ return fail(`Deployment finished, but the hostname is still not serving your app: ${problem}. Run \`glash logs ${dep.id}\` for details.`);
230
+ }
231
+ }
232
+ printDeploySuccess({ url, envSummary });
225
233
  return;
226
234
  }
227
235
  if (status === 'placeholder') {
@@ -238,6 +246,7 @@ export async function deploy({ flags }) {
238
246
  return fail(`Deployment failed (${status})${detail}. Run \`glash logs ${dep.id}\` for details.`);
239
247
  }
240
248
  currentPhase = phaseFromPipeline(pipe, currentPhase);
249
+ indicator.setPhase(currentPhase);
241
250
  pollDelayMs = nextPollDelay(pipe);
242
251
  await indicator.wait(pollDelayMs);
243
252
  }
@@ -247,7 +256,7 @@ export async function deploy({ flags }) {
247
256
 
248
257
  async function syncEnvBeforeDeploy(projectId, target, flags) {
249
258
  const files = await resolveEnvFiles(target, flags);
250
- if (!files.length) return;
259
+ if (!files.length) return { count: 0, files: [] };
251
260
 
252
261
  const merged = new Map();
253
262
  for (const file of files) {
@@ -258,9 +267,9 @@ async function syncEnvBeforeDeploy(projectId, target, flags) {
258
267
  .filter(([key]) => !key.startsWith('GLASH_'))
259
268
  .map(([key, value]) => ({ key, value, environment: target }));
260
269
 
261
- if (!vars.length) return;
270
+ if (!vars.length) return { count: 0, files };
262
271
  await apiPost(`/projects/${projectId}/env-vars/bulk`, { vars });
263
- info(color.dim(`→ synced ${vars.length} env vars from ${files.join(', ')}`));
272
+ return { count: vars.length, files };
264
273
  }
265
274
 
266
275
  async function resolveEnvFiles(target, flags) {
@@ -305,13 +314,6 @@ function parseDotenv(text) {
305
314
  return out;
306
315
  }
307
316
 
308
- function stepBadge(status) {
309
- if (status === 'success' || status === 'issued' || status === 'active' || status === 'ready') return color.green('✓');
310
- if (status === 'error' || status === 'failed') return color.red('✗');
311
- if (status === 'warning') return color.yellow('!');
312
- return color.cyan('•');
313
- }
314
-
315
317
  function nextPollDelay(pipe) {
316
318
  const status = pipe.deployStatus ?? pipe.buildStatus ?? '';
317
319
  const last = [...(pipe.events ?? [])].reverse().find(Boolean);
@@ -339,19 +341,19 @@ function formatDuration(ms) {
339
341
  }
340
342
 
341
343
  function phaseFromEvent(ev) {
342
- if (ev.step === 'detect') return 'detecting';
343
- if (ev.step === 'host') return 'assigning host';
344
- if (ev.step === 'package') return ev.status === 'queued' ? 'queued' : 'packaging';
344
+ if (ev.step === 'detect') return 'doing';
345
+ if (ev.step === 'host') return 'hatching';
346
+ if (ev.step === 'package') return ev.status === 'queued' ? 'hatching' : 'packaging';
345
347
  if (ev.step === 'build') return 'building';
346
- if (ev.step === 'deploy') return ev.status === 'ready' ? 'finalizing' : 'deploying';
348
+ if (ev.step === 'deploy') return ev.status === 'ready' ? 'checking' : 'deploying';
347
349
  if (ev.step === 'route' || ev.step === 'edge' || ev.step === 'ssl') return 'routing';
348
- if (ev.step === 'runtime') return 'checking runtime';
349
- return ev.step || 'deploying';
350
+ if (ev.step === 'runtime') return 'checking';
351
+ return ev.step || 'glashing';
350
352
  }
351
353
 
352
354
  function phaseFromPipeline(pipe, fallback) {
353
355
  const status = pipe.deployStatus ?? pipe.buildStatus ?? '';
354
- if (status === 'queued') return 'queued';
356
+ if (status === 'queued') return 'hatching';
355
357
  if (status === 'building') return 'building';
356
358
  if (status === 'rolling-out') return 'deploying';
357
359
  const last = [...(pipe.events ?? [])].reverse().find(Boolean);
@@ -368,23 +370,60 @@ function deploymentFailureHint(message = '') {
368
370
  return '';
369
371
  }
370
372
 
371
- function createDeployIndicator({ start, getPhase }) {
373
+ async function detectLiveUrlProblem(url) {
374
+ try {
375
+ const res = await fetch(url, {
376
+ redirect: 'manual',
377
+ signal: AbortSignal.timeout(10_000),
378
+ headers: { 'User-Agent': `glash-cli-live-check` },
379
+ });
380
+ const text = await res.clone().text().catch(() => '');
381
+ if (/glashDB:\s*no application is deployed at this hostname/i.test(text)) {
382
+ return 'the edge reached the host, but no nginx route exists for this hostname';
383
+ }
384
+ if (/Glash\s+Site reserved|Site reserved/i.test(text)) {
385
+ return 'the hostname is still showing the glashDB parked page';
386
+ }
387
+ if (res.status >= 500) {
388
+ return `the live URL returned HTTP ${res.status}`;
389
+ }
390
+ return '';
391
+ } catch (e) {
392
+ return `the live URL check failed (${e.message})`;
393
+ }
394
+ }
395
+
396
+ function printDeploySuccess({ url, envSummary }) {
397
+ const liveUrl = url ? `https://${url.replace(/^https?:\/\//, '')}` : '';
398
+ if (liveUrl) info(`Live: ${terminalLink(liveUrl)}`);
399
+ const envText = envSummary?.skipped ? 'skipped' : `${envSummary?.count ?? 0} moved to glash`;
400
+ info(`Env: ${envText}`);
401
+ ok('Success');
402
+ }
403
+
404
+ function terminalLink(url) {
405
+ const label = color.cyan(url);
406
+ if (!stdout.isTTY) return label;
407
+ return `\x1b]8;;${url}\x07${label}\x1b]8;;\x07`;
408
+ }
409
+
410
+ function createDeployIndicator() {
372
411
  const interactive = Boolean(stdout.isTTY);
373
412
  let frame = 0;
374
413
  let timer;
375
- let plainLast = 0;
414
+ let phase = 'doing';
415
+ let printedPhase = '';
376
416
  const frames = ['.', '..', '...'];
377
417
 
378
418
  function message() {
379
- const elapsed = Math.floor((Date.now() - start) / 1000);
380
419
  const dots = frames[frame++ % frames.length].padEnd(3, ' ');
381
- return ` ${color.cyan('•')} ${color.dim('deploy'.padEnd(8))} ${getPhase()}${dots} ${color.dim(`${elapsed}s elapsed`)}`;
420
+ return color.dim(`${phase}${dots}`);
382
421
  }
383
422
 
384
- function render() {
423
+ function render({ force = false } = {}) {
385
424
  if (!interactive) {
386
- if (Date.now() - plainLast > 30_000) {
387
- plainLast = Date.now();
425
+ if (force && phase !== printedPhase) {
426
+ printedPhase = phase;
388
427
  info(message().trimEnd());
389
428
  }
390
429
  return;
@@ -393,12 +432,15 @@ function createDeployIndicator({ start, getPhase }) {
393
432
  }
394
433
 
395
434
  return {
396
- start() {
435
+ start(initialPhase = 'doing') {
436
+ phase = initialPhase;
397
437
  render();
398
438
  timer = setInterval(render, 1000);
399
439
  },
400
- clear() {
401
- if (interactive) stdout.write('\r\x1b[2K');
440
+ setPhase(nextPhase) {
441
+ if (!nextPhase || nextPhase === phase) return;
442
+ phase = nextPhase;
443
+ render({ force: true });
402
444
  },
403
445
  stop() {
404
446
  if (timer) clearInterval(timer);
package/lib/api.mjs CHANGED
@@ -28,6 +28,12 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
28
28
  // proxies across machines.
29
29
  const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504, 522, 524]);
30
30
  const DEFAULT_RETRIES = 4;
31
+ // Per-request wall-clock ceiling. Without this a contended/wedged backend
32
+ // leaves `glash deploy` hanging forever after "source bundle X MB" with no
33
+ // output. On timeout we abort and retry instead of hanging. Uploads (FormData)
34
+ // stream a bundle and register a deployment, so they get a longer ceiling.
35
+ const DEFAULT_TIMEOUT_MS = 60_000;
36
+ const UPLOAD_TIMEOUT_MS = 180_000;
31
37
 
32
38
  function backoffMs(attempt, retryAfter) {
33
39
  if (retryAfter && Number.isFinite(retryAfter)) return Math.min(Math.max(retryAfter, 1000), 60000);
@@ -37,7 +43,7 @@ function backoffMs(attempt, retryAfter) {
37
43
  return Math.round(jitter);
38
44
  }
39
45
 
40
- export async function api(method, path, { body, token, query, retries = DEFAULT_RETRIES, signal } = {}) {
46
+ export async function api(method, path, { body, token, query, retries = DEFAULT_RETRIES, signal, timeoutMs } = {}) {
41
47
  const headers = {
42
48
  Accept: 'application/json',
43
49
  'User-Agent': `glash-cli/${VERSION}`,
@@ -62,25 +68,44 @@ export async function api(method, path, { body, token, query, retries = DEFAULT_
62
68
  // re-streamable — fetch reads them fresh on each attempt, which lets us
63
69
  // retry uploads without re-zipping.
64
70
  const requestBody = isForm ? body : (body === undefined ? undefined : JSON.stringify(body));
71
+ const effectiveTimeout = timeoutMs ?? (isForm ? UPLOAD_TIMEOUT_MS : DEFAULT_TIMEOUT_MS);
65
72
 
66
73
  let attempt = 0;
67
74
  // eslint-disable-next-line no-constant-condition
68
75
  while (true) {
76
+ // Each attempt gets a fresh AbortController so the timeout can fire
77
+ // independently. If the caller passed their own signal, chain it.
78
+ const ctrl = new AbortController();
79
+ let timedOut = false;
80
+ const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, effectiveTimeout);
81
+ const onCallerAbort = () => ctrl.abort();
82
+ if (signal) {
83
+ if (signal.aborted) ctrl.abort();
84
+ else signal.addEventListener('abort', onCallerAbort, { once: true });
85
+ }
86
+
69
87
  let res;
70
88
  try {
71
- res = await fetch(url, { method, headers, body: requestBody, signal });
89
+ res = await fetch(url, { method, headers, body: requestBody, signal: ctrl.signal });
72
90
  } 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.
91
+ clearTimeout(timer);
92
+ signal?.removeEventListener?.('abort', onCallerAbort);
93
+ // A caller-initiated cancel (not our timeout) must not be retried.
94
+ if (signal?.aborted && !timedOut) throw e;
95
+ const detail = timedOut
96
+ ? `timed out after ${Math.round(effectiveTimeout / 1000)}s`
97
+ : (e?.cause?.message || e?.message || String(e));
98
+ // Network failure or timeout: the request did not complete, so retrying
99
+ // is safe. Covers DNS, TLS, connection-reset, and wedged-server hangs.
77
100
  if (attempt < retries) {
78
101
  await sleep(backoffMs(attempt));
79
102
  attempt += 1;
80
103
  continue;
81
104
  }
82
- throw new GlashApiError(0, `Network request failed while calling ${method} ${path}: ${detail}`);
105
+ throw new GlashApiError(0, `Request failed while calling ${method} ${path}: ${detail}`);
83
106
  }
107
+ clearTimeout(timer);
108
+ signal?.removeEventListener?.('abort', onCallerAbort);
84
109
 
85
110
  if (!res.ok) {
86
111
  const retryAfter = retryAfterMs(res.headers);
package/lib/version.mjs CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.2.21';
1
+ export const VERSION = '0.2.22';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glash/cli",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
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",