@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 +46 -0
- package/bin/glash.mjs +2 -0
- package/commands/deploy.mjs +367 -37
- package/lib/zip.mjs +1 -1
- package/package.json +1 -1
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
|
|
package/commands/deploy.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
286
|
+
report.liveUrl = '';
|
|
239
287
|
}
|
|
240
|
-
|
|
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.
|
|
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
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
439
|
-
|
|
721
|
+
let progress = 0;
|
|
722
|
+
let lastPrinted = '';
|
|
440
723
|
|
|
441
724
|
function message() {
|
|
442
|
-
const
|
|
443
|
-
|
|
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 &&
|
|
449
|
-
|
|
450
|
-
info(
|
|
735
|
+
if (force && next !== lastPrinted) {
|
|
736
|
+
lastPrinted = next;
|
|
737
|
+
info(next);
|
|
451
738
|
}
|
|
452
739
|
return;
|
|
453
740
|
}
|
|
454
|
-
stdout.write(`\r\x1b[2K${
|
|
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
|
-
|
|
761
|
+
progress = 0;
|
|
762
|
+
render({ force: true });
|
|
461
763
|
timer = setInterval(render, 1000);
|
|
462
764
|
},
|
|
463
765
|
setPhase(nextPhase) {
|
|
464
|
-
if (!nextPhase
|
|
465
|
-
|
|
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
|
-
|
|
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