@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 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
 
@@ -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, 100)) {
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
- const blob = await openAsBlob(join(process.cwd(), 'public', asset.path), { type: local.contentType });
382
- const res = await fetch(asset.uploadUrl, {
383
- method: asset.method || 'PUT',
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
- new Error(`Upload ${asset.path} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glash/cli",
3
- "version": "0.2.30",
3
+ "version": "0.2.32",
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",