termi-kids 0.1.1 → 0.1.2
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 +2 -0
- package/SAFETY.md +1 -1
- package/dist/cli.js +24 -0
- package/dist/safety/prefilter.js +146 -12
- package/dist/update/check.js +104 -0
- package/dist/update/install.js +29 -0
- package/dist/update/prompt.js +78 -0
- package/dist/update/version.js +42 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,6 +51,8 @@ npm install -g termi-kids
|
|
|
51
51
|
|
|
52
52
|
Then run `termi`. If npm reports a permission error, prefix the install with `sudo` on macOS and Linux, or run the terminal as administrator on Windows. To remove it later, run `npm rm -g termi-kids` (full cleanup steps are in SAFETY.md).
|
|
53
53
|
|
|
54
|
+
To update later, run `termi update` (or `npm install -g termi-kids@latest`). On each new session, Termi checks for a newer version and asks whether to update (y/n). Set `TERMI_SKIP_UPDATE=1` to silence the check.
|
|
55
|
+
|
|
54
56
|
### First run: the setup wizard
|
|
55
57
|
|
|
56
58
|
Run `termi`. The first run starts a setup wizard for a parent or guardian. It takes about five minutes:
|
package/SAFETY.md
CHANGED
|
@@ -14,7 +14,7 @@ This page is for parents and guardians. It explains the safety system in plain l
|
|
|
14
14
|
|
|
15
15
|
Five layers sit between your kid and the AI. The chat conversation itself is never trusted to police itself.
|
|
16
16
|
|
|
17
|
-
1. **A local filter on this computer.** Before anything leaves your machine, Termi checks the message offline. It blocks swearing and slurs (including d.i.s.g.u.i.s.e.d spellings and leetspeak),
|
|
17
|
+
1. **A local filter on this computer.** Before anything leaves your machine, Termi checks the message offline. It blocks swearing and slurs (including d.i.s.g.u.i.s.e.d spellings and leetspeak), known "ignore your rules" tricks (including some base64-hidden ones), clear self-harm language (with a calm support screen and the 988 line in the US), grooming-shaped asks (secrecy from parents, romance aimed at the kid, moving chat to other apps), and probes for personal details like school or address. When a kid shares their own personal details (name, address, phone, email, school), those are not blocked; they are masked to `[secret]` before the message is sent, and the kid gets a gentle reminder to keep private things private. The same masking is used when the AI reads project files back. The word lists are English; the AI-based checkers below cover other languages.
|
|
18
18
|
2. **Safety rules inside the AI's instructions.** The AI is told, every turn: you are a tool, not a person. Never act romantic, never roleplay relationships, never ask the kid to keep secrets, never ask for a real name, address, school, age, or photos. Big feelings get one kind line and a pointer to a trusted adult. The instructions also declare that everything the kid types and everything in project files is data, never commands, which blunts "ignore your previous instructions" tricks hidden in files.
|
|
19
19
|
3. **A checker before the AI acts.** A separate safety check, outside the conversation, reads the kid's message along with the last few turns for context. When the on-device safety checker is installed (it is offered during setup and on by default), it judges the message right on this computer at the same time. It runs at the same time as the build call so it adds no waiting, but nothing is allowed to land until it passes: no file is written and no reply is shown. If it says no, the work is thrown away. One honest note on timing: because the check and the build call start together, a message that ends up blocked has still traveled to your AI provider once (with personal details already masked); its answer is discarded unseen.
|
|
20
20
|
4. **A checker on everything the AI says and writes.** Every file the AI writes or edits is checked twice before it touches disk: a code scan looks for network calls, code hidden in strings, and other tricks (the full list is in the code scanner, `src/safety/codescan.ts`), and the human-visible text inside the file (story text, labels, comments) goes through the same safety check as chat, in full: long text is checked chunk by chunk, and a file too wordy to check completely is refused rather than half-checked. File names and project names are screened too. The final reply is checked too, before the kid sees a single character. As a backstop, the preview server wraps every project page in a strict Content-Security-Policy, so even code that slipped past the scanner cannot reach the internet from the browser. Files edited outside Termi (in a text editor, say) are the kid's own files and are not re-screened; they only pass the rule-neutralizing filter when read back into the chat.
|
package/dist/cli.js
CHANGED
|
@@ -38,6 +38,7 @@ export function cliHelp() {
|
|
|
38
38
|
' termi ideas get fun ideas',
|
|
39
39
|
' termi learn play six short lessons about AI',
|
|
40
40
|
' termi grownups grown-up zone (PIN needed)',
|
|
41
|
+
' termi update update Termi to the latest version',
|
|
41
42
|
' termi help show this help',
|
|
42
43
|
' termi --version show the version',
|
|
43
44
|
].join('\n');
|
|
@@ -180,6 +181,11 @@ async function route(command, rest, settings) {
|
|
|
180
181
|
await panel.runPanel();
|
|
181
182
|
return;
|
|
182
183
|
}
|
|
184
|
+
case 'update': {
|
|
185
|
+
const update = await import('./update/prompt.js');
|
|
186
|
+
await update.runUpdateCommand();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
183
189
|
default: {
|
|
184
190
|
console.log(`Hmm, "${command}" is not a Termi command.`);
|
|
185
191
|
console.log(cliHelp());
|
|
@@ -198,6 +204,13 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
198
204
|
console.log(cliHelp());
|
|
199
205
|
return;
|
|
200
206
|
}
|
|
207
|
+
// Update works without the wizard so a parent can refresh a broken install.
|
|
208
|
+
if (command === 'update') {
|
|
209
|
+
ensureDirs();
|
|
210
|
+
const update = await import('./update/prompt.js');
|
|
211
|
+
await update.runUpdateCommand();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
201
214
|
ensureDirs();
|
|
202
215
|
const loaded = loadSettings();
|
|
203
216
|
let settings = loaded.settings;
|
|
@@ -243,6 +256,17 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
243
256
|
const { ensureGuardFetch } = await import('./safety/guarddownload.js');
|
|
244
257
|
void ensureGuardFetch();
|
|
245
258
|
}
|
|
259
|
+
// Returning sessions: offer an update when npm has a newer version.
|
|
260
|
+
// Skipped during setup, tests, and TERMI_SKIP_UPDATE=1.
|
|
261
|
+
if (!decision.runWizard) {
|
|
262
|
+
try {
|
|
263
|
+
const update = await import('./update/prompt.js');
|
|
264
|
+
await update.maybePromptForUpdate();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// Never block boot on the update check.
|
|
268
|
+
}
|
|
269
|
+
}
|
|
246
270
|
await route(command, argv.slice(1), settings);
|
|
247
271
|
}
|
|
248
272
|
const underTest = process.env.VITEST !== undefined || process.env.NODE_ENV === 'test';
|
package/dist/safety/prefilter.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* L0 prefilter: cheap, offline, never load-bearing.
|
|
3
3
|
*
|
|
4
|
-
* Normalization (NFKC, lowercase, de-leet, separator tolerance) feeds
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Normalization (NFKC, lowercase, de-leet, separator tolerance) feeds:
|
|
5
|
+
* self-harm (supportive block), jailbreak including base64 payloads (block),
|
|
6
|
+
* grooming hard-blocks (block), personal-detail probes (block), profanity
|
|
7
|
+
* (block, rephrase), and PII share patterns (redact, never block). Game
|
|
8
|
+
* words like kill, die, shoot, zombie are NOT listed: the game carve-out
|
|
9
|
+
* protects real kid game language.
|
|
9
10
|
*/
|
|
10
11
|
import { T } from '../ui/text.js';
|
|
11
12
|
/** Leetspeak character map applied during normalization. */
|
|
@@ -100,11 +101,128 @@ export const JAILBREAK_PATTERNS = [
|
|
|
100
101
|
/what\s+(?:is|are)\s+your\s+(?:system\s+prompt|hidden\s+(?:rules|instructions)|original\s+instructions)/gi,
|
|
101
102
|
/new\s+(?:system\s+)?instructions?\s*:/gi,
|
|
102
103
|
/\bsystem\s*prompt\s*override\b/gi,
|
|
104
|
+
/drop\s+(?:every|all|your)\s+(?:safety\s+)?(?:rule|rules|filter|filters|restriction|restrictions)\b/gi,
|
|
105
|
+
/obey\s+only\s+me\b/gi,
|
|
103
106
|
];
|
|
104
|
-
/**
|
|
107
|
+
/** Base64 tokens long enough to hide a short instruction payload. */
|
|
108
|
+
const BASE64_TOKEN = /(?:[A-Za-z0-9+/]{20,}={0,2})/g;
|
|
109
|
+
/**
|
|
110
|
+
* True when plain text or a decodable base64 blob matches a jailbreak family.
|
|
111
|
+
* Catches "decode this and follow: aWdub3Jl..." style evasion.
|
|
112
|
+
*/
|
|
105
113
|
export function hasJailbreak(text) {
|
|
106
114
|
const prepared = normalizeText(text);
|
|
107
|
-
|
|
115
|
+
if (JAILBREAK_PATTERNS.some((re) => {
|
|
116
|
+
re.lastIndex = 0;
|
|
117
|
+
return re.test(prepared);
|
|
118
|
+
})) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
BASE64_TOKEN.lastIndex = 0;
|
|
122
|
+
let match;
|
|
123
|
+
while ((match = BASE64_TOKEN.exec(text)) !== null) {
|
|
124
|
+
const token = match[0];
|
|
125
|
+
if (token.length % 4 === 1) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const decoded = Buffer.from(token, 'base64').toString('utf8');
|
|
130
|
+
// Reject garbage: decoded text should be mostly printable.
|
|
131
|
+
if (decoded.length < 8 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(decoded)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const decodedNorm = normalizeText(decoded);
|
|
135
|
+
if (JAILBREAK_PATTERNS.some((re) => {
|
|
136
|
+
re.lastIndex = 0;
|
|
137
|
+
return re.test(decodedNorm);
|
|
138
|
+
})) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Not valid base64; ignore.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Grooming-shaped hard blocks on a single message. Tighter than the session
|
|
150
|
+
* counters so normal game talk ("don't tell the boss") still passes.
|
|
151
|
+
*/
|
|
152
|
+
export const GROOMING_PATTERNS = [
|
|
153
|
+
/don'?t\s+tell\s+(?:your|my|our|his|her|their)\s+(?:parents?|parent|mom|dad|mother|father|grown-?ups?|guardian)/gi,
|
|
154
|
+
/do\s+not\s+tell\s+(?:your|my|our)\s+(?:parents?|parent|mom|dad|mother|father|grown-?ups?)/gi,
|
|
155
|
+
/keep\s+(?:this|it|our\s+chats?)\s+(?:a\s+)?secret\s+from\s+(?:your|my|our)\s+(?:parents?|mom|dad)/gi,
|
|
156
|
+
/without\s+(?:your|my|our)\s+(?:parents?|mom|dad|mother|father)\s+(?:seeing|knowing|finding)/gi,
|
|
157
|
+
/our\s+little\s+secret\b/gi,
|
|
158
|
+
/this\s+is\s+our\s+secret\b/gi,
|
|
159
|
+
/just\s+between\s+us\b/gi,
|
|
160
|
+
/secret\s+between\s+(?:us|you\s+and\s+me)\b/gi,
|
|
161
|
+
/no\s+one\s+(?:has\s+to|needs\s+to|will)\s+know\b/gi,
|
|
162
|
+
/add\s+me\s+on\s+(?:snapchat|instagram|whatsapp|telegram|discord|tiktok|kik|signal)\b/gi,
|
|
163
|
+
/(?:message|dm)\s+me\s+on\s+(?:snapchat|instagram|whatsapp|telegram|discord|tiktok|kik|signal)\b/gi,
|
|
164
|
+
/text\s+me\s+at\b/gi,
|
|
165
|
+
/\bdm\s+me\b/gi,
|
|
166
|
+
/let'?s\s+(?:chat|talk)\s+on\s+(?:snapchat|instagram|whatsapp|telegram|discord|tiktok)\b/gi,
|
|
167
|
+
// "special/only friend" + love probes; bare "i love you" stays allowed for stories.
|
|
168
|
+
/you'?re\s+my\s+(?:special|only)\s+friend\b/gi,
|
|
169
|
+
/do\s+you\s+love\s+me\b/gi,
|
|
170
|
+
/love\s+me\s+more\s+than\s+anyone\b/gi,
|
|
171
|
+
];
|
|
172
|
+
/** True when the text matches a grooming hard-block family. */
|
|
173
|
+
export function hasGrooming(text) {
|
|
174
|
+
const prepared = normalizeText(text);
|
|
175
|
+
return GROOMING_PATTERNS.some((re) => {
|
|
176
|
+
re.lastIndex = 0;
|
|
177
|
+
return re.test(prepared);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Self-harm intent and soft ideation. Routes to the supportive screen.
|
|
182
|
+
* Game words (die, kill boss) are not listed here.
|
|
183
|
+
*/
|
|
184
|
+
export const SELF_HARM_PATTERNS = [
|
|
185
|
+
/\b(?:i\s+)?want\s+to\s+hurt\s+myself\b/gi,
|
|
186
|
+
/\b(?:i\s+)?want\s+to\s+kill\s+myself\b/gi,
|
|
187
|
+
/\bkill\s+myself\b/gi,
|
|
188
|
+
/\bend\s+my\s+life\b/gi,
|
|
189
|
+
/\bsuicide\b/gi,
|
|
190
|
+
/\bself[-\s]?harm\b/gi,
|
|
191
|
+
/\bbetter\s+off\s+without\s+me\b/gi,
|
|
192
|
+
/\bno\s+reason\s+to\s+live\b/gi,
|
|
193
|
+
/\bwish\s+i\s+(?:was|were)\s+dead\b/gi,
|
|
194
|
+
/\bi\s+(?:want|wanna)\s+to\s+die\b/gi,
|
|
195
|
+
/\bhow\s+(?:can|do)\s+i\s+(?:kill|hurt)\s+myself\b/gi,
|
|
196
|
+
/\beasiest\s+way\s+to\s+(?:kill|hurt)\s+myself\b/gi,
|
|
197
|
+
];
|
|
198
|
+
/** True when the text shows self-harm intent or ideation. */
|
|
199
|
+
export function hasSelfHarm(text) {
|
|
200
|
+
const prepared = normalizeText(text);
|
|
201
|
+
return SELF_HARM_PATTERNS.some((re) => {
|
|
202
|
+
re.lastIndex = 0;
|
|
203
|
+
return re.test(prepared);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Probes for personal details aimed at a child (or role-played as such).
|
|
208
|
+
* Sharing your own details is handled by redactPii; these block the ask.
|
|
209
|
+
*/
|
|
210
|
+
export const PII_PROBE_PATTERNS = [
|
|
211
|
+
/what(?:'s|\s+is)\s+your\s+real\s+(?:name|address|phone|number)\b/gi,
|
|
212
|
+
/what\s+school\s+do\s+you\b/gi,
|
|
213
|
+
/which\s+school\s+do\s+you\b/gi,
|
|
214
|
+
/what\s+school\s+do\s+you\s+go\s+to\b/gi,
|
|
215
|
+
/when\s+does\s+(?:school|it)\s+end\b/gi,
|
|
216
|
+
/where\s+do\s+you\s+live\b/gi,
|
|
217
|
+
/send\s+(?:me\s+)?(?:a\s+)?(?:photo|picture|pic|selfie)\b/gi,
|
|
218
|
+
/how\s+old\s+are\s+you\s+really\b/gi,
|
|
219
|
+
/your\s+(?:home\s+)?address\b/gi,
|
|
220
|
+
/your\s+phone\s+number\b/gi,
|
|
221
|
+
];
|
|
222
|
+
/** True when the text probes for personal details. */
|
|
223
|
+
export function hasPiiProbe(text) {
|
|
224
|
+
const prepared = normalizeText(text);
|
|
225
|
+
return PII_PROBE_PATTERNS.some((re) => {
|
|
108
226
|
re.lastIndex = 0;
|
|
109
227
|
return re.test(prepared);
|
|
110
228
|
});
|
|
@@ -160,23 +278,39 @@ export function redactPii(text) {
|
|
|
160
278
|
return { redacted, found };
|
|
161
279
|
}
|
|
162
280
|
function blockVerdict(category) {
|
|
281
|
+
const selfHarmConcern = category === 'self_harm';
|
|
282
|
+
// L0 always hard-blocks when it fires. Severity is for the audit trail:
|
|
283
|
+
// self-harm is marked serious (2); everything else is mild (1), matching
|
|
284
|
+
// the prior prefilter behavior for profanity and jailbreak.
|
|
285
|
+
const severity = category === 'self_harm' ? 2 : 1;
|
|
163
286
|
return {
|
|
164
287
|
allowed: false,
|
|
165
288
|
categories: [category],
|
|
166
|
-
severity
|
|
167
|
-
selfHarmConcern
|
|
289
|
+
severity,
|
|
290
|
+
selfHarmConcern,
|
|
168
291
|
failClosed: false,
|
|
169
|
-
kidMessage: T.blocks.byCategory[category],
|
|
292
|
+
kidMessage: selfHarmConcern ? T.selfHarmSupport.message : T.blocks.byCategory[category],
|
|
170
293
|
};
|
|
171
294
|
}
|
|
172
295
|
/**
|
|
173
|
-
* L0 check for kid input. Jailbreak
|
|
174
|
-
*
|
|
296
|
+
* L0 check for kid input. Jailbreak, profanity, grooming, self-harm, and
|
|
297
|
+
* personal-detail probes block (kindly). Shared personal details redact
|
|
298
|
+
* with a gentle reminder and never block on their own.
|
|
175
299
|
*/
|
|
176
300
|
export function prefilterInput(text) {
|
|
301
|
+
// Self-harm first so the supportive screen wins over other matches.
|
|
302
|
+
if (hasSelfHarm(text)) {
|
|
303
|
+
return { ok: false, redacted: text, notice: null, block: blockVerdict('self_harm') };
|
|
304
|
+
}
|
|
177
305
|
if (hasJailbreak(text)) {
|
|
178
306
|
return { ok: false, redacted: text, notice: null, block: blockVerdict('jailbreak') };
|
|
179
307
|
}
|
|
308
|
+
if (hasGrooming(text)) {
|
|
309
|
+
return { ok: false, redacted: text, notice: null, block: blockVerdict('grooming') };
|
|
310
|
+
}
|
|
311
|
+
if (hasPiiProbe(text)) {
|
|
312
|
+
return { ok: false, redacted: text, notice: null, block: blockVerdict('pii') };
|
|
313
|
+
}
|
|
180
314
|
if (hasProfanity(text)) {
|
|
181
315
|
return { ok: false, redacted: text, notice: null, block: blockVerdict('profanity') };
|
|
182
316
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm registry version check with a short disk cache so session start stays
|
|
3
|
+
* snappy. Fail-open: network errors never block Termi from starting.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { atomicWriteFileSync, termiHome } from '../config/paths.js';
|
|
8
|
+
import { isNewerVersion, NPM_PACKAGE, readLocalVersion } from './version.js';
|
|
9
|
+
/** How long a successful registry answer is reused. */
|
|
10
|
+
export const VERSION_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
11
|
+
/** Hard cap on the registry round-trip so boot never hangs. */
|
|
12
|
+
export const VERSION_FETCH_TIMEOUT_MS = 2500;
|
|
13
|
+
export function versionCachePath() {
|
|
14
|
+
return path.join(termiHome(), 'version-check.json');
|
|
15
|
+
}
|
|
16
|
+
function readCache() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = fs.readFileSync(versionCachePath(), 'utf8');
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
if (typeof parsed.fetchedAt === 'string' &&
|
|
21
|
+
typeof parsed.latest === 'string' &&
|
|
22
|
+
typeof parsed.currentAtFetch === 'string') {
|
|
23
|
+
return {
|
|
24
|
+
fetchedAt: parsed.fetchedAt,
|
|
25
|
+
latest: parsed.latest,
|
|
26
|
+
currentAtFetch: parsed.currentAtFetch,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Missing or corrupt cache is fine.
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function writeCache(cache) {
|
|
36
|
+
try {
|
|
37
|
+
atomicWriteFileSync(versionCachePath(), `${JSON.stringify(cache, null, 2)}\n`);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Cache is best-effort.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Returns the latest published version from the npm registry, or null on
|
|
45
|
+
* any failure. Uses a 6-hour on-disk cache.
|
|
46
|
+
*/
|
|
47
|
+
export async function fetchLatestVersion(opts = {}) {
|
|
48
|
+
const now = opts.now ?? Date.now();
|
|
49
|
+
if (!opts.force) {
|
|
50
|
+
const cached = readCache();
|
|
51
|
+
if (cached !== null) {
|
|
52
|
+
const age = now - Date.parse(cached.fetchedAt);
|
|
53
|
+
if (Number.isFinite(age) && age >= 0 && age < VERSION_CACHE_TTL_MS && cached.latest.length > 0) {
|
|
54
|
+
return cached.latest;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
59
|
+
const timeoutMs = opts.timeoutMs ?? VERSION_FETCH_TIMEOUT_MS;
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetchImpl(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`, {
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
headers: { accept: 'application/json' },
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const body = (await res.json());
|
|
71
|
+
if (typeof body.version !== 'string' || body.version.length === 0) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
writeCache({
|
|
75
|
+
fetchedAt: new Date(now).toISOString(),
|
|
76
|
+
latest: body.version,
|
|
77
|
+
currentAtFetch: readLocalVersion(),
|
|
78
|
+
});
|
|
79
|
+
return body.version;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Compares the running install to the latest npm version.
|
|
90
|
+
* Never throws. Offline or timeout => skipped, updateAvailable false.
|
|
91
|
+
*/
|
|
92
|
+
export async function checkForUpdate(opts = {}) {
|
|
93
|
+
const current = readLocalVersion();
|
|
94
|
+
const latest = await fetchLatestVersion(opts);
|
|
95
|
+
if (latest === null) {
|
|
96
|
+
return { current, latest: null, updateAvailable: false, skipped: true, reason: 'registry-unavailable' };
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
current,
|
|
100
|
+
latest,
|
|
101
|
+
updateAvailable: isNewerVersion(latest, current),
|
|
102
|
+
skipped: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installs the latest termi-kids from npm into the global prefix.
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { NPM_PACKAGE } from './version.js';
|
|
6
|
+
/**
|
|
7
|
+
* Runs `npm install -g termi-kids@latest` and waits for exit.
|
|
8
|
+
* Streams npm output to the parent so parents can see progress.
|
|
9
|
+
*/
|
|
10
|
+
export function installLatestUpdate(opts = {}) {
|
|
11
|
+
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const child = spawnImpl('npm', ['install', '-g', `${NPM_PACKAGE}@latest`], {
|
|
14
|
+
stdio: 'inherit',
|
|
15
|
+
env: opts.env ?? process.env,
|
|
16
|
+
shell: process.platform === 'win32',
|
|
17
|
+
});
|
|
18
|
+
child.on('error', (err) => {
|
|
19
|
+
resolve({ ok: false, code: null, detail: err.message });
|
|
20
|
+
});
|
|
21
|
+
child.on('close', (code) => {
|
|
22
|
+
resolve({
|
|
23
|
+
ok: code === 0,
|
|
24
|
+
code,
|
|
25
|
+
detail: code === 0 ? 'updated' : `npm exited with code ${code ?? 'unknown'}`,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-start update prompt and the `termi update` command body.
|
|
3
|
+
*/
|
|
4
|
+
import * as p from '@clack/prompts';
|
|
5
|
+
import { style } from '../ui/theme.js';
|
|
6
|
+
import { checkForUpdate } from './check.js';
|
|
7
|
+
import { installLatestUpdate } from './install.js';
|
|
8
|
+
import { NPM_PACKAGE, readLocalVersion } from './version.js';
|
|
9
|
+
/** Env flag tests (and power users) use to skip the network check. */
|
|
10
|
+
export const SKIP_UPDATE_ENV = 'TERMI_SKIP_UPDATE';
|
|
11
|
+
export function shouldSkipUpdateCheck() {
|
|
12
|
+
if (process.env.VITEST !== undefined || process.env.NODE_ENV === 'test') {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
const flag = process.env[SKIP_UPDATE_ENV];
|
|
16
|
+
return flag === '1' || flag === 'true' || flag === 'yes';
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Quiet check used on every normal session start. Asks y/n when a newer
|
|
20
|
+
* version is on npm. Fail-open: errors and skips never interrupt the kid.
|
|
21
|
+
*/
|
|
22
|
+
export async function maybePromptForUpdate() {
|
|
23
|
+
if (shouldSkipUpdateCheck()) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
let result;
|
|
27
|
+
try {
|
|
28
|
+
result = await checkForUpdate();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!result.updateAvailable || result.latest === null) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
console.log('');
|
|
37
|
+
console.log(style.title(`A new Termi is ready: v${result.latest}`) +
|
|
38
|
+
style.dim(` (you have v${result.current})`));
|
|
39
|
+
const answer = await p.confirm({
|
|
40
|
+
message: 'Update Termi now?',
|
|
41
|
+
initialValue: true,
|
|
42
|
+
});
|
|
43
|
+
if (p.isCancel(answer) || answer !== true) {
|
|
44
|
+
console.log(style.dim('Okay. You can run "termi update" later.'));
|
|
45
|
+
console.log('');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await runUpdateCommand({ quietCheck: true });
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* `termi update`: always checks npm and installs when newer (or forced).
|
|
52
|
+
*/
|
|
53
|
+
export async function runUpdateCommand(opts = {}) {
|
|
54
|
+
const current = readLocalVersion();
|
|
55
|
+
if (!opts.quietCheck) {
|
|
56
|
+
console.log(`Termi v${current} (${NPM_PACKAGE})`);
|
|
57
|
+
console.log('Checking for a newer version...');
|
|
58
|
+
}
|
|
59
|
+
const result = await checkForUpdate({ force: true });
|
|
60
|
+
if (result.skipped || result.latest === null) {
|
|
61
|
+
console.log('Could not reach npm right now. Try again later.');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!result.updateAvailable) {
|
|
65
|
+
console.log(`You already have the latest version (v${result.current}).`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
console.log(`Updating ${NPM_PACKAGE} from v${result.current} to v${result.latest}...`);
|
|
69
|
+
const install = await installLatestUpdate();
|
|
70
|
+
if (!install.ok) {
|
|
71
|
+
console.log('The update did not finish.');
|
|
72
|
+
console.log(style.dim(install.detail));
|
|
73
|
+
console.log(style.dim(`You can also run: npm install -g ${NPM_PACKAGE}@latest`));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
console.log(`Updated to v${result.latest}.`);
|
|
77
|
+
console.log('Quit and run termi again to use the new version.');
|
|
78
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local version helpers and semver compare for the update checker.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
/** npm package name. The CLI bin is still `termi`. */
|
|
8
|
+
export const NPM_PACKAGE = 'termi-kids';
|
|
9
|
+
/** Reads the version embedded next to the built dist (package.json). */
|
|
10
|
+
export function readLocalVersion() {
|
|
11
|
+
try {
|
|
12
|
+
// dist/update/version.js -> package.json at package root
|
|
13
|
+
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
14
|
+
const raw = fs.readFileSync(pkgPath, 'utf8');
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
return typeof parsed.version === 'string' ? parsed.version : '0.0.0';
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return '0.0.0';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Parse a simple x.y.z (or vX.Y.Z) version; non-numeric parts become 0. */
|
|
23
|
+
export function parseSemver(version) {
|
|
24
|
+
const cleaned = version.trim().replace(/^v/i, '');
|
|
25
|
+
const parts = cleaned.split(/[.+-]/).slice(0, 3).map((p) => {
|
|
26
|
+
const n = Number.parseInt(p, 10);
|
|
27
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
28
|
+
});
|
|
29
|
+
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
|
30
|
+
}
|
|
31
|
+
/** True when latest is strictly newer than current. */
|
|
32
|
+
export function isNewerVersion(latest, current) {
|
|
33
|
+
const a = parseSemver(latest);
|
|
34
|
+
const b = parseSemver(current);
|
|
35
|
+
for (let i = 0; i < 3; i++) {
|
|
36
|
+
if (a[i] > b[i])
|
|
37
|
+
return true;
|
|
38
|
+
if (a[i] < b[i])
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termi-kids",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Termi is a friendly coding buddy for kids. Build games, art, stories, and websites right from your computer, with a grown-up approved AI helper and strong safety rails.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kids",
|