@glash/cli 0.2.28 → 0.2.30

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
 
@@ -132,9 +134,11 @@ export async function deploy({ flags }) {
132
134
  const commitHash = flags.commit || (remoteDeploy ? undefined : currentCommit());
133
135
  const gitBranch = flags.branch || currentBranch();
134
136
  const target = flags.prod || flags.production ? 'production' : (flags.target || 'production');
137
+ const requestedAssetMode = assetMode(flags);
135
138
 
136
139
  let dep;
137
140
  let envSummary = { count: 0, skipped: Boolean(flags['no-env']) };
141
+ let assetSummary = { mode: requestedAssetMode, status: requestedAssetMode === 'inline' ? 'inline' : 'pending', offloaded: false, count: 0, bytes: 0, skipped: 0 };
138
142
  let currentPhase = 'doing';
139
143
  const start = Date.now();
140
144
  const indicator = createDeployIndicator();
@@ -148,6 +152,7 @@ export async function deploy({ flags }) {
148
152
  filesIncluded: 0,
149
153
  deploymentSizeBytes: 0,
150
154
  envSummary,
155
+ assetSummary,
151
156
  liveUrl: '',
152
157
  error: '',
153
158
  start,
@@ -166,12 +171,32 @@ export async function deploy({ flags }) {
166
171
  return fail(`Env sync failed: ${e.message}`);
167
172
  }
168
173
  }
174
+
175
+ if (!remoteDeploy && assetSummary.mode !== 'inline') {
176
+ indicator.setPhase('assets');
177
+ try {
178
+ assetSummary = await offloadPublicAssetsBeforeDeploy(projectId, flags);
179
+ report.assetSummary = assetSummary;
180
+ } catch (e) {
181
+ const failedAssetSummary = e.assetSummary ?? { ...assetSummary, status: 'failed', error: e.message };
182
+ assetSummary = failedAssetSummary;
183
+ report.assetSummary = failedAssetSummary;
184
+ indicator.fail('failed');
185
+ printDeployReport({ ...report, assetSummary: failedAssetSummary, status: 'failed', error: `Public asset offload failed: ${e.message}` });
186
+ return fail(`Public asset offload failed: ${e.message}`);
187
+ }
188
+ } else if (assetSummary.mode !== 'inline') {
189
+ indicator.fail('failed');
190
+ const message = '--asset-mode is only available for local source uploads, not remote git deploys.';
191
+ printDeployReport({ ...report, status: 'failed', error: message });
192
+ return fail(message);
193
+ }
169
194
 
170
195
  if (!remoteDeploy) {
171
196
  indicator.setPhase('packaging');
172
197
  const zipPath = join(tmpdir(), `glash-deploy-${Date.now()}.zip`);
173
198
  try {
174
- const bundle = await zipDirectory(process.cwd(), zipPath);
199
+ const bundle = await zipDirectory(process.cwd(), zipPath, { ignore: assetSummary.offloaded ? ['public/**'] : [] });
175
200
  const zipStat = await stat(zipPath);
176
201
  report.filesIncluded = bundle.fileCount ?? 0;
177
202
  report.deploymentSizeBytes = zipStat.size || bundle.bytes || 0;
@@ -312,6 +337,196 @@ async function syncEnvBeforeDeploy(projectId, target, flags) {
312
337
  return { count: vars.length, files };
313
338
  }
314
339
 
340
+ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
341
+ const mode = assetMode(flags);
342
+ const empty = { mode, status: mode === 'inline' ? 'inline' : 'pending', offloaded: false, count: 0, bytes: 0, skipped: 0 };
343
+ if (mode === 'inline') return empty;
344
+
345
+ const files = await publicAssetFiles(process.cwd());
346
+ if (!files.length) return { ...empty, status: 'no-public-assets' };
347
+
348
+ const totalBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0);
349
+ const threshold = assetOffloadThresholdBytes(flags);
350
+ if (mode === 'auto' && totalBytes < threshold) {
351
+ return { ...empty, status: 'below-threshold', count: files.length, bytes: totalBytes, thresholdBytes: threshold };
352
+ }
353
+
354
+ const assets = [];
355
+ for (const file of files) {
356
+ assets.push({
357
+ path: file.path,
358
+ sizeBytes: file.sizeBytes,
359
+ sha256: await hashFile(file.absolutePath),
360
+ contentType: contentTypeFor(file.path),
361
+ });
362
+ }
363
+
364
+ let uploaded = 0;
365
+ let skipped = 0;
366
+ for (const batch of chunk(assets, 100)) {
367
+ let prepared;
368
+ try {
369
+ prepared = await apiPost(`/projects/${projectId}/public-assets/prepare`, { assets: batch });
370
+ } catch (e) {
371
+ throw assetOffloadFailure(e, { mode, status: 'prepare-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes });
372
+ }
373
+ for (const asset of prepared.assets ?? []) {
374
+ if (asset.skipped) {
375
+ skipped += 1;
376
+ continue;
377
+ }
378
+ if (!asset.uploadUrl) throw new Error(`No upload URL returned for ${asset.path}`);
379
+ const local = assets.find((item) => item.path === asset.path);
380
+ 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(() => '');
389
+ throw assetOffloadFailure(
390
+ new Error(`Upload ${asset.path} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`),
391
+ { mode, status: 'upload-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes },
392
+ );
393
+ }
394
+ uploaded += 1;
395
+ }
396
+ try {
397
+ await apiPost(`/projects/${projectId}/public-assets/complete`, { assets: batch });
398
+ } catch (e) {
399
+ throw assetOffloadFailure(e, { mode, status: 'complete-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes });
400
+ }
401
+ }
402
+
403
+ return {
404
+ mode,
405
+ status: 'offloaded',
406
+ offloaded: true,
407
+ count: assets.length,
408
+ uploaded,
409
+ skipped,
410
+ bytes: totalBytes,
411
+ };
412
+ }
413
+
414
+ function assetOffloadFailure(error, assetSummary) {
415
+ const message = friendlyAssetOffloadError(error);
416
+ const wrapped = new Error(message);
417
+ wrapped.cause = error;
418
+ wrapped.status = error?.status;
419
+ wrapped.assetSummary = { ...assetSummary, error: message };
420
+ return wrapped;
421
+ }
422
+
423
+ function friendlyAssetOffloadError(error) {
424
+ const message = error?.message || String(error);
425
+ if (error?.status === 404 && /public-assets\/prepare|public-assets/i.test(message)) {
426
+ return 'This GlashDB API does not support public asset offload yet. Deploy the backend public-assets route and router update, then retry.';
427
+ }
428
+ if (error?.status === 503 && /Public asset storage is not configured|storage is not configured/i.test(message)) {
429
+ return 'Public asset storage is not configured on this GlashDB API. Set the R2 asset storage secrets, redeploy the backend, then retry.';
430
+ }
431
+ return message;
432
+ }
433
+
434
+ function assetMode(flags) {
435
+ const raw = String(flags['asset-mode'] ?? flags.assets ?? flags['public-assets'] ?? 'inline').toLowerCase();
436
+ if (['inline', 'offload', 'auto'].includes(raw)) return raw;
437
+ throw new Error(`Unsupported --asset-mode "${raw}". Use inline, auto, or offload.`);
438
+ }
439
+
440
+ async function publicAssetFiles(cwd) {
441
+ const entries = await fg('public/**/*', {
442
+ cwd,
443
+ onlyFiles: true,
444
+ dot: true,
445
+ followSymbolicLinks: false,
446
+ });
447
+ const out = [];
448
+ for (const entry of entries) {
449
+ const absolutePath = join(cwd, entry);
450
+ const s = await stat(absolutePath);
451
+ out.push({
452
+ path: entry.replace(/^public\//, ''),
453
+ absolutePath,
454
+ sizeBytes: s.size,
455
+ });
456
+ }
457
+ return out;
458
+ }
459
+
460
+ function hashFile(file) {
461
+ return new Promise((resolve, reject) => {
462
+ const hash = createHash('sha256');
463
+ const stream = createReadStream(file);
464
+ stream.on('data', (chunk) => hash.update(chunk));
465
+ stream.on('error', reject);
466
+ stream.on('end', () => resolve(hash.digest('hex')));
467
+ });
468
+ }
469
+
470
+ function contentTypeFor(path) {
471
+ const ext = extname(path).toLowerCase();
472
+ const types = {
473
+ '.avif': 'image/avif',
474
+ '.css': 'text/css; charset=utf-8',
475
+ '.gif': 'image/gif',
476
+ '.html': 'text/html; charset=utf-8',
477
+ '.ico': 'image/x-icon',
478
+ '.jpeg': 'image/jpeg',
479
+ '.jpg': 'image/jpeg',
480
+ '.js': 'text/javascript; charset=utf-8',
481
+ '.json': 'application/json',
482
+ '.m4v': 'video/x-m4v',
483
+ '.map': 'application/json',
484
+ '.mp3': 'audio/mpeg',
485
+ '.mp4': 'video/mp4',
486
+ '.ogg': 'audio/ogg',
487
+ '.otf': 'font/otf',
488
+ '.pdf': 'application/pdf',
489
+ '.png': 'image/png',
490
+ '.svg': 'image/svg+xml',
491
+ '.ttf': 'font/ttf',
492
+ '.txt': 'text/plain; charset=utf-8',
493
+ '.wav': 'audio/wav',
494
+ '.webm': 'video/webm',
495
+ '.webp': 'image/webp',
496
+ '.woff': 'font/woff',
497
+ '.woff2': 'font/woff2',
498
+ '.xml': 'application/xml',
499
+ };
500
+ return types[ext] || 'application/octet-stream';
501
+ }
502
+
503
+ function assetOffloadThresholdBytes(flags) {
504
+ return parseByteSize(flags['asset-threshold'] ?? process.env.GLASH_ASSET_OFFLOAD_THRESHOLD ?? '100mb');
505
+ }
506
+
507
+ function parseByteSize(value) {
508
+ if (typeof value === 'number') return value;
509
+ const raw = String(value || '').trim().toLowerCase();
510
+ const match = raw.match(/^(\d+(?:\.\d+)?)\s*(b|kb|kib|mb|mib|gb|gib)?$/);
511
+ if (!match) throw new Error(`Invalid asset threshold "${value}"`);
512
+ const amount = Number(match[1]);
513
+ const unit = match[2] || 'b';
514
+ const multiplier = unit === 'gb' || unit === 'gib'
515
+ ? 1024 ** 3
516
+ : unit === 'mb' || unit === 'mib'
517
+ ? 1024 ** 2
518
+ : unit === 'kb' || unit === 'kib'
519
+ ? 1024
520
+ : 1;
521
+ return Math.round(amount * multiplier);
522
+ }
523
+
524
+ function chunk(items, size) {
525
+ const out = [];
526
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
527
+ return out;
528
+ }
529
+
315
530
  async function resolveEnvFiles(target, flags) {
316
531
  if (flags['env-file']) {
317
532
  const files = String(flags['env-file']).split(',').map((f) => f.trim()).filter(Boolean);
@@ -457,10 +672,12 @@ function printDeployReport(report) {
457
672
  const liveUrl = report.liveUrl ? terminalLink(report.liveUrl) : '-';
458
673
  const rows = [
459
674
  ['Status', formatReportStatus(report.status)],
675
+ ['Project ID', report.projectId || '-'],
460
676
  ['Deployment ID', report.deploymentId || '-'],
461
677
  ['Target', report.target || 'production'],
462
678
  ['Source', report.source || '-'],
463
679
  ['Packages uploaded', formatPackagesUploaded(report)],
680
+ ['Public assets', formatAssetSummary(report.assetSummary)],
464
681
  ['Deployment size', formatDeploymentSize(report)],
465
682
  ['Total time spent', formatMinutes(elapsedMs)],
466
683
  ['Live URL', liveUrl],
@@ -493,6 +710,32 @@ function formatPackagesUploaded(report) {
493
710
  return `${count.toLocaleString()} ${label}${files}`;
494
711
  }
495
712
 
713
+ function formatAssetSummary(assetSummary) {
714
+ if (!assetSummary || assetSummary.mode === 'inline') return 'inline (bundled with source)';
715
+ if (assetSummary.status === 'pending') return `${assetSummary.mode} requested`;
716
+ if (assetSummary.status === 'no-public-assets') return `${assetSummary.mode}, no public assets found`;
717
+ if (assetSummary.status === 'below-threshold') {
718
+ return `auto kept inline (${formatBytes(assetSummary.bytes)} below ${formatBytes(assetSummary.thresholdBytes)})`;
719
+ }
720
+ if (assetSummary.status === 'prepare-failed') {
721
+ return `prepare failed (${(assetSummary.count ?? 0).toLocaleString()} assets, ${formatBytes(assetSummary.bytes)})`;
722
+ }
723
+ if (assetSummary.status === 'upload-failed') {
724
+ return `upload failed (${(assetSummary.uploaded ?? 0).toLocaleString()}/${(assetSummary.count ?? 0).toLocaleString()} uploaded, ${formatBytes(assetSummary.bytes)})`;
725
+ }
726
+ if (assetSummary.status === 'complete-failed') {
727
+ return `activation failed (${(assetSummary.uploaded ?? 0).toLocaleString()} uploaded, ${formatBytes(assetSummary.bytes)})`;
728
+ }
729
+ if (assetSummary.status === 'failed') return `failed${assetSummary.error ? ` (${assetSummary.error})` : ''}`;
730
+ if (!assetSummary.offloaded) {
731
+ const size = assetSummary.bytes ? ` (${formatBytes(assetSummary.bytes)})` : '';
732
+ return `${assetSummary.mode}, no offload${size}`;
733
+ }
734
+ const uploaded = assetSummary.uploaded ?? Math.max(0, (assetSummary.count ?? 0) - (assetSummary.skipped ?? 0));
735
+ const skipped = assetSummary.skipped ? `, ${assetSummary.skipped.toLocaleString()} reused` : '';
736
+ return `${assetSummary.count.toLocaleString()} assets offloaded (${formatBytes(assetSummary.bytes)}; ${uploaded.toLocaleString()} uploaded${skipped})`;
737
+ }
738
+
496
739
  function formatDeploymentSize(report) {
497
740
  if (report.deploymentSizeBytes) return formatBytes(report.deploymentSizeBytes);
498
741
  return report.source === 'remote git deploy' ? 'not measured for remote deploy' : '-';
@@ -603,6 +846,7 @@ function progressFromPhase(phase) {
603
846
  const progressByPhase = {
604
847
  doing: 2,
605
848
  syncing: 8,
849
+ assets: 15,
606
850
  packaging: 22,
607
851
  glashing: 35,
608
852
  hatching: 45,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glash/cli",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
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",