cotutor 0.4.0 → 0.4.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 +39 -8
- package/dist/app/cli/cert.d.ts +6 -0
- package/dist/app/cli/cert.js +16 -2
- package/dist/app/cli/doctor.d.ts +5 -0
- package/dist/app/cli/doctor.js +65 -17
- package/dist/app/cli/init.js +1 -1
- package/dist/app/cli/main.js +58 -17
- package/dist/app/cli/serve.d.ts +0 -1
- package/dist/app/cli/serve.js +5 -0
- package/dist/app/cli/service.d.ts +40 -0
- package/dist/app/cli/service.js +50 -5
- package/dist/app/cli/tray-run.js +11 -2
- package/dist/app/cli/tray.d.ts +35 -4
- package/dist/app/cli/tray.js +67 -13
- package/dist/app/cli/update-cmd.d.ts +38 -0
- package/dist/app/cli/update-cmd.js +174 -0
- package/dist/app/cli/usage.d.ts +1 -1
- package/dist/app/cli/usage.js +4 -3
- package/dist/app/http/app.d.ts +2 -0
- package/dist/app/http/app.js +19 -2
- package/dist/app/machine/bailian.d.ts +26 -0
- package/dist/app/machine/bailian.js +91 -4
- package/dist/app/machine/update.d.ts +38 -0
- package/dist/app/machine/update.js +109 -0
- package/dist/brain/api.js +6 -3
- package/dist/realtime/preview.d.ts +16 -0
- package/dist/realtime/preview.js +43 -1
- package/dist/runtime/live/call.d.ts +2 -0
- package/dist/runtime/live/call.js +6 -1
- package/dist/shared/upgrade.d.ts +9 -0
- package/dist/shared/upgrade.js +23 -0
- package/dist/ui/kid/kid-page.js +8 -2
- package/dist/ui/parent/parent-home-page.js +47 -2
- package/package.json +11 -2
- package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +1 -1
- package/skills/cotutor-tune/references//345/255/227/346/256/265.md +1 -1
package/README.md
CHANGED
|
@@ -6,15 +6,24 @@
|
|
|
6
6
|
|
|
7
7
|
## 五分钟用起来
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**让 AI 助手帮你装:** 把这句话发给编程助手(Claude Code、Codex 这类能在电脑上跑命令的),或者网页版的聊天机器人,它会一步步带你做。百炼的 Key 由你自己填在页面上,不用告诉它。
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
请阅读 https://raw.githubusercontent.com/TarsLab/cotutor/main/INSTALL.md ,照着在这台电脑上安装 cotutor
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**自己装:** 需要一台电脑(Mac 最省事)、Node ≥ 22.18,和一个百炼账号(北京地域,按量付费)。老师的嘴和脑都走百炼,由应用直接调用,不用另装模型客户端:嘴是 `qwen3.8-omni-flash-realtime`,听孩子说、照着讲稿念;脑是 `qwen3.8-max`,写讲稿和板书。
|
|
10
16
|
|
|
11
17
|
```sh
|
|
12
18
|
npm i -g cotutor
|
|
13
19
|
cotutor init ming --name 小明 --grade 2 # 建 ~/cotutor/ming/:三位老师、技能、账本、对话目录、cotutor.json
|
|
14
|
-
cotutor service install #
|
|
15
|
-
cotutor open # 打开家长端,不用记地址
|
|
20
|
+
cotutor service install # 装成这台电脑的后台服务,第一次装会打开家长端,孩子的平板在那里扫码
|
|
16
21
|
```
|
|
17
22
|
|
|
23
|
+
Windows 的 PowerShell 缺省禁止运行脚本,报这个错时把 `npm`、`cotutor` 写成 `npm.cmd`、`cotutor.cmd`,不用改系统设置。
|
|
24
|
+
|
|
25
|
+
以后要打开家长端,点菜单栏(任务栏)的 cotutor 图标或跑 `cotutor open`,服务重启、登录电脑都不会自己弹出来。不想第一次弹,安装时加 `--no-open`。
|
|
26
|
+
|
|
18
27
|
后台服务登录后自己起来、崩了自己再起、关了终端照样跑,日志按天落文件:`cotutor service status` 看在不在跑,`cotutor service logs` 看日志,`cotutor service restart` 重起。Mac 上用 launchd,Linux 上用 systemd 用户服务,Windows 上是一条用户自己的登录项(不用管理员、没有黑窗口);第一次起来 Windows 可能弹窗问「允许访问」,点允许,平板才连得进来。要在终端里调试用 `cotutor serve`,先 `cotutor service stop` 让出端口。
|
|
19
28
|
|
|
20
29
|
Mac 和 Windows 上,`service install` 会一起装一个菜单栏(任务栏)图标,不用开终端就能看状态:正常时就是 logo;有事要处理出橙点;后台服务没在跑时 logo 变淡并出红点。状态变差时通知一次。点开图标能看原因、打开家长端、重启服务、看日志。「退出 cotutor」会停掉这台电脑上所有孩子的后台服务,平板打不开,下次登录自动恢复。Windows 的新图标先收在任务栏右下角的 `^` 里。不要图标就 `cotutor tray off`,装服务时加 `--no-tray` 也行。Linux 上没做,用 `cotutor service status` 看。
|
|
@@ -25,7 +34,9 @@ Mac 和 Windows 上,`service install` 会一起装一个菜单栏(任务栏)图
|
|
|
25
34
|
|
|
26
35
|
1. **填百炼**:在这台电脑上打开家长端 `/parent` 的「首页」,填百炼「按量付费」的 API Key 和业务空间 ID,保存前会先试连。API Key 在百炼控制台的「密钥管理」里创建,业务空间 ID 在控制台的业务空间详情里看,以百炼文档为准:[获取与配置 API Key](https://help.aliyun.com/zh/model-studio/get-api-key)、[首次调用千问 API](https://help.aliyun.com/zh/model-studio/first-api-call-to-qwen)(里面讲了业务空间 ID)。Key 跟地域绑定,控制台右上角先选北京。Token Plan 的 `sk-sp-` 不行。没填时孩子端只出「老师们还没到」。
|
|
27
36
|
2. **填档案**:workspace 里的 `孩子.md` 填 `school_start`(读一年级的年月,如 `2025-09`),老师据此知道现在是几年级哪个学期。想用自己的 Obsidian 库,改 `cotutor.json` 的 `paths.vault`。
|
|
28
|
-
3. **体检**:`cotutor doctor`
|
|
37
|
+
3. **体检**:`cotutor doctor` 逐项检查,不过的每条都带修法。
|
|
38
|
+
|
|
39
|
+
装好以后也可以让编程助手帮你管:在孩子的文件夹(`~/cotutor/<孩子>/`)里打开 Claude Code 或 Codex,已带的技能直接能用。`cotutor-home` 排孩子的首页,`cotutor-tune` 调老师的口气和规矩,`cotutor-analyze` 分析某次对话为什么那样答。
|
|
29
40
|
|
|
30
41
|
`init` 把出厂老师拷进 `agents/<老师>.md`,把做课包用的四个技能 chalk-* 和五个机器件 cotutor-tutor / cotutor-board / cotutor-analyze / cotutor-tune / cotutor-home 拷进 `.agents/skills/`(Codex、Qwen Code 直接读;Claude Code 读的 `.claude/skills` 是链到它的);
|
|
31
42
|
做小课堂的课包用 `cotutor chalk`(check / build / bake / snap / dub),烤画面与截图用本机的 Chrome(Windows 上没有 Chrome 就用 Edge)。要在平板上用(按住说话要 HTTPS):`cotutor cert` 签一张
|
|
@@ -89,20 +100,40 @@ import { loadWorkspace } from 'cotutor/workspace'; // 解
|
|
|
89
100
|
|
|
90
101
|
换了运行时,之前的话题接不上,也不导入历史:孩子接着说会开新话题;换之前聊的话题不记账,家长端卡上标「换过运行时 · 不记账」,记录照样能看。模型、预算与老师运行时都在 workspace 的 `cotutor.json` 里,编辑器按首行 `$schema` 提供字段说明。
|
|
91
102
|
|
|
103
|
+
百炼欠费、key 失效或这台电脑连不上百炼时,图标出橙点并通知,家长端首页说清楚是哪一种;要家长动手的那几种,孩子端显示「老师们还没到,叫爸爸妈妈来看一下」。处理好以后几分钟内自己恢复,也可以在家长端点「试连」。
|
|
104
|
+
|
|
92
105
|
`cotutor doctor` 检查缺失配置并给修复建议;`--live` 会实际连接服务。页面上的老师起不来时,先看 doctor 和 `conversations/<老师>/<日期>.<job>.err.log`。家长端与工作台需要访问锁时,运行 `cotutor pin` 设置本机 PIN。
|
|
93
106
|
|
|
94
107
|
## 数据与升级
|
|
95
108
|
|
|
96
109
|
默认 workspace 是 `~/cotutor/<孩子>/`:配置在 `cotutor.json`,老师在 `agents/<老师>.md`,activity 在 `agents/<老师>/activities/`,技能在 `.agents/skills/`,对话在 `conversations/`,账本在 `ledger/`,照片在 `captures/`,课包在 `bundles/`,首页在 `home/`。长期档案、记忆和日记在配置的 Obsidian vault,规则见 [obsidian 仓库设计](docs/obsidian仓库设计.md)。备份时保留整个 workspace 与 vault。
|
|
97
110
|
|
|
111
|
+
**有新版本时**,后台服务每天问一次 npm(只发一个普通请求,不带孩子或使用的任何信息),菜单栏(任务栏)图标的菜单和家长端「首页」顶部会说,通知一次,孩子端不说。不会自动升级,因为要重启服务,孩子可能正在上课。不想让它问:在 `~/.config/cotutor/update.json` 写 `{"off": true}`。
|
|
112
|
+
|
|
113
|
+
升级挑孩子不用的时候,运行一条命令就够了(Windows 上写成 `cotutor.cmd update`,因为 PowerShell 缺省禁止运行脚本,提示里给的就是这样):
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
cotutor update
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
它问 npm 有没有新版本,换上新程序,把每个孩子未修改的出厂老师、activity、主题与技能更新掉,再重起服务和图标。孩子正在通话时它不动;下载没成时服务也不动,还跑旧版。每个版本改了什么见 [更新说明](CHANGELOG.md)。
|
|
120
|
+
|
|
121
|
+
0.4.1 及更早的版本还没有这条命令,第一次要依次运行下面三条,Windows 上同样写成 `npm.cmd`、`cotutor.cmd`。几个孩子的话,后两条每个孩子各跑一次,加 `--workspace ~/cotutor/<孩子>`:
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
npm i -g cotutor # 换上新版程序
|
|
125
|
+
cotutor upgrade # 更新未修改的出厂老师、activity、主题与技能
|
|
126
|
+
cotutor service restart # 服务和图标换上新代码(在终端里跑的就重开 serve)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
升完它提示要迁移配置时再跑:
|
|
130
|
+
|
|
98
131
|
```sh
|
|
99
|
-
cotutor upgrade # 更新未修改的出厂老师、activity、主题与技能
|
|
100
132
|
cotutor upgrade --config --dry-run # 预览 cotutor.json 的迁移
|
|
101
133
|
cotutor upgrade --config # 执行配置迁移
|
|
102
|
-
cotutor doctor
|
|
103
134
|
```
|
|
104
135
|
|
|
105
|
-
|
|
136
|
+
家长改过的老师与主题保留并显示差异;机器技能会随版本覆盖。配置迁移补缺,并更新未自定义的旧命令模板,保留自定义配置。换了 Node 版本、或包装到了别处,再 `cotutor service install` 一次。
|
|
106
137
|
|
|
107
138
|
2026-10-30 之前建的 workspace,老师在 `.claude/agents/`、技能在 `.claude/skills/`。`cotutor upgrade`(或 `init`)先把它们原样挪到新位置,再照常升级;新位置已有同名的不挪,会点名。`.claude/` 里别的文件不动。
|
|
108
139
|
|
|
@@ -135,7 +166,7 @@ node bin/cotutor.js mock # 假模型接口,查看前端
|
|
|
135
166
|
|
|
136
167
|
改卡片文档或技能生成代码后运行 `pnpm gen:skills`。开发约定与分模块验证见 [AGENTS.md](AGENTS.md) 和 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
|
137
168
|
|
|
138
|
-
|
|
169
|
+
根目录的 [INSTALL.md](INSTALL.md) 给替家长安装的 AI 助手看。`docs/` 只保留以下六篇,历史讨论查 Git:
|
|
139
170
|
|
|
140
171
|
- [iPad 与 iPhone](docs/iPad与iPhone.md)
|
|
141
172
|
- [obsidian 仓库设计](docs/obsidian仓库设计.md)
|
package/dist/app/cli/cert.d.ts
CHANGED
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
* 没有 winget 的(11-02 那台就没有)下官方的 exe。
|
|
4
4
|
*/
|
|
5
5
|
export declare function mkcertHint(platform?: NodeJS.Platform): string;
|
|
6
|
+
/** mkcert 在、根证书建过了:cotutor cert 不用人就能跑。mkcert -install 要开机密码或点弹窗,那一步是人的 */
|
|
7
|
+
export declare function mkcertReady(caRootOf?: () => Promise<string>): Promise<boolean>;
|
|
8
|
+
/** 根证书没进这台电脑的系统信任。mkcert 没有根证书时自己建一个、照样签成功,只在 stderr 说一句(Firefox、Java 的信任库它也说,那些不算);没有单独的查询命令,只能这样看 */
|
|
9
|
+
export declare const mkcertUntrusted: (stderr: string) => boolean;
|
|
6
10
|
export interface CertResult {
|
|
7
11
|
cert: string;
|
|
8
12
|
key: string;
|
|
9
13
|
hosts: string[];
|
|
10
14
|
caRoot: string;
|
|
15
|
+
/** 这台电脑信任根证书了;false 时证书能用,但这台电脑的浏览器会报警告 */
|
|
16
|
+
trusted: boolean;
|
|
11
17
|
}
|
|
12
18
|
export declare function makeCert(extraHosts?: string[]): Promise<CertResult>;
|
package/dist/app/cli/cert.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* 地址认电脑名,IP 变了不用重签;证书里的 IP 只给手输数字地址的退路用。改了电脑名才要重签。
|
|
7
7
|
*/
|
|
8
8
|
import { execFile } from 'node:child_process';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
9
10
|
import { mkdir } from 'node:fs/promises';
|
|
10
11
|
import { hostname } from 'node:os';
|
|
11
12
|
import { join } from 'node:path';
|
|
@@ -24,6 +25,17 @@ export function mkcertHint(platform = process.platform) {
|
|
|
24
25
|
return 'winget install FiloSottile.mkcert(没有 winget 就到 https://github.com/FiloSottile/mkcert/releases 下 windows-amd64 那个,改名 mkcert.exe 放进 PATH),重开终端跑 mkcert -install(弹窗问装不装根证书,点「是」)';
|
|
25
26
|
return '用系统的包管理器装 mkcert(如 apt install mkcert),再 mkcert -install';
|
|
26
27
|
}
|
|
28
|
+
/** mkcert 在、根证书建过了:cotutor cert 不用人就能跑。mkcert -install 要开机密码或点弹窗,那一步是人的 */
|
|
29
|
+
export async function mkcertReady(caRootOf = async () => (await execFileP('mkcert', ['-CAROOT'], { timeout: 8000 })).stdout.trim()) {
|
|
30
|
+
try {
|
|
31
|
+
return existsSync(join(await caRootOf(), 'rootCA.pem'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** 根证书没进这台电脑的系统信任。mkcert 没有根证书时自己建一个、照样签成功,只在 stderr 说一句(Firefox、Java 的信任库它也说,那些不算);没有单独的查询命令,只能这样看 */
|
|
38
|
+
export const mkcertUntrusted = (stderr) => stderr.includes('not installed in the system trust store');
|
|
27
39
|
export async function makeCert(extraHosts = []) {
|
|
28
40
|
let caRoot;
|
|
29
41
|
try {
|
|
@@ -39,11 +51,13 @@ export async function makeCert(extraHosts = []) {
|
|
|
39
51
|
await mkdir(dir, { recursive: true });
|
|
40
52
|
const cert = join(dir, 'cert.pem');
|
|
41
53
|
const key = join(dir, 'key.pem');
|
|
54
|
+
let trusted;
|
|
42
55
|
try {
|
|
43
|
-
await execFileP('mkcert', ['-cert-file', cert, '-key-file', key, ...hosts], { timeout: 60_000 });
|
|
56
|
+
const { stderr } = await execFileP('mkcert', ['-cert-file', cert, '-key-file', key, ...hosts], { timeout: 60_000 });
|
|
57
|
+
trusted = !mkcertUntrusted(stderr);
|
|
44
58
|
}
|
|
45
59
|
catch (err) {
|
|
46
60
|
throw new UsageError(`mkcert 签证书失败:${err instanceof Error ? err.message : String(err)}\n先跑 mkcert -install,再重试。`);
|
|
47
61
|
}
|
|
48
|
-
return { cert, key, hosts, caRoot };
|
|
62
|
+
return { cert, key, hosts, caRoot, trusted };
|
|
49
63
|
}
|
package/dist/app/cli/doctor.d.ts
CHANGED
|
@@ -4,7 +4,12 @@ export interface DoctorCheck {
|
|
|
4
4
|
ok: boolean;
|
|
5
5
|
required: boolean;
|
|
6
6
|
detail: string;
|
|
7
|
+
/** 给人看的修法 */
|
|
7
8
|
fix?: string;
|
|
9
|
+
/** 不过时能直接跑的修复命令:幂等、不碰孩子的数据、不用人输东西(INSTALL.md 让 agent 照跑) */
|
|
10
|
+
cmd?: string;
|
|
11
|
+
/** 不过、又没有 cmd:要人来(填 key、输开机密码、点弹窗、写孩子的情况),agent 别动手。finish 统一补 */
|
|
12
|
+
human?: true;
|
|
8
13
|
}
|
|
9
14
|
export interface DoctorReport {
|
|
10
15
|
ok: boolean;
|
package/dist/app/cli/doctor.js
CHANGED
|
@@ -25,11 +25,23 @@ import { parseTimetable } from "../../memory/timetable.js";
|
|
|
25
25
|
import { configGapsOf, legacyLayout } from "./migrate.js";
|
|
26
26
|
import { httpsFiles } from "./serve.js";
|
|
27
27
|
import { readPin } from "../http/access.js";
|
|
28
|
+
import { BUILD } from "../build.js";
|
|
29
|
+
import { isNewer, upgradeSteps } from "../../shared/upgrade.js";
|
|
28
30
|
import { gradeOfSemester } from "../../memory/grade.js";
|
|
29
31
|
import { DIRS } from "./skeleton.js";
|
|
30
32
|
import { tutorStatuses, readManifest } from "./tutors.js";
|
|
31
33
|
import { workspaceGradePack } from "./grades.js";
|
|
32
34
|
import { CONFIG_FILE, ConfigError, USER_CONFIG, UsageError, assembleWorkspace, expandPath, parseConfig, readJson, redactHome, resolveRoot, } from "../workspace.js";
|
|
35
|
+
/** 每条不过的要么能跑(cmd)要么要人来(human),没有第三种:新加的检查项忘了写 cmd,也只是交给人,不会让 agent 瞎猜 */
|
|
36
|
+
function finish(report) {
|
|
37
|
+
for (const c of report.checks) {
|
|
38
|
+
if (c.ok)
|
|
39
|
+
delete c.cmd;
|
|
40
|
+
else if (!c.cmd)
|
|
41
|
+
c.human = true;
|
|
42
|
+
}
|
|
43
|
+
return report;
|
|
44
|
+
}
|
|
33
45
|
async function statOrNull(p) {
|
|
34
46
|
return stat(p).catch(() => null);
|
|
35
47
|
}
|
|
@@ -89,9 +101,14 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
89
101
|
detail: err.message,
|
|
90
102
|
fix: err instanceof ConfigError ? `修复 ${redactHome(err.file)} 的 JSON,或删掉它` : 'cotutor init <slug>,或用 --workspace / COTUTOR_WORKSPACE 指定',
|
|
91
103
|
});
|
|
92
|
-
return { ok: false, root: null, source: null, checks };
|
|
104
|
+
return finish({ ok: false, root: null, source: null, checks });
|
|
93
105
|
}
|
|
94
106
|
push({ name: 'workspace', ok: true, required: true, detail: `${redactHome(root)}(来源:${source})` });
|
|
107
|
+
// cmd 要能照原样跑:体检时用 --workspace 指的,修的时候也得指
|
|
108
|
+
// 路径写 $HOME/…,和别的字段一样:用户名不传给 agent 的厂商;redactDeep 只换开头,管不到命令中间的路径;sh 与 PowerShell 都展开
|
|
109
|
+
const quoted = `"${redactHome(root)}"`;
|
|
110
|
+
const flag = source === 'flag' ? ` --workspace ${quoted}` : '';
|
|
111
|
+
const upgrade = `cotutor upgrade${flag}`;
|
|
95
112
|
// ---- cotutor.json ----
|
|
96
113
|
let ws = null;
|
|
97
114
|
const configFile = join(root, CONFIG_FILE);
|
|
@@ -117,12 +134,20 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
117
134
|
push({ name: CONFIG_FILE, ok: false, required: true, detail: err.message, fix: '按上面逐条修;政策文件机器不重建,改坏了靠 git 回退' });
|
|
118
135
|
}
|
|
119
136
|
let teachingPack = null;
|
|
137
|
+
// 补缺的 init:slug 从配置来;年级照已装的写(init 不切换年级);建在缺省位置的不用写 --dir
|
|
138
|
+
const initCmd = () => {
|
|
139
|
+
if (!ws)
|
|
140
|
+
return undefined;
|
|
141
|
+
const slug = ws.config.kid.slug;
|
|
142
|
+
const grade = teachingPack ? ` --grade ${teachingPack.grade}` : '';
|
|
143
|
+
return `cotutor init ${slug}${grade}${root === join(homedir(), 'cotutor', slug) ? '' : ` --dir ${quoted}`}`;
|
|
144
|
+
};
|
|
120
145
|
try {
|
|
121
146
|
const manifest = await readManifest(root);
|
|
122
147
|
teachingPack = await workspaceGradePack(root);
|
|
123
148
|
push({ name: 'teachingPack', ok: true, required: true, detail: `${teachingPack.grade} 年级教学包 · ${redactHome(teachingPack.dir)}` });
|
|
124
149
|
if (!manifest.teachingPack)
|
|
125
|
-
push({ name: 'teachingPack.origin', ok: false, required: false, detail: '旧版出厂内容按二年级来源读取,尚未补记;孩子档案不变', fix: 'cotutor upgrade 补记来源,保留自定义文件与原 hash' });
|
|
150
|
+
push({ name: 'teachingPack.origin', ok: false, required: false, detail: '旧版出厂内容按二年级来源读取,尚未补记;孩子档案不变', fix: 'cotutor upgrade 补记来源,保留自定义文件与原 hash', cmd: upgrade });
|
|
126
151
|
}
|
|
127
152
|
catch (err) {
|
|
128
153
|
push({ name: 'teachingPack', ok: false, required: true, detail: err.message, fix: '检查 .cotutor/shipped.json 的 teachingPack 与本包 grades/;恢复原来源记录或安装包含该年级的版本,不要改成其他年级绕过' });
|
|
@@ -136,6 +161,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
136
161
|
required: false,
|
|
137
162
|
detail: gaps.length ? `${gaps.length} 项要改:${gaps.map((g) => g.detail).join(';')}` : 'cotutor.json 有出厂模板里的全部老师、运行时与旗标,也没有不再用的段',
|
|
138
163
|
fix: gaps.length ? 'cotutor upgrade --config --dry-run 先看,再 cotutor upgrade --config 改(只加缺的、删不再用的出厂段,你改过的值不动)' : undefined,
|
|
164
|
+
cmd: gaps.length ? `cotutor upgrade --config${flag}` : undefined,
|
|
139
165
|
});
|
|
140
166
|
}
|
|
141
167
|
// ---- 骨架 ----
|
|
@@ -149,11 +175,12 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
149
175
|
required: true,
|
|
150
176
|
detail: missing.length ? `缺 ${missing.join(' ')}` : [...DIRS].map((d) => `${d}/`).join(' '),
|
|
151
177
|
fix: missing.length ? `cotutor init <slug> --dir ${redactHome(root)}(幂等补缺)` : undefined,
|
|
178
|
+
cmd: missing.length ? initCmd() : undefined,
|
|
152
179
|
});
|
|
153
180
|
// 老布局(2026-10-30 之前):老师文件在 .claude/agents/、技能在 .claude/skills/,现在都读不到了
|
|
154
181
|
const legacy = await legacyLayout(root);
|
|
155
182
|
if (legacy.length)
|
|
156
|
-
push({ name: 'layout', ok: false, required: true, detail: `${legacy.join(' ')} 是老位置:老师文件现在在 agents/<老师>.md,技能在 ${SKILLS_DIR}/`, fix: 'cotutor upgrade(原样挪过去,不改内容;新位置已有同名的不挪、会点名)' });
|
|
183
|
+
push({ name: 'layout', ok: false, required: true, detail: `${legacy.join(' ')} 是老位置:老师文件现在在 agents/<老师>.md,技能在 ${SKILLS_DIR}/`, fix: 'cotutor upgrade(原样挪过去,不改内容;新位置已有同名的不挪、会点名)', cmd: upgrade });
|
|
157
184
|
// 家规(根的 QWEN.md)2026-09-15 起不出厂:有就是家长自建的共同规矩,提一句;没有不缺
|
|
158
185
|
const rulesThere = (await Promise.all(['QWEN.md'].map(async (f) => ((await statOrNull(join(root, f))) ? f : null)))).filter(Boolean);
|
|
159
186
|
push({ name: 'rules', ok: true, required: false, detail: rulesThere.length ? `家长自建的家规 ${rulesThere.join(' ')} 在,所有老师每轮都读` : '没有根目录的 QWEN.md(不需要:老师要知道的都在老师文件与技能里)' });
|
|
@@ -184,6 +211,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
184
211
|
: shipped
|
|
185
212
|
? `cotutor init 补拷(拷自本包 agents/${name}.md),或把老师键改成文件里的 name`
|
|
186
213
|
: `这是自家加的老师:写 agents/${name}.md(frontmatter name: ${name}),cotutor add ${name} --display <显示名> 可出模板;或把 cotutor.json 里这条删掉`,
|
|
214
|
+
cmd: !ok && shipped ? initCmd() : undefined,
|
|
187
215
|
});
|
|
188
216
|
const st = statuses.get(name);
|
|
189
217
|
if (!st)
|
|
@@ -196,11 +224,12 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
196
224
|
required: false,
|
|
197
225
|
detail: label[st.state],
|
|
198
226
|
fix: st.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined,
|
|
227
|
+
cmd: st.state === 'upgradable' ? upgrade : undefined,
|
|
199
228
|
});
|
|
200
229
|
}
|
|
201
230
|
const home = join(ws.dirs.agents, name);
|
|
202
231
|
const there = (await statOrNull(home))?.isDirectory() ?? false;
|
|
203
|
-
push({ name: `tutor.${name}.home`, ok: there, required: true, detail: there ? `agents/${name}/ 在(会话 cwd)` : `agents/${name}/ 不在`, fix: there ? undefined : 'cotutor init 补建' });
|
|
232
|
+
push({ name: `tutor.${name}.home`, ok: there, required: true, detail: there ? `agents/${name}/ 在(会话 cwd)` : `agents/${name}/ 不在`, fix: there ? undefined : 'cotutor init 补建', cmd: there ? undefined : initCmd() });
|
|
204
233
|
// 音色:一老师一音色走 API 是正路;没配的孩子端只剩浏览器合成声(只该在测试里);hidden 的老师不对孩子说话,不用音色
|
|
205
234
|
const tc = ws.config.tutors[name];
|
|
206
235
|
if (tc.enabled && !tc.hidden && !cardVoice(ws.config.tutors, name))
|
|
@@ -234,8 +263,13 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
234
263
|
const { portHolder, portState } = await import("./port.js");
|
|
235
264
|
const port = opts.port ?? ws.config.server.port;
|
|
236
265
|
const fix = '腾出这个端口;或改 cotutor.json 的 server.port,平板要重新扫码、重加主屏幕图标';
|
|
237
|
-
|
|
266
|
+
const running = await probeHealth(port);
|
|
267
|
+
if (running) {
|
|
238
268
|
push({ name: 'server.port', ok: true, required: false, detail: `端口 ${port} 上 cotutor 在答应` });
|
|
269
|
+
// 配好了不等于用得了:欠费、key 失效是跑起来才知道的,问在跑的那个服务
|
|
270
|
+
if (running.bailianTrouble)
|
|
271
|
+
push({ name: 'bailian.runtime', ok: false, required: false, detail: `${running.bailianTrouble.message}(${running.bailianTrouble.why ?? ''})`, fix: running.bailianTrouble.fix });
|
|
272
|
+
}
|
|
239
273
|
else {
|
|
240
274
|
const state = await portState(port);
|
|
241
275
|
if (state === 'free')
|
|
@@ -259,11 +293,11 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
259
293
|
const { CLAUDE_SKILLS_LINK, skillStatuses } = await import("./skills.js");
|
|
260
294
|
for (const sk of teachingPack ? await skillStatuses(root) : []) {
|
|
261
295
|
const label = { latest: sk.machine ? '机器件,最新' : '出厂件,最新', upgradable: sk.machine ? '机器件,和包里不一样(改过或包已更新)' : `出厂件,基于 ${sk.basedOn},包已更新`, custom: `自定义(基于 ${sk.basedOn})`, untracked: '自定义(没有出厂记录)', missing: sk.machine ? '缺(机器件,老师或家长的技能靠它)' : '缺' };
|
|
262
|
-
push({ name: `skill.${sk.name}`, ok: sk.state !== 'missing' && sk.state !== 'upgradable', required: Boolean(sk.machine), detail: `${SKILLS_DIR}/${sk.name}/:${label[sk.state]}`, fix: sk.state === 'missing' ? 'cotutor init 补拷' : sk.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined });
|
|
296
|
+
push({ name: `skill.${sk.name}`, ok: sk.state !== 'missing' && sk.state !== 'upgradable', required: Boolean(sk.machine), detail: `${SKILLS_DIR}/${sk.name}/:${label[sk.state]}`, fix: sk.state === 'missing' ? 'cotutor init 补拷' : sk.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined, cmd: sk.state === 'missing' ? initCmd() : sk.state === 'upgradable' ? upgrade : undefined });
|
|
263
297
|
}
|
|
264
298
|
// Claude Code 只认 .claude/skills:那里该是指向 .agents/skills 的链;老师用不到,家长在 workspace 里用 Claude Code 才要
|
|
265
299
|
const linked = (await lstat(join(root, CLAUDE_SKILLS_LINK)).catch(() => null))?.isSymbolicLink() ?? false;
|
|
266
|
-
push({ name: 'skills.claude', ok: linked, required: false, detail: linked ? `${CLAUDE_SKILLS_LINK} 链到 ${SKILLS_DIR}(Claude Code 读的是同一份)` : `${CLAUDE_SKILLS_LINK} 不是链,在 workspace 里用 Claude Code 看不到 ${SKILLS_DIR}/ 的技能`, fix: linked ? undefined : 'cotutor upgrade 建链(是实体目录的话先把里面的挪进 .agents/skills/ 再删掉)' });
|
|
300
|
+
push({ name: 'skills.claude', ok: linked, required: false, detail: linked ? `${CLAUDE_SKILLS_LINK} 链到 ${SKILLS_DIR}(Claude Code 读的是同一份)` : `${CLAUDE_SKILLS_LINK} 不是链,在 workspace 里用 Claude Code 看不到 ${SKILLS_DIR}/ 的技能`, fix: linked ? undefined : 'cotutor upgrade 建链(是实体目录的话先把里面的挪进 .agents/skills/ 再删掉)', cmd: linked ? undefined : upgrade });
|
|
267
301
|
// ---- activity:老师的家里 agents/<老师>/activities/<名>.md;出厂的看新旧,每份都过一遍 activity check ----
|
|
268
302
|
{
|
|
269
303
|
const { activityStatuses, checkActivities } = await import("./activities.js");
|
|
@@ -271,7 +305,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
271
305
|
const label = { latest: '出厂件,最新', upgradable: '出厂件,包已更新', custom: '自定义', untracked: '自定义(没有出厂记录)', missing: '缺', broken: '读不到' };
|
|
272
306
|
for (const s of states.values())
|
|
273
307
|
if (s.state === 'missing')
|
|
274
|
-
push({ name: `activity.${s.key}`, ok: false, required: false, detail: `agents/${s.key.replace('/', '/activities/')}.md 不在`, fix: 'cotutor init 补拷' });
|
|
308
|
+
push({ name: `activity.${s.key}`, ok: false, required: false, detail: `agents/${s.key.replace('/', '/activities/')}.md 不在`, fix: 'cotutor init 补拷', cmd: initCmd() });
|
|
275
309
|
// 学科色:手改 cotutor.json 写的颜色,新话题气泡的白字要压得住
|
|
276
310
|
for (const [name, t] of Object.entries(ws.config.tutors)) {
|
|
277
311
|
if (!t.color)
|
|
@@ -295,6 +329,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
295
329
|
required: false,
|
|
296
330
|
detail: fixes.length ? `${origin};${fixes.length} 条要改,第一条:${fixes[0].line ? `第 ${fixes[0].line} 行 ` : ''}${fixes[0].text}` : origin,
|
|
297
331
|
fix: fixes.length ? `cotutor activity check ${r.tutor} ${r.name} 看全` : st?.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined,
|
|
332
|
+
cmd: !fixes.length && st?.state === 'upgradable' ? upgrade : undefined,
|
|
298
333
|
});
|
|
299
334
|
}
|
|
300
335
|
}
|
|
@@ -308,7 +343,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
308
343
|
push({ name: 'theme.manifest', ok: true, required: false, detail: `themes/${name}/:${Object.keys(t.manifest.tints).length} 个底色槽、${Object.keys(t.manifest.looks).length} 个字形槽、${Object.keys(t.manifest.pens).length} 支笔,default = ${t.manifest.default}` });
|
|
309
344
|
}
|
|
310
345
|
catch (err) {
|
|
311
|
-
push({ name: 'theme.manifest', ok: false, required: false, detail: `themes/${name}/ 用不了(服务退回出厂 default):${err instanceof Error ? err.message : String(err)}`, fix: name === 'default' ? 'cotutor init 补拷(已有的不动)或 cotutor upgrade' : `修 themes/${name}/theme.json 与 kid.css,或把 cotutor.json 的 kid.theme 改回 default
|
|
346
|
+
push({ name: 'theme.manifest', ok: false, required: false, detail: `themes/${name}/ 用不了(服务退回出厂 default):${err instanceof Error ? err.message : String(err)}`, fix: name === 'default' ? 'cotutor init 补拷(已有的不动)或 cotutor upgrade' : `修 themes/${name}/theme.json 与 kid.css,或把 cotutor.json 的 kid.theme 改回 default`, cmd: name === 'default' ? initCmd() : undefined });
|
|
312
347
|
}
|
|
313
348
|
for (const th of teachingPack ? await themeStatuses(root) : []) {
|
|
314
349
|
if (!th.shipped) {
|
|
@@ -316,7 +351,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
316
351
|
continue;
|
|
317
352
|
}
|
|
318
353
|
const label = { latest: '出厂件,最新', upgradable: `出厂件,基于 ${th.basedOn},包已更新`, custom: `自定义(基于 ${th.basedOn})`, untracked: '自定义(没有出厂记录)', missing: '缺' };
|
|
319
|
-
push({ name: `theme.${th.name}.origin`, ok: th.state !== 'missing' && th.state !== 'upgradable', required: false, detail: `themes/${th.name}/:${label[th.state]}`, fix: th.state === 'missing' ? 'cotutor init 补拷' : th.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined });
|
|
354
|
+
push({ name: `theme.${th.name}.origin`, ok: th.state !== 'missing' && th.state !== 'upgradable', required: false, detail: `themes/${th.name}/:${label[th.state]}`, fix: th.state === 'missing' ? 'cotutor init 补拷' : th.state === 'upgradable' ? 'cotutor upgrade 换新版' : undefined, cmd: th.state === 'missing' ? initCmd() : th.state === 'upgradable' ? upgrade : undefined });
|
|
320
355
|
}
|
|
321
356
|
}
|
|
322
357
|
}
|
|
@@ -442,8 +477,10 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
442
477
|
push({ name: 'server.access', ok: pin !== null, required: false, detail: pin ? '已设 PIN' : '家长端、工作台没上锁', fix: pin ? undefined : 'cotutor pin' });
|
|
443
478
|
// ---- HTTPS:iPad 上录音要;没有只提醒 ----
|
|
444
479
|
const tls = httpsFiles(ws);
|
|
445
|
-
const { mkcertHint } = await import("./cert.js");
|
|
446
|
-
|
|
480
|
+
const { mkcertHint, mkcertReady } = await import("./cert.js");
|
|
481
|
+
// mkcert -install 要开机密码或点弹窗,是人的事;做过了,签证书就不用人了
|
|
482
|
+
const canSign = !tls && probeEnv && await mkcertReady();
|
|
483
|
+
push({ name: 'https', ok: tls !== null, required: false, detail: tls ? `${redactHome(tls.cert)}` : '没有证书,serve 走 HTTP(平板、手机上按住说话不可用)', fix: tls ? undefined : `cotutor cert(需要 mkcert:${mkcertHint()}),签完 cotutor service restart`, cmd: canSign ? 'cotutor cert' : undefined });
|
|
447
484
|
// ---- 百炼:老师靠实时模型开口,没配孩子端打不开;--live 真试连一次(握手就挂,不花钱) ----
|
|
448
485
|
// key 只认 ~/.config/cotutor/(2026-11-02 起);shell 里还 export 着旧的,说一句免得以为配了
|
|
449
486
|
const bl = dashscopeAuth(env);
|
|
@@ -502,14 +539,18 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
502
539
|
if (traySupported(process.platform)) {
|
|
503
540
|
const t = await trayStatus({ ...realHost(), env }).catch(() => null);
|
|
504
541
|
if (t && !t.off)
|
|
505
|
-
push({ name: 'tray', ok: t.installed, required: false, detail: t.installed ? `托盘图标装了${t.running ? ',在跑' : ',现在没在跑(点过退出,下次登录回来)'}` : '没装托盘图标:看状态只能开终端', fix: t.installed ? undefined : 'cotutor tray on' });
|
|
542
|
+
push({ name: 'tray', ok: t.installed, required: false, detail: t.installed ? `托盘图标装了${t.running ? ',在跑' : ',现在没在跑(点过退出,下次登录回来)'}` : '没装托盘图标:看状态只能开终端', fix: t.installed ? undefined : 'cotutor tray on', cmd: t.installed ? undefined : 'cotutor tray on' });
|
|
506
543
|
}
|
|
507
544
|
}
|
|
508
545
|
if (!s)
|
|
509
|
-
push({ name: 'service', ok: false, required: false, detail: '没装后台服务:在终端里跑的 cotutor serve,关了终端就停', fix: 'cotutor service install(登录后自己起来、崩了自己再起)' });
|
|
546
|
+
push({ name: 'service', ok: false, required: false, detail: '没装后台服务:在终端里跑的 cotutor serve,关了终端就停', fix: 'cotutor service install(登录后自己起来、崩了自己再起)', cmd: `cotutor service install --no-open${flag}` });
|
|
510
547
|
else {
|
|
548
|
+
const { stableNode } = await import("./service.js");
|
|
511
549
|
const gone = s.args.slice(0, 2).filter((f) => !existsSync(f));
|
|
512
|
-
|
|
550
|
+
// 还没坏,但 brew upgrade 以后就坏了,而且坏的时候图标也起不来,没人报警
|
|
551
|
+
if (!gone.length && stableNode(s.args[0]) !== s.args[0])
|
|
552
|
+
push({ name: 'service.node', ok: false, required: false, detail: `后台服务写死了带版本号的 node(${redactHome(s.args[0])}),brew upgrade 以后服务和图标都起不来`, fix: 'cotutor service install(换成不带版本号的路径)', cmd: `cotutor service install --no-open${flag}` });
|
|
553
|
+
push({ name: 'service', ok: !gone.length, required: false, detail: gone.length ? `后台服务 ${s.label} 指的 ${gone.map((f) => redactHome(f)).join('、')} 不在了(换了 node 版本、包挪了地方)` : `后台服务 ${s.label}(cotutor service status 看在不在跑)`, fix: gone.length ? 'cotutor service install(照现在的 node 与包重写一遍)' : undefined, cmd: gone.length ? `cotutor service install${flag}` : undefined });
|
|
513
554
|
}
|
|
514
555
|
}
|
|
515
556
|
if (probeEnv && process.platform === 'darwin') {
|
|
@@ -528,6 +569,13 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
528
569
|
if (sec !== null)
|
|
529
570
|
push({ name: 'windows.sleep', ok: sec === 0, required: false, detail: sec === 0 ? '接着电源时不闲置睡眠' : `接着电源 ${Math.round(sec / 60)} 分钟没人碰就睡,睡着时平板连不上(有通话时 cotutor 拦着它睡,别的时候拦不了)`, fix: sec === 0 ? undefined : '设置 › 系统 › 电源和电池 › 屏幕、睡眠和休眠超时,「插入电源时,使设备在以下时间后进入睡眠状态」选「从不」;合盖看「电源按钮和盖子」那一项' });
|
|
530
571
|
}
|
|
572
|
+
// ---- 有没有新版本:只看服务问来存下的那份,体检不上网;什么时候升级家长定(要重起服务,孩子可能在上课),所以没有 cmd ----
|
|
573
|
+
{
|
|
574
|
+
const { readUpdateRecord } = await import("../machine/update.js");
|
|
575
|
+
const u = await readUpdateRecord();
|
|
576
|
+
if (!u.off && isNewer(u.latest, BUILD.version))
|
|
577
|
+
push({ name: 'update', ok: false, required: false, detail: `有新版本 ${u.latest}(现在是 ${BUILD.version})`, fix: `挑孩子不用的时候,运行:${upgradeSteps().join(' ; ')}` });
|
|
578
|
+
}
|
|
531
579
|
// ---- 用户配置 ----
|
|
532
580
|
try {
|
|
533
581
|
const text = await readFile(USER_CONFIG, 'utf8');
|
|
@@ -547,7 +595,7 @@ export async function doctorWorkspace(override, opts = {}) {
|
|
|
547
595
|
push({ name: 'sandbox', ok: true, required: false, detail: `试用 workspace:${ws.sandbox.forkedAt.slice(0, 10)} 从 ${redactHome(ws.sandbox.from)} 分出来;--sandbox --fresh 重新分` });
|
|
548
596
|
else {
|
|
549
597
|
const gitThere = (await statOrNull(join(root, '.git'))) !== null;
|
|
550
|
-
push({ name: 'git', ok: gitThere, required: false, detail: gitThere ? 'workspace是 git 仓' : 'workspace不在 git 里', fix: gitThere ? undefined : '建议 git init:老师记忆、账本、政策都是不可再生状态' });
|
|
598
|
+
push({ name: 'git', ok: gitThere, required: false, detail: gitThere ? 'workspace是 git 仓' : 'workspace不在 git 里', fix: gitThere ? undefined : '建议 git init:老师记忆、账本、政策都是不可再生状态', cmd: gitThere ? undefined : `git init ${quoted}` });
|
|
551
599
|
}
|
|
552
|
-
return { ok: checks.filter((c) => c.required).every((c) => c.ok), root, source, checks };
|
|
600
|
+
return finish({ ok: checks.filter((c) => c.required).every((c) => c.ok), root, source, checks });
|
|
553
601
|
}
|
package/dist/app/cli/init.js
CHANGED
|
@@ -150,7 +150,7 @@ export async function initWorkspace(opts) {
|
|
|
150
150
|
if (!(await exists(join(root, '.git'))))
|
|
151
151
|
suggestions.push('git init(建议:老师记忆、账本、政策都是不可再生状态,git 是破坏后的兜底)');
|
|
152
152
|
suggestions.push('档案里填 school_start(读一年级的年月,如 2025-09),老师才知道现在是几年级哪个学期;想用自己的 Obsidian 库,改 cotutor.json 的 paths.vault');
|
|
153
|
-
suggestions.push('cotutor service install 装成后台服务(登录后自己起来、关了终端照样跑;在终端里调试用 cotutor serve)
|
|
153
|
+
suggestions.push('cotutor service install 装成后台服务(登录后自己起来、关了终端照样跑;在终端里调试用 cotutor serve),第一次装会打开家长端(以后 cotutor open),在「首页」填百炼的按量 API Key 与业务空间 ID(老师的嘴和脑都用它;没填孩子端只出「老师们还没到」)');
|
|
154
154
|
suggestions.push('在平板、手机上用:cotutor cert 签证书(要 mkcert),根证书装到设备上并打开信任,iPad / iPhone 的步骤见 README 里的《iPad 与 iPhone》');
|
|
155
155
|
suggestions.push('可选:点读、单词卡的配音用百炼(和老师的嘴同一把 key),到家长端首页点老师的「样子与声音」挑声音;cotutor pin 给家长端与工作台加锁');
|
|
156
156
|
suggestions.push('cotutor doctor 逐项体检');
|
package/dist/app/cli/main.js
CHANGED
|
@@ -21,11 +21,15 @@ import { addTheme, upgradeThemes } from "./themes.js";
|
|
|
21
21
|
import { patchConfig } from "../workspace.js";
|
|
22
22
|
import { resolveRoot } from "../workspace.js";
|
|
23
23
|
import { bindFailure, portHolder, whoHolds } from "./port.js";
|
|
24
|
-
import { httpsFiles, serveWorkspace, siteUrl } from "./serve.js";
|
|
24
|
+
import { certCovers, httpsFiles, localName, serveWorkspace, siteUrl } from "./serve.js";
|
|
25
|
+
import { dashscopeAuth } from "../../realtime/bailian.js";
|
|
25
26
|
import { teeLogs } from "./serve-log.js";
|
|
26
|
-
import { controlService, earlyLogHint, findService, installService, listServices, probeHealth, realHost, runKeeper, servicePlatform, serviceStatus, tailLog, uninstallService, waitHealthy } from "./service.js";
|
|
27
|
+
import { controlService, earlyLogHint, findService, installService, listServices, opensAfterInstall, probeHealth, realHost, runKeeper, servicePlatform, serviceStatus, tailLog, uninstallService, waitHealthy } from "./service.js";
|
|
27
28
|
import { openSandbox } from "./sandbox.js";
|
|
28
|
-
import { dropTrayIfNoServices, ensureTray, installTray, removeTray, trayDir, trayStatus, traySupported } from "./tray.js";
|
|
29
|
+
import { dropTrayIfNoServices, ensureTray, installTray, openTarget, refreshTray, removeTray, trayDir, trayStatus, traySupported } from "./tray.js";
|
|
30
|
+
import { BUILD } from "../build.js";
|
|
31
|
+
import { startUpdateChecks } from "../machine/update.js";
|
|
32
|
+
import { isNewer, upgradeSteps } from "../../shared/upgrade.js";
|
|
29
33
|
import { serveMock } from "../mock/mock.js";
|
|
30
34
|
import { UsageError, loadWorkspace, redactDeep, redactHome, workspaceReport } from "../workspace.js";
|
|
31
35
|
import { USAGE } from "./usage.js";
|
|
@@ -74,20 +78,15 @@ function parseArgs(argv, valued) {
|
|
|
74
78
|
return { cmd, positionals, flags };
|
|
75
79
|
}
|
|
76
80
|
/** 用默认浏览器打开(各系统各自的打开命令,失败不吭声) */
|
|
77
|
-
async function openInBrowser(page) {
|
|
78
|
-
const { spawn } = await import('node:child_process');
|
|
79
|
-
const [cmd, args] = process.platform === 'darwin' ? ['open', [page]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', page]] : ['xdg-open', [page]];
|
|
80
|
-
spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => { }).unref();
|
|
81
|
-
}
|
|
82
81
|
/** 打开哪一页的那一行(地址独占行尾:后面紧跟全角括号的话,终端不把它认成链接);--open(旧名 --open-qr)顺手打开 */
|
|
83
|
-
|
|
82
|
+
function printOpen(label, page, hint, open) {
|
|
84
83
|
process.stdout.write(` ${label} ${page}\n${' '.repeat(label.length * 2 + 3)}${hint}\n`);
|
|
85
84
|
if (open)
|
|
86
|
-
|
|
85
|
+
openTarget(process.platform, page);
|
|
87
86
|
}
|
|
88
87
|
const wantsOpen = (flags) => flags.open === true || flags['open-qr'] === true;
|
|
89
88
|
/** 不用 workspace 的命令:--sandbox 对它们没有意义 */
|
|
90
|
-
const NO_WORKSPACE = ['grades', 'init', 'mock', 'pin', 'cert', 'tray'];
|
|
89
|
+
const NO_WORKSPACE = ['grades', 'init', 'mock', 'pin', 'cert', 'tray', 'update'];
|
|
91
90
|
export async function main(argv) {
|
|
92
91
|
let json = false;
|
|
93
92
|
try {
|
|
@@ -197,6 +196,9 @@ export async function main(argv) {
|
|
|
197
196
|
r.ctx.runner.onEvent((e) => { if (keep(e.event))
|
|
198
197
|
process.stdout.write(`${e.tutor} ${e.job} ${formatEvent(e.event)}\n`); });
|
|
199
198
|
}
|
|
199
|
+
// 有没有新版本:从仓库跑的是开发,不问(npm 的提醒对它没意义);测试起的 serve 也都在仓库里
|
|
200
|
+
if (!BUILD.commit)
|
|
201
|
+
startUpdateChecks();
|
|
200
202
|
process.stdout.write(`cotutor serve ${JSON.stringify(workspaceReport(r.ws))}\n`);
|
|
201
203
|
if (r.ws.sandbox)
|
|
202
204
|
process.stdout.write(` 试用:从 ${redactHome(r.ws.sandbox.from)} 分出来,对话、照片、vault 的写入都留在这里,不进孩子的记录;--sandbox --fresh 重新分\n`);
|
|
@@ -205,7 +207,7 @@ export async function main(argv) {
|
|
|
205
207
|
if (!r.https)
|
|
206
208
|
process.stdout.write(' ! HTTP:平板、手机上按住说话要 HTTPS;cotutor cert 建证书后重启即走 HTTPS\n');
|
|
207
209
|
// 只给家长端一个入口:孩子的平板扫码、排首页、记录都在里面,工作台从它的右上角进
|
|
208
|
-
|
|
210
|
+
printOpen('家长端', r.parentPage, '在这台电脑上打开:孩子的平板扫码、排首页、看记录都在里面;--open 顺手打开', wantsOpen(flags));
|
|
209
211
|
return;
|
|
210
212
|
}
|
|
211
213
|
case 'open': {
|
|
@@ -225,7 +227,7 @@ export async function main(argv) {
|
|
|
225
227
|
return;
|
|
226
228
|
}
|
|
227
229
|
if (!json && flags.print !== true)
|
|
228
|
-
|
|
230
|
+
openTarget(process.platform, page);
|
|
229
231
|
return;
|
|
230
232
|
}
|
|
231
233
|
case 'tray': {
|
|
@@ -299,6 +301,12 @@ export async function main(argv) {
|
|
|
299
301
|
: `! 后台服务读不到 vault(${redactHome(ws.paths.vault)}):看看这个目录的权限,然后 cotutor service restart`);
|
|
300
302
|
if (h?.version && h.version !== version())
|
|
301
303
|
out.push(`! 在跑的是 ${h.version},装的是 ${version()}:cotutor service restart 换上新的`);
|
|
304
|
+
if (h?.bailianTrouble)
|
|
305
|
+
out.push(`! ${h.bailianTrouble.message}:${h.bailianTrouble.fix}`);
|
|
306
|
+
if (h?.scheme === 'http' && httpsFiles(ws))
|
|
307
|
+
out.push('! 证书有了,服务还在走 HTTP(平板上按住说话不可用):cotutor service restart');
|
|
308
|
+
if (isNewer(h?.latest, version()))
|
|
309
|
+
out.push(`! 有新版本 ${h?.latest}(现在是 ${version()}),升级运行:${upgradeSteps().join(' ; ')}`);
|
|
302
310
|
return out;
|
|
303
311
|
};
|
|
304
312
|
const tellLog = async (dir) => {
|
|
@@ -356,8 +364,9 @@ export async function main(argv) {
|
|
|
356
364
|
process.stdout.write(` ${platform === 'darwin' ? '菜单栏' : '任务栏'}多了 cotutor 图标:不用开终端就能看状态、打开家长端${tray === 'later' ? '(这个会话里没起来,下次登录出现)' : platform === 'win32' ? '(新图标先收在右下角的 ^ 里)' : ''};不要它:cotutor tray off\n`);
|
|
357
365
|
else if (tray === 'failed')
|
|
358
366
|
process.stdout.write(' ! 图标没装上,服务不受影响;cotutor tray on 再试\n');
|
|
367
|
+
const opening = opensAfterInstall({ firstTime: !had, open: wantsOpen(flags), noOpen: flags['no-open'] === true, platform, env: process.env });
|
|
359
368
|
if (h) {
|
|
360
|
-
|
|
369
|
+
printOpen('家长端', parentPage, `${opening ? '刚帮你打开了:' : ''}孩子的平板扫码、排首页、看记录都在里面`, opening);
|
|
361
370
|
for (const n of notes(h))
|
|
362
371
|
process.stdout.write(`${n}\n`);
|
|
363
372
|
}
|
|
@@ -393,6 +402,9 @@ export async function main(argv) {
|
|
|
393
402
|
return;
|
|
394
403
|
}
|
|
395
404
|
const h = await waitHealthy(port, 20_000, before?.started);
|
|
405
|
+
// 升级后跑的就是这一条:图标进程不归服务管,不换就还是旧代码,要等下次登录。它出问题不算重起失败
|
|
406
|
+
if (sub === 'restart')
|
|
407
|
+
await refreshTray(host).catch(() => false);
|
|
396
408
|
if (h) {
|
|
397
409
|
process.stdout.write(`${sub === 'start' ? '起来了' : '重起了'}:pid ${h.pid ?? '?'},版本 ${h.version ?? '?'}\n`);
|
|
398
410
|
for (const n of notes(h))
|
|
@@ -409,8 +421,25 @@ export async function main(argv) {
|
|
|
409
421
|
const st = await serviceStatus(host, root, port);
|
|
410
422
|
// 没答应时看看是不是别的程序占着:后台服务遇到这种情况只写一句日志就不起了,这里要说得出来
|
|
411
423
|
const holder = st.health ? null : await portHolder(host, port);
|
|
424
|
+
// 地址按实际走的协议算:签了证书还没重起时仍是 http
|
|
425
|
+
const served = st.health?.scheme ?? scheme;
|
|
426
|
+
const page = siteUrl(served, 'localhost', port, '/parent/home');
|
|
412
427
|
if (json) {
|
|
413
|
-
|
|
428
|
+
// 一次答清「装好没有」(INSTALL.md 靠它):服务、平板地址、证书、百炼、图标、版本
|
|
429
|
+
const name = localName();
|
|
430
|
+
const tls = httpsFiles(ws);
|
|
431
|
+
const tray = traySupported(host.platform) ? await trayStatus(host) : null;
|
|
432
|
+
process.stdout.write(`${JSON.stringify(redactDeep({
|
|
433
|
+
...st,
|
|
434
|
+
portHolder: holder,
|
|
435
|
+
version: version(),
|
|
436
|
+
parentPage: page,
|
|
437
|
+
tabletUrl: siteUrl(served, name, port),
|
|
438
|
+
cert: { present: Boolean(tls), coversName: tls ? certCovers(tls.cert, name) : false, serving: st.health?.scheme === 'https' },
|
|
439
|
+
bailian: Boolean(dashscopeAuth()),
|
|
440
|
+
tray: tray && { installed: tray.installed, running: tray.running },
|
|
441
|
+
latest: isNewer(st.health?.latest, version()) ? st.health?.latest : null,
|
|
442
|
+
}), null, 2)}\n`);
|
|
414
443
|
return;
|
|
415
444
|
}
|
|
416
445
|
if (!st.installed)
|
|
@@ -419,7 +448,7 @@ export async function main(argv) {
|
|
|
419
448
|
process.stdout.write(`后台服务:装了 ${st.installed.label}(${redactHome(st.installed.file)})\n ${st.running ? `在跑:pid ${st.pid ?? '?'}` : `没在跑${st.lastExit ? `(上次退出:${st.lastExit})` : ''};cotutor service start`}\n`);
|
|
420
449
|
const h = st.health;
|
|
421
450
|
if (h)
|
|
422
|
-
process.stdout.write(` 端口 ${port} 答应:${h.mode === 'service' ? '后台服务' : `终端里的 cotutor serve(pid ${h.pid ?? '?'})`},版本 ${h.version ?? '?'},起来于 ${when(h.started)}\n 家长端 ${
|
|
451
|
+
process.stdout.write(` 端口 ${port} 答应:${h.mode === 'service' ? '后台服务' : `终端里的 cotutor serve(pid ${h.pid ?? '?'})`},版本 ${h.version ?? '?'},起来于 ${when(h.started)}\n 家长端 ${page}\n`);
|
|
423
452
|
else
|
|
424
453
|
process.stdout.write(holder ? ` 端口 ${port} 没答应,被占着:${holder.name}(pid ${holder.pid})在用。腾出来以后 cotutor service start;或改 cotutor.json 的 server.port,平板要重新扫码、重加主屏幕图标\n` : ` 端口 ${port} 没答应\n`);
|
|
425
454
|
for (const n of notes(h))
|
|
@@ -491,7 +520,7 @@ export async function main(argv) {
|
|
|
491
520
|
for (const u of r.urls)
|
|
492
521
|
process.stdout.write(` ${u}\n`);
|
|
493
522
|
process.stdout.write(' 孩子端 /,家长端 /parent;直接开某位老师:/?tutor=chinese-tutor\n');
|
|
494
|
-
|
|
523
|
+
printOpen('扫码页', r.qrPage, '在这台电脑上打开,平板、手机用相机扫;--open 顺手打开', wantsOpen(flags));
|
|
495
524
|
return;
|
|
496
525
|
}
|
|
497
526
|
case 'add': {
|
|
@@ -525,6 +554,16 @@ export async function main(argv) {
|
|
|
525
554
|
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`);
|
|
526
555
|
return;
|
|
527
556
|
}
|
|
557
|
+
case 'update': {
|
|
558
|
+
const { realUpdateDeps, runUpdate } = await import("./update-cmd.js");
|
|
559
|
+
process.exitCode = await runUpdate(realUpdateDeps(() => { try {
|
|
560
|
+
return resolveRoot(workspace).root;
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return null;
|
|
564
|
+
} }), { anyway: flags.anyway === true });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
528
567
|
case 'upgrade': {
|
|
529
568
|
if (flags.grade !== undefined)
|
|
530
569
|
throw new UsageError('upgrade 只更新已安装的教学包,不能用 --grade 切换年级');
|
|
@@ -699,6 +738,8 @@ export async function main(argv) {
|
|
|
699
738
|
process.stdout.write(` iPad / iPhone:${send} → 设置里安装描述文件 → 通用 › 关于本机 › 证书信任设置里**把开关打开**(装了不等于信任)\n`);
|
|
700
739
|
process.stdout.write(` 安卓:${process.platform === 'darwin' ? '传到设备上' : send} → 设置里搜「CA 证书」,按提示安装(各家菜单不一样,会提示「网络可能受到监控」)\n`);
|
|
701
740
|
process.stdout.write(`然后 cotutor service restart(在终端里跑的就重开 cotutor serve),cotutor open 打开家长端,用相机扫「扫码打开」那张。地址认电脑名,换 Wi-Fi、IP 变了都不用重签;改了电脑名才要重跑这条。详见 docs/iPad与iPhone.md\n`);
|
|
741
|
+
if (!r.trusted)
|
|
742
|
+
process.stdout.write(`! 这台电脑还没信任根证书,浏览器打开家长端会报证书警告。请在这台电脑的终端里自己跑一次 mkcert -install(${process.platform === 'win32' ? '弹窗问时点「是」' : '要输开机密码'}),不用重签\n`);
|
|
702
743
|
}
|
|
703
744
|
return;
|
|
704
745
|
}
|
package/dist/app/cli/serve.d.ts
CHANGED
|
@@ -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;
|