ucode-agent 1.16.0 → 1.17.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA and Cohere models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
package/src/core/loop.js CHANGED
@@ -451,6 +451,13 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
451
451
  'believes you, looks, and finds it broken. Finish, check, then say so — and if',
452
452
  'something is incomplete, say which part and why.',
453
453
  '',
454
+ 'BE FAST. Every tool call is a round trip, and round trips are nearly all of the',
455
+ 'time a build takes. So: write a whole app in ONE batch_write rather than a',
456
+ 'write_file per file. Read every file you need in ONE read_files. Never read a',
457
+ 'file you just wrote, and never read one back after edit_file — the result',
458
+ 'already contains it. Do not re-check work the checks have already reported on.',
459
+ 'Fast is not sloppy: it is the same work with the waiting taken out.',
460
+ '',
454
461
  'FIRST, EVERY TIME: one short line saying what you are about to do, then the tool',
455
462
  'calls. Never open a turn with a tool call and no words. "Right, the HTML',
456
463
  'structure first." / "Now the state and the render loop." / "That is the layout',
@@ -529,7 +536,6 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
529
536
  ' reports - errors, layout that overflows a phone, the review points worth fixing -',
530
537
  ' in one pass, then look once more. A clean second look means it is done: report',
531
538
  ' back instead of polishing in circles. Never call an interface finished unlooked at.',
532
- '- Asked to deploy, publish, host or share the app? Use the deploy tool - it picks the',
533
539
  ' name, handles keys and returns the live link. Build locally first.',
534
540
  '- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
535
541
  ' would ask a question, or it fails instead of waiting: create-next-app --yes,',
@@ -1,283 +1,299 @@
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
- }
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
+ // A folder with an index.html and no package.json is a static site: every
227
+ // file in it is the site, and there is nothing to build. Saying so outright
228
+ // stops Vercel guessing at a build step and shipping only what that produced
229
+ // which is how a page arrives online with its stylesheet missing.
230
+ const staticSite = !pkg && existsSync(path.join(dir, 'index.html'));
231
+ if (staticSite) {
232
+ const config = path.join(dir, 'vercel.json');
233
+ if (!existsSync(config)) {
234
+ await fs.writeFile(config, JSON.stringify({ buildCommand: null, outputDirectory: '.' }, null, 2) + '\n');
235
+ }
236
+ const sending = (await fs.readdir(dir, { withFileTypes: true }))
237
+ .filter((e) => e.isFile() && !e.name.startsWith('.') && e.name !== 'vercel.json')
238
+ .map((e) => e.name);
239
+ say(`Sending ${sending.length} file${sending.length === 1 ? '' : 's'}: ${sending.slice(0, 8).join(', ')}`);
240
+ }
241
+
242
+ const vars = await readEnv(dir);
243
+ const keys = Object.keys(vars);
244
+ if (keys.length) {
245
+ say(`Copying ${keys.length} key${keys.length === 1 ? '' : 's'} to Vercel`);
246
+ await api(`/v10/projects/${link.projectId}/env?upsert=true`, {
247
+ method: 'POST',
248
+ body: keys.map((key) => ({ key, value: vars[key], type: 'encrypted', target: ['production', 'preview'] })),
249
+ });
250
+ }
251
+
252
+ say('Uploading and building on Vercel');
253
+ const started = Date.now();
254
+ const { code, output } = await new Promise((resolve) => {
255
+ const args = ['--yes', 'vercel@latest', 'deploy', '--prod', '--yes', '--token', token];
256
+ const child = spawn(process.platform === 'win32' ? 'npx.cmd' : 'npx', args, {
257
+ cwd: dir, shell: process.platform === 'win32', windowsHide: true,
258
+ env: { ...childEnv(), VERCEL_ORG_ID: orgId, VERCEL_PROJECT_ID: link.projectId, VERCEL_TELEMETRY_DISABLED: '1' },
259
+ });
260
+ let output = '';
261
+ const take = (chunk) => {
262
+ const text = scrub(chunk).replace(/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g, '');
263
+ output += text;
264
+ // Only lines that read as progress: no markup, no lone braces, no redraw leftovers.
265
+ const last = text.split(/\r?\n/).map((l) => l.replace(/\[[0-9;]*[A-Za-z]/g, '').trim())
266
+ .filter((l) => /[A-Za-z]{3}/.test(l) && !/^[<{}[\]]/.test(l)).at(-1);
267
+ if (last) say(last.replace(/^[▲✓>\s]+/, '').replace(/\s*\[\d+s\]$/, '').slice(0, 80));
268
+ };
269
+ child.stdout.on('data', take);
270
+ child.stderr.on('data', take);
271
+ const timer = setTimeout(() => child.kill(), DEPLOY_TIMEOUT);
272
+ child.on('error', (err) => { clearTimeout(timer); resolve({ code: -1, output: `${output}\n${err.message}` }); });
273
+ child.on('close', (c) => { clearTimeout(timer); resolve({ code: c, output }); });
274
+ });
275
+ const secs = Math.round((Date.now() - started) / 1000);
276
+
277
+ if (code !== 0) {
278
+ const tail = output.split(/\r?\n/).filter((l) => l.trim()).slice(-25).join('\n');
279
+ throw new ToolFailure({
280
+ kind: 'deploy_failed', attempted: `deploying ${where.show} to Vercel`,
281
+ failed: `The deploy failed after ${secs}s:\n${tail}`,
282
+ fix: 'The lines above name the problem — usually the same error `npm run build` shows locally. ' +
283
+ 'Fix it, check the build passes, then deploy again.',
284
+ });
285
+ }
286
+
287
+ const live = `https://${link.projectName}.vercel.app`;
288
+ const ok = await fetchImpl(live, { signal: AbortSignal.timeout(15_000) }).then((r) => r.status < 400).catch(() => false);
289
+ const url = ok ? live : (output.match(/https:\/\/[a-z0-9.-]+\.vercel\.app/g) ?? [live]).at(-1);
290
+ return result(
291
+ [
292
+ `Live at ${url}`,
293
+ `Deployed in ${secs}s as the Vercel project "${link.projectName}".`,
294
+ keys.length ? `Copied to Vercel as encrypted variables: ${keys.join(', ')}` : '',
295
+ 'Deploying again updates the same link.',
296
+ ].filter(Boolean).join('\n'),
297
+ `live · ${url} · ${secs}s`
298
+ );
299
+ }
@@ -54,6 +54,31 @@ export const lookAtAppTool = {
54
54
  },
55
55
  };
56
56
 
57
+ /**
58
+ * Deploying is not the model's to decide.
59
+ *
60
+ * Put something online and it is online: a link exists, someone may have it,
61
+ * and undoing that is not the same as undoing a file. It happens when the
62
+ * user says /deploy, and at no other time.
63
+ */
64
+ export const deployTool = {
65
+ name: 'deploy',
66
+ description:
67
+ 'Put an app online on Vercel and get its live link - use it when the user asks to deploy, ' +
68
+ 'publish, host or share the app. ucode picks a short free project name, copies the app\'s ' +
69
+ '.env keys to Vercel as encrypted variables, refuses code with a secret written into it ' +
70
+ '(move it to .env.local and a server route, then deploy again), and builds on Vercel. ' +
71
+ 'Run the local build first so errors show up here. Deploying again updates the same link.',
72
+ parameters: {
73
+ type: 'object',
74
+ properties: {
75
+ folder: str('The app folder, relative to the project root, e.g. "food-iq". Defaults to ".".'),
76
+ name: str('Optional: a project name to use instead of the one ucode would choose.'),
77
+ },
78
+ required: [],
79
+ },
80
+ };
81
+
57
82
  export const tools = [
58
83
  {
59
84
  name: 'create_app',
@@ -96,23 +121,6 @@ export const tools = [
96
121
  required: ['folder', 'name'],
97
122
  },
98
123
  },
99
- {
100
- name: 'deploy',
101
- description:
102
- 'Put an app online on Vercel and get its live link - use it when the user asks to deploy, ' +
103
- 'publish, host or share the app. ucode picks a short free project name, copies the app\'s ' +
104
- '.env keys to Vercel as encrypted variables, refuses code with a secret written into it ' +
105
- '(move it to .env.local and a server route, then deploy again), and builds on Vercel. ' +
106
- 'Run the local build first so errors show up here. Deploying again updates the same link.',
107
- parameters: {
108
- type: 'object',
109
- properties: {
110
- folder: str('The app folder, relative to the project root, e.g. "food-iq". Defaults to ".".'),
111
- name: str('Optional: a project name to use instead of the one ucode would choose.'),
112
- },
113
- required: [],
114
- },
115
- },
116
124
  {
117
125
  name: 'read_file',
118
126
  description:
package/src/ui/screen.js CHANGED
@@ -101,7 +101,7 @@ const title = (t) => `${ESC}]0;${t}\x07`;
101
101
  * Fixed rows below the header: the gap under it, the gap above the input box,
102
102
  * the input box's two borders, the blank row inside it, and the status row.
103
103
  */
104
- const CHROME_BELOW = 8;
104
+ const CHROME_BELOW = 6;
105
105
 
106
106
  /** How long one sentence of reasoning holds the line before the next takes it. */
107
107
  const THOUGHT_HOLD_MS = 1100;
@@ -285,6 +285,11 @@ export class Screen {
285
285
  this.add(boxRow(` ${lead} ${chalk.white(row)}`, width, edge));
286
286
  });
287
287
  this.add(boxBottom(width, edge));
288
+ // Room between what you asked for and what came back. Without it the reply
289
+ // starts against the bottom of your own message and the two read as one
290
+ // block of text.
291
+ this.add('');
292
+ this.add('');
288
293
  this.render();
289
294
  }
290
295
 
@@ -543,65 +548,35 @@ export class Screen {
543
548
  // and the transcript gets one line afterwards saying how long it took.
544
549
 
545
550
  /**
546
- * The model's reasoning, live, one line at a time.
551
+ * The first thing the model says, as soon as it has said it.
547
552
  *
548
- * Models reach for a tool before they say anything, so the first words of a
549
- * step were arriving a minute after it began while the reasoning channel
550
- * had been streaming words the whole time and we were throwing them away to
551
- * keep a timer. Its latest sentence now shows on one line that rewrites
552
- * itself, which is something to read from the first second.
553
+ * Models reach for a tool before writing any reply, so nothing appeared for
554
+ * the first minute of a step. The reasoning channel streams from the first
555
+ * moment, so its opening sentence goes up as one line and stays there: what
556
+ * it is setting out to do, which is the thing worth knowing while you wait.
553
557
  *
554
- * It is scaffolding, not the answer: it shimmers while it is live and it is
555
- * taken off the screen the moment the real reply starts.
558
+ * Written once, never rewritten. Rewriting it as more arrived was the
559
+ * flicker an unfinished sentence showed as a single word, then jumped.
556
560
  */
557
561
  thinkingDelta(text = '') {
558
562
  if (this.thoughtSince === undefined) this.thoughtSince = Date.now();
559
- if (!text) return;
563
+ if (!text || this.openedWith) return;
560
564
 
561
- this.thought = ((this.thought ?? '') + text).slice(-2000);
562
- // The last sentence it has finished, or what it has written of the next.
563
- const parts = this.thought.split(/(?<=[.!?])\s+|(?<=[.!?])(?=[A-Z])/).filter((p) => p.trim());
564
- const latest = (parts[parts.length - 1] ?? '').replace(/\s+/g, ' ').trim();
565
- if (!latest) return;
565
+ this.thought = ((this.thought ?? '') + text).slice(0, 600);
566
+ const tidy = this.thought.replace(/\s+/g, ' ').trim();
567
+ const finished = /^(.+?[.!?])(?:\s|$)/.exec(tidy);
568
+ if (!finished) return;
566
569
 
567
- // A sentence that is replaced the instant the next one arrives cannot be
568
- // read — it flashes. Each one holds the line for long enough to take in,
569
- // and whatever arrived meanwhile shows when its turn comes.
570
- const now = Date.now();
571
- if (latest !== this.shownThought) {
572
- if (this.shownThought !== undefined && now - (this.shownAt ?? 0) < THOUGHT_HOLD_MS) return;
573
- this.shownThought = latest;
574
- this.shownAt = now;
575
- }
570
+ const line = finished[1].trim();
571
+ if (line.length < 12) return; // "Okay." tells nobody anything
576
572
 
577
- const line = ` ${shimmer(clip(this.shownThought, Math.max(20, this.width() - 6)), this.tick * FRAME_MS)}`;
578
- if (this.thinkAt === undefined || this.lines[this.thinkAt] === undefined) {
579
- this.thinkAt = this.lines.length;
580
- this.push(line);
581
- } else {
582
- this.lines[this.thinkAt] = line;
583
- this.render();
584
- }
585
- }
586
-
587
- /** Keep the live thought moving between deltas. */
588
- paintLiveThought() {
589
- if (this.thinkAt !== undefined && this.lines[this.thinkAt] !== undefined) this.thinkingDelta('');
573
+ this.openedWith = line;
574
+ this.push(narration(' ' + clip(line, Math.max(30, this.width() - 6))));
590
575
  }
591
576
 
592
577
  thinkingEnd() {
593
- // The thought was the wait; once there is a reply it has nothing to add,
594
- // so it comes off the screen rather than settling into the transcript.
595
- if (this.thinkAt !== undefined) {
596
- this.lines.splice(this.thinkAt, 1);
597
- if (this.run && this.run.at > this.thinkAt) this.run.at--;
598
- for (const run of this.segment?.values() ?? []) if (run.at > this.thinkAt) run.at--;
599
- this.thinkAt = undefined;
600
- this.render();
601
- }
602
578
  this.thought = '';
603
- this.shownThought = undefined;
604
- this.shownAt = undefined;
579
+ this.openedWith = undefined;
605
580
  this.thoughtSince = undefined;
606
581
  }
607
582
 
@@ -945,7 +920,6 @@ export class Screen {
945
920
  this.spinTimer = setInterval(() => {
946
921
  this.tick++;
947
922
  this.paintLiveRun();
948
- this.paintLiveThought();
949
923
  this.paintStatus();
950
924
  }, FRAME_MS);
951
925
  this.spinTimer.unref?.();
@@ -1360,8 +1334,6 @@ export class Screen {
1360
1334
  // reads as part of the input rather than as the answer above it.
1361
1335
  '',
1362
1336
  ...this.inputBox(),
1363
- '',
1364
- '',
1365
1337
  ];
1366
1338
 
1367
1339
  // The cursor is hidden for the duration of the paint. Without this it is