ucode-agent 1.16.1 → 1.18.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 +20 -8
- package/src/tools/deploy.js +299 -283
- package/src/tools/index.js +25 -17
- package/src/ui/screen.js +19 -48
package/package.json
CHANGED
package/src/core/loop.js
CHANGED
|
@@ -451,13 +451,26 @@ 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
|
-
'
|
|
455
|
-
'
|
|
456
|
-
'
|
|
457
|
-
'
|
|
458
|
-
'
|
|
459
|
-
'
|
|
460
|
-
'
|
|
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
|
+
'',
|
|
461
|
+
'SPEAK AS "I", NEVER "WE". You are doing this, not a committee: "I will build',
|
|
462
|
+
'Tide as a single HTML file", not "we have created the file".',
|
|
463
|
+
'',
|
|
464
|
+
'Say three or four things over a whole build, not one per step:',
|
|
465
|
+
' - Open with what you are going to make and how, in one line, before anything',
|
|
466
|
+
' else: "I will build Tide as a single HTML file - markup, one stylesheet, one',
|
|
467
|
+
' module - with the tasks kept in localStorage."',
|
|
468
|
+
' - One line when you move between the big pieces of work: "The layout is done,',
|
|
469
|
+
' now the animations."',
|
|
470
|
+
' - One line at the end saying what it does and how to try it.',
|
|
471
|
+
'That is all. A line before every tool call is not narration, it is noise: the',
|
|
472
|
+
'steps already show on screen, and repeating them in words buries the few',
|
|
473
|
+
'sentences worth reading.',
|
|
461
474
|
'',
|
|
462
475
|
'FIRST, EVERY TIME: write one short line saying what you are about to do, then',
|
|
463
476
|
'make the tool calls. Never open a turn with a tool call and no words. Examples:',
|
|
@@ -529,7 +542,6 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
529
542
|
' reports - errors, layout that overflows a phone, the review points worth fixing -',
|
|
530
543
|
' in one pass, then look once more. A clean second look means it is done: report',
|
|
531
544
|
' 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
545
|
' name, handles keys and returns the live link. Build locally first.',
|
|
534
546
|
'- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
|
|
535
547
|
' 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,37 @@ 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
|
-
|
|
585
|
-
|
|
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
|
+
// Full strength: this is the model talking, and it is the thing on the page
|
|
575
|
+
// worth reading. The dimmed lines around it are the machinery.
|
|
576
|
+
this.push(' ' + chalk.white(clip(line, Math.max(30, this.width() - 6))));
|
|
595
577
|
}
|
|
596
578
|
|
|
597
579
|
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
580
|
this.thought = '';
|
|
608
|
-
this.
|
|
609
|
-
this.shownAt = undefined;
|
|
581
|
+
this.openedWith = undefined;
|
|
610
582
|
this.thoughtSince = undefined;
|
|
611
583
|
}
|
|
612
584
|
|
|
@@ -950,7 +922,6 @@ export class Screen {
|
|
|
950
922
|
this.spinTimer = setInterval(() => {
|
|
951
923
|
this.tick++;
|
|
952
924
|
this.paintLiveRun();
|
|
953
|
-
this.paintLiveThought();
|
|
954
925
|
this.paintStatus();
|
|
955
926
|
}, FRAME_MS);
|
|
956
927
|
this.spinTimer.unref?.();
|