fabrica-e-commerce 0.1.16 → 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.16",
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
@@ -9,7 +9,7 @@ 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
11
  import { choose, ask } from './prompt.js';
12
- import { banner, help, kv, section, subBox, resetSectionCount } from './ui.js';
12
+ import { banner, help, kv, section, endSections, subBox, log } from './ui.js';
13
13
  import { openUrl } from './system.js';
14
14
 
15
15
  async function packageVersion() {
@@ -29,6 +29,7 @@ async function build() {
29
29
  kv('BRIDGE', 'ONLINE');
30
30
  kv('SQL', 'Prepared securely (hidden from UI)');
31
31
  const supabase = await connectSupabase();
32
+
32
33
  const env = await collectEnv(supabase);
33
34
  const project = await cloneRepo();
34
35
 
@@ -40,6 +41,7 @@ async function build() {
40
41
 
41
42
  if (target === 'local') {
42
43
  await runLocally(project, env);
44
+ endSections();
43
45
  return;
44
46
  }
45
47
 
@@ -53,114 +55,119 @@ async function build() {
53
55
  kv('GitHub repo', record.githubRepo || 'n/a');
54
56
  kv('URL', record.productionUrl || 'n/a');
55
57
  kv('Path', record.target);
58
+ endSections();
56
59
  }
57
60
 
58
61
  // ── list ──────────────────────────────────────────────────────────────────────
59
62
  async function list() {
60
63
  banner();
61
64
  const projects = await readProjects();
65
+
66
+ section('Your Fabrica projects');
62
67
  if (!projects.length) {
63
- console.log(' No projects found. Run: fabrica build');
68
+ log('No projects found. Run: fabrica build');
69
+ endSections();
64
70
  return;
65
71
  }
66
72
 
67
- section('Your Fabrica projects');
68
73
  const choices = projects.map((p) => ({
69
74
  name: `${p.projectName} (${p.type === 'local' ? 'local' : 'cloud'})`,
70
75
  value: p.id,
71
76
  }));
72
77
 
78
+ endSections(); // flush before interactive prompt
73
79
  const selected = await choose('Select a project to view details:', choices);
74
80
  const project = projects.find((p) => p.id === selected);
75
81
 
76
82
  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);
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();
88
91
  }
89
92
 
90
93
  // ── env ───────────────────────────────────────────────────────────────────────
91
94
  async function env() {
92
95
  banner();
93
96
  const projects = await readProjects();
97
+
98
+ section('Environment manager');
94
99
  if (!projects.length) {
95
- console.log(' No projects found. Run: fabrica build');
100
+ log('No projects found. Run: fabrica build');
101
+ endSections();
96
102
  return;
97
103
  }
104
+ endSections();
98
105
 
99
- section('Environment manager');
100
-
101
- // Step 1 — pick project type filter
102
106
  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' },
107
+ { name: 'All projects', value: 'all' },
108
+ { name: 'Local projects only', value: 'local' },
109
+ { name: 'Cloud (Vercel) projects only', value: 'cloud' },
106
110
  ]);
107
111
 
108
112
  const filtered = typeFilter === 'all' ? projects : projects.filter((p) => p.type === typeFilter);
109
113
  if (!filtered.length) {
110
- console.log(` No ${typeFilter} projects found.`);
114
+ section('Environment manager');
115
+ log(`No ${typeFilter} projects found.`);
116
+ endSections();
111
117
  return;
112
118
  }
113
119
 
114
- // Step 2 — pick project
115
120
  const projectId = await choose('Select project:', filtered.map((p) => ({
116
121
  name: `${p.projectName} (${p.type === 'local' ? 'local' : 'cloud'})`,
117
122
  value: p.id,
118
123
  })));
119
124
  const project = filtered.find((p) => p.id === projectId);
120
125
 
121
- // Step 3 — pick env key
122
126
  const envKeys = project.envKeys || [];
123
127
  if (!envKeys.length) {
124
- console.log(' No env keys stored for this project.');
128
+ section('Environment manager');
129
+ log('No env keys stored for this project.');
130
+ endSections();
125
131
  return;
126
132
  }
127
133
 
128
134
  const key = await choose('Select env variable to update:', envKeys.map((k) => ({ name: k, value: k })));
129
-
130
- // Step 4 — new value
131
135
  const currentVal = (project.env || {})[key];
132
136
  const value = await ask(`New value for ${key}`, currentVal || '');
133
137
 
134
138
  section('Applying update');
135
139
  await updateProjectEnv(project, key, value);
136
140
  kv('Updated', `${key} → ${project.type === 'local' ? '.env.local' : 'Vercel + redeployed'}`);
141
+ endSections();
137
142
  }
138
143
 
139
144
  // ── rerun ─────────────────────────────────────────────────────────────────────
140
145
  async function rerun() {
141
146
  banner();
142
147
  const projects = await readProjects();
148
+
149
+ section('Re-run / re-open project');
143
150
  if (!projects.length) {
144
- console.log(' No projects found. Run: fabrica build');
151
+ log('No projects found. Run: fabrica build');
152
+ endSections();
145
153
  return;
146
154
  }
155
+ endSections();
147
156
 
148
- section('Re-run / re-open project');
149
-
150
- // Step 1 — local or cloud
151
157
  const typeFilter = await choose('Which type of project?', [
152
- { name: 'All projects', value: 'all' },
153
- { name: 'Local projects', value: 'local' },
158
+ { name: 'All projects', value: 'all' },
159
+ { name: 'Local projects', value: 'local' },
154
160
  { name: 'Cloud (Vercel) projects', value: 'cloud' },
155
161
  ]);
156
162
 
157
163
  const filtered = typeFilter === 'all' ? projects : projects.filter((p) => p.type === typeFilter);
158
164
  if (!filtered.length) {
159
- console.log(` No ${typeFilter} projects found.`);
165
+ section('Re-run / re-open project');
166
+ log(`No ${typeFilter} projects found.`);
167
+ endSections();
160
168
  return;
161
169
  }
162
170
 
163
- // Step 2 — pick project
164
171
  const projectId = await choose('Select project to re-run:', filtered.map((p) => ({
165
172
  name: `${p.projectName} (${p.type === 'local' ? 'local' : 'cloud'})`,
166
173
  value: p.id,
@@ -170,37 +177,27 @@ async function rerun() {
170
177
  section(`Re-running: ${project.projectName}`);
171
178
 
172
179
  if (project.type === 'local') {
173
- // Re-run local dev server
174
180
  kv('Path', project.target);
175
- kv('URL', 'http://localhost:3000');
181
+ kv('URL', 'http://localhost:3000');
182
+ log('Starting dev server — browser opens in 3s...');
183
+ endSections();
176
184
 
177
- const { runCommand } = await import('./system.js');
185
+ const { runCommand, runCommandCapture } = await import('./system.js');
178
186
  const { access } = await import('node:fs/promises');
179
187
  const hasPnpmLock = await access(path.join(project.target, 'pnpm-lock.yaml')).then(() => true, () => false);
180
- const { runCommandCapture } = await import('./system.js');
181
188
  const pnpmAvailable = (await runCommandCapture('pnpm', ['--version'])).code === 0;
182
189
  const devCommand = hasPnpmLock && pnpmAvailable ? ['pnpm', ['run', 'dev']] : ['npm', ['run', 'dev']];
183
-
184
- console.log(' Starting dev server... auto-opening browser in 3s');
185
190
  setTimeout(() => openUrl('http://localhost:3000'), 3000);
186
191
  await runCommand(devCommand[0], devCommand[1], { cwd: project.target });
187
192
  } else {
188
- // Cloud: show info + open URL
189
193
  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
- }
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);
204
201
  }
205
202
  }
206
203
 
@@ -208,28 +205,25 @@ async function rerun() {
208
205
  async function info() {
209
206
  banner();
210
207
  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);
208
+ kv('Package', `fabrica-e-commerce v${await packageVersion()}`);
209
+ kv('Bridge', BRIDGE_ORIGIN);
210
+ kv('Store repo', STORE_REPO);
211
+ kv('Local data', dataDir);
212
+ kv('Node', process.version);
213
+ kv('Creator', 'SPARROW AI SOLUTION');
214
+ endSections();
221
215
  }
222
216
 
223
217
  // ── router ────────────────────────────────────────────────────────────────────
224
218
  export async function run(args) {
225
219
  const command = args[0] || 'help';
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();
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();
233
227
  console.error(`Unknown command: ${command}`);
234
228
  help();
235
229
  process.exitCode = 1;
package/src/deploy.js CHANGED
@@ -4,7 +4,7 @@ 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
6
  import { runCommand, runCommandCapture, openUrl } from './system.js';
7
- import { dimOrange, kv, section, spinner, subBox, red, orange } from './ui.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,15 +12,23 @@ 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
  }
18
22
 
19
23
  export async function cloneRepo() {
24
+ endSections();
20
25
  section('Clone storefront');
21
26
  const projectName = await ask('Vercel project name', `fabrica-store-${Date.now()}`);
27
+ section('Clone storefront');
22
28
  const id = crypto.randomUUID();
23
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();
24
32
  await runCommand('git', ['clone', STORE_REPO, target]);
25
33
  return { id, projectName, target };
26
34
  }
@@ -38,20 +46,21 @@ export async function ensureVercelLogin() {
38
46
  return;
39
47
  }
40
48
  spin.fail('Not logged in to Vercel');
41
- console.log(' Opening "vercel login" — finish the login in your browser...');
49
+ log('Opening "vercel login" — finish the login in your browser...');
50
+ endSections();
42
51
  await runCommand('npx', ['vercel@latest', 'login']);
52
+ section('Vercel login');
43
53
  if (!(await isLoggedInToVercel())) {
44
54
  throw new Error('Vercel login was not completed. Run "fabrica build" again after logging in.');
45
55
  }
46
56
  kv('Vercel', 'Logged in');
47
57
  }
48
58
 
49
- // Capture vercel CLI output and render it in a sub-box, errors in red
59
+ // Capture vercel CLI output and add it into the current section buffer as a subBox
50
60
  async function runVercelBoxed(args, options = {}) {
51
61
  const result = await runCommandCapture('npx', ['vercel@latest', ...args], options);
52
62
  const raw = ((result.stdout || '') + (result.stderr || '')).trim();
53
63
  if (!raw) return result;
54
-
55
64
  const lines = raw.split('\n').map((l) => l.trimEnd()).filter(Boolean);
56
65
  const isErr = result.code !== 0 || lines.some((l) => /^Error:/i.test(l));
57
66
  subBox(lines, { isError: isErr });
@@ -73,21 +82,18 @@ async function connectGithubRepo(project, repoUrl) {
73
82
  const spin = spinner(`Connecting Vercel project to ${repoUrl}`);
74
83
  const attempts = 6;
75
84
  let lastError = '';
76
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
85
+ for (let attempt = 1; attempt <= attempts; attempt++) {
77
86
  const connect = await runCommandCapture('npx', ['--yes', 'vercel@latest', 'git', 'connect', repoUrl, '--yes'], { cwd: project.target });
78
- if (connect.code === 0) {
79
- spin.succeed('Vercel project connected to GitHub repo');
80
- return true;
81
- }
87
+ if (connect.code === 0) { spin.succeed('Vercel project connected to GitHub repo'); return true; }
82
88
  lastError = (connect.stderr || connect.stdout || '').trim();
83
- if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, attempt * 3000));
89
+ if (attempt < attempts) await new Promise((r) => setTimeout(r, attempt * 3000));
84
90
  }
85
91
  spin.fail('Could not auto-connect Git — continuing with a direct deploy');
86
92
  subBox([
87
93
  'This usually means one of two things:',
88
94
  `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"',
95
+ ` Re-link: npx vercel git connect ${repoUrl}`,
96
+ '2) Vercel GitHub App set to "Only select repositories"',
91
97
  ' Fix at: https://github.com/settings/installations',
92
98
  lastError ? `Last error: ${lastError}` : '',
93
99
  ].filter(Boolean), { isError: true });
@@ -97,6 +103,7 @@ async function connectGithubRepo(project, repoUrl) {
97
103
  export async function deployToVercel(project, env, githubRepo) {
98
104
  section('Vercel deployment');
99
105
  await ensureVercelLogin();
106
+ section('Vercel deployment');
100
107
 
101
108
  await runVercelBoxed(['link', '--yes', '--project', project.projectName], { cwd: project.target });
102
109
 
@@ -110,24 +117,23 @@ export async function deployToVercel(project, env, githubRepo) {
110
117
  const deployResult = await runCommandCapture('npx', ['vercel@latest', '--prod', '--yes'], { cwd: project.target });
111
118
  const deployOutput = ((deployResult.stdout || '') + (deployResult.stderr || '')).trim();
112
119
 
113
- // Extract URLs from deploy output
114
120
  let productionUrl = null;
115
121
  let aliasedUrl = null;
116
122
  let inspectUrl = null;
117
123
  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];
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];
121
127
  }
122
128
 
123
129
  subBox(deployOutput.split('\n').filter(Boolean));
124
130
  deploySpin.succeed('Production deployment created');
125
131
 
126
- // Auto-open the aliased/production URL
127
132
  const openTarget = aliasedUrl || productionUrl;
128
133
  if (openTarget) {
129
134
  kv('Opening', openTarget);
130
- await openUrl(openTarget);
135
+ // open after flush
136
+ process.nextTick(() => openUrl(openTarget));
131
137
  }
132
138
 
133
139
  const record = {
@@ -146,60 +152,20 @@ export async function deployToVercel(project, env, githubRepo) {
146
152
  return record;
147
153
  }
148
154
 
149
- export async function runLocally(project, env) {
150
- section('Local Next.js setup');
151
- const envPath = path.join(project.target, '.env.local');
152
- const contents = Object.entries(env).map(([key, value]) => `${key}=${String(value).replace(/\n/g, '\\n')}`).join('\n') + '\n';
153
- await fs.writeFile(envPath, contents, 'utf8');
154
- kv('Env file', envPath);
155
-
156
- const hasPnpmLock = await fs.access(path.join(project.target, 'pnpm-lock.yaml')).then(() => true, () => false);
157
- const pnpmAvailable = (await runCommandCapture('pnpm', ['--version'])).code === 0;
158
- const installCommand = hasPnpmLock && pnpmAvailable ? ['pnpm', ['install']] : ['npm', ['install']];
159
- const devCommand = hasPnpmLock && pnpmAvailable ? ['pnpm', ['run', 'dev']] : ['npm', ['run', 'dev']];
160
-
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
-
176
- section('Local app');
177
- kv('Path', project.target);
178
- kv('URL', 'http://localhost:3000');
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);
183
- await runCommand(devCommand[0], devCommand[1], { cwd: project.target });
184
- }
185
-
186
- // Called by env command - update a single env var for cloud or local project
187
155
  export async function updateProjectEnv(project, key, value) {
188
156
  if (project.type === 'local') {
189
- // Update .env.local
190
157
  const envPath = path.join(project.target, '.env.local');
191
158
  let contents = '';
192
- try { contents = await fs.readFile(envPath, 'utf8'); } catch { /* no file yet */ }
159
+ try { contents = await fs.readFile(envPath, 'utf8'); } catch { /* new file */ }
193
160
  const lines = contents.split('\n');
194
161
  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);
162
+ if (idx >= 0) lines[idx] = `${key}=${value}`;
163
+ else lines.push(`${key}=${value}`);
198
164
  await fs.writeFile(envPath, lines.join('\n'), 'utf8');
199
165
  kv('Updated', `${key} in .env.local`);
200
166
  } else {
201
- // Cloud: update on Vercel + redeploy
202
167
  await ensureVercelLogin();
168
+ section('Applying update');
203
169
  for (const environment of VERCEL_ENVIRONMENTS) {
204
170
  await runVercelBoxed(['env', 'rm', key, environment, '--yes'], { cwd: project.target, allowFailure: true });
205
171
  await runVercelBoxed(['env', 'add', key, environment], { cwd: project.target, input: `${value}\n` });
@@ -208,7 +174,39 @@ export async function updateProjectEnv(project, key, value) {
208
174
  await runVercelBoxed(['--prod', '--yes'], { cwd: project.target });
209
175
  spin.succeed(`Redeployed ${project.projectName}`);
210
176
  }
211
- // Persist updated env in store
212
177
  const updated = { ...project, env: { ...(project.env || {}), [key]: value } };
213
178
  await saveProject(updated);
214
179
  }
180
+
181
+ export async function runLocally(project, env) {
182
+ section('Local setup');
183
+ const envPath = path.join(project.target, '.env.local');
184
+ const contents = Object.entries(env).map(([k, v]) => `${k}=${String(v).replace(/\n/g, '\\n')}`).join('\n') + '\n';
185
+ await fs.writeFile(envPath, contents, 'utf8');
186
+ kv('Env file', envPath);
187
+
188
+ const hasPnpmLock = await fs.access(path.join(project.target, 'pnpm-lock.yaml')).then(() => true, () => false);
189
+ const pnpmAvailable = (await runCommandCapture('pnpm', ['--version'])).code === 0;
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 });
204
+
205
+ section('Local app');
206
+ kv('Path', 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 });
212
+ }
package/src/deps.js CHANGED
@@ -2,17 +2,15 @@ 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, subBox } from './ui.js';
5
+ import { kv, section, endSections, spinner, subBox, log } from './ui.js';
6
6
 
7
- // ── platform detection ────────────────────────────────────────────────────────
8
7
  function isTermux() {
9
8
  return process.platform === 'android' ||
10
9
  (process.platform === 'linux' && (process.env.TERMUX_VERSION || process.env.PREFIX?.includes('com.termux')));
11
10
  }
12
11
 
13
12
  async function runPrivileged(command, args, options = {}) {
14
- if (process.platform === 'win32') return runCommand(command, args, options);
15
- if (isTermux()) return runCommand(command, args, options); // Termux: no sudo
13
+ if (process.platform === 'win32' || isTermux()) return runCommand(command, args, options);
16
14
  if (await commandExists('sudo')) return runCommand('sudo', [command, ...args], options);
17
15
  return runCommand(command, args, options);
18
16
  }
@@ -21,158 +19,105 @@ function runPrivilegedShell(script, options = {}) {
21
19
  return runCommand('bash', ['-c', script], options);
22
20
  }
23
21
 
24
- // ── dependency definitions ────────────────────────────────────────────────────
25
22
  const DEPENDENCIES = [
26
- {
27
- name: 'git',
28
- label: 'Git',
29
- check: () => checkWithPathRefresh('git'),
30
- install: installGit,
31
- manualUrl: 'https://git-scm.com/downloads'
32
- },
33
- {
34
- name: 'gh',
35
- label: 'GitHub CLI (gh)',
36
- check: () => checkWithPathRefresh('gh'),
37
- install: installGithubCli,
38
- manualUrl: 'https://github.com/cli/cli#installation'
39
- },
40
- {
41
- name: 'vercel',
42
- label: 'Vercel CLI (via npx)',
43
- check: checkVercelCli,
44
- install: warmVercelCli,
45
- manualUrl: 'https://vercel.com/docs/cli'
46
- }
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' },
47
26
  ];
48
27
 
49
- // ── PATH helpers ──────────────────────────────────────────────────────────────
50
28
  function addToPathIfDirectory(directory) {
51
29
  if (!directory || !fs.existsSync(directory)) return;
52
- const delimiter = path.delimiter;
53
30
  const current = process.env.PATH || '';
54
- const entries = current.split(delimiter).filter(Boolean);
55
- if (!entries.some((entry) => entry.toLowerCase() === directory.toLowerCase())) {
56
- process.env.PATH = `${directory}${delimiter}${current}`;
57
- }
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}`;
58
34
  }
59
35
 
60
36
  function refreshWindowsToolPaths() {
61
37
  if (process.platform !== 'win32') return;
62
38
  const lad = process.env.LOCALAPPDATA;
63
- const pf = process.env.ProgramFiles;
39
+ const pf = process.env.ProgramFiles;
64
40
  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'));
41
+ const pd = process.env.ProgramData;
42
+ addToPathIfDirectory(lad && path.join(lad, 'Microsoft', 'WinGet', 'Links'));
43
+ addToPathIfDirectory(pf && path.join(pf, 'GitHub CLI'));
68
44
  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'));
45
+ addToPathIfDirectory(pd && path.join(pd, 'chocolatey', 'bin'));
46
+ addToPathIfDirectory(pf && path.join(pf, 'Git', 'cmd'));
47
+ addToPathIfDirectory(pf && path.join(pf, 'nodejs'));
72
48
  }
73
49
 
74
- async function checkWithPathRefresh(command) {
75
- refreshWindowsToolPaths();
76
- return commandExists(command);
77
- }
50
+ async function checkWithPathRefresh(cmd) { refreshWindowsToolPaths(); return commandExists(cmd); }
78
51
 
79
52
  async function runFirstSuccessful(candidates, options = {}) {
80
- for (const [command, args] of candidates) {
81
- const result = await runCommandCapture(command, args, options);
82
- 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;
83
56
  }
84
57
  return { code: 1, stdout: '', stderr: 'All fallback commands failed' };
85
58
  }
86
59
 
87
60
  async function checkVercelCli() {
88
61
  refreshWindowsToolPaths();
89
- const result = await runFirstSuccessful([
90
- ['npx', ['--yes', 'vercel@latest', '--version']],
91
- ['npm', ['exec', '--yes', 'vercel@latest', '--', '--version']],
92
- ['vercel', ['--version']]
62
+ const r = await runFirstSuccessful([
63
+ ['npx', ['--yes', 'vercel@latest', '--version']],
64
+ ['npm', ['exec', '--yes', 'vercel@latest', '--', '--version']],
65
+ ['vercel', ['--version']],
93
66
  ]);
94
- return result.code === 0;
67
+ return r.code === 0;
95
68
  }
96
69
 
97
70
  async function warmVercelCli() {
98
71
  refreshWindowsToolPaths();
99
- const warmed = await runFirstSuccessful([
100
- ['npx', ['--yes', 'vercel@latest', '--version']],
101
- ['npm', ['exec', '--yes', 'vercel@latest', '--', '--version']],
102
- ['vercel', ['--version']]
72
+ const r = await runFirstSuccessful([
73
+ ['npx', ['--yes', 'vercel@latest', '--version']],
74
+ ['npm', ['exec', '--yes', 'vercel@latest', '--', '--version']],
75
+ ['vercel', ['--version']],
103
76
  ]);
104
- if (warmed.code === 0) return;
77
+ if (r.code === 0) return;
105
78
  await runCommand('npm', ['install', '-g', 'vercel@latest'], { allowFailure: true });
106
79
  }
107
80
 
108
- // ── Git install ───────────────────────────────────────────────────────────────
109
81
  async function installGit() {
110
82
  refreshWindowsToolPaths();
111
- const platform = process.platform;
112
-
113
- if (isTermux()) {
114
- await runCommand('pkg', ['install', '-y', 'git'], { allowFailure: true });
115
- return;
116
- }
117
-
118
- if (platform === 'linux') {
119
- if (await commandExists('apt-get')) {
120
- await runPrivileged('apt-get', ['update'], { allowFailure: true });
121
- await runPrivileged('apt-get', ['install', '-y', 'git'], { allowFailure: true });
122
- return;
123
- }
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 }); }
124
87
  if (await commandExists('dnf')) return runPrivileged('dnf', ['install', '-y', 'git'], { allowFailure: true });
125
88
  if (await commandExists('yum')) return runPrivileged('yum', ['install', '-y', 'git'], { allowFailure: true });
126
89
  if (await commandExists('pacman')) return runPrivileged('pacman', ['-Sy', '--noconfirm', 'git'], { allowFailure: true });
127
90
  if (await commandExists('zypper')) return runPrivileged('zypper', ['install', '-y', 'git'], { allowFailure: true });
128
91
  if (await commandExists('apk')) return runPrivileged('apk', ['add', 'git'], { allowFailure: true });
129
92
  }
130
- if (platform === 'darwin') {
93
+ if (p === 'darwin') {
131
94
  if (await commandExists('brew')) return runCommand('brew', ['install', 'git'], { allowFailure: true });
132
- // Xcode command line tools
133
- await runCommand('xcode-select', ['--install'], { allowFailure: true });
95
+ return runCommand('xcode-select', ['--install'], { allowFailure: true });
134
96
  }
135
- if (platform === 'win32') {
136
- if (await commandExists('winget')) {
137
- await runCommand('winget', ['install', '--id', 'Git.Git', '-e', '--source', 'winget'], { allowFailure: true });
138
- refreshWindowsToolPaths();
139
- return;
140
- }
141
- if (await commandExists('choco')) {
142
- await runCommand('choco', ['install', 'git', '-y'], { allowFailure: true });
143
- refreshWindowsToolPaths();
144
- }
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(); }
145
100
  }
146
101
  }
147
102
 
148
- // ── GitHub CLI install ────────────────────────────────────────────────────────
149
103
  async function installGithubCli() {
150
104
  refreshWindowsToolPaths();
151
- const platform = process.platform;
152
-
153
- if (isTermux()) {
154
- await runCommand('pkg', ['install', '-y', 'gh'], { allowFailure: true });
155
- return;
156
- }
157
-
158
- if (platform === 'linux') {
105
+ const p = process.platform;
106
+ if (isTermux()) return runCommand('pkg', ['install', '-y', 'gh'], { allowFailure: true });
107
+ if (p === 'linux') {
159
108
  if (await commandExists('apt-get')) {
160
109
  const direct = await (await commandExists('sudo')
161
110
  ? runCommandCapture('sudo', ['apt-get', 'install', '-y', 'gh'])
162
111
  : runCommandCapture('apt-get', ['install', '-y', 'gh']));
163
112
  if (direct.code === 0) return;
164
- const usesSudo = await commandExists('sudo');
165
- const prefix = usesSudo ? 'sudo ' : '';
113
+ const pre = (await commandExists('sudo')) ? 'sudo ' : '';
166
114
  await runPrivilegedShell([
167
- 'set -e',
168
- `${prefix}apt-get update`,
169
- `${prefix}apt-get install -y curl ca-certificates gnupg`,
170
- `${prefix}mkdir -p -m 755 /etc/apt/keyrings`,
171
- `curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | ${prefix}tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null`,
172
- `${prefix}chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg`,
173
- `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`,
174
- `${prefix}apt-get update`,
175
- `${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`,
176
121
  ].join(' && '), { allowFailure: true });
177
122
  return;
178
123
  }
@@ -181,25 +126,17 @@ async function installGithubCli() {
181
126
  if (await commandExists('zypper')) return runPrivileged('zypper', ['install', '-y', 'gh'], { allowFailure: true });
182
127
  if (await commandExists('apk')) return runCommand('apk', ['add', 'github-cli'], { allowFailure: true });
183
128
  }
184
- if (platform === 'darwin') {
129
+ if (p === 'darwin') {
185
130
  if (await commandExists('brew')) return runCommand('brew', ['install', 'gh'], { allowFailure: true });
186
131
  }
187
- if (platform === 'win32') {
188
- if (await commandExists('winget')) {
189
- await runCommand('winget', ['install', '--id', 'GitHub.cli', '-e', '--source', 'winget'], { allowFailure: true });
190
- refreshWindowsToolPaths();
191
- return;
192
- }
193
- if (await commandExists('choco')) {
194
- await runCommand('choco', ['install', 'gh', '-y'], { allowFailure: true });
195
- refreshWindowsToolPaths();
196
- }
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(); }
197
135
  }
198
136
  }
199
137
 
200
- // ── Public API ────────────────────────────────────────────────────────────────
201
138
  export async function ensureDependencies({ autoInstall = true, names } = {}) {
202
- const targets = names ? DEPENDENCIES.filter((dep) => names.includes(dep.name)) : DEPENDENCIES;
139
+ const targets = names ? DEPENDENCIES.filter((d) => names.includes(d.name)) : DEPENDENCIES;
203
140
  const results = [];
204
141
  for (const dep of targets) {
205
142
  const spin = spinner(`Checking ${dep.label}`);
@@ -211,7 +148,7 @@ export async function ensureDependencies({ autoInstall = true, names } = {}) {
211
148
  await dep.install();
212
149
  present = await dep.check();
213
150
  if (present) installSpin.succeed(`${dep.label} installed`);
214
- else installSpin.fail(`${dep.label} could not be installed automatically`);
151
+ else installSpin.fail(`${dep.label} could not be installed automatically`);
215
152
  results.push({ ...dep, present, installed: present });
216
153
  }
217
154
  return results;
@@ -219,18 +156,20 @@ export async function ensureDependencies({ autoInstall = true, names } = {}) {
219
156
 
220
157
  export async function vinsCommand() {
221
158
  section('Fabrica dependency check (vins)');
222
- const platform = isTermux() ? 'Termux/Android' : process.platform;
223
- kv('Platform', platform);
159
+ kv('Platform', isTermux() ? 'Termux/Android' : process.platform);
224
160
  const results = await ensureDependencies();
161
+
225
162
  section('Summary');
226
- let allGood = true;
227
163
  const summaryLines = [];
164
+ let allGood = true;
228
165
  for (const dep of results) {
229
166
  summaryLines.push(`${dep.label}: ${dep.present ? '✓ OK' : '✗ MISSING — install manually'}`);
230
167
  if (!dep.present) { allGood = false; summaryLines.push(` Manual: ${dep.manualUrl}`); }
231
168
  }
232
169
  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; }
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;
235
174
  return results;
236
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/ui.js CHANGED
@@ -1,79 +1,120 @@
1
1
  import process from 'node:process';
2
2
  import boxen from 'boxen';
3
3
 
4
- // ── color helpers (raw ANSI, no deps) ────────────────────────────────────────
4
+ // ── colors ────────────────────────────────────────────────────────────────────
5
5
  const ESC = '\x1b[';
6
- export const orange = (t) => `${ESC}38;2;255;138;0m${t}${ESC}0m`;
6
+ export const orange = (t) => `${ESC}38;2;255;138;0m${t}${ESC}0m`;
7
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`;
12
-
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,
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`;
12
+
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)),
18
30
  titleAlignment: 'left',
19
31
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
20
32
  margin: { top: 0, bottom: 0, left: 0, right: 0 },
21
33
  borderStyle: 'round',
22
34
  borderColor: '#ff8a00',
23
35
  });
36
+
37
+ if (_sectionCount > 1) console.log(orange(' │'));
38
+ console.log(rendered);
39
+
40
+ _currentTitle = null;
41
+ _lines = [];
42
+ _isError = false;
24
43
  }
25
44
 
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
- });
45
+ // Add a line into the current section buffer
46
+ function addLine(text) {
47
+ _lines.push(text);
34
48
  }
35
49
 
36
- // ── section tracking (connector │ lines between boxes) ───────────────────────
37
- let _sectionCount = 0;
38
- export function resetSectionCount() { _sectionCount = 0; }
50
+ export function resetSectionCount() {
51
+ flushSection();
52
+ _sectionCount = 0;
53
+ }
54
+
55
+ // ── public API ────────────────────────────────────────────────────────────────
39
56
 
57
+ // Start a new named section (flushes previous one)
40
58
  export function section(title) {
41
- if (_sectionCount > 0) console.log(orange(' │'));
59
+ flushSection();
42
60
  _sectionCount++;
43
- console.log(sectionBox('', title));
61
+ _currentTitle = title;
62
+ _lines = [];
44
63
  }
45
64
 
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));
65
+ // Force-flush the current section (call at end of a command)
66
+ export function endSections() {
67
+ flushSection();
57
68
  }
58
69
 
59
- // kv inside current flow
70
+ // kv row inside current section
60
71
  export function kv(key, value) {
61
- console.log(` ${dimOrange('›')} ${bold(key)} ${dimOrange('→')} ${value}`);
72
+ addLine(` ${dimOrange('›')} ${bold(key)} ${dimOrange('→')} ${value}`);
62
73
  }
63
74
 
64
- // ── spinner ───────────────────────────────────────────────────────────────────
75
+ // spinner — writes inline to stdout while running, then adds result line to buffer
65
76
  const CLEAR_LINE = '\x1b[K';
66
77
  export function spinner(text) {
67
- process.stdout.write(' ' + dimOrange('○ ') + text);
78
+ process.stdout.write(` ${dimOrange('○')} ${text}`);
68
79
  return {
69
- succeed(msg) { process.stdout.write(`\r${CLEAR_LINE} ${orange('✓')} ${msg}\n`); },
70
- fail(msg) { process.stdout.write(`\r${CLEAR_LINE} ${red('✗')} ${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
+ },
71
88
  };
72
89
  }
73
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
+
74
111
  // ── banner ────────────────────────────────────────────────────────────────────
75
112
  export function banner() {
113
+ flushSection();
76
114
  _sectionCount = 0;
115
+ _currentTitle = null;
116
+ _lines = [];
117
+
77
118
  const art = [
78
119
  '███████╗ █████╗ ██████╗ ██████╗ ██╗ ██████╗ █████╗ ',
79
120
  '██╔════╝██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔══██╗',
@@ -90,7 +131,6 @@ export function banner() {
90
131
 
91
132
  console.log(boxen(`${art}\n\n${subtitle}`, {
92
133
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
93
- margin: { top: 0, bottom: 0, left: 0, right: 0 },
94
134
  borderStyle: 'double',
95
135
  borderColor: '#ff8a00',
96
136
  }));
@@ -119,7 +159,12 @@ export function help() {
119
159
  '',
120
160
  dim('Creator: SPARROW AI SOLUTION'),
121
161
  ].join('\n');
122
-
123
162
  console.log(orange(' │'));
124
- console.log(sectionBox(content, 'Help'));
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
+ }));
125
170
  }