@glash/cli 0.2.44 → 0.2.46
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 +7 -9
- package/bin/glash.mjs +4 -3
- package/commands/deploy.mjs +6 -1
- package/commands/pull.mjs +55 -12
- package/lib/api.mjs +39 -13
- package/lib/project-build.mjs +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -109,13 +109,11 @@ 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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
intentionally need to bypass this pre-deploy check. Remote Git deployments
|
|
118
|
-
continue to build on the Glash build worker.
|
|
112
|
+
The Glash build worker compiles uploaded source, so `glash deploy` does not run
|
|
113
|
+
the same build locally by default. Use `glash deploy --build` when you want an
|
|
114
|
+
additional local preflight build before upload. The package manager comes from
|
|
115
|
+
`package.json` or the project's lockfile, and a failed preflight stops before
|
|
116
|
+
source is uploaded. `--no-build` remains accepted for backward compatibility.
|
|
119
117
|
|
|
120
118
|
Framework/runtime checks:
|
|
121
119
|
|
|
@@ -150,8 +148,8 @@ For glashjs projects, `glash deploy` detects `glashjs` or `glash.config.mjs`, ma
|
|
|
150
148
|
| `glash projects:rm <slug>` | Delete a project |
|
|
151
149
|
| `glash link <slug>` | Link the current directory to an existing project |
|
|
152
150
|
| `glash open` | Open the linked project's primary domain |
|
|
153
|
-
| `glash deploy [--prod] [--detach]` |
|
|
154
|
-
| `glash deploy --
|
|
151
|
+
| `glash deploy [--prod] [--detach]` | Upload and deploy the linked project; streams the pipeline until live |
|
|
152
|
+
| `glash deploy --build` | Run an optional local preflight build before uploading source |
|
|
155
153
|
| `glash deploy --asset-mode offload` | Upload `public/` assets to object storage/CDN before zipping source |
|
|
156
154
|
| `glash deploy --source-upload chunked` | Stage the source zip through multipart object storage to bypass Cloudflare's single-request cap |
|
|
157
155
|
| `glash deployments` | List recent deployments |
|
package/bin/glash.mjs
CHANGED
|
@@ -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] Upload 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
|
|
@@ -88,7 +88,7 @@ ${color.bold('DATABASE')}
|
|
|
88
88
|
database upload Restore the latest/local dump into linked glashDB database
|
|
89
89
|
|
|
90
90
|
${color.bold('PULL')}
|
|
91
|
-
pull [<slug>] [--out dir] [--no-env]
|
|
91
|
+
pull [<slug>] [--out dir] [--no-env] Unpack a project (asks which, if no slug)
|
|
92
92
|
|
|
93
93
|
${color.bold('SYSTEM')}
|
|
94
94
|
update Update @glash/cli to the latest version
|
|
@@ -98,7 +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
|
-
--
|
|
101
|
+
--build Run an optional local preflight build before upload
|
|
102
|
+
--no-build Skip local build (legacy compatibility; now the default)
|
|
102
103
|
--asset-mode <mode> Public asset handling: inline, auto, offload
|
|
103
104
|
--asset-threshold <size> Auto-offload threshold (default: 100mb)
|
|
104
105
|
--source-upload <mode> Source upload: auto, direct, chunked
|
package/commands/deploy.mjs
CHANGED
|
@@ -181,7 +181,11 @@ export async function deploy({ flags }) {
|
|
|
181
181
|
try {
|
|
182
182
|
localBuild = await buildProjectBeforeDeploy(process.cwd(), {
|
|
183
183
|
packageJson: projectProfile.runtime?.pkg,
|
|
184
|
-
|
|
184
|
+
// The Glash build host compiles the uploaded source, so doing the same
|
|
185
|
+
// work locally on every deploy doubles build time. Keep local preflight
|
|
186
|
+
// validation available as an explicit --build option.
|
|
187
|
+
skip: !flags.build || Boolean(flags['no-build']) || process.env.GLASH_CLI_BUILD_RUNNING === '1',
|
|
188
|
+
skipReason: flags['no-build'] ? 'disabled' : 'not-requested',
|
|
185
189
|
onStart: () => indicator.setPhase('prebuilding'),
|
|
186
190
|
});
|
|
187
191
|
report.localBuild = localBuild;
|
|
@@ -1305,6 +1309,7 @@ function formatLocalBuildSummary(build) {
|
|
|
1305
1309
|
}
|
|
1306
1310
|
if (build.status === 'failed') return `${build.command || 'build command'} failed`;
|
|
1307
1311
|
if (build.reason === 'disabled') return 'skipped (--no-build)';
|
|
1312
|
+
if (build.reason === 'not-requested') return 'remote build (use --build for local preflight)';
|
|
1308
1313
|
if (build.reason === 'no-package-json') return 'skipped (no package.json)';
|
|
1309
1314
|
if (build.reason === 'no-build-script') return 'skipped (no build script)';
|
|
1310
1315
|
return build.status || '-';
|
package/commands/pull.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// glash pull
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Unpacks a glashDB project's active deployed image to the current directory,
|
|
4
|
+
// plus its .env. Works however long ago the project was deployed.
|
|
5
5
|
//
|
|
6
|
-
// glash pull →
|
|
6
|
+
// glash pull → pick a project from your glashDB account
|
|
7
7
|
// glash pull <slug> → pulls a specific project by slug
|
|
8
8
|
// glash pull --out ../somewhere → write to a different directory
|
|
9
9
|
// glash pull --no-env → skip writing .env
|
|
@@ -16,7 +16,8 @@ import { createWriteStream } from 'node:fs';
|
|
|
16
16
|
import { Readable } from 'node:stream';
|
|
17
17
|
import { argv, cwd, exit, stdout, stderr } from 'node:process';
|
|
18
18
|
import { API_URL, getToken, readProjectLink } from '../lib/config.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import { apiGet } from '../lib/api.mjs';
|
|
20
|
+
import { color, info, fail, prompt } from '../lib/ui.mjs';
|
|
20
21
|
|
|
21
22
|
export async function pull(parsed) {
|
|
22
23
|
const flags = parsed?.flags ?? {};
|
|
@@ -25,15 +26,12 @@ export async function pull(parsed) {
|
|
|
25
26
|
const token = await getToken();
|
|
26
27
|
if (!token) return fail('Not signed in. Run `glash login` first.');
|
|
27
28
|
|
|
28
|
-
// Resolve which project to pull
|
|
29
|
+
// Resolve which project to pull. With no argument, pick from the projects on
|
|
30
|
+
// this glashDB account rather than guessing.
|
|
29
31
|
let slugOrId = positional[0] ?? flags.project;
|
|
30
32
|
if (!slugOrId) {
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
else if (link?.projectId) slugOrId = link.projectId;
|
|
34
|
-
}
|
|
35
|
-
if (!slugOrId) {
|
|
36
|
-
return fail('No project specified. Pass `glash pull <slug>` or run `glash link <slug>` first.');
|
|
33
|
+
slugOrId = await chooseProject();
|
|
34
|
+
if (!slugOrId) return;
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
const outDir = resolve(flags.out ? String(flags.out) : cwd());
|
|
@@ -52,6 +50,51 @@ export async function pull(parsed) {
|
|
|
52
50
|
stdout.write(color.dim(`\n next: cd ${outDir} && glash deploy\n`));
|
|
53
51
|
}
|
|
54
52
|
|
|
53
|
+
// Lists this account's projects and asks which one to pull. The project linked
|
|
54
|
+
// to the current directory, if any, is the default on Enter.
|
|
55
|
+
async function chooseProject() {
|
|
56
|
+
let projects;
|
|
57
|
+
try {
|
|
58
|
+
projects = await apiGet('/projects');
|
|
59
|
+
} catch (e) {
|
|
60
|
+
fail(`Could not list your projects: ${e.message}`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (!projects?.length) {
|
|
64
|
+
fail('This account has no projects yet. Run `glash projects:new` first.');
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const link = await readProjectLink().catch(() => null);
|
|
69
|
+
const linkedIndex = projects.findIndex(
|
|
70
|
+
(p) => (link?.projectId && p.id === link.projectId) || (link?.slug && p.slug === link.slug),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
stdout.write('\n Your projects:\n\n');
|
|
74
|
+
projects.forEach((p, i) => {
|
|
75
|
+
const marker = i === linkedIndex ? color.dim(' (linked here)') : '';
|
|
76
|
+
stdout.write(` ${String(i + 1).padStart(2)}. ${p.slug}${p.name && p.name !== p.slug ? color.dim(` - ${p.name}`) : ''}${marker}\n`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const fallback = linkedIndex >= 0 ? projects[linkedIndex] : null;
|
|
80
|
+
const suffix = fallback ? ` [${fallback.slug}]` : '';
|
|
81
|
+
const answer = (await prompt(`\n Which project do you want to pull?${suffix} `)).trim();
|
|
82
|
+
|
|
83
|
+
if (!answer) {
|
|
84
|
+
if (fallback) return fallback.slug;
|
|
85
|
+
fail('No project selected.');
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const byNumber = Number(answer);
|
|
89
|
+
if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= projects.length) {
|
|
90
|
+
return projects[byNumber - 1].slug;
|
|
91
|
+
}
|
|
92
|
+
const byName = projects.find((p) => p.slug === answer || p.id === answer);
|
|
93
|
+
if (byName) return byName.slug;
|
|
94
|
+
fail(`No project named "${answer}" on this account.`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
55
98
|
async function downloadAndExtract(token, slugOrId, outDir) {
|
|
56
99
|
const url = `${API_URL}/projects/${encodeURIComponent(slugOrId)}/source.tar.gz`;
|
|
57
100
|
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
@@ -66,7 +109,7 @@ async function downloadAndExtract(token, slugOrId, outDir) {
|
|
|
66
109
|
if (child.status !== 0) {
|
|
67
110
|
throw new Error(`tar extract failed: ${child.stderr?.toString() || 'unknown'}`);
|
|
68
111
|
}
|
|
69
|
-
info(color.dim(` ✓
|
|
112
|
+
info(color.dim(` ✓ project files unpacked to ${outDir}`));
|
|
70
113
|
}
|
|
71
114
|
|
|
72
115
|
async function downloadEnv(token, slugOrId, outDir, force) {
|
package/lib/api.mjs
CHANGED
|
@@ -21,13 +21,11 @@ function retryAfterMs(headers) {
|
|
|
21
21
|
|
|
22
22
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
23
|
|
|
24
|
-
// Transient
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504, 522, 524]);
|
|
30
|
-
const DEFAULT_RETRIES = 4;
|
|
24
|
+
// Transient edge and rate-limit statuses worth a short retry budget. A plain
|
|
25
|
+
// application HTTP 500 is intentionally excluded: retrying it four times made
|
|
26
|
+
// deterministic failures slow and hid useful Cloudflare 1101 diagnostics.
|
|
27
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 502, 503, 504, 522, 524]);
|
|
28
|
+
const DEFAULT_RETRIES = 2;
|
|
31
29
|
// Per-request wall-clock ceiling. Without this a contended/wedged backend
|
|
32
30
|
// leaves `glash deploy` hanging forever after "source bundle X MB" with no
|
|
33
31
|
// output. On timeout we abort and retry instead of hanging. Uploads (FormData)
|
|
@@ -43,6 +41,38 @@ function backoffMs(attempt, retryAfter) {
|
|
|
43
41
|
return Math.round(jitter);
|
|
44
42
|
}
|
|
45
43
|
|
|
44
|
+
export function sanitizeRemoteMessage(value, maxLength = 500) {
|
|
45
|
+
const clean = String(value ?? '')
|
|
46
|
+
.replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '')
|
|
47
|
+
.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')
|
|
48
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
|
|
49
|
+
.replace(/<[^>]*>/g, ' ')
|
|
50
|
+
.replace(/\s+/g, ' ')
|
|
51
|
+
.trim();
|
|
52
|
+
if (!clean) return '';
|
|
53
|
+
return clean.length > maxLength ? `${clean.slice(0, maxLength - 1)}…` : clean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function responseErrorMessage(status, rawBody) {
|
|
57
|
+
const raw = String(rawBody ?? '');
|
|
58
|
+
if (/\berror(?:\s+code\s*:|\s+)\s*1101\b/i.test(raw)) {
|
|
59
|
+
return 'GlashDB API is temporarily unavailable (Cloudflare error 1101). The backend container did not start.';
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const data = JSON.parse(raw);
|
|
63
|
+
const remote = Array.isArray(data?.message) ? data.message.join(', ') : data?.message;
|
|
64
|
+
const safe = sanitizeRemoteMessage(remote);
|
|
65
|
+
if (safe) return safe;
|
|
66
|
+
} catch {}
|
|
67
|
+
// Do not echo arbitrary HTML or terminal control sequences returned by a
|
|
68
|
+
// compromised proxy. Plain short text is useful; markup is not.
|
|
69
|
+
if (!/[<>]/.test(raw)) {
|
|
70
|
+
const safe = sanitizeRemoteMessage(raw, 240);
|
|
71
|
+
if (safe) return `HTTP ${status}: ${safe}`;
|
|
72
|
+
}
|
|
73
|
+
return `HTTP ${status}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
46
76
|
export async function api(method, path, { body, token, query, retries = DEFAULT_RETRIES, signal, timeoutMs } = {}) {
|
|
47
77
|
const headers = {
|
|
48
78
|
Accept: 'application/json',
|
|
@@ -115,12 +145,8 @@ export async function api(method, path, { body, token, query, retries = DEFAULT_
|
|
|
115
145
|
attempt += 1;
|
|
116
146
|
continue;
|
|
117
147
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const data = await res.json();
|
|
121
|
-
if (typeof data?.message === 'string') message = data.message;
|
|
122
|
-
else if (Array.isArray(data?.message)) message = data.message.join(', ');
|
|
123
|
-
} catch {}
|
|
148
|
+
const rawBody = await res.text().catch(() => '');
|
|
149
|
+
let message = responseErrorMessage(res.status, rawBody);
|
|
124
150
|
if (res.status === 401 && !path.includes('/auth/login') && !path.includes('/auth/cli/login')) {
|
|
125
151
|
message = `${message}. Run \`glash login\` again, then retry the command.`;
|
|
126
152
|
}
|
package/lib/project-build.mjs
CHANGED
|
@@ -38,12 +38,13 @@ export async function planProjectBuild(cwd, { packageJson } = {}) {
|
|
|
38
38
|
export async function buildProjectBeforeDeploy(cwd, {
|
|
39
39
|
packageJson,
|
|
40
40
|
skip = false,
|
|
41
|
+
skipReason = 'disabled',
|
|
41
42
|
onStart,
|
|
42
43
|
onLine,
|
|
43
44
|
spawnImpl = spawn,
|
|
44
45
|
env = process.env,
|
|
45
46
|
} = {}) {
|
|
46
|
-
if (skip) return { status: 'skipped', reason:
|
|
47
|
+
if (skip) return { status: 'skipped', reason: skipReason };
|
|
47
48
|
|
|
48
49
|
const plan = await planProjectBuild(cwd, { packageJson });
|
|
49
50
|
if (plan.status !== 'ready') return plan;
|
package/package.json
CHANGED