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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fabrica-e-commerce",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Orange themed CMD launcher for deploying Fabrica e-commerce stores with Supabase and Vercel.",
5
5
  "type": "module",
6
6
  "bin": {
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
- console.log(`Open this URL if your browser does not start: ${job.connectUrl}`);
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, editProjectEnv, ensureVercelLogin, runLocally } from './deploy.js';
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('Step 3: Run target');
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
- console.log('No projects found. Run: fabrica build');
68
+ log('No projects found. Run: fabrica build');
69
+ endSections();
59
70
  return;
60
71
  }
61
- projects.forEach((project, index) => {
62
- console.log(`\n${index + 1}. ${project.projectName}`);
63
- kv('Created', project.createdAt);
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('GitHub repo', project.githubRepo || 'n/a');
66
- kv('Supabase', project.supabaseUrl);
67
- kv('Env keys', project.envKeys.join(', '));
68
- });
69
- await editProjectEnv(projects);
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
- kv('Package', `fabrica-e-commerce v${await packageVersion()}`);
75
- kv('Bridge', BRIDGE_ORIGIN);
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', process.version);
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') return build();
84
- if (command === 'list') return list();
85
- if (command === 'info' || command === '.info') return info();
86
- if (command === 'vins' || command === '/vins') return vinsCommand();
87
- if (command === 'help' || command === '--help' || command === '-h') return help();
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 { dimOrange, kv, section, spinner } from './ui.js';
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) env[key] = await ask(`${key}`);
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
- console.log('Opening "vercel login" — finish the login in your browser...');
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
- // Sets every env var across production, preview, and development so the
51
- // values are permanent for the project, not just the one deploy.
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 runCommand('npx', ['vercel@latest', 'env', 'rm', key, environment, '--yes'], { cwd: project.target, allowFailure: true });
57
- await runCommand('npx', ['vercel@latest', 'env', 'add', key, environment], { cwd: project.target, input: `${value}\n` });
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 += 1) {
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((resolve) => setTimeout(resolve, attempt * 3000));
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
- console.log(dimOrange(' This usually means one of two things:'));
83
- console.log(dimOrange(` 1) Vercel hasn't finished indexing the new fork yet (run "fabrica list" in a minute and re-link manually with: npx vercel git connect ${repoUrl})`));
84
- console.log(dimOrange(' 2) The "Vercel" GitHub App is set to "Only select repositories" and was never granted access to the new fork — fix at https://github.com/settings/installations'));
85
- if (lastError) console.log(dimOrange(` Last error: ${lastError}`));
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 runCommand('npx', ['vercel@latest', 'link', '--yes', '--project', project.projectName], { cwd: project.target });
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 runCommand('npx', ['vercel@latest', '--prod', '--yes'], { cwd: project.target });
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
- supabaseUrl: env.SUPABASE_URL
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
- 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` });
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
- await runCommand('npx', ['vercel@latest', '--prod', '--yes'], { cwd: project.target });
128
- kv('Redeployed', project.projectName);
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 Next.js setup');
182
+ section('Local setup');
134
183
  const envPath = path.join(project.target, '.env.local');
135
- const contents = Object.entries(env).map(([key, value]) => `${key}=${String(value).replace(/\n/g, '\\n')}`).join('\n') + '\n';
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 installCommand = hasPnpmLock && pnpmAvailable ? ['pnpm', ['install']] : ['npm', ['install']];
142
- const devCommand = hasPnpmLock && pnpmAvailable ? ['pnpm', ['run', 'dev']] : ['npm', ['run', 'dev']];
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', 'http://localhost:3000');
148
- console.log('Starting the Next.js dev server. Press Ctrl+C to stop it.');
149
- await runCommand(devCommand[0], devCommand[1], { cwd: project.target });
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 (await commandExists('sudo')) {
12
- return runCommand('sudo', [command, ...args], options);
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
- name: 'git',
27
- label: 'Git',
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((entry) => entry.toLowerCase() === directory.toLowerCase())) {
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 localAppData = process.env.LOCALAPPDATA;
62
- const programFiles = process.env.ProgramFiles;
63
- const programFilesX86 = process.env['ProgramFiles(x86)'];
64
- const programData = process.env.ProgramData;
65
- addToPathIfDirectory(localAppData && path.join(localAppData, 'Microsoft', 'WinGet', 'Links'));
66
- addToPathIfDirectory(programFiles && path.join(programFiles, 'GitHub CLI'));
67
- addToPathIfDirectory(programFilesX86 && path.join(programFilesX86, 'GitHub CLI'));
68
- addToPathIfDirectory(programData && path.join(programData, 'chocolatey', 'bin'));
69
- addToPathIfDirectory(programFiles && path.join(programFiles, 'Git', 'cmd'));
70
- addToPathIfDirectory(programFiles && path.join(programFiles, 'nodejs'));
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(command) {
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 [command, args] of candidates) {
80
- const result = await runCommandCapture(command, args, options);
81
- if (result.code === 0) return result;
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 result = await runFirstSuccessful([
89
- ['npx', ['--yes', 'vercel@latest', '--version']],
90
- ['npm', ['exec', '--yes', 'vercel@latest', '--', '--version']],
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 result.code === 0;
67
+ return r.code === 0;
94
68
  }
95
69
 
96
70
  async function warmVercelCli() {
97
71
  refreshWindowsToolPaths();
98
- const warmed = await runFirstSuccessful([
99
- ['npx', ['--yes', 'vercel@latest', '--version']],
100
- ['npm', ['exec', '--yes', 'vercel@latest', '--', '--version']],
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 (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.
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 platform = process.platform;
112
- if (platform === 'linux') {
113
- if (await commandExists('apt-get')) {
114
- await runPrivileged('apt-get', ['update'], { allowFailure: true });
115
- await runPrivileged('apt-get', ['install', '-y', 'git'], { allowFailure: true });
116
- return;
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 (platform === 'darwin') {
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 (platform === 'win32') {
126
- if (await commandExists('winget')) {
127
- await runCommand('winget', ['install', '--id', 'Git.Git', '-e', '--source', 'winget'], { allowFailure: true });
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 platform = process.platform;
142
- if (platform === 'linux') {
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
- // Fall back to the official GitHub CLI apt repository setup.
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
- `${prefix}apt-get update`,
155
- `${prefix}apt-get install -y curl ca-certificates gnupg`,
156
- `${prefix}mkdir -p -m 755 /etc/apt/keyrings`,
157
- `curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | ${prefix}tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null`,
158
- `${prefix}chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg`,
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')) return runPrivileged('dnf', ['install', '-y', 'gh'], { allowFailure: true });
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 (platform === 'darwin') {
129
+ if (p === 'darwin') {
169
130
  if (await commandExists('brew')) return runCommand('brew', ['install', 'gh'], { allowFailure: true });
170
131
  }
171
- if (platform === 'win32') {
172
- if (await commandExists('winget')) {
173
- await runCommand('winget', ['install', '--id', 'GitHub.cli', '-e', '--source', 'winget'], { allowFailure: true });
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((dep) => names.includes(dep.name)) : DEPENDENCIES;
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 installSpin.fail(`${dep.label} could not be installed automatically`);
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
- kv(dep.label, dep.present ? 'OK' : 'MISSING — install manually');
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
- console.log('A browser/device flow will open so you can log in with "gh auth login"...');
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
- console.log('Continuing with GitHub API publishing; if Git later asks for credentials, run: gh auth setup-git');
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
- console.log(dimOrange(` Note: this is named "${actualName}", not "${repoName}", because your GitHub account already had a fork of ${source}.`));
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(`${message}${suffix}: `);
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
- console.log(message);
14
- choices.forEach((choice, index) => console.log(` ${index + 1}. ${choice.name}`));
15
- const answer = Number(await ask('Select number', '1'));
16
- return choices[Math.max(0, Math.min(choices.length - 1, answer - 1))].value;
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 = (text) => `${ESC}38;2;255;138;0m${text}${ESC}0m`;
3
- export const dimOrange = (text) => `${ESC}38;2;182;95;0m${text}${ESC}0m`;
4
- export const bold = (text) => `${ESC}1m${text}${ESC}0m`;
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
- export function banner() {
7
- console.log(orange(String.raw`
8
- ███████╗ █████╗ ██████╗ ██████╗ ██╗ ██████╗ █████╗
9
- ██╔════╝██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔══██╗
10
- █████╗ ███████║██████╔╝██████╔╝██║██║ ███████║
11
- ██╔══╝ ██╔══██║██╔══██╗██╔══██╗██║██║ ██╔══██║
12
- ██║ ██║ ██║██████╔╝██║ ██║██║╚██████╗██║ ██║
13
- ╚═╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═════╝╚═╝ ╚═╝`));
14
- console.log(dimOrange(' CMD OAUTH BRIDGE // SUPABASE + VERCEL DEPLOYER'));
15
- console.log(orange('──────────────────────────────────────────────────────'));
16
- }
17
- export function section(title) { console.log('\n' + orange(`◆ ${title}`)); }
18
- export function kv(key, value) { console.log(`${dimOrange('>')} ${bold(key)} ${dimOrange('→')} ${value}`); }
19
- // Clear the rest of the line (\x1b[K) before writing the final message so a
20
- // shorter success/fail message never leaves leftover characters from the
21
- // longer spinner text trailing after it (e.g. "✓ Git foundGit").
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('> ') + text);
78
+ process.stdout.write(` ${dimOrange('')} ${text}`);
25
79
  return {
26
- succeed(msg) { process.stdout.write(`\r${CLEAR_LINE}${orange('✓')} ${msg}\n`); },
27
- fail(msg) { process.stdout.write(`\r${CLEAR_LINE}${orange('✗')} ${msg}\n`); }
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
- console.log(`
33
- ${orange('Commands')}
34
- ${bold('build')} Connect Supabase, collect secrets, then run locally or deploy to Vercel
35
- ${bold('list')} Show deployed Fabrica projects and edit env variables
36
- ${bold('vins')} Verify CLI dependencies (git, gh, vercel) and auto-install anything missing
37
- ${bold('info')} Show package, bridge, repo, and local storage information
38
- ${bold('help')} Show this help screen
39
-
40
- ${orange('Examples')}
41
- npx fabrica-e-commerce build
42
- npx fabrica-e-commerce list
43
- npx fabrica-e-commerce vins
44
- npx fabrica-e-commerce info
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
  }