ucode-agent 1.54.0 → 1.57.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.
@@ -1,528 +1,502 @@
1
- /**
2
- * scaffold.js — starting an app from a starter that is known to work.
3
- *
4
- * Setting up a Next.js + shadcn project from nothing is four minutes of
5
- * create-next-app and shadcn CLI runs — measured at 116s and 130s — plus a
6
- * dozen model round trips to drive them and then theme the result. Every app
7
- * starts from the same place anyway, so ucode ships that place: a project
8
- * that has already been built and type-checked, copied in one step, with its
9
- * install starting in the background while the model writes the first
10
- * component.
11
- */
12
-
13
- import { promises as fs } from 'node:fs';
14
- import path from 'node:path';
15
- import { fileURLToPath } from 'node:url';
16
- import { ToolFailure } from '../core/failure.js';
17
- import { resolveIn, guard, result, askedForPlainHtml, writeTracked } from './shared.js';
18
- import { batchWrite } from './files.js';
19
- import { packageJsonWritten, installIn } from './shell.js';
20
- import { restore, populate } from './cache.js';
21
-
22
- const TEMPLATES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
23
-
24
- /** npm silently drops these two names from published packages, so they ship renamed. */
25
- const RENAME = { _gitignore: '.gitignore', '_package-lock.json': 'package-lock.json' };
26
-
27
- /** Files the placeholders are filled into. Everything else is copied byte for byte. */
28
- const TEXT = /\.(?:json|md|mjs|css|html|jsx?|tsx?)$/i;
29
-
30
- export const TEMPLATE_NAMES = ['next-shadcn', 'plain-html'];
31
-
32
- /**
33
- * Which of a starter's files come back inside the result, in full.
34
- *
35
- * Reading a file ucode just copied is a whole round trip spent learning what
36
- * it already had on disk, and a round trip is ten to forty seconds. The
37
- * three-file starter is small enough to hand over outright; the Next.js one
38
- * is a hundred files and its guide has to do that job instead.
39
- */
40
- const SHOW_BACK = { 'plain-html': ['index.html', 'styles.css', 'app.js'] };
41
-
42
- /**
43
- * The files argument, however it was written.
44
- *
45
- * A list of `{ path, content }` is what the schema asks for, and it is what
46
- * arrives most of the time. The rest of the time it is a JSON string, or a
47
- * `{ "todo/app.js": "..." }` map, or the same list with the keys named
48
- * something adjacent. Each of those, refused, is a round trip spent being
49
- * told what could have been read — so they are all read.
50
- */
51
- export function normaliseFiles(files) {
52
- let value = files;
53
- if (typeof value === 'string') {
54
- try { value = JSON.parse(value); } catch { return []; }
55
- }
56
- if (!value || typeof value !== 'object') return [];
57
-
58
- const entries = Array.isArray(value)
59
- ? value
60
- : Object.entries(value).map(([path, content]) => ({ path, content }));
61
-
62
- return entries.map((entry) => {
63
- if (typeof entry !== 'object' || entry === null) return entry;
64
- const path = entry.path ?? entry.file ?? entry.filename ?? entry.name;
65
- const content = entry.content ?? entry.contents ?? entry.text ?? entry.body ?? entry.source;
66
- return { ...entry, path, content };
67
- });
68
- }
69
-
70
- /** What each starter is for, so the choice is made on purpose. */
71
- export const TEMPLATE_NOTES = {
72
- 'next-shadcn': 'Next.js, TypeScript, Tailwind and shadcn/ui. For anything with routes, data or many components.',
73
- 'plain-html': 'One index.html, one stylesheet, one module. No install, no build, opens straight in a browser.',
74
- };
75
-
76
- function slug(name) {
77
- return String(name).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
78
- }
79
-
80
- /** Text that is safe inside a JS string and a JSON string. */
81
- const plain = (s) => String(s ?? '').replace(/["'`\\<>]/g, '').replace(/\s+/g, ' ').trim();
82
-
83
- async function copyTree(from, to, fill) {
84
- await fs.mkdir(to, { recursive: true });
85
- const copied = [];
86
- for (const entry of await fs.readdir(from, { withFileTypes: true })) {
87
- const name = RENAME[entry.name] ?? entry.name;
88
- const src = path.join(from, entry.name);
89
- const dest = path.join(to, name);
90
- if (entry.isDirectory()) {
91
- copied.push(...(await copyTree(src, dest, fill)).map((f) => `${name}/${f}`));
92
- } else if (TEXT.test(entry.name) || entry.name in RENAME) {
93
- let text = await fs.readFile(src, 'utf8');
94
- for (const [token, value] of Object.entries(fill)) text = text.split(token).join(value);
95
- await writeTracked(dest, text);
96
- copied.push(name);
97
- } else {
98
- await fs.copyFile(src, dest);
99
- copied.push(name);
100
- }
101
- }
102
- return copied;
103
- }
104
-
105
- /**
106
- * Give the new app its look: one of the hand-picked presets in the starter's
107
- * presets/ folder — a full light and dark palette and a font — written into
108
- * globals.css and layout.tsx. Apps stop looking like the same default blue.
109
- * Returns the preset used, or null when the starter has none.
110
- */
111
- /**
112
- * The file in each starter that carries the design, not just some of the rules.
113
- *
114
- * next-shadcn has globals.css, which applyDesign re-tints. plain-html has one
115
- * stylesheet and it is the whole design system: the palette, a spacing scale,
116
- * radii, motion timings, focus rings, a reduced-motion rule and a breakpoint.
117
- */
118
- const TOKEN_FILE = { 'plain-html': 'styles.css' };
119
-
120
- /**
121
- * Put the starter's token block back when the app wrote over it without one.
122
- *
123
- * The stylesheet in plain-html is a design, not a placeholder — but `files`
124
- * lands straight on top of the starter, so a model passing its own styles.css
125
- * replaces the scale, the palette and the timings with whatever it typed. What
126
- * comes back is hand-rolled CSS with no system behind it, and an app built on
127
- * raw pixel values has uneven spacing everywhere for the rest of its life.
128
- *
129
- * A replacement that declares its own custom properties is left alone: that is
130
- * a model doing the job properly, and second-guessing it would be worse. Only
131
- * a stylesheet with no :root variables at all gets the starter's block put
132
- * back above it, where every rule underneath can reach it.
133
- */
134
- const ASSET_REF = /\b(href|src)=("|')(?!https?:|\/\/|\/|data:|#|mailto:|tel:)([^"']+)\2/g;
135
-
136
- /**
137
- * Take the app's own folder back out of its own links.
138
- *
139
- * create_app wants paths relative to the project root — "todo/index.html" —
140
- * because that is where every other file tool works from. The model then
141
- * carries the same prefix into the markup and writes
142
- * <link href="todo/styles.css"> inside todo/index.html, where it resolves to
143
- * todo/todo/styles.css and 404s. The page comes up as bare markup with no
144
- * stylesheet and no script: no design, no behaviour, nothing in the console
145
- * but two failed requests.
146
- *
147
- * It is the tool's own convention leaking into the file, so the tool takes it
148
- * back out. Only a leading "<folder>/" on a relative reference, which inside
149
- * that folder is always wrong — absolute paths, URLs, data: and anchors are
150
- * left exactly as they are.
151
- */
152
- async function unprefixOwnFolder(appDir, written) {
153
- const prefix = `${path.basename(appDir)}/`;
154
- const fixed = [];
155
-
156
- for (const abs of written) {
157
- if (!/\.html?$/i.test(abs)) continue;
158
- const text = await fs.readFile(abs, 'utf8').catch(() => null);
159
- if (text === null) continue;
160
-
161
- let hits = 0;
162
- const next = text.replace(ASSET_REF, (all, attr, quote, value) => {
163
- if (!value.startsWith(prefix)) return all;
164
- hits++;
165
- return `${attr}=${quote}${value.slice(prefix.length)}${quote}`;
166
- });
167
-
168
- if (hits) {
169
- await writeTracked(abs, next);
170
- fixed.push(`${path.relative(appDir, abs).split(path.sep).join('/')} (${hits})`);
171
- }
172
- }
173
- return fixed;
174
- }
175
-
176
- /**
177
- * Files that legitimately sit at the root of a Next.js project.
178
- *
179
- * Everything else a model writes there is app code that has missed src/.
180
- */
181
- const NEXT_ROOT = /^(?:package(?:-lock)?\.json|next\.config\.[mc]?[jt]s|tsconfig\.json|postcss\.config\.[mc]?js|eslint\.config\.[mc]?js|components\.json|README\.md|TEMPLATE\.md|\.gitignore|next-env\.d\.ts)$/i;
182
-
183
- /**
184
- * Put Next.js files where Next.js looks for them.
185
- *
186
- * Routing is folder-based and unforgiving: a page is a route only at
187
- * src/app/<segment>/page.tsx. A traced build wrote page.tsx at the project
188
- * root and api/expenses/route.ts beside it — the build passed, every route
189
- * 404ed, and nothing said why.
190
- *
191
- * The guide has the tree in it, and the guide cannot help: it comes back
192
- * inside the create_app result, and the model chooses these paths in the call
193
- * that produces that result. It is reading the map after it has parked. So the
194
- * paths are corrected on the way in, and the result says what moved and why,
195
- * which is the copy that arrives in time to matter for the next file.
196
- *
197
- * Only for next-shadcn, only for files under the app folder, and never for the
198
- * config files that genuinely belong at the root.
199
- */
200
- function placeForNext(files, folder) {
201
- const moved = [];
202
-
203
- const out = files.map((f) => {
204
- const rel = String(f.path ?? '').split('\\').join('/');
205
- const prefix = `${folder}/`;
206
- if (!rel.startsWith(prefix)) return f;
207
-
208
- const inside = rel.slice(prefix.length);
209
- if (!inside || inside.startsWith('src/') || inside.startsWith('public/')) return f;
210
- if (NEXT_ROOT.test(inside)) return f;
211
-
212
- // app/... is the right tree written one level too high; everything else
213
- // that is app code belongs under src/ as it stands.
214
- const under = inside.startsWith('app/') ? `src/${inside}` : `src/app/${inside}`;
215
- const isRoute = /(?:^|\/)(?:page|layout|loading|error|not-found)\.[jt]sx?$/.test(inside)
216
- || /(?:^|\/)route\.[jt]s$/.test(inside);
217
- const isCode = /^(?:components|lib|hooks|styles|utils)\//.test(inside);
218
-
219
- if (!isRoute && !isCode) return f;
220
- const to = isCode ? `${prefix}src/${inside}` : `${prefix}${under}`;
221
- if (to === rel) return f;
222
-
223
- moved.push(`${inside} -> ${to.slice(prefix.length)}`);
224
- return { ...f, path: to };
225
- });
226
-
227
- return { files: out, moved };
228
- }
229
-
230
- async function keepDesignTokens(appDir, template, written, starterCss) {
231
- const rel = TOKEN_FILE[template];
232
- if (!rel || !starterCss) return null;
233
-
234
- const abs = path.join(appDir, rel);
235
- if (!written.has(abs)) return null; // never overwritten
236
-
237
- const now = await fs.readFile(abs, 'utf8').catch(() => null);
238
- if (now === null || /:root\s*\{[^}]*--/.test(now)) return null;
239
-
240
- const block = /:root\s*\{[\s\S]*?\n\}/.exec(starterCss);
241
- if (!block) return null;
242
-
243
- await fs.writeFile(abs, `${block[0]}\n\n${now}`, 'utf8');
244
- return rel;
245
- }
246
-
247
- export async function applyDesign(appDir, design) {
248
- const dir = path.join(appDir, 'presets');
249
- const names = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
250
- if (!names.length) return null;
251
- const presets = await Promise.all(names.map(async (f) => JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'))));
252
- await fs.rm(dir, { recursive: true, force: true }); // the app needs the result, not the catalogue
253
- const preset = presets.find((p) => p.name === design) ?? presets.find((p) => p.default) ?? presets[0];
254
-
255
- const cssFile = path.join(appDir, 'src', 'app', 'globals.css');
256
- let css = await fs.readFile(cssFile, 'utf8').catch(() => null);
257
- if (css !== null) {
258
- const retint = (selector, tokens) => {
259
- const block = new RegExp(`(${selector}\\s*\\{)([\\s\\S]*?)(\\n\\})`);
260
- css = css.replace(block, (all, open, body, close) => {
261
- const seen = new Set();
262
- let next = body.replace(/(\n\s*)--([\w-]+):\s*[^;]+;/g, (line, lead, key) => {
263
- if (!(key in tokens)) return line;
264
- seen.add(key);
265
- return `${lead}--${key}: ${tokens[key]};`;
266
- });
267
- for (const [key, value] of Object.entries(tokens)) if (!seen.has(key)) next += `\n --${key}: ${value};`;
268
- return open + next + close;
269
- });
270
- };
271
- retint(':root', { radius: preset.radius, ...preset.light });
272
- retint('\\.dark', preset.dark);
273
- await fs.writeFile(cssFile, css);
274
- }
275
-
276
- const sans = preset.fonts?.sans;
277
- const layoutFile = path.join(appDir, 'src', 'app', 'layout.tsx');
278
- if (sans && sans !== 'Geist') {
279
- const id = sans.replace(/\s+/g, '_');
280
- const layout = await fs.readFile(layoutFile, 'utf8').catch(() => null);
281
- if (layout !== null) {
282
- await fs.writeFile(layoutFile, layout
283
- .replace('import { Geist, Geist_Mono } from "next/font/google";', `import { ${id}, Geist_Mono } from "next/font/google";`)
284
- .replace('const sans = Geist({', `const sans = ${id}({`));
285
- }
286
- }
287
-
288
- const guide = path.join(appDir, 'TEMPLATE.md');
289
- const text = await fs.readFile(guide, 'utf8').catch(() => null);
290
- if (text !== null) {
291
- await fs.writeFile(guide, `${text.trimEnd()}\n\n## Design\n\nThis app uses the **${preset.name}** preset — ` +
292
- `${preset.summary}. Font: ${sans ?? 'Geist'}. The palette lives in globals.css (light and dark): ` +
293
- 'build with the tokens (bg-primary, text-muted-foreground, border, ...) rather than raw colours, ' +
294
- 'so every screen stays in one look.\n');
295
- }
296
- return preset;
297
- }
298
-
299
- /**
300
- * Start an app, and — when the model passes them — write its files in the
301
- * same call.
302
- *
303
- * @param {object} o
304
- * @param {string} o.folder new, empty folder for the app
305
- * @param {string} o.name display name, e.g. "Stride"
306
- * @param {string} [o.description]
307
- * @param {string} [o.template] defaults to plain-html: nothing to install
308
- * @param {string} [o.design]
309
- * @param {{path: string, content: string}[]} [o.files] the app itself, paths
310
- * relative to the project root, written straight over the starter's
311
- * @param {boolean} [o.install] start the background install (tests turn it off)
312
- */
313
- export async function createApp({ folder, name, description, template = 'plain-html', design, files, install = true }) {
314
- if (!TEMPLATE_NAMES.includes(template)) {
315
- throw new ToolFailure({
316
- kind: 'bad_args',
317
- attempted: 'creating an app',
318
- failed: `There is no starter called "${template}".`,
319
- fix: `Use one of: ${TEMPLATE_NAMES.join(', ')}.`,
320
- });
321
- }
322
-
323
- // Every shape a model reaches for when handing over a set of files. Reading
324
- // them all costs nothing; refusing them costs a round trip each, which is
325
- // the whole reason this argument exists.
326
- const given = normaliseFiles(files);
327
-
328
- // Checked before anything is copied: a bad entry found halfway through
329
- // would leave the folder created, and the retry would then be refused for
330
- // already having files in it.
331
- const bad = given.findIndex(
332
- (f) => typeof f?.path !== 'string' || typeof f?.content !== 'string'
333
- );
334
- if (bad !== -1) {
335
- throw new ToolFailure({
336
- kind: 'bad_args',
337
- attempted: 'creating an app',
338
- failed: `Entry ${bad + 1} of "files" is missing "path" or "content" — both must be strings.`,
339
- fix: 'Fix that entry and call create_app again. Nothing has been created yet.',
340
- });
341
- }
342
-
343
- // The user said no framework. A starter that costs an npm install and a
344
- // build is then not a judgement call the model gets to make on their behalf
345
- // — it is the one thing they ruled out, and the cost of getting it wrong is
346
- // minutes of their time plus a rebuild from nothing.
347
- let overrode = false;
348
- if (template === 'next-shadcn' && askedForPlainHtml()) {
349
- template = 'plain-html';
350
- overrode = true;
351
- }
352
-
353
- const target = resolveIn(folder, 'create_app', 'folder');
354
- const attempted = `creating an app in ${target.show}`;
355
- if (target.show === '.') {
356
- throw new ToolFailure({
357
- kind: 'bad_args',
358
- attempted,
359
- failed: 'The app needs its own folder, not the project root.',
360
- fix: 'Pass a new folder name, e.g. "stride".',
361
- });
362
- }
363
- await guard(target, `create an app in ${target.abs}`);
364
-
365
- let existing = [];
366
- try {
367
- existing = await fs.readdir(target.abs);
368
- } catch {
369
- existing = [];
370
- }
371
- // A folder with something already in it, and the whole app passed in
372
- // alongside: that is the second attempt at a build that half happened.
373
- // Refusing it is how a Next.js build spent two calls being told no and then
374
- // started over from nothing. The starter is already on disk — take the files
375
- // and write them into it.
376
- //
377
- // With no files it is still a refusal, because then there is nothing to do
378
- // but copy a starter over work that is already there.
379
- const adopt = existing.length > 0 && given.length > 0;
380
- if (existing.length && !adopt) {
381
- throw new ToolFailure({
382
- kind: 'not_empty',
383
- attempted,
384
- failed: `${target.show} already has ${existing.length} item(s) in it: ${existing.slice(0, 5).join(', ')}.`,
385
- fix: 'Pick a new folder name, or pass the app in "files" to write it into the folder that is already there.',
386
- });
387
- }
388
-
389
- const display = plain(name) || path.basename(target.abs);
390
- const fill = {
391
- __APP_NAME__: display,
392
- __APP_SLUG__: slug(display),
393
- __APP_DESCRIPTION__: plain(description) || display,
394
- };
395
-
396
- const copied = adopt ? [] : await copyTree(path.join(TEMPLATES, template), target.abs, fill);
397
- // Next.js serves static files from public/; a plain page has no such place
398
- // and an empty folder in a three-file app is clutter.
399
- if (!adopt && template !== 'plain-html') await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
400
- const look = await applyDesign(target.abs, design);
401
-
402
- // Read before the app's own files land on top of it, so the tokens can be
403
- // put back if the replacement arrives without any.
404
- const starterCss = TOKEN_FILE[template]
405
- ? await fs.readFile(path.join(target.abs, TOKEN_FILE[template]), 'utf8').catch(() => null)
406
- : null;
407
-
408
- // The starter has been installed on this machine before: hard-link that
409
- // tree in, which is seconds where npm is a minute. Otherwise install as
410
- // usual, and keep the result so the next app is instant.
411
- let linked = 0;
412
- const needsInstall = !adopt && template !== 'plain-html';
413
- if (install && needsInstall) {
414
- // Keyed on the starter's lockfile, which is the same for every app made
415
- // from it — the app's own is rewritten by npm as it installs.
416
- const lockText = await fs
417
- .readFile(path.join(TEMPLATES, template, '_package-lock.json'), 'utf8')
418
- .catch(() => null);
419
- linked = await restore(target.abs, template, lockText);
420
- if (!linked) {
421
- const pkg = path.join(target.abs, 'package.json');
422
- packageJsonWritten(pkg, await fs.readFile(pkg, 'utf8'));
423
- installIn(target.abs)?.then((done) => {
424
- if (done?.code === 0) populate(target.abs, template, lockText);
425
- });
426
- }
427
- }
428
-
429
- const guide = await fs.readFile(path.join(target.abs, 'TEMPLATE.md'), 'utf8').catch(() => '');
430
-
431
- // The app's own files, written in this same call. Two round trips become
432
- // one, and round trips are nearly all of the time a build takes.
433
- // Next.js only: paths that have missed src/ are corrected before they land.
434
- // Paths are relative to the project root, but a model will name a file
435
- // "index.html" meaning the app's own. DeepSeek did: its tasker went to the
436
- // project root and the starter was left in tasker/ as the app. A path that
437
- // is not already under the folder is put under it.
438
- const home = resolveIn(folder, 'create_app', 'folder').abs;
439
- for (const f of given) {
440
- const abs = resolveIn(f.path, 'create_app', 'files').abs;
441
- const rel = path.relative(home, abs);
442
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
443
- f.path = path.join(String(folder), String(f.path).replace(/^[./\\]+/, ''));
444
- }
445
- }
446
- const placed = template === 'next-shadcn' ? placeForNext(given, path.basename(target.abs)) : { files: given, moved: [] };
447
- const mine = placed.files;
448
- const wrote = mine.length ? await batchWrite({ files: mine }) : null;
449
- const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
450
- const keptTokens = await keepDesignTokens(target.abs, template, written, starterCss);
451
- const relinked = await unprefixOwnFolder(target.abs, written);
452
-
453
- // The starter's own files, in full, so there is never a reason to read them
454
- // back — and only the ones this call did not already write over. A read is
455
- // another round trip to learn what ucode already knows.
456
- const starter = [];
457
- const leftover = [];
458
- for (const rel of SHOW_BACK[template] ?? []) {
459
- const abs = path.join(target.abs, rel);
460
- if (written.has(abs)) continue;
461
- const text = await fs.readFile(abs, 'utf8').catch(() => null);
462
- if (text !== null) {
463
- starter.push(`=== ${target.show}/${rel} ===\n${text}`);
464
- leftover.push(`${target.show}/${rel}`);
465
- }
466
- }
467
-
468
- const out = result(
469
- (overrode
470
- ? 'You asked for next-shadcn, but the request said plain HTML and no framework, so ' +
471
- 'this is the plain-html starter instead: three files, no install, no build. Write ' +
472
- 'the app in them.\n'
473
- : '') +
474
- `Created ${target.show} from the ${template} starter — ${copied.length} files, already known to build.\n` +
475
- (look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
476
- (wrote ? `\nYour ${mine.length} file${mine.length === 1 ? '' : 's'}:\n${wrote.content}\n` : '') +
477
- (placed.moved.length
478
- ? `\nMoved into place: ${placed.moved.join(', ')}. In Next.js a page is only a route at `
479
- + 'src/app/<segment>/page.tsx, an API handler only at src/app/api/<name>/route.ts, and '
480
- + 'components and libraries live under src/. A file written outside src/ builds fine and '
481
- + 'is never served, which is the hardest kind of wrong to notice. Write the rest there '
482
- + 'directly.\n'
483
- : '') +
484
- (relinked.length
485
- ? `\nFixed in ${relinked.join(', ')}: links that began with "${path.basename(target.abs)}/". ` +
486
- 'Paths in "files" are relative to the project root, but a link inside a page is ' +
487
- 'relative to that page — so "index.html" beside "styles.css" links to it as ' +
488
- '"styles.css", never "app/styles.css". Written that way the stylesheet and the ' +
489
- 'script 404 and the page comes up as bare markup.\n'
490
- : '') +
491
- (keptTokens
492
- ? `\nYour ${keptTokens} arrived with no :root block, so the starter's was kept above it — ` +
493
- 'the palette, the spacing scale (--s1 to --s5), the radius and the motion timings. ' +
494
- 'Build the rest of the stylesheet out of those variables: spacing that comes from a ' +
495
- 'scale is the difference between a designed page and an arranged one. Re-tint the ' +
496
- 'values to suit this app; do not go back to raw pixels.\n'
497
- : '') +
498
- (linked
499
- ? `Its packages are already in place (${linked.toLocaleString()} files, linked from the starter cache) — ` +
500
- 'nothing to install: build and run straight away.\n'
501
- : install && needsInstall
502
- ? `Its packages are installing in the background right now. Keep writing: any command you run in ` +
503
- `${target.show} waits for that install first, so there is no need to run npm install.\n`
504
- : '') +
505
- (needsInstall
506
- ? `Run this app's commands with cwd: "${target.show}" (npm run build, npm run dev).\n\n${guide}`
507
- : `Nothing to install and nothing to build: open ${target.show}/index.html directly, or serve the ` +
508
- `folder with "python -m http.server 8000" if it fetches anything.\n` + (mine.length ? '' : `\n${guide}`)) +
509
- // With the app passed in, the starter's placeholder files printed in full
510
- // read as the app having been overwritten: DeepSeek took them for that
511
- // and wrote the whole app a second time. So they are only named.
512
- (mine.length
513
- ? '\nYour files are on disk exactly as written above; nothing overwrote them.'
514
- + (starter.length
515
- ? ` Starter files you did not replace were left as they were: ${leftover.join(', ')}.`
516
- : '')
517
- + ' Do not write the app again: to check it, read it with read_files, and to change a '
518
- + 'part of it, use edit_file. Writing it out again to verify it costs minutes and checks nothing.'
519
- : starter.length
520
- ? `\n\nThe starter's files, in full — they are below, so do not read them back:\n\n${starter.join('\n\n')}`
521
- : ''),
522
- `${copied.length} files${wrote ? ` · ${mine.length} written` : ''}` +
523
- `${linked ? ' · packages ready' : install && needsInstall ? ' · installing in the background' : ''}`,
524
- 24_000
525
- );
526
- if (wrote?.diff?.length) out.diff = wrote.diff;
527
- return out;
528
- }
1
+ /**
2
+ * scaffold.js — starting an app from a starter that is known to work.
3
+ *
4
+ * Setting up a Next.js + shadcn project from nothing is four minutes of
5
+ * create-next-app and shadcn CLI runs — measured at 116s and 130s — plus a
6
+ * dozen model round trips to drive them and then theme the result. Every app
7
+ * starts from the same place anyway, so ucode ships that place: a project
8
+ * that has already been built and type-checked, copied in one step, with its
9
+ * install starting in the background while the model writes the first
10
+ * component.
11
+ */
12
+
13
+ import { promises as fs } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { ToolFailure } from '../core/failure.js';
17
+ import { resolveIn, guard, result, askedForPlainHtml, writeTracked } from './shared.js';
18
+ import { batchWrite } from './files.js';
19
+ import { packageJsonWritten, installIn } from './shell.js';
20
+ import { restore, populate } from './cache.js';
21
+
22
+ const TEMPLATES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
23
+
24
+ /** npm silently drops these two names from published packages, so they ship renamed. */
25
+ const RENAME = { _gitignore: '.gitignore', '_package-lock.json': 'package-lock.json' };
26
+
27
+ /** Files the placeholders are filled into. Everything else is copied byte for byte. */
28
+ const TEXT = /\.(?:json|md|mjs|css|html|jsx?|tsx?)$/i;
29
+
30
+ export const TEMPLATE_NAMES = ['next-shadcn', 'plain-html'];
31
+
32
+ /**
33
+ * Which of a starter's files come back inside the result, in full.
34
+ *
35
+ * Reading a file ucode just copied is a whole round trip spent learning what
36
+ * it already had on disk, and a round trip is ten to forty seconds. The
37
+ * three-file starter is small enough to hand over outright; the Next.js one
38
+ * is a hundred files and its guide has to do that job instead.
39
+ */
40
+ const SHOW_BACK = { 'plain-html': ['index.html', 'styles.css', 'app.js'] };
41
+
42
+ /**
43
+ * The files argument, however it was written.
44
+ *
45
+ * A list of `{ path, content }` is what the schema asks for, and it is what
46
+ * arrives most of the time. The rest of the time it is a JSON string, or a
47
+ * `{ "todo/app.js": "..." }` map, or the same list with the keys named
48
+ * something adjacent. Each of those, refused, is a round trip spent being
49
+ * told what could have been read — so they are all read.
50
+ */
51
+ export function normaliseFiles(files) {
52
+ let value = files;
53
+ if (typeof value === 'string') {
54
+ try { value = JSON.parse(value); } catch { return []; }
55
+ }
56
+ if (!value || typeof value !== 'object') return [];
57
+
58
+ const entries = Array.isArray(value)
59
+ ? value
60
+ : Object.entries(value).map(([path, content]) => ({ path, content }));
61
+
62
+ return entries.map((entry) => {
63
+ if (typeof entry !== 'object' || entry === null) return entry;
64
+ const path = entry.path ?? entry.file ?? entry.filename ?? entry.name;
65
+ const content = entry.content ?? entry.contents ?? entry.text ?? entry.body ?? entry.source;
66
+ return { ...entry, path, content };
67
+ });
68
+ }
69
+
70
+ /** What each starter is for, so the choice is made on purpose. */
71
+ export const TEMPLATE_NOTES = {
72
+ 'next-shadcn': 'Next.js, TypeScript, Tailwind and shadcn/ui. For anything with routes, data or many components.',
73
+ 'plain-html': 'One index.html, one stylesheet, one module. No install, no build, opens straight in a browser.',
74
+ };
75
+
76
+ function slug(name) {
77
+ return String(name).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
78
+ }
79
+
80
+ /** Text that is safe inside a JS string and a JSON string. */
81
+ const plain = (s) => String(s ?? '').replace(/["'`\\<>]/g, '').replace(/\s+/g, ' ').trim();
82
+
83
+ async function copyTree(from, to, fill) {
84
+ await fs.mkdir(to, { recursive: true });
85
+ const copied = [];
86
+ for (const entry of await fs.readdir(from, { withFileTypes: true })) {
87
+ const name = RENAME[entry.name] ?? entry.name;
88
+ const src = path.join(from, entry.name);
89
+ const dest = path.join(to, name);
90
+ if (entry.isDirectory()) {
91
+ copied.push(...(await copyTree(src, dest, fill)).map((f) => `${name}/${f}`));
92
+ } else if (TEXT.test(entry.name) || entry.name in RENAME) {
93
+ let text = await fs.readFile(src, 'utf8');
94
+ for (const [token, value] of Object.entries(fill)) text = text.split(token).join(value);
95
+ await writeTracked(dest, text);
96
+ copied.push(name);
97
+ } else {
98
+ await fs.copyFile(src, dest);
99
+ copied.push(name);
100
+ }
101
+ }
102
+ return copied;
103
+ }
104
+
105
+ /**
106
+ * Give the new app its look: one of the hand-picked presets in the starter's
107
+ * presets/ folder — a full light and dark palette and a font — written into
108
+ * globals.css and layout.tsx. Apps stop looking like the same default blue.
109
+ * Returns the preset used, or null when the starter has none.
110
+ */
111
+ /**
112
+ * The file in each starter that carries the design, not just some of the rules.
113
+ *
114
+ * next-shadcn has globals.css, which applyDesign re-tints. plain-html has one
115
+ * stylesheet and it is the whole design system: the palette, a spacing scale,
116
+ * radii, motion timings, focus rings, a reduced-motion rule and a breakpoint.
117
+ */
118
+ const TOKEN_FILE = { 'plain-html': 'styles.css' };
119
+
120
+ /**
121
+ * Put the starter's token block back when the app wrote over it without one.
122
+ *
123
+ * The stylesheet in plain-html is a design, not a placeholder — but `files`
124
+ * lands straight on top of the starter, so a model passing its own styles.css
125
+ * replaces the scale, the palette and the timings with whatever it typed. What
126
+ * comes back is hand-rolled CSS with no system behind it, and an app built on
127
+ * raw pixel values has uneven spacing everywhere for the rest of its life.
128
+ *
129
+ * A replacement that declares its own custom properties is left alone: that is
130
+ * a model doing the job properly, and second-guessing it would be worse. Only
131
+ * a stylesheet with no :root variables at all gets the starter's block put
132
+ * back above it, where every rule underneath can reach it.
133
+ */
134
+ const ASSET_REF = /\b(href|src)=("|')(?!https?:|\/\/|\/|data:|#|mailto:|tel:)([^"']+)\2/g;
135
+
136
+ /**
137
+ * Take the app's own folder back out of its own links.
138
+ *
139
+ * create_app wants paths relative to the project root — "todo/index.html" —
140
+ * because that is where every other file tool works from. The model then
141
+ * carries the same prefix into the markup and writes
142
+ * <link href="todo/styles.css"> inside todo/index.html, where it resolves to
143
+ * todo/todo/styles.css and 404s. The page comes up as bare markup with no
144
+ * stylesheet and no script: no design, no behaviour, nothing in the console
145
+ * but two failed requests.
146
+ *
147
+ * It is the tool's own convention leaking into the file, so the tool takes it
148
+ * back out. Only a leading "<folder>/" on a relative reference, which inside
149
+ * that folder is always wrong — absolute paths, URLs, data: and anchors are
150
+ * left exactly as they are.
151
+ */
152
+ async function unprefixOwnFolder(appDir, written) {
153
+ const prefix = `${path.basename(appDir)}/`;
154
+ const fixed = [];
155
+
156
+ for (const abs of written) {
157
+ if (!/\.html?$/i.test(abs)) continue;
158
+ const text = await fs.readFile(abs, 'utf8').catch(() => null);
159
+ if (text === null) continue;
160
+
161
+ let hits = 0;
162
+ const next = text.replace(ASSET_REF, (all, attr, quote, value) => {
163
+ if (!value.startsWith(prefix)) return all;
164
+ hits++;
165
+ return `${attr}=${quote}${value.slice(prefix.length)}${quote}`;
166
+ });
167
+
168
+ if (hits) {
169
+ await writeTracked(abs, next);
170
+ fixed.push(`${path.relative(appDir, abs).split(path.sep).join('/')} (${hits})`);
171
+ }
172
+ }
173
+ return fixed;
174
+ }
175
+
176
+ /**
177
+ * Files that legitimately sit at the root of a Next.js project.
178
+ *
179
+ * Everything else a model writes there is app code that has missed src/.
180
+ */
181
+ const NEXT_ROOT = /^(?:package(?:-lock)?\.json|next\.config\.[mc]?[jt]s|tsconfig\.json|postcss\.config\.[mc]?js|eslint\.config\.[mc]?js|components\.json|README\.md|TEMPLATE\.md|\.gitignore|next-env\.d\.ts)$/i;
182
+
183
+ /**
184
+ * Put Next.js files where Next.js looks for them.
185
+ *
186
+ * Routing is folder-based and unforgiving: a page is a route only at
187
+ * src/app/<segment>/page.tsx. A traced build wrote page.tsx at the project
188
+ * root and api/expenses/route.ts beside it — the build passed, every route
189
+ * 404ed, and nothing said why.
190
+ *
191
+ * The guide has the tree in it, and the guide cannot help: it comes back
192
+ * inside the create_app result, and the model chooses these paths in the call
193
+ * that produces that result. It is reading the map after it has parked. So the
194
+ * paths are corrected on the way in, and the result says what moved and why,
195
+ * which is the copy that arrives in time to matter for the next file.
196
+ *
197
+ * Only for next-shadcn, only for files under the app folder, and never for the
198
+ * config files that genuinely belong at the root.
199
+ */
200
+ function placeForNext(files, folder) {
201
+ const moved = [];
202
+
203
+ const out = files.map((f) => {
204
+ const rel = String(f.path ?? '').split('\\').join('/');
205
+ const prefix = `${folder}/`;
206
+ if (!rel.startsWith(prefix)) return f;
207
+
208
+ const inside = rel.slice(prefix.length);
209
+ if (!inside || inside.startsWith('src/') || inside.startsWith('public/')) return f;
210
+ if (NEXT_ROOT.test(inside)) return f;
211
+
212
+ // app/... is the right tree written one level too high; everything else
213
+ // that is app code belongs under src/ as it stands.
214
+ const under = inside.startsWith('app/') ? `src/${inside}` : `src/app/${inside}`;
215
+ const isRoute = /(?:^|\/)(?:page|layout|loading|error|not-found)\.[jt]sx?$/.test(inside)
216
+ || /(?:^|\/)route\.[jt]s$/.test(inside);
217
+ const isCode = /^(?:components|lib|hooks|styles|utils)\//.test(inside);
218
+
219
+ if (!isRoute && !isCode) return f;
220
+ const to = isCode ? `${prefix}src/${inside}` : `${prefix}${under}`;
221
+ if (to === rel) return f;
222
+
223
+ moved.push(`${inside} -> ${to.slice(prefix.length)}`);
224
+ return { ...f, path: to };
225
+ });
226
+
227
+ return { files: out, moved };
228
+ }
229
+
230
+ async function keepDesignTokens(appDir, template, written, starterCss) {
231
+ const rel = TOKEN_FILE[template];
232
+ if (!rel || !starterCss) return null;
233
+
234
+ const abs = path.join(appDir, rel);
235
+ if (!written.has(abs)) return null; // never overwritten
236
+
237
+ const now = await fs.readFile(abs, 'utf8').catch(() => null);
238
+ if (now === null || /:root\s*\{[^}]*--/.test(now)) return null;
239
+
240
+ const block = /:root\s*\{[\s\S]*?\n\}/.exec(starterCss);
241
+ if (!block) return null;
242
+
243
+ await fs.writeFile(abs, `${block[0]}\n\n${now}`, 'utf8');
244
+ return rel;
245
+ }
246
+
247
+ export async function applyDesign(appDir, design) {
248
+ const dir = path.join(appDir, 'presets');
249
+ const names = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
250
+ if (!names.length) return null;
251
+ const presets = await Promise.all(names.map(async (f) => JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'))));
252
+ await fs.rm(dir, { recursive: true, force: true }); // the app needs the result, not the catalogue
253
+ const preset = presets.find((p) => p.name === design) ?? presets.find((p) => p.default) ?? presets[0];
254
+
255
+ const cssFile = path.join(appDir, 'src', 'app', 'globals.css');
256
+ let css = await fs.readFile(cssFile, 'utf8').catch(() => null);
257
+ if (css !== null) {
258
+ const retint = (selector, tokens) => {
259
+ const block = new RegExp(`(${selector}\\s*\\{)([\\s\\S]*?)(\\n\\})`);
260
+ css = css.replace(block, (all, open, body, close) => {
261
+ const seen = new Set();
262
+ let next = body.replace(/(\n\s*)--([\w-]+):\s*[^;]+;/g, (line, lead, key) => {
263
+ if (!(key in tokens)) return line;
264
+ seen.add(key);
265
+ return `${lead}--${key}: ${tokens[key]};`;
266
+ });
267
+ for (const [key, value] of Object.entries(tokens)) if (!seen.has(key)) next += `\n --${key}: ${value};`;
268
+ return open + next + close;
269
+ });
270
+ };
271
+ retint(':root', { radius: preset.radius, ...preset.light });
272
+ retint('\\.dark', preset.dark);
273
+ await fs.writeFile(cssFile, css);
274
+ }
275
+
276
+ const sans = preset.fonts?.sans;
277
+ const layoutFile = path.join(appDir, 'src', 'app', 'layout.tsx');
278
+ if (sans && sans !== 'Geist') {
279
+ const id = sans.replace(/\s+/g, '_');
280
+ const layout = await fs.readFile(layoutFile, 'utf8').catch(() => null);
281
+ if (layout !== null) {
282
+ await fs.writeFile(layoutFile, layout
283
+ .replace('import { Geist, Geist_Mono } from "next/font/google";', `import { ${id}, Geist_Mono } from "next/font/google";`)
284
+ .replace('const sans = Geist({', `const sans = ${id}({`));
285
+ }
286
+ }
287
+
288
+ const guide = path.join(appDir, 'TEMPLATE.md');
289
+ const text = await fs.readFile(guide, 'utf8').catch(() => null);
290
+ if (text !== null) {
291
+ await fs.writeFile(guide, `${text.trimEnd()}\n\n## Design\n\nThis app uses the **${preset.name}** preset — ` +
292
+ `${preset.summary}. Font: ${sans ?? 'Geist'}. The palette lives in globals.css (light and dark): ` +
293
+ 'build with the tokens (bg-primary, text-muted-foreground, border, ...) rather than raw colours, ' +
294
+ 'so every screen stays in one look.\n');
295
+ }
296
+ return preset;
297
+ }
298
+
299
+ /**
300
+ * Start an app, and — when the model passes them — write its files in the
301
+ * same call.
302
+ *
303
+ * @param {object} o
304
+ * @param {string} o.folder new, empty folder for the app
305
+ * @param {string} o.name display name, e.g. "Stride"
306
+ * @param {string} [o.description]
307
+ * @param {string} [o.template] defaults to plain-html: nothing to install
308
+ * @param {string} [o.design]
309
+ * @param {{path: string, content: string}[]} [o.files] the app itself, paths
310
+ * relative to the project root, written straight over the starter's
311
+ * @param {boolean} [o.install] start the background install (tests turn it off)
312
+ */
313
+ export async function createApp({ folder, name, description, template = 'plain-html', design, files, install = true }) {
314
+ if (!TEMPLATE_NAMES.includes(template)) {
315
+ throw new ToolFailure({
316
+ kind: 'bad_args',
317
+ attempted: 'creating an app',
318
+ failed: `There is no starter called "${template}".`,
319
+ fix: `Use one of: ${TEMPLATE_NAMES.join(', ')}.`,
320
+ });
321
+ }
322
+
323
+ // Every shape a model reaches for when handing over a set of files. Reading
324
+ // them all costs nothing; refusing them costs a round trip each, which is
325
+ // the whole reason this argument exists.
326
+ const given = normaliseFiles(files);
327
+
328
+ // Checked before anything is copied: a bad entry found halfway through
329
+ // would leave the folder created, and the retry would then be refused for
330
+ // already having files in it.
331
+ const bad = given.findIndex(
332
+ (f) => typeof f?.path !== 'string' || typeof f?.content !== 'string'
333
+ );
334
+ if (bad !== -1) {
335
+ throw new ToolFailure({
336
+ kind: 'bad_args',
337
+ attempted: 'creating an app',
338
+ failed: `Entry ${bad + 1} of "files" is missing "path" or "content" — both must be strings.`,
339
+ fix: 'Fix that entry and call create_app again. Nothing has been created yet.',
340
+ });
341
+ }
342
+
343
+ // The user said no framework. A starter that costs an npm install and a
344
+ // build is then not a judgement call the model gets to make on their behalf
345
+ // — it is the one thing they ruled out, and the cost of getting it wrong is
346
+ // minutes of their time plus a rebuild from nothing.
347
+ let overrode = false;
348
+ if (template === 'next-shadcn' && askedForPlainHtml()) {
349
+ template = 'plain-html';
350
+ overrode = true;
351
+ }
352
+
353
+ const target = resolveIn(folder, 'create_app', 'folder');
354
+ const attempted = `creating an app in ${target.show}`;
355
+ if (target.show === '.') {
356
+ throw new ToolFailure({
357
+ kind: 'bad_args',
358
+ attempted,
359
+ failed: 'The app needs its own folder, not the project root.',
360
+ fix: 'Pass a new folder name, e.g. "stride".',
361
+ });
362
+ }
363
+ await guard(target, `create an app in ${target.abs}`);
364
+
365
+ let existing = [];
366
+ try {
367
+ existing = await fs.readdir(target.abs);
368
+ } catch {
369
+ existing = [];
370
+ }
371
+ // A folder with something already in it, and the whole app passed in
372
+ // alongside: that is the second attempt at a build that half happened.
373
+ // Refusing it is how a Next.js build spent two calls being told no and then
374
+ // started over from nothing. The starter is already on disk — take the files
375
+ // and write them into it.
376
+ //
377
+ // With no files it is still a refusal, because then there is nothing to do
378
+ // but copy a starter over work that is already there.
379
+ const adopt = existing.length > 0 && given.length > 0;
380
+ if (existing.length && !adopt) {
381
+ throw new ToolFailure({
382
+ kind: 'not_empty',
383
+ attempted,
384
+ failed: `${target.show} already has ${existing.length} item(s) in it: ${existing.slice(0, 5).join(', ')}.`,
385
+ fix: 'Pick a new folder name, or pass the app in "files" to write it into the folder that is already there.',
386
+ });
387
+ }
388
+
389
+ const display = plain(name) || path.basename(target.abs);
390
+ const fill = {
391
+ __APP_NAME__: display,
392
+ __APP_SLUG__: slug(display),
393
+ __APP_DESCRIPTION__: plain(description) || display,
394
+ };
395
+
396
+ const copied = adopt ? [] : await copyTree(path.join(TEMPLATES, template), target.abs, fill);
397
+ // Next.js serves static files from public/; a plain page has no such place
398
+ // and an empty folder in a three-file app is clutter.
399
+ if (!adopt && template !== 'plain-html') await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
400
+ const look = await applyDesign(target.abs, design);
401
+
402
+ // Read before the app's own files land on top of it, so the tokens can be
403
+ // put back if the replacement arrives without any.
404
+ const starterCss = TOKEN_FILE[template]
405
+ ? await fs.readFile(path.join(target.abs, TOKEN_FILE[template]), 'utf8').catch(() => null)
406
+ : null;
407
+
408
+ // The starter has been installed on this machine before: hard-link that
409
+ // tree in, which is seconds where npm is a minute. Otherwise install as
410
+ // usual, and keep the result so the next app is instant.
411
+ let linked = 0;
412
+ const needsInstall = !adopt && template !== 'plain-html';
413
+ if (install && needsInstall) {
414
+ // Keyed on the starter's lockfile, which is the same for every app made
415
+ // from it — the app's own is rewritten by npm as it installs.
416
+ const lockText = await fs
417
+ .readFile(path.join(TEMPLATES, template, '_package-lock.json'), 'utf8')
418
+ .catch(() => null);
419
+ linked = await restore(target.abs, template, lockText);
420
+ if (!linked) {
421
+ const pkg = path.join(target.abs, 'package.json');
422
+ packageJsonWritten(pkg, await fs.readFile(pkg, 'utf8'));
423
+ installIn(target.abs)?.then((done) => {
424
+ if (done?.code === 0) populate(target.abs, template, lockText);
425
+ });
426
+ }
427
+ }
428
+
429
+ const guide = await fs.readFile(path.join(target.abs, 'TEMPLATE.md'), 'utf8').catch(() => '');
430
+
431
+ // The app's own files, written in this same call. Two round trips become
432
+ // one, and round trips are nearly all of the time a build takes.
433
+ // Next.js only: paths that have missed src/ are corrected before they land.
434
+ const placed = template === 'next-shadcn' ? placeForNext(given, path.basename(target.abs)) : { files: given, moved: [] };
435
+ const mine = placed.files;
436
+ const wrote = mine.length ? await batchWrite({ files: mine }) : null;
437
+ const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
438
+ const keptTokens = await keepDesignTokens(target.abs, template, written, starterCss);
439
+ const relinked = await unprefixOwnFolder(target.abs, written);
440
+
441
+ // The starter's own files, in full, so there is never a reason to read them
442
+ // back — and only the ones this call did not already write over. A read is
443
+ // another round trip to learn what ucode already knows.
444
+ const starter = [];
445
+ for (const rel of SHOW_BACK[template] ?? []) {
446
+ const abs = path.join(target.abs, rel);
447
+ if (written.has(abs)) continue;
448
+ const text = await fs.readFile(abs, 'utf8').catch(() => null);
449
+ if (text !== null) starter.push(`=== ${target.show}/${rel} ===\n${text}`);
450
+ }
451
+
452
+ const out = result(
453
+ (overrode
454
+ ? 'You asked for next-shadcn, but the request said plain HTML and no framework, so ' +
455
+ 'this is the plain-html starter instead: three files, no install, no build. Write ' +
456
+ 'the app in them.\n'
457
+ : '') +
458
+ `Created ${target.show} from the ${template} starter — ${copied.length} files, already known to build.\n` +
459
+ (look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
460
+ (wrote ? `\nYour ${mine.length} file${mine.length === 1 ? '' : 's'}:\n${wrote.content}\n` : '') +
461
+ (placed.moved.length
462
+ ? `\nMoved into place: ${placed.moved.join(', ')}. In Next.js a page is only a route at `
463
+ + 'src/app/<segment>/page.tsx, an API handler only at src/app/api/<name>/route.ts, and '
464
+ + 'components and libraries live under src/. A file written outside src/ builds fine and '
465
+ + 'is never served, which is the hardest kind of wrong to notice. Write the rest there '
466
+ + 'directly.\n'
467
+ : '') +
468
+ (relinked.length
469
+ ? `\nFixed in ${relinked.join(', ')}: links that began with "${path.basename(target.abs)}/". ` +
470
+ 'Paths in "files" are relative to the project root, but a link inside a page is ' +
471
+ 'relative to that page — so "index.html" beside "styles.css" links to it as ' +
472
+ '"styles.css", never "app/styles.css". Written that way the stylesheet and the ' +
473
+ 'script 404 and the page comes up as bare markup.\n'
474
+ : '') +
475
+ (keptTokens
476
+ ? `\nYour ${keptTokens} arrived with no :root block, so the starter's was kept above it — ` +
477
+ 'the palette, the spacing scale (--s1 to --s5), the radius and the motion timings. ' +
478
+ 'Build the rest of the stylesheet out of those variables: spacing that comes from a ' +
479
+ 'scale is the difference between a designed page and an arranged one. Re-tint the ' +
480
+ 'values to suit this app; do not go back to raw pixels.\n'
481
+ : '') +
482
+ (linked
483
+ ? `Its packages are already in place (${linked.toLocaleString()} files, linked from the starter cache) — ` +
484
+ 'nothing to install: build and run straight away.\n'
485
+ : install && needsInstall
486
+ ? `Its packages are installing in the background right now. Keep writing: any command you run in ` +
487
+ `${target.show} waits for that install first, so there is no need to run npm install.\n`
488
+ : '') +
489
+ (needsInstall
490
+ ? `Run this app's commands with cwd: "${target.show}" (npm run build, npm run dev).\n\n${guide}`
491
+ : `Nothing to install and nothing to build: open ${target.show}/index.html directly, or serve the ` +
492
+ `folder with "python -m http.server 8000" if it fetches anything.\n\n${guide}`) +
493
+ (starter.length
494
+ ? `\n\nThe starter's files, in full — they are below, so do not read them back:\n\n${starter.join('\n\n')}`
495
+ : ''),
496
+ `${copied.length} files${wrote ? ` · ${mine.length} written` : ''}` +
497
+ `${linked ? ' · packages ready' : install && needsInstall ? ' · installing in the background' : ''}`,
498
+ 24_000
499
+ );
500
+ if (wrote?.diff?.length) out.diff = wrote.diff;
501
+ return out;
502
+ }