@glash/cli 0.1.1 → 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.1';
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
 
@@ -1,11 +1,11 @@
1
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';
2
+ import { readProjectLink, writeProjectLink } from '../lib/config.mjs';
3
+ import { ok, fail, info, warn, color, prompt } from '../lib/ui.mjs';
4
4
  import { execSync } from 'node:child_process';
5
5
  import { zipDirectory } from '../lib/zip.mjs';
6
- import { join } from 'node:path';
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 {
@@ -28,10 +28,43 @@ async function resolveProjectId(flags) {
28
28
  return found.id;
29
29
  }
30
30
  const link = await readProjectLink();
31
- if (!link?.projectId) {
32
- throw new Error('No linked project. Run `glash link <slug>` or pass --project <slug>.');
31
+ if (link?.projectId) {
32
+ return link.projectId;
33
33
  }
34
- return link.projectId;
34
+
35
+ // INTERACTIVE PROJECT SETUP (Vercel-style)
36
+ const dirName = basename(process.cwd());
37
+ const setup = await prompt(`Set up and deploy "${dirName}"? [Y/n] `);
38
+ if (setup.toLowerCase() === 'n') {
39
+ throw new Error('Deployment aborted.');
40
+ }
41
+
42
+ const existing = await prompt(`Link to existing project? [y/N] `);
43
+ if (existing.toLowerCase() === 'y') {
44
+ const list = await apiGet('/projects');
45
+ if (list.length === 0) {
46
+ info('No existing projects found. Proceeding to create a new one.');
47
+ } else {
48
+ info('\nYour existing projects:');
49
+ list.forEach((p, i) => info(` ${color.dim(i + 1 + '.')} ${color.bold(p.name)} ${color.dim('(' + p.slug + ')')}`));
50
+ const choice = await prompt('\nWhich project do you want to link to? (enter number or slug): ');
51
+ const p = list.find((x, i) => x.slug === choice || String(i + 1) === choice);
52
+ if (!p) throw new Error(`Project "${choice}" not found.`);
53
+ await writeProjectLink({ projectId: p.id, slug: p.slug });
54
+ ok(`Linked to ${p.name} (${p.slug})`);
55
+ return p.id;
56
+ }
57
+ }
58
+
59
+ // CREATE NEW PROJECT
60
+ const name = (await prompt(`What's your project's name? [${dirName}] `)) || dirName;
61
+ const slug = name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
62
+
63
+ info(color.dim(`Creating project "${name}"...`));
64
+ const p = await apiPost('/projects', { name, slug });
65
+ await writeProjectLink({ projectId: p.id, slug: p.slug });
66
+ ok(`Created and linked to ${p.name} (${p.slug})`);
67
+ return p.id;
35
68
  }
36
69
 
37
70
  export async function deploy({ flags }) {
@@ -43,9 +76,17 @@ export async function deploy({ flags }) {
43
76
  const target = flags.prod || flags.production ? 'production' : (flags.target || 'production');
44
77
 
45
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
+ }
46
87
 
47
- if (!commitHash || flags.local) {
48
- 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})`));
49
90
  const zipPath = join(tmpdir(), `glash-deploy-${Date.now()}.zip`);
50
91
  try {
51
92
  await zipDirectory(process.cwd(), zipPath);
@@ -55,6 +96,10 @@ export async function deploy({ flags }) {
55
96
  // We wrap the buffer in a Blob so fetch treats it as a file
56
97
  formData.append('file', new Blob([zipBuffer]), 'project.zip');
57
98
 
99
+ formData.append('commitHash', commitHash || 'local-upload');
100
+ formData.append('gitBranch', gitBranch || '');
101
+ formData.append('target', target);
102
+
58
103
  dep = await apiPost('/deployments/upload', formData);
59
104
  await rm(zipPath, { force: true }).catch(() => {});
60
105
  } catch (e) {
@@ -62,7 +107,7 @@ export async function deploy({ flags }) {
62
107
  return fail(`Packaging failed: ${e.message}`);
63
108
  }
64
109
  } else {
65
- 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})`));
66
111
  try {
67
112
  dep = await apiPost('/deployments', { projectId, commitHash, gitBranch, target });
68
113
  } catch (e) {
@@ -94,10 +139,10 @@ export async function deploy({ flags }) {
94
139
  info(` ${tag} ${color.dim(ev.step.padEnd(8))} ${ev.message ?? ''}`);
95
140
  }
96
141
 
97
- const status = pipe.deployment?.deployStatus ?? pipe.deployment?.buildStatus ?? '';
142
+ const status = pipe.deployStatus ?? pipe.buildStatus ?? '';
98
143
  if (status && status !== lastStatus) lastStatus = status;
99
144
  if (['ready', 'live', 'success'].includes(status)) {
100
- const url = pipe.deployment?.url || pipe.domain?.domain;
145
+ const url = pipe.hostings?.[0]?.domains?.[0]?.domain;
101
146
  ok(`Live${url ? ` → ${color.cyan(`https://${url.replace(/^https?:\/\//, '')}`)}` : ''}`);
102
147
  return;
103
148
  }
@@ -109,6 +154,66 @@ export async function deploy({ flags }) {
109
154
  warn('Timed out waiting for deployment. Check `glash status <id>`.');
110
155
  }
111
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
+
112
217
  function stepBadge(status) {
113
218
  if (status === 'success' || status === 'issued' || status === 'active' || status === 'ready') return color.green('✓');
114
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
  }
@@ -33,12 +33,21 @@ export async function createProject({ flags, positional }) {
33
33
 
34
34
  export async function linkProject({ flags, positional }) {
35
35
  let slug = flags.slug || positional[0];
36
+ const all = await apiGet('/projects');
37
+
36
38
  if (!slug) {
37
- const guess = basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]+/g, '-');
38
- slug = (await prompt(`Project slug [${guess}]: `)) || guess;
39
+ if (all.length === 0) {
40
+ return fail('No existing projects found. Run `glash projects:new` to create one.');
41
+ }
42
+ info('\nYour existing projects:');
43
+ all.forEach((p, i) => info(` ${color.dim(i + 1 + '.')} ${color.bold(p.name)} ${color.dim('(' + p.slug + ')')}`));
44
+ const choice = await prompt('\nWhich project do you want to link to? (enter number or slug): ');
45
+ const p = all.find((x, i) => x.slug === choice || String(i + 1) === choice);
46
+ if (!p) return fail(`No project found matching "${choice}".`);
47
+ slug = p.slug;
39
48
  }
49
+
40
50
  try {
41
- const all = await apiGet('/projects');
42
51
  const p = all.find((x) => x.slug === slug || x.id === slug);
43
52
  if (!p) return fail(`No project found matching "${slug}". Run \`glash projects\` to list.`);
44
53
  await writeProjectLink({ projectId: p.id, slug: p.slug });
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.1",
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": {