@glash/cli 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
- # @glashdb/cli
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 @glashdb/cli
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
@@ -3,7 +3,7 @@ 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
5
 
6
- const VERSION = '0.2.5';
6
+ const VERSION = '0.2.7';
7
7
 
8
8
  const sub = argv[2];
9
9
  const rest = argv.slice(3);
@@ -40,6 +40,10 @@ const commands = {
40
40
  'env:pull': () => import('../commands/env.mjs').then((m) => m.envPull(parsed)),
41
41
  'env:push': () => import('../commands/env.mjs').then((m) => m.envPush(parsed)),
42
42
 
43
+ database: () => import('../commands/database.mjs').then((m) => m.database(parsed)),
44
+ 'database:dump': () => import('../commands/database.mjs').then((m) => m.databaseDump(parsed)),
45
+ 'database:upload': () => import('../commands/database.mjs').then((m) => m.databaseUpload(parsed)),
46
+
43
47
  pull: () => import('../commands/pull.mjs').then((m) => m.pull(parsed)),
44
48
  open: () => import('../commands/open.mjs').then((m) => m.open(parsed)),
45
49
  update: () => import('../commands/update.mjs').then((m) => m.update(parsed)),
@@ -80,6 +84,10 @@ ${color.bold('ENV VARS')}
80
84
  env:pull [--out .env.local] Write env vars to a local .env file
81
85
  env:push [<file>] Bulk upload from a .env file
82
86
 
87
+ ${color.bold('DATABASE')}
88
+ database dump Dump a Postgres-compatible DATABASE_URL locally
89
+ database upload Restore the latest/local dump into linked glashDB database
90
+
83
91
  ${color.bold('PULL')}
84
92
  pull [<slug>] [--out dir] [--no-env] Pull source + .env from a glashDB project
85
93
 
@@ -0,0 +1,240 @@
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
+ return fail('Usage: glash database <dump|upload> [flags]');
29
+ }
30
+
31
+ export async function databaseDump({ flags, positional }) {
32
+ const sourceUrl = flags.url || flags.from || positional[0] || await discoverSourceUrl();
33
+ if (!sourceUrl) {
34
+ return fail([
35
+ 'No source database URL found.',
36
+ 'Pass one with `glash database dump --url "$DATABASE_URL"` or add DATABASE_URL to .env.',
37
+ ].join('\n'));
38
+ }
39
+
40
+ const format = normalizeFormat(flags.format || 'custom');
41
+ const source = parsePostgresUrl(sourceUrl, 'source');
42
+ const out = resolve(flags.out || flags.file || defaultDumpPath(source.database, format));
43
+ await mkdir(dirname(out), { recursive: true });
44
+
45
+ const pgDump = flags['pg-dump'] || flags.pgDump || 'pg_dump';
46
+ const args = [
47
+ '-h', source.host,
48
+ '-p', String(source.port),
49
+ '-U', source.user,
50
+ '-d', source.database,
51
+ '--no-owner',
52
+ '--no-acl',
53
+ '-F', format === 'plain' ? 'p' : 'c',
54
+ '-f', out,
55
+ ];
56
+ if (flags.schema) args.push('-n', String(flags.schema));
57
+ if (flags.table) {
58
+ const tables = Array.isArray(flags.table) ? flags.table : String(flags.table).split(',');
59
+ for (const table of tables.map((t) => t.trim()).filter(Boolean)) args.push('-t', table);
60
+ }
61
+
62
+ info(color.dim(`→ dumping ${source.host}/${source.database} to ${out}`));
63
+ await runCommand(pgDump, args, { ...processEnvForDb(source), PGCONNECT_TIMEOUT: '15' });
64
+
65
+ const manifest = {
66
+ file: out,
67
+ format,
68
+ createdAt: new Date().toISOString(),
69
+ source: { host: source.host, port: source.port, database: source.database, user: source.user },
70
+ };
71
+ await writeFile(`${out}.json`, JSON.stringify(manifest, null, 2));
72
+ await writeFile(LATEST_FILE, JSON.stringify(manifest, null, 2));
73
+ ok(`Database dump saved: ${out}`);
74
+ info(color.dim(` manifest: ${out}.json`));
75
+ }
76
+
77
+ export async function databaseUpload({ flags, positional }) {
78
+ const project = await resolveProject(flags.project);
79
+ const dump = await resolveDumpFile(flags.file || positional[0]);
80
+ if (!dump.file || !existsSync(dump.file)) return fail(`Dump file not found: ${dump.file}`);
81
+
82
+ const targetUrl = flags.url || flags.to || await getTargetDatabaseUrl(project.id);
83
+ const target = parsePostgresUrl(targetUrl, 'target');
84
+ const format = normalizeFormat(flags.format || dump.format || inferFormat(dump.file));
85
+ const yes = Boolean(flags.yes || flags.y);
86
+ const clean = flags.clean !== false && flags['no-clean'] !== true;
87
+
88
+ info(color.dim(`→ uploading ${dump.file} into ${project.slug || project.id} (${target.host}/${target.database})`));
89
+ if (!yes) {
90
+ const answer = await prompt(`Restore into glashDB project "${project.slug || project.id}"? Type "upload" to continue: `);
91
+ if (answer !== 'upload') return fail('Aborted');
92
+ }
93
+
94
+ if (format === 'plain') {
95
+ const psql = flags.psql || 'psql';
96
+ const args = [
97
+ '-h', target.host,
98
+ '-p', String(target.port),
99
+ '-U', target.user,
100
+ '-d', target.database,
101
+ '-v', 'ON_ERROR_STOP=1',
102
+ '-f', dump.file,
103
+ ];
104
+ await runCommand(psql, args, processEnvForDb(target));
105
+ } else {
106
+ const pgRestore = flags['pg-restore'] || flags.pgRestore || 'pg_restore';
107
+ const args = [
108
+ '-h', target.host,
109
+ '-p', String(target.port),
110
+ '-U', target.user,
111
+ '-d', target.database,
112
+ '--no-owner',
113
+ '--no-acl',
114
+ '--verbose',
115
+ ];
116
+ if (clean) args.push('--clean', '--if-exists');
117
+ if (flags.schema) args.push('-n', String(flags.schema));
118
+ args.push(dump.file);
119
+ await runCommand(pgRestore, args, processEnvForDb(target));
120
+ }
121
+
122
+ ok(`Uploaded database dump to ${project.slug || project.id}`);
123
+ }
124
+
125
+ async function discoverSourceUrl() {
126
+ for (const key of SOURCE_ENV_KEYS) {
127
+ if (env[key]) return env[key];
128
+ }
129
+ for (const file of ENV_FILES) {
130
+ const path = join(cwd(), file);
131
+ if (!existsSync(path)) continue;
132
+ try {
133
+ const parsed = parseDotenv(await readFile(path, 'utf8'));
134
+ for (const key of SOURCE_ENV_KEYS) {
135
+ if (parsed[key]) return parsed[key];
136
+ }
137
+ } catch {}
138
+ }
139
+ return null;
140
+ }
141
+
142
+ async function resolveProject(projectArg) {
143
+ if (projectArg) {
144
+ const all = await apiGet('/projects');
145
+ const match = all.find((p) => p.id === projectArg || p.slug === projectArg);
146
+ if (!match) fail(`No project found matching "${projectArg}". Run \`glash projects\` to list.`);
147
+ return match;
148
+ }
149
+ const link = await readProjectLink();
150
+ if (!link?.projectId) {
151
+ fail('No linked project. Run `glash link <slug>` or pass `--project <slug|id>`.');
152
+ }
153
+ return { id: link.projectId, slug: link.slug };
154
+ }
155
+
156
+ async function getTargetDatabaseUrl(projectId) {
157
+ const conn = await apiGet(`/database/connection/${projectId}?reveal=1`);
158
+ if (!conn?.direct || /••/.test(conn.direct)) {
159
+ fail('Could not reveal the glashDB direct database URL for this project.');
160
+ }
161
+ return conn.direct;
162
+ }
163
+
164
+ async function resolveDumpFile(explicit) {
165
+ if (explicit) {
166
+ return { file: resolve(explicit), format: inferFormat(explicit) };
167
+ }
168
+ try {
169
+ const latest = JSON.parse(await readFile(LATEST_FILE, 'utf8'));
170
+ return { file: latest.file, format: latest.format };
171
+ } catch {
172
+ return fail('No dump file provided and no latest dump exists. Run `glash database dump` first or pass `--file <path>`.');
173
+ }
174
+ }
175
+
176
+ function parsePostgresUrl(value, label) {
177
+ let url;
178
+ try {
179
+ url = new URL(value);
180
+ } catch {
181
+ fail(`Invalid ${label} database URL.`);
182
+ }
183
+ if (!['postgres:', 'postgresql:'].includes(url.protocol)) {
184
+ fail(`${label} database URL must start with postgres:// or postgresql://`);
185
+ }
186
+ return {
187
+ host: url.hostname,
188
+ port: Number(url.port || 5432),
189
+ database: decodeURIComponent(url.pathname.replace(/^\//, '') || 'postgres'),
190
+ user: decodeURIComponent(url.username || ''),
191
+ password: decodeURIComponent(url.password || ''),
192
+ sslmode: url.searchParams.get('sslmode') || undefined,
193
+ };
194
+ }
195
+
196
+ function processEnvForDb(db) {
197
+ return {
198
+ ...env,
199
+ PGPASSWORD: db.password,
200
+ PGSSLMODE: db.sslmode || env.PGSSLMODE || 'require',
201
+ };
202
+ }
203
+
204
+ function normalizeFormat(format) {
205
+ const f = String(format).toLowerCase();
206
+ if (['plain', 'sql', 'p'].includes(f)) return 'plain';
207
+ if (['custom', 'dump', 'dmp', 'c', 'auto'].includes(f)) return 'custom';
208
+ fail(`Unsupported dump format "${format}". Use custom or plain.`);
209
+ }
210
+
211
+ function inferFormat(file) {
212
+ const ext = extname(file).toLowerCase();
213
+ return ext === '.sql' ? 'plain' : 'custom';
214
+ }
215
+
216
+ function defaultDumpPath(database, format) {
217
+ const safeDb = String(database || 'database').replace(/[^a-z0-9_.-]+/gi, '-');
218
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
219
+ return join(DUMP_DIR, `${safeDb}-${stamp}.${format === 'plain' ? 'sql' : 'dump'}`);
220
+ }
221
+
222
+ function runCommand(command, args, commandEnv) {
223
+ return new Promise((resolvePromise, reject) => {
224
+ const child = spawn(command, args, {
225
+ env: commandEnv,
226
+ stdio: 'inherit',
227
+ });
228
+ child.on('error', (e) => {
229
+ if (e.code === 'ENOENT') {
230
+ reject(new Error(`${command} was not found. Install PostgreSQL client tools, then retry.`));
231
+ } else {
232
+ reject(e);
233
+ }
234
+ });
235
+ child.on('close', (code) => {
236
+ if (code === 0) resolvePromise();
237
+ else reject(new Error(`${basename(command)} exited with code ${code}`));
238
+ });
239
+ });
240
+ }
@@ -38,6 +38,14 @@ export async function status({ positional, flags }) {
38
38
  for (const ev of pipe.events ?? []) {
39
39
  info(` ${color.dim((ev.createdAt ?? '').slice(11, 19))} ${ev.step.padEnd(8)} ${ev.status.padEnd(10)} ${ev.message ?? ''}`);
40
40
  }
41
+ if (pipe.build_stderr && ['error', 'failed'].includes(pipe.deployStatus ?? pipe.buildStatus ?? '')) {
42
+ const tail = String(pipe.build_stderr).split('\n').slice(-80).join('\n').trim();
43
+ if (tail) {
44
+ info('');
45
+ info(color.bold('Build output tail'));
46
+ info(color.dim(tail));
47
+ }
48
+ }
41
49
  } catch (e) {
42
50
  fail(e.message);
43
51
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@glash/cli",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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": {