cotutor 0.4.4 → 0.4.5
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/CHANGELOG.md +9 -1
- package/INSTALL.md +159 -0
- package/README.md +35 -19
- package/dist/app/cli/activities.d.ts +1 -1
- package/dist/app/cli/activities.js +2 -2
- package/dist/app/cli/cert.js +2 -1
- package/dist/app/cli/chalk/main.d.ts +0 -1
- package/dist/app/cli/chalk/main.js +8 -12
- package/dist/app/cli/commands.d.ts +50 -0
- package/dist/app/cli/commands.js +353 -0
- package/dist/app/cli/doctor.js +32 -17
- package/dist/app/cli/guide.d.ts +6 -0
- package/dist/app/cli/guide.js +17 -0
- package/dist/app/cli/init.d.ts +2 -0
- package/dist/app/cli/init.js +60 -6
- package/dist/app/cli/main.js +109 -46
- package/dist/app/cli/migrate.js +1 -1
- package/dist/app/cli/port.js +2 -10
- package/dist/app/cli/sandbox.js +2 -7
- package/dist/app/cli/service.d.ts +4 -1
- package/dist/app/cli/service.js +5 -2
- package/dist/app/cli/shipped.js +1 -1
- package/dist/app/cli/skeleton.d.ts +2 -0
- package/dist/app/cli/skeleton.js +3 -2
- package/dist/app/cli/skills.js +2 -2
- package/dist/app/cli/themes.js +2 -2
- package/dist/app/cli/tray.d.ts +6 -1
- package/dist/app/cli/tray.js +8 -5
- package/dist/app/cli/tutors.d.ts +2 -2
- package/dist/app/cli/tutors.js +4 -4
- package/dist/app/cli/update-cmd.js +4 -4
- package/dist/app/cli/usage.d.ts +26 -2
- package/dist/app/cli/usage.js +167 -38
- package/dist/app/http/app.js +1 -1
- package/dist/app/skills/analyze-doc.js +4 -8
- package/dist/app/skills/home-doc.js +3 -7
- package/dist/app/skills/tune-doc.js +4 -8
- package/dist/app/workspace.d.ts +16 -3
- package/dist/app/workspace.js +31 -9
- package/dist/runtime/home/home.js +1 -1
- package/dist/runtime/runner.js +2 -2
- package/dist/shared/home.d.ts +2 -0
- package/dist/shared/home.js +9 -0
- package/dist/ui/dev/dev-page.js +5 -5
- package/dist/ui/parent/parent-home-page.js +1 -1
- package/install.ps1 +5 -4
- package/install.sh +3 -3
- package/package.json +7 -5
- package/skills/cotutor-analyze/SKILL.md +1 -1
- package/skills/cotutor-analyze/references//345/221/275/344/273/244/344/270/216/346/226/207/344/273/266.md +168 -10
- package/skills/cotutor-home/SKILL.md +1 -1
- package/skills/cotutor-home/references//345/221/275/344/273/244.md +75 -7
- package/skills/cotutor-tune/SKILL.md +8 -8
- package/skills/cotutor-tune/references//345/255/227/346/256/265.md +164 -10
- package/skills/cotutor-tutor/SKILL.md +1 -1
package/dist/app/cli/init.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* cotutor init <slug>:建 ~/cotutor/<slug>/
|
|
2
|
+
* cotutor init <slug>:建 ~/cotutor/<slug>/ 骨架,vault 建在旁边的 ~/cotutor/<slug>-vault/(--vault 另指;2026-11-09 起两边分开)。语义是**幂等补缺**——已有的文件与目录一律不动,缺什么补什么。
|
|
3
3
|
* 三条边界(沿 drawtell init):政策文件只在缺失时写模板;用户配置只在还没指定 workspace 时补;不动 git。
|
|
4
4
|
* 老师文件**拷贝**进 agents/<name>.md:拷进来就是家长的,想改就改;出厂 hash 记 .cotutor/shipped.json,
|
|
5
|
-
* cotutor
|
|
5
|
+
* cotutor refresh 据此换新或报 diff。老布局(.claude/agents、.claude/skills)先原样挪到新位置(migrate.ts 的 migrateLayout)。
|
|
6
6
|
*/
|
|
7
7
|
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
8
|
-
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
9
9
|
import { DIRS, GITIGNORE, LEDGER_FILES, REFERENCE_README, configTemplate, profileTemplate, shippedAgents, writeSchemaFile } from "./skeleton.js";
|
|
10
10
|
import { installTutors, readManifest, writeManifest } from "./tutors.js";
|
|
11
11
|
import { DEFAULT_GRADE, LEGACY_GRADE, requireGradePack } from "./grades.js";
|
|
@@ -16,10 +16,39 @@ import { migrateLayout } from "./migrate.js";
|
|
|
16
16
|
import { choiceNote, choosePort, knownPorts } from "./port.js";
|
|
17
17
|
import { installThemes } from "./themes.js";
|
|
18
18
|
import { scanVault } from "../../memory/vault.js";
|
|
19
|
-
import { CONFIG_FILE, ConfigError, UsageError, HOME_ROOT, USER_CONFIG, expandPath, loadWorkspace, parseConfig, readJson, redactHome } from "../workspace.js";
|
|
19
|
+
import { CONFIG_FILE, ConfigError, UsageError, HOME_ROOT, USER_CONFIG, defaultVaultOf, expandPath, homePath, isInside, knownWorkspaces, loadWorkspace, parseConfig, readJson, redactHome, vaultSeparate } from "../workspace.js";
|
|
20
20
|
async function exists(p) {
|
|
21
21
|
return (await stat(p).catch(() => null)) !== null;
|
|
22
22
|
}
|
|
23
|
+
/** 新建时 vault 能不能放那:和 workspace 谁也不在谁里面,不是别的孩子的 workspace,不和别的孩子共用(一孩一 vault)。报错里 workspace 说「cotutor 文件夹」、vault 说「笔记文件夹」(docs/词表.md) */
|
|
24
|
+
async function checkVaultPlace(root, vault) {
|
|
25
|
+
if (isInside(vault, root))
|
|
26
|
+
throw new UsageError(`笔记文件夹(vault)不能放在孩子的 cotutor 文件夹里(${redactHome(vault)}):笔记是家长的,cotutor 文件夹是程序的运行记录,两边分开放。换个 --vault,缺省是旁边的 ${basename(defaultVaultOf(root))}`);
|
|
27
|
+
if (isInside(root, vault))
|
|
28
|
+
throw new UsageError(`孩子的 cotutor 文件夹不能放在笔记文件夹(vault)里面(${redactHome(root)} 在 ${redactHome(vault)} 里):两边分开放,换个 --dir 或 --vault`);
|
|
29
|
+
const st = await stat(vault).catch(() => null);
|
|
30
|
+
if (st && !st.isDirectory())
|
|
31
|
+
throw new UsageError(`笔记文件夹(vault)要是一个目录,${redactHome(vault)} 是文件;换个 --vault`);
|
|
32
|
+
if (await exists(join(vault, CONFIG_FILE)))
|
|
33
|
+
throw new UsageError(`${redactHome(vault)} 是一个孩子的 cotutor 文件夹(有 ${CONFIG_FILE}),不能当笔记文件夹;换个 --vault`);
|
|
34
|
+
for (const other of knownWorkspaces()) {
|
|
35
|
+
if (other === root)
|
|
36
|
+
continue;
|
|
37
|
+
if (isInside(vault, other) || isInside(other, vault))
|
|
38
|
+
throw new UsageError(`${redactHome(vault)} 和 ${redactHome(other)}(别的孩子的 cotutor 文件夹)叠在一起了,不能当笔记文件夹;换个 --vault`);
|
|
39
|
+
let theirs;
|
|
40
|
+
try {
|
|
41
|
+
theirs = loadWorkspace(other).paths.vault;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
continue; // 那边配置坏了,doctor 去报
|
|
45
|
+
}
|
|
46
|
+
if (isInside(root, theirs))
|
|
47
|
+
throw new UsageError(`${redactHome(root)} 在 ${redactHome(other)} 的笔记文件夹里,不能在这里建 cotutor 文件夹;换个短名或 --dir`);
|
|
48
|
+
if (isInside(vault, theirs) || isInside(theirs, vault))
|
|
49
|
+
throw new UsageError(`${redactHome(vault)} 和 ${redactHome(other)} 的笔记文件夹(${redactHome(theirs)})重了:一个孩子一个笔记文件夹,换个 --vault`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
23
52
|
export async function initWorkspace(opts) {
|
|
24
53
|
const root = opts.dir ? expandPath(opts.dir, resolve(process.cwd())) : join(HOME_ROOT, opts.slug);
|
|
25
54
|
const steps = [];
|
|
@@ -33,6 +62,18 @@ export async function initWorkspace(opts) {
|
|
|
33
62
|
if (installed !== undefined && installed !== grade)
|
|
34
63
|
throw new UsageError(`已安装 ${installed} 年级包,init 不切换年级`);
|
|
35
64
|
const agents = await shippedAgents(pack.agentsDir);
|
|
65
|
+
// vault 只在新建 cotutor.json 时定(写进 paths.vault),以后 init 不改向;已有的再给 --vault,对不上就报错,不悄悄换
|
|
66
|
+
const vaultArg = opts.vault ? resolve(expandPath(opts.vault, resolve(process.cwd()))) : undefined; // resolve 去掉末尾的 /,好比较
|
|
67
|
+
let newVault;
|
|
68
|
+
if (!existing) {
|
|
69
|
+
newVault = vaultArg ?? defaultVaultOf(root);
|
|
70
|
+
await checkVaultPlace(root, newVault);
|
|
71
|
+
}
|
|
72
|
+
else if (vaultArg) {
|
|
73
|
+
const now = loadWorkspace(root).paths.vault;
|
|
74
|
+
if (resolve(now) !== vaultArg)
|
|
75
|
+
throw new UsageError(`已有 ${CONFIG_FILE},笔记文件夹(vault)在 ${redactHome(now)};init 只补缺,不换笔记文件夹。要换先把里面的笔记挪过去,再改 ${CONFIG_FILE} 的 paths.vault`);
|
|
76
|
+
}
|
|
36
77
|
const rootExisted = await exists(root);
|
|
37
78
|
if (!rootExisted)
|
|
38
79
|
await mkdir(root, { recursive: true });
|
|
@@ -57,7 +98,7 @@ export async function initWorkspace(opts) {
|
|
|
57
98
|
else {
|
|
58
99
|
// 端口只在这里选一次,写进文件以后不变(port.ts);--port 给了就听它的
|
|
59
100
|
const chosen = opts.port === undefined ? await choosePort(knownPorts()) : null;
|
|
60
|
-
await writeFile(config, configTemplate({ slug: opts.slug, name: opts.name, port: opts.port ?? chosen?.port, tutors: agents }));
|
|
101
|
+
await writeFile(config, configTemplate({ slug: opts.slug, name: opts.name, port: opts.port ?? chosen?.port, vault: newVault && homePath(newVault), tutors: agents }));
|
|
61
102
|
steps.push({ item: CONFIG_FILE, action: 'created', ...(chosen ? { note: choiceNote(chosen) } : {}) });
|
|
62
103
|
}
|
|
63
104
|
// 老师按 cotutor.json 里有谁走(家长自己加的也补目录与链);配置坏了就只管出厂的,doctor 去报
|
|
@@ -93,8 +134,18 @@ export async function initWorkspace(opts) {
|
|
|
93
134
|
}
|
|
94
135
|
// vault 侧(《obsidian仓库设计.md》§2):只补档案与参考 README,缺了才写;档案按 cotutor: profile 找(2026-09-17),vault 里已有一篇就不补;
|
|
95
136
|
// 入口文件(每科每学期一篇)不自动建,doctor 提醒;日记由记账现建,计划家长自己写
|
|
137
|
+
let vaultAt = null;
|
|
96
138
|
try {
|
|
97
139
|
const ws = loadWorkspace(root);
|
|
140
|
+
vaultAt = ws.paths.vault;
|
|
141
|
+
if (!vaultSeparate(root, vaultAt))
|
|
142
|
+
steps.push({ item: 'vault', action: 'kept', note: '笔记文件夹(vault)在 cotutor 文件夹里(旧版 init 的布局),照旧能用;要分开看 cotutor doctor 的 vault.separate' });
|
|
143
|
+
else {
|
|
144
|
+
const there = await exists(vaultAt);
|
|
145
|
+
if (!there)
|
|
146
|
+
await mkdir(vaultAt, { recursive: true });
|
|
147
|
+
steps.push({ item: redactHome(vaultAt), action: there ? 'exists' : 'created', note: '笔记文件夹:档案、日记、老师的记忆,都是普通 markdown' });
|
|
148
|
+
}
|
|
98
149
|
const profile = ws.paths.profile;
|
|
99
150
|
const found = (await scanVault(ws)).notes.find((n) => n.props.cotutor === 'profile');
|
|
100
151
|
if (found)
|
|
@@ -152,7 +203,10 @@ export async function initWorkspace(opts) {
|
|
|
152
203
|
const suggestions = [];
|
|
153
204
|
if (!(await exists(join(root, '.git'))))
|
|
154
205
|
suggestions.push('git init(建议:老师记忆、账本、政策都是不可再生状态,git 是破坏后的兜底)');
|
|
155
|
-
|
|
206
|
+
if (opts.grade === undefined)
|
|
207
|
+
suggestions.push('档案里填 school_start(读一年级的年月,如 2025-09),老师才知道现在是几年级哪个学期');
|
|
208
|
+
if (vaultAt && vaultSeparate(root, vaultAt))
|
|
209
|
+
suggestions.push(`孩子的档案、日记、老师的记忆在笔记文件夹(${redactHome(vaultAt)}):普通 markdown,不用装 Obsidian;想用 Obsidian 看,就把这个目录当库打开`);
|
|
156
210
|
suggestions.push('cotutor service install 装成后台服务(登录后自己起来、关了终端照样跑;在终端里调试用 cotutor serve),第一次装会打开家长端(以后 cotutor open),在「首页」填百炼的按量 API Key 与业务空间 ID(老师的嘴和脑都用它;没填孩子端只出「老师们还没到」)');
|
|
157
211
|
suggestions.push('在平板、手机上用:cotutor cert 签证书(要 mkcert),根证书装到设备上并打开信任,iPad / iPhone 的步骤见 README 里的《iPad 与 iPhone》');
|
|
158
212
|
suggestions.push('可选:点读、单词卡的配音用百炼(和老师的嘴同一把 key),到家长端首页点老师的「样子与声音」挑声音;cotutor pin 给家长端与工作台加锁');
|
package/dist/app/cli/main.js
CHANGED
|
@@ -11,10 +11,11 @@ import { basename, join } from 'node:path';
|
|
|
11
11
|
import { doctorWorkspace } from "./doctor.js";
|
|
12
12
|
import { initWorkspace } from "./init.js";
|
|
13
13
|
import { gradePacks } from "./grades.js";
|
|
14
|
+
import { guideText } from "./guide.js";
|
|
14
15
|
import { makeCert } from "./cert.js";
|
|
15
16
|
import { PIN_RE, USER_PIN_FILE, clearPin, readPin, setPin } from "../http/access.js";
|
|
16
|
-
import { addTutorFile, upgradeTutors } from "./tutors.js";
|
|
17
|
-
import { activityRunFor, checkActivities, upgradeActivities } from "./activities.js";
|
|
17
|
+
import { addTutorFile, restoreTutor, upgradeTutors } from "./tutors.js";
|
|
18
|
+
import { activityRunFor, checkActivities, restoreActivity, upgradeActivities } from "./activities.js";
|
|
18
19
|
import { configGapsOf, migrateLayout, upgradeConfig } from "./migrate.js";
|
|
19
20
|
import { linkClaudeSkills, upgradeSkills } from "./skills.js";
|
|
20
21
|
import { addTheme, upgradeThemes } from "./themes.js";
|
|
@@ -32,7 +33,8 @@ import { startUpdateChecks } from "../machine/update.js";
|
|
|
32
33
|
import { isNewer, upgradeSteps } from "../../shared/upgrade.js";
|
|
33
34
|
import { serveMock } from "../mock/mock.js";
|
|
34
35
|
import { UsageError, loadWorkspace, redactDeep, redactHome, workspaceReport } from "../workspace.js";
|
|
35
|
-
import {
|
|
36
|
+
import { checkArgs, commandHelp, topHelp, unknownCommand } from "./usage.js";
|
|
37
|
+
import { VALUED_FLAGS, commandSpec } from "./commands.js";
|
|
36
38
|
import { PHOTO_MAX_BYTES, createContext } from "../http/app.js";
|
|
37
39
|
import { MESSAGE_FROM } from "../../schema/index.js";
|
|
38
40
|
import { formatEvent, laneFilter, parseEvents } from "../../runtime/journal/events.js";
|
|
@@ -47,6 +49,9 @@ function version() {
|
|
|
47
49
|
function interactive(json) {
|
|
48
50
|
return !json && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
49
51
|
}
|
|
52
|
+
/** 参数写错了(不是运行时的状况):报错时补一句去看这条命令的帮助 */
|
|
53
|
+
class ArgError extends UsageError {
|
|
54
|
+
}
|
|
50
55
|
function parseArgs(argv, valued) {
|
|
51
56
|
const flags = {};
|
|
52
57
|
const positionals = [];
|
|
@@ -57,13 +62,13 @@ function parseArgs(argv, valued) {
|
|
|
57
62
|
const key = a.slice(2);
|
|
58
63
|
if (valued.includes(key)) {
|
|
59
64
|
const next = argv[i + 1];
|
|
60
|
-
// --force 的值可省:upgrade --force
|
|
65
|
+
// --force 的值可省:upgrade --force <老师>(旧名),home publish --force
|
|
61
66
|
if ((next === undefined || next.startsWith('--')) && key === 'force') {
|
|
62
67
|
flags[key] = true;
|
|
63
68
|
continue;
|
|
64
69
|
}
|
|
65
70
|
if (next === undefined || next.startsWith('--'))
|
|
66
|
-
throw new
|
|
71
|
+
throw new ArgError(`选项 --${key} 需要一个值`);
|
|
67
72
|
flags[key] = next;
|
|
68
73
|
i++;
|
|
69
74
|
}
|
|
@@ -103,12 +108,12 @@ async function backupBeforeUpgrade(root) {
|
|
|
103
108
|
return `! 这次没备份成:${err instanceof Error ? err.message : String(err)}`;
|
|
104
109
|
}
|
|
105
110
|
}
|
|
106
|
-
/** 不用 workspace 的命令:--sandbox 对它们没有意义 */
|
|
107
|
-
const NO_WORKSPACE = ['grades', 'init', 'mock', 'pin', 'cert', 'tray', 'update'];
|
|
108
111
|
export async function main(argv) {
|
|
109
112
|
let json = false;
|
|
113
|
+
let helpFor;
|
|
110
114
|
try {
|
|
111
|
-
const { cmd, positionals, flags } = parseArgs(argv,
|
|
115
|
+
const { cmd, positionals, flags } = parseArgs(argv, VALUED_FLAGS);
|
|
116
|
+
helpFor = cmd;
|
|
112
117
|
json = flags.json === true;
|
|
113
118
|
let workspace = typeof flags.workspace === 'string' ? flags.workspace : undefined;
|
|
114
119
|
// --version / --help 是旗标不是命令,parseArgs 把它们收进 flags,cmd 拿不到,所以在 switch 前处理
|
|
@@ -116,18 +121,32 @@ export async function main(argv) {
|
|
|
116
121
|
process.stdout.write(`${version()}\n`);
|
|
117
122
|
return;
|
|
118
123
|
}
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
// 帮助:裸跑与 --help 是顶层(--all 连开发与调试的),cotutor <命令> --help 与 cotutor help <命令> 是那条命令的
|
|
125
|
+
if (cmd === 'help' && positionals[0] && commandSpec(positionals[0])) {
|
|
126
|
+
process.stdout.write(commandHelp(positionals[0]));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (flags.help === true || cmd === undefined || cmd === '-h' || cmd === 'help') {
|
|
130
|
+
process.stdout.write(cmd && commandSpec(cmd) ? commandHelp(cmd) : topHelp({ all: flags.all === true }));
|
|
121
131
|
return;
|
|
122
132
|
}
|
|
133
|
+
// 照命令表先查一遍:打错的旗标、用错子命令的旗标、多余的参数都报错,不悄悄忽略
|
|
134
|
+
const spec = commandSpec(cmd);
|
|
135
|
+
if (!spec) {
|
|
136
|
+
helpFor = undefined;
|
|
137
|
+
throw new ArgError(unknownCommand(cmd));
|
|
138
|
+
}
|
|
139
|
+
const wrong = checkArgs(spec, positionals, flags);
|
|
140
|
+
if (wrong)
|
|
141
|
+
throw new ArgError(wrong);
|
|
123
142
|
// --sandbox:要 workspace 的命令都换到它的试用副本(src/app/cli/sandbox.ts),下面照常把它当 --workspace
|
|
124
143
|
if (flags.fresh === true && flags.sandbox !== true)
|
|
125
144
|
throw new UsageError('--fresh 只跟 --sandbox 一起用:cotutor serve --sandbox --fresh 重新分出试用 workspace');
|
|
126
145
|
if (flags.sandbox === true) {
|
|
127
|
-
if (NO_WORKSPACE.includes(cmd))
|
|
128
|
-
throw new UsageError(`${cmd} 不用 workspace,--sandbox 没有意义`);
|
|
129
146
|
if (cmd === 'service')
|
|
130
147
|
throw new UsageError('试用 workspace 不装后台服务:试的时候 cotutor serve --sandbox 在前台跑');
|
|
148
|
+
if (!spec.sandbox)
|
|
149
|
+
throw new UsageError(`${cmd} 不用 workspace,--sandbox 没有意义`);
|
|
131
150
|
const sb = await openSandbox(workspace, { fresh: flags.fresh === true });
|
|
132
151
|
workspace = sb.root;
|
|
133
152
|
if (!json) {
|
|
@@ -137,6 +156,10 @@ export async function main(argv) {
|
|
|
137
156
|
}
|
|
138
157
|
}
|
|
139
158
|
switch (cmd) {
|
|
159
|
+
case 'guide': {
|
|
160
|
+
process.stdout.write(guideText(version()));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
140
163
|
case 'grades': {
|
|
141
164
|
const packs = (await gradePacks()).map(({ grade }) => ({ grade }));
|
|
142
165
|
process.stdout.write(json ? `${JSON.stringify(packs, null, 2)}\n` : `可安装的教学包:${packs.map((p) => `${p.grade} 年级`).join('、') || '无'}\n`);
|
|
@@ -145,13 +168,13 @@ export async function main(argv) {
|
|
|
145
168
|
case 'init': {
|
|
146
169
|
const slug = positionals[0];
|
|
147
170
|
if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug))
|
|
148
|
-
throw new
|
|
171
|
+
throw new ArgError(`init 需要一个 slug(小写字母数字连字符,是短名不是真名),如 cotutor init ming`);
|
|
149
172
|
const port = typeof flags.port === 'string' ? Number(flags.port) : undefined;
|
|
150
173
|
if (port !== undefined && !(Number.isInteger(port) && port > 0 && port < 65536))
|
|
151
174
|
throw new UsageError('--port 要是 1–65535 的整数');
|
|
152
175
|
if (flags.grade !== undefined && (typeof flags.grade !== 'string' || !/^[1-6]$/.test(flags.grade)))
|
|
153
176
|
throw new UsageError('--grade 要是 1–6 的整数,如 --grade 2');
|
|
154
|
-
const r = await initWorkspace({ slug, grade: flags.grade === undefined ? undefined : Number(flags.grade), dir: typeof flags.dir === 'string' ? flags.dir : undefined, name: typeof flags.name === 'string' ? flags.name : undefined, port });
|
|
177
|
+
const r = await initWorkspace({ slug, grade: flags.grade === undefined ? undefined : Number(flags.grade), dir: typeof flags.dir === 'string' ? flags.dir : undefined, vault: typeof flags.vault === 'string' ? flags.vault : undefined, name: typeof flags.name === 'string' ? flags.name : undefined, port });
|
|
155
178
|
if (json)
|
|
156
179
|
process.stdout.write(`${JSON.stringify(redactDeep(r), null, 2)}\n`);
|
|
157
180
|
else {
|
|
@@ -179,9 +202,13 @@ export async function main(argv) {
|
|
|
179
202
|
const [sub, name] = positionals;
|
|
180
203
|
if (!sub) {
|
|
181
204
|
const b = await backup.makeBackup(target, { why: 'manual', version: BUILD.version });
|
|
205
|
+
if (json) {
|
|
206
|
+
process.stdout.write(`${JSON.stringify(redactDeep({ ok: true, backup: b, dir: backup.backupsOf(ws.root), vault: b.vault ?? null }), null, 2)}\n`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
182
209
|
process.stdout.write(`备份好了:${backup.describeBackup(b)}\n 在 ${redactHome(backup.backupsOf(ws.root))}\n 不含录音(wav)和能重新生成的文件${b.audioSkipped ? `,这次跳过 ${b.audioSkipped} 个录音` : ''}\n`);
|
|
183
210
|
if (b.vault === 'unreadable')
|
|
184
|
-
process.stdout.write(`! vault
|
|
211
|
+
process.stdout.write(`! 笔记文件夹(vault)读不了(${redactHome(ws.paths.vault)}),这份里没有它\n`);
|
|
185
212
|
return;
|
|
186
213
|
}
|
|
187
214
|
if (sub === 'list') {
|
|
@@ -197,11 +224,17 @@ export async function main(argv) {
|
|
|
197
224
|
return;
|
|
198
225
|
}
|
|
199
226
|
if (sub !== 'restore' || !name)
|
|
200
|
-
throw new
|
|
227
|
+
throw new ArgError('backup restore 要备份的名字,如 cotutor backup restore <名字> --yes(cotutor backup list 看有哪些)');
|
|
201
228
|
if (!(await backup.listBackups(ws.root)).some((b) => b.name === name))
|
|
202
229
|
throw new UsageError(`没有这份备份:${name}(cotutor backup list 看有哪些)`);
|
|
203
230
|
if (flags.yes !== true) {
|
|
204
|
-
|
|
231
|
+
// 不加 --yes 只说会做什么,以 1 退:agent 拿到 needsYes 也不能自己补,要家长明确说要恢复
|
|
232
|
+
if (json) {
|
|
233
|
+
process.stdout.write(`${JSON.stringify({ ok: false, needsYes: true, backup: name, would: '备份里有的文件覆盖回去,之后新多出来的不删;先把现在的也备一份;服务停一下;录音不受影响' }, null, 2)}\n`);
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
process.stdout.write(`会把孩子的 cotutor 文件夹恢复成 ${name} 那时的样子:备份里有的文件都覆盖回去,之后新多出来的不删。\n恢复前会先把现在的样子也备份一份,恢复错了还能回来。服务会停一下。录音不在备份里,不受影响。\n确定的话加 --yes\n`);
|
|
205
238
|
process.exitCode = 1;
|
|
206
239
|
return;
|
|
207
240
|
}
|
|
@@ -216,13 +249,18 @@ export async function main(argv) {
|
|
|
216
249
|
if (service && health)
|
|
217
250
|
await controlService(host, ws.root, 'stop');
|
|
218
251
|
const r = await backup.restoreBackup(target, name, { version: BUILD.version });
|
|
252
|
+
const restart = Boolean(service && health);
|
|
253
|
+
if (restart)
|
|
254
|
+
await controlService(host, ws.root, 'start');
|
|
255
|
+
if (json) {
|
|
256
|
+
process.stdout.write(`${JSON.stringify(redactDeep({ ok: true, backup: name, files: r.files, safety: r.safety, vaultCopy: r.vaultCopy, restarted: restart }), null, 2)}\n`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
219
259
|
process.stdout.write(`恢复好了:${r.files} 个文件。恢复前的样子留在 ${r.safety}\n`);
|
|
220
260
|
if (r.vaultCopy)
|
|
221
|
-
process.stdout.write(
|
|
222
|
-
if (
|
|
223
|
-
await controlService(host, ws.root, 'start');
|
|
261
|
+
process.stdout.write(`笔记文件夹(vault)没动;要拿回来,那一份的 md 在 ${redactHome(r.vaultCopy)}\n`);
|
|
262
|
+
if (restart)
|
|
224
263
|
process.stdout.write('服务重起了\n');
|
|
225
|
-
}
|
|
226
264
|
return;
|
|
227
265
|
}
|
|
228
266
|
case 'doctor': {
|
|
@@ -331,7 +369,7 @@ export async function main(argv) {
|
|
|
331
369
|
// 菜单栏 / 任务栏图标(tray.ts);tray run 由 bin/cotutor.js 直接走轻入口,不到这里
|
|
332
370
|
const sub = positionals[0];
|
|
333
371
|
if (sub !== 'on' && sub !== 'off' && sub !== 'status')
|
|
334
|
-
throw new
|
|
372
|
+
throw new ArgError(`tray 后面跟 on / off / status 之一`);
|
|
335
373
|
const host = realHost();
|
|
336
374
|
if (!traySupported(host.platform))
|
|
337
375
|
throw new UsageError(`${host.platform} 上没做托盘图标(GNOME 缺省不显示托盘):cotutor service status 看服务状态`);
|
|
@@ -378,7 +416,7 @@ export async function main(argv) {
|
|
|
378
416
|
}
|
|
379
417
|
const SUBS = ['install', 'uninstall', 'start', 'stop', 'restart', 'status', 'logs'];
|
|
380
418
|
if (!sub || !SUBS.includes(sub))
|
|
381
|
-
throw new
|
|
419
|
+
throw new ArgError(`service 后面跟 ${SUBS.join(' / ')} 之一`);
|
|
382
420
|
const host = realHost();
|
|
383
421
|
const platform = servicePlatform(host.platform);
|
|
384
422
|
const ws = loadWorkspace(workspace);
|
|
@@ -624,7 +662,7 @@ export async function main(argv) {
|
|
|
624
662
|
const name = positionals[0];
|
|
625
663
|
const display = typeof flags.display === 'string' ? flags.display : undefined;
|
|
626
664
|
if (!name || !display)
|
|
627
|
-
throw new
|
|
665
|
+
throw new ArgError(`add 需要老师名和 --display 显示名,如 cotutor add science-tutor --display 科学老师 --subject 科学 --avatar 🔬`);
|
|
628
666
|
const ws = loadWorkspace(workspace);
|
|
629
667
|
if (ws.config.tutors[name])
|
|
630
668
|
throw new UsageError(`cotutor.json 里已经有 ${name} 了;要改人设去老师团页或直接改文件`);
|
|
@@ -642,7 +680,7 @@ export async function main(argv) {
|
|
|
642
680
|
case 'add-theme': {
|
|
643
681
|
const name = positionals[0];
|
|
644
682
|
if (!name)
|
|
645
|
-
throw new
|
|
683
|
+
throw new ArgError(`add-theme 需要主题名,如 cotutor add-theme dark`);
|
|
646
684
|
const { root } = resolveRoot(workspace);
|
|
647
685
|
const r = await addTheme(root, name, typeof flags.from === 'string' ? flags.from : 'default');
|
|
648
686
|
if (json)
|
|
@@ -661,9 +699,32 @@ export async function main(argv) {
|
|
|
661
699
|
} }), { anyway: flags.anyway === true });
|
|
662
700
|
return;
|
|
663
701
|
}
|
|
664
|
-
case '
|
|
702
|
+
case 'reset': {
|
|
703
|
+
// 只把点名的出厂老师 / activity 换回出厂那份(工作台「恢复出厂」同一条路),别的不碰;现在那份存 .bak
|
|
704
|
+
if (!positionals.length)
|
|
705
|
+
throw new ArgError('reset 要说换回哪几份,如 cotutor reset math-tutor 或 cotutor reset chinese-tutor/你说我画');
|
|
706
|
+
const { root } = resolveRoot(workspace);
|
|
707
|
+
const said = await backupBeforeUpgrade(root);
|
|
708
|
+
if (said && !json)
|
|
709
|
+
process.stdout.write(`${said}\n`);
|
|
710
|
+
const done = [];
|
|
711
|
+
for (const name of positionals) {
|
|
712
|
+
const [tutor, activity] = name.split('/');
|
|
713
|
+
const r = activity ? await restoreActivity(root, tutor, activity) : await restoreTutor(root, tutor);
|
|
714
|
+
done.push({ name, bak: r.bak });
|
|
715
|
+
}
|
|
716
|
+
if (json)
|
|
717
|
+
process.stdout.write(`${JSON.stringify(redactDeep({ ok: true, reset: done }), null, 2)}\n`);
|
|
718
|
+
else
|
|
719
|
+
for (const d of done)
|
|
720
|
+
process.stdout.write(`✓ ${d.name} 换回出厂的${d.bak ? `;你那份存在 ${redactHome(d.bak)}` : '(本来就和出厂的一样)'}\n`);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
// upgrade 是 refresh 的旧名:0.4.x 的 cotutor update 换完程序会用新程序跑 upgrade --workspace <孩子>,一直要认;--force 的活现在归 reset
|
|
724
|
+
case 'upgrade':
|
|
725
|
+
case 'refresh': {
|
|
665
726
|
if (flags.grade !== undefined)
|
|
666
|
-
throw new UsageError(
|
|
727
|
+
throw new UsageError(`${cmd} 只更新已安装的教学包,不能用 --grade 切换年级`);
|
|
667
728
|
const { root } = resolveRoot(workspace);
|
|
668
729
|
// 升级前留一份:新代码弄坏了能回来。备份不成不拦升级,说一声。--dry-run 不动文件,不用
|
|
669
730
|
if (flags['dry-run'] !== true) {
|
|
@@ -685,7 +746,7 @@ export async function main(argv) {
|
|
|
685
746
|
for (const st of r.installed)
|
|
686
747
|
process.stdout.write(`✓ ${st.item.padEnd(24)} ${st.note ?? '建好了'}\n`);
|
|
687
748
|
process.stdout.write(dryRun
|
|
688
|
-
? `\n${r.gaps.length} 项要改,还没动文件:cotutor
|
|
749
|
+
? `\n${r.gaps.length} 项要改,还没动文件:cotutor refresh --config 真改(只加缺的、删不再用的出厂段,你改过的值不动)\n`
|
|
689
750
|
: `\n改了 ${r.gaps.length} 项到 ${redactHome(r.file)};服务在跑的话不用重启(改了就热重载)。\n`);
|
|
690
751
|
}
|
|
691
752
|
return;
|
|
@@ -715,7 +776,7 @@ export async function main(argv) {
|
|
|
715
776
|
for (const s of steps) {
|
|
716
777
|
process.stdout.write(`${s.action === 'kept-custom' ? '!' : '✓'} ${s.name.padEnd(18)} ${word[s.action]}${s.basedOn && s.action !== 'latest' ? `(基于 ${s.basedOn})` : ''}\n`);
|
|
717
778
|
if (s.diff?.length) {
|
|
718
|
-
process.stdout.write(` 你的 vs 新版(- 你的 / + 新版),想用新版:cotutor
|
|
779
|
+
process.stdout.write(` 你的 vs 新版(- 你的 / + 新版),想用新版:cotutor reset ${s.name}\n`);
|
|
719
780
|
for (const l of s.diff.slice(0, 40))
|
|
720
781
|
process.stdout.write(` ${l}\n`);
|
|
721
782
|
if (s.diff.length > 40)
|
|
@@ -725,7 +786,7 @@ export async function main(argv) {
|
|
|
725
786
|
for (const s of activitySteps) {
|
|
726
787
|
process.stdout.write(`${s.action === 'kept-custom' ? '!' : '✓'} activity ${s.key.padEnd(18)} ${word[s.action]}${s.from ? `(原来叫「${s.from}」)` : ''}${s.basedOn && s.action !== 'latest' ? `(基于 ${s.basedOn})` : ''}\n`);
|
|
727
788
|
if (s.diff?.length) {
|
|
728
|
-
process.stdout.write(` 你的 vs 新版(- 你的 / + 新版),想用新版:cotutor
|
|
789
|
+
process.stdout.write(` 你的 vs 新版(- 你的 / + 新版),想用新版:cotutor reset ${s.key}\n`);
|
|
729
790
|
for (const l of s.diff.slice(0, 40))
|
|
730
791
|
process.stdout.write(` ${l}\n`);
|
|
731
792
|
if (s.diff.length > 40)
|
|
@@ -733,19 +794,19 @@ export async function main(argv) {
|
|
|
733
794
|
}
|
|
734
795
|
}
|
|
735
796
|
if (gaps.length)
|
|
736
|
-
process.stdout.write(`! 配置有 ${gaps.length} 项要改(新出厂老师 / 运行时 / 命令模板旗标 / 不再用的段):cotutor
|
|
797
|
+
process.stdout.write(`! 配置有 ${gaps.length} 项要改(新出厂老师 / 运行时 / 命令模板旗标 / 不再用的段):cotutor refresh --config --dry-run 先看\n`);
|
|
737
798
|
}
|
|
738
799
|
return;
|
|
739
800
|
}
|
|
740
801
|
case 'activity': {
|
|
741
802
|
const sub = positionals[0];
|
|
742
803
|
if (sub !== 'check' && sub !== 'eval')
|
|
743
|
-
throw new
|
|
804
|
+
throw new ArgError(`activity 后面跟 check / eval,如 cotutor activity check chinese-tutor 你说我画`);
|
|
744
805
|
const ws = loadWorkspace(workspace);
|
|
745
806
|
const [, tutor, name] = positionals;
|
|
746
807
|
if (sub === 'eval') {
|
|
747
808
|
if (!tutor || !name)
|
|
748
|
-
throw new
|
|
809
|
+
throw new ArgError(`activity eval 要老师和名字,如 cotutor activity eval chinese-tutor 你说我画`);
|
|
749
810
|
const runs = typeof flags.runs === 'string' ? Number(flags.runs) : 3;
|
|
750
811
|
if (!Number.isInteger(runs) || runs < 1 || runs > 10)
|
|
751
812
|
throw new UsageError('--runs 要是 1–10 的整数');
|
|
@@ -777,7 +838,7 @@ export async function main(argv) {
|
|
|
777
838
|
}
|
|
778
839
|
const results = await checkActivities(ws.root, ws.config.tutors, { tutor, name });
|
|
779
840
|
if (!results.length)
|
|
780
|
-
throw new UsageError(tutor ? `agents/${tutor}/activities/ 里没有${name ? ` ${name}.md` : ' activity 文件'}` : '这个 workspace 里还没有 activity 文件(cotutor
|
|
841
|
+
throw new UsageError(tutor ? `agents/${tutor}/activities/ 里没有${name ? ` ${name}.md` : ' activity 文件'}` : '这个 workspace 里还没有 activity 文件(cotutor refresh 补出厂的)');
|
|
781
842
|
const fixes = results.reduce((n, r) => n + r.issues.filter((i) => i.level === 'fix').length, 0);
|
|
782
843
|
if (json)
|
|
783
844
|
process.stdout.write(`${JSON.stringify(redactDeep({ ok: fixes === 0, activities: results.map((r) => ({ tutor: r.tutor, name: r.name, file: r.file, issues: r.issues, params: r.activity?.params ?? [], state: r.activity?.state ?? [], gates: r.activity?.gates ?? [], tools: r.activity?.tools.map((t) => t.tool) ?? [] })) }), null, 2)}\n`);
|
|
@@ -849,7 +910,7 @@ export async function main(argv) {
|
|
|
849
910
|
case 'pack': {
|
|
850
911
|
const [tutor, ...rest] = positionals;
|
|
851
912
|
if (!tutor)
|
|
852
|
-
throw new
|
|
913
|
+
throw new ArgError(`pack 需要老师名,如 cotutor pack math-tutor "讲讲退位"`);
|
|
853
914
|
const ws = loadWorkspace(workspace);
|
|
854
915
|
const { packDryRun } = await import("../../runtime/runner.js");
|
|
855
916
|
const from = typeof flags.from === 'string' ? flags.from : 'kid';
|
|
@@ -890,7 +951,7 @@ export async function main(argv) {
|
|
|
890
951
|
case 'show': {
|
|
891
952
|
const [tutor, jobArg, dateArg] = positionals;
|
|
892
953
|
if (!tutor || !jobArg)
|
|
893
|
-
throw new
|
|
954
|
+
throw new ArgError(`show 需要老师名和 job,如 cotutor show math-tutor 1620-1 2026-09-12(日期缺省今天)`);
|
|
894
955
|
const ws = loadWorkspace(workspace);
|
|
895
956
|
const { rawView } = await import("../http/raw-view.js");
|
|
896
957
|
const { localDate } = await import("../../shared/time.js");
|
|
@@ -929,7 +990,7 @@ export async function main(argv) {
|
|
|
929
990
|
case 'replay': {
|
|
930
991
|
const [tutor, jobArg, dateArg] = positionals;
|
|
931
992
|
if (!tutor || !jobArg)
|
|
932
|
-
throw new
|
|
993
|
+
throw new ArgError(`replay 需要老师名和 job,如 cotutor replay math-tutor 1620-1 2026-09-12(日期缺省今天)`);
|
|
933
994
|
const ws = loadWorkspace(workspace);
|
|
934
995
|
const { compareReplay, formatCompare, startReplay } = await import("../replay.js");
|
|
935
996
|
const { localDate } = await import("../../shared/time.js");
|
|
@@ -950,7 +1011,7 @@ export async function main(argv) {
|
|
|
950
1011
|
case 'compare': {
|
|
951
1012
|
const [tutor, evalJobArg, dateArg] = positionals;
|
|
952
1013
|
if (!tutor)
|
|
953
|
-
throw new
|
|
1014
|
+
throw new ArgError(`compare 需要老师名,如 cotutor compare math-tutor 1712-1 2026-09-12`);
|
|
954
1015
|
const ws = loadWorkspace(workspace);
|
|
955
1016
|
const { compareReplay, formatCompare, listReplays } = await import("../replay.js");
|
|
956
1017
|
const { localDate } = await import("../../shared/time.js");
|
|
@@ -978,7 +1039,7 @@ export async function main(argv) {
|
|
|
978
1039
|
case 'trace': {
|
|
979
1040
|
const [tutor, jobArg, dateArg] = positionals;
|
|
980
1041
|
if (!tutor || !jobArg)
|
|
981
|
-
throw new
|
|
1042
|
+
throw new ArgError(`trace 需要老师名和 job,如 cotutor trace math-tutor 1620-1 2026-09-12(日期缺省今天)`);
|
|
982
1043
|
const ws = loadWorkspace(workspace);
|
|
983
1044
|
const { localDate } = await import("../../shared/time.js");
|
|
984
1045
|
const { conversationFiles } = await import("../../schema/index.js");
|
|
@@ -1003,7 +1064,7 @@ export async function main(argv) {
|
|
|
1003
1064
|
const [tutor, thread, score] = positionals;
|
|
1004
1065
|
const n = Number(score);
|
|
1005
1066
|
if (!tutor || !thread || !(n >= 1 && n <= 5 && Number.isInteger(n)))
|
|
1006
|
-
throw new
|
|
1067
|
+
throw new ArgError(`rate 需要老师名、话题 id 和 1–5 的星,如 cotutor rate math-tutor 1620-1 4`);
|
|
1007
1068
|
const ws = loadWorkspace(workspace);
|
|
1008
1069
|
const date = typeof flags.date === 'string' ? flags.date : localDate(new Date());
|
|
1009
1070
|
const index = await rateThread(ws, tutor, date, thread, n);
|
|
@@ -1016,7 +1077,7 @@ export async function main(argv) {
|
|
|
1016
1077
|
case 'bookkeep': {
|
|
1017
1078
|
const [tutor] = positionals;
|
|
1018
1079
|
if (!tutor)
|
|
1019
|
-
throw new
|
|
1080
|
+
throw new ArgError(`bookkeep 需要老师名,如 cotutor bookkeep math-tutor`);
|
|
1020
1081
|
const ctx = createContext(loadWorkspace(workspace));
|
|
1021
1082
|
const date = typeof flags.date === 'string' ? flags.date : localDate(new Date());
|
|
1022
1083
|
const only = typeof flags.thread === 'string' ? [flags.thread] : undefined;
|
|
@@ -1047,7 +1108,7 @@ export async function main(argv) {
|
|
|
1047
1108
|
case 'home': {
|
|
1048
1109
|
const sub = positionals[0];
|
|
1049
1110
|
if (sub !== 'check' && sub !== 'publish' && sub !== 'show')
|
|
1050
|
-
throw new
|
|
1111
|
+
throw new ArgError(`home 后面跟 check / publish / show,如 cotutor home check`);
|
|
1051
1112
|
const ws = loadWorkspace(workspace);
|
|
1052
1113
|
const now = new Date();
|
|
1053
1114
|
const home = await import("../../runtime/home/store.js");
|
|
@@ -1109,7 +1170,7 @@ export async function main(argv) {
|
|
|
1109
1170
|
const activityName = typeof flags.activity === 'string' ? flags.activity : undefined;
|
|
1110
1171
|
const text = words.join(' ') || activityName || '';
|
|
1111
1172
|
if (!tutor || (!text && !photoFiles.length))
|
|
1112
|
-
throw new
|
|
1173
|
+
throw new ArgError(`send 需要老师名和消息,如 cotutor send math-tutor "这一步为什么要借位"`);
|
|
1113
1174
|
const from = typeof flags.from === 'string' ? flags.from : 'kid';
|
|
1114
1175
|
if (!MESSAGE_FROM.includes(from))
|
|
1115
1176
|
throw new UsageError(`--from 只能是 ${MESSAGE_FROM.join(' / ')}`);
|
|
@@ -1165,15 +1226,17 @@ export async function main(argv) {
|
|
|
1165
1226
|
return;
|
|
1166
1227
|
}
|
|
1167
1228
|
default:
|
|
1168
|
-
throw new
|
|
1229
|
+
throw new ArgError(unknownCommand(cmd));
|
|
1169
1230
|
}
|
|
1170
1231
|
}
|
|
1171
1232
|
catch (err) {
|
|
1172
1233
|
if (err instanceof UsageError) {
|
|
1234
|
+
// 参数写错了:一句话 + 去哪看用法,不再把整篇帮助倒出来
|
|
1235
|
+
const help = err instanceof ArgError ? (helpFor && commandSpec(helpFor) ? `cotutor ${helpFor} --help` : 'cotutor --help') : undefined;
|
|
1173
1236
|
if (json)
|
|
1174
|
-
process.stdout.write(`${JSON.stringify({ ok: false, error: 'usage', message: err.message })}\n`);
|
|
1237
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: 'usage', message: err.message, ...(help ? { help } : {}) })}\n`);
|
|
1175
1238
|
else
|
|
1176
|
-
process.stderr.write(`cotutor: ${err.message}\n`);
|
|
1239
|
+
process.stderr.write(`cotutor: ${err.message}\n${help ? ` 用法:${help}\n` : ''}`);
|
|
1177
1240
|
process.exitCode = 2;
|
|
1178
1241
|
return;
|
|
1179
1242
|
}
|
package/dist/app/cli/migrate.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* cotutor.json 是**政策文件**:init 只在缺失时写模板,已有的那份机器永不改(家长的决定不由机器代做)。
|
|
5
5
|
* 代价是包更新带来的新出厂件不会自己出现在老 workspace 里,而且「没有」的表现是**静默**的——
|
|
6
6
|
* 所以有这一层:把「出厂模板有、你这份没有」算出来(doctor 的 `config.migrate` 点名、家长端设置页顶部提示),
|
|
7
|
-
* `cotutor
|
|
7
|
+
* `cotutor refresh --config` 只**补缺**——加缺的键;家长写过的值一个都不动(改小的预算、换掉的 default 运行时全都原样留着)。
|
|
8
8
|
* 唯一删的是 RETIRED 里点了名的出厂段(机器写进去、应用已经不读的,如 2026-10-31 的 tts);家长自己加的键不在表里,不碰。
|
|
9
9
|
*
|
|
10
10
|
* 差异是**现算的**:拿家长自己的 slug / name / port 生成一份出厂模板,和她这份逐项比,
|
package/dist/app/cli/port.js
CHANGED
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
* 旧 workspace 写着什么就是什么;cotutor.json 没写端口的走 DEFAULT_PORT,不跟着这里变。
|
|
8
8
|
*/
|
|
9
9
|
import { createConnection, createServer } from 'node:net';
|
|
10
|
-
import { homedir } from 'node:os';
|
|
11
10
|
import { join } from 'node:path';
|
|
12
11
|
import { DEFAULT_PORT } from "../../schema/index.js";
|
|
13
12
|
import { holderScript, parseHolder, parseLsof, portHolder } from "./port-holder.js";
|
|
14
13
|
import { probeHealth } from "./service.js";
|
|
15
|
-
import { CONFIG_FILE,
|
|
14
|
+
import { CONFIG_FILE, UsageError, knownWorkspaces, readJson } from "../workspace.js";
|
|
16
15
|
export const PORT_CANDIDATES = [443, ...Array.from({ length: 9 }, (_, i) => 8443 + i * 2)];
|
|
17
16
|
/** sandbox 用哪个端口 */
|
|
18
17
|
export const sandboxPort = (port) => (port === 65535 ? port - 1 : port + 1);
|
|
@@ -70,15 +69,8 @@ export function choiceNote(c) {
|
|
|
70
69
|
}
|
|
71
70
|
/** 这台电脑上别的 workspace 写着的端口(~/cotutor/ 下的,加上用户配置指的那个):还没起的也要让开 */
|
|
72
71
|
export function knownPorts() {
|
|
73
|
-
const roots = new Set(homeWorkspaces());
|
|
74
|
-
try {
|
|
75
|
-
const user = readJson(USER_CONFIG);
|
|
76
|
-
if (typeof user?.workspace === 'string' && user.workspace)
|
|
77
|
-
roots.add(expandPath(user.workspace, homedir()));
|
|
78
|
-
}
|
|
79
|
-
catch { /* 用户配置坏了 doctor 去报 */ }
|
|
80
72
|
const ports = [];
|
|
81
|
-
for (const root of
|
|
73
|
+
for (const root of knownWorkspaces()) {
|
|
82
74
|
try {
|
|
83
75
|
const raw = readJson(join(root, CONFIG_FILE));
|
|
84
76
|
if (!raw)
|
package/dist/app/cli/sandbox.js
CHANGED
|
@@ -5,21 +5,16 @@
|
|
|
5
5
|
* 已经在 sandbox 里(cwd 在里面、--workspace 指过来)就用它;--fresh 从它记着的真 workspace 重新分,不套娃。
|
|
6
6
|
*/
|
|
7
7
|
import { copyFile, cp, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
8
|
-
import { basename, dirname,
|
|
8
|
+
import { basename, dirname, join, relative } from 'node:path';
|
|
9
9
|
import { PATH_ROLES, ROLE_DEFAULTS, WORKSPACE_ROLES } from "../../schema/index.js";
|
|
10
10
|
import { SANDBOX_DIR, SANDBOX_KEEP_MACHINE, SANDBOX_MARK, SANDBOX_SKIP, SANDBOX_TITLE, SANDBOX_VAULT } from "../../schema/workspace.js";
|
|
11
|
-
import { CONFIG_FILE, UsageError, expandPath, loadWorkspace, parseConfig, readJson, readSandboxMark, redactHome, resolveRoot } from "../workspace.js";
|
|
11
|
+
import { CONFIG_FILE, UsageError, expandPath, isInside as inside, loadWorkspace, parseConfig, readJson, readSandboxMark, redactHome, resolveRoot } from "../workspace.js";
|
|
12
12
|
import { sandboxPort } from "./port.js";
|
|
13
13
|
import { DIRS, LEDGER_FILES } from "./skeleton.js";
|
|
14
14
|
/** vault 侧指在外面也不怕的角色:只读的材料,老师不往里写 */
|
|
15
15
|
const READ_ONLY_ROLES = ['textbooks', 'reference'];
|
|
16
16
|
/** ~/cotutor/ray → ~/cotutor/.sandbox/ray */
|
|
17
17
|
export const sandboxRootOf = (real) => join(dirname(real), SANDBOX_DIR, basename(real));
|
|
18
|
-
/** p 是 dir 本身或在它里面 */
|
|
19
|
-
function inside(p, dir) {
|
|
20
|
-
const r = relative(dir, p);
|
|
21
|
-
return r === '' || (r !== '..' && !r.startsWith(`..${sep}`) && !isAbsolute(r));
|
|
22
|
-
}
|
|
23
18
|
async function exists(p) {
|
|
24
19
|
return stat(p).then(() => true, () => false);
|
|
25
20
|
}
|
|
@@ -82,7 +82,10 @@ export interface ServiceSpec {
|
|
|
82
82
|
export declare const xml: (s: string) => string;
|
|
83
83
|
/** macOS 的 LaunchAgent */
|
|
84
84
|
export declare function launchdPlist(s: ServiceSpec): string;
|
|
85
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* Linux 的 systemd 用户服务。WorkingDirectory= 不认引号(只有 ExecStart=、Environment= 这种列表才认),
|
|
87
|
+
* 加了引号 systemd 当它是相对路径,整个服务起不来(2026-11-09 在 Ubuntu 22.04 / systemd 249 上撞到);它只要 % 写两个
|
|
88
|
+
*/
|
|
86
89
|
export declare function systemdUnit(s: ServiceSpec): string;
|
|
87
90
|
/** 从服务文件里读回跑的参数(找它是哪个 workspace 的) */
|
|
88
91
|
export declare function serviceArgs(text: string): string[];
|
package/dist/app/cli/service.js
CHANGED
|
@@ -218,7 +218,10 @@ ${s.args.map((a) => ` ${str(a)}`).join('\n')}
|
|
|
218
218
|
}
|
|
219
219
|
/** systemd 的一个参数:双引号括起来,反斜杠与引号转义,% 写两个 */
|
|
220
220
|
const sd = (v) => `"${v.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`;
|
|
221
|
-
/**
|
|
221
|
+
/**
|
|
222
|
+
* Linux 的 systemd 用户服务。WorkingDirectory= 不认引号(只有 ExecStart=、Environment= 这种列表才认),
|
|
223
|
+
* 加了引号 systemd 当它是相对路径,整个服务起不来(2026-11-09 在 Ubuntu 22.04 / systemd 249 上撞到);它只要 % 写两个
|
|
224
|
+
*/
|
|
222
225
|
export function systemdUnit(s) {
|
|
223
226
|
return `# cotutor service install 写的;改了会被下次 install 盖掉。卸掉:cotutor service uninstall --workspace ${s.root}
|
|
224
227
|
[Unit]
|
|
@@ -227,7 +230,7 @@ After=network-online.target
|
|
|
227
230
|
|
|
228
231
|
[Service]
|
|
229
232
|
ExecStart=${s.args.map(sd).join(' ')}
|
|
230
|
-
WorkingDirectory=${
|
|
233
|
+
WorkingDirectory=${s.root.replaceAll('%', '%%')}
|
|
231
234
|
Environment=${sd(`PATH=${s.path}`)}
|
|
232
235
|
Environment=${sd(`COTUTOR_SERVICE=${s.label}`)}
|
|
233
236
|
Restart=on-failure
|