fabrica-e-commerce 0.1.15 → 0.1.17
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/package.json +1 -1
- package/src/bridge.js +2 -2
- package/src/cli.js +161 -22
- package/src/deploy.js +112 -50
- package/src/deps.js +82 -137
- package/src/github.js +4 -4
- package/src/prompt.js +68 -6
- package/src/ui.js +160 -36
package/package.json
CHANGED
package/src/bridge.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { BRIDGE_API_KEY, BRIDGE_ORIGIN, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from './config.js';
|
|
2
2
|
import { FULL_SQL } from './sql.js';
|
|
3
3
|
import { openUrl } from './system.js';
|
|
4
|
-
import { spinner } from './ui.js';
|
|
4
|
+
import { spinner, log } from './ui.js';
|
|
5
5
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
6
|
export async function connectSupabase() {
|
|
7
7
|
const spin = spinner('Posting secure schema job to Fabrica Connect');
|
|
@@ -9,7 +9,7 @@ export async function connectSupabase() {
|
|
|
9
9
|
if (!response.ok) throw new Error(`Bridge rejected job: ${response.status} ${await response.text()}`);
|
|
10
10
|
const job = await response.json();
|
|
11
11
|
spin.succeed('Bridge job created');
|
|
12
|
-
|
|
12
|
+
log(`Open this URL if your browser does not start: ${job.connectUrl}`);
|
|
13
13
|
await openUrl(job.connectUrl);
|
|
14
14
|
const started = Date.now();
|
|
15
15
|
const poll = spinner('Waiting for Supabase authorization and project selection');
|
package/src/cli.js
CHANGED
|
@@ -3,13 +3,14 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { connectSupabase } from './bridge.js';
|
|
6
|
-
import { collectEnv, cloneRepo, deployToVercel,
|
|
6
|
+
import { collectEnv, cloneRepo, deployToVercel, updateProjectEnv, ensureVercelLogin, runLocally } from './deploy.js';
|
|
7
7
|
import { createGithubRepoFromClone } from './github.js';
|
|
8
8
|
import { ensureDependencies, vinsCommand } from './deps.js';
|
|
9
9
|
import { BRIDGE_ORIGIN, STORE_REPO } from './config.js';
|
|
10
10
|
import { dataDir, readProjects } from './store.js';
|
|
11
|
-
import { choose } from './prompt.js';
|
|
12
|
-
import { banner, help, kv, section } from './ui.js';
|
|
11
|
+
import { choose, ask } from './prompt.js';
|
|
12
|
+
import { banner, help, kv, section, endSections, subBox, log } from './ui.js';
|
|
13
|
+
import { openUrl } from './system.js';
|
|
13
14
|
|
|
14
15
|
async function packageVersion() {
|
|
15
16
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -17,6 +18,7 @@ async function packageVersion() {
|
|
|
17
18
|
return pkg.version;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// ── build ─────────────────────────────────────────────────────────────────────
|
|
20
22
|
async function build() {
|
|
21
23
|
banner();
|
|
22
24
|
|
|
@@ -27,17 +29,19 @@ async function build() {
|
|
|
27
29
|
kv('BRIDGE', 'ONLINE');
|
|
28
30
|
kv('SQL', 'Prepared securely (hidden from UI)');
|
|
29
31
|
const supabase = await connectSupabase();
|
|
32
|
+
|
|
30
33
|
const env = await collectEnv(supabase);
|
|
31
34
|
const project = await cloneRepo();
|
|
32
35
|
|
|
33
|
-
section('
|
|
36
|
+
section('Run target');
|
|
34
37
|
const target = await choose('Where should Fabrica run this storefront?', [
|
|
35
38
|
{ name: 'Deploy on Vercel cloud', value: 'vercel' },
|
|
36
|
-
{ name: 'Run locally on this computer', value: 'local' }
|
|
39
|
+
{ name: 'Run locally on this computer', value: 'local' },
|
|
37
40
|
]);
|
|
38
41
|
|
|
39
42
|
if (target === 'local') {
|
|
40
43
|
await runLocally(project, env);
|
|
44
|
+
endSections();
|
|
41
45
|
return;
|
|
42
46
|
}
|
|
43
47
|
|
|
@@ -45,46 +49,181 @@ async function build() {
|
|
|
45
49
|
await ensureVercelLogin();
|
|
46
50
|
const githubRepo = await createGithubRepoFromClone(project);
|
|
47
51
|
const record = await deployToVercel(project, env, githubRepo);
|
|
52
|
+
|
|
48
53
|
section('Done');
|
|
49
54
|
kv('Project', record.projectName);
|
|
50
55
|
kv('GitHub repo', record.githubRepo || 'n/a');
|
|
56
|
+
kv('URL', record.productionUrl || 'n/a');
|
|
51
57
|
kv('Path', record.target);
|
|
58
|
+
endSections();
|
|
52
59
|
}
|
|
53
60
|
|
|
61
|
+
// ── list ──────────────────────────────────────────────────────────────────────
|
|
54
62
|
async function list() {
|
|
55
63
|
banner();
|
|
56
64
|
const projects = await readProjects();
|
|
65
|
+
|
|
66
|
+
section('Your Fabrica projects');
|
|
57
67
|
if (!projects.length) {
|
|
58
|
-
|
|
68
|
+
log('No projects found. Run: fabrica build');
|
|
69
|
+
endSections();
|
|
59
70
|
return;
|
|
60
71
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
|
|
73
|
+
const choices = projects.map((p) => ({
|
|
74
|
+
name: `${p.projectName} (${p.type === 'local' ? 'local' : 'cloud'})`,
|
|
75
|
+
value: p.id,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
endSections(); // flush before interactive prompt
|
|
79
|
+
const selected = await choose('Select a project to view details:', choices);
|
|
80
|
+
const project = projects.find((p) => p.id === selected);
|
|
81
|
+
|
|
82
|
+
section(`Project: ${project.projectName}`);
|
|
83
|
+
kv('Type', project.type === 'local' ? 'local' : 'cloud');
|
|
84
|
+
kv('Created', project.createdAt);
|
|
85
|
+
kv('Path', project.target);
|
|
86
|
+
kv('GitHub', project.githubRepo || 'n/a');
|
|
87
|
+
kv('Supabase', project.supabaseUrl || 'n/a');
|
|
88
|
+
kv('URL', project.productionUrl || 'n/a');
|
|
89
|
+
kv('Env keys', (project.envKeys || []).join(', '));
|
|
90
|
+
endSections();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── env ───────────────────────────────────────────────────────────────────────
|
|
94
|
+
async function env() {
|
|
95
|
+
banner();
|
|
96
|
+
const projects = await readProjects();
|
|
97
|
+
|
|
98
|
+
section('Environment manager');
|
|
99
|
+
if (!projects.length) {
|
|
100
|
+
log('No projects found. Run: fabrica build');
|
|
101
|
+
endSections();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
endSections();
|
|
105
|
+
|
|
106
|
+
const typeFilter = await choose('Which projects to show?', [
|
|
107
|
+
{ name: 'All projects', value: 'all' },
|
|
108
|
+
{ name: 'Local projects only', value: 'local' },
|
|
109
|
+
{ name: 'Cloud (Vercel) projects only', value: 'cloud' },
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const filtered = typeFilter === 'all' ? projects : projects.filter((p) => p.type === typeFilter);
|
|
113
|
+
if (!filtered.length) {
|
|
114
|
+
section('Environment manager');
|
|
115
|
+
log(`No ${typeFilter} projects found.`);
|
|
116
|
+
endSections();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const projectId = await choose('Select project:', filtered.map((p) => ({
|
|
121
|
+
name: `${p.projectName} (${p.type === 'local' ? 'local' : 'cloud'})`,
|
|
122
|
+
value: p.id,
|
|
123
|
+
})));
|
|
124
|
+
const project = filtered.find((p) => p.id === projectId);
|
|
125
|
+
|
|
126
|
+
const envKeys = project.envKeys || [];
|
|
127
|
+
if (!envKeys.length) {
|
|
128
|
+
section('Environment manager');
|
|
129
|
+
log('No env keys stored for this project.');
|
|
130
|
+
endSections();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const key = await choose('Select env variable to update:', envKeys.map((k) => ({ name: k, value: k })));
|
|
135
|
+
const currentVal = (project.env || {})[key];
|
|
136
|
+
const value = await ask(`New value for ${key}`, currentVal || '');
|
|
137
|
+
|
|
138
|
+
section('Applying update');
|
|
139
|
+
await updateProjectEnv(project, key, value);
|
|
140
|
+
kv('Updated', `${key} → ${project.type === 'local' ? '.env.local' : 'Vercel + redeployed'}`);
|
|
141
|
+
endSections();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── rerun ─────────────────────────────────────────────────────────────────────
|
|
145
|
+
async function rerun() {
|
|
146
|
+
banner();
|
|
147
|
+
const projects = await readProjects();
|
|
148
|
+
|
|
149
|
+
section('Re-run / re-open project');
|
|
150
|
+
if (!projects.length) {
|
|
151
|
+
log('No projects found. Run: fabrica build');
|
|
152
|
+
endSections();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
endSections();
|
|
156
|
+
|
|
157
|
+
const typeFilter = await choose('Which type of project?', [
|
|
158
|
+
{ name: 'All projects', value: 'all' },
|
|
159
|
+
{ name: 'Local projects', value: 'local' },
|
|
160
|
+
{ name: 'Cloud (Vercel) projects', value: 'cloud' },
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const filtered = typeFilter === 'all' ? projects : projects.filter((p) => p.type === typeFilter);
|
|
164
|
+
if (!filtered.length) {
|
|
165
|
+
section('Re-run / re-open project');
|
|
166
|
+
log(`No ${typeFilter} projects found.`);
|
|
167
|
+
endSections();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const projectId = await choose('Select project to re-run:', filtered.map((p) => ({
|
|
172
|
+
name: `${p.projectName} (${p.type === 'local' ? 'local' : 'cloud'})`,
|
|
173
|
+
value: p.id,
|
|
174
|
+
})));
|
|
175
|
+
const project = filtered.find((p) => p.id === projectId);
|
|
176
|
+
|
|
177
|
+
section(`Re-running: ${project.projectName}`);
|
|
178
|
+
|
|
179
|
+
if (project.type === 'local') {
|
|
64
180
|
kv('Path', project.target);
|
|
65
|
-
kv('
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
181
|
+
kv('URL', 'http://localhost:3000');
|
|
182
|
+
log('Starting dev server — browser opens in 3s...');
|
|
183
|
+
endSections();
|
|
184
|
+
|
|
185
|
+
const { runCommand, runCommandCapture } = await import('./system.js');
|
|
186
|
+
const { access } = await import('node:fs/promises');
|
|
187
|
+
const hasPnpmLock = await access(path.join(project.target, 'pnpm-lock.yaml')).then(() => true, () => false);
|
|
188
|
+
const pnpmAvailable = (await runCommandCapture('pnpm', ['--version'])).code === 0;
|
|
189
|
+
const devCommand = hasPnpmLock && pnpmAvailable ? ['pnpm', ['run', 'dev']] : ['npm', ['run', 'dev']];
|
|
190
|
+
setTimeout(() => openUrl('http://localhost:3000'), 3000);
|
|
191
|
+
await runCommand(devCommand[0], devCommand[1], { cwd: project.target });
|
|
192
|
+
} else {
|
|
193
|
+
const url = project.productionUrl || null;
|
|
194
|
+
kv('GitHub', project.githubRepo || 'n/a');
|
|
195
|
+
kv('URL', url || 'n/a');
|
|
196
|
+
kv('Inspect', project.inspectUrl || 'n/a');
|
|
197
|
+
kv('Created', project.createdAt);
|
|
198
|
+
if (url) { log(`Opening ${url} ...`); }
|
|
199
|
+
endSections();
|
|
200
|
+
if (url) await openUrl(url);
|
|
201
|
+
}
|
|
70
202
|
}
|
|
71
203
|
|
|
204
|
+
// ── info ──────────────────────────────────────────────────────────────────────
|
|
72
205
|
async function info() {
|
|
73
206
|
banner();
|
|
74
|
-
|
|
75
|
-
kv('
|
|
207
|
+
section('Package info');
|
|
208
|
+
kv('Package', `fabrica-e-commerce v${await packageVersion()}`);
|
|
209
|
+
kv('Bridge', BRIDGE_ORIGIN);
|
|
76
210
|
kv('Store repo', STORE_REPO);
|
|
77
211
|
kv('Local data', dataDir);
|
|
78
|
-
kv('Node',
|
|
212
|
+
kv('Node', process.version);
|
|
213
|
+
kv('Creator', 'SPARROW AI SOLUTION');
|
|
214
|
+
endSections();
|
|
79
215
|
}
|
|
80
216
|
|
|
217
|
+
// ── router ────────────────────────────────────────────────────────────────────
|
|
81
218
|
export async function run(args) {
|
|
82
219
|
const command = args[0] || 'help';
|
|
83
|
-
if (command === 'build')
|
|
84
|
-
if (command === 'list')
|
|
85
|
-
if (command === '
|
|
86
|
-
if (command === '
|
|
87
|
-
if (command === '
|
|
220
|
+
if (command === 'build') return build();
|
|
221
|
+
if (command === 'list') return list();
|
|
222
|
+
if (command === 'env') return env();
|
|
223
|
+
if (command === 'rerun') return rerun();
|
|
224
|
+
if (command === 'info' || command === '.info') return info();
|
|
225
|
+
if (command === 'vins' || command === '/vins') return vinsCommand();
|
|
226
|
+
if (command === 'help' || command === '--help' || command === '-h') return help();
|
|
88
227
|
console.error(`Unknown command: ${command}`);
|
|
89
228
|
help();
|
|
90
229
|
process.exitCode = 1;
|
package/src/deploy.js
CHANGED
|
@@ -3,8 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { HARDCODED_ENV, REQUIRED_ENV_KEYS, STORE_REPO } from './config.js';
|
|
5
5
|
import { buildsDir, saveProject } from './store.js';
|
|
6
|
-
import { runCommand, runCommandCapture } from './system.js';
|
|
7
|
-
import {
|
|
6
|
+
import { runCommand, runCommandCapture, openUrl } from './system.js';
|
|
7
|
+
import { kv, section, endSections, spinner, subBox, log, red, dimOrange } from './ui.js';
|
|
8
8
|
import { ask, choose } from './prompt.js';
|
|
9
9
|
|
|
10
10
|
const VERCEL_ENVIRONMENTS = ['production', 'preview', 'development'];
|
|
@@ -12,25 +12,32 @@ const VERCEL_ENVIRONMENTS = ['production', 'preview', 'development'];
|
|
|
12
12
|
export async function collectEnv(supabase) {
|
|
13
13
|
section('Environment variables');
|
|
14
14
|
const env = { ...HARDCODED_ENV, SUPABASE_URL: supabase.url, SUPABASE_ANON_KEY: supabase.anonKey };
|
|
15
|
-
for (const key of REQUIRED_ENV_KEYS)
|
|
15
|
+
for (const key of REQUIRED_ENV_KEYS) {
|
|
16
|
+
endSections(); // flush so the ask() prompt appears cleanly
|
|
17
|
+
env[key] = await ask(`${key}`);
|
|
18
|
+
section('Environment variables');
|
|
19
|
+
}
|
|
16
20
|
return env;
|
|
17
21
|
}
|
|
22
|
+
|
|
18
23
|
export async function cloneRepo() {
|
|
24
|
+
endSections();
|
|
19
25
|
section('Clone storefront');
|
|
20
26
|
const projectName = await ask('Vercel project name', `fabrica-store-${Date.now()}`);
|
|
27
|
+
section('Clone storefront');
|
|
21
28
|
const id = crypto.randomUUID();
|
|
22
29
|
const target = path.join(buildsDir, `${projectName}-${id.slice(0, 8)}`);
|
|
30
|
+
// git clone output goes directly to inherited stdio — show it outside the box
|
|
31
|
+
endSections();
|
|
23
32
|
await runCommand('git', ['clone', STORE_REPO, target]);
|
|
24
33
|
return { id, projectName, target };
|
|
25
34
|
}
|
|
35
|
+
|
|
26
36
|
export async function isLoggedInToVercel() {
|
|
27
37
|
const result = await runCommandCapture('npx', ['--yes', 'vercel@latest', 'whoami']);
|
|
28
38
|
return result.code === 0;
|
|
29
39
|
}
|
|
30
40
|
|
|
31
|
-
// Step 3: verify Vercel login before anything else touches Vercel. If the
|
|
32
|
-
// user isn't logged in, run the real interactive `vercel login` flow (same
|
|
33
|
-
// as typing it in a terminal) and block until it succeeds.
|
|
34
41
|
export async function ensureVercelLogin() {
|
|
35
42
|
section('Vercel login');
|
|
36
43
|
const spin = spinner('Checking Vercel login');
|
|
@@ -39,58 +46,66 @@ export async function ensureVercelLogin() {
|
|
|
39
46
|
return;
|
|
40
47
|
}
|
|
41
48
|
spin.fail('Not logged in to Vercel');
|
|
42
|
-
|
|
49
|
+
log('Opening "vercel login" — finish the login in your browser...');
|
|
50
|
+
endSections();
|
|
43
51
|
await runCommand('npx', ['vercel@latest', 'login']);
|
|
52
|
+
section('Vercel login');
|
|
44
53
|
if (!(await isLoggedInToVercel())) {
|
|
45
54
|
throw new Error('Vercel login was not completed. Run "fabrica build" again after logging in.');
|
|
46
55
|
}
|
|
47
56
|
kv('Vercel', 'Logged in');
|
|
48
57
|
}
|
|
49
58
|
|
|
50
|
-
//
|
|
51
|
-
|
|
59
|
+
// Capture vercel CLI output and add it into the current section buffer as a subBox
|
|
60
|
+
async function runVercelBoxed(args, options = {}) {
|
|
61
|
+
const result = await runCommandCapture('npx', ['vercel@latest', ...args], options);
|
|
62
|
+
const raw = ((result.stdout || '') + (result.stderr || '')).trim();
|
|
63
|
+
if (!raw) return result;
|
|
64
|
+
const lines = raw.split('\n').map((l) => l.trimEnd()).filter(Boolean);
|
|
65
|
+
const isErr = result.code !== 0 || lines.some((l) => /^Error:/i.test(l));
|
|
66
|
+
subBox(lines, { isError: isErr });
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
async function setEnvEverywhere(project, env) {
|
|
53
71
|
for (const [key, value] of Object.entries(env)) {
|
|
54
72
|
const spin = spinner(`Setting ${key}`);
|
|
55
73
|
for (const environment of VERCEL_ENVIRONMENTS) {
|
|
56
|
-
await
|
|
57
|
-
await
|
|
74
|
+
await runVercelBoxed(['env', 'rm', key, environment, '--yes'], { cwd: project.target, allowFailure: true });
|
|
75
|
+
await runVercelBoxed(['env', 'add', key, environment], { cwd: project.target, input: `${value}\n` });
|
|
58
76
|
}
|
|
59
77
|
spin.succeed(`Set ${key} (production, preview, development)`);
|
|
60
78
|
}
|
|
61
79
|
}
|
|
62
80
|
|
|
63
|
-
// Vercel's GitHub App integration indexes newly created/forked repos on its
|
|
64
|
-
// own delay, separate from GitHub itself being aware of the repo. Right after
|
|
65
|
-
// a fork, `vercel git connect` can fail simply because Vercel hasn't synced
|
|
66
|
-
// yet — not because anything is actually wrong. Retry with backoff before
|
|
67
|
-
// giving up, then fall back to a direct deploy with clear next steps.
|
|
68
81
|
async function connectGithubRepo(project, repoUrl) {
|
|
69
82
|
const spin = spinner(`Connecting Vercel project to ${repoUrl}`);
|
|
70
83
|
const attempts = 6;
|
|
71
84
|
let lastError = '';
|
|
72
|
-
for (let attempt = 1; attempt <= attempts; attempt
|
|
85
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
73
86
|
const connect = await runCommandCapture('npx', ['--yes', 'vercel@latest', 'git', 'connect', repoUrl, '--yes'], { cwd: project.target });
|
|
74
|
-
if (connect.code === 0) {
|
|
75
|
-
spin.succeed('Vercel project connected to GitHub repo (future pushes auto-deploy)');
|
|
76
|
-
return true;
|
|
77
|
-
}
|
|
87
|
+
if (connect.code === 0) { spin.succeed('Vercel project connected to GitHub repo'); return true; }
|
|
78
88
|
lastError = (connect.stderr || connect.stdout || '').trim();
|
|
79
|
-
if (attempt < attempts) await new Promise((
|
|
89
|
+
if (attempt < attempts) await new Promise((r) => setTimeout(r, attempt * 3000));
|
|
80
90
|
}
|
|
81
91
|
spin.fail('Could not auto-connect Git — continuing with a direct deploy');
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
subBox([
|
|
93
|
+
'This usually means one of two things:',
|
|
94
|
+
`1) Vercel hasn't finished indexing the new fork yet`,
|
|
95
|
+
` Re-link: npx vercel git connect ${repoUrl}`,
|
|
96
|
+
'2) Vercel GitHub App set to "Only select repositories"',
|
|
97
|
+
' Fix at: https://github.com/settings/installations',
|
|
98
|
+
lastError ? `Last error: ${lastError}` : '',
|
|
99
|
+
].filter(Boolean), { isError: true });
|
|
86
100
|
return false;
|
|
87
101
|
}
|
|
88
102
|
|
|
89
103
|
export async function deployToVercel(project, env, githubRepo) {
|
|
90
104
|
section('Vercel deployment');
|
|
91
105
|
await ensureVercelLogin();
|
|
106
|
+
section('Vercel deployment');
|
|
92
107
|
|
|
93
|
-
await
|
|
108
|
+
await runVercelBoxed(['link', '--yes', '--project', project.projectName], { cwd: project.target });
|
|
94
109
|
|
|
95
110
|
if (githubRepo?.repoUrl) {
|
|
96
111
|
await connectGithubRepo(project, githubRepo.repoUrl);
|
|
@@ -99,52 +114,99 @@ export async function deployToVercel(project, env, githubRepo) {
|
|
|
99
114
|
await setEnvEverywhere(project, env);
|
|
100
115
|
|
|
101
116
|
const deploySpin = spinner('Creating production deployment');
|
|
102
|
-
await
|
|
117
|
+
const deployResult = await runCommandCapture('npx', ['vercel@latest', '--prod', '--yes'], { cwd: project.target });
|
|
118
|
+
const deployOutput = ((deployResult.stdout || '') + (deployResult.stderr || '')).trim();
|
|
119
|
+
|
|
120
|
+
let productionUrl = null;
|
|
121
|
+
let aliasedUrl = null;
|
|
122
|
+
let inspectUrl = null;
|
|
123
|
+
for (const line of deployOutput.split('\n')) {
|
|
124
|
+
const m = line.match(/Production\s+(\S+)/); if (m) productionUrl = m[1];
|
|
125
|
+
const a = line.match(/Aliased\s+(\S+)/); if (a) aliasedUrl = a[1];
|
|
126
|
+
const i = line.match(/Inspect\s+(\S+)/); if (i) inspectUrl = i[1];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
subBox(deployOutput.split('\n').filter(Boolean));
|
|
103
130
|
deploySpin.succeed('Production deployment created');
|
|
104
131
|
|
|
132
|
+
const openTarget = aliasedUrl || productionUrl;
|
|
133
|
+
if (openTarget) {
|
|
134
|
+
kv('Opening', openTarget);
|
|
135
|
+
// open after flush
|
|
136
|
+
process.nextTick(() => openUrl(openTarget));
|
|
137
|
+
}
|
|
138
|
+
|
|
105
139
|
const record = {
|
|
106
140
|
...project,
|
|
141
|
+
type: 'cloud',
|
|
107
142
|
repo: STORE_REPO,
|
|
108
143
|
githubRepo: githubRepo?.repoUrl || null,
|
|
109
144
|
createdAt: new Date().toISOString(),
|
|
110
145
|
envKeys: Object.keys(env),
|
|
111
|
-
|
|
146
|
+
env,
|
|
147
|
+
supabaseUrl: env.SUPABASE_URL,
|
|
148
|
+
productionUrl: aliasedUrl || productionUrl || null,
|
|
149
|
+
inspectUrl: inspectUrl || null,
|
|
112
150
|
};
|
|
113
151
|
await saveProject(record);
|
|
114
152
|
return record;
|
|
115
153
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
154
|
+
|
|
155
|
+
export async function updateProjectEnv(project, key, value) {
|
|
156
|
+
if (project.type === 'local') {
|
|
157
|
+
const envPath = path.join(project.target, '.env.local');
|
|
158
|
+
let contents = '';
|
|
159
|
+
try { contents = await fs.readFile(envPath, 'utf8'); } catch { /* new file */ }
|
|
160
|
+
const lines = contents.split('\n');
|
|
161
|
+
const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
|
|
162
|
+
if (idx >= 0) lines[idx] = `${key}=${value}`;
|
|
163
|
+
else lines.push(`${key}=${value}`);
|
|
164
|
+
await fs.writeFile(envPath, lines.join('\n'), 'utf8');
|
|
165
|
+
kv('Updated', `${key} in .env.local`);
|
|
166
|
+
} else {
|
|
167
|
+
await ensureVercelLogin();
|
|
168
|
+
section('Applying update');
|
|
169
|
+
for (const environment of VERCEL_ENVIRONMENTS) {
|
|
170
|
+
await runVercelBoxed(['env', 'rm', key, environment, '--yes'], { cwd: project.target, allowFailure: true });
|
|
171
|
+
await runVercelBoxed(['env', 'add', key, environment], { cwd: project.target, input: `${value}\n` });
|
|
172
|
+
}
|
|
173
|
+
const spin = spinner('Redeploying...');
|
|
174
|
+
await runVercelBoxed(['--prod', '--yes'], { cwd: project.target });
|
|
175
|
+
spin.succeed(`Redeployed ${project.projectName}`);
|
|
126
176
|
}
|
|
127
|
-
|
|
128
|
-
|
|
177
|
+
const updated = { ...project, env: { ...(project.env || {}), [key]: value } };
|
|
178
|
+
await saveProject(updated);
|
|
129
179
|
}
|
|
130
180
|
|
|
131
|
-
|
|
132
181
|
export async function runLocally(project, env) {
|
|
133
|
-
section('Local
|
|
182
|
+
section('Local setup');
|
|
134
183
|
const envPath = path.join(project.target, '.env.local');
|
|
135
|
-
const contents = Object.entries(env).map(([
|
|
184
|
+
const contents = Object.entries(env).map(([k, v]) => `${k}=${String(v).replace(/\n/g, '\\n')}`).join('\n') + '\n';
|
|
136
185
|
await fs.writeFile(envPath, contents, 'utf8');
|
|
137
186
|
kv('Env file', envPath);
|
|
138
187
|
|
|
139
188
|
const hasPnpmLock = await fs.access(path.join(project.target, 'pnpm-lock.yaml')).then(() => true, () => false);
|
|
140
189
|
const pnpmAvailable = (await runCommandCapture('pnpm', ['--version'])).code === 0;
|
|
141
|
-
const
|
|
142
|
-
const
|
|
190
|
+
const installCmd = hasPnpmLock && pnpmAvailable ? ['pnpm', ['install']] : ['npm', ['install']];
|
|
191
|
+
const devCmd = hasPnpmLock && pnpmAvailable ? ['pnpm', ['run', 'dev']] : ['npm', ['run', 'dev']];
|
|
192
|
+
|
|
193
|
+
const record = {
|
|
194
|
+
...project, type: 'local', repo: STORE_REPO, githubRepo: null,
|
|
195
|
+
createdAt: new Date().toISOString(), envKeys: Object.keys(env),
|
|
196
|
+
env, supabaseUrl: env.SUPABASE_URL, productionUrl: 'http://localhost:3000',
|
|
197
|
+
};
|
|
198
|
+
await saveProject(record);
|
|
199
|
+
|
|
200
|
+
kv('URL', 'http://localhost:3000');
|
|
201
|
+
log('Installing dependencies...');
|
|
202
|
+
endSections();
|
|
203
|
+
await runCommand(installCmd[0], installCmd[1], { cwd: project.target });
|
|
143
204
|
|
|
144
|
-
await runCommand(installCommand[0], installCommand[1], { cwd: project.target });
|
|
145
205
|
section('Local app');
|
|
146
206
|
kv('Path', project.target);
|
|
147
|
-
kv('URL',
|
|
148
|
-
|
|
149
|
-
|
|
207
|
+
kv('URL', 'http://localhost:3000');
|
|
208
|
+
log('Starting dev server — browser opens in 3s...');
|
|
209
|
+
endSections();
|
|
210
|
+
setTimeout(() => openUrl('http://localhost:3000'), 3000);
|
|
211
|
+
await runCommand(devCmd[0], devCmd[1], { cwd: project.target });
|
|
150
212
|
}
|
package/src/deps.js
CHANGED
|
@@ -2,15 +2,16 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { commandExists, runCommand, runCommandCapture } from './system.js';
|
|
5
|
-
import { kv, section, spinner } from './ui.js';
|
|
5
|
+
import { kv, section, endSections, spinner, subBox, log } from './ui.js';
|
|
6
|
+
|
|
7
|
+
function isTermux() {
|
|
8
|
+
return process.platform === 'android' ||
|
|
9
|
+
(process.platform === 'linux' && (process.env.TERMUX_VERSION || process.env.PREFIX?.includes('com.termux')));
|
|
10
|
+
}
|
|
6
11
|
|
|
7
|
-
// Some systems (root containers, minimal CI images) have no `sudo` binary
|
|
8
|
-
// at all. Fall back to running the command directly in that case instead
|
|
9
|
-
// of failing outright.
|
|
10
12
|
async function runPrivileged(command, args, options = {}) {
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
}
|
|
13
|
+
if (process.platform === 'win32' || isTermux()) return runCommand(command, args, options);
|
|
14
|
+
if (await commandExists('sudo')) return runCommand('sudo', [command, ...args], options);
|
|
14
15
|
return runCommand(command, args, options);
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -18,191 +19,136 @@ function runPrivilegedShell(script, options = {}) {
|
|
|
18
19
|
return runCommand('bash', ['-c', script], options);
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
// Dependencies the package actually shells out to. "git" and "gh" (GitHub
|
|
22
|
-
// CLI) are real external binaries we depend on; "vercel" is fetched on
|
|
23
|
-
// demand through npx so we just confirm npm/npx can resolve it.
|
|
24
22
|
const DEPENDENCIES = [
|
|
25
|
-
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
check: () => checkWithPathRefresh('git'),
|
|
29
|
-
install: installGit,
|
|
30
|
-
manualUrl: 'https://git-scm.com/downloads'
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
name: 'gh',
|
|
34
|
-
label: 'GitHub CLI (gh)',
|
|
35
|
-
check: () => checkWithPathRefresh('gh'),
|
|
36
|
-
install: installGithubCli,
|
|
37
|
-
manualUrl: 'https://github.com/cli/cli#installation'
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
name: 'vercel',
|
|
41
|
-
label: 'Vercel CLI (via npx)',
|
|
42
|
-
check: checkVercelCli,
|
|
43
|
-
install: warmVercelCli,
|
|
44
|
-
manualUrl: 'https://vercel.com/docs/cli'
|
|
45
|
-
}
|
|
23
|
+
{ name: 'git', label: 'Git', check: () => checkWithPathRefresh('git'), install: installGit, manualUrl: 'https://git-scm.com/downloads' },
|
|
24
|
+
{ name: 'gh', label: 'GitHub CLI (gh)', check: () => checkWithPathRefresh('gh'), install: installGithubCli, manualUrl: 'https://github.com/cli/cli#installation' },
|
|
25
|
+
{ name: 'vercel', label: 'Vercel CLI (via npx)', check: checkVercelCli, install: warmVercelCli, manualUrl: 'https://vercel.com/docs/cli' },
|
|
46
26
|
];
|
|
47
27
|
|
|
48
|
-
|
|
49
28
|
function addToPathIfDirectory(directory) {
|
|
50
29
|
if (!directory || !fs.existsSync(directory)) return;
|
|
51
|
-
const delimiter = path.delimiter;
|
|
52
30
|
const current = process.env.PATH || '';
|
|
53
|
-
const entries = current.split(delimiter).filter(Boolean);
|
|
54
|
-
if (!entries.some((
|
|
55
|
-
process.env.PATH = `${directory}${delimiter}${current}`;
|
|
56
|
-
}
|
|
31
|
+
const entries = current.split(path.delimiter).filter(Boolean);
|
|
32
|
+
if (!entries.some((e) => e.toLowerCase() === directory.toLowerCase()))
|
|
33
|
+
process.env.PATH = `${directory}${path.delimiter}${current}`;
|
|
57
34
|
}
|
|
58
35
|
|
|
59
36
|
function refreshWindowsToolPaths() {
|
|
60
37
|
if (process.platform !== 'win32') return;
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
addToPathIfDirectory(
|
|
66
|
-
addToPathIfDirectory(
|
|
67
|
-
addToPathIfDirectory(
|
|
68
|
-
addToPathIfDirectory(
|
|
69
|
-
addToPathIfDirectory(
|
|
70
|
-
addToPathIfDirectory(
|
|
38
|
+
const lad = process.env.LOCALAPPDATA;
|
|
39
|
+
const pf = process.env.ProgramFiles;
|
|
40
|
+
const pf86 = process.env['ProgramFiles(x86)'];
|
|
41
|
+
const pd = process.env.ProgramData;
|
|
42
|
+
addToPathIfDirectory(lad && path.join(lad, 'Microsoft', 'WinGet', 'Links'));
|
|
43
|
+
addToPathIfDirectory(pf && path.join(pf, 'GitHub CLI'));
|
|
44
|
+
addToPathIfDirectory(pf86 && path.join(pf86, 'GitHub CLI'));
|
|
45
|
+
addToPathIfDirectory(pd && path.join(pd, 'chocolatey', 'bin'));
|
|
46
|
+
addToPathIfDirectory(pf && path.join(pf, 'Git', 'cmd'));
|
|
47
|
+
addToPathIfDirectory(pf && path.join(pf, 'nodejs'));
|
|
71
48
|
}
|
|
72
49
|
|
|
73
|
-
async function checkWithPathRefresh(
|
|
74
|
-
refreshWindowsToolPaths();
|
|
75
|
-
return commandExists(command);
|
|
76
|
-
}
|
|
50
|
+
async function checkWithPathRefresh(cmd) { refreshWindowsToolPaths(); return commandExists(cmd); }
|
|
77
51
|
|
|
78
52
|
async function runFirstSuccessful(candidates, options = {}) {
|
|
79
|
-
for (const [
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
53
|
+
for (const [cmd, args] of candidates) {
|
|
54
|
+
const r = await runCommandCapture(cmd, args, options);
|
|
55
|
+
if (r.code === 0) return r;
|
|
82
56
|
}
|
|
83
57
|
return { code: 1, stdout: '', stderr: 'All fallback commands failed' };
|
|
84
58
|
}
|
|
85
59
|
|
|
86
60
|
async function checkVercelCli() {
|
|
87
61
|
refreshWindowsToolPaths();
|
|
88
|
-
const
|
|
89
|
-
['npx',
|
|
90
|
-
['npm',
|
|
91
|
-
['vercel', ['--version']]
|
|
62
|
+
const r = await runFirstSuccessful([
|
|
63
|
+
['npx', ['--yes', 'vercel@latest', '--version']],
|
|
64
|
+
['npm', ['exec', '--yes', 'vercel@latest', '--', '--version']],
|
|
65
|
+
['vercel', ['--version']],
|
|
92
66
|
]);
|
|
93
|
-
return
|
|
67
|
+
return r.code === 0;
|
|
94
68
|
}
|
|
95
69
|
|
|
96
70
|
async function warmVercelCli() {
|
|
97
71
|
refreshWindowsToolPaths();
|
|
98
|
-
const
|
|
99
|
-
['npx',
|
|
100
|
-
['npm',
|
|
101
|
-
['vercel', ['--version']]
|
|
72
|
+
const r = await runFirstSuccessful([
|
|
73
|
+
['npx', ['--yes', 'vercel@latest', '--version']],
|
|
74
|
+
['npm', ['exec', '--yes', 'vercel@latest', '--', '--version']],
|
|
75
|
+
['vercel', ['--version']],
|
|
102
76
|
]);
|
|
103
|
-
if (
|
|
104
|
-
// Last resort: install a global Vercel binary so future invocations have a
|
|
105
|
-
// real `vercel` executable even when npx/npm exec cannot launch correctly.
|
|
77
|
+
if (r.code === 0) return;
|
|
106
78
|
await runCommand('npm', ['install', '-g', 'vercel@latest'], { allowFailure: true });
|
|
107
79
|
}
|
|
108
80
|
|
|
109
81
|
async function installGit() {
|
|
110
82
|
refreshWindowsToolPaths();
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
if (await commandExists('dnf')) return runPrivileged('dnf', ['install', '-y', 'git'], { allowFailure: true });
|
|
119
|
-
if (await commandExists('yum')) return runPrivileged('yum', ['install', '-y', 'git'], { allowFailure: true });
|
|
83
|
+
const p = process.platform;
|
|
84
|
+
if (isTermux()) return runCommand('pkg', ['install', '-y', 'git'], { allowFailure: true });
|
|
85
|
+
if (p === 'linux') {
|
|
86
|
+
if (await commandExists('apt-get')) { await runPrivileged('apt-get', ['update'], { allowFailure: true }); return runPrivileged('apt-get', ['install', '-y', 'git'], { allowFailure: true }); }
|
|
87
|
+
if (await commandExists('dnf')) return runPrivileged('dnf', ['install', '-y', 'git'], { allowFailure: true });
|
|
88
|
+
if (await commandExists('yum')) return runPrivileged('yum', ['install', '-y', 'git'], { allowFailure: true });
|
|
120
89
|
if (await commandExists('pacman')) return runPrivileged('pacman', ['-Sy', '--noconfirm', 'git'], { allowFailure: true });
|
|
90
|
+
if (await commandExists('zypper')) return runPrivileged('zypper', ['install', '-y', 'git'], { allowFailure: true });
|
|
91
|
+
if (await commandExists('apk')) return runPrivileged('apk', ['add', 'git'], { allowFailure: true });
|
|
121
92
|
}
|
|
122
|
-
if (
|
|
93
|
+
if (p === 'darwin') {
|
|
123
94
|
if (await commandExists('brew')) return runCommand('brew', ['install', 'git'], { allowFailure: true });
|
|
95
|
+
return runCommand('xcode-select', ['--install'], { allowFailure: true });
|
|
124
96
|
}
|
|
125
|
-
if (
|
|
126
|
-
if (await commandExists('winget')) {
|
|
127
|
-
|
|
128
|
-
refreshWindowsToolPaths();
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
if (await commandExists('choco')) {
|
|
132
|
-
await runCommand('choco', ['install', 'git', '-y'], { allowFailure: true });
|
|
133
|
-
refreshWindowsToolPaths();
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
97
|
+
if (p === 'win32') {
|
|
98
|
+
if (await commandExists('winget')) { await runCommand('winget', ['install', '--id', 'Git.Git', '-e', '--source', 'winget'], { allowFailure: true }); refreshWindowsToolPaths(); return; }
|
|
99
|
+
if (await commandExists('choco')) { await runCommand('choco', ['install', 'git', '-y'], { allowFailure: true }); refreshWindowsToolPaths(); }
|
|
136
100
|
}
|
|
137
101
|
}
|
|
138
102
|
|
|
139
103
|
async function installGithubCli() {
|
|
140
104
|
refreshWindowsToolPaths();
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
105
|
+
const p = process.platform;
|
|
106
|
+
if (isTermux()) return runCommand('pkg', ['install', '-y', 'gh'], { allowFailure: true });
|
|
107
|
+
if (p === 'linux') {
|
|
143
108
|
if (await commandExists('apt-get')) {
|
|
144
|
-
// Try the plain package first (present on newer Ubuntu/Debian).
|
|
145
109
|
const direct = await (await commandExists('sudo')
|
|
146
110
|
? runCommandCapture('sudo', ['apt-get', 'install', '-y', 'gh'])
|
|
147
111
|
: runCommandCapture('apt-get', ['install', '-y', 'gh']));
|
|
148
112
|
if (direct.code === 0) return;
|
|
149
|
-
|
|
150
|
-
const usesSudo = await commandExists('sudo');
|
|
151
|
-
const prefix = usesSudo ? 'sudo ' : '';
|
|
113
|
+
const pre = (await commandExists('sudo')) ? 'sudo ' : '';
|
|
152
114
|
await runPrivilegedShell([
|
|
153
|
-
'set -e',
|
|
154
|
-
`${
|
|
155
|
-
|
|
156
|
-
`${
|
|
157
|
-
`
|
|
158
|
-
`${
|
|
159
|
-
`echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | ${prefix}tee /etc/apt/sources.list.d/github-cli.list > /dev/null`,
|
|
160
|
-
`${prefix}apt-get update`,
|
|
161
|
-
`${prefix}apt-get install -y gh`
|
|
115
|
+
'set -e', `${pre}apt-get update`, `${pre}apt-get install -y curl ca-certificates gnupg`,
|
|
116
|
+
`${pre}mkdir -p -m 755 /etc/apt/keyrings`,
|
|
117
|
+
`curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | ${pre}tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null`,
|
|
118
|
+
`${pre}chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg`,
|
|
119
|
+
`echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | ${pre}tee /etc/apt/sources.list.d/github-cli.list > /dev/null`,
|
|
120
|
+
`${pre}apt-get update`, `${pre}apt-get install -y gh`,
|
|
162
121
|
].join(' && '), { allowFailure: true });
|
|
163
122
|
return;
|
|
164
123
|
}
|
|
165
|
-
if (await commandExists('dnf'))
|
|
124
|
+
if (await commandExists('dnf')) return runPrivileged('dnf', ['install', '-y', 'gh'], { allowFailure: true });
|
|
166
125
|
if (await commandExists('pacman')) return runPrivileged('pacman', ['-Sy', '--noconfirm', 'github-cli'], { allowFailure: true });
|
|
126
|
+
if (await commandExists('zypper')) return runPrivileged('zypper', ['install', '-y', 'gh'], { allowFailure: true });
|
|
127
|
+
if (await commandExists('apk')) return runCommand('apk', ['add', 'github-cli'], { allowFailure: true });
|
|
167
128
|
}
|
|
168
|
-
if (
|
|
129
|
+
if (p === 'darwin') {
|
|
169
130
|
if (await commandExists('brew')) return runCommand('brew', ['install', 'gh'], { allowFailure: true });
|
|
170
131
|
}
|
|
171
|
-
if (
|
|
172
|
-
if (await commandExists('winget')) {
|
|
173
|
-
|
|
174
|
-
refreshWindowsToolPaths();
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
if (await commandExists('choco')) {
|
|
178
|
-
await runCommand('choco', ['install', 'gh', '-y'], { allowFailure: true });
|
|
179
|
-
refreshWindowsToolPaths();
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
132
|
+
if (p === 'win32') {
|
|
133
|
+
if (await commandExists('winget')) { await runCommand('winget', ['install', '--id', 'GitHub.cli', '-e', '--source', 'winget'], { allowFailure: true }); refreshWindowsToolPaths(); return; }
|
|
134
|
+
if (await commandExists('choco')) { await runCommand('choco', ['install', 'gh', '-y'], { allowFailure: true }); refreshWindowsToolPaths(); }
|
|
182
135
|
}
|
|
183
136
|
}
|
|
184
137
|
|
|
185
138
|
export async function ensureDependencies({ autoInstall = true, names } = {}) {
|
|
186
|
-
const targets = names ? DEPENDENCIES.filter((
|
|
139
|
+
const targets = names ? DEPENDENCIES.filter((d) => names.includes(d.name)) : DEPENDENCIES;
|
|
187
140
|
const results = [];
|
|
188
141
|
for (const dep of targets) {
|
|
189
142
|
const spin = spinner(`Checking ${dep.label}`);
|
|
190
143
|
let present = await dep.check();
|
|
191
|
-
if (present) {
|
|
192
|
-
spin.succeed(`${dep.label} found`);
|
|
193
|
-
results.push({ ...dep, present, installed: false });
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
144
|
+
if (present) { spin.succeed(`${dep.label} found`); results.push({ ...dep, present, installed: false }); continue; }
|
|
196
145
|
spin.fail(`${dep.label} missing`);
|
|
197
|
-
if (!autoInstall) {
|
|
198
|
-
results.push({ ...dep, present: false, installed: false });
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
146
|
+
if (!autoInstall) { results.push({ ...dep, present: false, installed: false }); continue; }
|
|
201
147
|
const installSpin = spinner(`Installing ${dep.label}`);
|
|
202
148
|
await dep.install();
|
|
203
149
|
present = await dep.check();
|
|
204
150
|
if (present) installSpin.succeed(`${dep.label} installed`);
|
|
205
|
-
else
|
|
151
|
+
else installSpin.fail(`${dep.label} could not be installed automatically`);
|
|
206
152
|
results.push({ ...dep, present, installed: present });
|
|
207
153
|
}
|
|
208
154
|
return results;
|
|
@@ -210,21 +156,20 @@ export async function ensureDependencies({ autoInstall = true, names } = {}) {
|
|
|
210
156
|
|
|
211
157
|
export async function vinsCommand() {
|
|
212
158
|
section('Fabrica dependency check (vins)');
|
|
159
|
+
kv('Platform', isTermux() ? 'Termux/Android' : process.platform);
|
|
213
160
|
const results = await ensureDependencies();
|
|
161
|
+
|
|
214
162
|
section('Summary');
|
|
163
|
+
const summaryLines = [];
|
|
215
164
|
let allGood = true;
|
|
216
165
|
for (const dep of results) {
|
|
217
|
-
|
|
218
|
-
if (!dep.present) {
|
|
219
|
-
allGood = false;
|
|
220
|
-
console.log(` Manual install: ${dep.manualUrl}`);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
if (allGood) {
|
|
224
|
-
console.log('\nAll dependencies are ready. You can run: fabrica build');
|
|
225
|
-
} else {
|
|
226
|
-
console.log('\nSome dependencies could not be installed automatically. Install them manually using the links above, then re-run "fabrica vins".');
|
|
227
|
-
process.exitCode = 1;
|
|
166
|
+
summaryLines.push(`${dep.label}: ${dep.present ? '✓ OK' : '✗ MISSING — install manually'}`);
|
|
167
|
+
if (!dep.present) { allGood = false; summaryLines.push(` Manual: ${dep.manualUrl}`); }
|
|
228
168
|
}
|
|
169
|
+
subBox(summaryLines, { isError: !allGood });
|
|
170
|
+
if (allGood) log('All dependencies ready. Run: fabrica build');
|
|
171
|
+
else log('Some deps could not be installed. See links above.');
|
|
172
|
+
endSections();
|
|
173
|
+
if (!allGood) process.exitCode = 1;
|
|
229
174
|
return results;
|
|
230
175
|
}
|
package/src/github.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { commandExists, runCommand, runCommandCapture } from './system.js';
|
|
2
2
|
import { STORE_REPO } from './config.js';
|
|
3
3
|
import { choose } from './prompt.js';
|
|
4
|
-
import { dimOrange, kv, section, spinner } from './ui.js';
|
|
4
|
+
import { dimOrange, kv, section, spinner, log } from './ui.js';
|
|
5
5
|
|
|
6
6
|
export async function isGithubCliInstalled() {
|
|
7
7
|
return commandExists('gh');
|
|
@@ -27,7 +27,7 @@ export async function ensureGithubLogin() {
|
|
|
27
27
|
spin.succeed('Already logged in to GitHub');
|
|
28
28
|
} else {
|
|
29
29
|
spin.fail('Not logged in to GitHub');
|
|
30
|
-
|
|
30
|
+
log('A browser/device flow will open — log in with "gh auth login"...');
|
|
31
31
|
await runCommand('gh', ['auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--web']);
|
|
32
32
|
if (!(await isLoggedInToGithub())) {
|
|
33
33
|
throw new Error('GitHub login was not completed. Run "fabrica build" again after logging in with "gh auth login".');
|
|
@@ -40,7 +40,7 @@ export async function ensureGithubLogin() {
|
|
|
40
40
|
gitSpin.succeed('GitHub API credentials ready');
|
|
41
41
|
} else {
|
|
42
42
|
gitSpin.fail('Could not configure GitHub git credentials automatically');
|
|
43
|
-
|
|
43
|
+
log('Continuing — if Git asks for credentials later, run: gh auth setup-git');
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -157,7 +157,7 @@ async function forkSourceRepo(owner, repoName) {
|
|
|
157
157
|
}
|
|
158
158
|
renameSpin.fail(`Could not rename — continuing with the existing fork name`);
|
|
159
159
|
kv('GitHub repo', `https://github.com/${owner}/${actualName}`);
|
|
160
|
-
|
|
160
|
+
log(`Note: this is named "${actualName}", not "${repoName}", because your GitHub account already had a fork of ${source}.`);
|
|
161
161
|
return actualName;
|
|
162
162
|
}
|
|
163
163
|
|
package/src/prompt.js
CHANGED
|
@@ -1,17 +1,79 @@
|
|
|
1
1
|
import readline from 'node:readline/promises';
|
|
2
2
|
import { stdin as input, stdout as output } from 'node:process';
|
|
3
|
+
import { orange, dimOrange, bold } from './ui.js';
|
|
3
4
|
|
|
5
|
+
// Raw readline ask (no frills)
|
|
4
6
|
export async function ask(message, defaultValue = '') {
|
|
5
7
|
const rl = readline.createInterface({ input, output });
|
|
6
8
|
const suffix = defaultValue ? ` (${defaultValue})` : '';
|
|
7
|
-
const answer = await rl.question(
|
|
9
|
+
const answer = await rl.question(` ${orange('?')} ${bold(message)}${dimOrange(suffix)}: `);
|
|
8
10
|
rl.close();
|
|
9
|
-
return answer || defaultValue;
|
|
11
|
+
return answer.trim() || defaultValue;
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
// Arrow-key + Enter dropdown selector
|
|
12
15
|
export async function choose(message, choices) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
if (!process.stdin.isTTY) {
|
|
18
|
+
// Fallback for non-TTY (piped): numbered list
|
|
19
|
+
console.log(` ${orange('◆')} ${bold(message)}`);
|
|
20
|
+
choices.forEach((choice, i) => console.log(` ${dimOrange((i + 1) + '.')} ${choice.name}`));
|
|
21
|
+
const rl = readline.createInterface({ input, output });
|
|
22
|
+
rl.question(` ${orange('?')} Select number (1): `, (answer) => {
|
|
23
|
+
rl.close();
|
|
24
|
+
const idx = Math.max(0, Math.min(choices.length - 1, (parseInt(answer, 10) || 1) - 1));
|
|
25
|
+
resolve(choices[idx].value);
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ESC = '\x1b[';
|
|
31
|
+
let selected = 0;
|
|
32
|
+
|
|
33
|
+
function render(first) {
|
|
34
|
+
if (!first) {
|
|
35
|
+
// Move up to redraw
|
|
36
|
+
process.stdout.write(`\x1b[${choices.length + 1}A`);
|
|
37
|
+
}
|
|
38
|
+
console.log(` ${orange('◆')} ${bold(message)}`);
|
|
39
|
+
choices.forEach((choice, i) => {
|
|
40
|
+
const cursor = i === selected ? orange('▶ ') : ' ';
|
|
41
|
+
const label = i === selected ? bold(orange(choice.name)) : dimOrange(choice.name);
|
|
42
|
+
console.log(` ${cursor}${label}`);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
render(true);
|
|
47
|
+
|
|
48
|
+
process.stdin.setRawMode(true);
|
|
49
|
+
process.stdin.resume();
|
|
50
|
+
process.stdin.setEncoding('utf8');
|
|
51
|
+
|
|
52
|
+
function onKey(key) {
|
|
53
|
+
if (key === '\x1b[A' || key === '\x1b[D') { // up / left
|
|
54
|
+
selected = (selected - 1 + choices.length) % choices.length;
|
|
55
|
+
render(false);
|
|
56
|
+
} else if (key === '\x1b[B' || key === '\x1b[C') { // down / right
|
|
57
|
+
selected = (selected + 1) % choices.length;
|
|
58
|
+
render(false);
|
|
59
|
+
} else if (key === '\r' || key === '\n') {
|
|
60
|
+
process.stdin.setRawMode(false);
|
|
61
|
+
process.stdin.pause();
|
|
62
|
+
process.stdin.removeListener('data', onKey);
|
|
63
|
+
// print selected
|
|
64
|
+
process.stdout.write(`\x1b[${choices.length + 1}A`);
|
|
65
|
+
console.log(` ${orange('◆')} ${bold(message)}`);
|
|
66
|
+
choices.forEach((choice, i) => {
|
|
67
|
+
if (i === selected) console.log(` ${orange('▶ ')}${bold(orange(choice.name))}`);
|
|
68
|
+
else console.log(` ${dimOrange(' ' + choice.name)}`);
|
|
69
|
+
});
|
|
70
|
+
resolve(choices[selected].value);
|
|
71
|
+
} else if (key === '\x03') { // Ctrl+C
|
|
72
|
+
process.stdin.setRawMode(false);
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
process.stdin.on('data', onKey);
|
|
78
|
+
});
|
|
17
79
|
}
|
package/src/ui.js
CHANGED
|
@@ -1,46 +1,170 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import boxen from 'boxen';
|
|
3
|
+
|
|
4
|
+
// ── colors ────────────────────────────────────────────────────────────────────
|
|
1
5
|
const ESC = '\x1b[';
|
|
2
|
-
export const orange
|
|
3
|
-
export const dimOrange = (
|
|
4
|
-
export const bold
|
|
6
|
+
export const orange = (t) => `${ESC}38;2;255;138;0m${t}${ESC}0m`;
|
|
7
|
+
export const dimOrange = (t) => `${ESC}38;2;182;95;0m${t}${ESC}0m`;
|
|
8
|
+
export const bold = (t) => `${ESC}1m${t}${ESC}0m`;
|
|
9
|
+
export const red = (t) => `${ESC}38;2;220;50;50m${t}${ESC}0m`;
|
|
10
|
+
export const dim = (t) => `${ESC}2m${t}${ESC}0m`;
|
|
11
|
+
export const green = (t) => `${ESC}38;2;80;200;80m${t}${ESC}0m`;
|
|
5
12
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
// strip ANSI for length calculations
|
|
14
|
+
function stripAnsi(s) { return s.replace(/\x1b\[[0-9;]*m/g, ''); }
|
|
15
|
+
|
|
16
|
+
// ── section buffer ─────────────────────────────────────────────────────────────
|
|
17
|
+
// Each section accumulates lines, then flushes as ONE boxen box when the
|
|
18
|
+
// next section (or flushSection) is called.
|
|
19
|
+
let _currentTitle = null;
|
|
20
|
+
let _lines = []; // raw strings (may contain ANSI)
|
|
21
|
+
let _sectionCount = 0;
|
|
22
|
+
let _isError = false;
|
|
23
|
+
|
|
24
|
+
function flushSection() {
|
|
25
|
+
if (_currentTitle === null) return;
|
|
26
|
+
|
|
27
|
+
const content = _lines.join('\n');
|
|
28
|
+
const rendered = boxen(content || ' ', {
|
|
29
|
+
title: bold(orange(_currentTitle)),
|
|
30
|
+
titleAlignment: 'left',
|
|
31
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
32
|
+
margin: { top: 0, bottom: 0, left: 0, right: 0 },
|
|
33
|
+
borderStyle: 'round',
|
|
34
|
+
borderColor: '#ff8a00',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (_sectionCount > 1) console.log(orange(' │'));
|
|
38
|
+
console.log(rendered);
|
|
39
|
+
|
|
40
|
+
_currentTitle = null;
|
|
41
|
+
_lines = [];
|
|
42
|
+
_isError = false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Add a line into the current section buffer
|
|
46
|
+
function addLine(text) {
|
|
47
|
+
_lines.push(text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resetSectionCount() {
|
|
51
|
+
flushSection();
|
|
52
|
+
_sectionCount = 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── public API ────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
// Start a new named section (flushes previous one)
|
|
58
|
+
export function section(title) {
|
|
59
|
+
flushSection();
|
|
60
|
+
_sectionCount++;
|
|
61
|
+
_currentTitle = title;
|
|
62
|
+
_lines = [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Force-flush the current section (call at end of a command)
|
|
66
|
+
export function endSections() {
|
|
67
|
+
flushSection();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// kv row inside current section
|
|
71
|
+
export function kv(key, value) {
|
|
72
|
+
addLine(` ${dimOrange('›')} ${bold(key)} ${dimOrange('→')} ${value}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// spinner — writes inline to stdout while running, then adds result line to buffer
|
|
22
76
|
const CLEAR_LINE = '\x1b[K';
|
|
23
77
|
export function spinner(text) {
|
|
24
|
-
process.stdout.write(dimOrange('
|
|
78
|
+
process.stdout.write(` ${dimOrange('○')} ${text}`);
|
|
25
79
|
return {
|
|
26
|
-
succeed(msg) {
|
|
27
|
-
|
|
80
|
+
succeed(msg) {
|
|
81
|
+
process.stdout.write(`\r${CLEAR_LINE}`);
|
|
82
|
+
addLine(` ${orange('✓')} ${msg}`);
|
|
83
|
+
},
|
|
84
|
+
fail(msg) {
|
|
85
|
+
process.stdout.write(`\r${CLEAR_LINE}`);
|
|
86
|
+
addLine(` ${red('✗')} ${msg}`);
|
|
87
|
+
},
|
|
28
88
|
};
|
|
29
89
|
}
|
|
90
|
+
|
|
91
|
+
// plain log inside current section
|
|
92
|
+
export function log(text) {
|
|
93
|
+
addLine(` ${text}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// sub-step block (e.g. raw Vercel output) — indented, dimmer border
|
|
97
|
+
export function subBox(lines, { isError = false } = {}) {
|
|
98
|
+
if (!lines || !lines.length) return;
|
|
99
|
+
const colored = isError ? lines.map((l) => red(l)) : lines.map((l) => dimOrange(l));
|
|
100
|
+
const content = colored.join('\n');
|
|
101
|
+
const rendered = boxen(content, {
|
|
102
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
103
|
+
margin: { top: 0, bottom: 0, left: 2, right: 0 },
|
|
104
|
+
borderStyle: 'single',
|
|
105
|
+
borderColor: isError ? '#dc3232' : '#b65f00',
|
|
106
|
+
});
|
|
107
|
+
// add each rendered line into buffer so it stays inside the section box
|
|
108
|
+
for (const line of rendered.split('\n')) addLine(line);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── banner ────────────────────────────────────────────────────────────────────
|
|
112
|
+
export function banner() {
|
|
113
|
+
flushSection();
|
|
114
|
+
_sectionCount = 0;
|
|
115
|
+
_currentTitle = null;
|
|
116
|
+
_lines = [];
|
|
117
|
+
|
|
118
|
+
const art = [
|
|
119
|
+
'███████╗ █████╗ ██████╗ ██████╗ ██╗ ██████╗ █████╗ ',
|
|
120
|
+
'██╔════╝██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔══██╗',
|
|
121
|
+
'█████╗ ███████║██████╔╝██████╔╝██║██║ ███████║',
|
|
122
|
+
'██╔══╝ ██╔══██║██╔══██╗██╔══██╗██║██║ ██╔══██║',
|
|
123
|
+
'██║ ██║ ██║██████╔╝██║ ██║██║╚██████╗██║ ██║',
|
|
124
|
+
'╚═╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═════╝╚═╝ ╚═╝',
|
|
125
|
+
].map((l) => orange(l)).join('\n');
|
|
126
|
+
|
|
127
|
+
const subtitle = [
|
|
128
|
+
dimOrange('CMD → OAUTH BRIDGE // SUPABASE + VERCEL DEPLOYER'),
|
|
129
|
+
dim('by SPARROW AI SOLUTION'),
|
|
130
|
+
].join('\n');
|
|
131
|
+
|
|
132
|
+
console.log(boxen(`${art}\n\n${subtitle}`, {
|
|
133
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
134
|
+
borderStyle: 'double',
|
|
135
|
+
borderColor: '#ff8a00',
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── help ──────────────────────────────────────────────────────────────────────
|
|
30
140
|
export function help() {
|
|
31
141
|
banner();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
${bold('
|
|
36
|
-
${bold('
|
|
37
|
-
${bold('
|
|
38
|
-
${bold('
|
|
39
|
-
|
|
40
|
-
${
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
`)
|
|
142
|
+
const content = [
|
|
143
|
+
bold(orange('Commands')),
|
|
144
|
+
'',
|
|
145
|
+
` ${bold('build')} Connect Supabase, collect secrets, deploy or run locally`,
|
|
146
|
+
` ${bold('list')} Show Fabrica projects (local & cloud)`,
|
|
147
|
+
` ${bold('env')} View and update environment variables for any project`,
|
|
148
|
+
` ${bold('rerun')} Re-run or re-open an existing project`,
|
|
149
|
+
` ${bold('vins')} Verify & auto-install CLI dependencies`,
|
|
150
|
+
` ${bold('info')} Package, bridge, repo and storage info`,
|
|
151
|
+
` ${bold('help')} Show this screen`,
|
|
152
|
+
'',
|
|
153
|
+
bold(orange('Examples')),
|
|
154
|
+
'',
|
|
155
|
+
` ${dimOrange('$')} npx fabrica-e-commerce build`,
|
|
156
|
+
` ${dimOrange('$')} npx fabrica-e-commerce env`,
|
|
157
|
+
` ${dimOrange('$')} npx fabrica-e-commerce rerun`,
|
|
158
|
+
` ${dimOrange('$')} npx fabrica-e-commerce list`,
|
|
159
|
+
'',
|
|
160
|
+
dim('Creator: SPARROW AI SOLUTION'),
|
|
161
|
+
].join('\n');
|
|
162
|
+
console.log(orange(' │'));
|
|
163
|
+
console.log(boxen(content, {
|
|
164
|
+
title: bold(orange('Help')),
|
|
165
|
+
titleAlignment: 'left',
|
|
166
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
167
|
+
borderStyle: 'round',
|
|
168
|
+
borderColor: '#ff8a00',
|
|
169
|
+
}));
|
|
46
170
|
}
|