fabrica-e-commerce 0.1.15 → 0.1.16
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/cli.js +169 -24
- package/src/deploy.js +103 -39
- package/src/deps.js +56 -50
- package/src/prompt.js +68 -6
- package/src/ui.js +115 -36
package/package.json
CHANGED
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, subBox, resetSectionCount } 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
|
|
|
@@ -30,10 +32,10 @@ async function build() {
|
|
|
30
32
|
const env = await collectEnv(supabase);
|
|
31
33
|
const project = await cloneRepo();
|
|
32
34
|
|
|
33
|
-
section('
|
|
35
|
+
section('Run target');
|
|
34
36
|
const target = await choose('Where should Fabrica run this storefront?', [
|
|
35
37
|
{ name: 'Deploy on Vercel cloud', value: 'vercel' },
|
|
36
|
-
{ name: 'Run locally on this computer', value: 'local' }
|
|
38
|
+
{ name: 'Run locally on this computer', value: 'local' },
|
|
37
39
|
]);
|
|
38
40
|
|
|
39
41
|
if (target === 'local') {
|
|
@@ -45,46 +47,189 @@ async function build() {
|
|
|
45
47
|
await ensureVercelLogin();
|
|
46
48
|
const githubRepo = await createGithubRepoFromClone(project);
|
|
47
49
|
const record = await deployToVercel(project, env, githubRepo);
|
|
50
|
+
|
|
48
51
|
section('Done');
|
|
49
52
|
kv('Project', record.projectName);
|
|
50
53
|
kv('GitHub repo', record.githubRepo || 'n/a');
|
|
54
|
+
kv('URL', record.productionUrl || 'n/a');
|
|
51
55
|
kv('Path', record.target);
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
// ── list ──────────────────────────────────────────────────────────────────────
|
|
54
59
|
async function list() {
|
|
55
60
|
banner();
|
|
56
61
|
const projects = await readProjects();
|
|
57
62
|
if (!projects.length) {
|
|
58
|
-
console.log('No projects found. Run: fabrica build');
|
|
63
|
+
console.log(' No projects found. Run: fabrica build');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
section('Your Fabrica projects');
|
|
68
|
+
const choices = projects.map((p) => ({
|
|
69
|
+
name: `${p.projectName} (${p.type === 'local' ? 'local' : 'cloud'})`,
|
|
70
|
+
value: p.id,
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
const selected = await choose('Select a project to view details:', choices);
|
|
74
|
+
const project = projects.find((p) => p.id === selected);
|
|
75
|
+
|
|
76
|
+
section(`Project: ${project.projectName}`);
|
|
77
|
+
const lines = [
|
|
78
|
+
`Name: ${project.projectName}`,
|
|
79
|
+
`Type: ${project.type === 'local' ? 'local' : 'cloud'}`,
|
|
80
|
+
`Created: ${project.createdAt}`,
|
|
81
|
+
`Path: ${project.target}`,
|
|
82
|
+
`GitHub: ${project.githubRepo || 'n/a'}`,
|
|
83
|
+
`Supabase: ${project.supabaseUrl || 'n/a'}`,
|
|
84
|
+
`URL: ${project.productionUrl || 'n/a'}`,
|
|
85
|
+
`Env keys: ${(project.envKeys || []).join(', ')}`,
|
|
86
|
+
];
|
|
87
|
+
subBox(lines);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── env ───────────────────────────────────────────────────────────────────────
|
|
91
|
+
async function env() {
|
|
92
|
+
banner();
|
|
93
|
+
const projects = await readProjects();
|
|
94
|
+
if (!projects.length) {
|
|
95
|
+
console.log(' No projects found. Run: fabrica build');
|
|
59
96
|
return;
|
|
60
97
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
98
|
+
|
|
99
|
+
section('Environment manager');
|
|
100
|
+
|
|
101
|
+
// Step 1 — pick project type filter
|
|
102
|
+
const typeFilter = await choose('Which projects to show?', [
|
|
103
|
+
{ name: 'All projects', value: 'all' },
|
|
104
|
+
{ name: 'Local projects only', value: 'local' },
|
|
105
|
+
{ name: 'Cloud (Vercel) projects only', value: 'cloud' },
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
const filtered = typeFilter === 'all' ? projects : projects.filter((p) => p.type === typeFilter);
|
|
109
|
+
if (!filtered.length) {
|
|
110
|
+
console.log(` No ${typeFilter} projects found.`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Step 2 — pick project
|
|
115
|
+
const projectId = await choose('Select project:', filtered.map((p) => ({
|
|
116
|
+
name: `${p.projectName} (${p.type === 'local' ? 'local' : 'cloud'})`,
|
|
117
|
+
value: p.id,
|
|
118
|
+
})));
|
|
119
|
+
const project = filtered.find((p) => p.id === projectId);
|
|
120
|
+
|
|
121
|
+
// Step 3 — pick env key
|
|
122
|
+
const envKeys = project.envKeys || [];
|
|
123
|
+
if (!envKeys.length) {
|
|
124
|
+
console.log(' No env keys stored for this project.');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const key = await choose('Select env variable to update:', envKeys.map((k) => ({ name: k, value: k })));
|
|
129
|
+
|
|
130
|
+
// Step 4 — new value
|
|
131
|
+
const currentVal = (project.env || {})[key];
|
|
132
|
+
const value = await ask(`New value for ${key}`, currentVal || '');
|
|
133
|
+
|
|
134
|
+
section('Applying update');
|
|
135
|
+
await updateProjectEnv(project, key, value);
|
|
136
|
+
kv('Updated', `${key} → ${project.type === 'local' ? '.env.local' : 'Vercel + redeployed'}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── rerun ─────────────────────────────────────────────────────────────────────
|
|
140
|
+
async function rerun() {
|
|
141
|
+
banner();
|
|
142
|
+
const projects = await readProjects();
|
|
143
|
+
if (!projects.length) {
|
|
144
|
+
console.log(' No projects found. Run: fabrica build');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
section('Re-run / re-open project');
|
|
149
|
+
|
|
150
|
+
// Step 1 — local or cloud
|
|
151
|
+
const typeFilter = await choose('Which type of project?', [
|
|
152
|
+
{ name: 'All projects', value: 'all' },
|
|
153
|
+
{ name: 'Local projects', value: 'local' },
|
|
154
|
+
{ name: 'Cloud (Vercel) projects', value: 'cloud' },
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
const filtered = typeFilter === 'all' ? projects : projects.filter((p) => p.type === typeFilter);
|
|
158
|
+
if (!filtered.length) {
|
|
159
|
+
console.log(` No ${typeFilter} projects found.`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Step 2 — pick project
|
|
164
|
+
const projectId = await choose('Select project to re-run:', filtered.map((p) => ({
|
|
165
|
+
name: `${p.projectName} (${p.type === 'local' ? 'local' : 'cloud'})`,
|
|
166
|
+
value: p.id,
|
|
167
|
+
})));
|
|
168
|
+
const project = filtered.find((p) => p.id === projectId);
|
|
169
|
+
|
|
170
|
+
section(`Re-running: ${project.projectName}`);
|
|
171
|
+
|
|
172
|
+
if (project.type === 'local') {
|
|
173
|
+
// Re-run local dev server
|
|
64
174
|
kv('Path', project.target);
|
|
65
|
-
kv('
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
175
|
+
kv('URL', 'http://localhost:3000');
|
|
176
|
+
|
|
177
|
+
const { runCommand } = await import('./system.js');
|
|
178
|
+
const { access } = await import('node:fs/promises');
|
|
179
|
+
const hasPnpmLock = await access(path.join(project.target, 'pnpm-lock.yaml')).then(() => true, () => false);
|
|
180
|
+
const { runCommandCapture } = await import('./system.js');
|
|
181
|
+
const pnpmAvailable = (await runCommandCapture('pnpm', ['--version'])).code === 0;
|
|
182
|
+
const devCommand = hasPnpmLock && pnpmAvailable ? ['pnpm', ['run', 'dev']] : ['npm', ['run', 'dev']];
|
|
183
|
+
|
|
184
|
+
console.log(' Starting dev server... auto-opening browser in 3s');
|
|
185
|
+
setTimeout(() => openUrl('http://localhost:3000'), 3000);
|
|
186
|
+
await runCommand(devCommand[0], devCommand[1], { cwd: project.target });
|
|
187
|
+
} else {
|
|
188
|
+
// Cloud: show info + open URL
|
|
189
|
+
const url = project.productionUrl || null;
|
|
190
|
+
const lines = [
|
|
191
|
+
`Project: ${project.projectName}`,
|
|
192
|
+
`GitHub: ${project.githubRepo || 'n/a'}`,
|
|
193
|
+
`URL: ${url || 'n/a'}`,
|
|
194
|
+
`Inspect: ${project.inspectUrl || 'n/a'}`,
|
|
195
|
+
`Created: ${project.createdAt}`,
|
|
196
|
+
];
|
|
197
|
+
subBox(lines);
|
|
198
|
+
if (url) {
|
|
199
|
+
kv('Opening', url);
|
|
200
|
+
await openUrl(url);
|
|
201
|
+
} else {
|
|
202
|
+
console.log(' No URL found for this project.');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
70
205
|
}
|
|
71
206
|
|
|
207
|
+
// ── info ──────────────────────────────────────────────────────────────────────
|
|
72
208
|
async function info() {
|
|
73
209
|
banner();
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
210
|
+
section('Package info');
|
|
211
|
+
const lines = [
|
|
212
|
+
`Package: fabrica-e-commerce v${await packageVersion()}`,
|
|
213
|
+
`Bridge: ${BRIDGE_ORIGIN}`,
|
|
214
|
+
`Store repo: ${STORE_REPO}`,
|
|
215
|
+
`Local data: ${dataDir}`,
|
|
216
|
+
`Node: ${process.version}`,
|
|
217
|
+
'',
|
|
218
|
+
'Creator: SPARROW AI SOLUTION',
|
|
219
|
+
];
|
|
220
|
+
subBox(lines);
|
|
79
221
|
}
|
|
80
222
|
|
|
223
|
+
// ── router ────────────────────────────────────────────────────────────────────
|
|
81
224
|
export async function run(args) {
|
|
82
225
|
const command = args[0] || 'help';
|
|
83
|
-
if (command === 'build')
|
|
84
|
-
if (command === 'list')
|
|
85
|
-
if (command === '
|
|
86
|
-
if (command === '
|
|
87
|
-
if (command === '
|
|
226
|
+
if (command === 'build') return build();
|
|
227
|
+
if (command === 'list') return list();
|
|
228
|
+
if (command === 'env') return env();
|
|
229
|
+
if (command === 'rerun') return rerun();
|
|
230
|
+
if (command === 'info' || command === '.info') return info();
|
|
231
|
+
if (command === 'vins' || command === '/vins') return vinsCommand();
|
|
232
|
+
if (command === 'help' || command === '--help' || command === '-h') return help();
|
|
88
233
|
console.error(`Unknown command: ${command}`);
|
|
89
234
|
help();
|
|
90
235
|
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 { dimOrange, kv, section, spinner } from './ui.js';
|
|
6
|
+
import { runCommand, runCommandCapture, openUrl } from './system.js';
|
|
7
|
+
import { dimOrange, kv, section, spinner, subBox, red, orange } from './ui.js';
|
|
8
8
|
import { ask, choose } from './prompt.js';
|
|
9
9
|
|
|
10
10
|
const VERCEL_ENVIRONMENTS = ['production', 'preview', 'development'];
|
|
@@ -15,6 +15,7 @@ export async function collectEnv(supabase) {
|
|
|
15
15
|
for (const key of REQUIRED_ENV_KEYS) env[key] = await ask(`${key}`);
|
|
16
16
|
return env;
|
|
17
17
|
}
|
|
18
|
+
|
|
18
19
|
export async function cloneRepo() {
|
|
19
20
|
section('Clone storefront');
|
|
20
21
|
const projectName = await ask('Vercel project name', `fabrica-store-${Date.now()}`);
|
|
@@ -23,14 +24,12 @@ export async function cloneRepo() {
|
|
|
23
24
|
await runCommand('git', ['clone', STORE_REPO, target]);
|
|
24
25
|
return { id, projectName, target };
|
|
25
26
|
}
|
|
27
|
+
|
|
26
28
|
export async function isLoggedInToVercel() {
|
|
27
29
|
const result = await runCommandCapture('npx', ['--yes', 'vercel@latest', 'whoami']);
|
|
28
30
|
return result.code === 0;
|
|
29
31
|
}
|
|
30
32
|
|
|
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
33
|
export async function ensureVercelLogin() {
|
|
35
34
|
section('Vercel login');
|
|
36
35
|
const spin = spinner('Checking Vercel login');
|
|
@@ -39,7 +38,7 @@ export async function ensureVercelLogin() {
|
|
|
39
38
|
return;
|
|
40
39
|
}
|
|
41
40
|
spin.fail('Not logged in to Vercel');
|
|
42
|
-
console.log('Opening "vercel login" — finish the login in your browser...');
|
|
41
|
+
console.log(' Opening "vercel login" — finish the login in your browser...');
|
|
43
42
|
await runCommand('npx', ['vercel@latest', 'login']);
|
|
44
43
|
if (!(await isLoggedInToVercel())) {
|
|
45
44
|
throw new Error('Vercel login was not completed. Run "fabrica build" again after logging in.');
|
|
@@ -47,24 +46,29 @@ export async function ensureVercelLogin() {
|
|
|
47
46
|
kv('Vercel', 'Logged in');
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
//
|
|
51
|
-
|
|
49
|
+
// Capture vercel CLI output and render it in a sub-box, errors in red
|
|
50
|
+
async function runVercelBoxed(args, options = {}) {
|
|
51
|
+
const result = await runCommandCapture('npx', ['vercel@latest', ...args], options);
|
|
52
|
+
const raw = ((result.stdout || '') + (result.stderr || '')).trim();
|
|
53
|
+
if (!raw) return result;
|
|
54
|
+
|
|
55
|
+
const lines = raw.split('\n').map((l) => l.trimEnd()).filter(Boolean);
|
|
56
|
+
const isErr = result.code !== 0 || lines.some((l) => /^Error:/i.test(l));
|
|
57
|
+
subBox(lines, { isError: isErr });
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
async function setEnvEverywhere(project, env) {
|
|
53
62
|
for (const [key, value] of Object.entries(env)) {
|
|
54
63
|
const spin = spinner(`Setting ${key}`);
|
|
55
64
|
for (const environment of VERCEL_ENVIRONMENTS) {
|
|
56
|
-
await
|
|
57
|
-
await
|
|
65
|
+
await runVercelBoxed(['env', 'rm', key, environment, '--yes'], { cwd: project.target, allowFailure: true });
|
|
66
|
+
await runVercelBoxed(['env', 'add', key, environment], { cwd: project.target, input: `${value}\n` });
|
|
58
67
|
}
|
|
59
68
|
spin.succeed(`Set ${key} (production, preview, development)`);
|
|
60
69
|
}
|
|
61
70
|
}
|
|
62
71
|
|
|
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
72
|
async function connectGithubRepo(project, repoUrl) {
|
|
69
73
|
const spin = spinner(`Connecting Vercel project to ${repoUrl}`);
|
|
70
74
|
const attempts = 6;
|
|
@@ -72,17 +76,21 @@ async function connectGithubRepo(project, repoUrl) {
|
|
|
72
76
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
73
77
|
const connect = await runCommandCapture('npx', ['--yes', 'vercel@latest', 'git', 'connect', repoUrl, '--yes'], { cwd: project.target });
|
|
74
78
|
if (connect.code === 0) {
|
|
75
|
-
spin.succeed('Vercel project connected to GitHub repo
|
|
79
|
+
spin.succeed('Vercel project connected to GitHub repo');
|
|
76
80
|
return true;
|
|
77
81
|
}
|
|
78
82
|
lastError = (connect.stderr || connect.stdout || '').trim();
|
|
79
83
|
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, attempt * 3000));
|
|
80
84
|
}
|
|
81
85
|
spin.fail('Could not auto-connect Git — continuing with a direct deploy');
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
subBox([
|
|
87
|
+
'This usually means one of two things:',
|
|
88
|
+
`1) Vercel hasn't finished indexing the new fork yet`,
|
|
89
|
+
` Re-link manually: npx vercel git connect ${repoUrl}`,
|
|
90
|
+
'2) The "Vercel" GitHub App is set to "Only select repositories"',
|
|
91
|
+
' Fix at: https://github.com/settings/installations',
|
|
92
|
+
lastError ? `Last error: ${lastError}` : '',
|
|
93
|
+
].filter(Boolean), { isError: true });
|
|
86
94
|
return false;
|
|
87
95
|
}
|
|
88
96
|
|
|
@@ -90,7 +98,7 @@ export async function deployToVercel(project, env, githubRepo) {
|
|
|
90
98
|
section('Vercel deployment');
|
|
91
99
|
await ensureVercelLogin();
|
|
92
100
|
|
|
93
|
-
await
|
|
101
|
+
await runVercelBoxed(['link', '--yes', '--project', project.projectName], { cwd: project.target });
|
|
94
102
|
|
|
95
103
|
if (githubRepo?.repoUrl) {
|
|
96
104
|
await connectGithubRepo(project, githubRepo.repoUrl);
|
|
@@ -99,35 +107,44 @@ export async function deployToVercel(project, env, githubRepo) {
|
|
|
99
107
|
await setEnvEverywhere(project, env);
|
|
100
108
|
|
|
101
109
|
const deploySpin = spinner('Creating production deployment');
|
|
102
|
-
await
|
|
110
|
+
const deployResult = await runCommandCapture('npx', ['vercel@latest', '--prod', '--yes'], { cwd: project.target });
|
|
111
|
+
const deployOutput = ((deployResult.stdout || '') + (deployResult.stderr || '')).trim();
|
|
112
|
+
|
|
113
|
+
// Extract URLs from deploy output
|
|
114
|
+
let productionUrl = null;
|
|
115
|
+
let aliasedUrl = null;
|
|
116
|
+
let inspectUrl = null;
|
|
117
|
+
for (const line of deployOutput.split('\n')) {
|
|
118
|
+
const m = line.match(/Production\s+(\S+)/); if (m) productionUrl = m[1];
|
|
119
|
+
const a = line.match(/Aliased\s+(\S+)/); if (a) aliasedUrl = a[1];
|
|
120
|
+
const i = line.match(/Inspect\s+(\S+)/); if (i) inspectUrl = i[1];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
subBox(deployOutput.split('\n').filter(Boolean));
|
|
103
124
|
deploySpin.succeed('Production deployment created');
|
|
104
125
|
|
|
126
|
+
// Auto-open the aliased/production URL
|
|
127
|
+
const openTarget = aliasedUrl || productionUrl;
|
|
128
|
+
if (openTarget) {
|
|
129
|
+
kv('Opening', openTarget);
|
|
130
|
+
await openUrl(openTarget);
|
|
131
|
+
}
|
|
132
|
+
|
|
105
133
|
const record = {
|
|
106
134
|
...project,
|
|
135
|
+
type: 'cloud',
|
|
107
136
|
repo: STORE_REPO,
|
|
108
137
|
githubRepo: githubRepo?.repoUrl || null,
|
|
109
138
|
createdAt: new Date().toISOString(),
|
|
110
139
|
envKeys: Object.keys(env),
|
|
111
|
-
|
|
140
|
+
env,
|
|
141
|
+
supabaseUrl: env.SUPABASE_URL,
|
|
142
|
+
productionUrl: aliasedUrl || productionUrl || null,
|
|
143
|
+
inspectUrl: inspectUrl || null,
|
|
112
144
|
};
|
|
113
145
|
await saveProject(record);
|
|
114
146
|
return record;
|
|
115
147
|
}
|
|
116
|
-
export async function editProjectEnv(projects) {
|
|
117
|
-
if (!projects.length) { console.log('No deployed projects saved yet. Run build first.'); return; }
|
|
118
|
-
const projectId = await choose('Select project:', projects.map((project) => ({ name: `${project.projectName} (${project.createdAt})`, value: project.id })));
|
|
119
|
-
const project = projects.find((item) => item.id === projectId);
|
|
120
|
-
const key = await choose('Variable to replace:', project.envKeys.map((item) => ({ name: item, value: item })));
|
|
121
|
-
const value = await ask(`New value for ${key}`);
|
|
122
|
-
await ensureVercelLogin();
|
|
123
|
-
for (const environment of VERCEL_ENVIRONMENTS) {
|
|
124
|
-
await runCommand('npx', ['vercel@latest', 'env', 'rm', key, environment, '--yes'], { cwd: project.target, allowFailure: true });
|
|
125
|
-
await runCommand('npx', ['vercel@latest', 'env', 'add', key, environment], { cwd: project.target, input: `${value}\n` });
|
|
126
|
-
}
|
|
127
|
-
await runCommand('npx', ['vercel@latest', '--prod', '--yes'], { cwd: project.target });
|
|
128
|
-
kv('Redeployed', project.projectName);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
148
|
|
|
132
149
|
export async function runLocally(project, env) {
|
|
133
150
|
section('Local Next.js setup');
|
|
@@ -142,9 +159,56 @@ export async function runLocally(project, env) {
|
|
|
142
159
|
const devCommand = hasPnpmLock && pnpmAvailable ? ['pnpm', ['run', 'dev']] : ['npm', ['run', 'dev']];
|
|
143
160
|
|
|
144
161
|
await runCommand(installCommand[0], installCommand[1], { cwd: project.target });
|
|
162
|
+
|
|
163
|
+
const record = {
|
|
164
|
+
...project,
|
|
165
|
+
type: 'local',
|
|
166
|
+
repo: STORE_REPO,
|
|
167
|
+
githubRepo: null,
|
|
168
|
+
createdAt: new Date().toISOString(),
|
|
169
|
+
envKeys: Object.keys(env),
|
|
170
|
+
env,
|
|
171
|
+
supabaseUrl: env.SUPABASE_URL,
|
|
172
|
+
productionUrl: 'http://localhost:3000',
|
|
173
|
+
};
|
|
174
|
+
await saveProject(record);
|
|
175
|
+
|
|
145
176
|
section('Local app');
|
|
146
177
|
kv('Path', project.target);
|
|
147
178
|
kv('URL', 'http://localhost:3000');
|
|
148
|
-
console.log('Starting the Next.js dev server. Press Ctrl+C to stop
|
|
179
|
+
console.log(' Starting the Next.js dev server. Press Ctrl+C to stop.');
|
|
180
|
+
|
|
181
|
+
// Auto-open after a brief delay so server has time to start
|
|
182
|
+
setTimeout(() => openUrl('http://localhost:3000'), 3000);
|
|
149
183
|
await runCommand(devCommand[0], devCommand[1], { cwd: project.target });
|
|
150
184
|
}
|
|
185
|
+
|
|
186
|
+
// Called by env command - update a single env var for cloud or local project
|
|
187
|
+
export async function updateProjectEnv(project, key, value) {
|
|
188
|
+
if (project.type === 'local') {
|
|
189
|
+
// Update .env.local
|
|
190
|
+
const envPath = path.join(project.target, '.env.local');
|
|
191
|
+
let contents = '';
|
|
192
|
+
try { contents = await fs.readFile(envPath, 'utf8'); } catch { /* no file yet */ }
|
|
193
|
+
const lines = contents.split('\n');
|
|
194
|
+
const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
|
|
195
|
+
const newLine = `${key}=${value}`;
|
|
196
|
+
if (idx >= 0) lines[idx] = newLine;
|
|
197
|
+
else lines.push(newLine);
|
|
198
|
+
await fs.writeFile(envPath, lines.join('\n'), 'utf8');
|
|
199
|
+
kv('Updated', `${key} in .env.local`);
|
|
200
|
+
} else {
|
|
201
|
+
// Cloud: update on Vercel + redeploy
|
|
202
|
+
await ensureVercelLogin();
|
|
203
|
+
for (const environment of VERCEL_ENVIRONMENTS) {
|
|
204
|
+
await runVercelBoxed(['env', 'rm', key, environment, '--yes'], { cwd: project.target, allowFailure: true });
|
|
205
|
+
await runVercelBoxed(['env', 'add', key, environment], { cwd: project.target, input: `${value}\n` });
|
|
206
|
+
}
|
|
207
|
+
const spin = spinner('Redeploying...');
|
|
208
|
+
await runVercelBoxed(['--prod', '--yes'], { cwd: project.target });
|
|
209
|
+
spin.succeed(`Redeployed ${project.projectName}`);
|
|
210
|
+
}
|
|
211
|
+
// Persist updated env in store
|
|
212
|
+
const updated = { ...project, env: { ...(project.env || {}), [key]: value } };
|
|
213
|
+
await saveProject(updated);
|
|
214
|
+
}
|
package/src/deps.js
CHANGED
|
@@ -2,15 +2,18 @@ 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, spinner, subBox } from './ui.js';
|
|
6
|
+
|
|
7
|
+
// ── platform detection ────────────────────────────────────────────────────────
|
|
8
|
+
function isTermux() {
|
|
9
|
+
return process.platform === 'android' ||
|
|
10
|
+
(process.platform === 'linux' && (process.env.TERMUX_VERSION || process.env.PREFIX?.includes('com.termux')));
|
|
11
|
+
}
|
|
6
12
|
|
|
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
13
|
async function runPrivileged(command, args, options = {}) {
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
if (process.platform === 'win32') return runCommand(command, args, options);
|
|
15
|
+
if (isTermux()) return runCommand(command, args, options); // Termux: no sudo
|
|
16
|
+
if (await commandExists('sudo')) return runCommand('sudo', [command, ...args], options);
|
|
14
17
|
return runCommand(command, args, options);
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -18,9 +21,7 @@ function runPrivilegedShell(script, options = {}) {
|
|
|
18
21
|
return runCommand('bash', ['-c', script], options);
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
//
|
|
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
|
+
// ── dependency definitions ────────────────────────────────────────────────────
|
|
24
25
|
const DEPENDENCIES = [
|
|
25
26
|
{
|
|
26
27
|
name: 'git',
|
|
@@ -45,7 +46,7 @@ const DEPENDENCIES = [
|
|
|
45
46
|
}
|
|
46
47
|
];
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
// ── PATH helpers ──────────────────────────────────────────────────────────────
|
|
49
50
|
function addToPathIfDirectory(directory) {
|
|
50
51
|
if (!directory || !fs.existsSync(directory)) return;
|
|
51
52
|
const delimiter = path.delimiter;
|
|
@@ -58,16 +59,16 @@ function addToPathIfDirectory(directory) {
|
|
|
58
59
|
|
|
59
60
|
function refreshWindowsToolPaths() {
|
|
60
61
|
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(
|
|
62
|
+
const lad = process.env.LOCALAPPDATA;
|
|
63
|
+
const pf = process.env.ProgramFiles;
|
|
64
|
+
const pf86 = process.env['ProgramFiles(x86)'];
|
|
65
|
+
const pd = process.env.ProgramData;
|
|
66
|
+
addToPathIfDirectory(lad && path.join(lad, 'Microsoft', 'WinGet', 'Links'));
|
|
67
|
+
addToPathIfDirectory(pf && path.join(pf, 'GitHub CLI'));
|
|
68
|
+
addToPathIfDirectory(pf86 && path.join(pf86, 'GitHub CLI'));
|
|
69
|
+
addToPathIfDirectory(pd && path.join(pd, 'chocolatey', 'bin'));
|
|
70
|
+
addToPathIfDirectory(pf && path.join(pf, 'Git', 'cmd'));
|
|
71
|
+
addToPathIfDirectory(pf && path.join(pf, 'nodejs'));
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
async function checkWithPathRefresh(command) {
|
|
@@ -101,26 +102,35 @@ async function warmVercelCli() {
|
|
|
101
102
|
['vercel', ['--version']]
|
|
102
103
|
]);
|
|
103
104
|
if (warmed.code === 0) return;
|
|
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.
|
|
106
105
|
await runCommand('npm', ['install', '-g', 'vercel@latest'], { allowFailure: true });
|
|
107
106
|
}
|
|
108
107
|
|
|
108
|
+
// ── Git install ───────────────────────────────────────────────────────────────
|
|
109
109
|
async function installGit() {
|
|
110
110
|
refreshWindowsToolPaths();
|
|
111
111
|
const platform = process.platform;
|
|
112
|
+
|
|
113
|
+
if (isTermux()) {
|
|
114
|
+
await runCommand('pkg', ['install', '-y', 'git'], { allowFailure: true });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
112
118
|
if (platform === 'linux') {
|
|
113
119
|
if (await commandExists('apt-get')) {
|
|
114
120
|
await runPrivileged('apt-get', ['update'], { allowFailure: true });
|
|
115
121
|
await runPrivileged('apt-get', ['install', '-y', 'git'], { allowFailure: true });
|
|
116
122
|
return;
|
|
117
123
|
}
|
|
118
|
-
if (await commandExists('dnf'))
|
|
119
|
-
if (await commandExists('yum'))
|
|
124
|
+
if (await commandExists('dnf')) return runPrivileged('dnf', ['install', '-y', 'git'], { allowFailure: true });
|
|
125
|
+
if (await commandExists('yum')) return runPrivileged('yum', ['install', '-y', 'git'], { allowFailure: true });
|
|
120
126
|
if (await commandExists('pacman')) return runPrivileged('pacman', ['-Sy', '--noconfirm', 'git'], { allowFailure: true });
|
|
127
|
+
if (await commandExists('zypper')) return runPrivileged('zypper', ['install', '-y', 'git'], { allowFailure: true });
|
|
128
|
+
if (await commandExists('apk')) return runPrivileged('apk', ['add', 'git'], { allowFailure: true });
|
|
121
129
|
}
|
|
122
130
|
if (platform === 'darwin') {
|
|
123
131
|
if (await commandExists('brew')) return runCommand('brew', ['install', 'git'], { allowFailure: true });
|
|
132
|
+
// Xcode command line tools
|
|
133
|
+
await runCommand('xcode-select', ['--install'], { allowFailure: true });
|
|
124
134
|
}
|
|
125
135
|
if (platform === 'win32') {
|
|
126
136
|
if (await commandExists('winget')) {
|
|
@@ -131,22 +141,26 @@ async function installGit() {
|
|
|
131
141
|
if (await commandExists('choco')) {
|
|
132
142
|
await runCommand('choco', ['install', 'git', '-y'], { allowFailure: true });
|
|
133
143
|
refreshWindowsToolPaths();
|
|
134
|
-
return;
|
|
135
144
|
}
|
|
136
145
|
}
|
|
137
146
|
}
|
|
138
147
|
|
|
148
|
+
// ── GitHub CLI install ────────────────────────────────────────────────────────
|
|
139
149
|
async function installGithubCli() {
|
|
140
150
|
refreshWindowsToolPaths();
|
|
141
151
|
const platform = process.platform;
|
|
152
|
+
|
|
153
|
+
if (isTermux()) {
|
|
154
|
+
await runCommand('pkg', ['install', '-y', 'gh'], { allowFailure: true });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
142
158
|
if (platform === 'linux') {
|
|
143
159
|
if (await commandExists('apt-get')) {
|
|
144
|
-
// Try the plain package first (present on newer Ubuntu/Debian).
|
|
145
160
|
const direct = await (await commandExists('sudo')
|
|
146
161
|
? runCommandCapture('sudo', ['apt-get', 'install', '-y', 'gh'])
|
|
147
162
|
: runCommandCapture('apt-get', ['install', '-y', 'gh']));
|
|
148
163
|
if (direct.code === 0) return;
|
|
149
|
-
// Fall back to the official GitHub CLI apt repository setup.
|
|
150
164
|
const usesSudo = await commandExists('sudo');
|
|
151
165
|
const prefix = usesSudo ? 'sudo ' : '';
|
|
152
166
|
await runPrivilegedShell([
|
|
@@ -162,8 +176,10 @@ async function installGithubCli() {
|
|
|
162
176
|
].join(' && '), { allowFailure: true });
|
|
163
177
|
return;
|
|
164
178
|
}
|
|
165
|
-
if (await commandExists('dnf'))
|
|
179
|
+
if (await commandExists('dnf')) return runPrivileged('dnf', ['install', '-y', 'gh'], { allowFailure: true });
|
|
166
180
|
if (await commandExists('pacman')) return runPrivileged('pacman', ['-Sy', '--noconfirm', 'github-cli'], { allowFailure: true });
|
|
181
|
+
if (await commandExists('zypper')) return runPrivileged('zypper', ['install', '-y', 'gh'], { allowFailure: true });
|
|
182
|
+
if (await commandExists('apk')) return runCommand('apk', ['add', 'github-cli'], { allowFailure: true });
|
|
167
183
|
}
|
|
168
184
|
if (platform === 'darwin') {
|
|
169
185
|
if (await commandExists('brew')) return runCommand('brew', ['install', 'gh'], { allowFailure: true });
|
|
@@ -177,27 +193,20 @@ async function installGithubCli() {
|
|
|
177
193
|
if (await commandExists('choco')) {
|
|
178
194
|
await runCommand('choco', ['install', 'gh', '-y'], { allowFailure: true });
|
|
179
195
|
refreshWindowsToolPaths();
|
|
180
|
-
return;
|
|
181
196
|
}
|
|
182
197
|
}
|
|
183
198
|
}
|
|
184
199
|
|
|
200
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
185
201
|
export async function ensureDependencies({ autoInstall = true, names } = {}) {
|
|
186
202
|
const targets = names ? DEPENDENCIES.filter((dep) => names.includes(dep.name)) : DEPENDENCIES;
|
|
187
203
|
const results = [];
|
|
188
204
|
for (const dep of targets) {
|
|
189
205
|
const spin = spinner(`Checking ${dep.label}`);
|
|
190
206
|
let present = await dep.check();
|
|
191
|
-
if (present) {
|
|
192
|
-
spin.succeed(`${dep.label} found`);
|
|
193
|
-
results.push({ ...dep, present, installed: false });
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
207
|
+
if (present) { spin.succeed(`${dep.label} found`); results.push({ ...dep, present, installed: false }); continue; }
|
|
196
208
|
spin.fail(`${dep.label} missing`);
|
|
197
|
-
if (!autoInstall) {
|
|
198
|
-
results.push({ ...dep, present: false, installed: false });
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
209
|
+
if (!autoInstall) { results.push({ ...dep, present: false, installed: false }); continue; }
|
|
201
210
|
const installSpin = spinner(`Installing ${dep.label}`);
|
|
202
211
|
await dep.install();
|
|
203
212
|
present = await dep.check();
|
|
@@ -210,21 +219,18 @@ export async function ensureDependencies({ autoInstall = true, names } = {}) {
|
|
|
210
219
|
|
|
211
220
|
export async function vinsCommand() {
|
|
212
221
|
section('Fabrica dependency check (vins)');
|
|
222
|
+
const platform = isTermux() ? 'Termux/Android' : process.platform;
|
|
223
|
+
kv('Platform', platform);
|
|
213
224
|
const results = await ensureDependencies();
|
|
214
225
|
section('Summary');
|
|
215
226
|
let allGood = true;
|
|
227
|
+
const summaryLines = [];
|
|
216
228
|
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;
|
|
229
|
+
summaryLines.push(`${dep.label}: ${dep.present ? '✓ OK' : '✗ MISSING — install manually'}`);
|
|
230
|
+
if (!dep.present) { allGood = false; summaryLines.push(` Manual: ${dep.manualUrl}`); }
|
|
228
231
|
}
|
|
232
|
+
subBox(summaryLines, { isError: !allGood });
|
|
233
|
+
if (allGood) console.log('\n All dependencies ready. Run: fabrica build');
|
|
234
|
+
else { console.log('\n Some deps could not be installed automatically.'); process.exitCode = 1; }
|
|
229
235
|
return results;
|
|
230
236
|
}
|
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,125 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import boxen from 'boxen';
|
|
3
|
+
|
|
4
|
+
// ── color helpers (raw ANSI, no deps) ────────────────────────────────────────
|
|
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
|
+
// ── boxen presets ─────────────────────────────────────────────────────────────
|
|
14
|
+
// Main section box — rounded corners, orange border
|
|
15
|
+
function sectionBox(content, title) {
|
|
16
|
+
return boxen(content, {
|
|
17
|
+
title: title ? bold(orange(title)) : undefined,
|
|
18
|
+
titleAlignment: 'left',
|
|
19
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
20
|
+
margin: { top: 0, bottom: 0, left: 0, right: 0 },
|
|
21
|
+
borderStyle: 'round',
|
|
22
|
+
borderColor: '#ff8a00',
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Sub-step box — single border, indented, dim orange or red
|
|
27
|
+
function subStepBox(content, isError = false) {
|
|
28
|
+
return boxen(content, {
|
|
29
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
30
|
+
margin: { top: 0, bottom: 0, left: 4, right: 0 },
|
|
31
|
+
borderStyle: 'single',
|
|
32
|
+
borderColor: isError ? '#dc3232' : '#b65f00',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── section tracking (connector │ lines between boxes) ───────────────────────
|
|
37
|
+
let _sectionCount = 0;
|
|
38
|
+
export function resetSectionCount() { _sectionCount = 0; }
|
|
39
|
+
|
|
40
|
+
export function section(title) {
|
|
41
|
+
if (_sectionCount > 0) console.log(orange(' │'));
|
|
42
|
+
_sectionCount++;
|
|
43
|
+
console.log(sectionBox('', title));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Print content in a section-style box (with optional title)
|
|
47
|
+
export function box(lines, title) {
|
|
48
|
+
const content = lines.join('\n');
|
|
49
|
+
console.log(sectionBox(content, title));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Print a sub-step box (indented, for raw tool output)
|
|
53
|
+
export function subBox(lines, { isError = false } = {}) {
|
|
54
|
+
if (!lines || !lines.length) return;
|
|
55
|
+
const content = (isError ? lines.map((l) => red(l)) : lines.map((l) => dimOrange(l))).join('\n');
|
|
56
|
+
console.log(subStepBox(content, isError));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// kv inside current flow
|
|
60
|
+
export function kv(key, value) {
|
|
61
|
+
console.log(` ${dimOrange('›')} ${bold(key)} ${dimOrange('→')} ${value}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── spinner ───────────────────────────────────────────────────────────────────
|
|
22
65
|
const CLEAR_LINE = '\x1b[K';
|
|
23
66
|
export function spinner(text) {
|
|
24
|
-
process.stdout.write(dimOrange('
|
|
67
|
+
process.stdout.write(' ' + dimOrange('○ ') + text);
|
|
25
68
|
return {
|
|
26
|
-
succeed(msg) { process.stdout.write(`\r${CLEAR_LINE}${orange('✓')} ${msg}\n`); },
|
|
27
|
-
fail(msg)
|
|
69
|
+
succeed(msg) { process.stdout.write(`\r${CLEAR_LINE} ${orange('✓')} ${msg}\n`); },
|
|
70
|
+
fail(msg) { process.stdout.write(`\r${CLEAR_LINE} ${red('✗')} ${msg}\n`); },
|
|
28
71
|
};
|
|
29
72
|
}
|
|
73
|
+
|
|
74
|
+
// ── banner ────────────────────────────────────────────────────────────────────
|
|
75
|
+
export function banner() {
|
|
76
|
+
_sectionCount = 0;
|
|
77
|
+
const art = [
|
|
78
|
+
'███████╗ █████╗ ██████╗ ██████╗ ██╗ ██████╗ █████╗ ',
|
|
79
|
+
'██╔════╝██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔══██╗',
|
|
80
|
+
'█████╗ ███████║██████╔╝██████╔╝██║██║ ███████║',
|
|
81
|
+
'██╔══╝ ██╔══██║██╔══██╗██╔══██╗██║██║ ██╔══██║',
|
|
82
|
+
'██║ ██║ ██║██████╔╝██║ ██║██║╚██████╗██║ ██║',
|
|
83
|
+
'╚═╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═════╝╚═╝ ╚═╝',
|
|
84
|
+
].map((l) => orange(l)).join('\n');
|
|
85
|
+
|
|
86
|
+
const subtitle = [
|
|
87
|
+
dimOrange('CMD → OAUTH BRIDGE // SUPABASE + VERCEL DEPLOYER'),
|
|
88
|
+
dim('by SPARROW AI SOLUTION'),
|
|
89
|
+
].join('\n');
|
|
90
|
+
|
|
91
|
+
console.log(boxen(`${art}\n\n${subtitle}`, {
|
|
92
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
93
|
+
margin: { top: 0, bottom: 0, left: 0, right: 0 },
|
|
94
|
+
borderStyle: 'double',
|
|
95
|
+
borderColor: '#ff8a00',
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── help ──────────────────────────────────────────────────────────────────────
|
|
30
100
|
export function help() {
|
|
31
101
|
banner();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
${bold('
|
|
36
|
-
${bold('
|
|
37
|
-
${bold('
|
|
38
|
-
${bold('
|
|
39
|
-
|
|
40
|
-
${
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
`)
|
|
102
|
+
const content = [
|
|
103
|
+
bold(orange('Commands')),
|
|
104
|
+
'',
|
|
105
|
+
` ${bold('build')} Connect Supabase, collect secrets, deploy or run locally`,
|
|
106
|
+
` ${bold('list')} Show Fabrica projects (local & cloud)`,
|
|
107
|
+
` ${bold('env')} View and update environment variables for any project`,
|
|
108
|
+
` ${bold('rerun')} Re-run or re-open an existing project`,
|
|
109
|
+
` ${bold('vins')} Verify & auto-install CLI dependencies`,
|
|
110
|
+
` ${bold('info')} Package, bridge, repo and storage info`,
|
|
111
|
+
` ${bold('help')} Show this screen`,
|
|
112
|
+
'',
|
|
113
|
+
bold(orange('Examples')),
|
|
114
|
+
'',
|
|
115
|
+
` ${dimOrange('$')} npx fabrica-e-commerce build`,
|
|
116
|
+
` ${dimOrange('$')} npx fabrica-e-commerce env`,
|
|
117
|
+
` ${dimOrange('$')} npx fabrica-e-commerce rerun`,
|
|
118
|
+
` ${dimOrange('$')} npx fabrica-e-commerce list`,
|
|
119
|
+
'',
|
|
120
|
+
dim('Creator: SPARROW AI SOLUTION'),
|
|
121
|
+
].join('\n');
|
|
122
|
+
|
|
123
|
+
console.log(orange(' │'));
|
|
124
|
+
console.log(sectionBox(content, 'Help'));
|
|
46
125
|
}
|