ucode-agent 1.5.0 → 1.6.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/README.md +53 -1
- package/package.json +3 -1
- package/skills/ui-ux/SKILL.md +2 -2
- package/src/core/doctor.js +122 -0
- package/src/core/loop.js +301 -12
- package/src/core/provider.js +93 -10
- package/src/core/stuck.js +269 -0
- package/src/tools/browser.js +121 -59
- package/src/tools/deploy.js +283 -0
- package/src/tools/files.js +91 -8
- package/src/tools/index.js +44 -10
- package/src/tools/scaffold.js +61 -1
- package/src/tools/shell.js +89 -1
- package/src/ui/activity.js +203 -0
- package/src/ui/plain.js +22 -3
- package/src/ui/screen.js +65 -19
- package/templates/next-shadcn/TEMPLATE.md +53 -9
- package/templates/next-shadcn/_package-lock.json +1335 -148
- package/templates/next-shadcn/components.json +1 -1
- package/templates/next-shadcn/next.config.ts +2 -1
- package/templates/next-shadcn/package.json +4 -2
- package/templates/next-shadcn/presets/citrus.json +77 -0
- package/templates/next-shadcn/presets/graphite.json +77 -0
- package/templates/next-shadcn/presets/grove.json +77 -0
- package/templates/next-shadcn/presets/ocean.json +78 -0
- package/templates/next-shadcn/presets/sunset.json +77 -0
- package/templates/next-shadcn/presets/violet.json +77 -0
- package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
- package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +34 -22
- package/templates/next-shadcn/src/components/ui/avatar.tsx +7 -4
- package/templates/next-shadcn/src/components/ui/badge.tsx +15 -18
- package/templates/next-shadcn/src/components/ui/button.tsx +12 -3
- package/templates/next-shadcn/src/components/ui/calendar.tsx +1 -0
- package/templates/next-shadcn/src/components/ui/checkbox.tsx +6 -2
- package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
- package/templates/next-shadcn/src/components/ui/command.tsx +1 -2
- package/templates/next-shadcn/src/components/ui/dialog.tsx +34 -26
- package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +115 -114
- package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/input-group.tsx +2 -4
- package/templates/next-shadcn/src/components/ui/input.tsx +1 -2
- package/templates/next-shadcn/src/components/ui/label.tsx +6 -2
- package/templates/next-shadcn/src/components/ui/popover.tsx +27 -28
- package/templates/next-shadcn/src/components/ui/progress.tsx +11 -63
- package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/scroll-area.tsx +6 -6
- package/templates/next-shadcn/src/components/ui/select.tsx +55 -64
- package/templates/next-shadcn/src/components/ui/separator.tsx +6 -3
- package/templates/next-shadcn/src/components/ui/sheet.tsx +35 -26
- package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
- package/templates/next-shadcn/src/components/ui/switch.tsx +3 -2
- package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
- package/templates/next-shadcn/src/components/ui/tabs.tsx +16 -8
- package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
- package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
- package/templates/next-shadcn/src/components/ui/tooltip.tsx +24 -33
- package/templates/next-shadcn/src/lib/utils.ts +6 -1
- package/ucode.js +8 -1
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deploy.js — put the app online on Vercel.
|
|
3
|
+
*
|
|
4
|
+
* The user says "deploy it" (or types /deploy) and gets a live link. ucode
|
|
5
|
+
* picks a short project name that fits the app and is actually free, creates
|
|
6
|
+
* or reuses the Vercel project, copies the app's .env keys to it as encrypted
|
|
7
|
+
* variables, refuses to upload code with a secret written into it, and hands
|
|
8
|
+
* the upload and build to the Vercel CLI — which knows every framework's
|
|
9
|
+
* build settings better than anything written here would.
|
|
10
|
+
*
|
|
11
|
+
* The token comes from VERCEL_TOKEN (~/.ucode/.env). It is never printed:
|
|
12
|
+
* anything the CLI says is scrubbed of it before it reaches the screen.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { ToolFailure } from '../core/failure.js';
|
|
19
|
+
import { ENV_FILE } from '../core/provider.js';
|
|
20
|
+
import { resolveIn, result } from './shared.js';
|
|
21
|
+
import { childEnv } from './shell.js';
|
|
22
|
+
|
|
23
|
+
const API = 'https://api.vercel.com';
|
|
24
|
+
const DEPLOY_TIMEOUT = 12 * 60_000;
|
|
25
|
+
let fetchImpl = (...a) => fetch(...a);
|
|
26
|
+
|
|
27
|
+
/** Tests hand in a fake fetch. */
|
|
28
|
+
export function setFetch(f) { fetchImpl = f ?? ((...a) => fetch(...a)); }
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Names
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** "Food IQ" → "food-iq": lowercase, a-z 0-9 and dashes, short, cut at a word. */
|
|
35
|
+
export function slugify(text, max = 20) {
|
|
36
|
+
let s = String(text ?? '').toLowerCase().replace(/^@[^/]+\//, '')
|
|
37
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
38
|
+
if (s.length > max) {
|
|
39
|
+
const cut = s.slice(0, max);
|
|
40
|
+
s = cut.includes('-') ? cut.slice(0, cut.lastIndexOf('-')) : cut;
|
|
41
|
+
}
|
|
42
|
+
return s || 'app';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Candidates in order of preference: the plain name first, then tidy variants. */
|
|
46
|
+
export function nameCandidates(base) {
|
|
47
|
+
const b = slugify(base);
|
|
48
|
+
const words = ['app', 'web', 'live', 'hq', 'hub'];
|
|
49
|
+
const rand = () => Math.random().toString(36).slice(2, 6);
|
|
50
|
+
return [b, ...words.map((w) => `${b}-${w}`), b.replace(/-/g, ''), `${b}-${rand()}`, `${b}-${rand()}`]
|
|
51
|
+
.filter((v, i, a) => v && a.indexOf(v) === i);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Is <name>.vercel.app free? Not a project of this account, and no one
|
|
56
|
+
* else's deployment answers at that address.
|
|
57
|
+
*/
|
|
58
|
+
async function available(name, ctx) {
|
|
59
|
+
const mine = await ctx.api(`/v9/projects/${name}`);
|
|
60
|
+
if (mine.status === 200) return false;
|
|
61
|
+
try {
|
|
62
|
+
const r = await fetchImpl(`https://${name}.vercel.app`, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(8000) });
|
|
63
|
+
return r.status === 404 && /DEPLOYMENT_NOT_FOUND|NOT_FOUND/i.test(r.headers.get('x-vercel-error') ?? 'NOT_FOUND');
|
|
64
|
+
} catch {
|
|
65
|
+
return true; // nothing answered at all — the name is unclaimed
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function pickName(base, ctx) {
|
|
70
|
+
for (const candidate of nameCandidates(base)) {
|
|
71
|
+
if (await available(candidate, ctx)) return candidate;
|
|
72
|
+
}
|
|
73
|
+
throw new ToolFailure({
|
|
74
|
+
kind: 'no_name', attempted: 'choosing a project name',
|
|
75
|
+
failed: `Every name tried for "${base}" is taken.`,
|
|
76
|
+
fix: 'Call deploy again with a different name.',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Secrets and env
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
const SECRET_PATTERNS = [
|
|
85
|
+
[/sk-or-v1-[0-9a-f]{20,}/, 'an API key (sk-or-v1-…)'],
|
|
86
|
+
[/\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{32,}/, 'an API key (sk-…)'],
|
|
87
|
+
[/\bvcp_[A-Za-z0-9]{20,}/, 'a Vercel token'],
|
|
88
|
+
[/\bAKIA[0-9A-Z]{16}\b/, 'an AWS access key'],
|
|
89
|
+
[/\bgh[pousr]_[A-Za-z0-9]{30,}/, 'a GitHub token'],
|
|
90
|
+
[/\bAIza[0-9A-Za-z_-]{35}\b/, 'a Google API key'],
|
|
91
|
+
];
|
|
92
|
+
const SKIP_DIRS = new Set(['node_modules', '.next', '.git', '.vercel', 'dist', 'build', 'out', '.ucode', '.turbo']);
|
|
93
|
+
const SCAN_EXT = /\.(?:[cm]?[jt]sx?|html?|vue|svelte|astro|json)$/i;
|
|
94
|
+
|
|
95
|
+
/** Files with a secret written into them. `.env*` files are the right place, so they are skipped. */
|
|
96
|
+
export async function scanSecrets(dir) {
|
|
97
|
+
const found = [];
|
|
98
|
+
async function walk(d) {
|
|
99
|
+
for (const entry of await fs.readdir(d, { withFileTypes: true }).catch(() => [])) {
|
|
100
|
+
if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) await walk(path.join(d, entry.name)); continue; }
|
|
101
|
+
if (entry.name.startsWith('.env') || entry.name === 'package-lock.json' || !SCAN_EXT.test(entry.name)) continue;
|
|
102
|
+
const file = path.join(d, entry.name);
|
|
103
|
+
const text = await fs.readFile(file, 'utf8').catch(() => '');
|
|
104
|
+
if (text.length > 1_000_000) continue;
|
|
105
|
+
const lines = text.split('\n');
|
|
106
|
+
for (let i = 0; i < lines.length; i++) {
|
|
107
|
+
const hit = SECRET_PATTERNS.find(([re]) => re.test(lines[i]));
|
|
108
|
+
if (hit) found.push({ file: path.relative(dir, file).split(path.sep).join('/'), line: i + 1, what: hit[1] });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
await walk(dir);
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** KEY=value pairs from .env, .env.local, .env.production — later files win. */
|
|
117
|
+
export async function readEnv(dir) {
|
|
118
|
+
const vars = {};
|
|
119
|
+
for (const name of ['.env', '.env.local', '.env.production']) {
|
|
120
|
+
const text = await fs.readFile(path.join(dir, name), 'utf8').catch(() => '');
|
|
121
|
+
for (const line of text.split(/\r?\n/)) {
|
|
122
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
|
|
123
|
+
if (!m) continue;
|
|
124
|
+
vars[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return vars;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function frameworkOf(pkg, dir) {
|
|
131
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
132
|
+
if (deps.next) return 'nextjs';
|
|
133
|
+
if (deps.vite) return 'vite';
|
|
134
|
+
if (deps['react-scripts']) return 'create-react-app';
|
|
135
|
+
if (!pkg && existsSync(path.join(dir, 'index.html'))) return null;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// The tool
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
export async function deploy({ folder = '.', name } = {}, { onOutput } = {}) {
|
|
144
|
+
const token = process.env.VERCEL_TOKEN;
|
|
145
|
+
if (!token) {
|
|
146
|
+
throw new ToolFailure({
|
|
147
|
+
kind: 'no_token', attempted: 'deploying to Vercel',
|
|
148
|
+
failed: 'There is no Vercel token yet.',
|
|
149
|
+
fix: `Tell the user: make one at vercel.com/account/tokens (Create Token), then add a line VERCEL_TOKEN=... to ${ENV_FILE} and restart ucode.`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const say = (line) => onOutput?.([line]);
|
|
153
|
+
const where = resolveIn(folder, 'deploy', 'folder');
|
|
154
|
+
const dir = where.abs;
|
|
155
|
+
const pkg = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8').catch(() => 'null'));
|
|
156
|
+
if (!pkg && !existsSync(path.join(dir, 'index.html'))) {
|
|
157
|
+
throw new ToolFailure({
|
|
158
|
+
kind: 'not_an_app', attempted: `deploying ${where.show}`,
|
|
159
|
+
failed: `${where.show} has no package.json or index.html, so there is nothing to deploy.`,
|
|
160
|
+
fix: 'Pass the folder of the app itself, e.g. deploy({ folder: "food-iq" }).',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const scrub = (s) => String(s).split(token).join('***');
|
|
165
|
+
const api = async (pathname, { method = 'GET', body } = {}) => {
|
|
166
|
+
const url = new URL(API + pathname);
|
|
167
|
+
if (ctx.teamId) url.searchParams.set('teamId', ctx.teamId);
|
|
168
|
+
const r = await fetchImpl(url, {
|
|
169
|
+
method, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(20_000),
|
|
170
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
171
|
+
});
|
|
172
|
+
return { status: r.status, ok: r.ok, json: await r.json().catch(() => ({})) };
|
|
173
|
+
};
|
|
174
|
+
const ctx = { api, teamId: null };
|
|
175
|
+
|
|
176
|
+
say('Checking for secrets');
|
|
177
|
+
const leaks = await scanSecrets(dir);
|
|
178
|
+
if (leaks.length) {
|
|
179
|
+
const list = leaks.slice(0, 5).map((l) => `${l.file}:${l.line} (${l.what})`).join(', ');
|
|
180
|
+
throw new ToolFailure({
|
|
181
|
+
kind: 'secret_in_code', attempted: `deploying ${where.show}`,
|
|
182
|
+
failed: `The code has a secret written into it: ${list}. Once online, anyone could read it.`,
|
|
183
|
+
fix: 'Move the value to .env.local (e.g. OPENROUTER_API_KEY=...), read it with process.env in a ' +
|
|
184
|
+
'server route (Next.js: app/api/.../route.ts), call that route from the page, then deploy again. ' +
|
|
185
|
+
'ucode copies .env.local to Vercel for you.',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const me = await api('/v2/user');
|
|
190
|
+
if (!me.ok) {
|
|
191
|
+
throw new ToolFailure({
|
|
192
|
+
kind: 'bad_token', attempted: 'deploying to Vercel', failed: 'Vercel did not accept the token.',
|
|
193
|
+
fix: 'Tell the user to make a new token at vercel.com/account/tokens and put it in ~/.ucode/.env as VERCEL_TOKEN.',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
ctx.teamId = me.json.user?.defaultTeamId ?? null;
|
|
197
|
+
const orgId = ctx.teamId ?? me.json.user?.id;
|
|
198
|
+
|
|
199
|
+
// Reuse the project this app was deployed to before, so the link stays the same.
|
|
200
|
+
const linkFile = path.join(dir, '.vercel', 'project.json');
|
|
201
|
+
let link = JSON.parse(await fs.readFile(linkFile, 'utf8').catch(() => 'null'));
|
|
202
|
+
if (link?.projectId) {
|
|
203
|
+
const still = await api(`/v9/projects/${link.projectId}`);
|
|
204
|
+
if (!still.ok) link = null;
|
|
205
|
+
else link.projectName = still.json.name;
|
|
206
|
+
}
|
|
207
|
+
if (!link?.projectId) {
|
|
208
|
+
say('Choosing a name');
|
|
209
|
+
const projectName = name ? slugify(name) : await pickName(pkg?.name || path.basename(dir), ctx);
|
|
210
|
+
const made = await api('/v11/projects', { method: 'POST', body: { name: projectName, framework: frameworkOf(pkg, dir) } });
|
|
211
|
+
if (!made.ok) {
|
|
212
|
+
throw new ToolFailure({
|
|
213
|
+
kind: 'vercel_error', attempted: `creating the Vercel project ${projectName}`,
|
|
214
|
+
failed: scrub(made.json?.error?.message ?? `HTTP ${made.status}`),
|
|
215
|
+
fix: 'Try again with a different name.',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
link = { projectId: made.json.id, orgId, projectName };
|
|
219
|
+
await fs.mkdir(path.dirname(linkFile), { recursive: true });
|
|
220
|
+
await fs.writeFile(linkFile, JSON.stringify({ projectId: link.projectId, orgId }, null, 2));
|
|
221
|
+
const gi = path.join(dir, '.gitignore');
|
|
222
|
+
const ignore = await fs.readFile(gi, 'utf8').catch(() => '');
|
|
223
|
+
if (!/^\.vercel\/?$/m.test(ignore)) await fs.writeFile(gi, `${ignore.replace(/\n?$/, '\n')}.vercel\n`).catch(() => {});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const vars = await readEnv(dir);
|
|
227
|
+
const keys = Object.keys(vars);
|
|
228
|
+
if (keys.length) {
|
|
229
|
+
say(`Copying ${keys.length} key${keys.length === 1 ? '' : 's'} to Vercel`);
|
|
230
|
+
await api(`/v10/projects/${link.projectId}/env?upsert=true`, {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
body: keys.map((key) => ({ key, value: vars[key], type: 'encrypted', target: ['production', 'preview'] })),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
say('Uploading and building on Vercel');
|
|
237
|
+
const started = Date.now();
|
|
238
|
+
const { code, output } = await new Promise((resolve) => {
|
|
239
|
+
const args = ['--yes', 'vercel@latest', 'deploy', '--prod', '--yes', '--token', token];
|
|
240
|
+
const child = spawn(process.platform === 'win32' ? 'npx.cmd' : 'npx', args, {
|
|
241
|
+
cwd: dir, shell: process.platform === 'win32', windowsHide: true,
|
|
242
|
+
env: { ...childEnv(), VERCEL_ORG_ID: orgId, VERCEL_PROJECT_ID: link.projectId, VERCEL_TELEMETRY_DISABLED: '1' },
|
|
243
|
+
});
|
|
244
|
+
let output = '';
|
|
245
|
+
const take = (chunk) => {
|
|
246
|
+
const text = scrub(chunk).replace(/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g, '');
|
|
247
|
+
output += text;
|
|
248
|
+
// Only lines that read as progress: no markup, no lone braces, no redraw leftovers.
|
|
249
|
+
const last = text.split(/\r?\n/).map((l) => l.replace(/\[[0-9;]*[A-Za-z]/g, '').trim())
|
|
250
|
+
.filter((l) => /[A-Za-z]{3}/.test(l) && !/^[<{}[\]]/.test(l)).at(-1);
|
|
251
|
+
if (last) say(last.replace(/^[▲✓>\s]+/, '').replace(/\s*\[\d+s\]$/, '').slice(0, 80));
|
|
252
|
+
};
|
|
253
|
+
child.stdout.on('data', take);
|
|
254
|
+
child.stderr.on('data', take);
|
|
255
|
+
const timer = setTimeout(() => child.kill(), DEPLOY_TIMEOUT);
|
|
256
|
+
child.on('error', (err) => { clearTimeout(timer); resolve({ code: -1, output: `${output}\n${err.message}` }); });
|
|
257
|
+
child.on('close', (c) => { clearTimeout(timer); resolve({ code: c, output }); });
|
|
258
|
+
});
|
|
259
|
+
const secs = Math.round((Date.now() - started) / 1000);
|
|
260
|
+
|
|
261
|
+
if (code !== 0) {
|
|
262
|
+
const tail = output.split(/\r?\n/).filter((l) => l.trim()).slice(-25).join('\n');
|
|
263
|
+
throw new ToolFailure({
|
|
264
|
+
kind: 'deploy_failed', attempted: `deploying ${where.show} to Vercel`,
|
|
265
|
+
failed: `The deploy failed after ${secs}s:\n${tail}`,
|
|
266
|
+
fix: 'The lines above name the problem — usually the same error `npm run build` shows locally. ' +
|
|
267
|
+
'Fix it, check the build passes, then deploy again.',
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const live = `https://${link.projectName}.vercel.app`;
|
|
272
|
+
const ok = await fetchImpl(live, { signal: AbortSignal.timeout(15_000) }).then((r) => r.status < 400).catch(() => false);
|
|
273
|
+
const url = ok ? live : (output.match(/https:\/\/[a-z0-9.-]+\.vercel\.app/g) ?? [live]).at(-1);
|
|
274
|
+
return result(
|
|
275
|
+
[
|
|
276
|
+
`Live at ${url}`,
|
|
277
|
+
`Deployed in ${secs}s as the Vercel project "${link.projectName}".`,
|
|
278
|
+
keys.length ? `Copied to Vercel as encrypted variables: ${keys.join(', ')}` : '',
|
|
279
|
+
'Deploying again updates the same link.',
|
|
280
|
+
].filter(Boolean).join('\n'),
|
|
281
|
+
`live · ${url} · ${secs}s`
|
|
282
|
+
);
|
|
283
|
+
}
|
package/src/tools/files.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
changedRegion, renderDiff, renderNewFile, READ_LINES, MAX_FILE_OUTPUT,
|
|
16
16
|
} from './shared.js';
|
|
17
17
|
import { packageJsonWritten } from './shell.js';
|
|
18
|
+
import { parse as parseSource } from '@babel/parser';
|
|
18
19
|
|
|
19
20
|
export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
|
|
20
21
|
const target = resolveIn(p, 'read_file');
|
|
@@ -94,6 +95,71 @@ function written(target, content) {
|
|
|
94
95
|
if (path.basename(target.abs) === 'package.json') packageJsonWritten(target.abs, content);
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
const PARSEABLE = /\.(?:[cm]?[jt]sx?)$/i;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Does this source parse? Checked the moment a file is written.
|
|
102
|
+
*
|
|
103
|
+
* Measured on a real build: a JSX typo sat unnoticed until `npm run build`,
|
|
104
|
+
* which takes up to a minute, failed on it — then the fix, then another full
|
|
105
|
+
* build. Parsing the file takes milliseconds, so the error comes back in the
|
|
106
|
+
* same step that wrote it, with the line, while the model still has the file
|
|
107
|
+
* in front of it. Syntax only: types are checked at the end of the turn.
|
|
108
|
+
*/
|
|
109
|
+
export function syntaxProblem(file, text) {
|
|
110
|
+
if (!PARSEABLE.test(file)) return null;
|
|
111
|
+
const ext = path.extname(file).toLowerCase();
|
|
112
|
+
const plugins = ext === '.tsx'
|
|
113
|
+
? ['typescript', 'jsx']
|
|
114
|
+
: /^\.[cm]?ts$/.test(ext) ? ['typescript'] : ['jsx'];
|
|
115
|
+
const where = (loc) => (loc ? `line ${loc.line}, column ${loc.column + 1}` : 'somewhere in the file');
|
|
116
|
+
const clean = (m) => String(m).replace(/\s*\(\d+:\d+\)\s*$/, '');
|
|
117
|
+
try {
|
|
118
|
+
const ast = parseSource(text, {
|
|
119
|
+
sourceType: 'unambiguous',
|
|
120
|
+
plugins: [...plugins, 'decorators-legacy'],
|
|
121
|
+
errorRecovery: true,
|
|
122
|
+
allowReturnOutsideFunction: true,
|
|
123
|
+
allowAwaitOutsideFunction: true,
|
|
124
|
+
});
|
|
125
|
+
const first = ast.errors?.[0];
|
|
126
|
+
return first ? `${where(first.loc)}: ${clean(first.message)}` : null;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return `${where(err.loc)}: ${clean(err.message)}`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The warning appended to a result when a file does not parse. */
|
|
133
|
+
const brokenNote = (show, problem) =>
|
|
134
|
+
`\n\n⚠ ${show} does not parse — ${problem}. Fix it now: the build will fail on it.`;
|
|
135
|
+
|
|
136
|
+
/** A file this short comes back whole after an edit; longer ones show the part around the change. */
|
|
137
|
+
const SHOW_WHOLE = 250;
|
|
138
|
+
const AROUND = 15;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The file as it stands after an edit, with the same line-number gutter as
|
|
142
|
+
* read_file.
|
|
143
|
+
*
|
|
144
|
+
* Measured on a real build: the model read the same component thirteen times
|
|
145
|
+
* in one turn, because an edit's result showed only the lines it replaced and
|
|
146
|
+
* the next edit needed the file as it now was. Sending the current text back
|
|
147
|
+
* with the edit costs the same tokens the re-read would have, and saves the
|
|
148
|
+
* round trip every time.
|
|
149
|
+
*/
|
|
150
|
+
function nowReads(show, text, at, span) {
|
|
151
|
+
const lines = toLines(text);
|
|
152
|
+
const whole = lines.length <= SHOW_WHOLE;
|
|
153
|
+
const from = whole ? 1 : Math.max(1, at - AROUND);
|
|
154
|
+
const to = whole ? lines.length : Math.min(lines.length, at + span + AROUND);
|
|
155
|
+
const width = String(to).length;
|
|
156
|
+
const body = lines.slice(from - 1, to).map((l, i) => `${String(from + i).padStart(width)} | ${l}`).join('\n');
|
|
157
|
+
const heading = whole
|
|
158
|
+
? `${show} now reads (all ${lines.length} lines`
|
|
159
|
+
: `${show} now reads, lines ${from}-${to} of ${lines.length}`;
|
|
160
|
+
return `\n\n${heading} — this is the current text, so there is no need to read it again):\n${body}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
97
163
|
/** At most this many files in one read_files call. */
|
|
98
164
|
const MAX_BATCH = 20;
|
|
99
165
|
|
|
@@ -216,6 +282,7 @@ async function put(target, content, { diffMax = 16 } = {}) {
|
|
|
216
282
|
existed,
|
|
217
283
|
lineCount,
|
|
218
284
|
diff,
|
|
285
|
+
problem: syntaxProblem(target.abs, content),
|
|
219
286
|
line: `${existed ? 'Overwrote' : 'Created'} ${target.show} ` +
|
|
220
287
|
`(${lineCount} lines, ${bytes(Buffer.byteLength(content))})`,
|
|
221
288
|
};
|
|
@@ -234,7 +301,10 @@ export async function writeFile({ path: p, content }) {
|
|
|
234
301
|
await guard(target, `write ${target.abs}`);
|
|
235
302
|
|
|
236
303
|
const written = await put(target, content);
|
|
237
|
-
const out = result(
|
|
304
|
+
const out = result(
|
|
305
|
+
`${written.line}.${written.problem ? brokenNote(target.show, written.problem) : ''}`,
|
|
306
|
+
`${written.existed ? 'overwrote' : 'created'} · ${written.lineCount} lines${written.problem ? ' · does not parse' : ''}`
|
|
307
|
+
);
|
|
238
308
|
out.diff = written.diff;
|
|
239
309
|
return out;
|
|
240
310
|
}
|
|
@@ -258,6 +328,7 @@ export async function batchWrite({ files }) {
|
|
|
258
328
|
const lines = [];
|
|
259
329
|
const diff = [];
|
|
260
330
|
let created = 0;
|
|
331
|
+
let broken = 0;
|
|
261
332
|
|
|
262
333
|
for (const [index, file] of files.entries()) {
|
|
263
334
|
const { path: p, content } = file ?? {};
|
|
@@ -277,13 +348,14 @@ export async function batchWrite({ files }) {
|
|
|
277
348
|
// would bury the reply under three hundred lines of gutter.
|
|
278
349
|
const written = await put(target, content, { diffMax: 6 });
|
|
279
350
|
if (!written.existed) created++;
|
|
280
|
-
lines.push(written.line);
|
|
351
|
+
lines.push(written.line + (written.problem ? brokenNote(target.show, written.problem) : ''));
|
|
352
|
+
if (written.problem) broken++;
|
|
281
353
|
diff.push(`~${target.show}`, ...written.diff);
|
|
282
354
|
}
|
|
283
355
|
|
|
284
356
|
const out = result(
|
|
285
357
|
lines.join('\n'),
|
|
286
|
-
`${files.length} file${files.length === 1 ? '' : 's'} · ${created} new`
|
|
358
|
+
`${files.length} file${files.length === 1 ? '' : 's'} · ${created} new${broken ? ` · ${broken} do not parse` : ''}`
|
|
287
359
|
);
|
|
288
360
|
out.diff = diff;
|
|
289
361
|
return out;
|
|
@@ -462,9 +534,13 @@ export async function editFile({ path: p, old_string, new_string }) {
|
|
|
462
534
|
const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
|
|
463
535
|
const how = loose ? ', matched ignoring whitespace and re-indented to fit' : '';
|
|
464
536
|
|
|
537
|
+
const span = toLines(new_string).length;
|
|
465
538
|
const out = result(
|
|
466
|
-
`Replaced one occurrence in ${target.show} at line ${at} (${change}${how})
|
|
467
|
-
|
|
539
|
+
`Replaced one occurrence in ${target.show} at line ${at} (${change}${how}).` +
|
|
540
|
+
(syntaxProblem(target.abs, text) ? brokenNote(target.show, syntaxProblem(target.abs, text)) : '') +
|
|
541
|
+
nowReads(target.show, text, at, span),
|
|
542
|
+
`1 change at line ${at} · ${change}${loose ? ' · whitespace-tolerant' : ''}${syntaxProblem(target.abs, text) ? ' · does not parse' : ''}`,
|
|
543
|
+
MAX_FILE_OUTPUT
|
|
468
544
|
);
|
|
469
545
|
// The replacement is diffed on its own and offset to where it landed, so
|
|
470
546
|
// the gutter shows the file's line numbers rather than 1, 2, 3.
|
|
@@ -531,8 +607,11 @@ export async function multiEdit({ path: p, edits }) {
|
|
|
531
607
|
const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
|
|
532
608
|
|
|
533
609
|
const out = result(
|
|
534
|
-
`Applied ${edits.length} edits to ${target.show} (${change})
|
|
535
|
-
|
|
610
|
+
`Applied ${edits.length} edits to ${target.show} (${change}).` +
|
|
611
|
+
(syntaxProblem(target.abs, text) ? brokenNote(target.show, syntaxProblem(target.abs, text)) : '') +
|
|
612
|
+
nowReads(target.show, text, 1, toLines(text).length),
|
|
613
|
+
`${edits.length} edits · ${change}`,
|
|
614
|
+
MAX_FILE_OUTPUT
|
|
536
615
|
);
|
|
537
616
|
out.diff = diff;
|
|
538
617
|
return out;
|
|
@@ -615,7 +694,11 @@ export async function editFiles({ files }) {
|
|
|
615
694
|
|
|
616
695
|
const edits = planned.reduce((n, p) => n + p.count, 0);
|
|
617
696
|
const out = result(
|
|
618
|
-
planned.map((p) =>
|
|
697
|
+
planned.map((p) => {
|
|
698
|
+
const problem = syntaxProblem(p.target.abs, p.text);
|
|
699
|
+
return `Edited ${p.target.show} (${p.count} change${p.count === 1 ? '' : 's'})` +
|
|
700
|
+
(problem ? brokenNote(p.target.show, problem) : '');
|
|
701
|
+
}).join('\n'),
|
|
619
702
|
`${planned.length} files · ${edits} edits`
|
|
620
703
|
);
|
|
621
704
|
out.diff = planned.flatMap((p) => p.diff);
|
package/src/tools/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { runCommand, runCommands } from './shell.js';
|
|
|
10
10
|
import { webSearch } from './web.js';
|
|
11
11
|
import { lookAtApp } from './browser.js';
|
|
12
12
|
import { createApp } from './scaffold.js';
|
|
13
|
+
import { deploy } from './deploy.js';
|
|
13
14
|
import { clip, READ_LINES } from './shared.js';
|
|
14
15
|
|
|
15
16
|
export { setRoot, setConfirm, getRoot } from './shared.js';
|
|
@@ -24,8 +25,8 @@ export const tools = [
|
|
|
24
25
|
description:
|
|
25
26
|
'Start a new Next.js + shadcn/ui app from the ready-made ucode starter. This is how every ' +
|
|
26
27
|
'Next.js app begins - never run create-next-app or shadcn init. It copies a project that ' +
|
|
27
|
-
'is already known to build (Next.js 16, TypeScript, Tailwind 4, shadcn with
|
|
28
|
-
'components, light/dark mode, toasts, a
|
|
28
|
+
'is already known to build (Next.js 16, TypeScript, Tailwind 4, shadcn with 33 common ' +
|
|
29
|
+
'components, light/dark mode, toasts, a design preset of colours and fonts) into a new empty folder, and ' +
|
|
29
30
|
'starts installing its packages in the background so you can write components at once. ' +
|
|
30
31
|
'The result lists everything included.',
|
|
31
32
|
parameters: {
|
|
@@ -34,14 +35,43 @@ export const tools = [
|
|
|
34
35
|
folder: str('A new, empty folder for the app, relative to the project root, e.g. "stride".'),
|
|
35
36
|
name: str('The display name of the app, e.g. "Stride".'),
|
|
36
37
|
description: str('One line about the app, used in the page metadata.'),
|
|
38
|
+
design: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
enum: ['ocean', 'grove', 'sunset', 'graphite', 'violet', 'citrus'],
|
|
41
|
+
description:
|
|
42
|
+
'The look: colours and fonts, light and dark. Pick the one that fits the app. ' +
|
|
43
|
+
'ocean - calm blue, for dashboards, finance, productivity (default). ' +
|
|
44
|
+
'grove - fresh green, for health, habits, food, nature. ' +
|
|
45
|
+
'sunset - warm coral with a serif, for travel, recipes, journaling, lifestyle. ' +
|
|
46
|
+
'graphite - monochrome and crisp, for developer tools, docs, portfolios. ' +
|
|
47
|
+
'violet - vivid violet, for AI tools, creative apps, music, learning. ' +
|
|
48
|
+
'citrus - bright lime and bold, for games, sport, kids, social.',
|
|
49
|
+
},
|
|
37
50
|
},
|
|
38
51
|
required: ['folder', 'name'],
|
|
39
52
|
},
|
|
40
53
|
},
|
|
54
|
+
{
|
|
55
|
+
name: 'deploy',
|
|
56
|
+
description:
|
|
57
|
+
'Put an app online on Vercel and get its live link - use it when the user asks to deploy, ' +
|
|
58
|
+
'publish, host or share the app. ucode picks a short free project name, copies the app\'s ' +
|
|
59
|
+
'.env keys to Vercel as encrypted variables, refuses code with a secret written into it ' +
|
|
60
|
+
'(move it to .env.local and a server route, then deploy again), and builds on Vercel. ' +
|
|
61
|
+
'Run the local build first so errors show up here. Deploying again updates the same link.',
|
|
62
|
+
parameters: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
folder: str('The app folder, relative to the project root, e.g. "food-iq". Defaults to ".".'),
|
|
66
|
+
name: str('Optional: a project name to use instead of the one ucode would choose.'),
|
|
67
|
+
},
|
|
68
|
+
required: [],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
41
71
|
{
|
|
42
72
|
name: 'read_file',
|
|
43
73
|
description:
|
|
44
|
-
'Read
|
|
74
|
+
'Read one text file - for two or more, use read_files instead. Comes back as numbered lines — the numbers are for you to ' +
|
|
45
75
|
'refer to and must never appear in an edit_file argument. Long files arrive in ' +
|
|
46
76
|
'pages; pass offset to keep going.',
|
|
47
77
|
parameters: {
|
|
@@ -120,7 +150,8 @@ export const tools = [
|
|
|
120
150
|
'Replace one exact piece of text in a file. old_string must match the file ' +
|
|
121
151
|
'character for character, including indentation, and must occur exactly once — ' +
|
|
122
152
|
'the edit is refused on zero matches and on two. This is the normal way to ' +
|
|
123
|
-
'change existing code.'
|
|
153
|
+
'change existing code. The result shows the file as it now stands, so do not ' +
|
|
154
|
+
'read it again afterwards.',
|
|
124
155
|
parameters: {
|
|
125
156
|
type: 'object',
|
|
126
157
|
properties: {
|
|
@@ -294,9 +325,10 @@ export const tools = [
|
|
|
294
325
|
'Open the running app in a real browser at a phone width (375px) and a desktop width ' +
|
|
295
326
|
'(1440px) and report what a person would run into: console errors, failed requests, ' +
|
|
296
327
|
'content that spills off the side of the screen, broken images, unlabeled buttons and ' +
|
|
297
|
-
'fields
|
|
298
|
-
'
|
|
299
|
-
'
|
|
328
|
+
'fields. The first look at an app also brings a designer-style review of the ' +
|
|
329
|
+
'screenshots; later looks re-run only the fast checks. Use it once the dev server is ' +
|
|
330
|
+
'ready, fix what it reports, then look once more to confirm. Screenshots are saved ' +
|
|
331
|
+
'under .ucode/screenshots.',
|
|
300
332
|
parameters: {
|
|
301
333
|
type: 'object',
|
|
302
334
|
properties: {
|
|
@@ -306,7 +338,6 @@ export const tools = [
|
|
|
306
338
|
description: 'Pages to open, e.g. ["/", "/settings"]. Defaults to ["/"]. Up to 4.',
|
|
307
339
|
items: { type: 'string' },
|
|
308
340
|
},
|
|
309
|
-
review: bool('Include the visual design review of the screenshots. Defaults to true.'),
|
|
310
341
|
},
|
|
311
342
|
required: ['url'],
|
|
312
343
|
},
|
|
@@ -345,11 +376,12 @@ const run = {
|
|
|
345
376
|
web_search: webSearch,
|
|
346
377
|
look_at_app: lookAtApp,
|
|
347
378
|
create_app: createApp,
|
|
379
|
+
deploy,
|
|
348
380
|
};
|
|
349
381
|
|
|
350
382
|
/** Tools that change the project or execute code. */
|
|
351
383
|
export const MUTATING = new Set([
|
|
352
|
-
'write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'run_command', 'run_commands',
|
|
384
|
+
'write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'run_command', 'run_commands', 'deploy',
|
|
353
385
|
]);
|
|
354
386
|
|
|
355
387
|
/** Tools with no side effects, so several may run at the same time. */
|
|
@@ -358,7 +390,7 @@ export const PARALLEL_SAFE = new Set(['read_file', 'read_files', 'list_dir', 'gl
|
|
|
358
390
|
/** Tools withheld in plan mode. Withholding beats asking a model not to. */
|
|
359
391
|
export const WRITES = new Set([
|
|
360
392
|
'write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'run_command', 'run_commands',
|
|
361
|
-
'delegate', 'create_app',
|
|
393
|
+
'delegate', 'create_app', 'deploy',
|
|
362
394
|
]);
|
|
363
395
|
|
|
364
396
|
/** Tools that change files on disk, which parallel workers take turns at. */
|
|
@@ -481,6 +513,8 @@ export function describe(name, args = {}) {
|
|
|
481
513
|
return `Running ${clip(args.command, 70)}${args.background ? ' in the background' : ''}`;
|
|
482
514
|
case 'run_commands':
|
|
483
515
|
return `Running ${args.commands?.length ?? 0} commands together`;
|
|
516
|
+
case 'deploy':
|
|
517
|
+
return `Deploying ${clip(args.folder || '.', 30)} to Vercel`;
|
|
484
518
|
case 'create_app':
|
|
485
519
|
return `Creating ${clip(args.name || args.folder, 30)} from the Next.js starter`;
|
|
486
520
|
case 'look_at_app':
|
package/src/tools/scaffold.js
CHANGED
|
@@ -64,7 +64,65 @@ async function copyTree(from, to, fill) {
|
|
|
64
64
|
* @param {string} [o.template]
|
|
65
65
|
* @param {boolean} [o.install] start the background install (tests turn it off)
|
|
66
66
|
*/
|
|
67
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Give the new app its look: one of the hand-picked presets in the starter's
|
|
69
|
+
* presets/ folder — a full light and dark palette and a font — written into
|
|
70
|
+
* globals.css and layout.tsx. Apps stop looking like the same default blue.
|
|
71
|
+
* Returns the preset used, or null when the starter has none.
|
|
72
|
+
*/
|
|
73
|
+
export async function applyDesign(appDir, design) {
|
|
74
|
+
const dir = path.join(appDir, 'presets');
|
|
75
|
+
const names = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
|
|
76
|
+
if (!names.length) return null;
|
|
77
|
+
const presets = await Promise.all(names.map(async (f) => JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'))));
|
|
78
|
+
await fs.rm(dir, { recursive: true, force: true }); // the app needs the result, not the catalogue
|
|
79
|
+
const preset = presets.find((p) => p.name === design) ?? presets.find((p) => p.default) ?? presets[0];
|
|
80
|
+
|
|
81
|
+
const cssFile = path.join(appDir, 'src', 'app', 'globals.css');
|
|
82
|
+
let css = await fs.readFile(cssFile, 'utf8').catch(() => null);
|
|
83
|
+
if (css !== null) {
|
|
84
|
+
const retint = (selector, tokens) => {
|
|
85
|
+
const block = new RegExp(`(${selector}\\s*\\{)([\\s\\S]*?)(\\n\\})`);
|
|
86
|
+
css = css.replace(block, (all, open, body, close) => {
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
let next = body.replace(/(\n\s*)--([\w-]+):\s*[^;]+;/g, (line, lead, key) => {
|
|
89
|
+
if (!(key in tokens)) return line;
|
|
90
|
+
seen.add(key);
|
|
91
|
+
return `${lead}--${key}: ${tokens[key]};`;
|
|
92
|
+
});
|
|
93
|
+
for (const [key, value] of Object.entries(tokens)) if (!seen.has(key)) next += `\n --${key}: ${value};`;
|
|
94
|
+
return open + next + close;
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
retint(':root', { radius: preset.radius, ...preset.light });
|
|
98
|
+
retint('\\.dark', preset.dark);
|
|
99
|
+
await fs.writeFile(cssFile, css);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sans = preset.fonts?.sans;
|
|
103
|
+
const layoutFile = path.join(appDir, 'src', 'app', 'layout.tsx');
|
|
104
|
+
if (sans && sans !== 'Geist') {
|
|
105
|
+
const id = sans.replace(/\s+/g, '_');
|
|
106
|
+
const layout = await fs.readFile(layoutFile, 'utf8').catch(() => null);
|
|
107
|
+
if (layout !== null) {
|
|
108
|
+
await fs.writeFile(layoutFile, layout
|
|
109
|
+
.replace('import { Geist, Geist_Mono } from "next/font/google";', `import { ${id}, Geist_Mono } from "next/font/google";`)
|
|
110
|
+
.replace('const sans = Geist({', `const sans = ${id}({`));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const guide = path.join(appDir, 'TEMPLATE.md');
|
|
115
|
+
const text = await fs.readFile(guide, 'utf8').catch(() => null);
|
|
116
|
+
if (text !== null) {
|
|
117
|
+
await fs.writeFile(guide, `${text.trimEnd()}\n\n## Design\n\nThis app uses the **${preset.name}** preset — ` +
|
|
118
|
+
`${preset.summary}. Font: ${sans ?? 'Geist'}. The palette lives in globals.css (light and dark): ` +
|
|
119
|
+
'build with the tokens (bg-primary, text-muted-foreground, border, ...) rather than raw colours, ' +
|
|
120
|
+
'so every screen stays in one look.\n');
|
|
121
|
+
}
|
|
122
|
+
return preset;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function createApp({ folder, name, description, template = 'next-shadcn', design, install = true }) {
|
|
68
126
|
if (!TEMPLATE_NAMES.includes(template)) {
|
|
69
127
|
throw new ToolFailure({
|
|
70
128
|
kind: 'bad_args',
|
|
@@ -110,6 +168,7 @@ export async function createApp({ folder, name, description, template = 'next-sh
|
|
|
110
168
|
|
|
111
169
|
const files = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
|
|
112
170
|
await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
|
|
171
|
+
const look = await applyDesign(target.abs, design);
|
|
113
172
|
|
|
114
173
|
if (install) {
|
|
115
174
|
const pkg = path.join(target.abs, 'package.json');
|
|
@@ -120,6 +179,7 @@ export async function createApp({ folder, name, description, template = 'next-sh
|
|
|
120
179
|
|
|
121
180
|
return result(
|
|
122
181
|
`Created ${target.show} from the ${template} starter — ${files.length} files, already known to build.\n` +
|
|
182
|
+
(look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
|
|
123
183
|
(install
|
|
124
184
|
? `Its packages are installing in the background right now. Keep writing: any command you run in ` +
|
|
125
185
|
`${target.show} waits for that install first, so there is no need to run npm install.\n`
|