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,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk token store for the ChatGPT sign-in (auth.json).
|
|
3
|
+
*
|
|
4
|
+
* Lives on disk, not in the keychain, because of platform blob size caps.
|
|
5
|
+
* Written atomically with owner-only mode. Refresh is single flight inside
|
|
6
|
+
* the process (promise mutex) and across processes (lockfile with stale
|
|
7
|
+
* takeover). Token values are never logged and never appear in errors.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { atomicWriteFileSync, authJsonPath, locksDir } from '../config/paths.js';
|
|
12
|
+
import { decodeIdToken, OAuthRefreshError, refreshTokens } from './oauth.js';
|
|
13
|
+
/** Fallback lifetime assumption when issued_at is missing: 10 days. */
|
|
14
|
+
const DEFAULT_LIFETIME_MS = 10 * 24 * 60 * 60 * 1000;
|
|
15
|
+
/** Refresh proactively once 80 percent of the lifetime has passed. */
|
|
16
|
+
const REFRESH_AT_FRACTION = 0.8;
|
|
17
|
+
/** A lockfile older than this is considered abandoned and taken over. */
|
|
18
|
+
const LOCK_STALE_MS = 30_000;
|
|
19
|
+
/** Give up waiting for another process after this long. */
|
|
20
|
+
const LOCK_WAIT_MS = 45_000;
|
|
21
|
+
const LOCK_POLL_MS = 100;
|
|
22
|
+
/** The saved sign-in can no longer be used. A grown-up must sign in again. */
|
|
23
|
+
export class AuthDeadError extends Error {
|
|
24
|
+
reason;
|
|
25
|
+
constructor(reason) {
|
|
26
|
+
super('The saved sign-in no longer works. A grown-up needs to sign in again.');
|
|
27
|
+
this.name = 'AuthDeadError';
|
|
28
|
+
this.reason = reason;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Writes the token set atomically with owner-only permissions. */
|
|
32
|
+
export function saveTokens(tokens) {
|
|
33
|
+
atomicWriteFileSync(authJsonPath(), JSON.stringify(tokens, null, 2), 0o600);
|
|
34
|
+
}
|
|
35
|
+
/** Reads and validates auth.json. Returns null when missing or malformed. */
|
|
36
|
+
export function loadTokens() {
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(authJsonPath(), 'utf8');
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const data = parsed;
|
|
44
|
+
if (data.provider !== 'openai-chatgpt' ||
|
|
45
|
+
typeof data.access_token !== 'string' ||
|
|
46
|
+
typeof data.refresh_token !== 'string' ||
|
|
47
|
+
typeof data.expires_at !== 'number') {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function hasTokens() {
|
|
57
|
+
return loadTokens() !== null;
|
|
58
|
+
}
|
|
59
|
+
export function clearTokens() {
|
|
60
|
+
fs.rmSync(authJsonPath(), { force: true });
|
|
61
|
+
}
|
|
62
|
+
/** Marks the stored sign-in dead so callers stop retrying refresh. */
|
|
63
|
+
export function markDead(reason) {
|
|
64
|
+
const current = loadTokens();
|
|
65
|
+
if (current !== null) {
|
|
66
|
+
saveTokens({ ...current, dead: true, deadReason: reason });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** True once 80 percent of the token lifetime has passed (or it expired). */
|
|
70
|
+
export function needsRefresh(tokens, now = Date.now()) {
|
|
71
|
+
if (now >= tokens.expires_at) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
const issuedAt = tokens.issued_at ?? tokens.expires_at - DEFAULT_LIFETIME_MS;
|
|
75
|
+
const lifetime = tokens.expires_at - issuedAt;
|
|
76
|
+
if (lifetime <= 0) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return now >= issuedAt + lifetime * REFRESH_AT_FRACTION;
|
|
80
|
+
}
|
|
81
|
+
function authLockPath() {
|
|
82
|
+
return path.join(locksDir(), 'auth.lock');
|
|
83
|
+
}
|
|
84
|
+
function sleep(ms) {
|
|
85
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Acquires the cross-process refresh lock. Waits for a live lock, takes
|
|
89
|
+
* over a stale one (older than 30 seconds). Returns a release function.
|
|
90
|
+
*/
|
|
91
|
+
async function acquireLock() {
|
|
92
|
+
const lockFile = authLockPath();
|
|
93
|
+
fs.mkdirSync(path.dirname(lockFile), { recursive: true });
|
|
94
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
95
|
+
for (;;) {
|
|
96
|
+
try {
|
|
97
|
+
fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, at: Date.now() }), {
|
|
98
|
+
flag: 'wx',
|
|
99
|
+
mode: 0o600,
|
|
100
|
+
});
|
|
101
|
+
return () => {
|
|
102
|
+
try {
|
|
103
|
+
fs.rmSync(lockFile, { force: true });
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Releasing is best effort; a leftover lock goes stale in 30s.
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
if (err.code !== 'EEXIST') {
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
let stale = false;
|
|
115
|
+
try {
|
|
116
|
+
stale = Date.now() - fs.statSync(lockFile).mtimeMs > LOCK_STALE_MS;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// The lock vanished between attempts; retry right away.
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (stale) {
|
|
123
|
+
try {
|
|
124
|
+
fs.rmSync(lockFile, { force: true });
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Another process may have removed it first.
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (Date.now() > deadline) {
|
|
132
|
+
throw new Error('auth-lock-timeout');
|
|
133
|
+
}
|
|
134
|
+
await sleep(LOCK_POLL_MS);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** In-process single flight: concurrent callers share one refresh. */
|
|
139
|
+
let refreshInFlight = null;
|
|
140
|
+
/**
|
|
141
|
+
* Returns a usable access token. Refreshes proactively past 80 percent of
|
|
142
|
+
* the lifetime: persist first, then return. Terminal refresh failures mark
|
|
143
|
+
* the store dead and throw AuthDeadError.
|
|
144
|
+
*/
|
|
145
|
+
export async function getValidAccessToken(fetchImpl = globalThis.fetch) {
|
|
146
|
+
const current = loadTokens();
|
|
147
|
+
if (current === null) {
|
|
148
|
+
throw new AuthDeadError('no-tokens');
|
|
149
|
+
}
|
|
150
|
+
if (current.dead === true) {
|
|
151
|
+
throw new AuthDeadError(current.deadReason ?? 'dead');
|
|
152
|
+
}
|
|
153
|
+
if (!needsRefresh(current)) {
|
|
154
|
+
return current.access_token;
|
|
155
|
+
}
|
|
156
|
+
if (refreshInFlight === null) {
|
|
157
|
+
refreshInFlight = refreshWithLock(fetchImpl).finally(() => {
|
|
158
|
+
refreshInFlight = null;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return refreshInFlight;
|
|
162
|
+
}
|
|
163
|
+
async function refreshWithLock(fetchImpl) {
|
|
164
|
+
const release = await acquireLock();
|
|
165
|
+
try {
|
|
166
|
+
// Re-read after acquiring: another process may have refreshed already.
|
|
167
|
+
const current = loadTokens();
|
|
168
|
+
if (current === null) {
|
|
169
|
+
throw new AuthDeadError('no-tokens');
|
|
170
|
+
}
|
|
171
|
+
if (current.dead === true) {
|
|
172
|
+
throw new AuthDeadError(current.deadReason ?? 'dead');
|
|
173
|
+
}
|
|
174
|
+
if (!needsRefresh(current)) {
|
|
175
|
+
return current.access_token;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const rotated = await refreshTokens(current.refresh_token, fetchImpl);
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
const next = {
|
|
181
|
+
...current,
|
|
182
|
+
access_token: rotated.access_token,
|
|
183
|
+
refresh_token: rotated.refresh_token ?? current.refresh_token,
|
|
184
|
+
id_token: rotated.id_token ?? current.id_token,
|
|
185
|
+
issued_at: now,
|
|
186
|
+
expires_at: rotated.expires_in !== undefined
|
|
187
|
+
? now + rotated.expires_in * 1000
|
|
188
|
+
: now + DEFAULT_LIFETIME_MS,
|
|
189
|
+
};
|
|
190
|
+
delete next.dead;
|
|
191
|
+
delete next.deadReason;
|
|
192
|
+
if (rotated.id_token !== undefined) {
|
|
193
|
+
try {
|
|
194
|
+
const info = decodeIdToken(rotated.id_token);
|
|
195
|
+
if (info.accountId.length > 0) {
|
|
196
|
+
next.account_id = info.accountId;
|
|
197
|
+
next.plan_type = info.planType;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Keep the prior identity fields when the new id_token is odd.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Persist FIRST (the refresh token rotated), THEN return.
|
|
205
|
+
saveTokens(next);
|
|
206
|
+
return next.access_token;
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
if (err instanceof OAuthRefreshError && err.kind === 'auth-dead') {
|
|
210
|
+
markDead(err.reason);
|
|
211
|
+
throw new AuthDeadError(err.reason);
|
|
212
|
+
}
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
release();
|
|
218
|
+
}
|
|
219
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The termi entry point: argv router and boot sequence.
|
|
3
|
+
*
|
|
4
|
+
* Boot: ensureDirs, loadSettings, tamper warning when the settings file
|
|
5
|
+
* fails its integrity check, the setup wizard when setup never finished,
|
|
6
|
+
* global crash handlers, then the route. Exit code 0 for normal paths,
|
|
7
|
+
* 1 for crashes (set by the global handlers).
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import * as p from '@clack/prompts';
|
|
11
|
+
import { isSetupComplete } from './config/pin.js';
|
|
12
|
+
import { ensureDirs, errorLogPath } from './config/paths.js';
|
|
13
|
+
import { loadSettings } from './config/settings.js';
|
|
14
|
+
import { startPreview } from './preview/server.js';
|
|
15
|
+
import { appendAudit } from './safety/audit.js';
|
|
16
|
+
import { guardModelReady } from './safety/modelstore.js';
|
|
17
|
+
import { renderBanner } from './ui/banner.js';
|
|
18
|
+
import { installGlobalHandlers, renderCrash } from './ui/errors.js';
|
|
19
|
+
import { mascot } from './ui/mascot.js';
|
|
20
|
+
import { style } from './ui/theme.js';
|
|
21
|
+
import { T } from './ui/text.js';
|
|
22
|
+
import { executeIdeas } from './surfaces/commands.js';
|
|
23
|
+
/** Pure boot branch logic: settings state in, actions out. */
|
|
24
|
+
export function decideBoot(state) {
|
|
25
|
+
return {
|
|
26
|
+
warnTamper: state.tampered,
|
|
27
|
+
runWizard: !state.setupComplete,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** The grown-up and kid help for the command line. */
|
|
31
|
+
export function cliHelp() {
|
|
32
|
+
return [
|
|
33
|
+
'How to use Termi:',
|
|
34
|
+
' termi open the home menu',
|
|
35
|
+
' termi new start a new project',
|
|
36
|
+
' termi go [name] open a project and build',
|
|
37
|
+
' termi preview watch a project run',
|
|
38
|
+
' termi ideas get fun ideas',
|
|
39
|
+
' termi learn play six short lessons about AI',
|
|
40
|
+
' termi grownups grown-up zone (PIN needed)',
|
|
41
|
+
' termi help show this help',
|
|
42
|
+
' termi --version show the version',
|
|
43
|
+
].join('\n');
|
|
44
|
+
}
|
|
45
|
+
function versionString() {
|
|
46
|
+
try {
|
|
47
|
+
const raw = fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8');
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
return typeof parsed.version === 'string' ? parsed.version : '0.0.0';
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return '0.0.0';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function showTamperWarning() {
|
|
56
|
+
console.log(mascot('oops'));
|
|
57
|
+
console.log('Termi found changed settings. Safe settings are on now.');
|
|
58
|
+
console.log(style.dim('A grown-up can review this in the grown-up zone.'));
|
|
59
|
+
try {
|
|
60
|
+
appendAudit({
|
|
61
|
+
ts: new Date().toISOString(),
|
|
62
|
+
layer: 'system',
|
|
63
|
+
event: 'settings_change',
|
|
64
|
+
excerpt: 'settings integrity check failed, strict defaults applied',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// The warning is what matters most here.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function resolveProject(nameArg, settings) {
|
|
72
|
+
let store;
|
|
73
|
+
try {
|
|
74
|
+
store = await import('./projects/store.js');
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
p.log.warn('Project tools are not ready yet.');
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (nameArg !== undefined && nameArg.length > 0) {
|
|
81
|
+
let slug = nameArg.toLowerCase();
|
|
82
|
+
try {
|
|
83
|
+
const create = await import('./projects/create.js');
|
|
84
|
+
slug = create.slugifyName(nameArg).slug;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Use the lowercased name as a best-effort slug.
|
|
88
|
+
}
|
|
89
|
+
const direct = store.openProject(slug);
|
|
90
|
+
if (direct !== null) {
|
|
91
|
+
return direct;
|
|
92
|
+
}
|
|
93
|
+
p.log.warn('I cannot find that project. Pick one below.');
|
|
94
|
+
}
|
|
95
|
+
const metas = store.listProjects();
|
|
96
|
+
if (metas.length === 0) {
|
|
97
|
+
p.log.info('No projects yet. Run "termi new" to make one!');
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const first = metas[0];
|
|
101
|
+
const initial = settings.lastProjectSlug !== null && metas.some((m) => m.slug === settings.lastProjectSlug)
|
|
102
|
+
? settings.lastProjectSlug
|
|
103
|
+
: first?.slug;
|
|
104
|
+
const pick = await p.select({
|
|
105
|
+
message: 'Pick a project.',
|
|
106
|
+
options: metas.map((m) => ({ value: m.slug, label: m.prettyName })),
|
|
107
|
+
...(initial !== undefined ? { initialValue: initial } : {}),
|
|
108
|
+
});
|
|
109
|
+
if (p.isCancel(pick)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return store.openProject(pick);
|
|
113
|
+
}
|
|
114
|
+
async function routePreview(nameArg, settings) {
|
|
115
|
+
const project = await resolveProject(nameArg, settings);
|
|
116
|
+
if (project === null) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const handle = await startPreview(project.dir, { openBrowser: true });
|
|
120
|
+
console.log(`${T.chat.previewOpened} ${style.dim(handle.url)}`);
|
|
121
|
+
console.log(style.dim('Press Ctrl+C when you are done watching.'));
|
|
122
|
+
await new Promise(() => {
|
|
123
|
+
// Held open on purpose; Ctrl+C ends it through the global handler.
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async function routeIdeas(settings) {
|
|
127
|
+
let scaffoldId = 'games';
|
|
128
|
+
if (settings.lastProjectSlug !== null) {
|
|
129
|
+
try {
|
|
130
|
+
const store = await import('./projects/store.js');
|
|
131
|
+
const last = store.openProject(settings.lastProjectSlug);
|
|
132
|
+
if (last !== null) {
|
|
133
|
+
scaffoldId = last.meta.scaffoldId;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Fall back to game ideas.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
await executeIdeas(scaffoldId, (text) => {
|
|
141
|
+
console.log(text);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async function route(command, rest, settings) {
|
|
145
|
+
const home = await import('./surfaces/home.js');
|
|
146
|
+
switch (command) {
|
|
147
|
+
case '': {
|
|
148
|
+
await home.showHome(settings);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
case 'new': {
|
|
152
|
+
const project = await home.runNewProject(settings);
|
|
153
|
+
if (project !== null) {
|
|
154
|
+
await home.openChatLoop(project, settings);
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
case 'go': {
|
|
159
|
+
const project = await resolveProject(rest[0], settings);
|
|
160
|
+
if (project !== null) {
|
|
161
|
+
await home.openChatLoop(project, settings);
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
case 'preview': {
|
|
166
|
+
await routePreview(rest[0], settings);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
case 'ideas': {
|
|
170
|
+
await routeIdeas(settings);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
case 'learn': {
|
|
174
|
+
const learn = await import('./learn/runner.js');
|
|
175
|
+
await learn.runLearnMenu();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
case 'grownups': {
|
|
179
|
+
const panel = await import('./grownups/panel.js');
|
|
180
|
+
await panel.runPanel();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
default: {
|
|
184
|
+
console.log(`Hmm, "${command}" is not a Termi command.`);
|
|
185
|
+
console.log(cliHelp());
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** Full boot and route. Exported for tests; auto-runs outside of them. */
|
|
191
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
192
|
+
const command = argv[0] ?? '';
|
|
193
|
+
if (command === '--version' || command === '-v') {
|
|
194
|
+
console.log(versionString());
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
198
|
+
console.log(cliHelp());
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
ensureDirs();
|
|
202
|
+
const loaded = loadSettings();
|
|
203
|
+
let settings = loaded.settings;
|
|
204
|
+
const decision = decideBoot({
|
|
205
|
+
firstRun: loaded.firstRun,
|
|
206
|
+
tampered: loaded.tampered,
|
|
207
|
+
setupComplete: isSetupComplete(),
|
|
208
|
+
});
|
|
209
|
+
if (decision.warnTamper) {
|
|
210
|
+
showTamperWarning();
|
|
211
|
+
}
|
|
212
|
+
if (decision.runWizard) {
|
|
213
|
+
const wizard = await import('./setup/wizard.js');
|
|
214
|
+
await wizard.runWizard();
|
|
215
|
+
settings = loadSettings().settings;
|
|
216
|
+
if (!isSetupComplete()) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else if (command === '') {
|
|
221
|
+
console.log(renderBanner());
|
|
222
|
+
}
|
|
223
|
+
installGlobalHandlers({
|
|
224
|
+
onCrash: (entry) => {
|
|
225
|
+
try {
|
|
226
|
+
fs.appendFileSync(errorLogPath(), entry);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Nowhere left to write; the friendly screen still shows.
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
logPath: errorLogPath(),
|
|
233
|
+
});
|
|
234
|
+
// Resume the safety-checker download in the background when it is enabled
|
|
235
|
+
// but its model file is not on disk yet (declined-then-enabled, an
|
|
236
|
+
// interrupted transfer, or a wizard quit mid-download). Never blocks.
|
|
237
|
+
if (settings.localClassifier && !guardModelReady()) {
|
|
238
|
+
const { ensureGuardFetch } = await import('./safety/guarddownload.js');
|
|
239
|
+
void ensureGuardFetch();
|
|
240
|
+
}
|
|
241
|
+
await route(command, argv.slice(1), settings);
|
|
242
|
+
}
|
|
243
|
+
const underTest = process.env.VITEST !== undefined || process.env.NODE_ENV === 'test';
|
|
244
|
+
if (!underTest) {
|
|
245
|
+
main()
|
|
246
|
+
.then(() => process.exit(0))
|
|
247
|
+
.catch((err) => {
|
|
248
|
+
const detail = err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ''}` : String(err);
|
|
249
|
+
try {
|
|
250
|
+
fs.appendFileSync(errorLogPath(), `[${new Date().toISOString()}] main\n${detail}\n`);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Nowhere left to write.
|
|
254
|
+
}
|
|
255
|
+
console.error(renderCrash(errorLogPath()));
|
|
256
|
+
process.exit(1);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central path helpers for Termi state on disk.
|
|
3
|
+
*
|
|
4
|
+
* Everything is computed lazily (functions, not top-level constants) so the
|
|
5
|
+
* TERMI_HOME and TERMI_PROJECTS_DIR environment overrides work at any point,
|
|
6
|
+
* which is what the test suite relies on.
|
|
7
|
+
*/
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
/** First port the preview server tries; it scans upward from here. */
|
|
13
|
+
export const previewBasePort = 4311;
|
|
14
|
+
/** Root for Termi's own state. Default: <home>/.termi. Override: TERMI_HOME. */
|
|
15
|
+
export function termiHome() {
|
|
16
|
+
const override = process.env.TERMI_HOME;
|
|
17
|
+
if (override && override.trim().length > 0) {
|
|
18
|
+
return path.resolve(override);
|
|
19
|
+
}
|
|
20
|
+
return path.join(os.homedir(), '.termi');
|
|
21
|
+
}
|
|
22
|
+
/** Where kid projects live. Default: <home>/Termi. Override: TERMI_PROJECTS_DIR. */
|
|
23
|
+
export function projectsDir() {
|
|
24
|
+
const override = process.env.TERMI_PROJECTS_DIR;
|
|
25
|
+
if (override && override.trim().length > 0) {
|
|
26
|
+
return path.resolve(override);
|
|
27
|
+
}
|
|
28
|
+
return path.join(os.homedir(), 'Termi');
|
|
29
|
+
}
|
|
30
|
+
/** Signed settings envelope (settings plus MAC). */
|
|
31
|
+
export function settingsPath() {
|
|
32
|
+
return path.join(termiHome(), 'settings.json');
|
|
33
|
+
}
|
|
34
|
+
/** OAuth token store. Kept on disk, not in the keychain, due to blob size caps. */
|
|
35
|
+
export function authJsonPath() {
|
|
36
|
+
return path.join(termiHome(), 'auth.json');
|
|
37
|
+
}
|
|
38
|
+
/** Forward hash chained audit log. */
|
|
39
|
+
export function auditLogPath() {
|
|
40
|
+
return path.join(termiHome(), 'audit.log');
|
|
41
|
+
}
|
|
42
|
+
/** Crash details land here; the kid only ever sees a friendly screen. */
|
|
43
|
+
export function errorLogPath() {
|
|
44
|
+
return path.join(termiHome(), 'error.log');
|
|
45
|
+
}
|
|
46
|
+
/** Content-addressed snapshot blobs and per-turn manifests. */
|
|
47
|
+
export function snapshotsDir() {
|
|
48
|
+
return path.join(termiHome(), 'snapshots');
|
|
49
|
+
}
|
|
50
|
+
/** Cross-process lock files (for example the token refresh lock). */
|
|
51
|
+
export function locksDir() {
|
|
52
|
+
return path.join(termiHome(), 'locks');
|
|
53
|
+
}
|
|
54
|
+
/** Local safety-model files (the on-device classifier). */
|
|
55
|
+
export function modelsDir() {
|
|
56
|
+
return path.join(termiHome(), 'models');
|
|
57
|
+
}
|
|
58
|
+
/** Creates every directory Termi needs. Safe to call repeatedly. */
|
|
59
|
+
export function ensureDirs() {
|
|
60
|
+
for (const dir of [termiHome(), projectsDir(), snapshotsDir(), locksDir(), modelsDir()]) {
|
|
61
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Writes a file atomically: temp file in the same directory, then rename.
|
|
66
|
+
* Mode defaults to 0o600 (owner only). Mode is best effort on Windows.
|
|
67
|
+
*/
|
|
68
|
+
export function atomicWriteFileSync(filePath, data, mode = 0o600) {
|
|
69
|
+
const dir = path.dirname(filePath);
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
const tmpName = `.${path.basename(filePath)}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
72
|
+
const tmpPath = path.join(dir, tmpName);
|
|
73
|
+
fs.writeFileSync(tmpPath, data, { mode });
|
|
74
|
+
try {
|
|
75
|
+
fs.renameSync(tmpPath, filePath);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
try {
|
|
79
|
+
fs.rmSync(tmpPath, { force: true });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Best effort cleanup; the original error matters more.
|
|
83
|
+
}
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
fs.chmodSync(filePath, mode);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Best effort: chmod is mostly a no-op on Windows.
|
|
91
|
+
}
|
|
92
|
+
}
|