cotutor 0.4.1 → 0.4.3

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.
Files changed (46) hide show
  1. package/README.md +24 -6
  2. package/dist/app/cli/backup.d.ts +68 -0
  3. package/dist/app/cli/backup.js +243 -0
  4. package/dist/app/cli/doctor-fix.d.ts +18 -0
  5. package/dist/app/cli/doctor-fix.js +70 -0
  6. package/dist/app/cli/doctor.js +19 -2
  7. package/dist/app/cli/init.d.ts +2 -0
  8. package/dist/app/cli/init.js +6 -3
  9. package/dist/app/cli/main.js +117 -2
  10. package/dist/app/cli/report.d.ts +11 -0
  11. package/dist/app/cli/report.js +110 -0
  12. package/dist/app/cli/serve.d.ts +0 -1
  13. package/dist/app/cli/serve.js +14 -0
  14. package/dist/app/cli/service.d.ts +26 -0
  15. package/dist/app/cli/service.js +36 -5
  16. package/dist/app/cli/skeleton.d.ts +1 -1
  17. package/dist/app/cli/skeleton.js +2 -2
  18. package/dist/app/cli/tray.js +6 -4
  19. package/dist/app/cli/update-cmd.d.ts +38 -0
  20. package/dist/app/cli/update-cmd.js +174 -0
  21. package/dist/app/cli/usage.d.ts +1 -1
  22. package/dist/app/cli/usage.js +4 -1
  23. package/dist/app/http/app.d.ts +2 -0
  24. package/dist/app/http/app.js +18 -3
  25. package/dist/app/http/parent-home.js +4 -0
  26. package/dist/app/http/parent-setup.d.ts +19 -0
  27. package/dist/app/http/parent-setup.js +96 -0
  28. package/dist/app/machine/bailian.d.ts +26 -0
  29. package/dist/app/machine/bailian.js +91 -4
  30. package/dist/app/machine/update.d.ts +14 -1
  31. package/dist/app/machine/update.js +35 -14
  32. package/dist/brain/api.js +6 -3
  33. package/dist/memory/vault-notes.d.ts +4 -0
  34. package/dist/memory/vault-notes.js +11 -0
  35. package/dist/realtime/preview.d.ts +16 -0
  36. package/dist/realtime/preview.js +43 -1
  37. package/dist/runtime/live/call.d.ts +2 -0
  38. package/dist/runtime/live/call.js +6 -1
  39. package/dist/shared/upgrade.d.ts +5 -3
  40. package/dist/shared/upgrade.js +8 -4
  41. package/dist/ui/kid/kid-page.js +8 -2
  42. package/dist/ui/parent/parent-home-page.js +67 -3
  43. package/dist/ui/parent/parent-settings-page.js +65 -1
  44. package/package.json +7 -1
  45. package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +1 -1
  46. package/skills/cotutor-tune/references//345/255/227/346/256/265.md +1 -1
@@ -85,8 +85,26 @@ function printOpen(label, page, hint, open) {
85
85
  openTarget(process.platform, page);
86
86
  }
87
87
  const wantsOpen = (flags) => flags.open === true || flags['open-qr'] === true;
88
+ /** 升级前的备份;试用的不备 */
89
+ async function backupBeforeUpgrade(root) {
90
+ const { describeBackup, makeBackup } = await import("./backup.js");
91
+ let vault;
92
+ try {
93
+ const ws = loadWorkspace(root);
94
+ if (ws.sandbox)
95
+ return null;
96
+ vault = ws.paths.vault;
97
+ }
98
+ catch { /* 配置读不了(老布局、写坏了):workspace 照样备,vault 不管 */ }
99
+ try {
100
+ return `升级前留了一份备份:${describeBackup(await makeBackup({ root, vault }, { why: 'upgrade', version: BUILD.version }))}`;
101
+ }
102
+ catch (err) {
103
+ return `! 这次没备份成:${err instanceof Error ? err.message : String(err)}`;
104
+ }
105
+ }
88
106
  /** 不用 workspace 的命令:--sandbox 对它们没有意义 */
89
- const NO_WORKSPACE = ['grades', 'init', 'mock', 'pin', 'cert', 'tray'];
107
+ const NO_WORKSPACE = ['grades', 'init', 'mock', 'pin', 'cert', 'tray', 'update'];
90
108
  export async function main(argv) {
91
109
  let json = false;
92
110
  try {
@@ -146,7 +164,86 @@ export async function main(argv) {
146
164
  }
147
165
  return;
148
166
  }
167
+ case 'report': {
168
+ const { buildReport } = await import("./report.js");
169
+ const ws = loadWorkspace(workspace);
170
+ process.stdout.write(await buildReport(ws, { health: await probeHealth(ws.config.server.port) }));
171
+ return;
172
+ }
173
+ case 'backup': {
174
+ const backup = await import("./backup.js");
175
+ const ws = loadWorkspace(workspace);
176
+ if (ws.sandbox)
177
+ throw new UsageError('试用的 workspace 不备份');
178
+ const target = { root: ws.root, vault: ws.paths.vault };
179
+ const [sub, name] = positionals;
180
+ if (!sub) {
181
+ const b = await backup.makeBackup(target, { why: 'manual', version: BUILD.version });
182
+ process.stdout.write(`备份好了:${backup.describeBackup(b)}\n 在 ${redactHome(backup.backupsOf(ws.root))}\n 不含录音(wav)和能重新生成的文件${b.audioSkipped ? `,这次跳过 ${b.audioSkipped} 个录音` : ''}\n`);
183
+ if (b.vault === 'unreadable')
184
+ process.stdout.write(`! vault 读不了(${redactHome(ws.paths.vault)}),这份里没有它\n`);
185
+ return;
186
+ }
187
+ if (sub === 'list') {
188
+ const list = await backup.listBackups(ws.root);
189
+ if (json) {
190
+ process.stdout.write(`${JSON.stringify(list, null, 2)}\n`);
191
+ return;
192
+ }
193
+ if (!list.length)
194
+ process.stdout.write('还没有备份:cotutor backup 备一份\n');
195
+ for (const b of list)
196
+ process.stdout.write(`${backup.describeBackup(b)}\n`);
197
+ return;
198
+ }
199
+ if (sub !== 'restore' || !name)
200
+ throw new UsageError(`cotutor backup [list | restore <名字> [--yes]]\n${USAGE}`);
201
+ if (!(await backup.listBackups(ws.root)).some((b) => b.name === name))
202
+ throw new UsageError(`没有这份备份:${name}(cotutor backup list 看有哪些)`);
203
+ if (flags.yes !== true) {
204
+ process.stdout.write(`会把孩子的文件夹恢复成 ${name} 那时的样子:备份里有的文件都覆盖回去,之后新多出来的不删。\n恢复前会先把现在的样子也备份一份,恢复错了还能回来。服务会停一下。录音不在备份里,不受影响。\n确定的话加 --yes\n`);
205
+ process.exitCode = 1;
206
+ return;
207
+ }
208
+ const host = realHost();
209
+ const port = ws.config.server.port;
210
+ const health = await probeHealth(port);
211
+ if (health && health.mode !== 'service')
212
+ throw new UsageError(`端口 ${port} 上有一个 cotutor serve 在终端里跑着:先关掉它(那个终端里 Ctrl-C),再恢复`);
213
+ if ((health?.calls ?? 0) > 0)
214
+ throw new UsageError('孩子正在和老师通话,恢复要停服务:等下课再来');
215
+ const service = await findService(host, ws.root);
216
+ if (service && health)
217
+ await controlService(host, ws.root, 'stop');
218
+ const r = await backup.restoreBackup(target, name, { version: BUILD.version });
219
+ process.stdout.write(`恢复好了:${r.files} 个文件。恢复前的样子留在 ${r.safety}\n`);
220
+ if (r.vaultCopy)
221
+ process.stdout.write(`外面的 vault 没动;要拿回来,那一份的 md 在 ${redactHome(r.vaultCopy)}\n`);
222
+ if (service && health) {
223
+ await controlService(host, ws.root, 'start');
224
+ process.stdout.write('服务重起了\n');
225
+ }
226
+ return;
227
+ }
149
228
  case 'doctor': {
229
+ if (flags.fix === true) {
230
+ const { runArgv, runFixes } = await import("./doctor-fix.js");
231
+ const r = await runFixes({ doctor: () => doctorWorkspace(workspace, {}), run: runArgv, print: (l) => process.stdout.write(`${l}\n`) });
232
+ const left = r.checks.filter((c) => !c.ok);
233
+ const manual = left.filter((c) => c.human);
234
+ const stuck = left.filter((c) => c.cmd);
235
+ if (manual.length) {
236
+ process.stdout.write('还要人来的:\n');
237
+ for (const c of manual)
238
+ process.stdout.write(` ${c.required ? '✗' : '!'} ${c.name.padEnd(26)} ${c.detail}\n`);
239
+ }
240
+ for (const c of stuck)
241
+ process.stdout.write(`修不好:${c.name} ${c.detail}\n`);
242
+ process.stdout.write(r.ok ? '体检通过\n' : '体检不过(✗ 是必需项)\n');
243
+ if (!r.ok)
244
+ process.exitCode = 1;
245
+ return;
246
+ }
150
247
  const r = await doctorWorkspace(workspace, { live: flags.live === true });
151
248
  if (json)
152
249
  process.stdout.write(`${JSON.stringify(redactDeep(r), null, 2)}\n`);
@@ -301,10 +398,12 @@ export async function main(argv) {
301
398
  : `! 后台服务读不到 vault(${redactHome(ws.paths.vault)}):看看这个目录的权限,然后 cotutor service restart`);
302
399
  if (h?.version && h.version !== version())
303
400
  out.push(`! 在跑的是 ${h.version},装的是 ${version()}:cotutor service restart 换上新的`);
401
+ if (h?.bailianTrouble)
402
+ out.push(`! ${h.bailianTrouble.message}:${h.bailianTrouble.fix}`);
304
403
  if (h?.scheme === 'http' && httpsFiles(ws))
305
404
  out.push('! 证书有了,服务还在走 HTTP(平板上按住说话不可用):cotutor service restart');
306
405
  if (isNewer(h?.latest, version()))
307
- out.push(`! 有新版本 ${h?.latest}(现在是 ${version()}),升级依次运行:${upgradeSteps().join(' ; ')}`);
406
+ out.push(`! 有新版本 ${h?.latest}(现在是 ${version()}),升级运行:${upgradeSteps().join(' ; ')}`);
308
407
  return out;
309
408
  };
310
409
  const tellLog = async (dir) => {
@@ -552,10 +651,26 @@ export async function main(argv) {
552
651
  process.stdout.write(`加了主题 ${name}(拷自 ${r.from}):\n ${redactHome(r.dir)}/theme.json ← 槽的清单(名字 + 给什么用)\n ${redactHome(r.dir)}/kid.css ← 样式,改了刷新就有\n把 cotutor.json 的 kid.theme 改成 "${name}" 就换过去了(服务不用重启)。\n`);
553
652
  return;
554
653
  }
654
+ case 'update': {
655
+ const { realUpdateDeps, runUpdate } = await import("./update-cmd.js");
656
+ process.exitCode = await runUpdate(realUpdateDeps(() => { try {
657
+ return resolveRoot(workspace).root;
658
+ }
659
+ catch {
660
+ return null;
661
+ } }), { anyway: flags.anyway === true });
662
+ return;
663
+ }
555
664
  case 'upgrade': {
556
665
  if (flags.grade !== undefined)
557
666
  throw new UsageError('upgrade 只更新已安装的教学包,不能用 --grade 切换年级');
558
667
  const { root } = resolveRoot(workspace);
668
+ // 升级前留一份:新代码弄坏了能回来。备份不成不拦升级,说一声。--dry-run 不动文件,不用
669
+ if (flags['dry-run'] !== true) {
670
+ const said = await backupBeforeUpgrade(root);
671
+ if (said && !json)
672
+ process.stdout.write(`${said}\n`);
673
+ }
559
674
  // --config 是另一件事:政策文件补缺(老师文件与 skill 不碰)
560
675
  if (flags.config === true) {
561
676
  const dryRun = flags['dry-run'] === true;
@@ -0,0 +1,11 @@
1
+ import type { Workspace } from '../workspace.ts';
2
+ import { type Health } from './service.ts';
3
+ /** 抹掉能认出这家人的东西。太短的(一个字的小名)不抹,不然满篇都被替换 */
4
+ export declare function scrub(text: string, secrets: readonly (string | null | undefined)[]): string;
5
+ export interface ReportOptions {
6
+ /** 在跑的服务答的;没在跑 null */
7
+ health: Health | null;
8
+ now?: Date;
9
+ env?: NodeJS.ProcessEnv;
10
+ }
11
+ export declare function buildReport(ws: Workspace, o: ReportOptions): Promise<string>;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 诊断信息(cotutor report、家长端「设置」里的「生成诊断信息」):出了问题时一次说清,发给开发者或帮忙的 AI 助手,不用来回问。
3
+ * 不含孩子的对话、照片、录音、档案和 key。只收:版本和系统、服务状态、体检没过的、最近出错的那几轮的原因(不要孩子说了什么)、日志里报错的行、孩子端页面的报错。
4
+ * 孩子的名字、key、业务空间、电脑名、IP、用户名一律抹掉(scrub),最后整篇再抹一遍。给人先看再发,不自动上传。
5
+ */
6
+ import { arch, homedir, hostname, platform, release, userInfo } from 'node:os';
7
+ import { localDate } from "../../shared/time.js";
8
+ import { dashscopeAuth } from "../../realtime/bailian.js";
9
+ import { listDates, readIndex } from "../../runtime/journal/store.js";
10
+ import { readKidLog } from "../../runtime/journal/kid-log.js";
11
+ import { BUILD } from "../build.js";
12
+ import { listTutors } from "../../schema/config.js";
13
+ import { doctorWorkspace } from "./doctor.js";
14
+ import { listBackups } from "./backup.js";
15
+ import { findService, realHost, tailLog } from "./service.js";
16
+ import { trayStatus } from "./tray.js";
17
+ import { computerName } from "./serve.js";
18
+ const DAYS = 7;
19
+ const MAX_LINE = 180;
20
+ const MAX_ERRORS = 15;
21
+ const LOG_ERROR = /error|fail|失败|没成|崩|exception|ENO[A-Z]+|EADDR|EPERM|EACCES/i;
22
+ const cut = (s, n = MAX_LINE) => (s.length > n ? `${s.slice(0, n)}…` : s);
23
+ /** 抹掉能认出这家人的东西。太短的(一个字的小名)不抹,不然满篇都被替换 */
24
+ export function scrub(text, secrets) {
25
+ let out = text.replace(/sk-[A-Za-z0-9._-]{4,}/g, 'sk-***').replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, (ip) => (ip === '127.0.0.1' ? ip : '<IP>'));
26
+ const home = homedir();
27
+ if (home)
28
+ out = out.split(home).join('$HOME');
29
+ const list = [...new Set(secrets.filter((s) => typeof s === 'string' && s.trim().length >= 2))].sort((a, b) => b.length - a.length);
30
+ for (const s of list)
31
+ out = out.split(s).join('<隐去>');
32
+ return out;
33
+ }
34
+ /** 这家人的名字们 */
35
+ function secretsOf(ws, env) {
36
+ const auth = dashscopeAuth(env);
37
+ let user = '';
38
+ try {
39
+ user = userInfo().username;
40
+ }
41
+ catch { /* 没有也行 */ }
42
+ const name = computerName();
43
+ return [ws.config.kid.name, ws.config.kid.slug, ws.config.title, auth?.workspaceId, auth?.key, user, name, `${name}.local`, hostname()].filter((s) => Boolean(s));
44
+ }
45
+ const DATE_N = (now, n) => Array.from({ length: n }, (_, i) => localDate(new Date(now.getTime() - i * 24 * 3600_000)));
46
+ /** 最近几天没成的那几轮:只要原因,不要内容 */
47
+ async function failedTurns(ws, now) {
48
+ const out = [];
49
+ const since = DATE_N(now, DAYS).at(-1);
50
+ for (const t of listTutors(ws.config)) {
51
+ for (const date of (await listDates(ws, t.name).catch(() => [])).filter((d) => d >= since)) {
52
+ const index = await readIndex(ws, t.name, date).catch(() => null);
53
+ for (const m of index?.messages ?? [])
54
+ if (m.result === 'error')
55
+ out.push(`${date} ${t.name} ${cut(String(m.error ?? '没说原因'), 120)}`);
56
+ }
57
+ }
58
+ return out.sort().reverse().slice(0, MAX_ERRORS);
59
+ }
60
+ /** 孩子端页面报的错:错误信息和位置,不要上下文(ctx 里可能有孩子正在做的) */
61
+ async function kidErrors(ws, now) {
62
+ const rows = await readKidLog(ws, DATE_N(now, DAYS)).catch(() => []);
63
+ const seen = new Map();
64
+ for (const r of rows)
65
+ if (r.k === 'error') {
66
+ const key = `${r.page} ${cut(r.msg, 120)} @ ${cut(r.where, 60)}`;
67
+ seen.set(key, (seen.get(key) ?? 0) + 1);
68
+ }
69
+ return [...seen].slice(-MAX_ERRORS).map(([k, n]) => (n > 1 ? `${k} ×${n}` : k));
70
+ }
71
+ /** 服务日志里报错的行 */
72
+ async function logErrors(logDir) {
73
+ if (!logDir)
74
+ return [];
75
+ const t = await tailLog(logDir, 400).catch(() => null);
76
+ return (t?.lines ?? []).filter((l) => LOG_ERROR.test(l)).slice(-MAX_ERRORS).map((l) => cut(l));
77
+ }
78
+ export async function buildReport(ws, o) {
79
+ const now = o.now ?? new Date();
80
+ const env = o.env ?? process.env;
81
+ const host = realHost();
82
+ const h = o.health;
83
+ const lines = [];
84
+ const section = (title, items) => {
85
+ lines.push('', `## ${title}`);
86
+ lines.push(...(items.length ? items.map((s) => `- ${s}`) : ['- 没有']));
87
+ };
88
+ lines.push('# cotutor 诊断信息', '不含孩子的对话、照片、录音、档案和 key;孩子的名字、电脑名、IP 已隐去。', '');
89
+ lines.push(`- 版本:${BUILD.version ?? '?'}${BUILD.commit ? `(从仓库跑的,${BUILD.commit.slice(0, 7)})` : ''}`);
90
+ lines.push(`- 系统:${platform()} ${release()} ${arch()};Node ${process.version}`);
91
+ lines.push(`- 时间:${now.toISOString()}`);
92
+ const s = await findService(host, ws.root).catch(() => null);
93
+ const tray = await trayStatus(host).catch(() => null);
94
+ const running = h ? `在跑(${h.mode === 'service' ? '后台服务' : '终端里'},版本 ${h.version ?? '?'},起来于 ${h.started},走 ${h.scheme ?? '?'})` : '没答应';
95
+ section('服务', [
96
+ running,
97
+ `后台服务:${s ? '装了' : '没装'}`,
98
+ tray?.supported ? `图标:${tray.installed ? (tray.running ? '在跑' : '装了,没在跑') : '没装'}` : '图标:这个系统上没有',
99
+ ...(h?.bailianTrouble ? [`百炼故障:${h.bailianTrouble.kind}(${h.bailianTrouble.why ?? ''})`] : []),
100
+ ...(h?.configError ? [`配置:${cut(h.configError)}`] : []),
101
+ ]);
102
+ const doc = await doctorWorkspace(ws.root, { probeEnv: true, firewall: false, now, env }).catch(() => null);
103
+ section('体检没过的', doc ? doc.checks.filter((c) => !c.ok).map((c) => `${c.required ? '必需' : '建议'} ${c.name}:${cut(c.detail, 140)}`) : ['体检没跑成']);
104
+ const backups = await listBackups(ws.root).catch(() => []);
105
+ section('备份', backups[0] ? [`最近一份 ${backups[0].at}(${backups[0].why}),共 ${backups.length} 份`] : ['还没有']);
106
+ section(`最近 ${DAYS} 天没成的话题(只有原因)`, await failedTurns(ws, now));
107
+ section('服务日志里报错的行', await logErrors(s?.logDir ?? null));
108
+ section(`孩子端页面最近 ${DAYS} 天的报错`, await kidErrors(ws, now));
109
+ return `${scrub(lines.join('\n'), secretsOf(ws, env))}\n`;
110
+ }
@@ -25,7 +25,6 @@ export interface ServeResult {
25
25
  parentPage: string;
26
26
  warnings: string[];
27
27
  }
28
- /** 证书路径:cotutor.json 的 server.https 优先(相对 workspace 根),否则机器级 ~/.config/cotutor/certs/;都没有 → null */
29
28
  export declare function httpsFiles(ws: Workspace): {
30
29
  cert: string;
31
30
  key: string;
@@ -18,10 +18,13 @@ import { answersLocally } from "./port.js";
18
18
  import { awakeScript, psArgs } from "./service-windows.js";
19
19
  import { createContext, createHandler } from "../http/app.js";
20
20
  import { liveUpgrade } from "../ws/route.js";
21
+ import { startDailyBackups } from "./backup.js";
22
+ import { BUILD } from "../build.js";
21
23
  import { AUTO_BOOKKEEP_EVERY_MS, autoBookkeep } from "../../runtime/auto-bookkeep.js";
22
24
  import { USER_CERT_DIR, expandPath, loadWorkspace } from "../workspace.js";
23
25
  export { siteUrl };
24
26
  /** 证书路径:cotutor.json 的 server.https 优先(相对 workspace 根),否则机器级 ~/.config/cotutor/certs/;都没有 → null */
27
+ const BAILIAN_RECHECK_MS = 120_000;
25
28
  export function httpsFiles(ws) {
26
29
  const h = ws.config.server.https;
27
30
  if (h)
@@ -126,6 +129,17 @@ export async function serveWorkspace(opts = {}) {
126
129
  }, AUTO_BOOKKEEP_EVERY_MS);
127
130
  timer.unref();
128
131
  server.once('close', () => clearInterval(timer));
132
+ // 嘴连不上时隔一阵再试:充上钱、网回来了,没人点也会恢复,孩子端和图标跟着好
133
+ const recheck = setInterval(() => { void ctx.bailian.recheck().catch(() => undefined); }, BAILIAN_RECHECK_MS);
134
+ recheck.unref();
135
+ server.once('close', () => clearInterval(recheck));
136
+ // 每天备份一份(backup.ts):试用的不备;孩子在通话时往后推
137
+ const stopBackups = startDailyBackups({
138
+ target: () => (ctx.ws.sandbox ? null : { root: ctx.ws.root, vault: ctx.ws.paths.vault }),
139
+ busy: () => liveUpgrade.openCalls() > 0,
140
+ version: BUILD.version,
141
+ });
142
+ server.once('close', stopBackups);
129
143
  if (opts.awake !== false) {
130
144
  const stop = holdAwake(() => liveUpgrade.openCalls() > 0 || Object.keys(ctx.ws.config.tutors).some((t) => ctx.runner.running(t) !== null));
131
145
  server.once('close', stop);
@@ -28,6 +28,22 @@ export declare function earlyLogHint(platform: ServicePlatform, s: {
28
28
  }): string;
29
29
  /** 看护进程的日志(Windows):serve 退了几次、起来之前的输出 */
30
30
  export declare const KEEPER_LOG = "keeper.log";
31
+ /**
32
+ * launchctl bootout 发完信号就回,进程还没退;这时 bootstrap 报 5: Input/output error,服务就停在那儿了(2026-11-07 实测重装 12 次失败 10 次)。
33
+ * 等它真的卸下来再往下走。launchd 最多等进程 20 秒(ExitTimeOut 缺省),再不走它自己会杀。
34
+ */
35
+ export declare function launchdUnload(host: Pick<ServiceHost, 'run'>, target: string, waitMs?: number): Promise<void>;
36
+ /** 装上;刚卸下的那一下还可能报错,隔一秒再试几次,真的不行才算不行 */
37
+ export declare function launchdLoad(host: Pick<ServiceHost, 'run' | 'uid'>, file: string, tries?: number): Promise<{
38
+ code: number;
39
+ stdout: string;
40
+ stderr: string;
41
+ }>;
42
+ /**
43
+ * 写进服务文件、登录项的 node。Homebrew 的 process.execPath 是带版本号的路径(实测 /opt/homebrew/Cellar/node/26.12.1_1/bin/node),
44
+ * brew upgrade 清掉旧版后,服务和图标下次登录都起不来,连报警的都没了;opt/<formula> 这个链接 brew 一直维护。别的管理器(nvm、asdf)不会自己删旧版,不动。
45
+ */
46
+ export declare function stableNode(execPath: string, exists?: (p: string) => boolean): string;
31
47
  /**
32
48
  * service install 之后开不开家长端:只有这个孩子第一次装时开,下一步就是填百炼、扫码。
33
49
  * 别的启动都不开:登录、崩溃重起、升级、重启都不是人要看页面;放进 serve 的话,一直崩就隔 10 秒开一个标签页。
@@ -120,8 +136,18 @@ export interface Health {
120
136
  calls?: number;
121
137
  /** 百炼配了没有(托盘:没配算有事要处理) */
122
138
  bailian?: boolean;
139
+ /** 跑起来以后百炼出的问题(欠费、key 失效、连不上);没有 → null(machine/bailian.ts) */
140
+ bailianTrouble?: {
141
+ kind: string;
142
+ message: string;
143
+ fix: string;
144
+ why?: string;
145
+ blocked?: boolean;
146
+ } | null;
123
147
  /** npm 上比在跑的这份新的版本;没有 → null(machine/update.ts) */
124
148
  latest?: string | null;
149
+ /** 那个版本写的更新说明 */
150
+ latestNotes?: string[];
125
151
  configError?: string | null;
126
152
  /** 问到的是 https 还是 http(这边加的,不是服务回的) */
127
153
  scheme?: 'https' | 'http';
@@ -88,6 +88,37 @@ export function earlyLogHint(platform, s) {
88
88
  export const KEEPER_LOG = 'keeper.log';
89
89
  const KEEPER_LOG_MAX = 5 * 1024 * 1024;
90
90
  const KEEPER_RESTART_MS = 10_000;
91
+ const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
92
+ /**
93
+ * launchctl bootout 发完信号就回,进程还没退;这时 bootstrap 报 5: Input/output error,服务就停在那儿了(2026-11-07 实测重装 12 次失败 10 次)。
94
+ * 等它真的卸下来再往下走。launchd 最多等进程 20 秒(ExitTimeOut 缺省),再不走它自己会杀。
95
+ */
96
+ export async function launchdUnload(host, target, waitMs = 25_000) {
97
+ await host.run('launchctl', ['bootout', target]);
98
+ const until = Date.now() + waitMs;
99
+ while ((await host.run('launchctl', ['print', target])).code === 0 && Date.now() < until)
100
+ await sleep(200);
101
+ }
102
+ /** 装上;刚卸下的那一下还可能报错,隔一秒再试几次,真的不行才算不行 */
103
+ export async function launchdLoad(host, file, tries = 5) {
104
+ let r = await host.run('launchctl', ['bootstrap', `gui/${host.uid}`, file]);
105
+ for (let i = 1; i < tries && r.code !== 0; i++) {
106
+ await sleep(1000);
107
+ r = await host.run('launchctl', ['bootstrap', `gui/${host.uid}`, file]);
108
+ }
109
+ return r;
110
+ }
111
+ /**
112
+ * 写进服务文件、登录项的 node。Homebrew 的 process.execPath 是带版本号的路径(实测 /opt/homebrew/Cellar/node/26.12.1_1/bin/node),
113
+ * brew upgrade 清掉旧版后,服务和图标下次登录都起不来,连报警的都没了;opt/<formula> 这个链接 brew 一直维护。别的管理器(nvm、asdf)不会自己删旧版,不动。
114
+ */
115
+ export function stableNode(execPath, exists = existsSync) {
116
+ const m = /^(.*)\/Cellar\/(node(?:@\d+)?)\/[^/]+\/bin\/node$/.exec(execPath);
117
+ if (!m)
118
+ return execPath;
119
+ const opt = `${m[1]}/opt/${m[2]}/bin/node`;
120
+ return exists(opt) ? opt : execPath;
121
+ }
91
122
  /**
92
123
  * service install 之后开不开家长端:只有这个孩子第一次装时开,下一步就是填百炼、扫码。
93
124
  * 别的启动都不开:登录、崩溃重起、升级、重启都不是人要看页面;放进 serve 的话,一直崩就隔 10 秒开一个标签页。
@@ -267,7 +298,7 @@ export async function installService(host, root, opts = {}) {
267
298
  const logDir = serviceLogDir(host, name);
268
299
  // Windows 上跑的是看护进程,它再起 serve(那边没有 launchd / systemd 替你拉起)
269
300
  const run = platform === 'win32' ? ['service', 'run'] : ['serve'];
270
- const spec = { name, label, root, logDir, path: host.env.PATH ?? '/usr/bin:/bin', args: [opts.node ?? process.execPath, opts.bin ?? binPath(), ...run, '--workspace', root, '--log-dir', logDir] };
301
+ const spec = { name, label, root, logDir, path: host.env.PATH ?? '/usr/bin:/bin', args: [opts.node ?? stableNode(process.execPath), opts.bin ?? binPath(), ...run, '--workspace', root, '--log-dir', logDir] };
271
302
  const file = fileOf(host, name);
272
303
  await mkdir(serviceDir(host), { recursive: true });
273
304
  await mkdir(logDir, { recursive: true });
@@ -284,9 +315,9 @@ export async function installService(host, root, opts = {}) {
284
315
  }
285
316
  else if (platform === 'darwin') {
286
317
  // 先卸下旧的(没装过会报错,不管),再装上;bootstrap 会按 RunAtLoad 马上起
287
- await host.run('launchctl', ['bootout', `gui/${host.uid}/${label}`]);
318
+ await launchdUnload(host, `gui/${host.uid}/${label}`);
288
319
  await writeFile(file, launchdPlist(spec));
289
- const r = await host.run('launchctl', ['bootstrap', `gui/${host.uid}`, file]);
320
+ const r = await launchdLoad(host, file);
290
321
  if (r.code !== 0)
291
322
  throw new UsageError(`launchctl bootstrap 没成:${(r.stderr || r.stdout).trim()}`);
292
323
  }
@@ -346,10 +377,10 @@ export async function controlService(host, root, what) {
346
377
  const loaded = (await host.run('launchctl', ['print', target])).code === 0;
347
378
  if (what === 'stop') {
348
379
  if (loaded)
349
- await host.run('launchctl', ['bootout', target]);
380
+ await launchdUnload(host, target);
350
381
  return s;
351
382
  }
352
- const r = loaded ? await host.run('launchctl', what === 'restart' ? ['kickstart', '-k', target] : ['kickstart', target]) : await host.run('launchctl', ['bootstrap', `gui/${host.uid}`, s.file]);
383
+ const r = loaded ? await host.run('launchctl', what === 'restart' ? ['kickstart', '-k', target] : ['kickstart', target]) : await launchdLoad(host, s.file);
353
384
  if (r.code !== 0)
354
385
  throw new UsageError(`launchctl 没成:${(r.stderr || r.stdout).trim()}`);
355
386
  return s;
@@ -12,7 +12,7 @@ export interface ShippedAgent {
12
12
  export declare function shippedAgents(dir?: string): Promise<ShippedAgent[]>;
13
13
  export declare const GITIGNORE = "# \u8F6C\u5F55\u65E5\u5FD7\u4F53\u79EF\u5927\u3001\u53EF\u4ECE\u5BF9\u8BDD\u7D22\u5F15\u91CD\u5EFA\u5173\u952E\u4FE1\u606F;\u8981\u7559\u5168\u6863\u5C31\u5220\u6389\u4E0B\u9762\u8FD9\u884C\nconversations/**/*.log\n# \u914D\u97F3\u53EF\u91CD\u65B0\u5408\u6210\nconversations/**/*.mp3\n# \u56DE\u653E(cotutor replay)\u662F\u8C03\u63D0\u793A\u8BCD\u4E0E vault \u65F6\u7684\u5BF9\u7167,\u4E0D\u662F\u5B69\u5B50\u7684\u5BF9\u8BDD\nevals/\n# \u505A\u597D\u7684\u8BFE(\u8BFE\u5305\u3001\u89C6\u9891\u8BFE)\u4E0E\u8BFE\u5305\u622A\u56FE:\u4F53\u79EF\u5927,\u6E90\u5728\u5BB6\u957F\u505A\u8BFE\u7684\u5730\u65B9\nbundles/\nlectures/\nsnaps/\n# \u53E3\u8BED\u8BFE(\u5B69\u5B50\u7684\u539F\u58F0\u3001\u7167\u7247)\ntalk/\n# \u4F5C\u4E1A\u7167\u7247(\u5B69\u5B50\u5728\u8001\u5E08\u9875\u4E0A\u62CD\u7684;paths.captures):\u4F53\u79EF\u5927\u3001\u53EA\u662F\u8FD9\u4E00\u8F6E\u7684\u7D20\u6750,\u8BA4\u51FA\u7684\u6587\u5B57\u5728\u65E5\u8BB0\u91CC\ncaptures/\n# \u5BB6\u957F\u5728 workspace \u91CC\u7528 Claude Code \u65F6\u5B83\u7684\u4E2A\u4EBA\u8BBE\u7F6E\n.claude/settings.local.json\n# .cotutor/ \u662F\u673A\u5668\u6587\u4EF6(\u677F\u4E66\u5199\u6CD5\u7F13\u5B58\u3001\u914D\u97F3\u7F13\u5B58\u3001\u4E8C\u7EF4\u7801\u3001\u65E5\u5FD7),\u53EA\u7559\u51FA\u5382 hash \u4E0E schema\n.cotutor/*\n!.cotutor/shipped.json\n!.cotutor/cotutor.schema.json\nnode_modules/\n.DS_Store\n";
14
14
  /** 档案模板(paths.profile,缺了才写):cotutor: profile 的那篇,原文整篇进每个话题的第一条 */
15
- export declare function profileTemplate(name: string): string;
15
+ export declare function profileTemplate(name: string, schoolStart?: string): string;
16
16
  /** 参考目录的 README(缺了才写) */
17
17
  export declare const REFERENCE_README = "# \u53C2\u8003\n\n\u8FD9\u91CC\u653E\u653F\u7B56\u4E0E\u4F60\u81EA\u5DF1\u7684\u7B14\u8BB0:\u8BB2\u67D0\u7C7B\u9898\u7684\u504F\u597D\u3001\u8584\u5F31\u70B9\u3001\u5FCC\u8BB3\u3001\u6309\u9898\u578B\u6574\u7406\u7684\u4E1C\u897F\u2014\u2014\u968F\u4FBF\u5EFA,\u673A\u5668\u53EA\u8BFB\u4E0D\u5199\u3002\n\u8BA9\u8001\u5E08\u770B\u5230\u7684\u529E\u6CD5:\u5728\u6863\u6848\u6216\u8FD9\u4F4D\u8001\u5E08\u7684\u5165\u53E3\u6587\u4EF6(`cotutor: subject` \u7684\u90A3\u7BC7)\u91CC\u94FE\u8FC7\u53BB(`[[\u627E\u89C4\u5F8B\u586B\u6570]]`);\u8001\u5E08\u62FF\u5230\u8DEF\u5F84,\u8981\u7528\u65F6\u81EA\u5DF1\u8BFB\u3002\n";
18
18
  export interface TutorTemplateInput {
@@ -55,12 +55,12 @@ node_modules/
55
55
  .DS_Store
56
56
  `;
57
57
  /** 档案模板(paths.profile,缺了才写):cotutor: profile 的那篇,原文整篇进每个话题的第一条 */
58
- export function profileTemplate(name) {
58
+ export function profileTemplate(name, schoolStart) {
59
59
  return `---
60
60
  cotutor: profile
61
61
  nickname: ${name}
62
62
  birthday:
63
- school_start:
63
+ school_start:${schoolStart ? ` ${schoolStart}` : ''}
64
64
  city:
65
65
  ---
66
66
 
@@ -20,7 +20,7 @@ import { siteUrl } from "../../shared/site-url.js";
20
20
  import { isNewer } from "../../shared/upgrade.js";
21
21
  import { portHolder } from "./port-holder.js";
22
22
  import { RUN_KEY, STARTUP_APPROVED_KEY, encodeLauncher, parseProcs, psArgs, runValue, startupDisabled, winCommandLine } from "./service-windows.js";
23
- import { binPath, controlService, listServices, parseLaunchctlPrint, probeHealth, wscriptPath, xml } from "./service.js";
23
+ import { binPath, controlService, listServices, parseLaunchctlPrint, probeHealth, wscriptPath, xml, launchdLoad, launchdUnload, stableNode } from "./service.js";
24
24
  import { macTrayIcons, windowsTrayIcons } from "./tray-icons.js";
25
25
  import { HELPER_NO_SESSION, TRAY_AUMID, TRAY_LABEL, TRAY_RUN_VALUE } from "./tray-ids.js";
26
26
  import { MAC_TRAY_SCRIPT } from "./tray-mac.js";
@@ -65,6 +65,8 @@ export function issuesOf(p, o) {
65
65
  out.push({ text: 'vault 不在了:看看 cotutor.json 的 paths.vault', fix: { kind: 'open', target: p.root } });
66
66
  if (h.bailian === false)
67
67
  out.push({ text: '还没填百炼:老师们到不了', fix: { kind: 'open', target: url } });
68
+ else if (h.bailianTrouble)
69
+ out.push({ text: h.bailianTrouble.message, fix: { kind: 'open', target: url } });
68
70
  if (h.version && h.version !== o.version)
69
71
  out.push({ text: `在跑的是 ${h.version},装的是 ${o.version}:重启一下`, fix: { kind: 'restart' } });
70
72
  return out;
@@ -540,7 +542,7 @@ export function realTrayDeps(host, dir, file, version) {
540
542
  };
541
543
  }
542
544
  /** 登录项跑的命令 */
543
- export const trayArgs = (host, o = {}) => [o.node ?? process.execPath, o.bin ?? binPath(), 'tray', 'run', '--dir', trayDir(host)];
545
+ export const trayArgs = (host, o = {}) => [o.node ?? stableNode(process.execPath), o.bin ?? binPath(), 'tray', 'run', '--dir', trayDir(host)];
544
546
  /** macOS 的 LaunchAgent:登录就起,崩了拉起,以 0 退(点了退出)不拉 */
545
547
  export function trayPlist(args, dir, path) {
546
548
  const str = (v) => `<string>${xml(v)}</string>`;
@@ -598,9 +600,9 @@ export async function installTray(host, o = {}) {
598
600
  }
599
601
  const file = plistFile(host);
600
602
  await mkdir(join(host.home, 'Library', 'LaunchAgents'), { recursive: true });
601
- await host.run('launchctl', ['bootout', `gui/${host.uid}/${TRAY_LABEL}`]);
603
+ await launchdUnload(host, `gui/${host.uid}/${TRAY_LABEL}`);
602
604
  await writeFile(file, trayPlist(args, dir, host.env.PATH ?? '/usr/bin:/bin'));
603
- const r = await host.run('launchctl', ['bootstrap', `gui/${host.uid}`, file]);
605
+ const r = await launchdLoad(host, file);
604
606
  return { file, started: r.code === 0 };
605
607
  }
606
608
  /** 卸掉:停下、删登录项。remember = 家长说不要(以后 service install 也不再装) */
@@ -0,0 +1,38 @@
1
+ import { type Release } from '../machine/update.ts';
2
+ import { type Health } from './service.ts';
3
+ export interface UpdateService {
4
+ name: string;
5
+ kid: string;
6
+ root: string;
7
+ port: number;
8
+ node: string;
9
+ health: Health | null;
10
+ }
11
+ export interface UpdateDeps {
12
+ current: string;
13
+ /** 从仓库跑的:版本跟着 git 走,npm 不管 */
14
+ fromRepo: boolean;
15
+ nodeVersion: string;
16
+ release(): Promise<Release | null>;
17
+ services(): Promise<UpdateService[]>;
18
+ health(port: number): Promise<Health | null>;
19
+ npm(spec: string, registry: string | null): Promise<{
20
+ code: number;
21
+ out: string;
22
+ }>;
23
+ /** 现在装着的版本:读文件,npm 刚换过 */
24
+ installed(): string | null;
25
+ /** 跑新的 bin */
26
+ cli(args: string[]): Promise<{
27
+ code: number;
28
+ out: string;
29
+ }>;
30
+ /** 没有后台服务时,在终端里跑 serve 的那个孩子 */
31
+ workspace(): string | null;
32
+ stableNode(path: string): string;
33
+ print(line: string): void;
34
+ }
35
+ export declare function runUpdate(d: UpdateDeps, o?: {
36
+ anyway?: boolean;
37
+ }): Promise<number>;
38
+ export declare function realUpdateDeps(workspace: () => string | null): UpdateDeps;