@glash/cli 0.2.28 → 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,6 +137,7 @@ 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();
@@ -148,6 +151,7 @@ export async function deploy({ flags }) {
148
151
  filesIncluded: 0,
149
152
  deploymentSizeBytes: 0,
150
153
  envSummary,
154
+ assetSummary,
151
155
  liveUrl: '',
152
156
  error: '',
153
157
  start,
@@ -166,12 +170,29 @@ export async function deploy({ flags }) {
166
170
  return fail(`Env sync failed: ${e.message}`);
167
171
  }
168
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
+ }
169
190
 
170
191
  if (!remoteDeploy) {
171
192
  indicator.setPhase('packaging');
172
193
  const zipPath = join(tmpdir(), `glash-deploy-${Date.now()}.zip`);
173
194
  try {
174
- const bundle = await zipDirectory(process.cwd(), zipPath);
195
+ const bundle = await zipDirectory(process.cwd(), zipPath, { ignore: assetSummary.offloaded ? ['public/**'] : [] });
175
196
  const zipStat = await stat(zipPath);
176
197
  report.filesIncluded = bundle.fileCount ?? 0;
177
198
  report.deploymentSizeBytes = zipStat.size || bundle.bytes || 0;
@@ -312,6 +333,163 @@ async function syncEnvBeforeDeploy(projectId, target, flags) {
312
333
  return { count: vars.length, files };
313
334
  }
314
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
+
315
493
  async function resolveEnvFiles(target, flags) {
316
494
  if (flags['env-file']) {
317
495
  const files = String(flags['env-file']).split(',').map((f) => f.trim()).filter(Boolean);
@@ -461,6 +639,7 @@ function printDeployReport(report) {
461
639
  ['Target', report.target || 'production'],
462
640
  ['Source', report.source || '-'],
463
641
  ['Packages uploaded', formatPackagesUploaded(report)],
642
+ ['Public assets', formatAssetSummary(report.assetSummary)],
464
643
  ['Deployment size', formatDeploymentSize(report)],
465
644
  ['Total time spent', formatMinutes(elapsedMs)],
466
645
  ['Live URL', liveUrl],
@@ -493,6 +672,17 @@ function formatPackagesUploaded(report) {
493
672
  return `${count.toLocaleString()} ${label}${files}`;
494
673
  }
495
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
+
496
686
  function formatDeploymentSize(report) {
497
687
  if (report.deploymentSizeBytes) return formatBytes(report.deploymentSizeBytes);
498
688
  return report.source === 'remote git deploy' ? 'not measured for remote deploy' : '-';
@@ -603,6 +793,7 @@ function progressFromPhase(phase) {
603
793
  const progressByPhase = {
604
794
  doing: 2,
605
795
  syncing: 8,
796
+ assets: 15,
606
797
  packaging: 22,
607
798
  glashing: 35,
608
799
  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.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",