@glash/cli 0.2.38 → 0.2.40
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 +14 -5
- package/bin/glash.mjs +3 -2
- package/commands/deploy.mjs +126 -23
- package/commands/login.mjs +18 -7
- package/commands/logout.mjs +1 -1
- package/commands/open.mjs +1 -1
- package/commands/whoami.mjs +1 -1
- package/lib/api.mjs +1 -1
- package/lib/project-build.mjs +146 -0
- package/lib/ui.mjs +1 -1
- package/package.json +2 -6
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The official command-line interface for [glashDB](https://glashdb.com).
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
-
Pick whichever install style you prefer
|
|
7
|
+
Pick whichever install style you prefer - all four produce the same `glash` command:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
# Short-name (recommended)
|
|
@@ -18,7 +18,7 @@ pnpm add -g glashdb
|
|
|
18
18
|
yarn global add glashdb
|
|
19
19
|
bun add -g glashdb
|
|
20
20
|
|
|
21
|
-
# No install
|
|
21
|
+
# No install - run on demand
|
|
22
22
|
npx -y glashdb deploy
|
|
23
23
|
```
|
|
24
24
|
|
|
@@ -44,7 +44,7 @@ glash --version
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
On macOS with Homebrew Node, the global prefix is usually
|
|
47
|
-
`/opt/homebrew/bin` (Apple Silicon) or `/usr/local/bin` (Intel)
|
|
47
|
+
`/opt/homebrew/bin` (Apple Silicon) or `/usr/local/bin` (Intel) - both are
|
|
48
48
|
typically already on `PATH`. With `nvm`, re-run `nvm use` in new shells or
|
|
49
49
|
add `nvm use --default` to your shell init.
|
|
50
50
|
|
|
@@ -109,6 +109,14 @@ glash deploy --asset-mode auto --asset-threshold 100mb
|
|
|
109
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
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
111
|
|
|
112
|
+
Before a local source upload, `glash deploy` automatically runs the project's
|
|
113
|
+
`build` script with the package manager declared in `package.json` or detected
|
|
114
|
+
from its lockfile. The progress display keeps the progress bar on top and shows
|
|
115
|
+
the active step and latest build output underneath it. A failed local build
|
|
116
|
+
stops the deployment before source is uploaded. Use `--no-build` only when you
|
|
117
|
+
intentionally need to bypass this pre-deploy check. Remote Git deployments
|
|
118
|
+
continue to build on the Glash build worker.
|
|
119
|
+
|
|
112
120
|
Framework/runtime checks:
|
|
113
121
|
|
|
114
122
|
`glash deploy` detects both the project framework and the runtime scripts in `package.json`. If they disagree, for example the project looks like Next.js but `start` calls `glash serve`, the CLI stops before upload and asks you to align the scripts or explicitly continue. Use `--ignore-framework-mismatch` only when the mismatch is intentional.
|
|
@@ -142,7 +150,8 @@ For glashjs projects, `glash deploy` detects `glashjs` or `glash.config.mjs`, ma
|
|
|
142
150
|
| `glash projects:rm <slug>` | Delete a project |
|
|
143
151
|
| `glash link <slug>` | Link the current directory to an existing project |
|
|
144
152
|
| `glash open` | Open the linked project's primary domain |
|
|
145
|
-
| `glash deploy [--prod] [--detach]` |
|
|
153
|
+
| `glash deploy [--prod] [--detach]` | Build and deploy the linked project; streams the pipeline until live |
|
|
154
|
+
| `glash deploy --no-build` | Skip the automatic local build before uploading source |
|
|
146
155
|
| `glash deploy --asset-mode offload` | Upload `public/` assets to object storage/CDN before zipping source |
|
|
147
156
|
| `glash deploy --source-upload chunked` | Stage the source zip through multipart object storage to bypass Cloudflare's single-request cap |
|
|
148
157
|
| `glash deployments` | List recent deployments |
|
|
@@ -200,7 +209,7 @@ The commands require PostgreSQL client tools locally: `pg_dump` for dumps,
|
|
|
200
209
|
| --- | --- | --- |
|
|
201
210
|
| `GLASH_API_URL` | `https://api.glashdb.com/api` | Override the API base (self-hosted, staging) |
|
|
202
211
|
| `GLASH_APP_URL` | `https://glashdb.com` | Override the dashboard base used for live monitor links |
|
|
203
|
-
| `GLASH_TOKEN` |
|
|
212
|
+
| `GLASH_TOKEN` | - | Use this token instead of `~/.glash/credentials.json` (useful in CI) |
|
|
204
213
|
| `GLASH_ASSET_OFFLOAD_THRESHOLD` | `100mb` | Default threshold for `glash deploy --asset-mode auto` |
|
|
205
214
|
| `GLASH_ASSET_BATCH_SIZE` | `50` | Number of public assets prepared per request during `--asset-mode offload` |
|
|
206
215
|
| `GLASH_SOURCE_UPLOAD_MODE` | `auto` | Source upload mode: `auto`, `direct`, or `chunked` |
|
package/bin/glash.mjs
CHANGED
|
@@ -49,7 +49,7 @@ const commands = {
|
|
|
49
49
|
};
|
|
50
50
|
|
|
51
51
|
function usage() {
|
|
52
|
-
info(`${color.bold('glash')} ${color.dim(`v${VERSION}`)}
|
|
52
|
+
info(`${color.bold('glash')} ${color.dim(`v${VERSION}`)} - glashDB command line
|
|
53
53
|
|
|
54
54
|
${color.bold('USAGE')}
|
|
55
55
|
glash <command> [args] [flags]
|
|
@@ -68,7 +68,7 @@ ${color.bold('PROJECTS')}
|
|
|
68
68
|
open Open the project's primary domain in a browser
|
|
69
69
|
|
|
70
70
|
${color.bold('DEPLOY')}
|
|
71
|
-
deploy [--prod] [--detach]
|
|
71
|
+
deploy [--prod] [--detach] Build and deploy the linked project, syncing .env files
|
|
72
72
|
new deploy Deploy to a brand new project, ignoring any existing link
|
|
73
73
|
deployments List recent deployments
|
|
74
74
|
status <id> Show deployment pipeline + events
|
|
@@ -98,6 +98,7 @@ ${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
|
+
--no-build Skip the automatic local build before source upload
|
|
101
102
|
--asset-mode <mode> Public asset handling: inline, auto, offload
|
|
102
103
|
--asset-threshold <size> Auto-offload threshold (default: 100mb)
|
|
103
104
|
--source-upload <mode> Source upload: auto, direct, chunked
|
package/commands/deploy.mjs
CHANGED
|
@@ -5,11 +5,12 @@ import fg from 'fast-glob';
|
|
|
5
5
|
import { execSync } from 'node:child_process';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { zipDirectory } from '../lib/zip.mjs';
|
|
8
|
+
import { buildProjectBeforeDeploy, compactBuildOutputLine } from '../lib/project-build.mjs';
|
|
8
9
|
import { join, basename, extname } from 'node:path';
|
|
9
10
|
import { tmpdir } from 'node:os';
|
|
10
11
|
import { createReadStream, openAsBlob } from 'node:fs';
|
|
11
12
|
import { access, readFile, rm, stat } from 'node:fs/promises';
|
|
12
|
-
import { stdin, stdout } from 'node:process';
|
|
13
|
+
import { stdin, stdout, stderr } from 'node:process';
|
|
13
14
|
|
|
14
15
|
function currentCommit() {
|
|
15
16
|
try {
|
|
@@ -147,6 +148,9 @@ export async function deploy({ flags }) {
|
|
|
147
148
|
let dep;
|
|
148
149
|
let envSummary = { count: 0, skipped: Boolean(flags['no-env']) };
|
|
149
150
|
let assetSummary = { mode: requestedAssetMode, status: requestedAssetMode === 'inline' ? 'inline' : 'pending', offloaded: false, count: 0, bytes: 0, skipped: 0 };
|
|
151
|
+
let localBuild = remoteDeploy
|
|
152
|
+
? { status: 'remote' }
|
|
153
|
+
: { status: 'pending' };
|
|
150
154
|
let currentPhase = 'doing';
|
|
151
155
|
const start = Date.now();
|
|
152
156
|
const indicator = createDeployIndicator();
|
|
@@ -158,6 +162,7 @@ export async function deploy({ flags }) {
|
|
|
158
162
|
source: remoteDeploy ? 'remote git deploy' : 'local upload',
|
|
159
163
|
framework: projectProfile.framework,
|
|
160
164
|
runtime: projectProfile.runtime?.summary || '',
|
|
165
|
+
localBuild,
|
|
161
166
|
sourceUpload: { mode: remoteDeploy ? 'remote' : 'pending', parts: 0 },
|
|
162
167
|
packagesUploaded: 0,
|
|
163
168
|
filesIncluded: 0,
|
|
@@ -172,6 +177,33 @@ export async function deploy({ flags }) {
|
|
|
172
177
|
info(`Live status: ${terminalLink(initialStatusUrl)}`);
|
|
173
178
|
indicator.start(currentPhase);
|
|
174
179
|
|
|
180
|
+
if (!remoteDeploy) {
|
|
181
|
+
try {
|
|
182
|
+
localBuild = await buildProjectBeforeDeploy(process.cwd(), {
|
|
183
|
+
packageJson: projectProfile.runtime?.pkg,
|
|
184
|
+
skip: Boolean(flags['no-build']) || process.env.GLASH_CLI_BUILD_RUNNING === '1',
|
|
185
|
+
onStart: (plan) => {
|
|
186
|
+
indicator.setPhase('prebuilding', `Building project (${plan.displayCommand})`);
|
|
187
|
+
},
|
|
188
|
+
onLine: ({ line }) => {
|
|
189
|
+
const detail = compactBuildOutputLine(line);
|
|
190
|
+
if (detail) indicator.setDetail(`Building project — ${detail}`);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
report.localBuild = localBuild;
|
|
194
|
+
} catch (e) {
|
|
195
|
+
localBuild = {
|
|
196
|
+
status: 'failed',
|
|
197
|
+
command: e.buildPlan?.displayCommand || '',
|
|
198
|
+
};
|
|
199
|
+
report.localBuild = localBuild;
|
|
200
|
+
indicator.fail('failed', 'Project build failed');
|
|
201
|
+
printBuildFailureOutput(e.buildOutput);
|
|
202
|
+
printDeployReport({ ...report, status: 'failed', error: `Project build failed: ${e.message}` });
|
|
203
|
+
return fail(`Project build failed: ${e.message}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
175
207
|
if (!flags['no-env']) {
|
|
176
208
|
indicator.setPhase('syncing');
|
|
177
209
|
try {
|
|
@@ -711,7 +743,7 @@ function sourceUploadPartSize(value) {
|
|
|
711
743
|
}
|
|
712
744
|
|
|
713
745
|
function sourceUploadConcurrency(flags = {}) {
|
|
714
|
-
// Default 4
|
|
746
|
+
// Default 4 - parts upload simultaneously and 4 is the measured throughput
|
|
715
747
|
// sweet spot: a single connection can't saturate a typical uplink (~2.5x
|
|
716
748
|
// speedup from parallelism), but past ~4 connections contention makes it
|
|
717
749
|
// slower and unreliable. Override with --source-concurrency or
|
|
@@ -1243,6 +1275,7 @@ function printDeployReport(report) {
|
|
|
1243
1275
|
['Source', report.source || '-'],
|
|
1244
1276
|
['Framework', report.framework || 'auto'],
|
|
1245
1277
|
['Runtime', report.runtime || '-'],
|
|
1278
|
+
['Local build', formatLocalBuildSummary(report.localBuild)],
|
|
1246
1279
|
['Packages uploaded', formatPackagesUploaded(report)],
|
|
1247
1280
|
['Public assets', formatAssetSummary(report.assetSummary)],
|
|
1248
1281
|
['Deployment size', formatDeploymentSize(report)],
|
|
@@ -1269,6 +1302,27 @@ function formatReportStatus(status) {
|
|
|
1269
1302
|
return status || '-';
|
|
1270
1303
|
}
|
|
1271
1304
|
|
|
1305
|
+
function formatLocalBuildSummary(build) {
|
|
1306
|
+
if (!build || build.status === 'pending') return 'pending';
|
|
1307
|
+
if (build.status === 'remote') return 'handled by the Glash build worker';
|
|
1308
|
+
if (build.status === 'built') {
|
|
1309
|
+
const duration = Number.isFinite(build.durationMs) ? ` in ${formatDuration(build.durationMs)}` : '';
|
|
1310
|
+
return `${build.displayCommand || 'build completed'}${duration}`;
|
|
1311
|
+
}
|
|
1312
|
+
if (build.status === 'failed') return `${build.command || 'build command'} failed`;
|
|
1313
|
+
if (build.reason === 'disabled') return 'skipped (--no-build)';
|
|
1314
|
+
if (build.reason === 'no-package-json') return 'skipped (no package.json)';
|
|
1315
|
+
if (build.reason === 'no-build-script') return 'skipped (no build script)';
|
|
1316
|
+
return build.status || '-';
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function printBuildFailureOutput(output) {
|
|
1320
|
+
const lines = String(output || '').trim().split(/\r?\n/).filter(Boolean).slice(-80);
|
|
1321
|
+
if (!lines.length) return;
|
|
1322
|
+
warn(`Build output (last ${lines.length} line${lines.length === 1 ? '' : 's'}):`);
|
|
1323
|
+
stderr.write(`${lines.join('\n')}\n`);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1272
1326
|
function formatPackagesUploaded(report) {
|
|
1273
1327
|
const count = report.packagesUploaded ?? 0;
|
|
1274
1328
|
if (report.sourceUpload?.mode === 'chunked') {
|
|
@@ -1347,27 +1401,38 @@ function createDeployIndicator() {
|
|
|
1347
1401
|
const interactive = Boolean(stdout.isTTY);
|
|
1348
1402
|
let timer;
|
|
1349
1403
|
let phase = 'doing';
|
|
1404
|
+
let detail = detailFromPhase(phase);
|
|
1350
1405
|
let progress = 0;
|
|
1351
1406
|
let lastPrinted = '';
|
|
1407
|
+
let rendered = false;
|
|
1352
1408
|
|
|
1353
|
-
function
|
|
1409
|
+
function messages() {
|
|
1354
1410
|
const width = 24;
|
|
1355
1411
|
const filled = Math.round((progress / 100) * width);
|
|
1356
1412
|
const bar = `${'#'.repeat(filled)}${'-'.repeat(width - filled)}`;
|
|
1357
1413
|
const percent = `${String(Math.round(progress)).padStart(3, ' ')}%`;
|
|
1358
|
-
return
|
|
1414
|
+
return {
|
|
1415
|
+
bar: `${color.dim(`[${bar}]`)} ${percent}`,
|
|
1416
|
+
status: ` ${color.cyan('→')} ${detail}`,
|
|
1417
|
+
};
|
|
1359
1418
|
}
|
|
1360
1419
|
|
|
1361
1420
|
function render({ force = false } = {}) {
|
|
1362
|
-
const next =
|
|
1421
|
+
const next = messages();
|
|
1422
|
+
const signature = `${next.bar}\n${next.status}`;
|
|
1423
|
+
if (signature === lastPrinted) return;
|
|
1363
1424
|
if (!interactive) {
|
|
1364
|
-
if (force
|
|
1365
|
-
lastPrinted =
|
|
1366
|
-
info(next);
|
|
1425
|
+
if (force) {
|
|
1426
|
+
lastPrinted = signature;
|
|
1427
|
+
info(next.bar);
|
|
1428
|
+
info(next.status);
|
|
1367
1429
|
}
|
|
1368
1430
|
return;
|
|
1369
1431
|
}
|
|
1370
|
-
stdout.write(
|
|
1432
|
+
if (rendered) stdout.write('\r\x1b[2K\x1b[1A\r\x1b[2K');
|
|
1433
|
+
stdout.write(`${next.bar}\n${next.status}`);
|
|
1434
|
+
lastPrinted = signature;
|
|
1435
|
+
rendered = true;
|
|
1371
1436
|
}
|
|
1372
1437
|
|
|
1373
1438
|
function stopTimer() {
|
|
@@ -1375,45 +1440,59 @@ function createDeployIndicator() {
|
|
|
1375
1440
|
timer = undefined;
|
|
1376
1441
|
}
|
|
1377
1442
|
|
|
1378
|
-
function
|
|
1443
|
+
function updateProgress(nextProgress, nextPhase = phase, nextDetail) {
|
|
1379
1444
|
const bounded = Math.max(0, Math.min(100, Number(nextProgress) || 0));
|
|
1380
1445
|
const advanced = Math.max(progress, bounded);
|
|
1381
|
-
const
|
|
1446
|
+
const resolvedDetail = nextDetail || (nextPhase !== phase ? detailFromPhase(nextPhase) : detail);
|
|
1447
|
+
const changed = nextPhase !== phase || advanced !== progress || resolvedDetail !== detail;
|
|
1382
1448
|
phase = nextPhase;
|
|
1383
1449
|
progress = advanced;
|
|
1450
|
+
detail = resolvedDetail;
|
|
1384
1451
|
if (changed) render({ force: true });
|
|
1385
1452
|
}
|
|
1386
1453
|
|
|
1387
1454
|
return {
|
|
1388
1455
|
start(initialPhase = 'doing') {
|
|
1389
1456
|
phase = initialPhase;
|
|
1457
|
+
detail = detailFromPhase(initialPhase);
|
|
1390
1458
|
progress = 0;
|
|
1459
|
+
lastPrinted = '';
|
|
1460
|
+
rendered = false;
|
|
1391
1461
|
render({ force: true });
|
|
1392
1462
|
timer = setInterval(render, 1000);
|
|
1393
1463
|
},
|
|
1394
|
-
setPhase(nextPhase) {
|
|
1464
|
+
setPhase(nextPhase, nextDetail) {
|
|
1395
1465
|
if (!nextPhase) return;
|
|
1396
|
-
|
|
1466
|
+
updateProgress(progressFromPhase(nextPhase), nextPhase, nextDetail);
|
|
1397
1467
|
},
|
|
1398
|
-
setProgress(nextProgress, nextPhase = phase) {
|
|
1399
|
-
|
|
1468
|
+
setProgress(nextProgress, nextPhase = phase, nextDetail) {
|
|
1469
|
+
updateProgress(nextProgress, nextPhase, nextDetail);
|
|
1400
1470
|
},
|
|
1401
|
-
|
|
1471
|
+
setDetail(nextDetail) {
|
|
1472
|
+
const value = String(nextDetail || '').trim();
|
|
1473
|
+
if (!value || value === detail) return;
|
|
1474
|
+
detail = value;
|
|
1475
|
+
render();
|
|
1476
|
+
},
|
|
1477
|
+
finish(finalPhase = 'complete', finalDetail = detailFromPhase(finalPhase)) {
|
|
1402
1478
|
stopTimer();
|
|
1403
1479
|
phase = finalPhase;
|
|
1480
|
+
detail = finalDetail;
|
|
1404
1481
|
progress = 100;
|
|
1405
1482
|
render({ force: true });
|
|
1406
1483
|
if (interactive) stdout.write('\n');
|
|
1407
1484
|
},
|
|
1408
|
-
fail(finalPhase = 'failed') {
|
|
1485
|
+
fail(finalPhase = 'failed', finalDetail = detailFromPhase(finalPhase)) {
|
|
1409
1486
|
stopTimer();
|
|
1410
1487
|
phase = finalPhase;
|
|
1488
|
+
detail = finalDetail;
|
|
1411
1489
|
render({ force: true });
|
|
1412
1490
|
if (interactive) stdout.write('\n');
|
|
1413
1491
|
},
|
|
1414
1492
|
stop() {
|
|
1415
1493
|
stopTimer();
|
|
1416
|
-
if (interactive) stdout.write('\r\x1b[2K');
|
|
1494
|
+
if (interactive && rendered) stdout.write('\r\x1b[2K\x1b[1A\r\x1b[2K');
|
|
1495
|
+
rendered = false;
|
|
1417
1496
|
},
|
|
1418
1497
|
wait(ms) {
|
|
1419
1498
|
return sleep(ms);
|
|
@@ -1421,14 +1500,38 @@ function createDeployIndicator() {
|
|
|
1421
1500
|
};
|
|
1422
1501
|
}
|
|
1423
1502
|
|
|
1503
|
+
function detailFromPhase(phase) {
|
|
1504
|
+
const details = {
|
|
1505
|
+
doing: 'Preparing deployment',
|
|
1506
|
+
prebuilding: 'Building project',
|
|
1507
|
+
syncing: 'Syncing environment variables',
|
|
1508
|
+
assets: 'Preparing public assets',
|
|
1509
|
+
packaging: 'Packaging source files',
|
|
1510
|
+
uploading: 'Uploading source chunks',
|
|
1511
|
+
glashing: 'Uploading source bundle',
|
|
1512
|
+
hatching: 'Waiting for a Glash build worker',
|
|
1513
|
+
waiting: 'Waiting for deployment updates',
|
|
1514
|
+
building: 'Building project on Glash',
|
|
1515
|
+
deploying: 'Deploying application',
|
|
1516
|
+
routing: 'Configuring domains and routing',
|
|
1517
|
+
checking: 'Checking the live deployment',
|
|
1518
|
+
complete: 'Deployment complete',
|
|
1519
|
+
queued: 'Deployment queued',
|
|
1520
|
+
failed: 'Deployment failed',
|
|
1521
|
+
'timed out': 'Deployment timed out',
|
|
1522
|
+
};
|
|
1523
|
+
return details[phase] || String(phase || 'Working');
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1424
1526
|
function progressFromPhase(phase) {
|
|
1425
1527
|
const progressByPhase = {
|
|
1426
1528
|
doing: 2,
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1529
|
+
prebuilding: 7,
|
|
1530
|
+
syncing: 12,
|
|
1531
|
+
assets: 18,
|
|
1532
|
+
packaging: 25,
|
|
1533
|
+
uploading: 32,
|
|
1534
|
+
glashing: 38,
|
|
1432
1535
|
hatching: 45,
|
|
1433
1536
|
waiting: 48,
|
|
1434
1537
|
building: 65,
|
package/commands/login.mjs
CHANGED
|
@@ -7,6 +7,17 @@ import { prompt, ok, fail, info, warn, color } from '../lib/ui.mjs';
|
|
|
7
7
|
|
|
8
8
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
9
9
|
|
|
10
|
+
async function identityPost(path, body, opts) {
|
|
11
|
+
try {
|
|
12
|
+
return await apiPost(`/identity${path}`, body, opts);
|
|
13
|
+
} catch (e) {
|
|
14
|
+
if (e?.status === 404) {
|
|
15
|
+
return apiPost(`/auth${path}`, body, opts);
|
|
16
|
+
}
|
|
17
|
+
throw e;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
function openBrowser(url) {
|
|
11
22
|
try {
|
|
12
23
|
const opener = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
@@ -29,9 +40,9 @@ export async function login({ flags }) {
|
|
|
29
40
|
async function browserLogin(flags) {
|
|
30
41
|
let start;
|
|
31
42
|
try {
|
|
32
|
-
start = await
|
|
43
|
+
start = await identityPost('/cli/device/start', { deviceName: hostname() });
|
|
33
44
|
} catch (e) {
|
|
34
|
-
// Older backend without device auth
|
|
45
|
+
// Older backend without device auth - fall back to password login.
|
|
35
46
|
warn('Browser login is unavailable; falling back to email/password.');
|
|
36
47
|
return passwordLogin(flags);
|
|
37
48
|
}
|
|
@@ -50,7 +61,7 @@ async function browserLogin(flags) {
|
|
|
50
61
|
} else if (openBrowser(approveUrl)) {
|
|
51
62
|
info(color.dim(' Opened your browser… waiting for approval.'));
|
|
52
63
|
} else {
|
|
53
|
-
info(' Could not open a browser automatically
|
|
64
|
+
info(' Could not open a browser automatically - open the link above.');
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
const deadline = Date.now() + expiresIn * 1000;
|
|
@@ -60,7 +71,7 @@ async function browserLogin(flags) {
|
|
|
60
71
|
await sleep(delay);
|
|
61
72
|
let r;
|
|
62
73
|
try {
|
|
63
|
-
r = await
|
|
74
|
+
r = await identityPost('/cli/device/poll', { deviceCode });
|
|
64
75
|
} catch (e) {
|
|
65
76
|
return fail(e.message);
|
|
66
77
|
}
|
|
@@ -84,16 +95,16 @@ async function passwordLogin(flags) {
|
|
|
84
95
|
if (!email || !password) return fail('email and password required');
|
|
85
96
|
|
|
86
97
|
try {
|
|
87
|
-
let r = await
|
|
98
|
+
let r = await identityPost('/login', { email, password });
|
|
88
99
|
|
|
89
100
|
// Second factor is always required. Complete the email-OTP challenge.
|
|
90
101
|
if (!r?.accessToken && r?.secondFactorRequired && !r?.challengeId) {
|
|
91
|
-
r = await
|
|
102
|
+
r = await identityPost('/login', { email, password, secondFactorMethod: 'email_otp' });
|
|
92
103
|
}
|
|
93
104
|
if (!r?.accessToken && (r?.otpRequired || r?.challengeId)) {
|
|
94
105
|
const code = flags.otp || (await prompt('Enter the 6-digit code sent to your email: '));
|
|
95
106
|
if (!code) throw new Error('A verification code is required to sign in.');
|
|
96
|
-
r = await
|
|
107
|
+
r = await identityPost('/login', {
|
|
97
108
|
email,
|
|
98
109
|
password,
|
|
99
110
|
emailOtpCode: String(code).trim(),
|
package/commands/logout.mjs
CHANGED
|
@@ -11,7 +11,7 @@ export async function logout() {
|
|
|
11
11
|
try {
|
|
12
12
|
await apiPost('/auth/logout', {}, { token });
|
|
13
13
|
} catch (e) {
|
|
14
|
-
info(color.dim(`(server-side revoke failed: ${e?.message || e}
|
|
14
|
+
info(color.dim(`(server-side revoke failed: ${e?.message || e} - clearing local creds anyway)`));
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
await clearCreds();
|
package/commands/open.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export async function open({ flags }) {
|
|
|
12
12
|
try {
|
|
13
13
|
const p = await apiGet(`/projects/${projectId}`);
|
|
14
14
|
const primary = p.domains?.find((d) => d.isPrimary) ?? p.domains?.[0];
|
|
15
|
-
if (!primary) return fail('Project has no domains yet
|
|
15
|
+
if (!primary) return fail('Project has no domains yet - deploy first.');
|
|
16
16
|
url = `https://${primary.domain}`;
|
|
17
17
|
} catch (e) {
|
|
18
18
|
return fail(e.message);
|
package/commands/whoami.mjs
CHANGED
|
@@ -10,6 +10,6 @@ export async function whoami() {
|
|
|
10
10
|
info(`${color.bold(me.email)} ${color.dim(`(${me.role})`)}`);
|
|
11
11
|
info(color.dim(`API: ${API_URL}`));
|
|
12
12
|
} catch (e) {
|
|
13
|
-
fail(`${e.message}
|
|
13
|
+
fail(`${e.message} - try \`glash login\` again`);
|
|
14
14
|
}
|
|
15
15
|
}
|
package/lib/api.mjs
CHANGED
|
@@ -65,7 +65,7 @@ export async function api(method, path, { body, token, query, retries = DEFAULT_
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
// FormData bodies carry file Blobs backed by the on-disk zip, so they are
|
|
68
|
-
// re-streamable
|
|
68
|
+
// re-streamable - fetch reads them fresh on each attempt, which lets us
|
|
69
69
|
// retry uploads without re-zipping.
|
|
70
70
|
const requestBody = isForm ? body : (body === undefined ? undefined : JSON.stringify(body));
|
|
71
71
|
const effectiveTimeout = timeoutMs ?? (isForm ? UPLOAD_TIMEOUT_MS : DEFAULT_TIMEOUT_MS);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { access, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
|
|
6
|
+
const LOCKFILES = [
|
|
7
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
8
|
+
['yarn.lock', 'yarn'],
|
|
9
|
+
['bun.lock', 'bun'],
|
|
10
|
+
['bun.lockb', 'bun'],
|
|
11
|
+
['package-lock.json', 'npm'],
|
|
12
|
+
['npm-shrinkwrap.json', 'npm'],
|
|
13
|
+
];
|
|
14
|
+
const MAX_CAPTURED_OUTPUT = 64 * 1024;
|
|
15
|
+
|
|
16
|
+
export async function planProjectBuild(cwd, { packageJson } = {}) {
|
|
17
|
+
const pkg = packageJson ?? await readPackageJson(cwd);
|
|
18
|
+
if (!pkg) {
|
|
19
|
+
return { status: 'skipped', reason: 'no-package-json' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const script = String(pkg.scripts?.build || '').trim();
|
|
23
|
+
if (!script) {
|
|
24
|
+
return { status: 'skipped', reason: 'no-build-script' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const packageManager = await detectPackageManager(cwd, pkg);
|
|
28
|
+
const args = ['run', 'build'];
|
|
29
|
+
return {
|
|
30
|
+
status: 'ready',
|
|
31
|
+
command: packageManager,
|
|
32
|
+
args,
|
|
33
|
+
displayCommand: `${packageManager} ${args.join(' ')}`,
|
|
34
|
+
script,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function buildProjectBeforeDeploy(cwd, {
|
|
39
|
+
packageJson,
|
|
40
|
+
skip = false,
|
|
41
|
+
onStart,
|
|
42
|
+
onLine,
|
|
43
|
+
spawnImpl = spawn,
|
|
44
|
+
env = process.env,
|
|
45
|
+
} = {}) {
|
|
46
|
+
if (skip) return { status: 'skipped', reason: 'disabled' };
|
|
47
|
+
|
|
48
|
+
const plan = await planProjectBuild(cwd, { packageJson });
|
|
49
|
+
if (plan.status !== 'ready') return plan;
|
|
50
|
+
|
|
51
|
+
onStart?.(plan);
|
|
52
|
+
const startedAt = Date.now();
|
|
53
|
+
let capturedOutput = '';
|
|
54
|
+
|
|
55
|
+
const appendOutput = (stream, line) => {
|
|
56
|
+
const text = String(line || '').trimEnd();
|
|
57
|
+
if (!text) return;
|
|
58
|
+
capturedOutput += `${stream === 'stderr' ? '[stderr] ' : ''}${text}\n`;
|
|
59
|
+
if (capturedOutput.length > MAX_CAPTURED_OUTPUT) {
|
|
60
|
+
capturedOutput = capturedOutput.slice(-MAX_CAPTURED_OUTPUT);
|
|
61
|
+
}
|
|
62
|
+
try { onLine?.({ stream, line: text }); } catch {}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
const child = spawnImpl(plan.command, plan.args, {
|
|
67
|
+
cwd,
|
|
68
|
+
env: { ...env, GLASH_CLI_BUILD_RUNNING: '1' },
|
|
69
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
streamLines(child.stdout, (line) => appendOutput('stdout', line));
|
|
73
|
+
streamLines(child.stderr, (line) => appendOutput('stderr', line));
|
|
74
|
+
|
|
75
|
+
child.once('error', (cause) => {
|
|
76
|
+
const error = new Error(`Could not start ${plan.displayCommand}: ${cause.message}`, { cause });
|
|
77
|
+
error.buildOutput = capturedOutput;
|
|
78
|
+
error.buildPlan = plan;
|
|
79
|
+
reject(error);
|
|
80
|
+
});
|
|
81
|
+
child.once('close', (code, signal) => {
|
|
82
|
+
if (code === 0) return resolve();
|
|
83
|
+
const outcome = signal ? `signal ${signal}` : `exit code ${code ?? 'unknown'}`;
|
|
84
|
+
const error = new Error(`${plan.displayCommand} failed with ${outcome}`);
|
|
85
|
+
error.exitCode = code;
|
|
86
|
+
error.signal = signal;
|
|
87
|
+
error.buildOutput = capturedOutput;
|
|
88
|
+
error.buildPlan = plan;
|
|
89
|
+
reject(error);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
...plan,
|
|
95
|
+
status: 'built',
|
|
96
|
+
durationMs: Date.now() - startedAt,
|
|
97
|
+
output: capturedOutput,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function compactBuildOutputLine(line, maxLength = 110) {
|
|
102
|
+
const compact = String(line || '')
|
|
103
|
+
.replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '')
|
|
104
|
+
.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')
|
|
105
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
|
|
106
|
+
.replace(/\s+/g, ' ')
|
|
107
|
+
.trim();
|
|
108
|
+
if (!compact) return '';
|
|
109
|
+
return compact.length > maxLength ? `${compact.slice(0, maxLength - 1)}…` : compact;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function detectPackageManager(cwd, pkg) {
|
|
113
|
+
const declared = String(pkg.packageManager || '').trim().toLowerCase().match(/^([a-z]+)(?:@|$)/)?.[1];
|
|
114
|
+
if (declared && PACKAGE_MANAGERS.has(declared)) return declared;
|
|
115
|
+
|
|
116
|
+
for (const [filename, packageManager] of LOCKFILES) {
|
|
117
|
+
try {
|
|
118
|
+
await access(join(cwd, filename));
|
|
119
|
+
return packageManager;
|
|
120
|
+
} catch {}
|
|
121
|
+
}
|
|
122
|
+
return 'npm';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function readPackageJson(cwd) {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8'));
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function streamLines(stream, onLine) {
|
|
134
|
+
if (!stream) return;
|
|
135
|
+
let pending = '';
|
|
136
|
+
stream.setEncoding('utf8');
|
|
137
|
+
stream.on('data', (chunk) => {
|
|
138
|
+
pending += chunk;
|
|
139
|
+
const lines = pending.split(/\r?\n|\r/);
|
|
140
|
+
pending = lines.pop() ?? '';
|
|
141
|
+
for (const line of lines) onLine(line);
|
|
142
|
+
});
|
|
143
|
+
stream.on('end', () => {
|
|
144
|
+
if (pending) onLine(pending);
|
|
145
|
+
});
|
|
146
|
+
}
|
package/lib/ui.mjs
CHANGED
|
@@ -25,7 +25,7 @@ export async function prompt(question, { mask = false } = {}) {
|
|
|
25
25
|
rl.close();
|
|
26
26
|
return a.trim();
|
|
27
27
|
}
|
|
28
|
-
// Masked input
|
|
28
|
+
// Masked input - write * for each typed char. readline doesn't natively
|
|
29
29
|
// mask, so we monkey-patch the output stream for this prompt only.
|
|
30
30
|
const orig = rl._writeToOutput?.bind(rl);
|
|
31
31
|
rl._writeToOutput = (s) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glash/cli",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "glashDB command-line interface
|
|
3
|
+
"version": "0.2.40",
|
|
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",
|
|
7
7
|
"repository": {
|
|
@@ -30,13 +30,9 @@
|
|
|
30
30
|
"database"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"chalk": "^5.3.0",
|
|
34
|
-
"commander": "^12.1.0",
|
|
35
33
|
"dotenv": "^16.4.5",
|
|
36
34
|
"fast-glob": "^3.3.3",
|
|
37
35
|
"fs-extra": "^11.2.0",
|
|
38
|
-
"open": "^10.1.0",
|
|
39
|
-
"ora": "^8.0.1",
|
|
40
36
|
"yazl": "^3.3.1"
|
|
41
37
|
},
|
|
42
38
|
"publishConfig": {
|