c0de-agent 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -6
- package/dist/cli/commands/acp.js +3 -3
- package/dist/cli/commands/auth.d.ts +17 -0
- package/dist/cli/commands/auth.js +73 -0
- package/dist/cli/commands/chat.js +95 -1
- package/dist/cli/commands/config.d.ts +2 -9
- package/dist/cli/commands/config.js +25 -54
- package/dist/cli/commands/init.js +4 -2
- package/dist/cli/commands/plugin.js +8 -4
- package/dist/cli/commands/sessions.d.ts +10 -0
- package/dist/cli/commands/sessions.js +94 -0
- package/dist/cli/commands/update.js +18 -10
- package/dist/cli/index.js +51 -4
- package/dist/cli/modes/print.js +19 -2
- package/dist/core/compact.d.ts +2 -0
- package/dist/core/compact.js +1 -0
- package/dist/core/config-path.d.ts +9 -0
- package/dist/core/config-path.js +48 -0
- package/dist/core/config.d.ts +20 -3
- package/dist/core/config.js +103 -14
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/loop/compaction.d.ts +2 -1
- package/dist/core/loop/compaction.js +19 -6
- package/dist/core/loop/stream-collect.js +9 -1
- package/dist/core/loop/subagent.js +23 -5
- package/dist/core/loop.js +13 -3
- package/dist/core/redact.d.ts +11 -0
- package/dist/core/redact.js +49 -0
- package/dist/core/slash.js +167 -63
- package/dist/core/title.js +22 -1
- package/dist/core/types.d.ts +4 -0
- package/dist/db/client.d.ts +4 -0
- package/dist/db/client.js +5 -0
- package/dist/db/schema.d.ts +51 -0
- package/dist/db/schema.js +6 -0
- package/dist/kanban/index.d.ts +1 -1
- package/dist/kanban/index.js +1 -1
- package/dist/kanban/store.d.ts +5 -1
- package/dist/kanban/store.js +76 -2
- package/dist/llm/index.d.ts +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/provider.js +1 -1
- package/dist/llm/retry.d.ts +5 -2
- package/dist/llm/retry.js +6 -5
- package/dist/llm/routing.d.ts +24 -1
- package/dist/llm/routing.js +29 -1
- package/dist/project/index.d.ts +1 -1
- package/dist/project/index.js +1 -1
- package/dist/project/project.d.ts +14 -0
- package/dist/project/project.js +77 -1
- package/dist/server/agent-manager.d.ts +14 -0
- package/dist/server/agent-manager.js +41 -0
- package/dist/server/app.js +12 -2
- package/dist/server/auth-manager.d.ts +85 -0
- package/dist/server/auth-manager.js +289 -0
- package/dist/server/context.js +4 -0
- package/dist/server/middleware/auth.d.ts +4 -3
- package/dist/server/middleware/auth.js +5 -5
- package/dist/server/permission/interactive.d.ts +7 -2
- package/dist/server/permission/interactive.js +25 -5
- package/dist/server/permission/store.d.ts +15 -1
- package/dist/server/permission/store.js +31 -2
- package/dist/server/registry-config.d.ts +14 -0
- package/dist/server/registry-config.js +44 -0
- package/dist/server/routes/auth.d.ts +4 -0
- package/dist/server/routes/auth.js +124 -0
- package/dist/server/routes/chat.d.ts +18 -1
- package/dist/server/routes/chat.js +295 -37
- package/dist/server/routes/commands.d.ts +4 -2
- package/dist/server/routes/commands.js +10 -3
- package/dist/server/routes/config.js +143 -15
- package/dist/server/routes/files.js +22 -4
- package/dist/server/routes/kanban.js +68 -6
- package/dist/server/routes/permissions.d.ts +0 -1
- package/dist/server/routes/permissions.js +153 -4
- package/dist/server/routes/project.js +106 -3
- package/dist/server/routes/provider.js +48 -3
- package/dist/server/routes/session.js +319 -19
- package/dist/server/routes/terminal.js +17 -2
- package/dist/server/routes/update.d.ts +11 -5
- package/dist/server/routes/update.js +98 -16
- package/dist/server/server.d.ts +4 -12
- package/dist/server/server.js +235 -100
- package/dist/server/types.d.ts +15 -1
- package/dist/session/archive.d.ts +4 -2
- package/dist/session/archive.js +13 -1
- package/dist/session/branch.d.ts +1 -0
- package/dist/session/branch.js +32 -7
- package/dist/session/import.d.ts +44 -0
- package/dist/session/import.js +83 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/index.js +1 -1
- package/dist/session/jobs.d.ts +12 -0
- package/dist/session/jobs.js +53 -0
- package/dist/session/message.d.ts +7 -1
- package/dist/session/message.js +21 -1
- package/dist/session/session.d.ts +143 -6
- package/dist/session/session.js +561 -11
- package/dist/session/snapshot.d.ts +5 -2
- package/dist/session/snapshot.js +10 -2
- package/dist/session/types.d.ts +12 -2
- package/dist/shared/types/agent.d.ts +22 -0
- package/dist/shared/types/config.d.ts +10 -1
- package/dist/shared/types/kanban.d.ts +13 -0
- package/dist/shared/types/message.d.ts +15 -2
- package/dist/tools/index.js +2 -3
- package/dist/tools/websearch/index.js +6 -3
- package/dist/update/index.d.ts +3 -3
- package/dist/update/index.js +2 -2
- package/dist/update/ipc.d.ts +19 -2
- package/dist/update/ipc.js +43 -6
- package/dist/update/snapshot.d.ts +5 -0
- package/dist/update/snapshot.js +10 -0
- package/dist/update/updater.d.ts +69 -8
- package/dist/update/updater.js +263 -32
- package/drizzle/0006_sour_susan_delgado.sql +2 -0
- package/drizzle/0007_same_wilson_fisk.sql +2 -0
- package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
- package/drizzle/meta/0006_snapshot.json +867 -0
- package/drizzle/meta/0007_snapshot.json +869 -0
- package/drizzle/meta/0008_snapshot.json +875 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +41 -42
package/README.md
CHANGED
|
@@ -6,14 +6,19 @@
|
|
|
6
6
|
|
|
7
7
|
- **Browser-Server 架构**:Hono 后端通过 SSE 推送 LLM 流式响应;React 前端单页应用。
|
|
8
8
|
- **本地优先**:会话与索引存储在 PGLite(浏览器/进程内 PostgreSQL),数据随用随取。
|
|
9
|
-
-
|
|
9
|
+
- **多项目**:工作区隔离的多项目管理,每个项目独立会话历史与配置。项目目录被**移动/重命名**后,顶栏「目录已失效」提示提供「重新定位」——输入新路径即可把会话与看板整体迁移过去(无需删除项目)。
|
|
10
10
|
- **工具系统**:内置文件读写、bash、grep、glob、编辑等工具,可按需启用/禁用。
|
|
11
|
-
- **权限模式**:`default` / `auto`
|
|
11
|
+
- **权限模式**:`default` / `auto` 等授权模式,控制工具执行前是否需用户确认。权限确认双层超时:5 分钟未确认仅提示(可在弹窗中重新确认,不重发消息);再过 25 分钟仍未响应则自动拒绝(run 继续执行,会话不会无限挂起)。
|
|
12
12
|
- **会话可恢复**:会话快照序列化/反序列化,支持进程重启后无缝恢复。
|
|
13
|
-
-
|
|
14
|
-
-
|
|
13
|
+
- **无感知热更新**:后台定期检查新版本,安装成功后才暂停活跃会话 → 序列化会话快照 → npm 自更新 → 端口 handoff 接管。空闲会话零感知;进行中的对话会中断(界面提示后可一键重发继续),安装失败时不触碰任何会话状态。更新完成后请**刷新页面**加载新版界面(后续请求已自动落到新实例,不刷新则页面仍运行旧版前端)。
|
|
14
|
+
- **追加指令**:对话进行中可注入「追加指令」(steering),立即影响下一轮模型输出,并作为会话历史的一部分持久化——刷新后不丢失。
|
|
15
|
+
- **故障回退**:主 provider 失败时自动重试;启用后按配置的重试参数执行,并回退到声明了同一模型的其他 provider(设置 → 故障回退)。
|
|
15
16
|
- **斜杠命令**:`/compact`、`/model`、`/clear` 等可配置的斜杠命令。
|
|
16
|
-
-
|
|
17
|
+
- **会话回收站**:删除会话软删除入回收站,保留 60 天(自**首次在回收站看到该条目**起算——只标记一次,不随每次打开重置,倒计时稳定可预期)可恢复(按项目隔离,恢复时自动重新归属)。到期后先进入 **7 天宽限期**(回收站内明示「即将清除」,仍可恢复),宽限期满才物理清除,绝不静默清空。删除项目会将其会话移入回收站的「未归属项目」分组(跨项目可见,恢复时归属到某项目);该分组默认折叠,展开时才启动保留期倒计时。恢复会话时仅还原同一次删除操作中一并删除的分支,漏掉的分支会在提示中显式告知数量。
|
|
18
|
+
- **会话导出/导入**:会话可导出为 JSON 备份,并在任意项目重新导入(会话列表「导入」按钮),跨机器迁移或本地备份均可用。导出含权限态(自动授权/始终允许白名单)时,导入前会明示风险并让你选择是否随迁——默认剥离,避免导入他人导出的会话时静默继承对危险工具的免确认放行。导出仅含本会话与归档;有派生分支时会先提示分支内容不会随迁。
|
|
19
|
+
- **看板**:项目级任务看板(与会话内 agent 待办相互独立,可把待办导出为看板卡片),支持导出/导入整板 JSON 备份——注意:删除项目会**永久**级联删除看板(会话有回收站,看板没有),删除前请先导出备份;目录只是被移动/重命名时请用「重新定位」整体迁移,不要用删除。
|
|
20
|
+
- **会话用量**:会话列表每行显示累计 token 徽标;会话页「会话信息」面板提供 token/成本/消息数等完整统计(token 为服务端上报用量,成本按配置价目估算)。
|
|
21
|
+
- **主题**:亮/暗/跟随系统主题。
|
|
17
22
|
|
|
18
23
|
## 安装
|
|
19
24
|
|
|
@@ -29,9 +34,14 @@ pnpm add -g c0de-agent
|
|
|
29
34
|
|
|
30
35
|
## 安全
|
|
31
36
|
|
|
32
|
-
-
|
|
37
|
+
- **密钥存储**:`config.json` 写入时强制 `chmod 600`;`providers[].apiKey` 与 `websearch.tavilyApiKey/braveApiKey` 落盘前自动加密(AES-256-GCM,机器绑定,`enc:` 前缀),无论经 Web 设置、`c0de config set` 还是 `/config` 斜杠命令写入均不落明文。例外:`security.token`(静态模式专用)以明文存储,仅建议 CI/脚本场景使用,交互使用请走设备配对。`/config` 与 `c0de config get` 展示配置时对 apiKey/token 等字段自动脱敏。
|
|
38
|
+
- 项目级配置(`<项目>/.c0de/config.json`)含密钥且目录在 git 仓库内时,设置页会提示将 `.c0de/` 加入 `.gitignore`,防止误提交。
|
|
39
|
+
- **认证**:`security.authEnabled` 默认开启。首次启动自动生成 bootstrap token(持久化于数据目录 `auth-token` 文件),浏览器首访凭启动打印的 URL `?token=` 注册为**首台设备**,服务端随即**轮换 bootstrap token**(旧 token 立即失效,杜绝 URL/shell 历史泄漏)并下发设备 token;后续 API 请求与终端 WebSocket 均携带设备 token。新增设备无 token 时进入**配对流程**:新设备生成 6 位配对码,由已授权设备在「设备配对」弹窗中核对并批准后下发新设备 token。显式配置 `security.token` 时为静态模式(不轮换、不配对,适合 CI/脚本)。
|
|
40
|
+
- **首设备先到先得**:任何能看见启动打印 URL 的人(同机其他用户、终端输出被截图/共享)都能抢先注册为首台设备,随后真实用户只能经其配对审批。请勿在共享主机或会被录屏的场景让 URL 暴露。默认防护:bootstrap token 生成后 **5 分钟**内有效(`security.firstDeviceTtlMs` 默认值),超时需重启 `c0de serve`(无已注册设备时重启会重新生成 bootstrap,窗口随之刷新);共享主机/CI 等场景可显式配置更短的值。
|
|
33
41
|
- token 解析优先级:`security.token` 配置 > 环境变量 `C0DE_AUTH_TOKEN` > 数据目录 token 文件 > 自动生成并持久化。
|
|
34
42
|
- 显式关闭:`security.authEnabled: false`(本机单用户且端口仅本机可达时)。
|
|
43
|
+
- **设备管理**:`c0de auth list` 列出已授权设备;`c0de auth revoke <id>` 撤销设备(运行中的服务立即生效);`c0de auth reset` 清除全部设备与 token。运行中的服务会热加载 `devices.json` 的变更。
|
|
44
|
+
- **丢失唯一设备的恢复**:若唯一设备的浏览器数据被清空(设备 token 丢失),配对流程因无已授权设备可批准而无法完成。恢复步骤:`c0de auth reset` → 重启 `c0de serve` → 打开启动日志打印的带 `?token=` 的 URL 重新注册首台设备。
|
|
35
45
|
- **CORS/Origin 校验**:仅放行本机回环 origin 与 `security.allowedOrigins` 中显式配置的 origin;WebSocket 升级在服务端独立校验 Origin(浏览器 WS 不受 CORS 约束)。
|
|
36
46
|
- **监听地址**:默认绑定 `0.0.0.0`(便于容器/远程访问),安全性由 token + allowedOrigins 保证;需要时可用 `security.allowedOrigins` 收紧或自行反向代理限制。
|
|
37
47
|
- **热更新交接**:新实例经环境变量继承 token,请求旧实例 `/handoff` 时须携带匹配的 Bearer token,防止任意本地进程借 handoff 杀掉服务。
|
|
@@ -51,12 +61,24 @@ c0de serve --restore snapshot.json
|
|
|
51
61
|
# 打印模式(单次问答,不启动服务)
|
|
52
62
|
c0de chat "解释这段代码"
|
|
53
63
|
|
|
64
|
+
# serve 运行时打印模式默认拒绝(对话无法持久化),显式接受临时模式:
|
|
65
|
+
c0de chat --temp "解释这段代码"
|
|
66
|
+
|
|
54
67
|
# 更新检查
|
|
55
68
|
c0de update --check
|
|
56
69
|
```
|
|
57
70
|
|
|
58
71
|
启动后访问 `http://localhost:3000`。
|
|
59
72
|
|
|
73
|
+
> **CLI 与 Web 的会话隔离**:`c0de chat` 产生的会话标记为 `cli` 来源,不会出现在
|
|
74
|
+
> Web 界面的会话树中(Web 会话树仅展示 web 会话)。CLI 侧 `c0de sessions list` 会列出
|
|
75
|
+
> 全部会话(`[cli]`/`[web]` 标注),并可删除/恢复任意会话——这是 Web 会话的 CLI 恢复途径。
|
|
76
|
+
> 注意:数据库为单写者,`c0de sessions` / `c0de chat`(非 `--temp`)在 serve 运行期间会报错拒绝
|
|
77
|
+
> (CLI 管理需先停止 serve;运行中的服务请用 Web 界面管理)。
|
|
78
|
+
> 恢复的会话若原项目目录已不存在(孤儿会话),可用 `c0de sessions restore <id> --project <路径>`
|
|
79
|
+
> 显式归属到项目。两者存于同一数据库。`c0de chat --continue` 续接过的 CLI 会话不会被自动清理;
|
|
80
|
+
> 未续接的一次性问答会话 30 天后自动清除。
|
|
81
|
+
|
|
60
82
|
## 开发
|
|
61
83
|
|
|
62
84
|
```bash
|
package/dist/cli/commands/acp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import readline from 'node:readline/promises';
|
|
2
|
-
import { createSession,
|
|
2
|
+
import { createSession, listAllSessions } from '../../session/session.js';
|
|
3
3
|
import { formatACPEvent, runAcpLoop } from '../modes/acp.js';
|
|
4
4
|
import { runPrintMode } from '../modes/print.js';
|
|
5
5
|
/** 构造 ACP method → handler 映射。chat 复用 Print 模式。 */
|
|
@@ -7,11 +7,11 @@ function createAcpHandlers(config, deps, opts) {
|
|
|
7
7
|
return {
|
|
8
8
|
'session/create': async (params) => {
|
|
9
9
|
const title = params.title ?? 'acp-session';
|
|
10
|
-
const session = await createSession(deps.db, title);
|
|
10
|
+
const session = await createSession(deps.db, title, undefined, undefined, 'cli');
|
|
11
11
|
return { sessionId: session.id };
|
|
12
12
|
},
|
|
13
13
|
'session/list': async () => {
|
|
14
|
-
const sessions = await
|
|
14
|
+
const sessions = await listAllSessions(deps.db);
|
|
15
15
|
return { sessions };
|
|
16
16
|
},
|
|
17
17
|
chat: async (params) => {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CommandArgs } from '../parser.js';
|
|
2
|
+
type DeviceRecord = {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
tokenHash: string;
|
|
6
|
+
createdAt: number;
|
|
7
|
+
};
|
|
8
|
+
type AuthCommandContext = {
|
|
9
|
+
args: CommandArgs;
|
|
10
|
+
write?: (s: string) => void;
|
|
11
|
+
};
|
|
12
|
+
declare function devicesPath(): string;
|
|
13
|
+
declare function readDevices(): DeviceRecord[];
|
|
14
|
+
declare function writeDevices(devices: DeviceRecord[]): void;
|
|
15
|
+
declare function runAuthCommand(ctx: AuthCommandContext): Promise<void>;
|
|
16
|
+
export type { AuthCommandContext };
|
|
17
|
+
export { devicesPath, readDevices, runAuthCommand, writeDevices };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/cli/commands/auth.ts — 设备管理与认证恢复(P1-4/P2-5)。
|
|
2
|
+
//
|
|
3
|
+
// 本地终端是可信信道:唯一设备丢失(浏览器数据清空)导致配对死锁时,
|
|
4
|
+
// 用户可通过 `c0de auth reset` 清除设备注册表,重启 serve 后用启动日志
|
|
5
|
+
// 打印的带 token URL 重新注册首台设备。
|
|
6
|
+
// 运行中的 serve 通过 authManager 的 devices.json watcher 热加载 revoke/reset 变更。
|
|
7
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { resolveDbDir } from '../../server/server.js';
|
|
10
|
+
function devicesPath() {
|
|
11
|
+
return join(resolveDbDir(), 'devices.json');
|
|
12
|
+
}
|
|
13
|
+
function authTokenPath() {
|
|
14
|
+
return join(resolveDbDir(), 'auth-token');
|
|
15
|
+
}
|
|
16
|
+
function readDevices() {
|
|
17
|
+
try {
|
|
18
|
+
if (!existsSync(devicesPath()))
|
|
19
|
+
return [];
|
|
20
|
+
const raw = JSON.parse(readFileSync(devicesPath(), 'utf-8'));
|
|
21
|
+
return Array.isArray(raw.devices) ? raw.devices : [];
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error('auth: 无法读取设备注册表(devices.json 损坏?)');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function writeDevices(devices) {
|
|
28
|
+
const file = { version: 1, devices };
|
|
29
|
+
writeFileSync(devicesPath(), JSON.stringify(file, null, 2), 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
async function runAuthCommand(ctx) {
|
|
32
|
+
const write = ctx.write ?? ((s) => process.stdout.write(s));
|
|
33
|
+
const sub = ctx.args.positionals[0] ?? 'list';
|
|
34
|
+
if (sub === 'list') {
|
|
35
|
+
const devices = readDevices();
|
|
36
|
+
if (devices.length === 0) {
|
|
37
|
+
write('无已授权设备。\n');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const d of devices) {
|
|
41
|
+
write(`- ${d.name} (id: ${d.id}, 注册于 ${new Date(d.createdAt).toISOString()})\n`);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (sub === 'revoke') {
|
|
46
|
+
const id = ctx.args.positionals[1];
|
|
47
|
+
if (!id)
|
|
48
|
+
throw new Error('auth revoke: a device id is required (use `c0de auth list`)');
|
|
49
|
+
const devices = readDevices();
|
|
50
|
+
const target = devices.find((d) => d.id === id);
|
|
51
|
+
if (!target)
|
|
52
|
+
throw new Error(`auth revoke: device not found: ${id}`);
|
|
53
|
+
writeDevices(devices.filter((d) => d.id !== id));
|
|
54
|
+
write(`已撤销设备 "${target.name}"(${id})。运行中的 serve 将立即生效。\n`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (sub === 'reset') {
|
|
58
|
+
// 清除设备注册表 + bootstrap:恢复到「未注册」状态。
|
|
59
|
+
// 运行中的 serve 经 watcher 热清空内存设备;bootstrap 在重启后重新生成并打印。
|
|
60
|
+
try {
|
|
61
|
+
rmSync(devicesPath(), { force: true });
|
|
62
|
+
rmSync(authTokenPath(), { force: true });
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
throw new Error(`auth reset: ${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
+
}
|
|
67
|
+
write('已清除全部已授权设备与认证 token。\n');
|
|
68
|
+
write('恢复步骤:重启 c0de serve,然后打开启动日志中打印的带 ?token= 的 URL 注册首台设备。\n');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`auth: unknown subcommand "${sub}" (expected list|revoke|reset)`);
|
|
72
|
+
}
|
|
73
|
+
export { devicesPath, readDevices, runAuthCommand, writeDevices };
|
|
@@ -1,4 +1,70 @@
|
|
|
1
1
|
import { runPrintMode } from '../modes/print.js';
|
|
2
|
+
/**
|
|
3
|
+
* CLI 斜杠命令拦截:与 Web chat 路由同语义(P1 跨端一致性)。
|
|
4
|
+
* 此前 print 模式把 /clear、/config 等当普通文本发给 LLM,模型会虚构执行结果;
|
|
5
|
+
* 现在 parseSlashInput + registry 拦截,未启用/未知命令回退与 Web 端一致。
|
|
6
|
+
* /compact 需要会话上下文,由消费方在此执行压缩并输出摘要。
|
|
7
|
+
*/
|
|
8
|
+
async function runSlashCommand(message, continueId, ctx) {
|
|
9
|
+
const { createSlashRegistry, parseSlashInput } = await import('../../core/slash.js');
|
|
10
|
+
const parsed = parseSlashInput(message);
|
|
11
|
+
if (!parsed)
|
|
12
|
+
return false;
|
|
13
|
+
const registry = createSlashRegistry();
|
|
14
|
+
const cmd = registry.get(parsed.name);
|
|
15
|
+
if (!cmd)
|
|
16
|
+
return false; // 未知斜杠命令:回退为正常消息发给 agent(与 Web 端一致)
|
|
17
|
+
const out = ctx.stdout ?? process.stdout.write.bind(process.stdout);
|
|
18
|
+
const err = ctx.stderr ?? process.stderr.write.bind(process.stderr);
|
|
19
|
+
const enabledList = ctx.config.slashCommands?.enabled ?? [];
|
|
20
|
+
const enabledSet = new Set(enabledList.map((n) => (n.startsWith('/') ? n.slice(1) : n)));
|
|
21
|
+
if (enabledSet.size > 0 && !enabledSet.has(parsed.name)) {
|
|
22
|
+
err(`斜杠命令 /${parsed.name} 未启用(config.slashCommands.enabled)\n`);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
const { compactContext } = await import('../../core/loop.js');
|
|
26
|
+
const { createAgent } = await import('../../core/agent.js');
|
|
27
|
+
const { getSession } = await import('../../session/session.js');
|
|
28
|
+
const result = await cmd.execute(parsed.args, {
|
|
29
|
+
cwd: ctx.deps.cwd,
|
|
30
|
+
config: ctx.config,
|
|
31
|
+
deps: ctx.deps,
|
|
32
|
+
sessionId: continueId,
|
|
33
|
+
// 消费渠道:/model 等命令按渠道给指引(CLI 无底部模型选择器)。
|
|
34
|
+
channel: 'cli',
|
|
35
|
+
});
|
|
36
|
+
if (result._tag === 'compact') {
|
|
37
|
+
// /compact:消费方执行压缩(Web 端由 chat 路由消费)。需要会话上下文。
|
|
38
|
+
if (!continueId) {
|
|
39
|
+
err('/compact 需要会话上下文:请加 --continue <session-id> 指定会话\n');
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
const session = await getSession(ctx.deps.db, continueId);
|
|
43
|
+
if (!session) {
|
|
44
|
+
throw new Error(`session not found: ${continueId}`);
|
|
45
|
+
}
|
|
46
|
+
const agentConfig = {
|
|
47
|
+
provider: ctx.config.defaultProvider,
|
|
48
|
+
model: ctx.config.defaultModel,
|
|
49
|
+
tools: [],
|
|
50
|
+
plugins: ctx.config.plugins.enabled,
|
|
51
|
+
agentName: 'default',
|
|
52
|
+
};
|
|
53
|
+
const state = await createAgent(session, agentConfig, ctx.deps);
|
|
54
|
+
for await (const event of compactContext(state, ctx.deps)) {
|
|
55
|
+
if (event._tag === 'text_delta')
|
|
56
|
+
out(`${event.text}\n`);
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (result._tag === 'error') {
|
|
61
|
+
err(`${result.message}\n`);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
const text = result._tag === 'success' ? result.message : result.text;
|
|
65
|
+
out(`${text}\n`);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
2
68
|
async function runChatCommand(ctx) {
|
|
3
69
|
const message = ctx.args.positionals.join(' ').trim();
|
|
4
70
|
if (!message)
|
|
@@ -7,7 +73,22 @@ async function runChatCommand(ctx) {
|
|
|
7
73
|
const err = ctx.stderr ?? process.stderr.write.bind(process.stderr);
|
|
8
74
|
const format = ctx.args.options.format ?? 'text';
|
|
9
75
|
const model = ctx.args.options.model;
|
|
10
|
-
|
|
76
|
+
let continueId = ctx.args.options.continue;
|
|
77
|
+
// --continue last:续接最近一次会话,免手抄 UUID(P3 产品发现性)。
|
|
78
|
+
if (continueId === 'last') {
|
|
79
|
+
const { listAllSessions } = await import('../../session/session.js');
|
|
80
|
+
const sessions = await listAllSessions(ctx.deps.db);
|
|
81
|
+
if (sessions.length === 0) {
|
|
82
|
+
throw new Error('chat: no sessions to continue (run c0de chat once first)');
|
|
83
|
+
}
|
|
84
|
+
sessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
85
|
+
continueId = sessions[0]?.id;
|
|
86
|
+
if (!continueId)
|
|
87
|
+
throw new Error('chat: no sessions to continue');
|
|
88
|
+
}
|
|
89
|
+
// 斜杠命令拦截:命中则执行本地语义,不把命令文本发给 LLM。
|
|
90
|
+
if (await runSlashCommand(message, continueId, ctx))
|
|
91
|
+
return;
|
|
11
92
|
const text = await runPrintMode(ctx.config, message, ctx.deps, {
|
|
12
93
|
...(model ? { model } : {}),
|
|
13
94
|
...(continueId ? { sessionId: continueId } : {}),
|
|
@@ -16,6 +97,14 @@ async function runChatCommand(ctx) {
|
|
|
16
97
|
err(`[tool] ${e.tool}\n`);
|
|
17
98
|
else if (e._tag === 'thinking')
|
|
18
99
|
err(`[thinking] ${e.text}\n`);
|
|
100
|
+
else if (e._tag === 'tool_call_end') {
|
|
101
|
+
// P2-17:非交互模式被拒绝的工具调用,把拒绝原因直接可见地输出给用户
|
|
102
|
+
//(拒绝原因本身含 -y / serve 指引),而不是只留给模型转述。
|
|
103
|
+
const result = e.result;
|
|
104
|
+
if (result?._tag === 'deny') {
|
|
105
|
+
err(`[tool] 工具调用已拒绝:${result.reason ?? '需要确认'}\n`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
19
108
|
},
|
|
20
109
|
});
|
|
21
110
|
if (format === 'json') {
|
|
@@ -24,5 +113,10 @@ async function runChatCommand(ctx) {
|
|
|
24
113
|
else {
|
|
25
114
|
out(`${text}\n`);
|
|
26
115
|
}
|
|
116
|
+
// P2-5:一次性 CLI 问答不进 Web 会话树,明确告知查看/续接途径,
|
|
117
|
+
// 避免「我的会话去哪了」(--continue 续接时用户已知会话存在,不提示)。
|
|
118
|
+
if (!continueId) {
|
|
119
|
+
err('此问答会话不会出现在 Web 界面;可用 `c0de sessions list` 查看,或 `c0de chat --continue` 续接。\n');
|
|
120
|
+
}
|
|
27
121
|
}
|
|
28
122
|
export { runChatCommand };
|
|
@@ -1,17 +1,10 @@
|
|
|
1
|
+
import { coerce, getByPath, setPathPatch } from '../../core/config-path.js';
|
|
1
2
|
import type { CommandArgs } from '../parser.js';
|
|
2
3
|
type ConfigCommandContext = {
|
|
3
4
|
args: CommandArgs;
|
|
4
5
|
cwd: string;
|
|
5
6
|
write?: (s: string) => void;
|
|
6
7
|
};
|
|
7
|
-
/** 按 a.b.c 点路径从对象取值;找不到抛错。 */
|
|
8
|
-
declare function getByPath(obj: unknown, path: string): unknown;
|
|
9
|
-
/** 按 a.b.c 点路径写值(创建中间对象)。 */
|
|
10
|
-
declare function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void;
|
|
11
|
-
/** 把字符串解析为合适的标量类型。 */
|
|
12
|
-
declare function coerce(value: string): unknown;
|
|
13
|
-
/** 构造 a.b.c → { a: { b: { c: value } } } 的 patch 对象。 */
|
|
14
|
-
declare function setPathPatch(path: string, value: unknown): Record<string, unknown>;
|
|
15
8
|
declare function runConfigCommand(ctx: ConfigCommandContext): Promise<void>;
|
|
16
9
|
export type { ConfigCommandContext };
|
|
17
|
-
export { coerce, getByPath, runConfigCommand,
|
|
10
|
+
export { coerce, getByPath, runConfigCommand, setPathPatch };
|
|
@@ -1,81 +1,52 @@
|
|
|
1
|
-
import { loadConfigScopes, mergeConfig,
|
|
2
|
-
|
|
3
|
-
function getByPath(obj, path) {
|
|
4
|
-
const parts = path.split('.');
|
|
5
|
-
let cur = obj;
|
|
6
|
-
for (const p of parts) {
|
|
7
|
-
if (cur === null || typeof cur !== 'object')
|
|
8
|
-
throw new Error(`config: key "${path}" not found`);
|
|
9
|
-
cur = cur[p];
|
|
10
|
-
if (cur === undefined)
|
|
11
|
-
throw new Error(`config: key "${path}" not found`);
|
|
12
|
-
}
|
|
13
|
-
return cur;
|
|
14
|
-
}
|
|
15
|
-
/** 按 a.b.c 点路径写值(创建中间对象)。 */
|
|
16
|
-
function setByPath(obj, path, value) {
|
|
17
|
-
const parts = path.split('.');
|
|
18
|
-
let cur = obj;
|
|
19
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
20
|
-
const k = parts[i];
|
|
21
|
-
const next = cur[k];
|
|
22
|
-
cur[k] = (next !== null && typeof next === 'object' ? next : {});
|
|
23
|
-
cur = cur[k];
|
|
24
|
-
}
|
|
25
|
-
cur[parts[parts.length - 1]] = value;
|
|
26
|
-
}
|
|
27
|
-
/** 把字符串解析为合适的标量类型。 */
|
|
28
|
-
function coerce(value) {
|
|
29
|
-
if (value === 'true')
|
|
30
|
-
return true;
|
|
31
|
-
if (value === 'false')
|
|
32
|
-
return false;
|
|
33
|
-
if (/^-?\d+(\.\d+)?$/.test(value))
|
|
34
|
-
return Number(value);
|
|
35
|
-
try {
|
|
36
|
-
return JSON.parse(value);
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return value;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
/** 构造 a.b.c → { a: { b: { c: value } } } 的 patch 对象。 */
|
|
43
|
-
function setPathPatch(path, value) {
|
|
44
|
-
const root = {};
|
|
45
|
-
setByPath(root, path, value);
|
|
46
|
-
return root;
|
|
47
|
-
}
|
|
1
|
+
import { applyScopedPatch, loadConfigScopes, mergeConfig, saveConfigScoped, } from '../../core/config.js';
|
|
2
|
+
import { coerce, getByPath, setPathPatch } from '../../core/config-path.js';
|
|
48
3
|
async function runConfigCommand(ctx) {
|
|
49
4
|
const write = ctx.write ?? process.stdout.write.bind(process.stdout);
|
|
50
5
|
const sub = ctx.args.positionals[0];
|
|
51
6
|
const scopes = loadConfigScopes(ctx.cwd);
|
|
52
7
|
// get 展示用合并视图(global ← project,含默认值),与 loadConfig 语义一致。
|
|
53
8
|
const config = mergeConfig(scopes.global, scopes.project);
|
|
9
|
+
// 未知顶层键告警(loadConfig 只在 serve/chat 启动路径调用;config 命令走 loadConfigScopes)。
|
|
10
|
+
const { warnUnknownConfigKeys } = await import('../../core/config.js');
|
|
11
|
+
warnUnknownConfigKeys('global', scopes.global);
|
|
12
|
+
warnUnknownConfigKeys('project', scopes.project);
|
|
54
13
|
if (sub === 'get') {
|
|
55
14
|
const key = ctx.args.positionals[1];
|
|
15
|
+
const { redactSecrets } = await import('../../core/redact.js');
|
|
56
16
|
if (!key) {
|
|
57
|
-
|
|
17
|
+
// 展示前脱敏:apiKey/token 明文绝不输出到终端(P0 密钥暴露)。
|
|
18
|
+
write(`${JSON.stringify(redactSecrets(config), null, 2)}\n`);
|
|
58
19
|
return;
|
|
59
20
|
}
|
|
60
|
-
|
|
21
|
+
// 用点路径末段作为键名参与脱敏判定:config get security.token 等单键读取同样掩码。
|
|
22
|
+
const leaf = key.split('.').pop();
|
|
23
|
+
const val = redactSecrets(getByPath(config, key), leaf);
|
|
61
24
|
write(`${typeof val === 'object' ? JSON.stringify(val) : String(val)}\n`);
|
|
62
25
|
return;
|
|
63
26
|
}
|
|
64
27
|
if (sub === 'set') {
|
|
65
28
|
const key = ctx.args.positionals[1];
|
|
66
|
-
const
|
|
29
|
+
const rawArg = ctx.args.positionals[2];
|
|
67
30
|
if (!key)
|
|
68
31
|
throw new Error('config set: a key is required');
|
|
69
|
-
if (
|
|
32
|
+
if (rawArg === undefined)
|
|
70
33
|
throw new Error('config set: a value is required');
|
|
34
|
+
// 顶层键校验:拒绝拼写错误/过时键,避免写入永不生效的配置(此前静默接受)。
|
|
35
|
+
const { KNOWN_CONFIG_KEYS } = await import('../../core/config.js');
|
|
36
|
+
const topKey = key.split('.')[0] ?? '';
|
|
37
|
+
if (!KNOWN_CONFIG_KEYS.has(topKey)) {
|
|
38
|
+
throw new Error(`config set: 未知的顶层配置键 "${topKey}"。有效键:${[...KNOWN_CONFIG_KEYS].join(', ')}`);
|
|
39
|
+
}
|
|
71
40
|
const scope = ctx.args.options.global ? 'global' : 'project';
|
|
72
41
|
// 只把 patch 合并进目标作用域的原始文件,不写入默认值与其他作用域的配置(P2-3)。
|
|
42
|
+
// 值为 null → 删除该键(作用域内取消覆盖,回落另一作用域/默认值)。
|
|
73
43
|
const scopeCfg = scope === 'global' ? scopes.global : scopes.project;
|
|
74
|
-
const
|
|
44
|
+
const value = coerce(rawArg);
|
|
45
|
+
const next = applyScopedPatch(scopeCfg ?? {}, setPathPatch(key, value));
|
|
75
46
|
await saveConfigScoped(scope, ctx.cwd, next);
|
|
76
|
-
write(
|
|
47
|
+
write(`${value === null ? '已取消设置' : '已设置'} ${key} (scope: ${scope})\n`);
|
|
77
48
|
return;
|
|
78
49
|
}
|
|
79
50
|
throw new Error(`config: unknown subcommand "${sub ?? ''}" (expected get|set)`);
|
|
80
51
|
}
|
|
81
|
-
export { coerce, getByPath, runConfigCommand,
|
|
52
|
+
export { coerce, getByPath, runConfigCommand, setPathPatch };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { saveConfigScoped } from '../../core/config.js';
|
|
4
4
|
async function runInitCommand(ctx) {
|
|
5
5
|
const log = ctx.log ?? process.stdout.write.bind(process.stdout);
|
|
6
6
|
const force = ctx.args.options.force ?? false;
|
|
@@ -8,7 +8,9 @@ async function runInitCommand(ctx) {
|
|
|
8
8
|
if (existsSync(path) && !force) {
|
|
9
9
|
throw new Error(`init: config already exists at ${path} (use --force to overwrite)`);
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
// P1-3:只写空配置——写入 DEFAULT_CONFIG 会把 providers: [] 等默认值固化进项目文件,
|
|
12
|
+
// 数组整体替换语义会覆盖全局 providers,使项目内 AI 服务失效。
|
|
13
|
+
await saveConfigScoped('project', ctx.cwd, {});
|
|
12
14
|
log(`Created ${path}\n`);
|
|
13
15
|
}
|
|
14
16
|
export { runInitCommand };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { applyScopedPatch, loadConfigScopes, mergeConfig } from '../../core/config.js';
|
|
2
2
|
async function runPluginCommand(ctx) {
|
|
3
3
|
const write = ctx.write ?? process.stdout.write.bind(process.stdout);
|
|
4
4
|
const discover = ctx.discover ?? (async () => []);
|
|
@@ -18,11 +18,15 @@ async function runPluginCommand(ctx) {
|
|
|
18
18
|
const name = ctx.args.positionals[1];
|
|
19
19
|
if (!name)
|
|
20
20
|
throw new Error('plugin install: a plugin name is required');
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// P1-2:只把 plugins.enabled 合并进 project 作用域文件,不整体落盘
|
|
22
|
+
//(saveConfig 全量写入会把默认值与全局配置固化进项目文件)。
|
|
23
|
+
const scopes = loadConfigScopes(ctx.cwd);
|
|
24
|
+
const enabled = [...(mergeConfig(scopes.global, scopes.project).plugins?.enabled ?? [])];
|
|
23
25
|
if (!enabled.includes(name))
|
|
24
26
|
enabled.push(name);
|
|
25
|
-
|
|
27
|
+
const next = applyScopedPatch(scopes.project ?? {}, { plugins: { enabled } });
|
|
28
|
+
const { saveConfigScoped } = await import('../../core/config.js');
|
|
29
|
+
await saveConfigScoped('project', ctx.cwd, next);
|
|
26
30
|
write(`Enabled plugin "${name}".\n`);
|
|
27
31
|
return;
|
|
28
32
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DB } from '../../db/client.js';
|
|
2
|
+
import type { CommandArgs } from '../parser.js';
|
|
3
|
+
type SessionsCommandContext = {
|
|
4
|
+
args: CommandArgs;
|
|
5
|
+
db: DB;
|
|
6
|
+
write?: (s: string) => void;
|
|
7
|
+
};
|
|
8
|
+
declare function runSessionsCommand(ctx: SessionsCommandContext): Promise<void>;
|
|
9
|
+
export type { SessionsCommandContext };
|
|
10
|
+
export { runSessionsCommand };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// src/cli/commands/sessions.ts — CLI 会话管理(P3:清理 print 模式积累的 CLI 会话)。
|
|
2
|
+
//
|
|
3
|
+
// c0de chat 每次运行都创建 source='cli' 会话且被 Web 会话树排除,
|
|
4
|
+
// 此前无任何 CLI 途径列出/清理,垃圾数据只增不减。
|
|
5
|
+
// 复用 withAgentDeps 的持久库生命周期(serve 占用时退化为内存库则明确报错)。
|
|
6
|
+
//
|
|
7
|
+
// P1 闭环修复:delete 移入回收站后必须有 CLI 恢复途径(此前文案承诺
|
|
8
|
+
// 「60 天内可恢复」但 CLI 无 restore 子命令,恢复只能绕道 Web 界面)。
|
|
9
|
+
import { fromDirectory } from '../../project/project.js';
|
|
10
|
+
import { getSession, listAllSessions, listDeletedSessions, rebindSession, restoreSession, softDeleteSession, } from '../../session/session.js';
|
|
11
|
+
async function runSessionsCommand(ctx) {
|
|
12
|
+
const write = ctx.write ?? ((s) => process.stdout.write(s));
|
|
13
|
+
const sub = ctx.args.positionals[0] ?? 'list';
|
|
14
|
+
if (sub === 'list') {
|
|
15
|
+
const sessions = await listAllSessions(ctx.db);
|
|
16
|
+
if (sessions.length === 0) {
|
|
17
|
+
write('无会话。\n');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (const s of sessions) {
|
|
21
|
+
const source = s.source === 'cli' ? 'cli' : 'web';
|
|
22
|
+
const when = new Date(s.updatedAt).toISOString();
|
|
23
|
+
write(`- [${source}] ${s.title} (id: ${s.id}, updated ${when})\n`);
|
|
24
|
+
}
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (sub === 'delete') {
|
|
28
|
+
const id = ctx.args.positionals[1];
|
|
29
|
+
if (!id)
|
|
30
|
+
throw new Error('sessions delete: a session id is required (use `c0de sessions list`)');
|
|
31
|
+
const ok = await softDeleteSession(ctx.db, id);
|
|
32
|
+
if (!ok)
|
|
33
|
+
throw new Error(`sessions delete: session not found or already deleted: ${id}`);
|
|
34
|
+
write(`已删除会话 ${id}(移入回收站,60 天内可用 \`c0de sessions restore ${id}\` 恢复)。\n`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (sub === 'restore') {
|
|
38
|
+
const id = ctx.args.positionals[1];
|
|
39
|
+
if (!id)
|
|
40
|
+
throw new Error('sessions restore: a session id is required (use `c0de sessions list`)');
|
|
41
|
+
const ok = await restoreSession(ctx.db, id);
|
|
42
|
+
if (!ok)
|
|
43
|
+
throw new Error(`sessions restore: session not found or not deleted: ${id}`);
|
|
44
|
+
// P2-2:与 Web restore 对齐——会话项目已删除(FK set null)时按 worktreePath
|
|
45
|
+
// 重建归属,避免恢复成功但在 Web 各项目视图不可达。
|
|
46
|
+
// P2 修复:--project <path> 提供显式归属出口(Web 有「归属到当前项目」,
|
|
47
|
+
// CLI 此前只有警告、用户自己修不了)。
|
|
48
|
+
const projectPath = ctx.args.options.project;
|
|
49
|
+
const session = await getSession(ctx.db, id);
|
|
50
|
+
if (session && !session.projectId && (session.worktreePath || projectPath)) {
|
|
51
|
+
try {
|
|
52
|
+
const { existsSync } = await import('node:fs');
|
|
53
|
+
const target = projectPath ?? session.worktreePath;
|
|
54
|
+
if (target && existsSync(target)) {
|
|
55
|
+
const project = await fromDirectory(ctx.db, target);
|
|
56
|
+
await rebindSession(ctx.db, id, project);
|
|
57
|
+
write(`已恢复会话 ${id}(已重新归属到项目 ${project.id})。\n`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (projectPath) {
|
|
61
|
+
throw new Error(`项目目录不存在:${projectPath}`);
|
|
62
|
+
}
|
|
63
|
+
write(`已恢复会话 ${id}(警告:原项目目录不存在,会话未归属任何项目,Web 不可见)。\n`);
|
|
64
|
+
write(`提示:c0de sessions restore ${id} --project <项目路径> 可显式归属。\n`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
write(`已恢复会话 ${id}(警告:项目归属失败——${error instanceof Error ? error.message : String(error)},会话可能不可见)。\n`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (session?.projectId && projectPath) {
|
|
73
|
+
write(`已恢复会话 ${id}(会话已有项目归属,忽略 --project)。\n`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
write(`已恢复会话 ${id}。\n`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// 回收站列表:软删除会话 + 剩余保留信息(辅助 restore)
|
|
80
|
+
if (sub === 'deleted') {
|
|
81
|
+
const sessions = await listDeletedSessions(ctx.db);
|
|
82
|
+
if (sessions.length === 0) {
|
|
83
|
+
write('回收站为空。\n');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const s of sessions) {
|
|
87
|
+
const when = s.deletedAt ? new Date(s.deletedAt).toISOString() : '-';
|
|
88
|
+
write(`- ${s.title} (id: ${s.id}, deleted ${when})\n`);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`sessions: unknown subcommand "${sub}" (expected list|delete|restore|deleted)`);
|
|
93
|
+
}
|
|
94
|
+
export { runSessionsCommand };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { checkForUpdate, performHotUpdate, serializeSessions, } from '../../update/index.js';
|
|
1
|
+
import { checkForUpdate, performHotUpdate, performInstall, serializeSessions, } from '../../update/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* `c0de update`:检查 npm registry 新版本。
|
|
4
4
|
* - 默认:check-only,报告有无更新。
|
|
@@ -19,16 +19,24 @@ async function runUpdateCommand(ctx) {
|
|
|
19
19
|
out(`发现新版本 ${result.latestVersion}。加 --apply 执行热更新。`);
|
|
20
20
|
return;
|
|
21
21
|
}
|
|
22
|
+
// P2-7:无 db(独立 CLI,无活跃 serve)→ 仅安装,不 spawn `serve`。
|
|
23
|
+
// 此前会拉起一个用户未请求的常驻服务;活跃 serve 的热更新走 /api/update/apply。
|
|
24
|
+
if (!ctx.db) {
|
|
25
|
+
out(`发现新版本 ${result.latestVersion},开始安装…`);
|
|
26
|
+
const r = await performInstall(ctx.updateOpts);
|
|
27
|
+
if (r._tag === 'success') {
|
|
28
|
+
out('安装完成。若 c0de serve 正在运行,请在页面顶部的更新横幅中应用热更新,或重启 serve。');
|
|
29
|
+
}
|
|
30
|
+
else if (r._tag === 'install_failed') {
|
|
31
|
+
out(`安装失败:${r.error}`);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
out(`无法自动安装(${r.error})。请手动执行:${r.command}`);
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
22
38
|
out(`发现新版本 ${result.latestVersion},开始热更新…`);
|
|
23
|
-
const snapshot = ctx.db
|
|
24
|
-
? await serializeSessions(ctx.db, ctx.config)
|
|
25
|
-
: {
|
|
26
|
-
version: result.currentVersion,
|
|
27
|
-
sessions: [],
|
|
28
|
-
entries: [],
|
|
29
|
-
config: ctx.config ?? null,
|
|
30
|
-
timestamp: Date.now(),
|
|
31
|
-
};
|
|
39
|
+
const snapshot = await serializeSessions(ctx.db, ctx.config);
|
|
32
40
|
const r = await performHotUpdate(snapshot, ctx.updateOpts);
|
|
33
41
|
if (r._tag === 'success') {
|
|
34
42
|
out(`热更新完成,快照写入 ${r.snapshotPath}。新实例将接管。`);
|