@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 +52 -0
- package/bin/glash.mjs +102 -0
- package/commands/deploy.mjs +119 -0
- package/commands/deployments.mjs +85 -0
- package/commands/env.mjs +110 -0
- package/commands/login.mjs +17 -0
- package/commands/logout.mjs +7 -0
- package/commands/open.mjs +23 -0
- package/commands/projects.mjs +67 -0
- package/commands/pull.mjs +13 -0
- package/commands/whoami.mjs +15 -0
- package/glash-pull.mjs +132 -0
- package/lib/api.mjs +51 -0
- package/lib/args.mjs +38 -0
- package/lib/config.mjs +55 -0
- package/lib/ui.mjs +50 -0
- package/lib/zip.mjs +28 -0
- package/package.json +46 -0
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
|
+
}
|
package/commands/env.mjs
ADDED
|
@@ -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,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
|
+
}
|