ucode-agent 1.16.1 → 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 +1 -1
- package/src/core/loop.js +7 -1
- package/src/tools/deploy.js +299 -283
- package/src/tools/index.js +25 -17
- package/src/ui/screen.js +17 -48
package/package.json
CHANGED
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,',
|
package/src/tools/deploy.js
CHANGED
|
@@ -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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
child
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
+
}
|
package/src/tools/index.js
CHANGED
|
@@ -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
|
@@ -548,65 +548,35 @@ export class Screen {
|
|
|
548
548
|
// and the transcript gets one line afterwards saying how long it took.
|
|
549
549
|
|
|
550
550
|
/**
|
|
551
|
-
* The model
|
|
551
|
+
* The first thing the model says, as soon as it has said it.
|
|
552
552
|
*
|
|
553
|
-
* Models reach for a tool before
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
* 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.
|
|
558
557
|
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
558
|
+
* Written once, never rewritten. Rewriting it as more arrived was the
|
|
559
|
+
* flicker — an unfinished sentence showed as a single word, then jumped.
|
|
561
560
|
*/
|
|
562
561
|
thinkingDelta(text = '') {
|
|
563
562
|
if (this.thoughtSince === undefined) this.thoughtSince = Date.now();
|
|
564
|
-
if (!text) return;
|
|
563
|
+
if (!text || this.openedWith) return;
|
|
565
564
|
|
|
566
|
-
this.thought = ((this.thought ?? '') + text).slice(
|
|
567
|
-
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
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;
|
|
571
569
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
// and whatever arrived meanwhile shows when its turn comes.
|
|
575
|
-
const now = Date.now();
|
|
576
|
-
if (latest !== this.shownThought) {
|
|
577
|
-
if (this.shownThought !== undefined && now - (this.shownAt ?? 0) < THOUGHT_HOLD_MS) return;
|
|
578
|
-
this.shownThought = latest;
|
|
579
|
-
this.shownAt = now;
|
|
580
|
-
}
|
|
570
|
+
const line = finished[1].trim();
|
|
571
|
+
if (line.length < 12) return; // "Okay." tells nobody anything
|
|
581
572
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
this.thinkAt = this.lines.length;
|
|
585
|
-
this.push(line);
|
|
586
|
-
} else {
|
|
587
|
-
this.lines[this.thinkAt] = line;
|
|
588
|
-
this.render();
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/** Keep the live thought moving between deltas. */
|
|
593
|
-
paintLiveThought() {
|
|
594
|
-
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))));
|
|
595
575
|
}
|
|
596
576
|
|
|
597
577
|
thinkingEnd() {
|
|
598
|
-
// The thought was the wait; once there is a reply it has nothing to add,
|
|
599
|
-
// so it comes off the screen rather than settling into the transcript.
|
|
600
|
-
if (this.thinkAt !== undefined) {
|
|
601
|
-
this.lines.splice(this.thinkAt, 1);
|
|
602
|
-
if (this.run && this.run.at > this.thinkAt) this.run.at--;
|
|
603
|
-
for (const run of this.segment?.values() ?? []) if (run.at > this.thinkAt) run.at--;
|
|
604
|
-
this.thinkAt = undefined;
|
|
605
|
-
this.render();
|
|
606
|
-
}
|
|
607
578
|
this.thought = '';
|
|
608
|
-
this.
|
|
609
|
-
this.shownAt = undefined;
|
|
579
|
+
this.openedWith = undefined;
|
|
610
580
|
this.thoughtSince = undefined;
|
|
611
581
|
}
|
|
612
582
|
|
|
@@ -950,7 +920,6 @@ export class Screen {
|
|
|
950
920
|
this.spinTimer = setInterval(() => {
|
|
951
921
|
this.tick++;
|
|
952
922
|
this.paintLiveRun();
|
|
953
|
-
this.paintLiveThought();
|
|
954
923
|
this.paintStatus();
|
|
955
924
|
}, FRAME_MS);
|
|
956
925
|
this.spinTimer.unref?.();
|