@glash/cli 0.2.31 → 0.2.33

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
@@ -68,6 +68,7 @@ Common Node and frontend targets:
68
68
 
69
69
  | Framework or runtime | Notes |
70
70
  | --- | --- |
71
+ | glashjs | Native glashDB framework support; the CLI detects `glashjs`/`glash.config.*` and packages build/start scripts when missing |
71
72
  | Next.js | Built and started with the Next runtime when no custom start script is present |
72
73
  | React | Works with Vite, Create React App, Gatsby, Remix, and custom React servers |
73
74
  | Vue | Works with Vue apps and Nuxt projects |
@@ -93,6 +94,7 @@ Deployment checklist:
93
94
  - Set required secrets with `glash env:add` or the dashboard before deploying.
94
95
  - Make sure the app listens on the expected runtime port.
95
96
  - For large `public/` folders, deploy with `--asset-mode offload` to upload public assets to glashDB object storage/CDN and keep the source bundle small.
97
+ - Source bundles over 90 MB automatically use chunked source upload, keeping each request below Cloudflare Free's 100 MB upload limit.
96
98
 
97
99
  Large public folders:
98
100
 
@@ -105,6 +107,22 @@ glash deploy --asset-mode auto --asset-threshold 100mb
105
107
  ```
106
108
 
107
109
  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.
110
+ 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.
111
+
112
+ Large source bundles:
113
+
114
+ ```bash
115
+ # Default: direct upload below 90 MB, chunked upload above it
116
+ glash deploy
117
+
118
+ # Force chunked upload even for a smaller bundle
119
+ glash deploy --source-upload chunked
120
+
121
+ # Tune the auto-chunk threshold
122
+ glash deploy --source-threshold 75mb
123
+ ```
124
+
125
+ For glashjs projects, `glash deploy` detects `glashjs` or `glash.config.mjs`, marks the project as `glashjs`, keeps deploy-safe `.glash` output, and adds missing `build` / `start` scripts inside the deployment bundle without editing your local `package.json`.
108
126
 
109
127
  ## Commands
110
128
 
@@ -122,6 +140,7 @@ Every deploy prints a live monitor link. Open it to watch the black-screen deplo
122
140
  | `glash open` | Open the linked project's primary domain |
123
141
  | `glash deploy [--prod] [--detach]` | Deploy the linked project; streams the pipeline until live |
124
142
  | `glash deploy --asset-mode offload` | Upload `public/` assets to object storage/CDN before zipping source |
143
+ | `glash deploy --source-upload chunked` | Stage the source zip through multipart object storage to bypass Cloudflare's single-request cap |
125
144
  | `glash deployments` | List recent deployments |
126
145
  | `glash status <id>` | Show a deployment's pipeline + events |
127
146
  | `glash logs <id>` | Alias of `status` |
@@ -179,6 +198,9 @@ The commands require PostgreSQL client tools locally: `pg_dump` for dumps,
179
198
  | `GLASH_APP_URL` | `https://glashdb.com` | Override the dashboard base used for live monitor links |
180
199
  | `GLASH_TOKEN` | — | Use this token instead of `~/.glash/credentials.json` (useful in CI) |
181
200
  | `GLASH_ASSET_OFFLOAD_THRESHOLD` | `100mb` | Default threshold for `glash deploy --asset-mode auto` |
201
+ | `GLASH_ASSET_BATCH_SIZE` | `50` | Number of public assets prepared per request during `--asset-mode offload` |
202
+ | `GLASH_SOURCE_UPLOAD_MODE` | `auto` | Source upload mode: `auto`, `direct`, or `chunked` |
203
+ | `GLASH_SOURCE_UPLOAD_THRESHOLD` | `90mb` | Default threshold for automatic chunked source upload |
182
204
 
183
205
  ## Requirements
184
206
 
package/bin/glash.mjs CHANGED
@@ -100,6 +100,9 @@ ${color.bold('FLAGS')}
100
100
  --no-env Skip automatic env sync during deploy
101
101
  --asset-mode <mode> Public asset handling: inline, auto, offload
102
102
  --asset-threshold <size> Auto-offload threshold (default: 100mb)
103
+ --source-upload <mode> Source upload: auto, direct, chunked
104
+ --source-threshold <size> Auto-chunk source threshold (default: 90mb)
105
+ --framework <name> Override framework detection (glashjs, next, vite, etc.)
103
106
  --help, -h Show this help
104
107
  --version, -v Show CLI version
105
108
 
@@ -118,9 +118,10 @@ async function createNewProjectFromCurrentDirectory() {
118
118
  const dirName = basename(process.cwd());
119
119
  const name = (await prompt(`What's your project's name? [${dirName}] `)) || dirName;
120
120
  const slug = name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
121
+ const profile = await detectProjectProfile(process.cwd());
121
122
 
122
123
  info(color.dim(`Creating project "${name}"...`));
123
- const p = await apiPost('/projects', { name, slug });
124
+ const p = await apiPost('/projects', { name, slug, framework: profile.framework });
124
125
  await writeProjectLink({ projectId: p.id, slug: p.slug, name: p.name });
125
126
  ok(`Created and linked to ${p.name} (${p.slug})`);
126
127
  return p.id;
@@ -135,6 +136,7 @@ export async function deploy({ flags }) {
135
136
  const gitBranch = flags.branch || currentBranch();
136
137
  const target = flags.prod || flags.production ? 'production' : (flags.target || 'production');
137
138
  const requestedAssetMode = assetMode(flags);
139
+ const projectProfile = remoteDeploy ? { framework: frameworkHint(flags), label: frameworkHint(flags) || 'auto' } : await detectProjectProfile(process.cwd(), flags);
138
140
  const monitorStartedAt = Date.now();
139
141
  const initialStatusUrl = deployMonitorUrl({ projectId, after: monitorStartedAt });
140
142
 
@@ -150,6 +152,8 @@ export async function deploy({ flags }) {
150
152
  deploymentId: '',
151
153
  target,
152
154
  source: remoteDeploy ? 'remote git deploy' : 'local upload',
155
+ framework: projectProfile.framework,
156
+ sourceUpload: { mode: remoteDeploy ? 'remote' : 'pending', parts: 0 },
153
157
  packagesUploaded: 0,
154
158
  filesIncluded: 0,
155
159
  deploymentSizeBytes: 0,
@@ -200,23 +204,45 @@ export async function deploy({ flags }) {
200
204
  indicator.setPhase('packaging');
201
205
  const zipPath = join(tmpdir(), `glash-deploy-${Date.now()}.zip`);
202
206
  try {
203
- const bundle = await zipDirectory(process.cwd(), zipPath, { ignore: assetSummary.offloaded ? ['public/**'] : [] });
207
+ const packageOverrides = await packageOverridesForProfile(process.cwd(), projectProfile);
208
+ const bundle = await zipDirectory(process.cwd(), zipPath, {
209
+ ignore: assetSummary.offloaded ? ['public/**'] : [],
210
+ includeGlashOutput: projectProfile.framework === 'glashjs',
211
+ overrides: packageOverrides,
212
+ });
204
213
  const zipStat = await stat(zipPath);
205
214
  report.filesIncluded = bundle.fileCount ?? 0;
206
215
  report.deploymentSizeBytes = zipStat.size || bundle.bytes || 0;
207
- const formData = new FormData();
208
- formData.append('projectId', projectId);
209
- formData.append('file', await openAsBlob(zipPath, { type: 'application/zip' }), 'project.zip');
210
-
211
- formData.append('commitHash', commitHash || 'local-upload');
212
- formData.append('gitBranch', gitBranch || '');
213
- formData.append('target', target);
214
-
215
- indicator.setPhase('glashing');
216
- dep = await apiPost('/deployments/upload', formData);
216
+ const uploadMeta = {
217
+ commitHash: commitHash || 'local-upload',
218
+ gitBranch: gitBranch || '',
219
+ target,
220
+ framework: projectProfile.framework,
221
+ };
222
+
223
+ if (shouldUseChunkedSourceUpload(zipStat.size, flags)) {
224
+ indicator.setPhase('uploading');
225
+ const staged = await uploadSourceBundleStaged(projectId, zipPath, zipStat, uploadMeta, indicator);
226
+ dep = staged.deployment;
227
+ report.source = 'local upload (chunked)';
228
+ report.sourceUpload = { mode: 'chunked', parts: staged.partsUploaded, sha256: staged.sha256 };
229
+ report.packagesUploaded = staged.partsUploaded;
230
+ } else {
231
+ const formData = new FormData();
232
+ formData.append('projectId', projectId);
233
+ formData.append('file', await openAsBlob(zipPath, { type: 'application/zip' }), 'project.zip');
234
+ formData.append('commitHash', uploadMeta.commitHash);
235
+ formData.append('gitBranch', uploadMeta.gitBranch);
236
+ formData.append('target', uploadMeta.target);
237
+ if (uploadMeta.framework) formData.append('framework', uploadMeta.framework);
238
+
239
+ indicator.setPhase('glashing');
240
+ dep = await apiPost('/deployments/upload', formData);
241
+ report.sourceUpload = { mode: 'direct', parts: 1 };
242
+ report.packagesUploaded = 1;
243
+ }
217
244
  report.deploymentId = dep?.id ?? '';
218
245
  report.statusUrl = deployMonitorUrl({ deploymentId: dep?.id, projectId, after: monitorStartedAt });
219
- report.packagesUploaded = 1;
220
246
  await rm(zipPath, { force: true }).catch(() => {});
221
247
  } catch (e) {
222
248
  await rm(zipPath, { force: true }).catch(() => {});
@@ -227,7 +253,7 @@ export async function deploy({ flags }) {
227
253
  } else {
228
254
  indicator.setPhase('hatching');
229
255
  try {
230
- dep = await apiPost('/deployments', { projectId, commitHash, gitBranch, target });
256
+ dep = await apiPost('/deployments', { projectId, commitHash, gitBranch, target, framework: projectProfile.framework });
231
257
  report.deploymentId = dep?.id ?? '';
232
258
  report.statusUrl = deployMonitorUrl({ deploymentId: dep?.id, projectId, after: monitorStartedAt });
233
259
  } catch (e) {
@@ -370,10 +396,10 @@ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
370
396
 
371
397
  let uploaded = 0;
372
398
  let skipped = 0;
373
- for (const batch of chunk(assets, 100)) {
399
+ for (const batch of chunk(assets, publicAssetBatchSize(flags))) {
374
400
  let prepared;
375
401
  try {
376
- prepared = await apiPost(`/projects/${projectId}/public-assets/prepare`, { assets: batch });
402
+ prepared = await apiPost(`/projects/${projectId}/public-assets/prepare`, { assets: batch }, { timeoutMs: 120_000 });
377
403
  } catch (e) {
378
404
  throw assetOffloadFailure(e, { mode, status: 'prepare-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes });
379
405
  }
@@ -382,26 +408,20 @@ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
382
408
  skipped += 1;
383
409
  continue;
384
410
  }
385
- if (!asset.uploadUrl) throw new Error(`No upload URL returned for ${asset.path}`);
386
411
  const local = assets.find((item) => item.path === asset.path);
387
412
  if (!local) throw new Error(`Prepared unknown asset ${asset.path}`);
388
- const blob = await openAsBlob(join(process.cwd(), 'public', asset.path), { type: local.contentType });
389
- const res = await fetch(asset.uploadUrl, {
390
- method: asset.method || 'PUT',
391
- headers: { 'Content-Type': local.contentType },
392
- body: blob,
393
- });
394
- if (!res.ok) {
395
- const detail = await res.text().catch(() => '');
413
+ try {
414
+ await uploadPreparedPublicAsset(asset, local);
415
+ } catch (e) {
396
416
  throw assetOffloadFailure(
397
- new Error(`Upload ${asset.path} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`),
417
+ e,
398
418
  { mode, status: 'upload-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes },
399
419
  );
400
420
  }
401
421
  uploaded += 1;
402
422
  }
403
423
  try {
404
- await apiPost(`/projects/${projectId}/public-assets/complete`, { assets: batch });
424
+ await apiPost(`/projects/${projectId}/public-assets/complete`, { assets: batch }, { timeoutMs: 120_000 });
405
425
  } catch (e) {
406
426
  throw assetOffloadFailure(e, { mode, status: 'complete-failed', offloaded: false, count: assets.length, uploaded, skipped, bytes: totalBytes });
407
427
  }
@@ -418,6 +438,200 @@ async function offloadPublicAssetsBeforeDeploy(projectId, flags) {
418
438
  };
419
439
  }
420
440
 
441
+ async function uploadPreparedPublicAsset(asset, local) {
442
+ if (asset.uploadKind === 'multipart' || asset.uploadPartUrl || asset.completeMultipartUrl) {
443
+ return uploadMultipartPublicAsset(asset, local);
444
+ }
445
+ if (!asset.uploadUrl) throw new Error(`No upload URL returned for ${asset.path}`);
446
+ const blob = await openAsBlob(join(process.cwd(), 'public', asset.path), { type: local.contentType });
447
+ const res = await fetchWithRetries(asset.uploadUrl, {
448
+ method: asset.method || 'PUT',
449
+ headers: { 'Content-Type': local.contentType },
450
+ body: blob,
451
+ }, { timeoutMs: uploadTimeoutMsFor(local.sizeBytes) });
452
+ if (!res.ok) {
453
+ const detail = await res.text().catch(() => '');
454
+ throw new Error(`Upload ${asset.path} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
455
+ }
456
+ }
457
+
458
+ async function uploadMultipartPublicAsset(asset, local) {
459
+ if (!asset.uploadPartUrl || !asset.completeMultipartUrl || !asset.uploadId) {
460
+ throw new Error(`Multipart upload details missing for ${asset.path}`);
461
+ }
462
+ const partSize = publicAssetPartSize(asset.partSize);
463
+ const blob = await openAsBlob(join(process.cwd(), 'public', asset.path), { type: local.contentType });
464
+ const parts = [];
465
+ let partNumber = 1;
466
+ for (let offset = 0; offset < local.sizeBytes; offset += partSize) {
467
+ const end = Math.min(offset + partSize, local.sizeBytes);
468
+ const partUrl = withQueryParam(asset.uploadPartUrl, 'partNumber', String(partNumber));
469
+ const res = await fetchWithRetries(partUrl, {
470
+ method: 'PUT',
471
+ headers: { 'Content-Type': 'application/octet-stream' },
472
+ body: blob.slice(offset, end, 'application/octet-stream'),
473
+ }, { timeoutMs: uploadTimeoutMsFor(end - offset) });
474
+ if (!res.ok) {
475
+ const detail = await res.text().catch(() => '');
476
+ throw new Error(`Upload ${asset.path} part ${partNumber} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
477
+ }
478
+ let data;
479
+ try { data = await res.json(); } catch { data = {}; }
480
+ const part = data?.part;
481
+ if (!part?.etag || !Number.isInteger(Number(part.partNumber))) {
482
+ throw new Error(`Upload ${asset.path} part ${partNumber} did not return a multipart ETag`);
483
+ }
484
+ parts.push({ partNumber: Number(part.partNumber), etag: String(part.etag) });
485
+ partNumber += 1;
486
+ }
487
+
488
+ const complete = await fetchWithRetries(asset.completeMultipartUrl, {
489
+ method: 'POST',
490
+ headers: { 'Content-Type': 'application/json' },
491
+ body: JSON.stringify({ parts }),
492
+ }, { timeoutMs: 120_000 });
493
+ if (!complete.ok) {
494
+ const detail = await complete.text().catch(() => '');
495
+ throw new Error(`Completing multipart upload for ${asset.path} failed with HTTP ${complete.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
496
+ }
497
+ }
498
+
499
+ async function uploadSourceBundleStaged(projectId, zipPath, zipStat, meta, indicator) {
500
+ const sizeBytes = zipStat.size;
501
+ const sha256 = await hashFile(zipPath);
502
+ let prepared;
503
+ try {
504
+ prepared = await apiPost('/deployments/source-upload/prepare', {
505
+ projectId,
506
+ fileName: 'project.zip',
507
+ sizeBytes,
508
+ sha256,
509
+ contentType: 'application/zip',
510
+ framework: meta.framework,
511
+ }, { timeoutMs: 120_000 });
512
+ } catch (e) {
513
+ throw sourceUploadFailure(e);
514
+ }
515
+
516
+ if (!prepared?.uploadPartUrl || !prepared?.completeUrl || !prepared?.uploadId) {
517
+ throw new Error('Chunked source upload was not prepared by this GlashDB API.');
518
+ }
519
+
520
+ const partSize = sourceUploadPartSize(prepared.partSize);
521
+ const blob = await openAsBlob(zipPath, { type: 'application/zip' });
522
+ const parts = [];
523
+ let partNumber = 1;
524
+ for (let offset = 0; offset < sizeBytes; offset += partSize) {
525
+ const end = Math.min(offset + partSize, sizeBytes);
526
+ const partUrl = withQueryParam(prepared.uploadPartUrl, 'partNumber', String(partNumber));
527
+ const res = await fetchWithRetries(partUrl, {
528
+ method: 'PUT',
529
+ headers: { 'Content-Type': 'application/octet-stream' },
530
+ body: blob.slice(offset, end, 'application/octet-stream'),
531
+ }, { timeoutMs: uploadTimeoutMsFor(end - offset) });
532
+ if (!res.ok) {
533
+ const detail = await res.text().catch(() => '');
534
+ throw new Error(`Chunked source upload part ${partNumber} failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
535
+ }
536
+ let data;
537
+ try { data = await res.json(); } catch { data = {}; }
538
+ const part = data?.part;
539
+ if (!part?.etag || !Number.isInteger(Number(part.partNumber))) {
540
+ throw new Error(`Chunked source upload part ${partNumber} did not return a multipart ETag`);
541
+ }
542
+ parts.push({ partNumber: Number(part.partNumber), etag: String(part.etag) });
543
+ const percent = 28 + Math.round((end / sizeBytes) * 14);
544
+ indicator?.setProgress?.(percent, 'uploading');
545
+ partNumber += 1;
546
+ }
547
+
548
+ const completed = await fetchWithRetries(prepared.completeUrl, {
549
+ method: 'POST',
550
+ headers: { 'Content-Type': 'application/json' },
551
+ body: JSON.stringify({ parts }),
552
+ }, { timeoutMs: 120_000 });
553
+ if (!completed.ok) {
554
+ const detail = await completed.text().catch(() => '');
555
+ throw new Error(`Completing chunked source upload failed with HTTP ${completed.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
556
+ }
557
+ let completion;
558
+ try { completion = await completed.json(); } catch { completion = {}; }
559
+ if (!completion?.sourceUrl) {
560
+ throw new Error('Chunked source upload completed without a staged source URL.');
561
+ }
562
+
563
+ indicator?.setProgress?.(43, 'hatching');
564
+ const deployment = await apiPost('/deployments/source-upload/commit', {
565
+ projectId,
566
+ sourceUrl: completion.sourceUrl,
567
+ sha256,
568
+ sizeBytes: String(sizeBytes),
569
+ commitHash: meta.commitHash,
570
+ gitBranch: meta.gitBranch,
571
+ target: meta.target,
572
+ framework: meta.framework,
573
+ }, { timeoutMs: 120_000 });
574
+
575
+ return { deployment, partsUploaded: parts.length, sha256 };
576
+ }
577
+
578
+ function sourceUploadFailure(error) {
579
+ const message = error?.message || String(error);
580
+ if (error?.status === 404 && /source-upload|Cannot POST/i.test(message)) {
581
+ return new Error('This GlashDB edge does not support chunked source upload yet. Deploy the router/backend source-upload update, then retry.');
582
+ }
583
+ if (error?.status === 503 && /storage is not configured/i.test(message)) {
584
+ return new Error('Chunked source upload storage is not configured on this GlashDB API.');
585
+ }
586
+ return error instanceof Error ? error : new Error(message);
587
+ }
588
+
589
+ async function fetchWithRetries(url, init, { retries = 3, timeoutMs = 120_000 } = {}) {
590
+ let attempt = 0;
591
+ // eslint-disable-next-line no-constant-condition
592
+ while (true) {
593
+ const ctrl = new AbortController();
594
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
595
+ try {
596
+ const res = await fetch(url, { ...init, signal: ctrl.signal });
597
+ clearTimeout(timer);
598
+ if (res.ok || !isRetryableUploadStatus(res.status) || attempt >= retries) return res;
599
+ await res.body?.cancel?.().catch(() => {});
600
+ } catch (error) {
601
+ clearTimeout(timer);
602
+ if (attempt >= retries) throw error;
603
+ }
604
+ await sleep(uploadBackoffMs(attempt));
605
+ attempt += 1;
606
+ }
607
+ }
608
+
609
+ function isRetryableUploadStatus(status) {
610
+ return [408, 425, 429, 500, 502, 503, 504, 522, 524].includes(Number(status));
611
+ }
612
+
613
+ function uploadBackoffMs(attempt) {
614
+ const base = Math.min(1000 * 2 ** attempt, 10000);
615
+ return Math.round(base * (0.75 + Math.random() * 0.5));
616
+ }
617
+
618
+ function withQueryParam(url, key, value) {
619
+ const next = new URL(url);
620
+ next.searchParams.set(key, value);
621
+ return next.toString();
622
+ }
623
+
624
+ function uploadTimeoutMsFor(bytes) {
625
+ const size = Number(bytes) || 0;
626
+ return Math.max(120_000, Math.min(15 * 60_000, Math.ceil(size / (1024 * 1024)) * 15_000));
627
+ }
628
+
629
+ function publicAssetPartSize(value) {
630
+ const parsed = Number(value);
631
+ if (Number.isFinite(parsed) && parsed >= 5 * 1024 * 1024) return Math.round(parsed);
632
+ return 16 * 1024 * 1024;
633
+ }
634
+
421
635
  function assetOffloadFailure(error, assetSummary) {
422
636
  const message = friendlyAssetOffloadError(error);
423
637
  const wrapped = new Error(message);
@@ -444,6 +658,93 @@ function assetMode(flags) {
444
658
  throw new Error(`Unsupported --asset-mode "${raw}". Use inline, auto, or offload.`);
445
659
  }
446
660
 
661
+ function sourceUploadMode(flags) {
662
+ const raw = String(flags['source-upload'] ?? process.env.GLASH_SOURCE_UPLOAD_MODE ?? 'auto').toLowerCase();
663
+ if (['auto', 'direct', 'chunked'].includes(raw)) return raw;
664
+ throw new Error(`Unsupported --source-upload "${raw}". Use auto, direct, or chunked.`);
665
+ }
666
+
667
+ function shouldUseChunkedSourceUpload(sizeBytes, flags) {
668
+ const mode = sourceUploadMode(flags);
669
+ if (mode === 'direct') return false;
670
+ if (mode === 'chunked') return true;
671
+ return Number(sizeBytes) >= sourceUploadThresholdBytes(flags);
672
+ }
673
+
674
+ function sourceUploadThresholdBytes(flags) {
675
+ return parseByteSize(flags['source-threshold'] ?? process.env.GLASH_SOURCE_UPLOAD_THRESHOLD ?? '90mb');
676
+ }
677
+
678
+ function sourceUploadPartSize(value) {
679
+ const parsed = Number(value);
680
+ if (Number.isFinite(parsed) && parsed >= 5 * 1024 * 1024) return Math.round(parsed);
681
+ return 16 * 1024 * 1024;
682
+ }
683
+
684
+ async function detectProjectProfile(cwd, flags = {}) {
685
+ const forced = frameworkHint(flags);
686
+ if (forced) return { framework: forced, label: forced };
687
+ const pkg = await readPackageJson(cwd);
688
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
689
+ const hasGlashConfig = await existsAny(cwd, ['glash.config.mjs', 'glash.config.js', 'glash.config.cjs']);
690
+ if (deps.glashjs || hasGlashConfig) return { framework: 'glashjs', label: 'glashjs' };
691
+ if (deps.next) return { framework: 'next', label: 'Next.js' };
692
+ if (deps.nuxt || deps.nuxt3) return { framework: 'nuxt', label: 'Nuxt' };
693
+ if (deps.vite) return { framework: 'vite', label: 'Vite' };
694
+ if (deps['@remix-run/dev'] || deps.remix) return { framework: 'remix', label: 'Remix' };
695
+ if (deps.astro) return { framework: 'astro', label: 'Astro' };
696
+ if (deps.svelte || deps['@sveltejs/kit']) return { framework: 'sveltekit', label: 'SvelteKit' };
697
+ if (deps.express || deps.fastify || deps['@nestjs/core']) return { framework: 'node', label: 'Node.js' };
698
+ if (pkg) return { framework: 'node', label: 'Node.js' };
699
+ return { framework: 'static', label: 'Static' };
700
+ }
701
+
702
+ async function packageOverridesForProfile(cwd, profile) {
703
+ if (profile?.framework !== 'glashjs') return {};
704
+ const pkg = await readPackageJson(cwd);
705
+ if (!pkg) return {};
706
+ const scripts = { ...(pkg.scripts ?? {}) };
707
+ let changed = false;
708
+ if (!scripts.build) {
709
+ scripts.build = 'glash build';
710
+ changed = true;
711
+ }
712
+ if (!scripts.start) {
713
+ scripts.start = 'glash serve --port ${PORT:-3000}';
714
+ changed = true;
715
+ }
716
+ if (!changed) return {};
717
+ return {
718
+ 'package.json': `${JSON.stringify({ ...pkg, scripts }, null, 2)}\n`,
719
+ };
720
+ }
721
+
722
+ function frameworkHint(flags = {}) {
723
+ const raw = String(flags.framework ?? flags.runtime ?? '').trim().toLowerCase();
724
+ if (!raw) return '';
725
+ const aliases = { nextjs: 'next', glash: 'glashjs', glashdb: 'glashjs', react: 'vite' };
726
+ const normalized = aliases[raw] ?? raw;
727
+ return /^[a-z0-9._-]{1,40}$/.test(normalized) ? normalized : '';
728
+ }
729
+
730
+ async function readPackageJson(cwd) {
731
+ try {
732
+ return JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8'));
733
+ } catch {
734
+ return null;
735
+ }
736
+ }
737
+
738
+ async function existsAny(cwd, files) {
739
+ for (const file of files) {
740
+ try {
741
+ await access(join(cwd, file));
742
+ return true;
743
+ } catch {}
744
+ }
745
+ return false;
746
+ }
747
+
447
748
  async function publicAssetFiles(cwd) {
448
749
  const entries = await fg('public/**/*', {
449
750
  cwd,
@@ -511,11 +812,18 @@ function assetOffloadThresholdBytes(flags) {
511
812
  return parseByteSize(flags['asset-threshold'] ?? process.env.GLASH_ASSET_OFFLOAD_THRESHOLD ?? '100mb');
512
813
  }
513
814
 
815
+ function publicAssetBatchSize(flags) {
816
+ const raw = flags['asset-batch-size'] ?? process.env.GLASH_ASSET_BATCH_SIZE ?? '50';
817
+ const parsed = Number(raw);
818
+ if (!Number.isFinite(parsed) || parsed < 1) return 50;
819
+ return Math.max(1, Math.min(250, Math.floor(parsed)));
820
+ }
821
+
514
822
  function parseByteSize(value) {
515
823
  if (typeof value === 'number') return value;
516
824
  const raw = String(value || '').trim().toLowerCase();
517
825
  const match = raw.match(/^(\d+(?:\.\d+)?)\s*(b|kb|kib|mb|mib|gb|gib)?$/);
518
- if (!match) throw new Error(`Invalid asset threshold "${value}"`);
826
+ if (!match) throw new Error(`Invalid size "${value}"`);
519
827
  const amount = Number(match[1]);
520
828
  const unit = match[2] || 'b';
521
829
  const multiplier = unit === 'gb' || unit === 'gib'
@@ -700,6 +1008,7 @@ function printDeployReport(report) {
700
1008
  ['Deployment ID', report.deploymentId || '-'],
701
1009
  ['Target', report.target || 'production'],
702
1010
  ['Source', report.source || '-'],
1011
+ ['Framework', report.framework || 'auto'],
703
1012
  ['Packages uploaded', formatPackagesUploaded(report)],
704
1013
  ['Public assets', formatAssetSummary(report.assetSummary)],
705
1014
  ['Deployment size', formatDeploymentSize(report)],
@@ -728,6 +1037,14 @@ function formatReportStatus(status) {
728
1037
 
729
1038
  function formatPackagesUploaded(report) {
730
1039
  const count = report.packagesUploaded ?? 0;
1040
+ if (report.sourceUpload?.mode === 'chunked') {
1041
+ const files = report.filesIncluded ? ` (${report.filesIncluded.toLocaleString()} files included)` : '';
1042
+ return `${count.toLocaleString()} source chunk${count === 1 ? '' : 's'}${files}`;
1043
+ }
1044
+ if (report.sourceUpload?.mode === 'direct') {
1045
+ const files = report.filesIncluded ? ` (${report.filesIncluded.toLocaleString()} files included)` : '';
1046
+ return `1 source bundle${files}`;
1047
+ }
731
1048
  const label = count === 1 ? 'source bundle' : 'source bundles';
732
1049
  const files = report.filesIncluded
733
1050
  ? ` (${report.filesIncluded.toLocaleString()} files included)`
@@ -844,6 +1161,9 @@ function createDeployIndicator() {
844
1161
  if (!nextPhase) return;
845
1162
  setProgress(progressFromPhase(nextPhase), nextPhase);
846
1163
  },
1164
+ setProgress(nextProgress, nextPhase = phase) {
1165
+ setProgress(nextProgress, nextPhase);
1166
+ },
847
1167
  finish(finalPhase = 'complete') {
848
1168
  stopTimer();
849
1169
  phase = finalPhase;
@@ -873,6 +1193,7 @@ function progressFromPhase(phase) {
873
1193
  syncing: 8,
874
1194
  assets: 15,
875
1195
  packaging: 22,
1196
+ uploading: 28,
876
1197
  glashing: 35,
877
1198
  hatching: 45,
878
1199
  waiting: 48,
package/lib/zip.mjs CHANGED
@@ -4,7 +4,7 @@ import { createWriteStream } from 'node:fs';
4
4
  import { join, relative } from 'node:path';
5
5
  import fs from 'fs-extra';
6
6
 
7
- export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
7
+ export async function zipDirectory(sourceDir, outPath, { ignore = [], includeGlashOutput = false, overrides = {} } = {}) {
8
8
  // Default ignores
9
9
  const defaultIgnore = [
10
10
  'node_modules/**',
@@ -32,7 +32,7 @@ export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
32
32
  'build/**',
33
33
  '**/build/**',
34
34
  'out/**',
35
- '**/out/**',
35
+ ...(includeGlashOutput ? [] : ['**/out/**']),
36
36
  'coverage/**',
37
37
  '**/coverage/**',
38
38
  '.turbo/**',
@@ -43,8 +43,19 @@ export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
43
43
  '**/.parcel-cache/**',
44
44
  '.vite/**',
45
45
  '**/.vite/**',
46
- '.glash/**',
47
- '**/.glash/**',
46
+ ...(includeGlashOutput
47
+ ? [
48
+ '.glash/project.json',
49
+ '**/.glash/project.json',
50
+ '.glash/credentials.json',
51
+ '**/.glash/credentials.json',
52
+ '.glash/cache/**',
53
+ '**/.glash/cache/**',
54
+ ]
55
+ : [
56
+ '.glash/**',
57
+ '**/.glash/**',
58
+ ]),
48
59
  '*.log',
49
60
  '**/*.log',
50
61
  'npm-debug.log*',
@@ -63,20 +74,21 @@ export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
63
74
  '**/.env.development',
64
75
  ];
65
76
  const allIgnore = [...new Set([...defaultIgnore, ...loadIgnorePatterns(sourceDir), ...ignore])];
66
- const files = await fg('**/*', {
77
+ const overridePaths = new Set(Object.keys(overrides));
78
+ const files = (await fg('**/*', {
67
79
  cwd: sourceDir,
68
80
  ignore: allIgnore,
69
81
  dot: true,
70
82
  onlyFiles: true,
71
83
  followSymbolicLinks: false,
72
- });
84
+ })).filter((file) => !overridePaths.has(file));
73
85
 
74
86
  return new Promise((resolve, reject) => {
75
87
  const zip = new yazl.ZipFile();
76
88
  const output = createWriteStream(outPath);
77
89
  let bytes = 0;
78
90
 
79
- output.on('close', () => resolve({ bytes, fileCount: files.length, files }));
91
+ output.on('close', () => resolve({ bytes, fileCount: files.length + overridePaths.size, files: [...files, ...overridePaths] }));
80
92
  output.on('error', reject);
81
93
  zip.outputStream.on('data', (chunk) => { bytes += chunk.length; });
82
94
  zip.outputStream.on('error', reject);
@@ -85,6 +97,9 @@ export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
85
97
  for (const file of files) {
86
98
  zip.addFile(join(sourceDir, file), file);
87
99
  }
100
+ for (const [file, content] of Object.entries(overrides)) {
101
+ zip.addBuffer(Buffer.isBuffer(content) ? content : Buffer.from(String(content)), file);
102
+ }
88
103
  zip.end();
89
104
  });
90
105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glash/cli",
3
- "version": "0.2.31",
3
+ "version": "0.2.33",
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",