@glash/cli 0.1.1

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 ADDED
@@ -0,0 +1,52 @@
1
+ # @glashdb/cli
2
+
3
+ The official command-line interface for [glashDB](https://glashdb.com).
4
+
5
+ ```
6
+ npm install -g @glashdb/cli
7
+ glash login
8
+ glash deploy
9
+ ```
10
+
11
+ ## Commands
12
+
13
+ | Command | Purpose |
14
+ | --- | --- |
15
+ | `glash login` | Sign in (stores a token at `~/.glash/credentials.json`) |
16
+ | `glash logout` | Clear local credentials |
17
+ | `glash whoami` | Show the signed-in user |
18
+ | `glash projects` | List your projects |
19
+ | `glash projects:new <name>` | Create a project and link it to the current directory |
20
+ | `glash projects:rm <slug>` | Delete a project |
21
+ | `glash link <slug>` | Link the current directory to an existing project |
22
+ | `glash open` | Open the linked project's primary domain |
23
+ | `glash deploy [--prod] [--detach]` | Deploy the linked project; streams the pipeline until live |
24
+ | `glash deployments` | List recent deployments |
25
+ | `glash status <id>` | Show a deployment's pipeline + events |
26
+ | `glash logs <id>` | Alias of `status` |
27
+ | `glash rollback <id>` | Roll the linked project back to a previous deployment |
28
+ | `glash retry <id> [--fix <id>]` | Retry a failed deployment |
29
+ | `glash env` | List env vars for the linked project |
30
+ | `glash env:add KEY=value` | Set a single env var |
31
+ | `glash env:rm KEY` | Remove an env var |
32
+ | `glash env:pull [--out .env.local]` | Pull env vars to a local file |
33
+ | `glash env:push [<file>]` | Bulk upload from a `.env` file |
34
+ | `glash pull --from vercel --project <id>` | Import a Vercel project (source + env) |
35
+
36
+ Pass `--project <slug>` to any command to operate on a project other than
37
+ the one linked in the current directory.
38
+
39
+ ## Environment
40
+
41
+ | Variable | Default | Notes |
42
+ | --- | --- | --- |
43
+ | `GLASH_API_URL` | `https://api.glashdb.com/api` | Override the API base (self-hosted, staging) |
44
+ | `GLASH_TOKEN` | — | Use this token instead of `~/.glash/credentials.json` (useful in CI) |
45
+
46
+ ## Requirements
47
+
48
+ Node.js 20+. Zero runtime dependencies.
49
+
50
+ ## License
51
+
52
+ Apache-2.0
package/bin/glash.mjs ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import { argv, exit } from 'node:process';
3
+ import { parseArgs } from '../lib/args.mjs';
4
+ import { info, fail, color } from '../lib/ui.mjs';
5
+
6
+ const VERSION = '0.1.1';
7
+
8
+ const sub = argv[2];
9
+ const rest = argv.slice(3);
10
+ const parsed = parseArgs(rest);
11
+
12
+ const commands = {
13
+ login: () => import('../commands/login.mjs').then((m) => m.login(parsed)),
14
+ logout: () => import('../commands/logout.mjs').then((m) => m.logout(parsed)),
15
+ whoami: () => import('../commands/whoami.mjs').then((m) => m.whoami(parsed)),
16
+
17
+ projects: () => import('../commands/projects.mjs').then((m) => m.listProjects(parsed)),
18
+ 'projects:new': () => import('../commands/projects.mjs').then((m) => m.createProject(parsed)),
19
+ 'projects:rm': () => import('../commands/projects.mjs').then((m) => m.removeProject(parsed)),
20
+ link: () => import('../commands/projects.mjs').then((m) => m.linkProject(parsed)),
21
+
22
+ deploy: () => import('../commands/deploy.mjs').then((m) => m.deploy(parsed)),
23
+ deployments: () => import('../commands/deployments.mjs').then((m) => m.listDeployments(parsed)),
24
+ status: () => import('../commands/deployments.mjs').then((m) => m.status(parsed)),
25
+ logs: () => import('../commands/deployments.mjs').then((m) => m.logs(parsed)),
26
+ rollback: () => import('../commands/deployments.mjs').then((m) => m.rollback(parsed)),
27
+ retry: () => import('../commands/deployments.mjs').then((m) => m.retry(parsed)),
28
+
29
+ env: () => import('../commands/env.mjs').then((m) => m.envList(parsed)),
30
+ 'env:add': () => import('../commands/env.mjs').then((m) => m.envAdd(parsed)),
31
+ 'env:rm': () => import('../commands/env.mjs').then((m) => m.envRm(parsed)),
32
+ 'env:pull': () => import('../commands/env.mjs').then((m) => m.envPull(parsed)),
33
+ 'env:push': () => import('../commands/env.mjs').then((m) => m.envPush(parsed)),
34
+
35
+ pull: () => import('../commands/pull.mjs').then((m) => m.pull(parsed)),
36
+ open: () => import('../commands/open.mjs').then((m) => m.open(parsed)),
37
+ };
38
+
39
+ function usage() {
40
+ info(`${color.bold('glash')} ${color.dim(`v${VERSION}`)} — glashDB command line
41
+
42
+ ${color.bold('USAGE')}
43
+ glash <command> [args] [flags]
44
+
45
+ ${color.bold('AUTH')}
46
+ login Sign in to glashDB
47
+ logout Clear local credentials
48
+ whoami Show signed-in user
49
+
50
+ ${color.bold('PROJECTS')}
51
+ projects List your projects
52
+ projects:new <name> Create a project (auto-links to cwd)
53
+ projects:rm <slug> Delete a project (--yes to skip confirm)
54
+ link <slug> Link the current directory to a project
55
+ open Open the project's primary domain in a browser
56
+
57
+ ${color.bold('DEPLOY')}
58
+ deploy [--prod] [--detach] Deploy the linked project
59
+ deployments List recent deployments
60
+ status <id> Show deployment pipeline + events
61
+ logs <id> Stream deployment events (alias of status)
62
+ rollback <id> Roll back the linked project to a deployment
63
+ retry <id> [--fix <id>] Retry a failed deployment
64
+
65
+ ${color.bold('ENV VARS')}
66
+ env List env vars for the linked project
67
+ env:add KEY=value Set a single env var
68
+ env:rm KEY Remove an env var
69
+ env:pull [--out .env.local] Write env vars to a local .env file
70
+ env:push [<file>] Bulk upload from a .env file
71
+
72
+ ${color.bold('IMPORT')}
73
+ pull --from vercel --project <id> Pull source + env from a Vercel project
74
+
75
+ ${color.bold('FLAGS')}
76
+ --project <slug|id> Override the linked project for this command
77
+ --environment <name> Env-var environment (default: production)
78
+ --help, -h Show this help
79
+ --version, -v Show CLI version
80
+
81
+ ${color.bold('ENVIRONMENT')}
82
+ GLASH_API_URL Override API base (default: https://api.glashdb.com/api)
83
+ GLASH_TOKEN Use this token instead of ~/.glash/credentials.json
84
+ `);
85
+ }
86
+
87
+ async function main() {
88
+ if (parsed.flags.help || parsed.flags.h) { usage(); return; }
89
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { usage(); return; }
90
+ if (sub === '--version' || sub === '-v' || sub === 'version') { info(VERSION); return; }
91
+ const handler = commands[sub];
92
+ if (!handler) {
93
+ fail(`Unknown command "${sub}". Run \`glash help\` for available commands.`);
94
+ }
95
+ try {
96
+ await handler();
97
+ } catch (e) {
98
+ fail(e?.message || String(e));
99
+ }
100
+ }
101
+
102
+ main().catch((e) => { fail(e?.message || String(e)); exit(1); });
@@ -0,0 +1,119 @@
1
+ import { apiGet, apiPost } from '../lib/api.mjs';
2
+ import { readProjectLink } from '../lib/config.mjs';
3
+ import { ok, fail, info, warn, color } from '../lib/ui.mjs';
4
+ import { execSync } from 'node:child_process';
5
+ import { zipDirectory } from '../lib/zip.mjs';
6
+ import { join } from 'node:path';
7
+ import { tmpdir } from 'node:os';
8
+ import { readFile, rm } from 'node:fs/promises';
9
+
10
+ function currentCommit() {
11
+ try {
12
+ return execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
13
+ } catch { return undefined; }
14
+ }
15
+
16
+ function currentBranch() {
17
+ try {
18
+ return execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
19
+ } catch { return undefined; }
20
+ }
21
+
22
+ async function resolveProjectId(flags) {
23
+ if (flags.project) {
24
+ if (/^[0-9a-f-]{36}$/.test(flags.project)) return flags.project;
25
+ const list = await apiGet('/projects');
26
+ const found = list.find((p) => p.slug === flags.project || p.id === flags.project);
27
+ if (!found) throw new Error(`No project matches "${flags.project}". Run \`glash projects\`.`);
28
+ return found.id;
29
+ }
30
+ const link = await readProjectLink();
31
+ if (!link?.projectId) {
32
+ throw new Error('No linked project. Run `glash link <slug>` or pass --project <slug>.');
33
+ }
34
+ return link.projectId;
35
+ }
36
+
37
+ export async function deploy({ flags }) {
38
+ let projectId;
39
+ try { projectId = await resolveProjectId(flags); } catch (e) { return fail(e.message); }
40
+
41
+ const commitHash = flags.commit || currentCommit();
42
+ const gitBranch = flags.branch || currentBranch();
43
+ const target = flags.prod || flags.production ? 'production' : (flags.target || 'production');
44
+
45
+ let dep;
46
+
47
+ if (!commitHash || flags.local) {
48
+ info(color.dim(`→ packaging local files for project ${projectId} (${target})`));
49
+ const zipPath = join(tmpdir(), `glash-deploy-${Date.now()}.zip`);
50
+ try {
51
+ await zipDirectory(process.cwd(), zipPath);
52
+ const zipBuffer = await readFile(zipPath);
53
+ const formData = new FormData();
54
+ formData.append('projectId', projectId);
55
+ // We wrap the buffer in a Blob so fetch treats it as a file
56
+ formData.append('file', new Blob([zipBuffer]), 'project.zip');
57
+
58
+ dep = await apiPost('/deployments/upload', formData);
59
+ await rm(zipPath, { force: true }).catch(() => {});
60
+ } catch (e) {
61
+ await rm(zipPath, { force: true }).catch(() => {});
62
+ return fail(`Packaging failed: ${e.message}`);
63
+ }
64
+ } else {
65
+ info(color.dim(`→ deploying project ${projectId}${commitHash ? ` @ ${commitHash.slice(0, 7)}` : ''} (${target})`));
66
+ try {
67
+ dep = await apiPost('/deployments', { projectId, commitHash, gitBranch, target });
68
+ } catch (e) {
69
+ return fail(e.message);
70
+ }
71
+ }
72
+
73
+ ok(`Deployment ${color.bold(dep.id)} queued`);
74
+
75
+ if (flags.detach || flags.D) {
76
+ info(`Track it with: ${color.cyan(`glash status ${dep.id}`)}`);
77
+ return;
78
+ }
79
+
80
+ // Poll the pipeline until it terminates.
81
+ const seen = new Set();
82
+ let lastStatus = '';
83
+ const start = Date.now();
84
+ while (Date.now() - start < 15 * 60 * 1000) {
85
+ let pipe;
86
+ try { pipe = await apiGet(`/deployments/${dep.id}/pipeline`); }
87
+ catch (e) { warn(`poll error: ${e.message}`); await sleep(3000); continue; }
88
+
89
+ for (const ev of pipe.events ?? []) {
90
+ const key = `${ev.id ?? ev.createdAt}:${ev.step}:${ev.status}`;
91
+ if (seen.has(key)) continue;
92
+ seen.add(key);
93
+ const tag = stepBadge(ev.status);
94
+ info(` ${tag} ${color.dim(ev.step.padEnd(8))} ${ev.message ?? ''}`);
95
+ }
96
+
97
+ const status = pipe.deployment?.deployStatus ?? pipe.deployment?.buildStatus ?? '';
98
+ if (status && status !== lastStatus) lastStatus = status;
99
+ if (['ready', 'live', 'success'].includes(status)) {
100
+ const url = pipe.deployment?.url || pipe.domain?.domain;
101
+ ok(`Live${url ? ` → ${color.cyan(`https://${url.replace(/^https?:\/\//, '')}`)}` : ''}`);
102
+ return;
103
+ }
104
+ if (['error', 'failed'].includes(status)) {
105
+ return fail(`Deployment failed (${status}). Run \`glash logs ${dep.id}\` for details.`);
106
+ }
107
+ await sleep(2500);
108
+ }
109
+ warn('Timed out waiting for deployment. Check `glash status <id>`.');
110
+ }
111
+
112
+ function stepBadge(status) {
113
+ if (status === 'success' || status === 'issued' || status === 'active' || status === 'ready') return color.green('✓');
114
+ if (status === 'error' || status === 'failed') return color.red('✗');
115
+ if (status === 'warning') return color.yellow('!');
116
+ return color.cyan('•');
117
+ }
118
+
119
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -0,0 +1,85 @@
1
+ import { apiGet, apiPost } from '../lib/api.mjs';
2
+ import { readProjectLink } from '../lib/config.mjs';
3
+ import { table, ok, fail, info, color } from '../lib/ui.mjs';
4
+
5
+ async function resolveProjectId(flags) {
6
+ if (flags.project) {
7
+ if (/^[0-9a-f-]{36}$/.test(flags.project)) return flags.project;
8
+ const list = await apiGet('/projects');
9
+ const found = list.find((p) => p.slug === flags.project);
10
+ if (!found) throw new Error(`No project "${flags.project}"`);
11
+ return found.id;
12
+ }
13
+ const link = await readProjectLink();
14
+ if (!link?.projectId) throw new Error('No linked project. Pass --project <slug> or run `glash link`.');
15
+ return link.projectId;
16
+ }
17
+
18
+ export async function listDeployments({ flags }) {
19
+ let projectId;
20
+ try { projectId = await resolveProjectId(flags); } catch (e) { return fail(e.message); }
21
+ const rows = await apiGet('/deployments', { query: { projectId } });
22
+ table(rows, [
23
+ { label: 'ID', get: (r) => r.id.slice(0, 8) },
24
+ { label: 'STATUS', get: (r) => r.deployStatus ?? r.buildStatus ?? '-' },
25
+ { label: 'COMMIT', get: (r) => (r.commitHash ?? '').slice(0, 7) },
26
+ { label: 'BRANCH', get: (r) => r.gitBranch ?? '-' },
27
+ { label: 'AGE', get: (r) => age(r.createdAt) },
28
+ ]);
29
+ }
30
+
31
+ export async function status({ positional, flags }) {
32
+ const id = positional[0] || flags.id;
33
+ if (!id) return fail('Usage: glash status <deployment-id>');
34
+ try {
35
+ const pipe = await apiGet(`/deployments/${id}/pipeline`);
36
+ info(color.bold(`Deployment ${id}`));
37
+ info(color.dim(`status: ${pipe.deployment?.deployStatus ?? pipe.deployment?.buildStatus ?? '?'}`));
38
+ for (const ev of pipe.events ?? []) {
39
+ info(` ${color.dim((ev.createdAt ?? '').slice(11, 19))} ${ev.step.padEnd(8)} ${ev.status.padEnd(10)} ${ev.message ?? ''}`);
40
+ }
41
+ } catch (e) {
42
+ fail(e.message);
43
+ }
44
+ }
45
+
46
+ export async function logs({ positional, flags }) {
47
+ // Logs are exposed via the pipeline endpoint for now (each event = a log line).
48
+ // When a dedicated /logs endpoint ships, swap in here.
49
+ return status({ positional, flags });
50
+ }
51
+
52
+ export async function rollback({ flags, positional }) {
53
+ let projectId;
54
+ try { projectId = await resolveProjectId(flags); } catch (e) { return fail(e.message); }
55
+ const deploymentId = positional[0] || flags.to;
56
+ if (!deploymentId) return fail('Usage: glash rollback <deployment-id>');
57
+ try {
58
+ await apiPost('/deployments/rollback', { projectId, deploymentId });
59
+ ok(`Rolled back to ${deploymentId}`);
60
+ } catch (e) {
61
+ fail(e.message);
62
+ }
63
+ }
64
+
65
+ export async function retry({ flags, positional }) {
66
+ let projectId;
67
+ try { projectId = await resolveProjectId(flags); } catch (e) { return fail(e.message); }
68
+ const deploymentId = positional[0];
69
+ if (!deploymentId) return fail('Usage: glash retry <deployment-id>');
70
+ try {
71
+ const r = await apiPost('/deployments/retry', { projectId, deploymentId, autoFixId: flags.fix });
72
+ ok(`Retry queued (${r.id ?? 'ok'})`);
73
+ } catch (e) {
74
+ fail(e.message);
75
+ }
76
+ }
77
+
78
+ function age(iso) {
79
+ if (!iso) return '-';
80
+ const d = (Date.now() - new Date(iso).getTime()) / 1000;
81
+ if (d < 60) return `${Math.floor(d)}s`;
82
+ if (d < 3600) return `${Math.floor(d / 60)}m`;
83
+ if (d < 86400) return `${Math.floor(d / 3600)}h`;
84
+ return `${Math.floor(d / 86400)}d`;
85
+ }
@@ -0,0 +1,110 @@
1
+ import { apiGet, apiPost, apiDel } from '../lib/api.mjs';
2
+ import { readProjectLink } from '../lib/config.mjs';
3
+ import { table, ok, fail, info, color } from '../lib/ui.mjs';
4
+ import { readFile, writeFile } from 'node:fs/promises';
5
+
6
+ async function resolveProjectId(flags) {
7
+ if (flags.project) {
8
+ if (/^[0-9a-f-]{36}$/.test(flags.project)) return flags.project;
9
+ const list = await apiGet('/projects');
10
+ const found = list.find((p) => p.slug === flags.project);
11
+ if (!found) throw new Error(`No project "${flags.project}"`);
12
+ return found.id;
13
+ }
14
+ const link = await readProjectLink();
15
+ if (!link?.projectId) throw new Error('No linked project. Pass --project <slug> or run `glash link`.');
16
+ return link.projectId;
17
+ }
18
+
19
+ function parseDotenv(text) {
20
+ const out = [];
21
+ for (const raw of text.split(/\r?\n/)) {
22
+ const line = raw.trim();
23
+ if (!line || line.startsWith('#')) continue;
24
+ const eq = line.indexOf('=');
25
+ if (eq === -1) continue;
26
+ let value = line.slice(eq + 1).trim();
27
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
28
+ value = value.slice(1, -1);
29
+ }
30
+ out.push({ key: line.slice(0, eq).trim(), value });
31
+ }
32
+ return out;
33
+ }
34
+
35
+ export async function envList({ flags }) {
36
+ let projectId;
37
+ try { projectId = await resolveProjectId(flags); } catch (e) { return fail(e.message); }
38
+ const rows = await apiGet(`/projects/${projectId}/env-vars`, { query: { environment: flags.environment } });
39
+ table(rows, [
40
+ { label: 'KEY', get: (r) => r.key },
41
+ { label: 'VALUE', get: (r) => maskValue(r) },
42
+ { label: 'ENV', get: (r) => r.environment ?? 'production' },
43
+ ]);
44
+ }
45
+
46
+ function maskValue(r) {
47
+ if (r.e2ee) return color.dim('(encrypted)');
48
+ const v = r.value ?? '';
49
+ if (v.length <= 4) return '*'.repeat(v.length);
50
+ return v.slice(0, 2) + '*'.repeat(Math.max(0, v.length - 4)) + v.slice(-2);
51
+ }
52
+
53
+ export async function envAdd({ flags, positional }) {
54
+ let projectId;
55
+ try { projectId = await resolveProjectId(flags); } catch (e) { return fail(e.message); }
56
+ const arg = positional[0];
57
+ if (!arg || !arg.includes('=')) return fail('Usage: glash env:add KEY=value [--environment production]');
58
+ const key = arg.slice(0, arg.indexOf('='));
59
+ const value = arg.slice(arg.indexOf('=') + 1);
60
+ try {
61
+ await apiPost(`/projects/${projectId}/env-vars`, {
62
+ key, value,
63
+ environment: flags.environment ?? 'production',
64
+ });
65
+ ok(`Set ${key}`);
66
+ } catch (e) {
67
+ fail(e.message);
68
+ }
69
+ }
70
+
71
+ export async function envRm({ flags, positional }) {
72
+ let projectId;
73
+ try { projectId = await resolveProjectId(flags); } catch (e) { return fail(e.message); }
74
+ const key = positional[0];
75
+ if (!key) return fail('Usage: glash env:rm <KEY>');
76
+ const all = await apiGet(`/projects/${projectId}/env-vars`);
77
+ const target = all.find((r) => r.key === key && (!flags.environment || r.environment === flags.environment));
78
+ if (!target) return fail(`No env var "${key}" found`);
79
+ await apiDel(`/projects/${projectId}/env-vars/${target.id}`);
80
+ ok(`Removed ${key}`);
81
+ }
82
+
83
+ export async function envPull({ flags }) {
84
+ let projectId;
85
+ try { projectId = await resolveProjectId(flags); } catch (e) { return fail(e.message); }
86
+ const out = flags.out || '.env.local';
87
+ const rows = await apiGet(`/projects/${projectId}/env-vars`, { query: { environment: flags.environment ?? 'production' } });
88
+ const lines = rows
89
+ .filter((r) => !r.e2ee)
90
+ .map((r) => `${r.key}=${JSON.stringify(r.value ?? '')}`);
91
+ await writeFile(out, lines.join('\n') + '\n');
92
+ ok(`Wrote ${lines.length} vars to ${out}`);
93
+ }
94
+
95
+ export async function envPush({ flags, positional }) {
96
+ let projectId;
97
+ try { projectId = await resolveProjectId(flags); } catch (e) { return fail(e.message); }
98
+ const file = positional[0] || flags.file || '.env.local';
99
+ const text = await readFile(file, 'utf8').catch(() => { fail(`Cannot read ${file}`); });
100
+ const vars = parseDotenv(text).map((v) => ({
101
+ ...v, environment: flags.environment ?? 'production',
102
+ }));
103
+ if (!vars.length) return fail(`No vars parsed from ${file}`);
104
+ try {
105
+ await apiPost(`/projects/${projectId}/env-vars/bulk`, { vars });
106
+ ok(`Pushed ${vars.length} vars from ${file}`);
107
+ } catch (e) {
108
+ fail(e.message);
109
+ }
110
+ }
@@ -0,0 +1,17 @@
1
+ import { apiPost } from '../lib/api.mjs';
2
+ import { writeCreds } from '../lib/config.mjs';
3
+ import { prompt, ok, fail } from '../lib/ui.mjs';
4
+
5
+ export async function login({ flags }) {
6
+ const email = flags.email || (await prompt('Email: '));
7
+ const password = flags.password || (await prompt('Password: ', { mask: true }));
8
+ if (!email || !password) return fail('email and password required');
9
+
10
+ try {
11
+ const r = await apiPost('/auth/login', { email, password });
12
+ await writeCreds({ token: r.accessToken, email: r.user?.email });
13
+ ok(`Signed in as ${r.user?.email ?? email}`);
14
+ } catch (e) {
15
+ fail(e.message);
16
+ }
17
+ }
@@ -0,0 +1,7 @@
1
+ import { clearCreds } from '../lib/config.mjs';
2
+ import { ok } from '../lib/ui.mjs';
3
+
4
+ export async function logout() {
5
+ await clearCreds();
6
+ ok('Signed out');
7
+ }
@@ -0,0 +1,23 @@
1
+ import { apiGet } from '../lib/api.mjs';
2
+ import { readProjectLink } from '../lib/config.mjs';
3
+ import { fail, info, color } from '../lib/ui.mjs';
4
+ import { spawn } from 'node:child_process';
5
+ import { platform } from 'node:process';
6
+
7
+ export async function open({ flags }) {
8
+ const link = await readProjectLink();
9
+ if (!link?.projectId && !flags.project) return fail('No linked project. Run `glash link` first.');
10
+ const projectId = link?.projectId;
11
+ let url;
12
+ try {
13
+ const p = await apiGet(`/projects/${projectId}`);
14
+ const primary = p.domains?.find((d) => d.isPrimary) ?? p.domains?.[0];
15
+ if (!primary) return fail('Project has no domains yet — deploy first.');
16
+ url = `https://${primary.domain}`;
17
+ } catch (e) {
18
+ return fail(e.message);
19
+ }
20
+ info(color.dim(`→ ${url}`));
21
+ const opener = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
22
+ spawn(opener, [url], { stdio: 'ignore', detached: true }).unref();
23
+ }
@@ -0,0 +1,67 @@
1
+ import { apiGet, apiPost, apiDel } from '../lib/api.mjs';
2
+ import { writeProjectLink } from '../lib/config.mjs';
3
+ import { table, ok, fail, info, color, prompt } from '../lib/ui.mjs';
4
+ import { basename } from 'node:path';
5
+
6
+ export async function listProjects() {
7
+ const rows = await apiGet('/projects');
8
+ table(rows, [
9
+ { label: 'SLUG', get: (r) => r.slug },
10
+ { label: 'NAME', get: (r) => r.name },
11
+ { label: 'FRAMEWORK', get: (r) => r.framework ?? '-' },
12
+ { label: 'STATUS', get: (r) => r.status ?? '-' },
13
+ { label: 'ID', get: (r) => r.id },
14
+ ]);
15
+ }
16
+
17
+ export async function createProject({ flags, positional }) {
18
+ const name = flags.name || positional[0] || (await prompt('Project name: '));
19
+ const slug = flags.slug || name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
20
+ const framework = flags.framework;
21
+ const repositoryUrl = flags.repo || flags.repositoryUrl;
22
+ try {
23
+ const p = await apiPost('/projects', { name, slug, framework, repositoryUrl });
24
+ ok(`Created ${color.bold(p.slug)} (${p.id})`);
25
+ if (flags.link !== false) {
26
+ await writeProjectLink({ projectId: p.id, slug: p.slug });
27
+ info(color.dim('Linked current directory to this project (.glash/project.json).'));
28
+ }
29
+ } catch (e) {
30
+ fail(e.message);
31
+ }
32
+ }
33
+
34
+ export async function linkProject({ flags, positional }) {
35
+ let slug = flags.slug || positional[0];
36
+ if (!slug) {
37
+ const guess = basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]+/g, '-');
38
+ slug = (await prompt(`Project slug [${guess}]: `)) || guess;
39
+ }
40
+ try {
41
+ const all = await apiGet('/projects');
42
+ const p = all.find((x) => x.slug === slug || x.id === slug);
43
+ if (!p) return fail(`No project found matching "${slug}". Run \`glash projects\` to list.`);
44
+ await writeProjectLink({ projectId: p.id, slug: p.slug });
45
+ ok(`Linked to ${color.bold(p.slug)} (${p.id})`);
46
+ } catch (e) {
47
+ fail(e.message);
48
+ }
49
+ }
50
+
51
+ export async function removeProject({ flags, positional }) {
52
+ const slug = flags.slug || positional[0];
53
+ if (!slug) return fail('Provide a project slug: glash projects:rm <slug>');
54
+ if (!flags.yes) {
55
+ const a = await prompt(`Type the slug to confirm deletion of "${slug}": `);
56
+ if (a !== slug) return fail('Aborted');
57
+ }
58
+ try {
59
+ const all = await apiGet('/projects');
60
+ const p = all.find((x) => x.slug === slug || x.id === slug);
61
+ if (!p) return fail(`No project found matching "${slug}"`);
62
+ await apiDel(`/projects/${p.id}`);
63
+ ok(`Deleted ${slug}`);
64
+ } catch (e) {
65
+ fail(e.message);
66
+ }
67
+ }
@@ -0,0 +1,13 @@
1
+ // Thin shim — delegates to the existing reference implementation so we
2
+ // don't duplicate the Vercel walk logic.
3
+ import { spawnSync } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+ import { argv, exit } from 'node:process';
7
+
8
+ export async function pull() {
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ const script = join(here, '..', 'glash-pull.mjs');
11
+ const r = spawnSync(process.execPath, [script, ...argv.slice(3)], { stdio: 'inherit' });
12
+ exit(r.status ?? 0);
13
+ }
@@ -0,0 +1,15 @@
1
+ import { apiGet } from '../lib/api.mjs';
2
+ import { getToken, API_URL } from '../lib/config.mjs';
3
+ import { info, fail, color } from '../lib/ui.mjs';
4
+
5
+ export async function whoami() {
6
+ const token = await getToken();
7
+ if (!token) return fail('Not signed in. Run `glash login`.');
8
+ try {
9
+ const me = await apiGet('/auth/me');
10
+ info(`${color.bold(me.email)} ${color.dim(`(${me.role})`)}`);
11
+ info(color.dim(`API: ${API_URL}`));
12
+ } catch (e) {
13
+ fail(`${e.message} — try \`glash login\` again`);
14
+ }
15
+ }
package/glash-pull.mjs ADDED
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * glash pull — reference implementation
4
+ *
5
+ * glash pull --from vercel --project <id> [--team <id>] [--token <vercel-token>] [--out .]
6
+ *
7
+ * Pulls the latest production deployment from Vercel into the current
8
+ * working directory:
9
+ * • walks /v6/deployments/<uid>/files
10
+ * • downloads each file via /v7/deployments/<uid>/files/<file-uid>
11
+ * • writes them at the same paths locally
12
+ * • dumps env vars to .env.local
13
+ *
14
+ * Token sources, in order: --token flag, $VERCEL_TOKEN, ~/.glash/credentials.json (vercelToken).
15
+ *
16
+ * No external deps — pure Node 20+. The shipping `@glashdb/cli` wraps this
17
+ * with a nicer UX (interactive prompts, progress bars, `glash login`-aware).
18
+ */
19
+
20
+ import { argv, env, exit, cwd, stdout, stderr } from 'node:process';
21
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
22
+ import { homedir } from 'node:os';
23
+ import { join, dirname } from 'node:path';
24
+
25
+ const VERCEL = 'https://api.vercel.com';
26
+
27
+ function parseArgs(argv) {
28
+ const out = {};
29
+ for (let i = 2; i < argv.length; i++) {
30
+ const a = argv[i];
31
+ if (a.startsWith('--')) {
32
+ const k = a.slice(2);
33
+ const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : 'true';
34
+ out[k] = v;
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ async function readToken(flag) {
41
+ if (flag) return flag;
42
+ if (env.VERCEL_TOKEN) return env.VERCEL_TOKEN;
43
+ try {
44
+ const raw = await readFile(join(homedir(), '.glash', 'credentials.json'), 'utf8');
45
+ const j = JSON.parse(raw);
46
+ if (j.vercelToken) return j.vercelToken;
47
+ } catch {}
48
+ stderr.write("error: no Vercel token. Pass --token, set VERCEL_TOKEN, or run `glash login --vercel`.\n");
49
+ exit(2);
50
+ }
51
+
52
+ async function vercel(token, path, teamId) {
53
+ const url = `${VERCEL}${path}${teamId ? (path.includes('?') ? '&' : '?') + 'teamId=' + encodeURIComponent(teamId) : ''}`;
54
+ const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
55
+ if (!r.ok) throw new Error(`Vercel ${r.status} ${r.statusText} on ${path}`);
56
+ return r.json();
57
+ }
58
+
59
+ async function vercelRaw(token, path, teamId) {
60
+ const url = `${VERCEL}${path}${teamId ? (path.includes('?') ? '&' : '?') + 'teamId=' + encodeURIComponent(teamId) : ''}`;
61
+ const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
62
+ if (!r.ok) throw new Error(`Vercel ${r.status} on ${path}`);
63
+ return Buffer.from(await r.arrayBuffer());
64
+ }
65
+
66
+ async function walk(token, deploymentUid, teamId, tree, prefix, out) {
67
+ for (const node of tree) {
68
+ const rel = prefix ? `${prefix}/${node.name}` : node.name;
69
+ if (node.type === 'directory' && node.children) {
70
+ await walk(token, deploymentUid, teamId, node.children, rel, out);
71
+ } else if (node.type === 'file' && node.uid) {
72
+ out.push({ rel, uid: node.uid });
73
+ }
74
+ }
75
+ }
76
+
77
+ async function main() {
78
+ const args = parseArgs(argv);
79
+ if (args.from !== 'vercel') {
80
+ stderr.write("usage: glash pull --from vercel --project <id> [--team <id>] [--out .]\n");
81
+ exit(2);
82
+ }
83
+ if (!args.project) { stderr.write("error: --project <vercel-project-id> required\n"); exit(2); }
84
+
85
+ const token = await readToken(args.token);
86
+ const team = args.team;
87
+ const outDir = args.out || cwd();
88
+
89
+ stdout.write(`→ pulling vercel project ${args.project}${team ? ` (team ${team})` : ''} into ${outDir}\n`);
90
+
91
+ // 1) latest production deployment
92
+ const list = await vercel(token, `/v6/deployments?projectId=${encodeURIComponent(args.project)}&limit=1&target=production&state=READY`, team);
93
+ const dep = list.deployments?.[0];
94
+ if (!dep) { stderr.write("error: no production deployment on Vercel for that project.\n"); exit(1); }
95
+ stdout.write(`✓ deployment ${dep.uid}\n`);
96
+
97
+ // 2) file tree
98
+ const tree = await vercel(token, `/v6/deployments/${dep.uid}/files`, team);
99
+ const files = [];
100
+ await walk(token, dep.uid, team, tree, '', files);
101
+ stdout.write(`✓ ${files.length} files to download\n`);
102
+
103
+ // 3) write each file
104
+ let n = 0;
105
+ for (const f of files) {
106
+ const buf = await vercelRaw(token, `/v7/deployments/${dep.uid}/files/${f.uid}`, team);
107
+ const dest = join(outDir, f.rel);
108
+ await mkdir(dirname(dest), { recursive: true });
109
+ await writeFile(dest, buf);
110
+ n++;
111
+ if (n % 10 === 0 || n === files.length) stdout.write(` ${n}/${files.length}\r`);
112
+ }
113
+ stdout.write(`\n✓ wrote ${n} files\n`);
114
+
115
+ // 4) env vars → .env.local
116
+ try {
117
+ const env = await vercel(token, `/v9/projects/${encodeURIComponent(args.project)}/env?decrypt=true`, team);
118
+ const lines = (env.envs ?? [])
119
+ .filter((e) => !e.target || e.target.includes('production'))
120
+ .map((e) => `${e.key}=${JSON.stringify(e.value ?? '')}`);
121
+ if (lines.length) {
122
+ await writeFile(join(outDir, '.env.local'), lines.join('\n') + '\n');
123
+ stdout.write(`✓ wrote ${lines.length} env vars to .env.local\n`);
124
+ }
125
+ } catch (e) {
126
+ stdout.write(`! skipped env vars: ${e.message}\n`);
127
+ }
128
+
129
+ stdout.write(`done.\n next: cd ${outDir} && glash deploy\n`);
130
+ }
131
+
132
+ main().catch((e) => { stderr.write(`error: ${e.message}\n`); exit(1); });
package/lib/api.mjs ADDED
@@ -0,0 +1,51 @@
1
+ import { API_URL, getToken } from './config.mjs';
2
+
3
+ export class GlashApiError extends Error {
4
+ constructor(status, message) {
5
+ super(message);
6
+ this.status = status;
7
+ }
8
+ }
9
+
10
+ export async function api(method, path, { body, token, query } = {}) {
11
+ const headers = { Accept: 'application/json' };
12
+ const t = token ?? (await getToken());
13
+ if (t) headers.Authorization = `Bearer ${t}`;
14
+ if (body !== undefined && !(body instanceof FormData)) {
15
+ headers['Content-Type'] = 'application/json';
16
+ }
17
+
18
+ let url = `${API_URL}${path}`;
19
+ if (query) {
20
+ const qs = new URLSearchParams(
21
+ Object.entries(query).filter(([, v]) => v !== undefined && v !== null),
22
+ ).toString();
23
+ if (qs) url += (url.includes('?') ? '&' : '?') + qs;
24
+ }
25
+
26
+ const res = await fetch(url, {
27
+ method,
28
+ headers,
29
+ body: body instanceof FormData ? body : (body === undefined ? undefined : JSON.stringify(body)),
30
+ });
31
+
32
+ if (!res.ok) {
33
+ let message = `HTTP ${res.status}`;
34
+ try {
35
+ const data = await res.json();
36
+ if (typeof data?.message === 'string') message = data.message;
37
+ else if (Array.isArray(data?.message)) message = data.message.join(', ');
38
+ } catch {}
39
+ throw new GlashApiError(res.status, message);
40
+ }
41
+
42
+ if (res.status === 204) return undefined;
43
+ const text = await res.text();
44
+ if (!text) return undefined;
45
+ try { return JSON.parse(text); } catch { return text; }
46
+ }
47
+
48
+ export const apiGet = (p, opts) => api('GET', p, opts);
49
+ export const apiPost = (p, body, opts) => api('POST', p, { ...opts, body });
50
+ export const apiPatch = (p, body, opts) => api('PATCH', p, { ...opts, body });
51
+ export const apiDel = (p, opts) => api('DELETE', p, opts);
package/lib/args.mjs ADDED
@@ -0,0 +1,38 @@
1
+ // Tiny argv parser. Supports --flag value, --flag=value, -f shorthand,
2
+ // boolean flags, and a positional argument list.
3
+ export function parseArgs(argv) {
4
+ const flags = {};
5
+ const positional = [];
6
+ for (let i = 0; i < argv.length; i++) {
7
+ const a = argv[i];
8
+ if (a === '--') {
9
+ positional.push(...argv.slice(i + 1));
10
+ break;
11
+ }
12
+ if (a.startsWith('--')) {
13
+ const eq = a.indexOf('=');
14
+ if (eq !== -1) {
15
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
16
+ } else {
17
+ const k = a.slice(2);
18
+ const next = argv[i + 1];
19
+ if (next !== undefined && !next.startsWith('-')) {
20
+ flags[k] = next; i++;
21
+ } else {
22
+ flags[k] = true;
23
+ }
24
+ }
25
+ } else if (a.startsWith('-') && a.length > 1) {
26
+ const k = a.slice(1);
27
+ const next = argv[i + 1];
28
+ if (next !== undefined && !next.startsWith('-')) {
29
+ flags[k] = next; i++;
30
+ } else {
31
+ flags[k] = true;
32
+ }
33
+ } else {
34
+ positional.push(a);
35
+ }
36
+ }
37
+ return { flags, positional };
38
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,55 @@
1
+ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { env } from 'node:process';
5
+
6
+ const HOME_DIR = join(homedir(), '.glash');
7
+ const CREDS_FILE = join(HOME_DIR, 'credentials.json');
8
+ const PROJECT_FILE = join(process.cwd(), '.glash', 'project.json');
9
+
10
+ export const API_URL = (env.GLASH_API_URL || 'https://api.glashdb.com/api').replace(/\/$/, '');
11
+
12
+ export async function readCreds() {
13
+ try {
14
+ const raw = await readFile(CREDS_FILE, 'utf8');
15
+ return JSON.parse(raw);
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+
21
+ export async function writeCreds(patch) {
22
+ const current = await readCreds();
23
+ const next = { ...current, ...patch };
24
+ await mkdir(HOME_DIR, { recursive: true, mode: 0o700 });
25
+ await writeFile(CREDS_FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
26
+ return next;
27
+ }
28
+
29
+ export async function clearCreds() {
30
+ await writeFile(CREDS_FILE, JSON.stringify({}, null, 2), { mode: 0o600 });
31
+ }
32
+
33
+ export async function getToken() {
34
+ if (env.GLASH_TOKEN) return env.GLASH_TOKEN;
35
+ const c = await readCreds();
36
+ return c.token || null;
37
+ }
38
+
39
+ export async function readProjectLink() {
40
+ try {
41
+ const raw = await readFile(PROJECT_FILE, 'utf8');
42
+ return JSON.parse(raw);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ export async function writeProjectLink(data) {
49
+ await mkdir(join(process.cwd(), '.glash'), { recursive: true });
50
+ await writeFile(PROJECT_FILE, JSON.stringify(data, null, 2));
51
+ }
52
+
53
+ export async function projectFileExists() {
54
+ try { await stat(PROJECT_FILE); return true; } catch { return false; }
55
+ }
package/lib/ui.mjs ADDED
@@ -0,0 +1,50 @@
1
+ import { stdout, stderr, stdin, exit } from 'node:process';
2
+ import { createInterface } from 'node:readline/promises';
3
+
4
+ const isTTY = stdout.isTTY;
5
+ const c = (code) => (s) => isTTY ? `\x1b[${code}m${s}\x1b[0m` : String(s);
6
+ export const color = {
7
+ dim: c('2'),
8
+ bold: c('1'),
9
+ red: c('31'),
10
+ green: c('32'),
11
+ yellow: c('33'),
12
+ blue: c('34'),
13
+ cyan: c('36'),
14
+ };
15
+
16
+ export function info(msg) { stdout.write(`${msg}\n`); }
17
+ export function ok(msg) { stdout.write(`${color.green('✓')} ${msg}\n`); }
18
+ export function warn(msg) { stderr.write(`${color.yellow('!')} ${msg}\n`); }
19
+ export function fail(msg, code = 1) { stderr.write(`${color.red('error')}: ${msg}\n`); exit(code); }
20
+
21
+ export async function prompt(question, { mask = false } = {}) {
22
+ const rl = createInterface({ input: stdin, output: stdout, terminal: true });
23
+ if (!mask) {
24
+ const a = await rl.question(question);
25
+ rl.close();
26
+ return a.trim();
27
+ }
28
+ // Masked input — write * for each typed char. readline doesn't natively
29
+ // mask, so we monkey-patch the output stream for this prompt only.
30
+ const orig = rl._writeToOutput?.bind(rl);
31
+ rl._writeToOutput = (s) => {
32
+ if (s === question) return stdout.write(s);
33
+ stdout.write('*'.repeat(s.replace(/\n/g, '').length));
34
+ };
35
+ const a = await rl.question(question);
36
+ if (orig) rl._writeToOutput = orig;
37
+ rl.close();
38
+ stdout.write('\n');
39
+ return a.trim();
40
+ }
41
+
42
+ export function table(rows, columns) {
43
+ if (!rows.length) { info(color.dim('(none)')); return; }
44
+ const widths = columns.map((c) =>
45
+ Math.max(c.label.length, ...rows.map((r) => String(c.get(r) ?? '').length)),
46
+ );
47
+ const line = (cells) => cells.map((c, i) => String(c).padEnd(widths[i])).join(' ');
48
+ stdout.write(color.dim(line(columns.map((c) => c.label))) + '\n');
49
+ for (const r of rows) stdout.write(line(columns.map((c) => c.get(r) ?? '')) + '\n');
50
+ }
package/lib/zip.mjs ADDED
@@ -0,0 +1,28 @@
1
+ import archiver from 'archiver';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ import fs from 'fs-extra';
5
+
6
+ export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
7
+ return new Promise((resolve, reject) => {
8
+ 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);
15
+
16
+ // Default ignores
17
+ const defaultIgnore = ['node_modules', '.git', '.next', 'dist', '.glash', 'package-lock.json', 'yarn.lock'];
18
+ const allIgnore = [...new Set([...defaultIgnore, ...ignore])];
19
+
20
+ archive.glob('**/*', {
21
+ cwd: sourceDir,
22
+ ignore: allIgnore,
23
+ dot: true,
24
+ });
25
+
26
+ archive.finalize();
27
+ });
28
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@glash/cli",
3
+ "version": "0.1.1",
4
+ "description": "glashDB command-line interface — deploy projects, manage env vars, pull from Vercel.",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://glashdb.com",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/glashdb/glashdb.git",
10
+ "directory": "cli"
11
+ },
12
+ "bin": {
13
+ "glash": "bin/glash.mjs"
14
+ },
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "lib",
22
+ "commands",
23
+ "glash-pull.mjs",
24
+ "README.md"
25
+ ],
26
+ "keywords": [
27
+ "glashdb",
28
+ "deploy",
29
+ "cli",
30
+ "hosting",
31
+ "database"
32
+ ],
33
+ "dependencies": {
34
+ "archiver": "^7.0.1",
35
+ "chalk": "^5.3.0",
36
+ "commander": "^12.1.0",
37
+ "dotenv": "^16.4.5",
38
+ "fs-extra": "^11.2.0",
39
+ "node-fetch": "^3.3.2",
40
+ "open": "^10.1.0",
41
+ "ora": "^8.0.1"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }