termi-kids 0.1.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/LICENSE +34 -0
- package/README.md +148 -0
- package/SAFETY.md +187 -0
- package/bin/termi.js +22 -0
- package/dist/agent/context.js +126 -0
- package/dist/agent/loop.js +172 -0
- package/dist/agent/prompts/system.js +45 -0
- package/dist/agent/tools.js +335 -0
- package/dist/auth/keychain.js +146 -0
- package/dist/auth/oauth.js +375 -0
- package/dist/auth/tokens.js +219 -0
- package/dist/cli.js +258 -0
- package/dist/config/paths.js +92 -0
- package/dist/config/pin.js +150 -0
- package/dist/config/settings.js +131 -0
- package/dist/grownups/panel.js +483 -0
- package/dist/learn/lessons.js +490 -0
- package/dist/learn/runner.js +193 -0
- package/dist/preview/server.js +407 -0
- package/dist/projects/create.js +103 -0
- package/dist/projects/ideas.js +182 -0
- package/dist/projects/quests.js +277 -0
- package/dist/projects/scaffolds/art.js +484 -0
- package/dist/projects/scaffolds/biggames.js +554 -0
- package/dist/projects/scaffolds/characters.js +580 -0
- package/dist/projects/scaffolds/games.js +516 -0
- package/dist/projects/scaffolds/index.js +24 -0
- package/dist/projects/scaffolds/music.js +528 -0
- package/dist/projects/scaffolds/pets.js +567 -0
- package/dist/projects/scaffolds/quizzes.js +757 -0
- package/dist/projects/scaffolds/stories.js +620 -0
- package/dist/projects/scaffolds/vendor/KAPLAY-LICENSE.txt +35 -0
- package/dist/projects/scaffolds/vendor/kaplay.mjs +57 -0
- package/dist/projects/scaffolds/websites.js +474 -0
- package/dist/projects/snapshots.js +203 -0
- package/dist/projects/store.js +325 -0
- package/dist/providers/errors.js +207 -0
- package/dist/providers/index.js +316 -0
- package/dist/providers/models.js +38 -0
- package/dist/safety/audit.js +195 -0
- package/dist/safety/blocks.js +29 -0
- package/dist/safety/classifier.js +337 -0
- package/dist/safety/codescan.js +168 -0
- package/dist/safety/guarddownload.js +79 -0
- package/dist/safety/guardrunner.js +125 -0
- package/dist/safety/localguard.js +227 -0
- package/dist/safety/modelstore.js +127 -0
- package/dist/safety/prefilter.js +214 -0
- package/dist/safety/session.js +118 -0
- package/dist/safety/taxonomy.js +246 -0
- package/dist/safety/textextract.js +193 -0
- package/dist/setup/launcher.js +65 -0
- package/dist/setup/wizard.js +469 -0
- package/dist/surfaces/chat.js +439 -0
- package/dist/surfaces/commands.js +206 -0
- package/dist/surfaces/home.js +438 -0
- package/dist/types.js +5 -0
- package/dist/ui/banner.js +35 -0
- package/dist/ui/celebrate.js +141 -0
- package/dist/ui/errors.js +97 -0
- package/dist/ui/mascot.js +223 -0
- package/dist/ui/text.js +156 -0
- package/dist/ui/theme.js +92 -0
- package/package.json +67 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Termi preview server.
|
|
3
|
+
*
|
|
4
|
+
* Serves a kid's project folder over plain HTTP on 127.0.0.1 only.
|
|
5
|
+
* Adds live reload through a small server-sent-events channel.
|
|
6
|
+
* Security posture:
|
|
7
|
+
* - bound strictly to the loopback address
|
|
8
|
+
* - strict Content-Security-Policy on every response (primary egress control)
|
|
9
|
+
* - path traversal guard with win32 case folding
|
|
10
|
+
* - dotfiles and TERMI.md are never served
|
|
11
|
+
* - no directory listings, no caching, no sniffing
|
|
12
|
+
*/
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import fsp from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import open from 'open';
|
|
18
|
+
import { previewBasePort } from '../config/paths.js';
|
|
19
|
+
const PORT_SCAN_RANGE = 50;
|
|
20
|
+
const SSE_PATH = '/__termi/reload';
|
|
21
|
+
const SSE_SCRIPT_PATH = '/__termi/reload.js';
|
|
22
|
+
const HEARTBEAT_MS = 25_000;
|
|
23
|
+
const DEBOUNCE_MS = 100;
|
|
24
|
+
/** Exact policy from the safety plan. CSP is the sound egress control. */
|
|
25
|
+
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
|
26
|
+
"img-src 'self' data:; media-src 'self' data:; connect-src 'self'; " +
|
|
27
|
+
"frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'self'";
|
|
28
|
+
const MIME = {
|
|
29
|
+
html: 'text/html; charset=utf-8',
|
|
30
|
+
css: 'text/css; charset=utf-8',
|
|
31
|
+
js: 'text/javascript; charset=utf-8',
|
|
32
|
+
mjs: 'text/javascript; charset=utf-8',
|
|
33
|
+
json: 'application/json; charset=utf-8',
|
|
34
|
+
png: 'image/png',
|
|
35
|
+
jpg: 'image/jpeg',
|
|
36
|
+
jpeg: 'image/jpeg',
|
|
37
|
+
gif: 'image/gif',
|
|
38
|
+
svg: 'image/svg+xml',
|
|
39
|
+
ico: 'image/x-icon',
|
|
40
|
+
txt: 'text/plain; charset=utf-8',
|
|
41
|
+
wav: 'audio/wav',
|
|
42
|
+
mp3: 'audio/mpeg',
|
|
43
|
+
};
|
|
44
|
+
const DEFAULT_MIME = 'application/octet-stream';
|
|
45
|
+
/**
|
|
46
|
+
* Loaded by the tag injected into every HTML page.
|
|
47
|
+
* Kept as a same-origin file so the CSP (script-src 'self') allows it.
|
|
48
|
+
*/
|
|
49
|
+
const RELOAD_CLIENT_JS = [
|
|
50
|
+
'(function () {',
|
|
51
|
+
" var source = new EventSource('" + SSE_PATH + "');",
|
|
52
|
+
' source.onmessage = function () { location.reload(); };',
|
|
53
|
+
'})();',
|
|
54
|
+
].join('\n');
|
|
55
|
+
const RELOAD_SCRIPT_TAG = '<script src="' + SSE_SCRIPT_PATH + '"></script>';
|
|
56
|
+
/**
|
|
57
|
+
* Served when a project has no favicon of its own. Browsers request
|
|
58
|
+
* /favicon.ico on every load; without this the console shows a 404
|
|
59
|
+
* error on every project. A tiny robot face keeps it friendly.
|
|
60
|
+
*/
|
|
61
|
+
const FALLBACK_FAVICON_SVG = [
|
|
62
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">',
|
|
63
|
+
'<rect x="2" y="3" width="12" height="10" rx="2" fill="#7fd4ff"/>',
|
|
64
|
+
'<rect x="4.5" y="6" width="2.5" height="2.5" rx="0.5" fill="#101326"/>',
|
|
65
|
+
'<rect x="9" y="6" width="2.5" height="2.5" rx="0.5" fill="#101326"/>',
|
|
66
|
+
'<rect x="5" y="10" width="6" height="1.5" rx="0.75" fill="#101326"/>',
|
|
67
|
+
'<rect x="7.25" y="1" width="1.5" height="2" fill="#7fd4ff"/>',
|
|
68
|
+
'</svg>',
|
|
69
|
+
].join('');
|
|
70
|
+
/** Friendly 404 page. No outside files. A tiny text robot keeps it warm. */
|
|
71
|
+
const NOT_FOUND_HTML = `<!doctype html>
|
|
72
|
+
<html lang="en">
|
|
73
|
+
<head>
|
|
74
|
+
<meta charset="utf-8">
|
|
75
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
76
|
+
<title>Page not found</title>
|
|
77
|
+
<style>
|
|
78
|
+
body { background: #101326; color: #e8e9f5; font-family: system-ui, sans-serif;
|
|
79
|
+
display: flex; align-items: center; justify-content: center;
|
|
80
|
+
min-height: 100vh; margin: 0; text-align: center; }
|
|
81
|
+
.card { padding: 2rem 2.5rem; }
|
|
82
|
+
pre { color: #7fd4ff; font-size: 1.1rem; line-height: 1.25; margin: 0 0 1rem; }
|
|
83
|
+
h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
|
|
84
|
+
p { margin: 0.3rem 0; color: #b9bdd8; }
|
|
85
|
+
a { color: #7fd4ff; }
|
|
86
|
+
</style>
|
|
87
|
+
</head>
|
|
88
|
+
<body>
|
|
89
|
+
<div class="card">
|
|
90
|
+
<pre>
|
|
91
|
+
___
|
|
92
|
+
[o_o]
|
|
93
|
+
/|===|\\
|
|
94
|
+
|___|
|
|
95
|
+
d b
|
|
96
|
+
</pre>
|
|
97
|
+
<h1>Termi looked everywhere.</h1>
|
|
98
|
+
<p>This page is not in your project.</p>
|
|
99
|
+
<p>Check the file name. Then try again.</p>
|
|
100
|
+
<p><a href="/">Go back to your project</a></p>
|
|
101
|
+
</div>
|
|
102
|
+
</body>
|
|
103
|
+
</html>
|
|
104
|
+
`;
|
|
105
|
+
/** Case folds a path on Windows so jail checks ignore letter case. */
|
|
106
|
+
function foldCase(p) {
|
|
107
|
+
return process.platform === 'win32' ? p.toLowerCase() : p;
|
|
108
|
+
}
|
|
109
|
+
/** True when target is projectRoot itself or lives inside it. */
|
|
110
|
+
function isInsideRoot(projectRoot, target) {
|
|
111
|
+
const rootCmp = foldCase(projectRoot);
|
|
112
|
+
const targetCmp = foldCase(target);
|
|
113
|
+
if (targetCmp === rootCmp)
|
|
114
|
+
return true;
|
|
115
|
+
const rootWithSep = rootCmp.endsWith(path.sep) ? rootCmp : rootCmp + path.sep;
|
|
116
|
+
return targetCmp.startsWith(rootWithSep);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Resolves a decoded URL path against the project root.
|
|
120
|
+
* Returns null when the result escapes the root, names a dotfile,
|
|
121
|
+
* or names TERMI.md.
|
|
122
|
+
*/
|
|
123
|
+
function resolveSafe(projectRoot, decodedPath) {
|
|
124
|
+
if (decodedPath.includes('\0'))
|
|
125
|
+
return null;
|
|
126
|
+
// Treat backslashes as separators too, then drop leading separators
|
|
127
|
+
// so absolute-looking requests stay relative to the root.
|
|
128
|
+
const rel = decodedPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
129
|
+
const target = path.resolve(projectRoot, rel);
|
|
130
|
+
if (!isInsideRoot(projectRoot, target))
|
|
131
|
+
return null;
|
|
132
|
+
const relFromRoot = path.relative(projectRoot, target);
|
|
133
|
+
if (relFromRoot.length > 0) {
|
|
134
|
+
for (const segment of relFromRoot.split(path.sep)) {
|
|
135
|
+
if (segment.startsWith('.'))
|
|
136
|
+
return null;
|
|
137
|
+
if (segment.toLowerCase() === 'termi.md')
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return target;
|
|
142
|
+
}
|
|
143
|
+
function baseHeaders(contentType) {
|
|
144
|
+
return {
|
|
145
|
+
'Content-Type': contentType,
|
|
146
|
+
'Cache-Control': 'no-store',
|
|
147
|
+
'Content-Security-Policy': CSP,
|
|
148
|
+
'X-Content-Type-Options': 'nosniff',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function sendText(res, status, contentType, body, headOnly) {
|
|
152
|
+
const buf = Buffer.from(body, 'utf-8');
|
|
153
|
+
res.writeHead(status, {
|
|
154
|
+
...baseHeaders(contentType),
|
|
155
|
+
'Content-Length': String(buf.byteLength),
|
|
156
|
+
});
|
|
157
|
+
res.end(headOnly ? undefined : buf);
|
|
158
|
+
}
|
|
159
|
+
function sendNotFound(res, headOnly) {
|
|
160
|
+
sendText(res, 404, MIME['html'] ?? DEFAULT_MIME, NOT_FOUND_HTML, headOnly);
|
|
161
|
+
}
|
|
162
|
+
/** Injects the reload script tag right before the closing body tag. */
|
|
163
|
+
export function injectReloadScript(html) {
|
|
164
|
+
const match = /<\/body>/i.exec(html);
|
|
165
|
+
if (!match)
|
|
166
|
+
return html + '\n' + RELOAD_SCRIPT_TAG + '\n';
|
|
167
|
+
const at = match.index;
|
|
168
|
+
return html.slice(0, at) + RELOAD_SCRIPT_TAG + '\n' + html.slice(at);
|
|
169
|
+
}
|
|
170
|
+
function listenOnce(server, port) {
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
const onError = (err) => {
|
|
173
|
+
server.removeListener('listening', onListening);
|
|
174
|
+
if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
|
|
175
|
+
resolve(false);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
reject(err);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const onListening = () => {
|
|
182
|
+
server.removeListener('error', onError);
|
|
183
|
+
resolve(true);
|
|
184
|
+
};
|
|
185
|
+
server.once('error', onError);
|
|
186
|
+
server.once('listening', onListening);
|
|
187
|
+
server.listen({ host: '127.0.0.1', port, exclusive: true });
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
export async function startPreview(projectDir, opts) {
|
|
191
|
+
const projectRoot = path.resolve(projectDir);
|
|
192
|
+
const sseClients = new Set();
|
|
193
|
+
let stopped = false;
|
|
194
|
+
let debounceTimer = null;
|
|
195
|
+
const broadcast = () => {
|
|
196
|
+
for (const client of sseClients) {
|
|
197
|
+
try {
|
|
198
|
+
client.write('data: reload\n\n');
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
sseClients.delete(client);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const notifyChange = () => {
|
|
206
|
+
if (stopped)
|
|
207
|
+
return;
|
|
208
|
+
if (debounceTimer)
|
|
209
|
+
clearTimeout(debounceTimer);
|
|
210
|
+
debounceTimer = setTimeout(() => {
|
|
211
|
+
debounceTimer = null;
|
|
212
|
+
broadcast();
|
|
213
|
+
}, DEBOUNCE_MS);
|
|
214
|
+
};
|
|
215
|
+
const handleSse = (req, res) => {
|
|
216
|
+
res.writeHead(200, {
|
|
217
|
+
...baseHeaders('text/event-stream'),
|
|
218
|
+
Connection: 'keep-alive',
|
|
219
|
+
});
|
|
220
|
+
res.write(': connected\n\n');
|
|
221
|
+
sseClients.add(res);
|
|
222
|
+
req.on('close', () => {
|
|
223
|
+
sseClients.delete(res);
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
const serveFile = async (res, filePath, headOnly) => {
|
|
227
|
+
let target = filePath;
|
|
228
|
+
let stats;
|
|
229
|
+
try {
|
|
230
|
+
stats = await fsp.stat(target);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
sendNotFound(res, headOnly);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (stats.isDirectory()) {
|
|
237
|
+
// Directories serve their index.html only. Never a listing.
|
|
238
|
+
target = path.join(target, 'index.html');
|
|
239
|
+
try {
|
|
240
|
+
stats = await fsp.stat(target);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
sendNotFound(res, headOnly);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (stats.isDirectory()) {
|
|
247
|
+
sendNotFound(res, headOnly);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const ext = path.extname(target).slice(1).toLowerCase();
|
|
252
|
+
const mime = MIME[ext] ?? DEFAULT_MIME;
|
|
253
|
+
if (ext === 'html') {
|
|
254
|
+
const html = await fsp.readFile(target, 'utf-8');
|
|
255
|
+
sendText(res, 200, mime, injectReloadScript(html), headOnly);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const data = await fsp.readFile(target);
|
|
259
|
+
res.writeHead(200, {
|
|
260
|
+
...baseHeaders(mime),
|
|
261
|
+
'Content-Length': String(data.byteLength),
|
|
262
|
+
});
|
|
263
|
+
res.end(headOnly ? undefined : data);
|
|
264
|
+
};
|
|
265
|
+
const server = http.createServer((req, res) => {
|
|
266
|
+
void (async () => {
|
|
267
|
+
const method = req.method ?? 'GET';
|
|
268
|
+
const headOnly = method === 'HEAD';
|
|
269
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
270
|
+
sendText(res, 405, MIME['txt'] ?? DEFAULT_MIME, 'Only GET works here.\n', false);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
let pathname;
|
|
274
|
+
try {
|
|
275
|
+
pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
sendNotFound(res, headOnly);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (pathname === SSE_PATH) {
|
|
282
|
+
handleSse(req, res);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (pathname === SSE_SCRIPT_PATH) {
|
|
286
|
+
sendText(res, 200, MIME['js'] ?? DEFAULT_MIME, RELOAD_CLIENT_JS, headOnly);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
let decoded;
|
|
290
|
+
try {
|
|
291
|
+
decoded = decodeURIComponent(pathname);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
sendNotFound(res, headOnly);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const target = resolveSafe(projectRoot, decoded);
|
|
298
|
+
if (target === null) {
|
|
299
|
+
sendNotFound(res, headOnly);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
// Built-in favicon fallback so every project load stays error free.
|
|
303
|
+
if (decoded === '/favicon.ico' && !fs.existsSync(target)) {
|
|
304
|
+
sendText(res, 200, 'image/svg+xml', FALLBACK_FAVICON_SVG, headOnly);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
await serveFile(res, target, headOnly);
|
|
308
|
+
})().catch(() => {
|
|
309
|
+
try {
|
|
310
|
+
if (!res.headersSent) {
|
|
311
|
+
sendText(res, 500, MIME['txt'] ?? DEFAULT_MIME, 'Something went wrong. Try again.\n', false);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
res.end();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// Socket already gone. Nothing left to do.
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
// Find a port: explicit port from opts, or base port plus an upward scan.
|
|
323
|
+
let boundPort = null;
|
|
324
|
+
const candidates = [];
|
|
325
|
+
if (opts?.port !== undefined) {
|
|
326
|
+
candidates.push(opts.port);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
for (let i = 0; i <= PORT_SCAN_RANGE; i += 1) {
|
|
330
|
+
candidates.push(previewBasePort + i);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
for (const candidate of candidates) {
|
|
334
|
+
// eslint-disable-next-line no-await-in-loop
|
|
335
|
+
if (await listenOnce(server, candidate)) {
|
|
336
|
+
boundPort = candidate;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (boundPort === null) {
|
|
341
|
+
throw new Error('No free port for the preview. Close other previews and try again.');
|
|
342
|
+
}
|
|
343
|
+
// Heartbeat comments keep proxies from dropping quiet SSE connections.
|
|
344
|
+
const heartbeat = setInterval(() => {
|
|
345
|
+
for (const client of sseClients) {
|
|
346
|
+
try {
|
|
347
|
+
client.write(': heartbeat\n\n');
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
sseClients.delete(client);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}, HEARTBEAT_MS);
|
|
354
|
+
heartbeat.unref();
|
|
355
|
+
// Backup watcher only. Tool code calls notifyChange() directly after writes.
|
|
356
|
+
let watcher = null;
|
|
357
|
+
try {
|
|
358
|
+
watcher = fs.watch(projectRoot, { recursive: true }, () => notifyChange());
|
|
359
|
+
watcher.on('error', () => {
|
|
360
|
+
// Recursive watch can fail on some platforms. The direct trigger covers us.
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
watcher = null;
|
|
365
|
+
}
|
|
366
|
+
const url = `http://127.0.0.1:${boundPort}/`;
|
|
367
|
+
const stop = async () => {
|
|
368
|
+
if (stopped)
|
|
369
|
+
return;
|
|
370
|
+
stopped = true;
|
|
371
|
+
if (debounceTimer) {
|
|
372
|
+
clearTimeout(debounceTimer);
|
|
373
|
+
debounceTimer = null;
|
|
374
|
+
}
|
|
375
|
+
clearInterval(heartbeat);
|
|
376
|
+
for (const client of sseClients) {
|
|
377
|
+
try {
|
|
378
|
+
client.end();
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// Already closed.
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
sseClients.clear();
|
|
385
|
+
if (watcher) {
|
|
386
|
+
try {
|
|
387
|
+
watcher.close();
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
// Already closed.
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
await new Promise((resolve) => {
|
|
394
|
+
server.close(() => resolve());
|
|
395
|
+
server.closeAllConnections();
|
|
396
|
+
});
|
|
397
|
+
};
|
|
398
|
+
if (opts?.openBrowser) {
|
|
399
|
+
try {
|
|
400
|
+
await open(url);
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
// The preview still works. The kid can open the link by hand.
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return { url, port: boundPort, notifyChange, stop };
|
|
407
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project creation: turn a scaffold, a theme, and a name into a real
|
|
3
|
+
* project on disk, ready for the preview server before any model call.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { projectsDir } from '../config/paths.js';
|
|
8
|
+
import { scaffoldById } from './scaffolds/index.js';
|
|
9
|
+
import { notesFileName, openProject, renderTermiMd, saveProjectMeta, } from './store.js';
|
|
10
|
+
/** Slugs may never be a Windows reserved device name. */
|
|
11
|
+
const windowsReserved = new Set(['con', 'prn', 'aux', 'nul']);
|
|
12
|
+
for (let i = 1; i <= 9; i += 1) {
|
|
13
|
+
windowsReserved.add(`com${i}`);
|
|
14
|
+
windowsReserved.add(`lpt${i}`);
|
|
15
|
+
}
|
|
16
|
+
const maxSlugLength = 40;
|
|
17
|
+
/**
|
|
18
|
+
* Turns a pretty name into a safe folder slug.
|
|
19
|
+
* Lowercase a-z, 0-9 and dashes only. Emoji and accents are dropped.
|
|
20
|
+
* Empty results become "my-project". Windows reserved names get "-app".
|
|
21
|
+
* If the slug's folder already exists, a free "-2", "-3"... slug is
|
|
22
|
+
* returned and collision is true.
|
|
23
|
+
*/
|
|
24
|
+
export function slugifyName(input) {
|
|
25
|
+
let s = input
|
|
26
|
+
.normalize('NFKD')
|
|
27
|
+
.replace(/\p{M}+/gu, '')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
30
|
+
.replace(/-+/g, '-')
|
|
31
|
+
.replace(/^-+/, '')
|
|
32
|
+
.replace(/-+$/, '');
|
|
33
|
+
if (s.length > maxSlugLength) {
|
|
34
|
+
s = s.slice(0, maxSlugLength).replace(/-+$/, '');
|
|
35
|
+
}
|
|
36
|
+
if (s.length === 0)
|
|
37
|
+
s = 'my-project';
|
|
38
|
+
if (windowsReserved.has(s))
|
|
39
|
+
s = `${s}-app`;
|
|
40
|
+
const taken = (slug) => fs.existsSync(path.join(projectsDir(), slug));
|
|
41
|
+
if (!taken(s)) {
|
|
42
|
+
return { slug: s, collision: false };
|
|
43
|
+
}
|
|
44
|
+
let n = 2;
|
|
45
|
+
while (taken(`${s}-${n}`))
|
|
46
|
+
n += 1;
|
|
47
|
+
return { slug: `${s}-${n}`, collision: true };
|
|
48
|
+
}
|
|
49
|
+
function writeRel(dir, relPath, content) {
|
|
50
|
+
const target = path.join(dir, ...relPath.split('/'));
|
|
51
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
52
|
+
fs.writeFileSync(target, content, 'utf8');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Creates a new project from a scaffold and theme.
|
|
56
|
+
* Writes the kid files, any vendored engine files, TERMI.md, and
|
|
57
|
+
* .termi.json, then opens the project. Throws kid-readable errors.
|
|
58
|
+
*/
|
|
59
|
+
export function createProject(scaffoldId, themeId, prettyNameInput) {
|
|
60
|
+
const scaffold = scaffoldById(scaffoldId);
|
|
61
|
+
if (!scaffold) {
|
|
62
|
+
throw new Error('I do not know that project type. Pick one from the menu.');
|
|
63
|
+
}
|
|
64
|
+
const theme = scaffold.themes.find((t) => t.id === themeId);
|
|
65
|
+
if (!theme) {
|
|
66
|
+
throw new Error('I do not know that style. Pick one from the list.');
|
|
67
|
+
}
|
|
68
|
+
const trimmed = prettyNameInput.trim();
|
|
69
|
+
const prettyName = trimmed.length > 0 ? trimmed : 'My Project';
|
|
70
|
+
const { slug } = slugifyName(prettyName);
|
|
71
|
+
const dir = path.join(projectsDir(), slug);
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
73
|
+
const files = scaffold.files(theme, prettyName);
|
|
74
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
75
|
+
writeRel(dir, relPath, content);
|
|
76
|
+
}
|
|
77
|
+
for (const [relPath, content] of Object.entries(scaffold.vendorFiles ?? {})) {
|
|
78
|
+
writeRel(dir, relPath, content);
|
|
79
|
+
}
|
|
80
|
+
if (!(notesFileName in files)) {
|
|
81
|
+
const kidFiles = Object.keys(files).filter((f) => f !== notesFileName);
|
|
82
|
+
writeRel(dir, notesFileName, renderTermiMd(prettyName, {
|
|
83
|
+
whatThisIs: `A ${scaffold.label} starter. ${theme.narrativeIntro}`,
|
|
84
|
+
files: kidFiles,
|
|
85
|
+
builtSoFar: [`Started from the ${scaffold.label} starter`],
|
|
86
|
+
recapLine: `We just made ${prettyName}!`,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
saveProjectMeta({
|
|
91
|
+
slug,
|
|
92
|
+
prettyName,
|
|
93
|
+
scaffoldId,
|
|
94
|
+
themeId,
|
|
95
|
+
createdAt: now,
|
|
96
|
+
lastOpenedAt: now,
|
|
97
|
+
});
|
|
98
|
+
const project = openProject(slug);
|
|
99
|
+
if (project === null) {
|
|
100
|
+
throw new Error('Something went wrong while making the project. Try again.');
|
|
101
|
+
}
|
|
102
|
+
return { project, starterPrompts: scaffold.starterPrompts(theme) };
|
|
103
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated prompt ideas for `termi ideas` and /ideas.
|
|
3
|
+
* Each list starts tiny and grows bolder so kids can pick their level.
|
|
4
|
+
* Theme-agnostic on purpose: every idea works for any theme.
|
|
5
|
+
*/
|
|
6
|
+
const ideasByScaffold = {
|
|
7
|
+
games: [
|
|
8
|
+
'Make the player move a little faster.',
|
|
9
|
+
'Give the player three lives.',
|
|
10
|
+
'Make the obstacles spin as they fall.',
|
|
11
|
+
'Play a sound when you grab a bonus.',
|
|
12
|
+
'Add a power up that makes you tiny.',
|
|
13
|
+
'Make the game speed up every 20 points.',
|
|
14
|
+
'Add a high score that saves on this computer.',
|
|
15
|
+
'Add a shield that blocks one hit.',
|
|
16
|
+
'Add a boss level with a health bar.',
|
|
17
|
+
'Add a friendly robot that follows you around.',
|
|
18
|
+
'Make the background change color as you score.',
|
|
19
|
+
'Add a slow motion power up.',
|
|
20
|
+
'Give the obstacles funny faces.',
|
|
21
|
+
'Add a two player mode with a second set of keys.',
|
|
22
|
+
'Make a rainbow trail follow the player.',
|
|
23
|
+
],
|
|
24
|
+
biggames: [
|
|
25
|
+
'Make the player jump a little higher.',
|
|
26
|
+
'Add more things to collect in level one.',
|
|
27
|
+
'Make a platform that moves up and down.',
|
|
28
|
+
'Give the player a double jump.',
|
|
29
|
+
'Add a secret room with a big bonus.',
|
|
30
|
+
'Add a new enemy that walks back and forth.',
|
|
31
|
+
'Add a timer that shows how fast you finish.',
|
|
32
|
+
'Build a whole new level three.',
|
|
33
|
+
'Add a boss level with a health bar.',
|
|
34
|
+
'Add bouncy mushrooms that fling you up high.',
|
|
35
|
+
'Hide three stars in hard to reach spots.',
|
|
36
|
+
'Add a checkpoint flag in the middle of the level.',
|
|
37
|
+
'Give the player a cape that slows falling.',
|
|
38
|
+
'Add rain or snow to one level.',
|
|
39
|
+
'Make a bonus level that is all about jumping.',
|
|
40
|
+
],
|
|
41
|
+
art: [
|
|
42
|
+
'Add a new bright color to the paint set.',
|
|
43
|
+
'Make the brush bigger when you press B.',
|
|
44
|
+
'Add an eraser button.',
|
|
45
|
+
'Add a rainbow brush that changes color as you draw.',
|
|
46
|
+
'Add a stamp tool with fun shapes.',
|
|
47
|
+
'Add an undo button for the last stroke.',
|
|
48
|
+
'Let me save my picture to the computer.',
|
|
49
|
+
'Add a mirror mode that paints both sides at once.',
|
|
50
|
+
'Add a gallery page that shows all my saved art.',
|
|
51
|
+
'Add a spray paint brush with dots.',
|
|
52
|
+
'Make the canvas shake when you draw fast.',
|
|
53
|
+
'Add glow in the dark colors on a black canvas.',
|
|
54
|
+
'Add a button that draws a random doodle to finish.',
|
|
55
|
+
'Let me pick the canvas color before I start.',
|
|
56
|
+
'Add a symmetry mode that makes snowflakes.',
|
|
57
|
+
],
|
|
58
|
+
music: [
|
|
59
|
+
'Add one new drum sound.',
|
|
60
|
+
'Make the beat a little faster.',
|
|
61
|
+
'Add a button that plays a funny sound.',
|
|
62
|
+
'Make the lights flash with the beat.',
|
|
63
|
+
'Add a slider that changes the speed.',
|
|
64
|
+
'Let me record a short loop and play it back.',
|
|
65
|
+
'Add a new dance move for the dancer.',
|
|
66
|
+
'Make a song that builds up and then drops.',
|
|
67
|
+
'Add a whole second song with its own lights.',
|
|
68
|
+
'Add an animal band that plays your song.',
|
|
69
|
+
'Make a keyboard I can play with my keys.',
|
|
70
|
+
'Add a clap sound on every fourth beat.',
|
|
71
|
+
'Let me name and save my favorite beat.',
|
|
72
|
+
'Add a robot voice that counts the beat.',
|
|
73
|
+
'Make fireworks pop on the big drops.',
|
|
74
|
+
],
|
|
75
|
+
pets: [
|
|
76
|
+
'Give my pet a new snack to eat.',
|
|
77
|
+
'Make my pet blink now and then.',
|
|
78
|
+
'Add a happiness meter with hearts.',
|
|
79
|
+
'Add a play button with a tiny game inside.',
|
|
80
|
+
'Make my pet get sleepy at night.',
|
|
81
|
+
'Add a closet with hats for my pet.',
|
|
82
|
+
'Let my pet learn a trick after ten treats.',
|
|
83
|
+
'Add a friend pet that visits sometimes.',
|
|
84
|
+
'Add levels so my pet grows up over time.',
|
|
85
|
+
'Give my pet a tiny house it can go inside.',
|
|
86
|
+
'Add weather that changes my pet\'s mood.',
|
|
87
|
+
'Let my pet send me a note when it misses me.',
|
|
88
|
+
'Add a bath time with bubbles.',
|
|
89
|
+
'Give my pet a birthday with a cake.',
|
|
90
|
+
'Add a photo button that saves cute pet pictures.',
|
|
91
|
+
],
|
|
92
|
+
stories: [
|
|
93
|
+
'Add one more choice to the first scene.',
|
|
94
|
+
'Give a character a funny catchphrase.',
|
|
95
|
+
'Add a new scene with a surprise twist.',
|
|
96
|
+
'Add an item you can pick up and use later.',
|
|
97
|
+
'Make a new ending where everyone celebrates.',
|
|
98
|
+
'Add a riddle the reader must answer.',
|
|
99
|
+
'Add a sneaky shortcut that skips a scene.',
|
|
100
|
+
'Add a map screen that shows where you are.',
|
|
101
|
+
'Write a whole second chapter with new places.',
|
|
102
|
+
'Add a talking animal who gives hints.',
|
|
103
|
+
'Let the reader name the hero at the start.',
|
|
104
|
+
'Add a scene that is different every time.',
|
|
105
|
+
'Add a brave choice and a sneaky choice everywhere.',
|
|
106
|
+
'Give the story chapter titles with emoji.',
|
|
107
|
+
'Add a wrong turn that loops back with a joke.',
|
|
108
|
+
],
|
|
109
|
+
quizzes: [
|
|
110
|
+
'Add one new question to the quiz.',
|
|
111
|
+
'Add a silly wrong answer to a question.',
|
|
112
|
+
'Show a happy message for a perfect score.',
|
|
113
|
+
'Add a timer for each question.',
|
|
114
|
+
'Shuffle the questions every time.',
|
|
115
|
+
'Add emoji to the questions.',
|
|
116
|
+
'Count how many right answers in a row you get.',
|
|
117
|
+
'Add easy, medium, and hard levels.',
|
|
118
|
+
'Add a final boss question worth double points.',
|
|
119
|
+
'Add a picture question with emoji clues.',
|
|
120
|
+
'Let players pick a team name first.',
|
|
121
|
+
'Add a bonus round with rapid fire questions.',
|
|
122
|
+
'Play a drum roll before showing the score.',
|
|
123
|
+
'Add a trick question with a wink.',
|
|
124
|
+
'Show a medal for first, second, and third try.',
|
|
125
|
+
],
|
|
126
|
+
websites: [
|
|
127
|
+
'Add a new favorite to my list.',
|
|
128
|
+
'Change the page colors to my favorites.',
|
|
129
|
+
'Add a box for my favorite joke.',
|
|
130
|
+
'Add a big welcome banner at the top.',
|
|
131
|
+
'Make the buttons wiggle when you point at them.',
|
|
132
|
+
'Add a day and night switch.',
|
|
133
|
+
'Add a guest book that saves on this computer.',
|
|
134
|
+
'Add a second page about my hobby.',
|
|
135
|
+
'Hide a tiny game somewhere on the page.',
|
|
136
|
+
'Add a pet corner with drawings of animals.',
|
|
137
|
+
'Make confetti fall when the page opens.',
|
|
138
|
+
'Add a favorite songs list with star ratings.',
|
|
139
|
+
'Add a big red button that does something silly.',
|
|
140
|
+
'Make a fan page for your favorite animal.',
|
|
141
|
+
'Add a quiz about you for your friends.',
|
|
142
|
+
],
|
|
143
|
+
characters: [
|
|
144
|
+
'Give your character a new greeting.',
|
|
145
|
+
'Add a joke your character can tell.',
|
|
146
|
+
'Add a new question players can ask.',
|
|
147
|
+
'Give your character a happy mood and a grumpy mood.',
|
|
148
|
+
'Make the character remember your favorite color.',
|
|
149
|
+
'Add sound effects when the character talks.',
|
|
150
|
+
'Add a second character who butts in sometimes.',
|
|
151
|
+
'Add a quiz round your character hosts.',
|
|
152
|
+
'Give your character a secret story to unlock.',
|
|
153
|
+
'Give your character a theme song.',
|
|
154
|
+
'Let your character play rock paper scissors.',
|
|
155
|
+
'Add a sleepy mode after ten questions.',
|
|
156
|
+
'Give your character a best friend it talks about.',
|
|
157
|
+
'Let your character give out silly awards.',
|
|
158
|
+
'Add a story mode where it tells one long tale.',
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
/** Fallback ideas that fit any project. */
|
|
162
|
+
const genericIdeas = [
|
|
163
|
+
'Change the colors to your favorites.',
|
|
164
|
+
'Add a sound effect.',
|
|
165
|
+
'Add a fun title at the top.',
|
|
166
|
+
'Make something move on its own.',
|
|
167
|
+
'Add a surprise that shows up after a while.',
|
|
168
|
+
'Add a score or a counter.',
|
|
169
|
+
'Save something so it is still there tomorrow.',
|
|
170
|
+
'Add a whole new screen or level.',
|
|
171
|
+
'Add a silly secret that only you know about.',
|
|
172
|
+
'Give it a happy start screen with your project name.',
|
|
173
|
+
'Add a night mode with dark colors.',
|
|
174
|
+
];
|
|
175
|
+
/**
|
|
176
|
+
* Returns prompt ideas for a project type.
|
|
177
|
+
* Unknown ids get the generic list. Always returns a fresh copy.
|
|
178
|
+
*/
|
|
179
|
+
export function getIdeas(scaffoldId) {
|
|
180
|
+
const list = ideasByScaffold[scaffoldId];
|
|
181
|
+
return list !== undefined ? [...list] : [...genericIdeas];
|
|
182
|
+
}
|