@glash/cli 0.2.6 → 0.2.12
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 +36 -2
- package/bin/glash.mjs +9 -2
- package/commands/database.mjs +297 -0
- package/commands/deploy.mjs +31 -3
- package/commands/login.mjs +3 -0
- package/commands/logout.mjs +14 -2
- package/lib/api.mjs +22 -3
- package/lib/version.mjs +1 -0
- package/lib/zip.mjs +74 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# @
|
|
1
|
+
# @glash/cli
|
|
2
2
|
|
|
3
3
|
The official command-line interface for [glashDB](https://glashdb.com).
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
npm install -g @
|
|
6
|
+
npm install -g @glash/cli
|
|
7
7
|
glash login
|
|
8
8
|
glash deploy
|
|
9
9
|
```
|
|
@@ -33,11 +33,45 @@ glash deploy
|
|
|
33
33
|
| `glash env:rm KEY` | Remove an env var |
|
|
34
34
|
| `glash env:pull [--out .env.local]` | Pull env vars to a local file |
|
|
35
35
|
| `glash env:push [<file>]` | Bulk upload from a `.env` file |
|
|
36
|
+
| `glash database dump [--url "$DATABASE_URL"]` | Dump a Postgres-compatible source database to `.glash/database-dumps/` |
|
|
37
|
+
| `glash database upload [--file dump.dump]` | Restore a local dump into the linked glashDB database |
|
|
36
38
|
| `glash update` | Update `@glash/cli` to the latest version |
|
|
37
39
|
|
|
38
40
|
Pass `--project <slug>` to any command to operate on a project other than
|
|
39
41
|
the one linked in the current directory.
|
|
40
42
|
|
|
43
|
+
## Database dump and upload
|
|
44
|
+
|
|
45
|
+
`glash database dump` looks for a Postgres-compatible source URL in this order:
|
|
46
|
+
|
|
47
|
+
1. `--url` / `--from`
|
|
48
|
+
2. the current shell environment
|
|
49
|
+
3. `.env`, `.env.local`, `.env.production`, `.env.development`
|
|
50
|
+
|
|
51
|
+
Supported env names include `DATABASE_URL`, `POSTGRES_URL`,
|
|
52
|
+
`POSTGRES_URL_NON_POOLING`, `DIRECT_URL`, and `SUPABASE_DB_URL`.
|
|
53
|
+
|
|
54
|
+
Examples:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
glash database dump
|
|
58
|
+
glash database dump --url "$DATABASE_URL" --out ./backup.dump
|
|
59
|
+
glash database dump --format plain --out ./backup.sql
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`glash database upload` restores the latest dump into the linked glashDB
|
|
63
|
+
project. It fetches the project's revealed direct database URL from the glashDB
|
|
64
|
+
API and runs `pg_restore` for custom dumps or `psql` for `.sql` dumps.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
glash link my-project
|
|
68
|
+
glash database upload --yes
|
|
69
|
+
glash database upload --file ./backup.sql --format plain --project my-project
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The commands require PostgreSQL client tools locally: `pg_dump` for dumps,
|
|
73
|
+
`pg_restore` for custom-format restores, and `psql` for plain SQL restores.
|
|
74
|
+
|
|
41
75
|
## Environment
|
|
42
76
|
|
|
43
77
|
| Variable | Default | Notes |
|
package/bin/glash.mjs
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
import { argv, exit } from 'node:process';
|
|
3
3
|
import { parseArgs } from '../lib/args.mjs';
|
|
4
4
|
import { info, fail, color } from '../lib/ui.mjs';
|
|
5
|
-
|
|
6
|
-
const VERSION = '0.2.6';
|
|
5
|
+
import { VERSION } from '../lib/version.mjs';
|
|
7
6
|
|
|
8
7
|
const sub = argv[2];
|
|
9
8
|
const rest = argv.slice(3);
|
|
@@ -40,6 +39,10 @@ const commands = {
|
|
|
40
39
|
'env:pull': () => import('../commands/env.mjs').then((m) => m.envPull(parsed)),
|
|
41
40
|
'env:push': () => import('../commands/env.mjs').then((m) => m.envPush(parsed)),
|
|
42
41
|
|
|
42
|
+
database: () => import('../commands/database.mjs').then((m) => m.database(parsed)),
|
|
43
|
+
'database:dump': () => import('../commands/database.mjs').then((m) => m.databaseDump(parsed)),
|
|
44
|
+
'database:upload': () => import('../commands/database.mjs').then((m) => m.databaseUpload(parsed)),
|
|
45
|
+
|
|
43
46
|
pull: () => import('../commands/pull.mjs').then((m) => m.pull(parsed)),
|
|
44
47
|
open: () => import('../commands/open.mjs').then((m) => m.open(parsed)),
|
|
45
48
|
update: () => import('../commands/update.mjs').then((m) => m.update(parsed)),
|
|
@@ -80,6 +83,10 @@ ${color.bold('ENV VARS')}
|
|
|
80
83
|
env:pull [--out .env.local] Write env vars to a local .env file
|
|
81
84
|
env:push [<file>] Bulk upload from a .env file
|
|
82
85
|
|
|
86
|
+
${color.bold('DATABASE')}
|
|
87
|
+
database dump Dump a Postgres-compatible DATABASE_URL locally
|
|
88
|
+
database upload Restore the latest/local dump into linked glashDB database
|
|
89
|
+
|
|
83
90
|
${color.bold('PULL')}
|
|
84
91
|
pull [<slug>] [--out dir] [--no-env] Pull source + .env from a glashDB project
|
|
85
92
|
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
5
|
+
import { cwd, env } from 'node:process';
|
|
6
|
+
import { parse as parseDotenv } from 'dotenv';
|
|
7
|
+
import { apiGet } from '../lib/api.mjs';
|
|
8
|
+
import { readProjectLink } from '../lib/config.mjs';
|
|
9
|
+
import { fail, info, ok, color, prompt } from '../lib/ui.mjs';
|
|
10
|
+
|
|
11
|
+
const DUMP_DIR = join(cwd(), '.glash', 'database-dumps');
|
|
12
|
+
const LATEST_FILE = join(DUMP_DIR, 'latest.json');
|
|
13
|
+
const ENV_FILES = ['.env', '.env.local', '.env.production', '.env.development'];
|
|
14
|
+
const SOURCE_ENV_KEYS = [
|
|
15
|
+
'DATABASE_URL',
|
|
16
|
+
'POSTGRES_URL',
|
|
17
|
+
'POSTGRES_PRISMA_URL',
|
|
18
|
+
'POSTGRES_URL_NON_POOLING',
|
|
19
|
+
'DIRECT_URL',
|
|
20
|
+
'SUPABASE_DB_URL',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export async function database(parsed) {
|
|
24
|
+
const action = parsed.positional[0];
|
|
25
|
+
const next = { ...parsed, positional: parsed.positional.slice(1) };
|
|
26
|
+
if (action === 'dump') return databaseDump(next);
|
|
27
|
+
if (action === 'upload') return databaseUpload(next);
|
|
28
|
+
if (action === 'migrate') return databaseMigrate(next);
|
|
29
|
+
return fail('Usage: glash database <dump|upload|migrate> [flags]');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* End-to-end Supabase → glashDB migration.
|
|
34
|
+
*
|
|
35
|
+
* glash database migrate \
|
|
36
|
+
* --from "$SUPABASE_DB_URL" # source connection (defaults to discovered DATABASE_URL/SUPABASE_DB_URL)
|
|
37
|
+
* --project my-app # destination glashDB project (defaults to linked project)
|
|
38
|
+
* --include-auth-users # also copy auth.users → glashauth (bcrypt hashes preserved)
|
|
39
|
+
* --yes # skip confirmation prompts
|
|
40
|
+
*
|
|
41
|
+
* Steps: dump (custom format) → confirm → restore --clean --if-exists → optional auth.users copy.
|
|
42
|
+
*/
|
|
43
|
+
export async function databaseMigrate({ flags, positional }) {
|
|
44
|
+
const sourceUrl = flags.from || flags.url || positional[0] || await discoverSourceUrl();
|
|
45
|
+
if (!sourceUrl) return fail('No source URL. Pass --from "$SUPABASE_DB_URL" or set DATABASE_URL/SUPABASE_DB_URL.');
|
|
46
|
+
|
|
47
|
+
const project = await resolveProject(flags.project);
|
|
48
|
+
const yes = Boolean(flags.yes || flags.y);
|
|
49
|
+
if (!yes) {
|
|
50
|
+
const answer = await prompt(`Migrate into glashDB project "${project.slug || project.id}"? Type "migrate" to continue: `);
|
|
51
|
+
if (answer !== 'migrate') return fail('Aborted');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 1. dump
|
|
55
|
+
await databaseDump({ flags: { ...flags, url: sourceUrl, format: 'custom' }, positional: [] });
|
|
56
|
+
// 2. upload (the latest dump is already pointed at by .glash/database-dumps/latest.json)
|
|
57
|
+
await databaseUpload({ flags: { ...flags, project: project.slug || project.id, yes: true }, positional: [] });
|
|
58
|
+
// 3. optional auth users
|
|
59
|
+
if (flags['include-auth-users'] || flags.includeAuthUsers) {
|
|
60
|
+
await copyAuthUsers(sourceUrl, project.id);
|
|
61
|
+
}
|
|
62
|
+
ok(`Migration complete → ${project.slug || project.id}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function copyAuthUsers(sourceUrl, projectId) {
|
|
66
|
+
info(color.dim('→ copying auth.users → glashauth (bcrypt hashes preserved)'));
|
|
67
|
+
const source = parsePostgresUrl(sourceUrl, 'source');
|
|
68
|
+
// Stream rows out via psql --csv, then POST to the backend importer.
|
|
69
|
+
const psql = 'psql';
|
|
70
|
+
const sql = `COPY (SELECT id, email, encrypted_password, raw_user_meta_data->>'full_name' AS full_name, created_at FROM auth.users WHERE email IS NOT NULL) TO STDOUT WITH CSV HEADER`;
|
|
71
|
+
const child = spawn(psql, ['-h', source.host, '-p', String(source.port), '-U', source.user, '-d', source.database, '-c', sql], {
|
|
72
|
+
env: processEnvForDb(source),
|
|
73
|
+
});
|
|
74
|
+
const chunks = [];
|
|
75
|
+
child.stdout.on('data', (d) => chunks.push(d));
|
|
76
|
+
const errs = [];
|
|
77
|
+
child.stderr.on('data', (d) => errs.push(d));
|
|
78
|
+
await new Promise((res, rej) => {
|
|
79
|
+
child.on('error', rej);
|
|
80
|
+
child.on('close', (code) => code === 0 ? res() : rej(new Error(`psql exit ${code}: ${Buffer.concat(errs).toString().slice(0, 400)}`)));
|
|
81
|
+
});
|
|
82
|
+
const csv = Buffer.concat(chunks).toString('utf8');
|
|
83
|
+
const { apiPost } = await import('../lib/api.mjs');
|
|
84
|
+
const result = await apiPost(`/database/migrate/${projectId}/import-auth-users`, { csv });
|
|
85
|
+
ok(`Imported ${result.imported ?? 0} auth users (${result.skipped ?? 0} skipped).`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function databaseDump({ flags, positional }) {
|
|
89
|
+
const sourceUrl = flags.url || flags.from || positional[0] || await discoverSourceUrl();
|
|
90
|
+
if (!sourceUrl) {
|
|
91
|
+
return fail([
|
|
92
|
+
'No source database URL found.',
|
|
93
|
+
'Pass one with `glash database dump --url "$DATABASE_URL"` or add DATABASE_URL to .env.',
|
|
94
|
+
].join('\n'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const format = normalizeFormat(flags.format || 'custom');
|
|
98
|
+
const source = parsePostgresUrl(sourceUrl, 'source');
|
|
99
|
+
const out = resolve(flags.out || flags.file || defaultDumpPath(source.database, format));
|
|
100
|
+
await mkdir(dirname(out), { recursive: true });
|
|
101
|
+
|
|
102
|
+
const pgDump = flags['pg-dump'] || flags.pgDump || 'pg_dump';
|
|
103
|
+
const args = [
|
|
104
|
+
'-h', source.host,
|
|
105
|
+
'-p', String(source.port),
|
|
106
|
+
'-U', source.user,
|
|
107
|
+
'-d', source.database,
|
|
108
|
+
'--no-owner',
|
|
109
|
+
'--no-acl',
|
|
110
|
+
'-F', format === 'plain' ? 'p' : 'c',
|
|
111
|
+
'-f', out,
|
|
112
|
+
];
|
|
113
|
+
if (flags.schema) args.push('-n', String(flags.schema));
|
|
114
|
+
if (flags.table) {
|
|
115
|
+
const tables = Array.isArray(flags.table) ? flags.table : String(flags.table).split(',');
|
|
116
|
+
for (const table of tables.map((t) => t.trim()).filter(Boolean)) args.push('-t', table);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
info(color.dim(`→ dumping ${source.host}/${source.database} to ${out}`));
|
|
120
|
+
await runCommand(pgDump, args, { ...processEnvForDb(source), PGCONNECT_TIMEOUT: '15' });
|
|
121
|
+
|
|
122
|
+
const manifest = {
|
|
123
|
+
file: out,
|
|
124
|
+
format,
|
|
125
|
+
createdAt: new Date().toISOString(),
|
|
126
|
+
source: { host: source.host, port: source.port, database: source.database, user: source.user },
|
|
127
|
+
};
|
|
128
|
+
await writeFile(`${out}.json`, JSON.stringify(manifest, null, 2));
|
|
129
|
+
await writeFile(LATEST_FILE, JSON.stringify(manifest, null, 2));
|
|
130
|
+
ok(`Database dump saved: ${out}`);
|
|
131
|
+
info(color.dim(` manifest: ${out}.json`));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function databaseUpload({ flags, positional }) {
|
|
135
|
+
const project = await resolveProject(flags.project);
|
|
136
|
+
const dump = await resolveDumpFile(flags.file || positional[0]);
|
|
137
|
+
if (!dump.file || !existsSync(dump.file)) return fail(`Dump file not found: ${dump.file}`);
|
|
138
|
+
|
|
139
|
+
const targetUrl = flags.url || flags.to || await getTargetDatabaseUrl(project.id);
|
|
140
|
+
const target = parsePostgresUrl(targetUrl, 'target');
|
|
141
|
+
const format = normalizeFormat(flags.format || dump.format || inferFormat(dump.file));
|
|
142
|
+
const yes = Boolean(flags.yes || flags.y);
|
|
143
|
+
const clean = flags.clean !== false && flags['no-clean'] !== true;
|
|
144
|
+
|
|
145
|
+
info(color.dim(`→ uploading ${dump.file} into ${project.slug || project.id} (${target.host}/${target.database})`));
|
|
146
|
+
if (!yes) {
|
|
147
|
+
const answer = await prompt(`Restore into glashDB project "${project.slug || project.id}"? Type "upload" to continue: `);
|
|
148
|
+
if (answer !== 'upload') return fail('Aborted');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (format === 'plain') {
|
|
152
|
+
const psql = flags.psql || 'psql';
|
|
153
|
+
const args = [
|
|
154
|
+
'-h', target.host,
|
|
155
|
+
'-p', String(target.port),
|
|
156
|
+
'-U', target.user,
|
|
157
|
+
'-d', target.database,
|
|
158
|
+
'-v', 'ON_ERROR_STOP=1',
|
|
159
|
+
'-f', dump.file,
|
|
160
|
+
];
|
|
161
|
+
await runCommand(psql, args, processEnvForDb(target));
|
|
162
|
+
} else {
|
|
163
|
+
const pgRestore = flags['pg-restore'] || flags.pgRestore || 'pg_restore';
|
|
164
|
+
const args = [
|
|
165
|
+
'-h', target.host,
|
|
166
|
+
'-p', String(target.port),
|
|
167
|
+
'-U', target.user,
|
|
168
|
+
'-d', target.database,
|
|
169
|
+
'--no-owner',
|
|
170
|
+
'--no-acl',
|
|
171
|
+
'--verbose',
|
|
172
|
+
];
|
|
173
|
+
if (clean) args.push('--clean', '--if-exists');
|
|
174
|
+
if (flags.schema) args.push('-n', String(flags.schema));
|
|
175
|
+
args.push(dump.file);
|
|
176
|
+
await runCommand(pgRestore, args, processEnvForDb(target));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
ok(`Uploaded database dump to ${project.slug || project.id}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function discoverSourceUrl() {
|
|
183
|
+
for (const key of SOURCE_ENV_KEYS) {
|
|
184
|
+
if (env[key]) return env[key];
|
|
185
|
+
}
|
|
186
|
+
for (const file of ENV_FILES) {
|
|
187
|
+
const path = join(cwd(), file);
|
|
188
|
+
if (!existsSync(path)) continue;
|
|
189
|
+
try {
|
|
190
|
+
const parsed = parseDotenv(await readFile(path, 'utf8'));
|
|
191
|
+
for (const key of SOURCE_ENV_KEYS) {
|
|
192
|
+
if (parsed[key]) return parsed[key];
|
|
193
|
+
}
|
|
194
|
+
} catch {}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function resolveProject(projectArg) {
|
|
200
|
+
if (projectArg) {
|
|
201
|
+
const all = await apiGet('/projects');
|
|
202
|
+
const match = all.find((p) => p.id === projectArg || p.slug === projectArg);
|
|
203
|
+
if (!match) fail(`No project found matching "${projectArg}". Run \`glash projects\` to list.`);
|
|
204
|
+
return match;
|
|
205
|
+
}
|
|
206
|
+
const link = await readProjectLink();
|
|
207
|
+
if (!link?.projectId) {
|
|
208
|
+
fail('No linked project. Run `glash link <slug>` or pass `--project <slug|id>`.');
|
|
209
|
+
}
|
|
210
|
+
return { id: link.projectId, slug: link.slug };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function getTargetDatabaseUrl(projectId) {
|
|
214
|
+
const conn = await apiGet(`/database/connection/${projectId}?reveal=1`);
|
|
215
|
+
if (!conn?.direct || /••/.test(conn.direct)) {
|
|
216
|
+
fail('Could not reveal the glashDB direct database URL for this project.');
|
|
217
|
+
}
|
|
218
|
+
return conn.direct;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function resolveDumpFile(explicit) {
|
|
222
|
+
if (explicit) {
|
|
223
|
+
return { file: resolve(explicit), format: inferFormat(explicit) };
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const latest = JSON.parse(await readFile(LATEST_FILE, 'utf8'));
|
|
227
|
+
return { file: latest.file, format: latest.format };
|
|
228
|
+
} catch {
|
|
229
|
+
return fail('No dump file provided and no latest dump exists. Run `glash database dump` first or pass `--file <path>`.');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parsePostgresUrl(value, label) {
|
|
234
|
+
let url;
|
|
235
|
+
try {
|
|
236
|
+
url = new URL(value);
|
|
237
|
+
} catch {
|
|
238
|
+
fail(`Invalid ${label} database URL.`);
|
|
239
|
+
}
|
|
240
|
+
if (!['postgres:', 'postgresql:'].includes(url.protocol)) {
|
|
241
|
+
fail(`${label} database URL must start with postgres:// or postgresql://`);
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
host: url.hostname,
|
|
245
|
+
port: Number(url.port || 5432),
|
|
246
|
+
database: decodeURIComponent(url.pathname.replace(/^\//, '') || 'postgres'),
|
|
247
|
+
user: decodeURIComponent(url.username || ''),
|
|
248
|
+
password: decodeURIComponent(url.password || ''),
|
|
249
|
+
sslmode: url.searchParams.get('sslmode') || undefined,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function processEnvForDb(db) {
|
|
254
|
+
return {
|
|
255
|
+
...env,
|
|
256
|
+
PGPASSWORD: db.password,
|
|
257
|
+
PGSSLMODE: db.sslmode || env.PGSSLMODE || 'require',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function normalizeFormat(format) {
|
|
262
|
+
const f = String(format).toLowerCase();
|
|
263
|
+
if (['plain', 'sql', 'p'].includes(f)) return 'plain';
|
|
264
|
+
if (['custom', 'dump', 'dmp', 'c', 'auto'].includes(f)) return 'custom';
|
|
265
|
+
fail(`Unsupported dump format "${format}". Use custom or plain.`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function inferFormat(file) {
|
|
269
|
+
const ext = extname(file).toLowerCase();
|
|
270
|
+
return ext === '.sql' ? 'plain' : 'custom';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function defaultDumpPath(database, format) {
|
|
274
|
+
const safeDb = String(database || 'database').replace(/[^a-z0-9_.-]+/gi, '-');
|
|
275
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
276
|
+
return join(DUMP_DIR, `${safeDb}-${stamp}.${format === 'plain' ? 'sql' : 'dump'}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function runCommand(command, args, commandEnv) {
|
|
280
|
+
return new Promise((resolvePromise, reject) => {
|
|
281
|
+
const child = spawn(command, args, {
|
|
282
|
+
env: commandEnv,
|
|
283
|
+
stdio: 'inherit',
|
|
284
|
+
});
|
|
285
|
+
child.on('error', (e) => {
|
|
286
|
+
if (e.code === 'ENOENT') {
|
|
287
|
+
reject(new Error(`${command} was not found. Install PostgreSQL client tools, then retry.`));
|
|
288
|
+
} else {
|
|
289
|
+
reject(e);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
child.on('close', (code) => {
|
|
293
|
+
if (code === 0) resolvePromise();
|
|
294
|
+
else reject(new Error(`${basename(command)} exited with code ${code}`));
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
}
|
package/commands/deploy.mjs
CHANGED
|
@@ -177,10 +177,27 @@ export async function deploy({ flags }) {
|
|
|
177
177
|
const seen = new Set();
|
|
178
178
|
let lastStatus = '';
|
|
179
179
|
const start = Date.now();
|
|
180
|
+
let pollDelayMs = 5000;
|
|
181
|
+
let lastProgressAt = Date.now();
|
|
180
182
|
while (Date.now() - start < 15 * 60 * 1000) {
|
|
181
183
|
let pipe;
|
|
182
|
-
try {
|
|
183
|
-
|
|
184
|
+
try {
|
|
185
|
+
pipe = await apiGet(`/deployments/${dep.id}/pipeline`);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (e.status === 429) {
|
|
188
|
+
const wait = Math.min(Math.max(e.retryAfterMs ?? pollDelayMs * 2, 10000), 60000);
|
|
189
|
+
pollDelayMs = wait;
|
|
190
|
+
await sleep(wait);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (Date.now() - lastProgressAt > 60_000) {
|
|
194
|
+
info(color.dim(` • deploy Still working; waiting for the next deployment update…`));
|
|
195
|
+
lastProgressAt = Date.now();
|
|
196
|
+
}
|
|
197
|
+
pollDelayMs = Math.min(Math.max(pollDelayMs * 1.5, 5000), 30000);
|
|
198
|
+
await sleep(pollDelayMs);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
184
201
|
|
|
185
202
|
for (const ev of pipe.events ?? []) {
|
|
186
203
|
const key = `${ev.id ?? ev.createdAt}:${ev.step}:${ev.status}`;
|
|
@@ -188,6 +205,7 @@ export async function deploy({ flags }) {
|
|
|
188
205
|
seen.add(key);
|
|
189
206
|
const tag = stepBadge(ev.status);
|
|
190
207
|
info(` ${tag} ${color.dim(ev.step.padEnd(8))} ${ev.message ?? ''}`);
|
|
208
|
+
lastProgressAt = Date.now();
|
|
191
209
|
}
|
|
192
210
|
|
|
193
211
|
const status = pipe.deployStatus ?? pipe.buildStatus ?? '';
|
|
@@ -200,7 +218,8 @@ export async function deploy({ flags }) {
|
|
|
200
218
|
if (['error', 'failed'].includes(status)) {
|
|
201
219
|
return fail(`Deployment failed (${status}). Run \`glash logs ${dep.id}\` for details.`);
|
|
202
220
|
}
|
|
203
|
-
|
|
221
|
+
pollDelayMs = nextPollDelay(pipe);
|
|
222
|
+
await sleep(pollDelayMs);
|
|
204
223
|
}
|
|
205
224
|
warn('Timed out waiting for deployment. Check `glash status <id>`.');
|
|
206
225
|
}
|
|
@@ -272,4 +291,13 @@ function stepBadge(status) {
|
|
|
272
291
|
return color.cyan('•');
|
|
273
292
|
}
|
|
274
293
|
|
|
294
|
+
function nextPollDelay(pipe) {
|
|
295
|
+
const status = pipe.deployStatus ?? pipe.buildStatus ?? '';
|
|
296
|
+
const last = [...(pipe.events ?? [])].reverse().find(Boolean);
|
|
297
|
+
if (status === 'queued' || last?.status === 'queued') return 8000;
|
|
298
|
+
if (status === 'building' || last?.step === 'build') return 10000;
|
|
299
|
+
if (status === 'rolling-out') return 5000;
|
|
300
|
+
return 5000;
|
|
301
|
+
}
|
|
302
|
+
|
|
275
303
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
package/commands/login.mjs
CHANGED
|
@@ -9,6 +9,9 @@ export async function login({ flags }) {
|
|
|
9
9
|
|
|
10
10
|
try {
|
|
11
11
|
const r = await apiPost('/auth/login', { email, password });
|
|
12
|
+
if (!r?.accessToken || !r?.user?.email) {
|
|
13
|
+
throw new Error('Login response was missing a session token. Please try again.');
|
|
14
|
+
}
|
|
12
15
|
await writeCreds({ token: r.accessToken, email: r.user?.email });
|
|
13
16
|
ok(`Signed in as ${r.user?.email ?? email}`);
|
|
14
17
|
} catch (e) {
|
package/commands/logout.mjs
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { apiPost } from '../lib/api.mjs';
|
|
2
|
+
import { clearCreds, getToken } from '../lib/config.mjs';
|
|
3
|
+
import { ok, info, color } from '../lib/ui.mjs';
|
|
3
4
|
|
|
4
5
|
export async function logout() {
|
|
6
|
+
// Best-effort server-side revocation. If the token is already invalid or the
|
|
7
|
+
// server is unreachable, we still clear the local file so the user is signed
|
|
8
|
+
// out from this machine.
|
|
9
|
+
const token = await getToken();
|
|
10
|
+
if (token) {
|
|
11
|
+
try {
|
|
12
|
+
await apiPost('/auth/logout', {}, { token });
|
|
13
|
+
} catch (e) {
|
|
14
|
+
info(color.dim(`(server-side revoke failed: ${e?.message || e} — clearing local creds anyway)`));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
5
17
|
await clearCreds();
|
|
6
18
|
ok('Signed out');
|
|
7
19
|
}
|
package/lib/api.mjs
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
import { API_URL, getToken } from './config.mjs';
|
|
2
|
+
import { VERSION } from './version.mjs';
|
|
2
3
|
|
|
3
4
|
export class GlashApiError extends Error {
|
|
4
|
-
constructor(status, message) {
|
|
5
|
+
constructor(status, message, meta = {}) {
|
|
5
6
|
super(message);
|
|
6
7
|
this.status = status;
|
|
8
|
+
this.retryAfterMs = meta.retryAfterMs;
|
|
7
9
|
}
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
function retryAfterMs(headers) {
|
|
13
|
+
const raw = headers?.get?.('retry-after');
|
|
14
|
+
if (!raw) return undefined;
|
|
15
|
+
const seconds = Number(raw);
|
|
16
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
|
17
|
+
const date = Date.parse(raw);
|
|
18
|
+
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
export async function api(method, path, { body, token, query } = {}) {
|
|
11
|
-
const headers = {
|
|
23
|
+
const headers = {
|
|
24
|
+
Accept: 'application/json',
|
|
25
|
+
'User-Agent': `glash-cli/${VERSION}`,
|
|
26
|
+
'X-Glash-Client': 'cli',
|
|
27
|
+
};
|
|
12
28
|
const t = token ?? (await getToken());
|
|
13
29
|
if (t) headers.Authorization = `Bearer ${t}`;
|
|
14
30
|
if (body !== undefined && !(body instanceof FormData)) {
|
|
@@ -42,7 +58,10 @@ export async function api(method, path, { body, token, query } = {}) {
|
|
|
42
58
|
if (typeof data?.message === 'string') message = data.message;
|
|
43
59
|
else if (Array.isArray(data?.message)) message = data.message.join(', ');
|
|
44
60
|
} catch {}
|
|
45
|
-
|
|
61
|
+
if (res.status === 401 && !path.includes('/auth/login') && !path.includes('/auth/cli/login')) {
|
|
62
|
+
message = `${message}. Run \`glash login\` again, then retry the command.`;
|
|
63
|
+
}
|
|
64
|
+
throw new GlashApiError(res.status, message, { retryAfterMs: retryAfterMs(res.headers) });
|
|
46
65
|
}
|
|
47
66
|
|
|
48
67
|
if (res.status === 204) return undefined;
|
package/lib/version.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = '0.2.12';
|
package/lib/zip.mjs
CHANGED
|
@@ -6,7 +6,9 @@ import fs from 'fs-extra';
|
|
|
6
6
|
export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
|
|
7
7
|
return new Promise((resolve, reject) => {
|
|
8
8
|
const output = createWriteStream(outPath);
|
|
9
|
-
const
|
|
9
|
+
const rawLevel = Number(process.env.GLASH_ZIP_LEVEL ?? 1);
|
|
10
|
+
const level = Number.isInteger(rawLevel) && rawLevel >= 0 && rawLevel <= 9 ? rawLevel : 1;
|
|
11
|
+
const archive = archiver('zip', { zlib: { level } });
|
|
10
12
|
|
|
11
13
|
output.on('close', () => resolve(archive.pointer()));
|
|
12
14
|
archive.on('error', (err) => reject(err));
|
|
@@ -16,34 +18,61 @@ export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
|
|
|
16
18
|
// Default ignores
|
|
17
19
|
const defaultIgnore = [
|
|
18
20
|
'node_modules/**',
|
|
21
|
+
'**/node_modules/**',
|
|
19
22
|
'.git/**',
|
|
23
|
+
'**/.git/**',
|
|
20
24
|
'.next/**',
|
|
25
|
+
'**/.next/**',
|
|
21
26
|
'.next*/**',
|
|
27
|
+
'**/.next*/**',
|
|
22
28
|
'.nuxt/**',
|
|
29
|
+
'**/.nuxt/**',
|
|
23
30
|
'.nuxt*/**',
|
|
31
|
+
'**/.nuxt*/**',
|
|
24
32
|
'.output/**',
|
|
33
|
+
'**/.output/**',
|
|
25
34
|
'.svelte-kit/**',
|
|
35
|
+
'**/.svelte-kit/**',
|
|
26
36
|
'.wrangler/**',
|
|
37
|
+
'**/.wrangler/**',
|
|
27
38
|
'.netlify/**',
|
|
39
|
+
'**/.netlify/**',
|
|
28
40
|
'dist/**',
|
|
41
|
+
'**/dist/**',
|
|
29
42
|
'build/**',
|
|
43
|
+
'**/build/**',
|
|
30
44
|
'out/**',
|
|
45
|
+
'**/out/**',
|
|
31
46
|
'coverage/**',
|
|
47
|
+
'**/coverage/**',
|
|
32
48
|
'.turbo/**',
|
|
49
|
+
'**/.turbo/**',
|
|
33
50
|
'.cache/**',
|
|
51
|
+
'**/.cache/**',
|
|
34
52
|
'.parcel-cache/**',
|
|
53
|
+
'**/.parcel-cache/**',
|
|
35
54
|
'.vite/**',
|
|
55
|
+
'**/.vite/**',
|
|
36
56
|
'.glash/**',
|
|
57
|
+
'**/.glash/**',
|
|
37
58
|
'*.log',
|
|
59
|
+
'**/*.log',
|
|
38
60
|
'npm-debug.log*',
|
|
61
|
+
'**/npm-debug.log*',
|
|
39
62
|
'yarn-debug.log*',
|
|
63
|
+
'**/yarn-debug.log*',
|
|
40
64
|
'yarn-error.log*',
|
|
65
|
+
'**/yarn-error.log*',
|
|
41
66
|
'.env',
|
|
67
|
+
'**/.env',
|
|
42
68
|
'.env.local',
|
|
69
|
+
'**/.env.local',
|
|
43
70
|
'.env.production',
|
|
71
|
+
'**/.env.production',
|
|
44
72
|
'.env.development',
|
|
73
|
+
'**/.env.development',
|
|
45
74
|
];
|
|
46
|
-
const allIgnore = [...new Set([...defaultIgnore, ...
|
|
75
|
+
const allIgnore = [...new Set([...defaultIgnore, ...loadIgnorePatterns(sourceDir), ...ignore])];
|
|
47
76
|
|
|
48
77
|
archive.glob('**/*', {
|
|
49
78
|
cwd: sourceDir,
|
|
@@ -55,19 +84,51 @@ export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
|
|
|
55
84
|
});
|
|
56
85
|
}
|
|
57
86
|
|
|
58
|
-
function
|
|
59
|
-
const
|
|
60
|
-
if (!
|
|
87
|
+
function parseIgnorePattern(line) {
|
|
88
|
+
const raw = line.trim();
|
|
89
|
+
if (!raw || raw.startsWith('#') || raw.startsWith('!')) return [];
|
|
90
|
+
|
|
91
|
+
const hasLeadingSlash = raw.startsWith('/');
|
|
92
|
+
|
|
93
|
+
// Normalize by stripping leading and trailing slashes
|
|
94
|
+
let normalized = raw.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
95
|
+
if (!normalized) return [];
|
|
96
|
+
|
|
97
|
+
const hasMiddleSlash = normalized.includes('/');
|
|
98
|
+
const isRootRelative = hasLeadingSlash || hasMiddleSlash;
|
|
61
99
|
|
|
62
100
|
const patterns = [];
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (!normalized) continue;
|
|
69
|
-
patterns.push(normalized);
|
|
70
|
-
if (!/[*!?[\]{}()]/.test(normalized)) patterns.push(`${normalized}/**`);
|
|
101
|
+
|
|
102
|
+
// Add base patterns
|
|
103
|
+
patterns.push(normalized);
|
|
104
|
+
if (!normalized.endsWith('/**') && !normalized.endsWith('/*')) {
|
|
105
|
+
patterns.push(`${normalized}/**`);
|
|
71
106
|
}
|
|
107
|
+
|
|
108
|
+
// If it's not root-relative, match at any level
|
|
109
|
+
if (!isRootRelative) {
|
|
110
|
+
patterns.push(`**/${normalized}`);
|
|
111
|
+
if (!normalized.endsWith('/**') && !normalized.endsWith('/*')) {
|
|
112
|
+
patterns.push(`**/${normalized}/**`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
72
116
|
return patterns;
|
|
73
117
|
}
|
|
118
|
+
|
|
119
|
+
function loadIgnorePatterns(sourceDir) {
|
|
120
|
+
const files = ['.gitignore', '.glashignore'];
|
|
121
|
+
const allPatterns = [];
|
|
122
|
+
|
|
123
|
+
for (const filename of files) {
|
|
124
|
+
const path = join(sourceDir, filename);
|
|
125
|
+
if (!fs.existsSync(path)) continue;
|
|
126
|
+
|
|
127
|
+
const text = fs.readFileSync(path, 'utf8');
|
|
128
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
129
|
+
const parsed = parseIgnorePattern(raw);
|
|
130
|
+
allPatterns.push(...parsed);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return allPatterns;
|
|
134
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glash/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
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",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/glashdb/glashdb.git",
|
|
9
|
+
"url": "git+https://github.com/glashdb/glashdb.git",
|
|
10
10
|
"directory": "cli"
|
|
11
11
|
},
|
|
12
12
|
"bin": {
|