@glash/cli 0.2.27 → 0.2.29

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
@@ -60,6 +60,50 @@ source ~/.zshrc
60
60
  npm install -g glashdb
61
61
  ```
62
62
 
63
+ ## Deployable frameworks
64
+
65
+ `glash deploy` works with more than Next.js. For Node projects, glashDB can infer a build from `package.json` and your lockfile. For anything else, include a `Dockerfile` and glashDB will build and run the container.
66
+
67
+ Common Node and frontend targets:
68
+
69
+ | Framework or runtime | Notes |
70
+ | --- | --- |
71
+ | Next.js | Built and started with the Next runtime when no custom start script is present |
72
+ | React | Works with Vite, Create React App, Gatsby, Remix, and custom React servers |
73
+ | Vue | Works with Vue apps and Nuxt projects |
74
+ | Svelte | Works with Svelte and SvelteKit projects |
75
+ | Astro | Works for static or server-rendered Astro projects when the start command is configured |
76
+ | Static sites | Plain HTML/CSS/JS, generated static output, and Hugo-style sites |
77
+ | Node servers | Express, Fastify, NestJS, Hono, and other servers that expose a start script |
78
+
79
+ Containerized targets:
80
+
81
+ | Framework or runtime | Requirement |
82
+ | --- | --- |
83
+ | Django, FastAPI, Flask | Add a `Dockerfile` that starts the Python app |
84
+ | Laravel, Rails | Add a `Dockerfile` with the app server and required runtime setup |
85
+ | Go, Rust, Java, Spring | Add a `Dockerfile` that builds and runs the service |
86
+ | Any custom runtime | Add a valid `Dockerfile` and expose the listening port |
87
+
88
+ Deployment checklist:
89
+
90
+ - Add a `build` script if your app needs a production build.
91
+ - Add a `start` script for Node apps, or provide `server.js` / `index.js`.
92
+ - Add a `Dockerfile` for non-Node apps or custom runtime needs.
93
+ - Set required secrets with `glash env:add` or the dashboard before deploying.
94
+ - Make sure the app listens on the expected runtime port.
95
+ - For large `public/` folders, deploy with `--asset-mode offload` to upload public assets to glashDB object storage/CDN and keep the source bundle small.
96
+
97
+ Large public folders:
98
+
99
+ ```bash
100
+ # Always offload public/ before building
101
+ glash deploy --asset-mode offload
102
+
103
+ # Offload only when public/ exceeds the threshold
104
+ glash deploy --asset-mode auto --asset-threshold 100mb
105
+ ```
106
+
63
107
  ## Commands
64
108
 
65
109
  | Command | Purpose |
@@ -75,6 +119,7 @@ npm install -g glashdb
75
119
  | `glash link <slug>` | Link the current directory to an existing project |
76
120
  | `glash open` | Open the linked project's primary domain |
77
121
  | `glash deploy [--prod] [--detach]` | Deploy the linked project; streams the pipeline until live |
122
+ | `glash deploy --asset-mode offload` | Upload `public/` assets to object storage/CDN before zipping source |
78
123
  | `glash deployments` | List recent deployments |
79
124
  | `glash status <id>` | Show a deployment's pipeline + events |
80
125
  | `glash logs <id>` | Alias of `status` |
@@ -130,6 +175,7 @@ The commands require PostgreSQL client tools locally: `pg_dump` for dumps,
130
175
  | --- | --- | --- |
131
176
  | `GLASH_API_URL` | `https://api.glashdb.com/api` | Override the API base (self-hosted, staging) |
132
177
  | `GLASH_TOKEN` | — | Use this token instead of `~/.glash/credentials.json` (useful in CI) |
178
+ | `GLASH_ASSET_OFFLOAD_THRESHOLD` | `100mb` | Default threshold for `glash deploy --asset-mode auto` |
133
179
 
134
180
  ## Requirements
135
181
 
package/bin/glash.mjs CHANGED
@@ -98,6 +98,8 @@ ${color.bold('FLAGS')}
98
98
  --environment <name> Env-var environment (default: production)
99
99
  --env-file <file[,file]> Env file(s) to sync before deploy
100
100
  --no-env Skip automatic env sync during deploy
101
+ --asset-mode <mode> Public asset handling: inline, auto, offload
102
+ --asset-threshold <size> Auto-offload threshold (default: 100mb)
101
103
  --help, -h Show this help
102
104
  --version, -v Show CLI version
103
105
 
@@ -1,11 +1,13 @@
1
1
  import { apiGet, apiPost } from '../lib/api.mjs';
2
2
  import { readProjectLink, writeProjectLink } from '../lib/config.mjs';
3
3
  import { ok, fail, info, warn, color, prompt } from '../lib/ui.mjs';
4
+ import fg from 'fast-glob';
4
5
  import { execSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
5
7
  import { zipDirectory } from '../lib/zip.mjs';
6
- import { join, basename } from 'node:path';
8
+ import { join, basename, extname } from 'node:path';
7
9
  import { tmpdir } from 'node:os';
8
- import { openAsBlob } from 'node:fs';
10
+ import { createReadStream, openAsBlob } from 'node:fs';
9
11
  import { access, readFile, rm, stat } from 'node:fs/promises';
10
12
  import { stdout } from 'node:process';
11
13
 
@@ -135,27 +137,65 @@ export async function deploy({ flags }) {
135
137
 
136
138
  let dep;
137
139
  let envSummary = { count: 0, skipped: Boolean(flags['no-env']) };
140
+ let assetSummary = { mode: assetMode(flags), offloaded: false, count: 0, bytes: 0, skipped: 0 };
138
141
  let currentPhase = 'doing';
139
142
  const start = Date.now();
140
143
  const indicator = createDeployIndicator();
144
+ const report = {
145
+ status: 'running',
146
+ projectId,
147
+ deploymentId: '',
148
+ target,
149
+ source: remoteDeploy ? 'remote git deploy' : 'local upload',
150
+ packagesUploaded: 0,
151
+ filesIncluded: 0,
152
+ deploymentSizeBytes: 0,
153
+ envSummary,
154
+ assetSummary,
155
+ liveUrl: '',
156
+ error: '',
157
+ start,
158
+ };
141
159
  indicator.start(currentPhase);
142
160
 
143
161
  if (!flags['no-env']) {
144
162
  indicator.setPhase('syncing');
145
163
  try {
146
164
  envSummary = await syncEnvBeforeDeploy(projectId, target, flags);
165
+ report.envSummary = envSummary;
147
166
  } catch (e) {
148
- indicator.stop();
167
+ indicator.fail('failed');
168
+ report.envSummary = envSummary;
169
+ printDeployReport({ ...report, status: 'failed', error: `Env sync failed: ${e.message}` });
149
170
  return fail(`Env sync failed: ${e.message}`);
150
171
  }
151
172
  }
173
+
174
+ if (!remoteDeploy && assetSummary.mode !== 'inline') {
175
+ indicator.setPhase('assets');
176
+ try {
177
+ assetSummary = await offloadPublicAssetsBeforeDeploy(projectId, flags);
178
+ report.assetSummary = assetSummary;
179
+ } catch (e) {
180
+ indicator.fail('failed');
181
+ printDeployReport({ ...report, assetSummary, status: 'failed', error: `Public asset offload failed: ${e.message}` });
182
+ return fail(`Public asset offload failed: ${e.message}`);
183
+ }
184
+ } else if (assetSummary.mode !== 'inline') {
185
+ indicator.fail('failed');
186
+ const message = '--asset-mode is only available for local source uploads, not remote git deploys.';
187
+ printDeployReport({ ...report, status: 'failed', error: message });
188
+ return fail(message);
189
+ }
152
190
 
153
191
  if (!remoteDeploy) {
154
192
  indicator.setPhase('packaging');
155
193
  const zipPath = join(tmpdir(), `glash-deploy-${Date.now()}.zip`);
156
194
  try {
157
- await zipDirectory(process.cwd(), zipPath);
158
- await stat(zipPath);
195
+ const bundle = await zipDirectory(process.cwd(), zipPath, { ignore: assetSummary.offloaded ? ['public/**'] : [] });
196
+ const zipStat = await stat(zipPath);
197
+ report.filesIncluded = bundle.fileCount ?? 0;
198
+ report.deploymentSizeBytes = zipStat.size || bundle.bytes || 0;
159
199
  const formData = new FormData();
160
200
  formData.append('projectId', projectId);
161
201
  formData.append('file', await openAsBlob(zipPath, { type: 'application/zip' }), 'project.zip');
@@ -166,24 +206,30 @@ export async function deploy({ flags }) {
166
206
 
167
207
  indicator.setPhase('glashing');
168
208
  dep = await apiPost('/deployments/upload', formData);
209
+ report.deploymentId = dep?.id ?? '';
210
+ report.packagesUploaded = 1;
169
211
  await rm(zipPath, { force: true }).catch(() => {});
170
212
  } catch (e) {
171
213
  await rm(zipPath, { force: true }).catch(() => {});
172
- indicator.stop();
214
+ indicator.fail('failed');
215
+ printDeployReport({ ...report, status: 'failed', error: `Upload failed: ${e.message}` });
173
216
  return fail(`Upload failed: ${e.message}`);
174
217
  }
175
218
  } else {
176
219
  indicator.setPhase('hatching');
177
220
  try {
178
221
  dep = await apiPost('/deployments', { projectId, commitHash, gitBranch, target });
222
+ report.deploymentId = dep?.id ?? '';
179
223
  } catch (e) {
180
- indicator.stop();
224
+ indicator.fail('failed');
225
+ printDeployReport({ ...report, status: 'failed', error: e.message });
181
226
  return fail(e.message);
182
227
  }
183
228
  }
184
229
 
185
230
  if (flags.detach || flags.D) {
186
- indicator.stop();
231
+ indicator.fail('queued');
232
+ printDeployReport({ ...report, status: 'queued' });
187
233
  info(`Queued: ${color.dim(dep.id)}`);
188
234
  info(`Track it with: ${color.cyan(`glash status ${dep.id}`)}`);
189
235
  return;
@@ -203,7 +249,7 @@ export async function deploy({ flags }) {
203
249
  if (e.status === 429) {
204
250
  const wait = Math.min(Math.max(e.retryAfterMs ?? pollDelayMs * 2, 10000), 60000);
205
251
  pollDelayMs = wait;
206
- await sleep(wait);
252
+ await indicator.wait(wait);
207
253
  continue;
208
254
  }
209
255
  if (Date.now() - lastProgressAt > 60_000) {
@@ -211,7 +257,7 @@ export async function deploy({ flags }) {
211
257
  lastProgressAt = Date.now();
212
258
  }
213
259
  pollDelayMs = Math.min(Math.max(pollDelayMs * 1.5, 5000), 30000);
214
- await sleep(pollDelayMs);
260
+ await indicator.wait(pollDelayMs);
215
261
  continue;
216
262
  }
217
263
 
@@ -229,28 +275,34 @@ export async function deploy({ flags }) {
229
275
  const url = pipe.hostings?.[0]?.domains?.[0]?.domain;
230
276
  if (url) {
231
277
  indicator.setPhase('checking');
232
- const problem = await waitForLiveUrlProblem(`https://${url.replace(/^https?:\/\//, '')}`);
233
- indicator.stop();
278
+ report.liveUrl = normalizeLiveUrl(url);
279
+ const problem = await waitForLiveUrlProblem(report.liveUrl);
234
280
  if (problem) {
281
+ indicator.fail('failed');
282
+ printDeployReport({ ...report, status: 'failed', error: `Live URL check failed: ${problem}` });
235
283
  return fail(`Deployment finished, but the hostname is still not serving your app: ${problem}. Run \`glash logs ${dep.id}\` for details.`);
236
284
  }
237
285
  } else {
238
- indicator.stop();
286
+ report.liveUrl = '';
239
287
  }
240
- printDeploySuccess({ url, envSummary });
288
+ indicator.finish('complete');
289
+ printDeployReport({ ...report, status: 'successful' });
290
+ ok('Deployment live');
241
291
  return;
242
292
  }
243
293
  if (status === 'placeholder') {
244
- indicator.stop();
245
294
  const lastPlaceholder = [...(pipe.events ?? [])].reverse().find((ev) => ev.status === 'placeholder' || ev.step === 'deploy');
246
295
  const detail = lastPlaceholder?.message ? `: ${lastPlaceholder.message}` : '';
296
+ indicator.fail('failed');
297
+ printDeployReport({ ...report, status: 'failed', error: `Deployment reached the glashDB parked page, not your app${detail}` });
247
298
  return fail(`Deployment reached the glashDB parked page, not your app${detail}. Run \`glash logs ${dep.id}\` for details.`);
248
299
  }
249
300
  if (['error', 'failed'].includes(status)) {
250
- indicator.stop();
251
301
  const lastError = [...(pipe.events ?? [])].reverse().find((ev) => ev.status === 'error' || ev.status === 'failed');
252
302
  const rawDetail = lastError?.message ? `: ${lastError.message}` : '';
253
303
  const detail = `${rawDetail}${deploymentFailureHint(lastError?.message)}`;
304
+ indicator.fail('failed');
305
+ printDeployReport({ ...report, status: 'failed', error: `Deployment failed (${status})${rawDetail}` });
254
306
  return fail(`Deployment failed (${status})${detail}. Run \`glash logs ${dep.id}\` for details.`);
255
307
  }
256
308
  currentPhase = phaseFromPipeline(pipe, currentPhase);
@@ -258,7 +310,8 @@ export async function deploy({ flags }) {
258
310
  pollDelayMs = nextPollDelay(pipe);
259
311
  await indicator.wait(pollDelayMs);
260
312
  }
261
- indicator.stop();
313
+ indicator.fail('timed out');
314
+ printDeployReport({ ...report, status: 'timed out', error: `Waited ${formatDuration(timeoutMs)} for deployment to finish` });
262
315
  warn(`Timed out after ${formatDuration(timeoutMs)} waiting for deployment. Check \`glash status ${dep.id}\`.`);
263
316
  }
264
317
 
@@ -280,6 +333,163 @@ async function syncEnvBeforeDeploy(projectId, target, flags) {
280
333
  return { count: vars.length, files };
281
334
  }
282
335
 
336
+ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
337
+ const mode = assetMode(flags);
338
+ const empty = { mode, offloaded: false, count: 0, bytes: 0, skipped: 0 };
339
+ if (mode === 'inline') return empty;
340
+
341
+ const files = await publicAssetFiles(process.cwd());
342
+ if (!files.length) return empty;
343
+
344
+ const totalBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0);
345
+ const threshold = assetOffloadThresholdBytes(flags);
346
+ if (mode === 'auto' && totalBytes < threshold) {
347
+ return { ...empty, bytes: totalBytes };
348
+ }
349
+
350
+ const assets = [];
351
+ for (const file of files) {
352
+ assets.push({
353
+ path: file.path,
354
+ sizeBytes: file.sizeBytes,
355
+ sha256: await hashFile(file.absolutePath),
356
+ contentType: contentTypeFor(file.path),
357
+ });
358
+ }
359
+
360
+ let uploaded = 0;
361
+ let skipped = 0;
362
+ for (const batch of chunk(assets, 100)) {
363
+ const prepared = await apiPost(`/projects/${projectId}/public-assets/prepare`, { assets: batch });
364
+ for (const asset of prepared.assets ?? []) {
365
+ if (asset.skipped) {
366
+ skipped += 1;
367
+ continue;
368
+ }
369
+ if (!asset.uploadUrl) throw new Error(`No upload URL returned for ${asset.path}`);
370
+ const local = assets.find((item) => item.path === asset.path);
371
+ if (!local) throw new Error(`Prepared unknown asset ${asset.path}`);
372
+ const blob = await openAsBlob(join(process.cwd(), 'public', asset.path), { type: local.contentType });
373
+ const res = await fetch(asset.uploadUrl, {
374
+ method: asset.method || 'PUT',
375
+ headers: { 'Content-Type': local.contentType },
376
+ body: blob,
377
+ });
378
+ if (!res.ok) {
379
+ const detail = await res.text().catch(() => '');
380
+ throw new Error(`Upload ${asset.path} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
381
+ }
382
+ uploaded += 1;
383
+ }
384
+ await apiPost(`/projects/${projectId}/public-assets/complete`, { assets: batch });
385
+ }
386
+
387
+ return {
388
+ mode,
389
+ offloaded: true,
390
+ count: assets.length,
391
+ uploaded,
392
+ skipped,
393
+ bytes: totalBytes,
394
+ };
395
+ }
396
+
397
+ function assetMode(flags) {
398
+ const raw = String(flags['asset-mode'] ?? flags.assets ?? flags['public-assets'] ?? 'inline').toLowerCase();
399
+ if (['inline', 'offload', 'auto'].includes(raw)) return raw;
400
+ throw new Error(`Unsupported --asset-mode "${raw}". Use inline, auto, or offload.`);
401
+ }
402
+
403
+ async function publicAssetFiles(cwd) {
404
+ const entries = await fg('public/**/*', {
405
+ cwd,
406
+ onlyFiles: true,
407
+ dot: true,
408
+ followSymbolicLinks: false,
409
+ });
410
+ const out = [];
411
+ for (const entry of entries) {
412
+ const absolutePath = join(cwd, entry);
413
+ const s = await stat(absolutePath);
414
+ out.push({
415
+ path: entry.replace(/^public\//, ''),
416
+ absolutePath,
417
+ sizeBytes: s.size,
418
+ });
419
+ }
420
+ return out;
421
+ }
422
+
423
+ function hashFile(file) {
424
+ return new Promise((resolve, reject) => {
425
+ const hash = createHash('sha256');
426
+ const stream = createReadStream(file);
427
+ stream.on('data', (chunk) => hash.update(chunk));
428
+ stream.on('error', reject);
429
+ stream.on('end', () => resolve(hash.digest('hex')));
430
+ });
431
+ }
432
+
433
+ function contentTypeFor(path) {
434
+ const ext = extname(path).toLowerCase();
435
+ const types = {
436
+ '.avif': 'image/avif',
437
+ '.css': 'text/css; charset=utf-8',
438
+ '.gif': 'image/gif',
439
+ '.html': 'text/html; charset=utf-8',
440
+ '.ico': 'image/x-icon',
441
+ '.jpeg': 'image/jpeg',
442
+ '.jpg': 'image/jpeg',
443
+ '.js': 'text/javascript; charset=utf-8',
444
+ '.json': 'application/json',
445
+ '.m4v': 'video/x-m4v',
446
+ '.map': 'application/json',
447
+ '.mp3': 'audio/mpeg',
448
+ '.mp4': 'video/mp4',
449
+ '.ogg': 'audio/ogg',
450
+ '.otf': 'font/otf',
451
+ '.pdf': 'application/pdf',
452
+ '.png': 'image/png',
453
+ '.svg': 'image/svg+xml',
454
+ '.ttf': 'font/ttf',
455
+ '.txt': 'text/plain; charset=utf-8',
456
+ '.wav': 'audio/wav',
457
+ '.webm': 'video/webm',
458
+ '.webp': 'image/webp',
459
+ '.woff': 'font/woff',
460
+ '.woff2': 'font/woff2',
461
+ '.xml': 'application/xml',
462
+ };
463
+ return types[ext] || 'application/octet-stream';
464
+ }
465
+
466
+ function assetOffloadThresholdBytes(flags) {
467
+ return parseByteSize(flags['asset-threshold'] ?? process.env.GLASH_ASSET_OFFLOAD_THRESHOLD ?? '100mb');
468
+ }
469
+
470
+ function parseByteSize(value) {
471
+ if (typeof value === 'number') return value;
472
+ const raw = String(value || '').trim().toLowerCase();
473
+ const match = raw.match(/^(\d+(?:\.\d+)?)\s*(b|kb|kib|mb|mib|gb|gib)?$/);
474
+ if (!match) throw new Error(`Invalid asset threshold "${value}"`);
475
+ const amount = Number(match[1]);
476
+ const unit = match[2] || 'b';
477
+ const multiplier = unit === 'gb' || unit === 'gib'
478
+ ? 1024 ** 3
479
+ : unit === 'mb' || unit === 'mib'
480
+ ? 1024 ** 2
481
+ : unit === 'kb' || unit === 'kib'
482
+ ? 1024
483
+ : 1;
484
+ return Math.round(amount * multiplier);
485
+ }
486
+
487
+ function chunk(items, size) {
488
+ const out = [];
489
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
490
+ return out;
491
+ }
492
+
283
493
  async function resolveEnvFiles(target, flags) {
284
494
  if (flags['env-file']) {
285
495
  const files = String(flags['env-file']).split(',').map((f) => f.trim()).filter(Boolean);
@@ -416,12 +626,86 @@ function isTransientLiveUrlProblem(problem) {
416
626
  return /no nginx route|parked page|HTTP 5\d\d|live URL check failed/i.test(problem);
417
627
  }
418
628
 
419
- function printDeploySuccess({ url, envSummary }) {
420
- const liveUrl = url ? `https://${url.replace(/^https?:\/\//, '')}` : '';
421
- if (liveUrl) info(`Live: ${terminalLink(liveUrl)}`);
422
- const envText = envSummary?.skipped ? 'skipped' : `${envSummary?.count ?? 0} moved to glash`;
423
- info(`Env: ${envText}`);
424
- ok('Success');
629
+ function normalizeLiveUrl(url) {
630
+ return url ? `https://${String(url).replace(/^https?:\/\//, '')}` : '';
631
+ }
632
+
633
+ function printDeployReport(report) {
634
+ const elapsedMs = Date.now() - report.start;
635
+ const liveUrl = report.liveUrl ? terminalLink(report.liveUrl) : '-';
636
+ const rows = [
637
+ ['Status', formatReportStatus(report.status)],
638
+ ['Deployment ID', report.deploymentId || '-'],
639
+ ['Target', report.target || 'production'],
640
+ ['Source', report.source || '-'],
641
+ ['Packages uploaded', formatPackagesUploaded(report)],
642
+ ['Public assets', formatAssetSummary(report.assetSummary)],
643
+ ['Deployment size', formatDeploymentSize(report)],
644
+ ['Total time spent', formatMinutes(elapsedMs)],
645
+ ['Live URL', liveUrl],
646
+ ['Environment', formatEnvSummary(report.envSummary)],
647
+ ];
648
+ if (report.error) rows.push(['Error', report.error]);
649
+
650
+ const width = rows.reduce((max, [label]) => Math.max(max, label.length), 0);
651
+ info('');
652
+ info(color.bold('Deployment report'));
653
+ for (const [label, value] of rows) {
654
+ info(` ${color.dim(label.padEnd(width))} ${value}`);
655
+ }
656
+ }
657
+
658
+ function formatReportStatus(status) {
659
+ if (status === 'successful') return color.green('successful');
660
+ if (status === 'queued') return color.yellow('queued');
661
+ if (status === 'timed out') return color.yellow('timed out');
662
+ if (status === 'failed') return color.red('failed');
663
+ return status || '-';
664
+ }
665
+
666
+ function formatPackagesUploaded(report) {
667
+ const count = report.packagesUploaded ?? 0;
668
+ const label = count === 1 ? 'source bundle' : 'source bundles';
669
+ const files = report.filesIncluded
670
+ ? ` (${report.filesIncluded.toLocaleString()} files included)`
671
+ : (report.source === 'remote git deploy' ? ' (remote source)' : '');
672
+ return `${count.toLocaleString()} ${label}${files}`;
673
+ }
674
+
675
+ function formatAssetSummary(assetSummary) {
676
+ if (!assetSummary || assetSummary.mode === 'inline') return 'inline';
677
+ if (!assetSummary.offloaded) {
678
+ const size = assetSummary.bytes ? ` (${formatBytes(assetSummary.bytes)})` : '';
679
+ return `${assetSummary.mode}, no offload${size}`;
680
+ }
681
+ const uploaded = assetSummary.uploaded ?? Math.max(0, (assetSummary.count ?? 0) - (assetSummary.skipped ?? 0));
682
+ const skipped = assetSummary.skipped ? `, ${assetSummary.skipped.toLocaleString()} reused` : '';
683
+ return `${assetSummary.count.toLocaleString()} offloaded (${formatBytes(assetSummary.bytes)}; ${uploaded.toLocaleString()} uploaded${skipped})`;
684
+ }
685
+
686
+ function formatDeploymentSize(report) {
687
+ if (report.deploymentSizeBytes) return formatBytes(report.deploymentSizeBytes);
688
+ return report.source === 'remote git deploy' ? 'not measured for remote deploy' : '-';
689
+ }
690
+
691
+ function formatEnvSummary(envSummary) {
692
+ if (envSummary?.skipped) return 'skipped';
693
+ const count = envSummary?.count ?? 0;
694
+ return `${count.toLocaleString()} variable${count === 1 ? '' : 's'} synced`;
695
+ }
696
+
697
+ function formatMinutes(ms) {
698
+ const minutes = ms / 60_000;
699
+ const rounded = minutes < 10 ? minutes.toFixed(2) : minutes.toFixed(1);
700
+ return `${rounded} min (${formatDuration(ms)})`;
701
+ }
702
+
703
+ function formatBytes(bytes) {
704
+ if (!Number.isFinite(bytes) || bytes <= 0) return '-';
705
+ if (bytes < 1024) return `${bytes} B`;
706
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
707
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
708
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
425
709
  }
426
710
 
427
711
  function terminalLink(url) {
@@ -432,42 +716,71 @@ function terminalLink(url) {
432
716
 
433
717
  function createDeployIndicator() {
434
718
  const interactive = Boolean(stdout.isTTY);
435
- let frame = 0;
436
719
  let timer;
437
720
  let phase = 'doing';
438
- let printedPhase = '';
439
- const frames = ['.', '..', '...'];
721
+ let progress = 0;
722
+ let lastPrinted = '';
440
723
 
441
724
  function message() {
442
- const dots = frames[frame++ % frames.length].padEnd(3, ' ');
443
- return color.dim(`${phase}${dots}`);
725
+ const width = 24;
726
+ const filled = Math.round((progress / 100) * width);
727
+ const bar = `${'#'.repeat(filled)}${'-'.repeat(width - filled)}`;
728
+ const percent = `${String(Math.round(progress)).padStart(3, ' ')}%`;
729
+ return `${color.dim(`[${bar}]`)} ${percent} ${phase}`;
444
730
  }
445
731
 
446
732
  function render({ force = false } = {}) {
733
+ const next = message();
447
734
  if (!interactive) {
448
- if (force && phase !== printedPhase) {
449
- printedPhase = phase;
450
- info(message().trimEnd());
735
+ if (force && next !== lastPrinted) {
736
+ lastPrinted = next;
737
+ info(next);
451
738
  }
452
739
  return;
453
740
  }
454
- stdout.write(`\r\x1b[2K${message()}`);
741
+ stdout.write(`\r\x1b[2K${next}`);
742
+ }
743
+
744
+ function stopTimer() {
745
+ if (timer) clearInterval(timer);
746
+ timer = undefined;
747
+ }
748
+
749
+ function setProgress(nextProgress, nextPhase = phase) {
750
+ const bounded = Math.max(0, Math.min(100, Number(nextProgress) || 0));
751
+ const advanced = Math.max(progress, bounded);
752
+ const changed = nextPhase !== phase || advanced !== progress;
753
+ phase = nextPhase;
754
+ progress = advanced;
755
+ if (changed) render({ force: true });
455
756
  }
456
757
 
457
758
  return {
458
759
  start(initialPhase = 'doing') {
459
760
  phase = initialPhase;
460
- render();
761
+ progress = 0;
762
+ render({ force: true });
461
763
  timer = setInterval(render, 1000);
462
764
  },
463
765
  setPhase(nextPhase) {
464
- if (!nextPhase || nextPhase === phase) return;
465
- phase = nextPhase;
766
+ if (!nextPhase) return;
767
+ setProgress(progressFromPhase(nextPhase), nextPhase);
768
+ },
769
+ finish(finalPhase = 'complete') {
770
+ stopTimer();
771
+ phase = finalPhase;
772
+ progress = 100;
466
773
  render({ force: true });
774
+ if (interactive) stdout.write('\n');
775
+ },
776
+ fail(finalPhase = 'failed') {
777
+ stopTimer();
778
+ phase = finalPhase;
779
+ render({ force: true });
780
+ if (interactive) stdout.write('\n');
467
781
  },
468
782
  stop() {
469
- if (timer) clearInterval(timer);
470
- timer = undefined;
783
+ stopTimer();
471
784
  if (interactive) stdout.write('\r\x1b[2K');
472
785
  },
473
786
  wait(ms) {
@@ -476,4 +789,21 @@ function createDeployIndicator() {
476
789
  };
477
790
  }
478
791
 
792
+ function progressFromPhase(phase) {
793
+ const progressByPhase = {
794
+ doing: 2,
795
+ syncing: 8,
796
+ assets: 15,
797
+ packaging: 22,
798
+ glashing: 35,
799
+ hatching: 45,
800
+ waiting: 48,
801
+ building: 65,
802
+ deploying: 82,
803
+ routing: 90,
804
+ checking: 96,
805
+ };
806
+ return progressByPhase[phase] ?? 50;
807
+ }
808
+
479
809
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
package/lib/zip.mjs CHANGED
@@ -76,7 +76,7 @@ export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
76
76
  const output = createWriteStream(outPath);
77
77
  let bytes = 0;
78
78
 
79
- output.on('close', () => resolve(bytes));
79
+ output.on('close', () => resolve({ bytes, fileCount: files.length, files }));
80
80
  output.on('error', reject);
81
81
  zip.outputStream.on('data', (chunk) => { bytes += chunk.length; });
82
82
  zip.outputStream.on('error', reject);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glash/cli",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
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",