@glash/cli 0.2.7 → 0.2.13

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/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.7';
5
+ import { VERSION } from '../lib/version.mjs';
7
6
 
8
7
  const sub = argv[2];
9
8
  const rest = argv.slice(3);
@@ -25,7 +25,64 @@ export async function database(parsed) {
25
25
  const next = { ...parsed, positional: parsed.positional.slice(1) };
26
26
  if (action === 'dump') return databaseDump(next);
27
27
  if (action === 'upload') return databaseUpload(next);
28
- return fail('Usage: glash database <dump|upload> [flags]');
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).`);
29
86
  }
30
87
 
31
88
  export async function databaseDump({ flags, positional }) {
@@ -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 { pipe = await apiGet(`/deployments/${dep.id}/pipeline`); }
183
- catch (e) { warn(`poll error: ${e.message}`); await sleep(3000); continue; }
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
- await sleep(2500);
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));
@@ -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) {
@@ -1,7 +1,19 @@
1
- import { clearCreds } from '../lib/config.mjs';
2
- import { ok } from '../lib/ui.mjs';
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 = { Accept: 'application/json' };
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
- throw new GlashApiError(res.status, message);
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;
@@ -0,0 +1 @@
1
+ export const VERSION = '0.2.13';
package/lib/zip.mjs CHANGED
@@ -1,73 +1,139 @@
1
- import archiver from 'archiver';
1
+ import fg from 'fast-glob';
2
+ import yazl from 'yazl';
2
3
  import { createWriteStream } from 'node:fs';
3
4
  import { join, relative } from 'node:path';
4
5
  import fs from 'fs-extra';
5
6
 
6
7
  export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
8
+ // Default ignores
9
+ const defaultIgnore = [
10
+ 'node_modules/**',
11
+ '**/node_modules/**',
12
+ '.git/**',
13
+ '**/.git/**',
14
+ '.next/**',
15
+ '**/.next/**',
16
+ '.next*/**',
17
+ '**/.next*/**',
18
+ '.nuxt/**',
19
+ '**/.nuxt/**',
20
+ '.nuxt*/**',
21
+ '**/.nuxt*/**',
22
+ '.output/**',
23
+ '**/.output/**',
24
+ '.svelte-kit/**',
25
+ '**/.svelte-kit/**',
26
+ '.wrangler/**',
27
+ '**/.wrangler/**',
28
+ '.netlify/**',
29
+ '**/.netlify/**',
30
+ 'dist/**',
31
+ '**/dist/**',
32
+ 'build/**',
33
+ '**/build/**',
34
+ 'out/**',
35
+ '**/out/**',
36
+ 'coverage/**',
37
+ '**/coverage/**',
38
+ '.turbo/**',
39
+ '**/.turbo/**',
40
+ '.cache/**',
41
+ '**/.cache/**',
42
+ '.parcel-cache/**',
43
+ '**/.parcel-cache/**',
44
+ '.vite/**',
45
+ '**/.vite/**',
46
+ '.glash/**',
47
+ '**/.glash/**',
48
+ '*.log',
49
+ '**/*.log',
50
+ 'npm-debug.log*',
51
+ '**/npm-debug.log*',
52
+ 'yarn-debug.log*',
53
+ '**/yarn-debug.log*',
54
+ 'yarn-error.log*',
55
+ '**/yarn-error.log*',
56
+ '.env',
57
+ '**/.env',
58
+ '.env.local',
59
+ '**/.env.local',
60
+ '.env.production',
61
+ '**/.env.production',
62
+ '.env.development',
63
+ '**/.env.development',
64
+ ];
65
+ const allIgnore = [...new Set([...defaultIgnore, ...loadIgnorePatterns(sourceDir), ...ignore])];
66
+ const files = await fg('**/*', {
67
+ cwd: sourceDir,
68
+ ignore: allIgnore,
69
+ dot: true,
70
+ onlyFiles: true,
71
+ followSymbolicLinks: false,
72
+ });
73
+
7
74
  return new Promise((resolve, reject) => {
75
+ const zip = new yazl.ZipFile();
8
76
  const output = createWriteStream(outPath);
9
- const archive = archiver('zip', { zlib: { level: 9 } });
10
-
11
- output.on('close', () => resolve(archive.pointer()));
12
- archive.on('error', (err) => reject(err));
13
-
14
- archive.pipe(output);
77
+ let bytes = 0;
15
78
 
16
- // Default ignores
17
- const defaultIgnore = [
18
- 'node_modules/**',
19
- '.git/**',
20
- '.next/**',
21
- '.next*/**',
22
- '.nuxt/**',
23
- '.nuxt*/**',
24
- '.output/**',
25
- '.svelte-kit/**',
26
- '.wrangler/**',
27
- '.netlify/**',
28
- 'dist/**',
29
- 'build/**',
30
- 'out/**',
31
- 'coverage/**',
32
- '.turbo/**',
33
- '.cache/**',
34
- '.parcel-cache/**',
35
- '.vite/**',
36
- '.glash/**',
37
- '*.log',
38
- 'npm-debug.log*',
39
- 'yarn-debug.log*',
40
- 'yarn-error.log*',
41
- '.env',
42
- '.env.local',
43
- '.env.production',
44
- '.env.development',
45
- ];
46
- const allIgnore = [...new Set([...defaultIgnore, ...loadGitignorePatterns(sourceDir), ...ignore])];
79
+ output.on('close', () => resolve(bytes));
80
+ output.on('error', reject);
81
+ zip.outputStream.on('data', (chunk) => { bytes += chunk.length; });
82
+ zip.outputStream.on('error', reject);
83
+ zip.outputStream.pipe(output);
47
84
 
48
- archive.glob('**/*', {
49
- cwd: sourceDir,
50
- ignore: allIgnore,
51
- dot: true,
52
- });
53
-
54
- archive.finalize();
85
+ for (const file of files) {
86
+ zip.addFile(join(sourceDir, file), file);
87
+ }
88
+ zip.end();
55
89
  });
56
90
  }
57
91
 
58
- function loadGitignorePatterns(sourceDir) {
59
- const path = join(sourceDir, '.gitignore');
60
- if (!fs.existsSync(path)) return [];
92
+ function parseIgnorePattern(line) {
93
+ const raw = line.trim();
94
+ if (!raw || raw.startsWith('#') || raw.startsWith('!')) return [];
95
+
96
+ const hasLeadingSlash = raw.startsWith('/');
97
+
98
+ // Normalize by stripping leading and trailing slashes
99
+ let normalized = raw.replace(/^\/+/, '').replace(/\/+$/, '');
100
+ if (!normalized) return [];
101
+
102
+ const hasMiddleSlash = normalized.includes('/');
103
+ const isRootRelative = hasLeadingSlash || hasMiddleSlash;
61
104
 
62
105
  const patterns = [];
63
- const text = fs.readFileSync(path, 'utf8');
64
- for (const raw of text.split(/\r?\n/)) {
65
- const line = raw.trim();
66
- if (!line || line.startsWith('#') || line.startsWith('!')) continue;
67
- const normalized = line.replace(/^\/+/, '').replace(/\/+$/, '');
68
- if (!normalized) continue;
69
- patterns.push(normalized);
70
- if (!/[*!?[\]{}()]/.test(normalized)) patterns.push(`${normalized}/**`);
106
+
107
+ // Add base patterns
108
+ patterns.push(normalized);
109
+ if (!normalized.endsWith('/**') && !normalized.endsWith('/*')) {
110
+ patterns.push(`${normalized}/**`);
71
111
  }
112
+
113
+ // If it's not root-relative, match at any level
114
+ if (!isRootRelative) {
115
+ patterns.push(`**/${normalized}`);
116
+ if (!normalized.endsWith('/**') && !normalized.endsWith('/*')) {
117
+ patterns.push(`**/${normalized}/**`);
118
+ }
119
+ }
120
+
72
121
  return patterns;
73
122
  }
123
+
124
+ function loadIgnorePatterns(sourceDir) {
125
+ const files = ['.gitignore', '.glashignore'];
126
+ const allPatterns = [];
127
+
128
+ for (const filename of files) {
129
+ const path = join(sourceDir, filename);
130
+ if (!fs.existsSync(path)) continue;
131
+
132
+ const text = fs.readFileSync(path, 'utf8');
133
+ for (const raw of text.split(/\r?\n/)) {
134
+ const parsed = parseIgnorePattern(raw);
135
+ allPatterns.push(...parsed);
136
+ }
137
+ }
138
+ return allPatterns;
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glash/cli",
3
- "version": "0.2.7",
3
+ "version": "0.2.13",
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",
@@ -30,14 +30,14 @@
30
30
  "database"
31
31
  ],
32
32
  "dependencies": {
33
- "archiver": "^7.0.1",
34
33
  "chalk": "^5.3.0",
35
34
  "commander": "^12.1.0",
36
35
  "dotenv": "^16.4.5",
36
+ "fast-glob": "^3.3.3",
37
37
  "fs-extra": "^11.2.0",
38
- "node-fetch": "^3.3.2",
39
38
  "open": "^10.1.0",
40
- "ora": "^8.0.1"
39
+ "ora": "^8.0.1",
40
+ "yazl": "^3.3.1"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public"