@glash/cli 0.1.2 → 0.2.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/bin/glash.mjs CHANGED
@@ -3,7 +3,7 @@ import { argv, exit } from 'node:process';
3
3
  import { parseArgs } from '../lib/args.mjs';
4
4
  import { info, fail, color } from '../lib/ui.mjs';
5
5
 
6
- const VERSION = '0.1.2';
6
+ const VERSION = '0.2.1';
7
7
 
8
8
  const sub = argv[2];
9
9
  const rest = argv.slice(3);
@@ -55,7 +55,7 @@ ${color.bold('PROJECTS')}
55
55
  open Open the project's primary domain in a browser
56
56
 
57
57
  ${color.bold('DEPLOY')}
58
- deploy [--prod] [--detach] Deploy the linked project
58
+ deploy [--prod] [--detach] Deploy the linked project, syncing .env files first
59
59
  deployments List recent deployments
60
60
  status <id> Show deployment pipeline + events
61
61
  logs <id> Stream deployment events (alias of status)
@@ -69,12 +69,15 @@ ${color.bold('ENV VARS')}
69
69
  env:pull [--out .env.local] Write env vars to a local .env file
70
70
  env:push [<file>] Bulk upload from a .env file
71
71
 
72
- ${color.bold('IMPORT')}
72
+ ${color.bold('PULL')}
73
+ pull [<slug>] [--out dir] [--no-env] Pull source + .env from a glashDB project
73
74
  pull --from vercel --project <id> Pull source + env from a Vercel project
74
75
 
75
76
  ${color.bold('FLAGS')}
76
77
  --project <slug|id> Override the linked project for this command
77
78
  --environment <name> Env-var environment (default: production)
79
+ --env-file <file[,file]> Env file(s) to sync before deploy
80
+ --no-env Skip automatic env sync during deploy
78
81
  --help, -h Show this help
79
82
  --version, -v Show CLI version
80
83
 
@@ -5,7 +5,7 @@ import { execSync } from 'node:child_process';
5
5
  import { zipDirectory } from '../lib/zip.mjs';
6
6
  import { join, basename } from 'node:path';
7
7
  import { tmpdir } from 'node:os';
8
- import { readFile, rm } from 'node:fs/promises';
8
+ import { access, readFile, rm } from 'node:fs/promises';
9
9
 
10
10
  function currentCommit() {
11
11
  try {
@@ -76,9 +76,17 @@ export async function deploy({ flags }) {
76
76
  const target = flags.prod || flags.production ? 'production' : (flags.target || 'production');
77
77
 
78
78
  let dep;
79
+
80
+ if (!flags['no-env']) {
81
+ try {
82
+ await syncEnvBeforeDeploy(projectId, target, flags);
83
+ } catch (e) {
84
+ return fail(`Env sync failed: ${e.message}`);
85
+ }
86
+ }
79
87
 
80
- if (!commitHash || flags.local) {
81
- info(color.dim(`→ packaging local files for project ${projectId} (${target})`));
88
+ if (!flags.git && !flags.repo && !flags.remote) {
89
+ info(color.dim(`→ uploading local source for project ${projectId} (${target})`));
82
90
  const zipPath = join(tmpdir(), `glash-deploy-${Date.now()}.zip`);
83
91
  try {
84
92
  await zipDirectory(process.cwd(), zipPath);
@@ -88,6 +96,10 @@ export async function deploy({ flags }) {
88
96
  // We wrap the buffer in a Blob so fetch treats it as a file
89
97
  formData.append('file', new Blob([zipBuffer]), 'project.zip');
90
98
 
99
+ formData.append('commitHash', commitHash || 'local-upload');
100
+ formData.append('gitBranch', gitBranch || '');
101
+ formData.append('target', target);
102
+
91
103
  dep = await apiPost('/deployments/upload', formData);
92
104
  await rm(zipPath, { force: true }).catch(() => {});
93
105
  } catch (e) {
@@ -95,7 +107,7 @@ export async function deploy({ flags }) {
95
107
  return fail(`Packaging failed: ${e.message}`);
96
108
  }
97
109
  } else {
98
- info(color.dim(`→ deploying project ${projectId}${commitHash ? ` @ ${commitHash.slice(0, 7)}` : ''} (${target})`));
110
+ info(color.dim(`→ deploying connected repo for project ${projectId}${commitHash ? ` @ ${commitHash.slice(0, 7)}` : ''} (${target})`));
99
111
  try {
100
112
  dep = await apiPost('/deployments', { projectId, commitHash, gitBranch, target });
101
113
  } catch (e) {
@@ -127,10 +139,10 @@ export async function deploy({ flags }) {
127
139
  info(` ${tag} ${color.dim(ev.step.padEnd(8))} ${ev.message ?? ''}`);
128
140
  }
129
141
 
130
- const status = pipe.deployment?.deployStatus ?? pipe.deployment?.buildStatus ?? '';
142
+ const status = pipe.deployStatus ?? pipe.buildStatus ?? '';
131
143
  if (status && status !== lastStatus) lastStatus = status;
132
144
  if (['ready', 'live', 'success'].includes(status)) {
133
- const url = pipe.deployment?.url || pipe.domain?.domain;
145
+ const url = pipe.hostings?.[0]?.domains?.[0]?.domain;
134
146
  ok(`Live${url ? ` → ${color.cyan(`https://${url.replace(/^https?:\/\//, '')}`)}` : ''}`);
135
147
  return;
136
148
  }
@@ -142,6 +154,66 @@ export async function deploy({ flags }) {
142
154
  warn('Timed out waiting for deployment. Check `glash status <id>`.');
143
155
  }
144
156
 
157
+ async function syncEnvBeforeDeploy(projectId, target, flags) {
158
+ const files = await resolveEnvFiles(target, flags);
159
+ if (!files.length) return;
160
+
161
+ const merged = new Map();
162
+ for (const file of files) {
163
+ const text = await readFile(file, 'utf8');
164
+ for (const entry of parseDotenv(text)) merged.set(entry.key, entry.value);
165
+ }
166
+ const vars = [...merged.entries()]
167
+ .filter(([key]) => !key.startsWith('GLASH_'))
168
+ .map(([key, value]) => ({ key, value, environment: target }));
169
+
170
+ if (!vars.length) return;
171
+ await apiPost(`/projects/${projectId}/env-vars/bulk`, { vars });
172
+ info(color.dim(`→ synced ${vars.length} env vars from ${files.join(', ')}`));
173
+ }
174
+
175
+ async function resolveEnvFiles(target, flags) {
176
+ if (flags['env-file']) {
177
+ const files = String(flags['env-file']).split(',').map((f) => f.trim()).filter(Boolean);
178
+ for (const file of files) await access(file);
179
+ return files;
180
+ }
181
+
182
+ const candidates = [
183
+ '.env',
184
+ '.env.local',
185
+ `.env.${target}`,
186
+ `.env.${target}.local`,
187
+ ];
188
+ const out = [];
189
+ for (const file of candidates) {
190
+ try {
191
+ await access(file);
192
+ out.push(file);
193
+ } catch {}
194
+ }
195
+ return out;
196
+ }
197
+
198
+ function parseDotenv(text) {
199
+ const out = [];
200
+ for (const raw of text.split(/\r?\n/)) {
201
+ let line = raw.trim();
202
+ if (!line || line.startsWith('#')) continue;
203
+ if (line.startsWith('export ')) line = line.slice('export '.length).trim();
204
+ const eq = line.indexOf('=');
205
+ if (eq === -1) continue;
206
+ const key = line.slice(0, eq).trim();
207
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
208
+ let value = line.slice(eq + 1).trim();
209
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
210
+ value = value.slice(1, -1);
211
+ }
212
+ out.push({ key, value });
213
+ }
214
+ return out;
215
+ }
216
+
145
217
  function stepBadge(status) {
146
218
  if (status === 'success' || status === 'issued' || status === 'active' || status === 'ready') return color.green('✓');
147
219
  if (status === 'error' || status === 'failed') return color.red('✗');
@@ -34,7 +34,7 @@ export async function status({ positional, flags }) {
34
34
  try {
35
35
  const pipe = await apiGet(`/deployments/${id}/pipeline`);
36
36
  info(color.bold(`Deployment ${id}`));
37
- info(color.dim(`status: ${pipe.deployment?.deployStatus ?? pipe.deployment?.buildStatus ?? '?'}`));
37
+ info(color.dim(`status: ${pipe.deployStatus ?? pipe.buildStatus ?? '?'}`));
38
38
  for (const ev of pipe.events ?? []) {
39
39
  info(` ${color.dim((ev.createdAt ?? '').slice(11, 19))} ${ev.step.padEnd(8)} ${ev.status.padEnd(10)} ${ev.message ?? ''}`);
40
40
  }
package/commands/pull.mjs CHANGED
@@ -1,13 +1,112 @@
1
- // Thin shim — delegates to the existing reference implementation so we
2
- // don't duplicate the Vercel walk logic.
1
+ // glash pull
2
+ //
3
+ // Default mode: pulls the linked glashDB project's source + .env to the
4
+ // current directory.
5
+ //
6
+ // glash pull → uses the linked project (.glash/project.json)
7
+ // glash pull <slug> → pulls a specific project by slug
8
+ // glash pull --out ../somewhere → write to a different directory
9
+ // glash pull --no-env → skip writing .env
10
+ //
11
+ // Vercel mode (unchanged): glash pull --from vercel --project <id> [--team <id>]
12
+ // delegates to the existing reference implementation in glash-pull.mjs.
13
+
3
14
  import { spawnSync } from 'node:child_process';
4
15
  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);
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { mkdir, writeFile, stat } from 'node:fs/promises';
18
+ import { createWriteStream } from 'node:fs';
19
+ import { Readable } from 'node:stream';
20
+ import { argv, cwd, exit, stdout, stderr } from 'node:process';
21
+ import { API_URL, getToken, readProjectLink } from '../lib/config.mjs';
22
+ import { color, info, fail } from '../lib/ui.mjs';
23
+
24
+ export async function pull(parsed) {
25
+ const flags = parsed?.flags ?? {};
26
+ const positional = parsed?.positional ?? [];
27
+
28
+ // Vercel-import compatibility path.
29
+ if (flags.from === 'vercel') {
30
+ const here = dirname(fileURLToPath(import.meta.url));
31
+ const script = join(here, '..', 'glash-pull.mjs');
32
+ const r = spawnSync(process.execPath, [script, ...argv.slice(3)], { stdio: 'inherit' });
33
+ exit(r.status ?? 0);
34
+ }
35
+
36
+ const token = await getToken();
37
+ if (!token) return fail('Not signed in. Run `glash login` first.');
38
+
39
+ // Resolve which project to pull
40
+ let slugOrId = positional[0] ?? flags.project;
41
+ if (!slugOrId) {
42
+ const link = await readProjectLink();
43
+ if (link?.slug) slugOrId = link.slug;
44
+ else if (link?.projectId) slugOrId = link.projectId;
45
+ }
46
+ if (!slugOrId) {
47
+ return fail('No project specified. Pass `glash pull <slug>` or run `glash link <slug>` first.');
48
+ }
49
+
50
+ const outDir = resolve(flags.out ? String(flags.out) : cwd());
51
+ await mkdir(outDir, { recursive: true });
52
+
53
+ info(color.dim(`→ pulling ${slugOrId} into ${outDir}`));
54
+
55
+ // 1) source.tar.gz
56
+ await downloadAndExtract(token, slugOrId, outDir);
57
+
58
+ // 2) .env (unless --no-env)
59
+ if (!flags['no-env']) {
60
+ await downloadEnv(token, slugOrId, outDir, !!flags.force);
61
+ }
62
+
63
+ stdout.write(color.dim(`\n next: cd ${outDir} && glash deploy\n`));
64
+ }
65
+
66
+ async function downloadAndExtract(token, slugOrId, outDir) {
67
+ const url = `${API_URL}/projects/${encodeURIComponent(slugOrId)}/source.tar.gz`;
68
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
69
+ if (!res.ok) {
70
+ const body = await res.text().catch(() => '');
71
+ throw new Error(`source download failed (${res.status}): ${body.slice(0, 200) || res.statusText}`);
72
+ }
73
+ if (!res.body) throw new Error('empty response from server');
74
+
75
+ // Stream-pipe into `tar -xz -C outDir` via stdin
76
+ const child = spawnSync('tar', ['-xz', '-C', outDir], { input: Buffer.from(await res.arrayBuffer()) });
77
+ if (child.status !== 0) {
78
+ throw new Error(`tar extract failed: ${child.stderr?.toString() || 'unknown'}`);
79
+ }
80
+ info(color.dim(` ✓ source written to ${outDir}`));
81
+ }
82
+
83
+ async function downloadEnv(token, slugOrId, outDir, force) {
84
+ const url = `${API_URL}/projects/${encodeURIComponent(slugOrId)}/env-vars/dotenv`;
85
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
86
+ if (!res.ok) {
87
+ stderr.write(color.dim(` ! skipped .env (${res.status})\n`));
88
+ return;
89
+ }
90
+ const body = await res.text();
91
+ if (!body.trim()) {
92
+ info(color.dim(' · no env vars set on this project'));
93
+ return;
94
+ }
95
+
96
+ // Preserve any existing .env unless --force
97
+ const envPath = join(outDir, '.env');
98
+ const exists = await stat(envPath).then(() => true).catch(() => false);
99
+ if (exists && !force) {
100
+ const backup = `${envPath}.glash-backup-${Date.now()}`;
101
+ await writeFile(backup, await readFileOrEmpty(envPath));
102
+ info(color.dim(` · existing .env backed up → ${backup}`));
103
+ }
104
+ await writeFile(envPath, body, { mode: 0o600 });
105
+ const lines = body.split('\n').filter(Boolean).length;
106
+ info(color.dim(` ✓ ${lines} env var${lines === 1 ? '' : 's'} written to .env`));
107
+ }
108
+
109
+ async function readFileOrEmpty(p) {
110
+ try { const { readFile } = await import('node:fs/promises'); return await readFile(p); }
111
+ catch { return ''; }
13
112
  }
package/lib/zip.mjs CHANGED
@@ -14,7 +14,34 @@ export async function zipDirectory(sourceDir, outPath, { ignore = [] } = {}) {
14
14
  archive.pipe(output);
15
15
 
16
16
  // Default ignores
17
- const defaultIgnore = ['node_modules', '.git', '.next', 'dist', '.glash', 'package-lock.json', 'yarn.lock'];
17
+ const defaultIgnore = [
18
+ 'node_modules/**',
19
+ '.git/**',
20
+ '.next/**',
21
+ '.nuxt/**',
22
+ '.output/**',
23
+ '.svelte-kit/**',
24
+ '.vercel/**',
25
+ '.wrangler/**',
26
+ '.netlify/**',
27
+ 'dist/**',
28
+ 'build/**',
29
+ 'out/**',
30
+ 'coverage/**',
31
+ '.turbo/**',
32
+ '.cache/**',
33
+ '.parcel-cache/**',
34
+ '.vite/**',
35
+ '.glash/**',
36
+ '*.log',
37
+ 'npm-debug.log*',
38
+ 'yarn-debug.log*',
39
+ 'yarn-error.log*',
40
+ '.env',
41
+ '.env.local',
42
+ '.env.production',
43
+ '.env.development',
44
+ ];
18
45
  const allIgnore = [...new Set([...defaultIgnore, ...ignore])];
19
46
 
20
47
  archive.glob('**/*', {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@glash/cli",
3
- "version": "0.1.2",
4
- "description": "glashDB command-line interface — deploy projects, manage env vars, pull from Vercel.",
3
+ "version": "0.2.1",
4
+ "description": "glashDB command-line interface — deploy projects, pull source + env, manage env vars, import from Vercel.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://glashdb.com",
7
7
  "repository": {