@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.
- package/commands/deploy.mjs +87 -45
- package/lib/api.mjs +32 -7
- package/lib/version.mjs +1 -1
- package/package.json +1 -1
package/commands/deploy.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
343
|
-
if (ev.step === 'host') return '
|
|
344
|
-
if (ev.step === 'package') return ev.status === 'queued' ? '
|
|
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' ? '
|
|
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
|
|
349
|
-
return ev.step || '
|
|
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 '
|
|
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
|
|
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
|
|
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
|
|
420
|
+
return color.dim(`${phase}${dots}`);
|
|
382
421
|
}
|
|
383
422
|
|
|
384
|
-
function render() {
|
|
423
|
+
function render({ force = false } = {}) {
|
|
385
424
|
if (!interactive) {
|
|
386
|
-
if (
|
|
387
|
-
|
|
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
|
-
|
|
401
|
-
if (
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
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, `
|
|
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.
|
|
1
|
+
export const VERSION = '0.2.22';
|
package/package.json
CHANGED