ucode-agent 1.5.0 → 1.7.0

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.
Files changed (71) hide show
  1. package/README.md +399 -327
  2. package/package.json +6 -1
  3. package/skills/ui-ux/SKILL.md +2 -2
  4. package/src/core/doctor.js +122 -0
  5. package/src/core/livelog.js +113 -0
  6. package/src/core/loop.js +2105 -1659
  7. package/src/core/provider.js +93 -10
  8. package/src/core/stuck.js +269 -0
  9. package/src/core/tests.js +86 -0
  10. package/src/tools/blocks.js +117 -0
  11. package/src/tools/browser.js +121 -59
  12. package/src/tools/cache.js +105 -0
  13. package/src/tools/deploy.js +283 -0
  14. package/src/tools/files.js +91 -8
  15. package/src/tools/index.js +634 -495
  16. package/src/tools/rename.js +157 -0
  17. package/src/tools/scaffold.js +85 -6
  18. package/src/tools/shell.js +799 -701
  19. package/src/tools/symbols.js +218 -0
  20. package/src/tools/types.js +179 -0
  21. package/src/ui/activity.js +203 -0
  22. package/src/ui/plain.js +22 -3
  23. package/src/ui/screen.js +65 -19
  24. package/src/ui/theme.js +5 -1
  25. package/templates/blocks/app-shell.tsx +81 -0
  26. package/templates/blocks/data-table.tsx +117 -0
  27. package/templates/blocks/empty-state.tsx +41 -0
  28. package/templates/blocks/page-header.tsx +27 -0
  29. package/templates/blocks/stat-cards.tsx +46 -0
  30. package/templates/next-shadcn/TEMPLATE.md +53 -9
  31. package/templates/next-shadcn/_package-lock.json +1335 -148
  32. package/templates/next-shadcn/components.json +1 -1
  33. package/templates/next-shadcn/next.config.ts +2 -1
  34. package/templates/next-shadcn/package.json +4 -2
  35. package/templates/next-shadcn/presets/citrus.json +77 -0
  36. package/templates/next-shadcn/presets/graphite.json +77 -0
  37. package/templates/next-shadcn/presets/grove.json +77 -0
  38. package/templates/next-shadcn/presets/ocean.json +78 -0
  39. package/templates/next-shadcn/presets/sunset.json +77 -0
  40. package/templates/next-shadcn/presets/violet.json +77 -0
  41. package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
  42. package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +34 -22
  43. package/templates/next-shadcn/src/components/ui/avatar.tsx +7 -4
  44. package/templates/next-shadcn/src/components/ui/badge.tsx +15 -18
  45. package/templates/next-shadcn/src/components/ui/button.tsx +12 -3
  46. package/templates/next-shadcn/src/components/ui/calendar.tsx +1 -0
  47. package/templates/next-shadcn/src/components/ui/checkbox.tsx +6 -2
  48. package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
  49. package/templates/next-shadcn/src/components/ui/command.tsx +1 -2
  50. package/templates/next-shadcn/src/components/ui/dialog.tsx +34 -26
  51. package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +115 -114
  52. package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
  53. package/templates/next-shadcn/src/components/ui/input-group.tsx +2 -4
  54. package/templates/next-shadcn/src/components/ui/input.tsx +1 -2
  55. package/templates/next-shadcn/src/components/ui/label.tsx +6 -2
  56. package/templates/next-shadcn/src/components/ui/popover.tsx +27 -28
  57. package/templates/next-shadcn/src/components/ui/progress.tsx +11 -63
  58. package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
  59. package/templates/next-shadcn/src/components/ui/scroll-area.tsx +6 -6
  60. package/templates/next-shadcn/src/components/ui/select.tsx +55 -64
  61. package/templates/next-shadcn/src/components/ui/separator.tsx +6 -3
  62. package/templates/next-shadcn/src/components/ui/sheet.tsx +35 -26
  63. package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
  64. package/templates/next-shadcn/src/components/ui/switch.tsx +3 -2
  65. package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
  66. package/templates/next-shadcn/src/components/ui/tabs.tsx +16 -8
  67. package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
  68. package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
  69. package/templates/next-shadcn/src/components/ui/tooltip.tsx +24 -33
  70. package/templates/next-shadcn/src/lib/utils.ts +6 -1
  71. package/ucode.js +8 -1
@@ -25,6 +25,7 @@ const WIDTHS = [
25
25
  { name: 'desktop', width: 1440, height: 900 },
26
26
  ];
27
27
  const LOCAL = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i;
28
+ const MAX_SHOT_HEIGHT = 3000;
28
29
 
29
30
  let browserPromise = null;
30
31
 
@@ -131,7 +132,8 @@ async function review(shots) {
131
132
  role: 'system',
132
133
  content:
133
134
  'You are a senior product designer reviewing screenshots of a web app, one at a phone width ' +
134
- 'and one at desktop width. List the concrete visual problems a user would notice, most ' +
135
+ 'and one at desktop width. Each screenshot is the whole page, top to bottom, so anything not ' +
136
+ 'in it is genuinely not there. List the concrete visual problems a user would notice, most ' +
135
137
  'important first: broken or cramped layout, overflow, misalignment, weak hierarchy (is the ' +
136
138
  'most important thing the most prominent?), inconsistent spacing, low contrast, default-looking ' +
137
139
  'components, awkward empty states, text that is too small. For each: where it is, what is wrong, ' +
@@ -144,11 +146,40 @@ async function review(shots) {
144
146
  images: shots.map((s) => s.dataUrl),
145
147
  },
146
148
  ];
147
- const reply = await ask(request, [], { model: VISION_MODEL, temperature: 0.2, maxOutputTokens: 900 });
148
- return reply.text.trim();
149
+ const reply = await ask(request, [], {
150
+ model: VISION_MODEL,
151
+ temperature: 0.2,
152
+ // A reasoning model spends its budget thinking before it writes; 900
153
+ // tokens came back as an empty review. Keep the thinking short, and leave
154
+ // room for the answer.
155
+ maxOutputTokens: 4000,
156
+ reasoning: { effort: 'low' },
157
+ // The free vision model is often busy. One try, and a hard cap: a review
158
+ // that cannot run is skipped, never waited on.
159
+ attempts: 1,
160
+ signal: AbortSignal.timeout(REVIEW_BUDGET_MS),
161
+ });
162
+ const text = reply.text.trim();
163
+ if (!text) throw new Error('the vision model returned an empty review');
164
+ return text;
149
165
  }
150
166
 
151
- export async function lookAtApp({ url, paths = ['/'], review: wantReview = true }) {
167
+ const REVIEW_BUDGET_MS = 60_000;
168
+
169
+ /**
170
+ * The designer's review is the slow part — a reasoning model looking at
171
+ * screenshots, most of a minute — so each app gets one per turn: a look after
172
+ * the fixes only re-runs the fast checks. A review that failed (busy model,
173
+ * empty reply) gets one more try on the next look, then is let go.
174
+ */
175
+ const reviews = new Map(); // base URL -> { done, tries }
176
+
177
+ /** A new request from the user: the apps may be reviewed afresh. */
178
+ export function forgetReviews() {
179
+ reviews.clear();
180
+ }
181
+
182
+ export async function lookAtApp({ url, paths = ['/'] }) {
152
183
  const base = String(url ?? '').trim().replace(/\/+$/, '');
153
184
  if (!LOCAL.test(`${base}/`)) {
154
185
  throw new ToolFailure({
@@ -167,76 +198,104 @@ export async function lookAtApp({ url, paths = ['/'], review: wantReview = true
167
198
  await fs.mkdir(shotsDir, { recursive: true });
168
199
 
169
200
  const b = await browser();
170
- const sections = [];
171
- const toReview = [];
172
- let problems = 0;
173
201
 
174
- for (const pagePath of pages) {
175
- for (const size of WIDTHS) {
176
- const context = await b.newContext({ viewport: { width: size.width, height: size.height }, deviceScaleFactor: 1 });
177
- const page = await context.newPage();
178
- const errors = [];
179
- const failed = [];
180
- page.on('console', (m) => {
181
- if (m.type() === 'error' && !/devtools|download the react/i.test(m.text())) errors.push(m.text().slice(0, 200));
182
- });
183
- page.on('pageerror', (e) => errors.push(`uncaught: ${String(e.message).slice(0, 200)}`));
184
- page.on('requestfailed', (r) => failed.push(`${r.method()} ${r.url().slice(0, 100)} — ${r.failure()?.errorText ?? 'failed'}`));
185
- page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(0, 100)}`); });
202
+ // Every page at every width opens at once, each in its own context: the
203
+ // wait is for the slowest one, not the sum of them all.
204
+ const checks = await Promise.all(pages.flatMap((pagePath) => WIDTHS.map(async (size) => {
205
+ let problems = 0;
206
+ let shot = null;
207
+ const context = await b.newContext({ viewport: { width: size.width, height: size.height }, deviceScaleFactor: 1 });
208
+ const page = await context.newPage();
209
+ const errors = [];
210
+ const failed = [];
211
+ page.on('console', (m) => {
212
+ if (m.type() === 'error' && !/devtools|download the react/i.test(m.text())) errors.push(m.text().slice(0, 200));
213
+ });
214
+ page.on('pageerror', (e) => errors.push(`uncaught: ${String(e.message).slice(0, 200)}`));
215
+ page.on('requestfailed', (r) => failed.push(`${r.method()} ${r.url().slice(0, 100)} — ${r.failure()?.errorText ?? 'failed'}`));
216
+ page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(0, 100)}`); });
186
217
 
187
- const target = `${base}${pagePath}`;
188
- let loadError = null;
189
- try {
190
- // A dev server compiles a page on its first request, which can take a
191
- // while; networkidle then waits for the page's own data to arrive.
192
- await page.goto(target, { waitUntil: 'networkidle', timeout: 60_000 });
193
- } catch (err) {
194
- try { await page.goto(target, { waitUntil: 'load', timeout: 30_000 }); }
195
- catch (err2) { loadError = String(err2.message).split('\n')[0]; }
196
- }
197
- await page.waitForTimeout(600); // let entrance animations settle
218
+ const target = `${base}${pagePath}`;
219
+ let loadError = null;
220
+ try {
221
+ // 'load', not 'networkidle': a dev server holds a hot-reload
222
+ // connection open and polls, so the network may never go quiet and
223
+ // 'networkidle' would wait out its whole timeout on every page, at
224
+ // every width. 'load' covers the first-request compile; the page's own
225
+ // data then gets a short, bounded chance to settle.
226
+ await page.goto(target, { waitUntil: 'load', timeout: 90_000 });
227
+ await page.waitForLoadState('networkidle', { timeout: 1_500 }).catch(() => {});
228
+ } catch (err) {
229
+ loadError = String(err.message).split('\n')[0];
230
+ }
231
+ await page.waitForTimeout(400); // let entrance animations settle
198
232
 
199
- const file = path.join(shotsDir, `${safeName(pagePath)}-${size.name}.jpg`);
200
- let facts = null;
233
+ const file = path.join(shotsDir, `${safeName(pagePath)}-${size.name}.jpg`);
234
+ let facts = null;
235
+ try {
201
236
  if (!loadError) {
202
237
  facts = await page.evaluate(inspect).catch((err) => ({ error: err.message }));
203
- const buffer = await page.screenshot({ type: 'jpeg', quality: 70, fullPage: false });
238
+ // The whole page, so the reviewer never reports as missing what is
239
+ // only below the fold — capped, so an endless feed stays one image.
240
+ const tall = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);
241
+ const buffer = await page.screenshot({
242
+ type: 'jpeg',
243
+ quality: 70,
244
+ fullPage: true,
245
+ ...(tall > MAX_SHOT_HEIGHT ? { clip: { x: 0, y: 0, width: size.width, height: MAX_SHOT_HEIGHT } } : {}),
246
+ });
204
247
  await fs.writeFile(file, buffer);
205
- toReview.push({ label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` });
248
+ shot = { label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` };
206
249
  }
207
- await context.close();
250
+ } catch (err) {
251
+ loadError = `the page broke while being checked: ${String(err.message).split('\n')[0]}`;
252
+ } finally {
253
+ await context.close().catch(() => {});
254
+ }
208
255
 
209
- const lines = [`### ${pagePath} at ${size.width}px (${size.name})`];
210
- if (loadError) {
211
- lines.push(`Could not load: ${loadError}`);
256
+ const lines = [`### ${pagePath} at ${size.width}px (${size.name})`];
257
+ if (loadError) {
258
+ lines.push(`Could not load: ${loadError}`);
259
+ problems++;
260
+ } else {
261
+ lines.push(`Screenshot: ${path.relative(getRoot(), file).split(path.sep).join('/')}`);
262
+ if (facts?.empty) { lines.push('- The page rendered no visible text at all.'); problems++; }
263
+ if (facts?.overflow) {
264
+ lines.push(`- Content is ${facts.overflow}px wider than the screen, so it scrolls sideways:`, ...facts.wide.map((w) => ` - ${w}`));
212
265
  problems++;
213
- } else {
214
- lines.push(`Screenshot: ${path.relative(getRoot(), file).split(path.sep).join('/')}`);
215
- if (facts?.empty) { lines.push('- The page rendered no visible text at all.'); problems++; }
216
- if (facts?.overflow) {
217
- lines.push(`- Content is ${facts.overflow}px wider than the screen, so it scrolls sideways:`, ...facts.wide.map((w) => ` - ${w}`));
218
- problems++;
219
- }
220
- if (facts?.broken?.length) { lines.push(`- Broken images: ${facts.broken.join(', ')}`); problems++; }
221
- if (facts?.unnamed?.length) { lines.push(`- Buttons or links with no accessible name: ${facts.unnamed.join(', ')}`); problems++; }
222
- if (facts?.inputsNoLabel) { lines.push(`- ${facts.inputsNoLabel} form field(s) without a label.`); problems++; }
223
- if (facts?.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
224
- if (facts?.tiny) lines.push(`- ${facts.tiny} tap target(s) smaller than 32px on a phone.`);
225
- if (facts?.smallText) lines.push(`- ${facts.smallText} text element(s) under 12px.`);
226
266
  }
227
- if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
228
- if (failed.length) { lines.push('- Failed requests:', ...[...new Set(failed)].slice(0, 6).map((f) => ` - ${f}`)); problems++; }
229
- if (lines.length === 2 && !loadError) lines.push('- No errors, no overflow, nothing unlabeled.');
230
- sections.push(lines.join('\n'));
267
+ if (facts?.broken?.length) { lines.push(`- Broken images: ${facts.broken.join(', ')}`); problems++; }
268
+ if (facts?.unnamed?.length) { lines.push(`- Buttons or links with no accessible name: ${facts.unnamed.join(', ')}`); problems++; }
269
+ if (facts?.inputsNoLabel) { lines.push(`- ${facts.inputsNoLabel} form field(s) without a label.`); problems++; }
270
+ if (facts?.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
271
+ if (facts?.tiny) lines.push(`- ${facts.tiny} tap target(s) smaller than 32px on a phone.`);
272
+ if (facts?.smallText) lines.push(`- ${facts.smallText} text element(s) under 12px.`);
231
273
  }
232
- }
274
+ if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
275
+ if (failed.length) { lines.push('- Failed requests:', ...[...new Set(failed)].slice(0, 6).map((f) => ` - ${f}`)); problems++; }
276
+ if (lines.length === 2 && !loadError) lines.push('- No errors, no overflow, nothing unlabeled.');
277
+ const broken = Boolean(loadError || errors.length || facts?.empty);
278
+ return { section: lines.join('\n'), shot, problems, broken };
279
+ })));
280
+
281
+ const sections = checks.map((c) => c.section);
282
+ const toReview = checks.map((c) => c.shot).filter(Boolean);
283
+ const problems = checks.reduce((n, c) => n + c.problems, 0);
284
+ const broken = checks.some((c) => c.broken);
233
285
 
286
+ // A page that crashed or threw is fixed first; reviewing a screenshot of an
287
+ // error overlay is a minute spent on nothing.
234
288
  let critique = '';
235
- if (wantReview !== false && toReview.length) {
289
+ const state = reviews.get(base) ?? { done: false, tries: 0 };
290
+ if (!broken && !state.done && state.tries < 2 && toReview.length) {
291
+ state.tries++;
292
+ reviews.set(base, state);
236
293
  try {
237
294
  critique = await review(toReview.slice(0, 4));
295
+ state.done = true;
238
296
  } catch (err) {
239
- critique = `(The visual review could not run: ${err.failed ?? err.message}. The checks above still apply.)`;
297
+ const why = String(err.failed ?? err.message).replace(/[.\s]+$/, '');
298
+ critique = `(The visual review could not run: ${why}. The checks above still apply.)`;
240
299
  }
241
300
  }
242
301
 
@@ -246,7 +305,10 @@ export async function lookAtApp({ url, paths = ['/'], review: wantReview = true
246
305
  '',
247
306
  problems
248
307
  ? 'Fix the problems above, then look again to confirm.'
249
- : 'The automatic checks found nothing. Weigh the visual review, fix what is worth fixing.',
308
+ : state.done && critique
309
+ ? 'The automatic checks found nothing. Weigh the visual review, fix what is worth fixing - ' +
310
+ 'the next look re-runs only the fast checks.'
311
+ : 'The automatic checks found nothing.',
250
312
  ].filter(Boolean).join('\n\n');
251
313
 
252
314
  return result(
@@ -0,0 +1,105 @@
1
+ /**
2
+ * cache.js — the starter's packages, installed once and reused.
3
+ *
4
+ * A new app's `npm install` is a minute of the model waiting on the network
5
+ * for a tree it has installed a hundred times before. The first install of a
6
+ * starter is kept in ~/.ucode/cache, keyed by the lockfile, and every app
7
+ * after that gets it as hard links: the same files on disk under a new name,
8
+ * so it costs no extra space and finishes in seconds.
9
+ *
10
+ * Hard links rather than a junction or a symlink, because Turbopack refuses a
11
+ * node_modules that points outside the project. Anything that cannot be linked
12
+ * — another drive, a filesystem without links — is copied instead, and if even
13
+ * that fails the normal install runs as before.
14
+ *
15
+ * A cache entry is only used once its marker file is there, which is written
16
+ * last: a half-written cache from an interrupted copy is ignored, not used.
17
+ *
18
+ * The key is the *starter's* lockfile, never the app's. npm rewrites an app's
19
+ * package-lock.json as it installs, so keying on that would file the cache
20
+ * under one name and look it up under another — a cache that never hits and
21
+ * never says why.
22
+ */
23
+
24
+ import { promises as fs } from 'node:fs';
25
+ import { createHash } from 'node:crypto';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+
29
+ const MARKER = '.ucode-complete';
30
+
31
+ /** Written into node_modules by build tools; never worth carrying between apps. */
32
+ const NOT_WORTH_KEEPING = new Set(['.cache', '.vite', '.turbo']);
33
+
34
+ export const cacheRoot = (home = os.homedir()) => path.join(home, '.ucode', 'cache');
35
+
36
+ /** Where this starter's install lives, given its lockfile text. */
37
+ export function slotFor(template, lockText, home) {
38
+ const hash = createHash('sha1').update(String(lockText ?? '')).digest('hex').slice(0, 12);
39
+ return path.join(cacheRoot(home), `${template}-${hash}`);
40
+ }
41
+
42
+ /**
43
+ * Copy a tree as hard links, falling back to real copies where linking fails.
44
+ * `skip` names top-level entries to leave behind.
45
+ */
46
+ export async function linkTree(from, to, skip = null) {
47
+ let files = 0;
48
+ const walk = async (src, dst, top) => {
49
+ await fs.mkdir(dst, { recursive: true });
50
+ for (const entry of await fs.readdir(src, { withFileTypes: true })) {
51
+ if (top && skip?.has(entry.name)) continue;
52
+ const a = path.join(src, entry.name);
53
+ const b = path.join(dst, entry.name);
54
+ if (entry.isDirectory()) {
55
+ await walk(a, b, false);
56
+ } else if (entry.isSymbolicLink()) {
57
+ const target = await fs.readlink(a).catch(() => null);
58
+ if (target) await fs.symlink(target, b).catch(() => fs.copyFile(a, b).catch(() => {}));
59
+ } else {
60
+ await fs.link(a, b).catch(() => fs.copyFile(a, b));
61
+ files++;
62
+ }
63
+ }
64
+ };
65
+ await walk(from, to, true);
66
+ return files;
67
+ }
68
+
69
+ /**
70
+ * Put the cached packages into the app, if this starter has been installed
71
+ * before. Returns how many files were linked, or 0 when there is no cache.
72
+ */
73
+ export async function restore(appDir, template, lockText, { home } = {}) {
74
+ if (!lockText) return 0;
75
+ const slot = slotFor(template, lockText, home);
76
+ if (!(await fs.stat(path.join(slot, MARKER)).catch(() => null))) return 0;
77
+ const target = path.join(appDir, 'node_modules');
78
+ if (await fs.stat(target).catch(() => null)) return 0; // already installed
79
+ try {
80
+ return await linkTree(path.join(slot, 'node_modules'), target);
81
+ } catch {
82
+ await fs.rm(target, { recursive: true, force: true }).catch(() => {});
83
+ return 0; // a partial restore is worse than none: let npm do it
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Keep this app's node_modules as the cache for its starter, so the next app
89
+ * starts from it. Runs after a successful install and never throws.
90
+ */
91
+ export async function populate(appDir, template, lockText, { home } = {}) {
92
+ try {
93
+ if (!lockText) return false;
94
+ const slot = slotFor(template, lockText, home);
95
+ if (await fs.stat(path.join(slot, MARKER)).catch(() => null)) return false;
96
+ const from = path.join(appDir, 'node_modules');
97
+ if (!(await fs.stat(from).catch(() => null))) return false;
98
+ await fs.rm(slot, { recursive: true, force: true }).catch(() => {});
99
+ await linkTree(from, path.join(slot, 'node_modules'), NOT_WORTH_KEEPING);
100
+ await fs.writeFile(path.join(slot, MARKER), `${template}\n${new Date().toISOString()}\n`);
101
+ return true;
102
+ } catch {
103
+ return false; // the cache is an optimisation; never let it break a build
104
+ }
105
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * deploy.js — put the app online on Vercel.
3
+ *
4
+ * The user says "deploy it" (or types /deploy) and gets a live link. ucode
5
+ * picks a short project name that fits the app and is actually free, creates
6
+ * or reuses the Vercel project, copies the app's .env keys to it as encrypted
7
+ * variables, refuses to upload code with a secret written into it, and hands
8
+ * the upload and build to the Vercel CLI — which knows every framework's
9
+ * build settings better than anything written here would.
10
+ *
11
+ * The token comes from VERCEL_TOKEN (~/.ucode/.env). It is never printed:
12
+ * anything the CLI says is scrubbed of it before it reaches the screen.
13
+ */
14
+
15
+ import { spawn } from 'node:child_process';
16
+ import { promises as fs, existsSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { ToolFailure } from '../core/failure.js';
19
+ import { ENV_FILE } from '../core/provider.js';
20
+ import { resolveIn, result } from './shared.js';
21
+ import { childEnv } from './shell.js';
22
+
23
+ const API = 'https://api.vercel.com';
24
+ const DEPLOY_TIMEOUT = 12 * 60_000;
25
+ let fetchImpl = (...a) => fetch(...a);
26
+
27
+ /** Tests hand in a fake fetch. */
28
+ export function setFetch(f) { fetchImpl = f ?? ((...a) => fetch(...a)); }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Names
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** "Food IQ" → "food-iq": lowercase, a-z 0-9 and dashes, short, cut at a word. */
35
+ export function slugify(text, max = 20) {
36
+ let s = String(text ?? '').toLowerCase().replace(/^@[^/]+\//, '')
37
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
38
+ if (s.length > max) {
39
+ const cut = s.slice(0, max);
40
+ s = cut.includes('-') ? cut.slice(0, cut.lastIndexOf('-')) : cut;
41
+ }
42
+ return s || 'app';
43
+ }
44
+
45
+ /** Candidates in order of preference: the plain name first, then tidy variants. */
46
+ export function nameCandidates(base) {
47
+ const b = slugify(base);
48
+ const words = ['app', 'web', 'live', 'hq', 'hub'];
49
+ const rand = () => Math.random().toString(36).slice(2, 6);
50
+ return [b, ...words.map((w) => `${b}-${w}`), b.replace(/-/g, ''), `${b}-${rand()}`, `${b}-${rand()}`]
51
+ .filter((v, i, a) => v && a.indexOf(v) === i);
52
+ }
53
+
54
+ /**
55
+ * Is <name>.vercel.app free? Not a project of this account, and no one
56
+ * else's deployment answers at that address.
57
+ */
58
+ async function available(name, ctx) {
59
+ const mine = await ctx.api(`/v9/projects/${name}`);
60
+ if (mine.status === 200) return false;
61
+ try {
62
+ const r = await fetchImpl(`https://${name}.vercel.app`, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(8000) });
63
+ return r.status === 404 && /DEPLOYMENT_NOT_FOUND|NOT_FOUND/i.test(r.headers.get('x-vercel-error') ?? 'NOT_FOUND');
64
+ } catch {
65
+ return true; // nothing answered at all — the name is unclaimed
66
+ }
67
+ }
68
+
69
+ export async function pickName(base, ctx) {
70
+ for (const candidate of nameCandidates(base)) {
71
+ if (await available(candidate, ctx)) return candidate;
72
+ }
73
+ throw new ToolFailure({
74
+ kind: 'no_name', attempted: 'choosing a project name',
75
+ failed: `Every name tried for "${base}" is taken.`,
76
+ fix: 'Call deploy again with a different name.',
77
+ });
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Secrets and env
82
+ // ---------------------------------------------------------------------------
83
+
84
+ const SECRET_PATTERNS = [
85
+ [/sk-or-v1-[0-9a-f]{20,}/, 'an API key (sk-or-v1-…)'],
86
+ [/\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{32,}/, 'an API key (sk-…)'],
87
+ [/\bvcp_[A-Za-z0-9]{20,}/, 'a Vercel token'],
88
+ [/\bAKIA[0-9A-Z]{16}\b/, 'an AWS access key'],
89
+ [/\bgh[pousr]_[A-Za-z0-9]{30,}/, 'a GitHub token'],
90
+ [/\bAIza[0-9A-Za-z_-]{35}\b/, 'a Google API key'],
91
+ ];
92
+ const SKIP_DIRS = new Set(['node_modules', '.next', '.git', '.vercel', 'dist', 'build', 'out', '.ucode', '.turbo']);
93
+ const SCAN_EXT = /\.(?:[cm]?[jt]sx?|html?|vue|svelte|astro|json)$/i;
94
+
95
+ /** Files with a secret written into them. `.env*` files are the right place, so they are skipped. */
96
+ export async function scanSecrets(dir) {
97
+ const found = [];
98
+ async function walk(d) {
99
+ for (const entry of await fs.readdir(d, { withFileTypes: true }).catch(() => [])) {
100
+ if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) await walk(path.join(d, entry.name)); continue; }
101
+ if (entry.name.startsWith('.env') || entry.name === 'package-lock.json' || !SCAN_EXT.test(entry.name)) continue;
102
+ const file = path.join(d, entry.name);
103
+ const text = await fs.readFile(file, 'utf8').catch(() => '');
104
+ if (text.length > 1_000_000) continue;
105
+ const lines = text.split('\n');
106
+ for (let i = 0; i < lines.length; i++) {
107
+ const hit = SECRET_PATTERNS.find(([re]) => re.test(lines[i]));
108
+ if (hit) found.push({ file: path.relative(dir, file).split(path.sep).join('/'), line: i + 1, what: hit[1] });
109
+ }
110
+ }
111
+ }
112
+ await walk(dir);
113
+ return found;
114
+ }
115
+
116
+ /** KEY=value pairs from .env, .env.local, .env.production — later files win. */
117
+ export async function readEnv(dir) {
118
+ const vars = {};
119
+ for (const name of ['.env', '.env.local', '.env.production']) {
120
+ const text = await fs.readFile(path.join(dir, name), 'utf8').catch(() => '');
121
+ for (const line of text.split(/\r?\n/)) {
122
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
123
+ if (!m) continue;
124
+ vars[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
125
+ }
126
+ }
127
+ return vars;
128
+ }
129
+
130
+ function frameworkOf(pkg, dir) {
131
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
132
+ if (deps.next) return 'nextjs';
133
+ if (deps.vite) return 'vite';
134
+ if (deps['react-scripts']) return 'create-react-app';
135
+ if (!pkg && existsSync(path.join(dir, 'index.html'))) return null;
136
+ return null;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // The tool
141
+ // ---------------------------------------------------------------------------
142
+
143
+ export async function deploy({ folder = '.', name } = {}, { onOutput } = {}) {
144
+ const token = process.env.VERCEL_TOKEN;
145
+ if (!token) {
146
+ throw new ToolFailure({
147
+ kind: 'no_token', attempted: 'deploying to Vercel',
148
+ failed: 'There is no Vercel token yet.',
149
+ fix: `Tell the user: make one at vercel.com/account/tokens (Create Token), then add a line VERCEL_TOKEN=... to ${ENV_FILE} and restart ucode.`,
150
+ });
151
+ }
152
+ const say = (line) => onOutput?.([line]);
153
+ const where = resolveIn(folder, 'deploy', 'folder');
154
+ const dir = where.abs;
155
+ const pkg = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8').catch(() => 'null'));
156
+ if (!pkg && !existsSync(path.join(dir, 'index.html'))) {
157
+ throw new ToolFailure({
158
+ kind: 'not_an_app', attempted: `deploying ${where.show}`,
159
+ failed: `${where.show} has no package.json or index.html, so there is nothing to deploy.`,
160
+ fix: 'Pass the folder of the app itself, e.g. deploy({ folder: "food-iq" }).',
161
+ });
162
+ }
163
+
164
+ const scrub = (s) => String(s).split(token).join('***');
165
+ const api = async (pathname, { method = 'GET', body } = {}) => {
166
+ const url = new URL(API + pathname);
167
+ if (ctx.teamId) url.searchParams.set('teamId', ctx.teamId);
168
+ const r = await fetchImpl(url, {
169
+ method, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(20_000),
170
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
171
+ });
172
+ return { status: r.status, ok: r.ok, json: await r.json().catch(() => ({})) };
173
+ };
174
+ const ctx = { api, teamId: null };
175
+
176
+ say('Checking for secrets');
177
+ const leaks = await scanSecrets(dir);
178
+ if (leaks.length) {
179
+ const list = leaks.slice(0, 5).map((l) => `${l.file}:${l.line} (${l.what})`).join(', ');
180
+ throw new ToolFailure({
181
+ kind: 'secret_in_code', attempted: `deploying ${where.show}`,
182
+ failed: `The code has a secret written into it: ${list}. Once online, anyone could read it.`,
183
+ fix: 'Move the value to .env.local (e.g. OPENROUTER_API_KEY=...), read it with process.env in a ' +
184
+ 'server route (Next.js: app/api/.../route.ts), call that route from the page, then deploy again. ' +
185
+ 'ucode copies .env.local to Vercel for you.',
186
+ });
187
+ }
188
+
189
+ const me = await api('/v2/user');
190
+ if (!me.ok) {
191
+ throw new ToolFailure({
192
+ kind: 'bad_token', attempted: 'deploying to Vercel', failed: 'Vercel did not accept the token.',
193
+ fix: 'Tell the user to make a new token at vercel.com/account/tokens and put it in ~/.ucode/.env as VERCEL_TOKEN.',
194
+ });
195
+ }
196
+ ctx.teamId = me.json.user?.defaultTeamId ?? null;
197
+ const orgId = ctx.teamId ?? me.json.user?.id;
198
+
199
+ // Reuse the project this app was deployed to before, so the link stays the same.
200
+ const linkFile = path.join(dir, '.vercel', 'project.json');
201
+ let link = JSON.parse(await fs.readFile(linkFile, 'utf8').catch(() => 'null'));
202
+ if (link?.projectId) {
203
+ const still = await api(`/v9/projects/${link.projectId}`);
204
+ if (!still.ok) link = null;
205
+ else link.projectName = still.json.name;
206
+ }
207
+ if (!link?.projectId) {
208
+ say('Choosing a name');
209
+ const projectName = name ? slugify(name) : await pickName(pkg?.name || path.basename(dir), ctx);
210
+ const made = await api('/v11/projects', { method: 'POST', body: { name: projectName, framework: frameworkOf(pkg, dir) } });
211
+ if (!made.ok) {
212
+ throw new ToolFailure({
213
+ kind: 'vercel_error', attempted: `creating the Vercel project ${projectName}`,
214
+ failed: scrub(made.json?.error?.message ?? `HTTP ${made.status}`),
215
+ fix: 'Try again with a different name.',
216
+ });
217
+ }
218
+ link = { projectId: made.json.id, orgId, projectName };
219
+ await fs.mkdir(path.dirname(linkFile), { recursive: true });
220
+ await fs.writeFile(linkFile, JSON.stringify({ projectId: link.projectId, orgId }, null, 2));
221
+ const gi = path.join(dir, '.gitignore');
222
+ const ignore = await fs.readFile(gi, 'utf8').catch(() => '');
223
+ if (!/^\.vercel\/?$/m.test(ignore)) await fs.writeFile(gi, `${ignore.replace(/\n?$/, '\n')}.vercel\n`).catch(() => {});
224
+ }
225
+
226
+ const vars = await readEnv(dir);
227
+ const keys = Object.keys(vars);
228
+ if (keys.length) {
229
+ say(`Copying ${keys.length} key${keys.length === 1 ? '' : 's'} to Vercel`);
230
+ await api(`/v10/projects/${link.projectId}/env?upsert=true`, {
231
+ method: 'POST',
232
+ body: keys.map((key) => ({ key, value: vars[key], type: 'encrypted', target: ['production', 'preview'] })),
233
+ });
234
+ }
235
+
236
+ say('Uploading and building on Vercel');
237
+ const started = Date.now();
238
+ const { code, output } = await new Promise((resolve) => {
239
+ const args = ['--yes', 'vercel@latest', 'deploy', '--prod', '--yes', '--token', token];
240
+ const child = spawn(process.platform === 'win32' ? 'npx.cmd' : 'npx', args, {
241
+ cwd: dir, shell: process.platform === 'win32', windowsHide: true,
242
+ env: { ...childEnv(), VERCEL_ORG_ID: orgId, VERCEL_PROJECT_ID: link.projectId, VERCEL_TELEMETRY_DISABLED: '1' },
243
+ });
244
+ let output = '';
245
+ const take = (chunk) => {
246
+ const text = scrub(chunk).replace(/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g, '');
247
+ output += text;
248
+ // Only lines that read as progress: no markup, no lone braces, no redraw leftovers.
249
+ const last = text.split(/\r?\n/).map((l) => l.replace(/\[[0-9;]*[A-Za-z]/g, '').trim())
250
+ .filter((l) => /[A-Za-z]{3}/.test(l) && !/^[<{}[\]]/.test(l)).at(-1);
251
+ if (last) say(last.replace(/^[▲✓>\s]+/, '').replace(/\s*\[\d+s\]$/, '').slice(0, 80));
252
+ };
253
+ child.stdout.on('data', take);
254
+ child.stderr.on('data', take);
255
+ const timer = setTimeout(() => child.kill(), DEPLOY_TIMEOUT);
256
+ child.on('error', (err) => { clearTimeout(timer); resolve({ code: -1, output: `${output}\n${err.message}` }); });
257
+ child.on('close', (c) => { clearTimeout(timer); resolve({ code: c, output }); });
258
+ });
259
+ const secs = Math.round((Date.now() - started) / 1000);
260
+
261
+ if (code !== 0) {
262
+ const tail = output.split(/\r?\n/).filter((l) => l.trim()).slice(-25).join('\n');
263
+ throw new ToolFailure({
264
+ kind: 'deploy_failed', attempted: `deploying ${where.show} to Vercel`,
265
+ failed: `The deploy failed after ${secs}s:\n${tail}`,
266
+ fix: 'The lines above name the problem — usually the same error `npm run build` shows locally. ' +
267
+ 'Fix it, check the build passes, then deploy again.',
268
+ });
269
+ }
270
+
271
+ const live = `https://${link.projectName}.vercel.app`;
272
+ const ok = await fetchImpl(live, { signal: AbortSignal.timeout(15_000) }).then((r) => r.status < 400).catch(() => false);
273
+ const url = ok ? live : (output.match(/https:\/\/[a-z0-9.-]+\.vercel\.app/g) ?? [live]).at(-1);
274
+ return result(
275
+ [
276
+ `Live at ${url}`,
277
+ `Deployed in ${secs}s as the Vercel project "${link.projectName}".`,
278
+ keys.length ? `Copied to Vercel as encrypted variables: ${keys.join(', ')}` : '',
279
+ 'Deploying again updates the same link.',
280
+ ].filter(Boolean).join('\n'),
281
+ `live · ${url} · ${secs}s`
282
+ );
283
+ }