@glash/cli 0.2.30 → 0.2.32
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 +5 -0
- package/commands/deploy.mjs +143 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -104,6 +104,9 @@ glash deploy --asset-mode offload
|
|
|
104
104
|
glash deploy --asset-mode auto --asset-threshold 100mb
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
Every deploy prints a live monitor link. Open it to watch the black-screen deployment flow while the CLI is packaging, uploading, building, routing, and checking the live URL.
|
|
108
|
+
Public asset offload prepares assets in small batches and uses multipart uploads for large files, so image/video-heavy `public/` folders can upload without forcing the source bundle over the platform limit.
|
|
109
|
+
|
|
107
110
|
## Commands
|
|
108
111
|
|
|
109
112
|
| Command | Purpose |
|
|
@@ -174,8 +177,10 @@ The commands require PostgreSQL client tools locally: `pg_dump` for dumps,
|
|
|
174
177
|
| Variable | Default | Notes |
|
|
175
178
|
| --- | --- | --- |
|
|
176
179
|
| `GLASH_API_URL` | `https://api.glashdb.com/api` | Override the API base (self-hosted, staging) |
|
|
180
|
+
| `GLASH_APP_URL` | `https://glashdb.com` | Override the dashboard base used for live monitor links |
|
|
177
181
|
| `GLASH_TOKEN` | — | Use this token instead of `~/.glash/credentials.json` (useful in CI) |
|
|
178
182
|
| `GLASH_ASSET_OFFLOAD_THRESHOLD` | `100mb` | Default threshold for `glash deploy --asset-mode auto` |
|
|
183
|
+
| `GLASH_ASSET_BATCH_SIZE` | `50` | Number of public assets prepared per request during `--asset-mode offload` |
|
|
179
184
|
|
|
180
185
|
## Requirements
|
|
181
186
|
|
package/commands/deploy.mjs
CHANGED
|
@@ -135,6 +135,8 @@ export async function deploy({ flags }) {
|
|
|
135
135
|
const gitBranch = flags.branch || currentBranch();
|
|
136
136
|
const target = flags.prod || flags.production ? 'production' : (flags.target || 'production');
|
|
137
137
|
const requestedAssetMode = assetMode(flags);
|
|
138
|
+
const monitorStartedAt = Date.now();
|
|
139
|
+
const initialStatusUrl = deployMonitorUrl({ projectId, after: monitorStartedAt });
|
|
138
140
|
|
|
139
141
|
let dep;
|
|
140
142
|
let envSummary = { count: 0, skipped: Boolean(flags['no-env']) };
|
|
@@ -154,9 +156,11 @@ export async function deploy({ flags }) {
|
|
|
154
156
|
envSummary,
|
|
155
157
|
assetSummary,
|
|
156
158
|
liveUrl: '',
|
|
159
|
+
statusUrl: initialStatusUrl,
|
|
157
160
|
error: '',
|
|
158
161
|
start,
|
|
159
162
|
};
|
|
163
|
+
info(`Live status: ${terminalLink(initialStatusUrl)}`);
|
|
160
164
|
indicator.start(currentPhase);
|
|
161
165
|
|
|
162
166
|
if (!flags['no-env']) {
|
|
@@ -211,6 +215,7 @@ export async function deploy({ flags }) {
|
|
|
211
215
|
indicator.setPhase('glashing');
|
|
212
216
|
dep = await apiPost('/deployments/upload', formData);
|
|
213
217
|
report.deploymentId = dep?.id ?? '';
|
|
218
|
+
report.statusUrl = deployMonitorUrl({ deploymentId: dep?.id, projectId, after: monitorStartedAt });
|
|
214
219
|
report.packagesUploaded = 1;
|
|
215
220
|
await rm(zipPath, { force: true }).catch(() => {});
|
|
216
221
|
} catch (e) {
|
|
@@ -224,6 +229,7 @@ export async function deploy({ flags }) {
|
|
|
224
229
|
try {
|
|
225
230
|
dep = await apiPost('/deployments', { projectId, commitHash, gitBranch, target });
|
|
226
231
|
report.deploymentId = dep?.id ?? '';
|
|
232
|
+
report.statusUrl = deployMonitorUrl({ deploymentId: dep?.id, projectId, after: monitorStartedAt });
|
|
227
233
|
} catch (e) {
|
|
228
234
|
indicator.fail('failed');
|
|
229
235
|
printDeployReport({ ...report, status: 'failed', error: e.message });
|
|
@@ -235,6 +241,7 @@ export async function deploy({ flags }) {
|
|
|
235
241
|
indicator.fail('queued');
|
|
236
242
|
printDeployReport({ ...report, status: 'queued' });
|
|
237
243
|
info(`Queued: ${color.dim(dep.id)}`);
|
|
244
|
+
info(`Watch live: ${terminalLink(report.statusUrl)}`);
|
|
238
245
|
info(`Track it with: ${color.cyan(`glash status ${dep.id}`)}`);
|
|
239
246
|
return;
|
|
240
247
|
}
|
|
@@ -363,10 +370,10 @@ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
|
|
|
363
370
|
|
|
364
371
|
let uploaded = 0;
|
|
365
372
|
let skipped = 0;
|
|
366
|
-
for (const batch of chunk(assets,
|
|
373
|
+
for (const batch of chunk(assets, publicAssetBatchSize(flags))) {
|
|
367
374
|
let prepared;
|
|
368
375
|
try {
|
|
369
|
-
prepared = await apiPost(`/projects/${projectId}/public-assets/prepare`, { assets: batch });
|
|
376
|
+
prepared = await apiPost(`/projects/${projectId}/public-assets/prepare`, { assets: batch }, { timeoutMs: 120_000 });
|
|
370
377
|
} catch (e) {
|
|
371
378
|
throw assetOffloadFailure(e, { mode, status: 'prepare-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes });
|
|
372
379
|
}
|
|
@@ -375,26 +382,20 @@ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
|
|
|
375
382
|
skipped += 1;
|
|
376
383
|
continue;
|
|
377
384
|
}
|
|
378
|
-
if (!asset.uploadUrl) throw new Error(`No upload URL returned for ${asset.path}`);
|
|
379
385
|
const local = assets.find((item) => item.path === asset.path);
|
|
380
386
|
if (!local) throw new Error(`Prepared unknown asset ${asset.path}`);
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
headers: { 'Content-Type': local.contentType },
|
|
385
|
-
body: blob,
|
|
386
|
-
});
|
|
387
|
-
if (!res.ok) {
|
|
388
|
-
const detail = await res.text().catch(() => '');
|
|
387
|
+
try {
|
|
388
|
+
await uploadPreparedPublicAsset(asset, local);
|
|
389
|
+
} catch (e) {
|
|
389
390
|
throw assetOffloadFailure(
|
|
390
|
-
|
|
391
|
+
e,
|
|
391
392
|
{ mode, status: 'upload-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes },
|
|
392
393
|
);
|
|
393
394
|
}
|
|
394
395
|
uploaded += 1;
|
|
395
396
|
}
|
|
396
397
|
try {
|
|
397
|
-
await apiPost(`/projects/${projectId}/public-assets/complete`, { assets: batch });
|
|
398
|
+
await apiPost(`/projects/${projectId}/public-assets/complete`, { assets: batch }, { timeoutMs: 120_000 });
|
|
398
399
|
} catch (e) {
|
|
399
400
|
throw assetOffloadFailure(e, { mode, status: 'complete-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes });
|
|
400
401
|
}
|
|
@@ -411,6 +412,110 @@ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
|
|
|
411
412
|
};
|
|
412
413
|
}
|
|
413
414
|
|
|
415
|
+
async function uploadPreparedPublicAsset(asset, local) {
|
|
416
|
+
if (asset.uploadKind === 'multipart' || asset.uploadPartUrl || asset.completeMultipartUrl) {
|
|
417
|
+
return uploadMultipartPublicAsset(asset, local);
|
|
418
|
+
}
|
|
419
|
+
if (!asset.uploadUrl) throw new Error(`No upload URL returned for ${asset.path}`);
|
|
420
|
+
const blob = await openAsBlob(join(process.cwd(), 'public', asset.path), { type: local.contentType });
|
|
421
|
+
const res = await fetchWithRetries(asset.uploadUrl, {
|
|
422
|
+
method: asset.method || 'PUT',
|
|
423
|
+
headers: { 'Content-Type': local.contentType },
|
|
424
|
+
body: blob,
|
|
425
|
+
}, { timeoutMs: uploadTimeoutMsFor(local.sizeBytes) });
|
|
426
|
+
if (!res.ok) {
|
|
427
|
+
const detail = await res.text().catch(() => '');
|
|
428
|
+
throw new Error(`Upload ${asset.path} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function uploadMultipartPublicAsset(asset, local) {
|
|
433
|
+
if (!asset.uploadPartUrl || !asset.completeMultipartUrl || !asset.uploadId) {
|
|
434
|
+
throw new Error(`Multipart upload details missing for ${asset.path}`);
|
|
435
|
+
}
|
|
436
|
+
const partSize = publicAssetPartSize(asset.partSize);
|
|
437
|
+
const blob = await openAsBlob(join(process.cwd(), 'public', asset.path), { type: local.contentType });
|
|
438
|
+
const parts = [];
|
|
439
|
+
let partNumber = 1;
|
|
440
|
+
for (let offset = 0; offset < local.sizeBytes; offset += partSize) {
|
|
441
|
+
const end = Math.min(offset + partSize, local.sizeBytes);
|
|
442
|
+
const partUrl = withQueryParam(asset.uploadPartUrl, 'partNumber', String(partNumber));
|
|
443
|
+
const res = await fetchWithRetries(partUrl, {
|
|
444
|
+
method: 'PUT',
|
|
445
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
446
|
+
body: blob.slice(offset, end, 'application/octet-stream'),
|
|
447
|
+
}, { timeoutMs: uploadTimeoutMsFor(end - offset) });
|
|
448
|
+
if (!res.ok) {
|
|
449
|
+
const detail = await res.text().catch(() => '');
|
|
450
|
+
throw new Error(`Upload ${asset.path} part ${partNumber} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
451
|
+
}
|
|
452
|
+
let data;
|
|
453
|
+
try { data = await res.json(); } catch { data = {}; }
|
|
454
|
+
const part = data?.part;
|
|
455
|
+
if (!part?.etag || !Number.isInteger(Number(part.partNumber))) {
|
|
456
|
+
throw new Error(`Upload ${asset.path} part ${partNumber} did not return a multipart ETag`);
|
|
457
|
+
}
|
|
458
|
+
parts.push({ partNumber: Number(part.partNumber), etag: String(part.etag) });
|
|
459
|
+
partNumber += 1;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const complete = await fetchWithRetries(asset.completeMultipartUrl, {
|
|
463
|
+
method: 'POST',
|
|
464
|
+
headers: { 'Content-Type': 'application/json' },
|
|
465
|
+
body: JSON.stringify({ parts }),
|
|
466
|
+
}, { timeoutMs: 120_000 });
|
|
467
|
+
if (!complete.ok) {
|
|
468
|
+
const detail = await complete.text().catch(() => '');
|
|
469
|
+
throw new Error(`Completing multipart upload for ${asset.path} failed with HTTP ${complete.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function fetchWithRetries(url, init, { retries = 3, timeoutMs = 120_000 } = {}) {
|
|
474
|
+
let attempt = 0;
|
|
475
|
+
// eslint-disable-next-line no-constant-condition
|
|
476
|
+
while (true) {
|
|
477
|
+
const ctrl = new AbortController();
|
|
478
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
479
|
+
try {
|
|
480
|
+
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
|
481
|
+
clearTimeout(timer);
|
|
482
|
+
if (res.ok || !isRetryableUploadStatus(res.status) || attempt >= retries) return res;
|
|
483
|
+
await res.body?.cancel?.().catch(() => {});
|
|
484
|
+
} catch (error) {
|
|
485
|
+
clearTimeout(timer);
|
|
486
|
+
if (attempt >= retries) throw error;
|
|
487
|
+
}
|
|
488
|
+
await sleep(uploadBackoffMs(attempt));
|
|
489
|
+
attempt += 1;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function isRetryableUploadStatus(status) {
|
|
494
|
+
return [408, 425, 429, 500, 502, 503, 504, 522, 524].includes(Number(status));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function uploadBackoffMs(attempt) {
|
|
498
|
+
const base = Math.min(1000 * 2 ** attempt, 10000);
|
|
499
|
+
return Math.round(base * (0.75 + Math.random() * 0.5));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function withQueryParam(url, key, value) {
|
|
503
|
+
const next = new URL(url);
|
|
504
|
+
next.searchParams.set(key, value);
|
|
505
|
+
return next.toString();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function uploadTimeoutMsFor(bytes) {
|
|
509
|
+
const size = Number(bytes) || 0;
|
|
510
|
+
return Math.max(120_000, Math.min(15 * 60_000, Math.ceil(size / (1024 * 1024)) * 15_000));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function publicAssetPartSize(value) {
|
|
514
|
+
const parsed = Number(value);
|
|
515
|
+
if (Number.isFinite(parsed) && parsed >= 5 * 1024 * 1024) return Math.round(parsed);
|
|
516
|
+
return 16 * 1024 * 1024;
|
|
517
|
+
}
|
|
518
|
+
|
|
414
519
|
function assetOffloadFailure(error, assetSummary) {
|
|
415
520
|
const message = friendlyAssetOffloadError(error);
|
|
416
521
|
const wrapped = new Error(message);
|
|
@@ -504,6 +609,13 @@ function assetOffloadThresholdBytes(flags) {
|
|
|
504
609
|
return parseByteSize(flags['asset-threshold'] ?? process.env.GLASH_ASSET_OFFLOAD_THRESHOLD ?? '100mb');
|
|
505
610
|
}
|
|
506
611
|
|
|
612
|
+
function publicAssetBatchSize(flags) {
|
|
613
|
+
const raw = flags['asset-batch-size'] ?? process.env.GLASH_ASSET_BATCH_SIZE ?? '50';
|
|
614
|
+
const parsed = Number(raw);
|
|
615
|
+
if (!Number.isFinite(parsed) || parsed < 1) return 50;
|
|
616
|
+
return Math.max(1, Math.min(250, Math.floor(parsed)));
|
|
617
|
+
}
|
|
618
|
+
|
|
507
619
|
function parseByteSize(value) {
|
|
508
620
|
if (typeof value === 'number') return value;
|
|
509
621
|
const raw = String(value || '').trim().toLowerCase();
|
|
@@ -667,6 +779,23 @@ function normalizeLiveUrl(url) {
|
|
|
667
779
|
return url ? `https://${String(url).replace(/^https?:\/\//, '')}` : '';
|
|
668
780
|
}
|
|
669
781
|
|
|
782
|
+
function deployMonitorUrl({ deploymentId, projectId, after } = {}) {
|
|
783
|
+
const base = appBaseUrl();
|
|
784
|
+
if (deploymentId) return `${base}/app/deploy/live/${encodeURIComponent(deploymentId)}`;
|
|
785
|
+
const url = new URL(`${base}/app/deploy/live`);
|
|
786
|
+
if (projectId) url.searchParams.set('projectId', projectId);
|
|
787
|
+
if (after) url.searchParams.set('after', String(after));
|
|
788
|
+
return url.toString();
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function appBaseUrl() {
|
|
792
|
+
const configured = process.env.GLASH_APP_URL?.trim().replace(/\/$/, '');
|
|
793
|
+
if (configured) return configured;
|
|
794
|
+
const api = process.env.GLASH_API_URL || '';
|
|
795
|
+
if (/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])/i.test(api)) return 'http://localhost:3000';
|
|
796
|
+
return 'https://glashdb.com';
|
|
797
|
+
}
|
|
798
|
+
|
|
670
799
|
function printDeployReport(report) {
|
|
671
800
|
const elapsedMs = Date.now() - report.start;
|
|
672
801
|
const liveUrl = report.liveUrl ? terminalLink(report.liveUrl) : '-';
|
|
@@ -680,6 +809,7 @@ function printDeployReport(report) {
|
|
|
680
809
|
['Public assets', formatAssetSummary(report.assetSummary)],
|
|
681
810
|
['Deployment size', formatDeploymentSize(report)],
|
|
682
811
|
['Total time spent', formatMinutes(elapsedMs)],
|
|
812
|
+
['Status link', report.statusUrl ? terminalLink(report.statusUrl) : '-'],
|
|
683
813
|
['Live URL', liveUrl],
|
|
684
814
|
['Environment', formatEnvSummary(report.envSummary)],
|
|
685
815
|
];
|
package/package.json
CHANGED