Merge pull request #1658 from pikasTech/feat/todo-note-cli
feat: 新增简洁 Todo Note CLI
This commit is contained in:
@@ -18,12 +18,23 @@ description: UniDesk Decision Center 与秘书日程入口。用户说“你是
|
||||
秘书入口固定顺序:
|
||||
|
||||
1. 用 `TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S %Z'` 拉取最新北京时间。
|
||||
2. 读取 Todo Note 当前清单,再读取 Decision Center 最近工作日记;日记只读,不代替 Todo Note。
|
||||
2. 用 `unidesk todo-note list` / `unidesk todo-note show <列表>` 读取 Todo Note 当前清单,再读取 Decision Center 最近工作日记;日记只读,不代替 Todo Note。
|
||||
3. 根据硬约束和最新记录给出当前动作、时间盒、反馈格式。
|
||||
4. 用户反馈后,先同步 Todo Note,再滚动安排下一段。
|
||||
|
||||
若任一 CLI、frontend、backend-core、Todo Note 或 Decision Center 请求失败,先调查并修复工具/服务,完成原入口复测后再回到日程;不得跳过故障继续凭印象排程,也不得用 Decision diary 绕过 Todo Note 写入故障。
|
||||
|
||||
Todo Note 日常维护固定使用资源命令,不手写 `/api/...` 或 JSON body:
|
||||
|
||||
```bash
|
||||
unidesk todo-note list
|
||||
unidesk todo-note show 大论文
|
||||
unidesk todo-note add 大论文 --title "跟踪外审意见"
|
||||
unidesk todo-note update 大论文 <todo-id> --title "新标题"
|
||||
unidesk todo-note complete 大论文 <todo-id>
|
||||
unidesk todo-note remind 大论文 <todo-id> --at 2026-07-31T18:00:00+08:00
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 记录管理
|
||||
|
||||
+21
-16
@@ -4,6 +4,8 @@ UniDesk 的统一 CLI 实现入口是根目录 `scripts/cli.ts`,运行方式
|
||||
|
||||
主 server 必须在 PATH 上提供 `/root/.local/bin/trans` 可执行 wrapper,内容委托 repo 内版本化 `scripts/trans` 并执行 `bun scripts/ssh-cli.ts ssh "$@"`;交互 shell 可额外提供 alias,但非交互 Codex `exec` 和脚本不能依赖 alias 展开。
|
||||
|
||||
主 server 同时提供 `/root/.local/bin/unidesk`,委托版本化 `scripts/unidesk` 执行根 CLI。高频人工入口优先写 `unidesk <command>`;仓库内验证和未安装 wrapper 的 checkout 使用等价的 `bun scripts/cli.ts <command>`。
|
||||
|
||||
`trans` wrapper 是 SSH/WSL/k3s 透传的唯一默认入口:人工/Codex 远端操作、长期参考文档、AGENTS 索引、CLI help、非交互脚本和非交互 `exec` 都必须直接调主 server PATH 上的 `/root/.local/bin/trans`;禁止把 `bun scripts/cli.ts ssh ...`、`bun scripts/cli.ts trans ...` 或任何带 `bun scripts/cli.ts` 前缀的透传写法作为默认入口。`bun scripts/cli.ts help`、`config`、`server`、`provider`、`microservice` 等普通根 CLI 子命令不受这条限制,仍使用 `bun scripts/cli.ts <command>`,避免透传命令和根子命令在调用前缀上互相混淆。
|
||||
|
||||
CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI/CD server 和生产运行面。CLI/server 能力协商、unsupported-version 失败语义和 release-line 边界由 `docs/reference/release-governance.md` 与 [GitHub issue #6](https://github.com/pikasTech/unidesk/issues/6) 约束。
|
||||
@@ -175,26 +177,29 @@ UniDesk/HWLAB Web 开发、HWLAB `web-probe run|script|observe|screenshot`、fak
|
||||
|
||||
`microservice proxy` 是面向人工验证和受控调试的私有后端入口。默认 method 为 GET;使用 `--body-json JSON`、`--body-file path` 或 `--body-stdin` 时默认 method 切换为 POST,也可显式加 `--method POST|PUT|PATCH|DELETE`,但 GET/HEAD 不允许携带请求体。所有请求仍受 config 中的 `allowedMethods` 和 `allowedPathPrefixes` 限制。为了避免 Pipeline snapshot 这类超大业务 JSON 造成 CLI 输出爆炸,响应 body 超过默认阈值时会返回 `bodyOmitted=true`、`bodyPreview`、`bodyBytes` 和 `rawHint`;`--raw` 仍受默认硬限额保护,需要完整 body 时显式添加 `--raw --full`,或用 `--max-body-bytes <N>` 调整预览阈值。正式 frontend 展示仍应优先使用业务控件和 `__unideskArrayLimit` 这类展示级裁剪参数,而不是默认倾倒完整 JSON。
|
||||
|
||||
**Todo Note 写操作 CLI 范式(2026-06-01 [#190](https://github.com/pikasTech/unidesk/issues/190) 固化)**:Todo Note microservice 写不走 REST 集合(如 `/api/instances/:id/todos`),所有 task 写都走 action 队列 `POST /api/instances/:id/actions` + body `{action: {type, ...}}`。常见范式:
|
||||
## Todo Note Resource CLI
|
||||
|
||||
Todo Note 的人工与秘书入口是 `unidesk todo-note`。命令内部通过 backend-core 私有代理访问 NC01 服务,调用者不拼 `/api/...` 或 JSON action body。默认输出是有界表格,显式 `--json` 才返回结构化对象:
|
||||
|
||||
```bash
|
||||
# 标记完成 / 取消完成
|
||||
bun scripts/cli.ts microservice proxy todo-note \
|
||||
/api/instances/<id>/actions --method POST \
|
||||
--body-json '{"action":{"type":"toggleTodoCompleted","todoId":"todo_xxx"}}'
|
||||
|
||||
# 新增 todo
|
||||
bun scripts/cli.ts microservice proxy todo-note \
|
||||
/api/instances/<id>/actions --method POST \
|
||||
--body-json '{"action":{"type":"addTodo","title":"..."}}'
|
||||
|
||||
# 改名 / 改 reminder / 删除 / 移动:换 type 即可,见 docs/reference/microservices.md
|
||||
# 撤销 / 重做
|
||||
bun scripts/cli.ts microservice proxy todo-note /api/instances/<id>/undo --method POST
|
||||
bun scripts/cli.ts microservice proxy todo-note /api/instances/<id>/redo --method POST
|
||||
unidesk todo-note list
|
||||
unidesk todo-note show 大论文
|
||||
unidesk todo-note show HWLAB --all --limit 80
|
||||
unidesk todo-note add 大论文 --title "跟踪外审意见" [--parent <todo-id>]
|
||||
unidesk todo-note update 大论文 <todo-id|精确标题> --title "新标题"
|
||||
unidesk todo-note complete 大论文 <todo-id|精确标题>
|
||||
unidesk todo-note reopen 大论文 <todo-id|精确标题>
|
||||
unidesk todo-note remind 大论文 <todo-id|精确标题> --at 2026-07-31T18:00:00+08:00
|
||||
unidesk todo-note remind 大论文 <todo-id|精确标题> --clear
|
||||
unidesk todo-note delete 大论文 <todo-id|精确标题> --confirm
|
||||
unidesk todo-note undo 大论文
|
||||
unidesk todo-note redo 大论文
|
||||
unidesk todo-note health
|
||||
```
|
||||
|
||||
看到 `404 {"error":"Todo Note is running in backend-only mode"}` 时**第一反应**是路径错(用了不存在的 REST 端点被 catch-all 兜底),不是 mode 锁了写;详见 `docs/reference/microservices.md` 的 Todo Note 段。
|
||||
`complete` 和 `reopen` 是幂等命令:目标已经处于期望状态时返回 `CHANGED=false`,不会反向切换。列表支持名称或 instance id;todo 支持 id 或唯一精确标题,标题重名时必须使用 id。`microservice proxy todo-note ...` 仅保留底层诊断、迁移和尚未产品化的 API 调试,不是日常维护入口。
|
||||
|
||||
看到底层诊断返回 `404 {"error":"Todo Note is running in backend-only mode"}` 时**第一反应**是路径错(用了不存在的 REST 端点被 catch-all 兜底),不是 mode 锁了写;详见 `docs/reference/microservices.md` 的 Todo Note 段。
|
||||
|
||||
**Todo Note 错路径结构化诊断(issue #198)**:旧 Gitee/CC01 runtime 的 catch-all handler 会把未注册路径回成 `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,与 `TODO_NOTE_BACKEND_ONLY=1` 的真实语义(只关 Vite 前端)混淆。当前 vendored NC01 服务直接返回明确 route-not-found;backend-core/CLI 仍保留旧特征改写作为迁移兼容兜底:
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
推荐读取顺序如下:
|
||||
|
||||
- 当前待办:`bun scripts/cli.ts microservice proxy todo-note /api/instances`,必要时再查看具体实例。
|
||||
- 当前待办:`unidesk todo-note list`,必要时用 `unidesk todo-note show <列表>` 查看未完成项;维护使用 `add|update|complete|reopen|remind|delete` 资源命令,不手写 API path 或 JSON body。
|
||||
- 之前日程:先用 `bun scripts/cli.ts decision diary months` 和 `bun scripts/cli.ts decision diary list --month YYYY-MM` 找到日期,再用 `bun scripts/cli.ts decision diary show YYYY-MM-DD` 读取具体条目。
|
||||
- 系统定时日程:`bun scripts/cli.ts schedule list` 与 `bun scripts/cli.ts schedule runs --limit N`。
|
||||
|
||||
|
||||
+21
-5
@@ -10,6 +10,7 @@ import { checkHelp, parseCheckOptions, runChecks, runRecoveryGuardrailsCheck } f
|
||||
import { runSsh } from "./src/ssh";
|
||||
import { autoRemoteCiPublishUserServiceDryRunPlan, extractRemoteCliOptions, runRemoteCli } from "./src/remote";
|
||||
import { runMicroserviceCommand } from "./src/microservices";
|
||||
import { runTodoNoteCommand } from "./src/todo-note";
|
||||
import { runCodeQueueCommand } from "./src/code-queue";
|
||||
import { runDecisionCenterCommand } from "./src/decision-center";
|
||||
import { deployHelp, runCodeQueueDeployCompatCommand, runDeployCommand } from "./src/deploy";
|
||||
@@ -471,7 +472,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const config = readConfig();
|
||||
if (top === "microservice" && !args.includes("--check-path")) {
|
||||
if ((top === "microservice" && !args.includes("--check-path")) || top === "todo-note" || top === "todo") {
|
||||
const host = readHostK8sPublicHost();
|
||||
if (host !== null) {
|
||||
process.exitCode = await runRemoteCli({
|
||||
@@ -600,6 +601,19 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "todo-note" || top === "todo") {
|
||||
const result = await runTodoNoteCommand(config, args.slice(1));
|
||||
const ok = resultOk(result);
|
||||
if (isRenderedCliResult(result)) {
|
||||
emitText(result.renderedText, result.command || commandName);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
emitJson(commandName, result, ok);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "decision" || top === "decision-center") {
|
||||
const result = await runDecisionCenterCommand(config, args.slice(1));
|
||||
const ok = resultOk(result);
|
||||
@@ -754,7 +768,9 @@ async function main(): Promise<void> {
|
||||
throw new Error(`Unknown command: ${commandName}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
emitError(commandName, error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
if (import.meta.main && !process.execArgv.includes("--check")) {
|
||||
main().catch((error) => {
|
||||
emitError(commandName, error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export function rootHelp(): unknown {
|
||||
{ command: "microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--body-json JSON|--body-file path|--body-stdin] [--check-path] [--raw] [--max-body-bytes N]", description: "Access a private user-service backend path through the same frontend-only proxy used by WebUI; JSON request bodies are supported for controlled write/debug endpoints; --check-path (currently todo-note only) validates the path/method against the registered CLI-side endpoint catalog without contacting upstream." },
|
||||
{ command: "microservice diagnostics <id> [--full|--raw]", description: "Split k3sctl-managed proxy health into a compact summary by default; use --full/--raw for complete evidence." },
|
||||
{ command: "microservice tunnel-self-test <id>", description: "Trigger an expected provider HTTP tunnel failure and verify requestId/stage diagnostics are returned." },
|
||||
{ command: "todo-note list|show|add|update|complete|reopen|remind|delete|undo|redo|create|rename|delete-list|health", description: "Manage Todo Note through concise list/todo resource commands; default output is a bounded table and --json is explicit." },
|
||||
{ command: "decision upload <markdown-file> [--title text] [--type meeting|decision|goal|external_goal|internal_goal|blocker|debt|experiment] [--level|--priority G0|G1|G2|G3|P0|P1|P2|P3|none] [--doc-no DC-...] [--doc-type DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS] [--doc-priority P0|P1|P2|P3] [--signer text] [--issued-at ISO]", description: "Upload a meeting note or decision/requirement record through backend-core -> decision-center user-service proxy." },
|
||||
{ command: "decision diary import <markdown-file> [--source-file path] [--tag tag] [--include-entries]", description: "Import a dated work log Markdown into PostgreSQL diary entries split as YYYY-MM/YYYY-MM-DD.md." },
|
||||
{ command: "decision diary list [--month YYYY-MM] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--limit N] [--include-body]", description: "List daily Markdown diary entries stored by Decision Center." },
|
||||
|
||||
+21
-1
@@ -43,6 +43,7 @@ import {
|
||||
import { runApplyPatchV2, type ApplyPatchV2Executor } from "./apply-patch-v2";
|
||||
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexPrPreflightQueryAsync, codexQueuesQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync, codexUnreadTriageAsync } from "./code-queue";
|
||||
import { runDecisionCenterCommandAsync } from "./decision-center";
|
||||
import { runTodoNoteCommandAsync } from "./todo-note";
|
||||
import {
|
||||
artifactRegistryReadonlyResultFromCommand,
|
||||
buildArtifactRegistryReadonlyProbe,
|
||||
@@ -1621,7 +1622,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
emitRemoteJson(name, {
|
||||
transport: "frontend",
|
||||
baseUrl: session.baseUrl,
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "artifact-registry status|health", "ci publish-user-service --dry-run", "ci publish-backend-core --dry-run", "microservice list", "microservice status <id>", "microservice health <id>", "microservice diagnostics <id>", "microservice tunnel-self-test <id>", "microservice proxy <id> <path>", "decision upload <markdown-file>", "decision list", "decision show <id>", "codex task <taskId>", "codex tasks", "codex unread", "codex queues", "codex judge <taskId> --attempt N", "codex pr-preflight [--remote]", "network perf"],
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "artifact-registry status|health", "ci publish-user-service --dry-run", "ci publish-backend-core --dry-run", "microservice list", "microservice status <id>", "microservice health <id>", "microservice diagnostics <id>", "microservice tunnel-self-test <id>", "microservice proxy <id> <path>", "todo-note list|show|add|update|complete|reopen|remind|delete|undo|redo", "decision upload <markdown-file>", "decision list", "decision show <id>", "codex task <taskId>", "codex tasks", "codex unread", "codex queues", "codex judge <taskId> --attempt N", "codex pr-preflight [--remote]", "network perf"],
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
@@ -1643,6 +1644,25 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
emitRemoteJson(name, result, ok);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
if (top === "todo-note" || top === "todo") {
|
||||
const fetcher = (path: string, init?: { method?: string; body?: unknown }): Promise<FetchJsonResult> => {
|
||||
const requestInit = init === undefined
|
||||
? undefined
|
||||
: {
|
||||
method: init.method,
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
};
|
||||
return frontendJson(session, path, requestInit, 30_000, 5_000_000);
|
||||
};
|
||||
const result = await runTodoNoteCommandAsync(config, args.slice(1), fetcher);
|
||||
const ok = typeof result !== "object" || result === null || !("ok" in result) || (result as { ok?: unknown }).ok !== false;
|
||||
if (typeof result === "object" && result !== null && "renderedText" in result && typeof (result as { renderedText?: unknown }).renderedText === "string") {
|
||||
process.stdout.write((result as { renderedText: string }).renderedText);
|
||||
} else {
|
||||
emitRemoteJson(name, result, ok);
|
||||
}
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
if (top === "artifact-registry") {
|
||||
emitRemoteJson(name, await remoteArtifactRegistry(session, args));
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { coreInternalFetch } from "./microservices";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
|
||||
interface TodoInstanceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
todoCount: number;
|
||||
completedCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TodoItem {
|
||||
id: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
reminderAt: string | null;
|
||||
updatedAt: string;
|
||||
children: TodoItem[];
|
||||
}
|
||||
|
||||
interface TodoInstance {
|
||||
id: string;
|
||||
name: string;
|
||||
historyPointer: number;
|
||||
updatedAt: string;
|
||||
todos: TodoItem[];
|
||||
}
|
||||
|
||||
interface FlatTodo {
|
||||
item: TodoItem;
|
||||
depth: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
type TodoNoteFetcher = (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function stringField(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function option(args: string[], name: string): string | null {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return null;
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function hasFlag(args: string[], name: string): boolean {
|
||||
return args.includes(name);
|
||||
}
|
||||
|
||||
function positiveIntegerOption(args: string[], name: string, fallback: number): number {
|
||||
const raw = option(args, name);
|
||||
if (raw === null) return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function errorMessage(body: unknown): string {
|
||||
const record = asRecord(body);
|
||||
return typeof record?.error === "string" ? record.error : typeof record?.message === "string" ? record.message : "request failed";
|
||||
}
|
||||
|
||||
async function request(fetcher: TodoNoteFetcher, path: string, init?: { method?: string; body?: unknown }): Promise<unknown> {
|
||||
const response = asRecord(await fetcher(`/api/microservices/todo-note/proxy${path}`, init));
|
||||
if (response === null) throw new Error(`todo-note ${path} returned an invalid response`);
|
||||
const body = response.body;
|
||||
if (response.ok !== true || asRecord(body)?.ok === false) {
|
||||
throw new Error(`todo-note ${init?.method ?? "GET"} ${path} failed (${String(response.status ?? "unknown")}): ${errorMessage(body)}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function listInstances(fetcher: TodoNoteFetcher): Promise<TodoInstanceSummary[]> {
|
||||
const body = asRecord(await request(fetcher, "/api/instances"));
|
||||
const rows = Array.isArray(body?.instances) ? body.instances : [];
|
||||
return rows.map((value, index) => {
|
||||
const row = asRecord(value);
|
||||
if (row === null) throw new Error(`todo-note instances[${index}] must be an object`);
|
||||
return {
|
||||
id: stringField(row.id, `instances[${index}].id`),
|
||||
name: stringField(row.name, `instances[${index}].name`),
|
||||
todoCount: Number(row.todoCount ?? 0),
|
||||
completedCount: Number(row.completedCount ?? 0),
|
||||
updatedAt: typeof row.updatedAt === "string" ? row.updatedAt : "-",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveInstance(fetcher: TodoNoteFetcher, token: string | undefined): Promise<TodoInstanceSummary> {
|
||||
if (!token) throw new Error("todo-note command requires a list name or instance id");
|
||||
const rows = await listInstances(fetcher);
|
||||
const exact = rows.filter((row) => row.id === token || row.name === token);
|
||||
if (exact.length === 1) return exact[0];
|
||||
const folded = rows.filter((row) => row.name.toLocaleLowerCase() === token.toLocaleLowerCase());
|
||||
if (folded.length === 1) return folded[0];
|
||||
if (exact.length > 1 || folded.length > 1) throw new Error(`todo-note list is ambiguous: ${token}`);
|
||||
throw new Error(`todo-note list not found: ${token}; available: ${rows.map((row) => row.name).join(", ")}`);
|
||||
}
|
||||
|
||||
function parseTodo(value: unknown, label: string): TodoItem {
|
||||
const row = asRecord(value);
|
||||
if (row === null) throw new Error(`${label} must be an object`);
|
||||
return {
|
||||
id: stringField(row.id, `${label}.id`),
|
||||
title: stringField(row.title, `${label}.title`),
|
||||
completed: row.completed === true,
|
||||
reminderAt: typeof row.reminderAt === "string" ? row.reminderAt : null,
|
||||
updatedAt: typeof row.updatedAt === "string" ? row.updatedAt : "-",
|
||||
children: Array.isArray(row.children) ? row.children.map((child, index) => parseTodo(child, `${label}.children[${index}]`)) : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function getInstance(fetcher: TodoNoteFetcher, summary: TodoInstanceSummary): Promise<TodoInstance> {
|
||||
const body = asRecord(await request(fetcher, `/api/instances/${encodeURIComponent(summary.id)}`));
|
||||
if (body === null) throw new Error(`todo-note list ${summary.name} returned an invalid body`);
|
||||
return {
|
||||
id: stringField(body.id, "instance.id"),
|
||||
name: stringField(body.name, "instance.name"),
|
||||
historyPointer: Number(body.historyPointer ?? 0),
|
||||
updatedAt: typeof body.updatedAt === "string" ? body.updatedAt : "-",
|
||||
todos: Array.isArray(body.todos) ? body.todos.map((todo, index) => parseTodo(todo, `todos[${index}]`)) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function flattenTodos(items: TodoItem[], depth = 0, parents: string[] = []): FlatTodo[] {
|
||||
const rows: FlatTodo[] = [];
|
||||
for (const item of items) {
|
||||
const path = [...parents, item.title];
|
||||
rows.push({ item, depth, path: path.join(" > ") });
|
||||
rows.push(...flattenTodos(item.children, depth + 1, path));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function resolveTodo(instance: TodoInstance, token: string | undefined): FlatTodo {
|
||||
if (!token) throw new Error("todo-note command requires a todo id or exact title");
|
||||
const rows = flattenTodos(instance.todos);
|
||||
const exact = rows.filter((row) => row.item.id === token || row.item.title === token);
|
||||
if (exact.length === 1) return exact[0];
|
||||
const folded = rows.filter((row) => row.item.title.toLocaleLowerCase() === token.toLocaleLowerCase());
|
||||
if (folded.length === 1) return folded[0];
|
||||
if (exact.length > 1 || folded.length > 1) throw new Error(`todo-note title is ambiguous; use todo id: ${token}`);
|
||||
throw new Error(`todo-note item not found in ${instance.name}: ${token}`);
|
||||
}
|
||||
|
||||
async function applyAction(fetcher: TodoNoteFetcher, instanceId: string, action: Record<string, unknown>): Promise<TodoInstance> {
|
||||
const body = await request(fetcher, `/api/instances/${encodeURIComponent(instanceId)}/actions`, { method: "POST", body: { action } });
|
||||
const record = asRecord(body);
|
||||
if (record === null) throw new Error("todo-note action returned an invalid instance");
|
||||
const summary: TodoInstanceSummary = {
|
||||
id: stringField(record.id, "instance.id"),
|
||||
name: stringField(record.name, "instance.name"),
|
||||
todoCount: 0,
|
||||
completedCount: 0,
|
||||
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : "-",
|
||||
};
|
||||
return {
|
||||
...summary,
|
||||
historyPointer: Number(record.historyPointer ?? 0),
|
||||
todos: Array.isArray(record.todos) ? record.todos.map((todo, index) => parseTodo(todo, `todos[${index}]`)) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function short(value: string, max = 64): string {
|
||||
const compact = value.replace(/\s+/gu, " ").trim();
|
||||
return compact.length <= max ? compact : `${compact.slice(0, Math.max(1, max - 3))}...`;
|
||||
}
|
||||
|
||||
function timeText(value: string | null): string {
|
||||
if (value === null) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date).replaceAll("/", "-");
|
||||
}
|
||||
|
||||
function displayWidth(value: string): number {
|
||||
return Array.from(value).reduce((width, character) => width + ((character.codePointAt(0) ?? 0) > 0xff ? 2 : 1), 0);
|
||||
}
|
||||
|
||||
function table(headers: string[], rows: string[][]): string[] {
|
||||
const widths = headers.map((header, index) => Math.max(displayWidth(header), ...rows.map((row) => displayWidth(row[index] ?? ""))));
|
||||
const line = (row: string[]) => row.map((cell, index) => `${cell}${" ".repeat(Math.max(0, (widths[index] ?? 0) - displayWidth(cell)))}`).join(" ").trimEnd();
|
||||
return [line(headers), widths.map((width) => "-".repeat(width)).join(" "), ...rows.map(line)];
|
||||
}
|
||||
|
||||
function rendered(command: string, lines: string[], ok = true): RenderedCliResult {
|
||||
return { ok, command, renderedText: `${lines.join("\n")}\n`, contentType: "text/plain" };
|
||||
}
|
||||
|
||||
function mutationResult(command: string, action: string, instance: TodoInstance, todo: FlatTodo | null, changed = true): RenderedCliResult {
|
||||
return rendered(command, [
|
||||
`TODO NOTE ${action.toUpperCase()}`,
|
||||
...table(["LIST", "TODO", "STATE", "CHANGED", "TITLE"], [[
|
||||
instance.name,
|
||||
todo?.item.id ?? "-",
|
||||
todo === null ? "-" : todo.item.completed ? "completed" : "open",
|
||||
String(changed),
|
||||
short(todo?.item.title ?? instance.name, 72),
|
||||
]]),
|
||||
]);
|
||||
}
|
||||
|
||||
function help(): RenderedCliResult {
|
||||
return rendered("todo-note help", [
|
||||
"TODO NOTE",
|
||||
" unidesk todo-note list [--json]",
|
||||
" unidesk todo-note show <list|instance-id> [--all] [--limit N] [--json]",
|
||||
" unidesk todo-note add <list> --title <text> [--parent <todo-id>]",
|
||||
" unidesk todo-note update <list> <todo-id|exact-title> --title <text>",
|
||||
" unidesk todo-note complete|reopen <list> <todo-id|exact-title>",
|
||||
" unidesk todo-note remind <list> <todo-id|exact-title> (--at <ISO>|--clear)",
|
||||
" unidesk todo-note delete <list> <todo-id|exact-title> --confirm",
|
||||
" unidesk todo-note undo|redo <list>",
|
||||
" unidesk todo-note create <name>",
|
||||
" unidesk todo-note rename <list> --name <name>",
|
||||
" unidesk todo-note delete-list <list> --confirm",
|
||||
" unidesk todo-note health [--json]",
|
||||
]);
|
||||
}
|
||||
|
||||
export async function runTodoNoteCommand(_config: UniDeskConfig, args: string[]): Promise<unknown> {
|
||||
const fetcher: TodoNoteFetcher = async (path, init) => coreInternalFetch(path, {
|
||||
method: init?.method,
|
||||
body: init?.body,
|
||||
maxResponseBytes: 5_000_000,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
return runTodoNoteCommandAsync(_config, args, fetcher);
|
||||
}
|
||||
|
||||
export async function runTodoNoteCommandAsync(_config: UniDeskConfig, args: string[], fetcher: TodoNoteFetcher): Promise<unknown> {
|
||||
try {
|
||||
return await runTodoNoteCommandInner(args, fetcher);
|
||||
} catch (error) {
|
||||
if (hasFlag(args, "--json")) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return rendered("todo-note error", ["TODO NOTE ERROR", ` ${message}`, " help: unidesk todo-note --help"], false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTodoNoteCommandInner(args: string[], fetcher: TodoNoteFetcher): Promise<unknown> {
|
||||
const [action = "list", listToken, todoToken] = args;
|
||||
const json = hasFlag(args, "--json");
|
||||
if (action === "help" || action === "--help" || action === "-h") return help();
|
||||
|
||||
if (action === "list") {
|
||||
const rows = await listInstances(fetcher);
|
||||
if (json) return { ok: true, instances: rows };
|
||||
return rendered("todo-note list", [
|
||||
"TODO NOTE LISTS",
|
||||
...table(["LIST", "OPEN", "DONE", "TOTAL", "ID"], rows.map((row) => [
|
||||
row.name,
|
||||
String(row.todoCount - row.completedCount),
|
||||
String(row.completedCount),
|
||||
String(row.todoCount),
|
||||
row.id,
|
||||
])),
|
||||
]);
|
||||
}
|
||||
|
||||
if (action === "health") {
|
||||
const response = asRecord(await fetcher("/api/microservices/todo-note/health"));
|
||||
const body = asRecord(response?.body);
|
||||
if (response?.ok !== true || body?.ok !== true) throw new Error(`todo-note health failed: ${errorMessage(response?.body)}`);
|
||||
if (json) return { ok: true, health: body };
|
||||
const storage = asRecord(body.storage);
|
||||
const config = asRecord(storage?.config);
|
||||
const verification = asRecord(storage?.verification);
|
||||
return rendered("todo-note health", [
|
||||
"TODO NOTE HEALTH",
|
||||
...table(["STATUS", "STORAGE", "INSTANCES", "DB/GIT", "HEAD"], [[
|
||||
String(body.status ?? "-"),
|
||||
String(config?.primary ?? "-"),
|
||||
String(verification?.instances ?? "-"),
|
||||
verification?.databaseHash === verification?.gitHash ? "aligned" : "mismatch",
|
||||
String(asRecord(config?.head)?.shortCommit ?? "-"),
|
||||
]]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (action === "create") {
|
||||
const name = option(args, "--name") ?? listToken;
|
||||
if (!name) throw new Error("todo-note create requires a name or --name <text>");
|
||||
const body = asRecord(await request(fetcher, "/api/instances", { method: "POST", body: { name } }));
|
||||
if (body === null) throw new Error("todo-note create returned an invalid instance");
|
||||
const created = await getInstance(fetcher, { id: stringField(body.id, "instance.id"), name: stringField(body.name, "instance.name"), todoCount: 0, completedCount: 0, updatedAt: String(body.updatedAt ?? "-") });
|
||||
return json ? { ok: true, instance: created } : mutationResult("todo-note create", "create", created, null);
|
||||
}
|
||||
|
||||
const summary = await resolveInstance(fetcher, listToken);
|
||||
const instance = await getInstance(fetcher, summary);
|
||||
|
||||
if (action === "show") {
|
||||
const all = hasFlag(args, "--all");
|
||||
const limit = positiveIntegerOption(args, "--limit", 100);
|
||||
const rows = flattenTodos(instance.todos).filter((row) => all || !row.item.completed).slice(0, limit);
|
||||
if (json) return { ok: true, instance, items: rows.map((row) => ({ ...row.item, depth: row.depth, path: row.path })) };
|
||||
return rendered("todo-note show", [
|
||||
`TODO NOTE ${instance.name}`,
|
||||
`OPEN ${flattenTodos(instance.todos).filter((row) => !row.item.completed).length} SHOWN ${rows.length} HISTORY ${instance.historyPointer}`,
|
||||
...table(["STATE", "ID", "REMINDER", "TITLE"], rows.map((row) => [
|
||||
row.item.completed ? "done" : "open",
|
||||
row.item.id,
|
||||
timeText(row.item.reminderAt),
|
||||
`${" ".repeat(row.depth)}${short(row.item.title, 76)}`,
|
||||
])),
|
||||
]);
|
||||
}
|
||||
|
||||
if (action === "undo" || action === "redo") {
|
||||
const body = asRecord(await request(fetcher, `/api/instances/${encodeURIComponent(instance.id)}/${action}`, { method: "POST", body: {} }));
|
||||
if (body === null) throw new Error(`todo-note ${action} returned an invalid instance`);
|
||||
const updated = await getInstance(fetcher, { ...summary, updatedAt: String(body.updatedAt ?? summary.updatedAt) });
|
||||
return json ? { ok: true, instance: updated } : mutationResult(`todo-note ${action}`, action, updated, null);
|
||||
}
|
||||
|
||||
if (action === "rename") {
|
||||
const name = option(args, "--name");
|
||||
if (!name) throw new Error("todo-note rename requires --name <text>");
|
||||
const updated = await applyAction(fetcher, instance.id, { type: "renameInstance", name });
|
||||
return json ? { ok: true, instance: updated } : mutationResult("todo-note rename", "rename", updated, null);
|
||||
}
|
||||
|
||||
if (action === "delete-list") {
|
||||
if (!hasFlag(args, "--confirm")) throw new Error("todo-note delete-list requires --confirm");
|
||||
await request(fetcher, `/api/instances/${encodeURIComponent(instance.id)}`, { method: "DELETE" });
|
||||
return json ? { ok: true, deleted: true, instance: summary } : rendered("todo-note delete-list", [
|
||||
"TODO NOTE DELETE LIST",
|
||||
...table(["LIST", "ID", "DELETED"], [[summary.name, summary.id, "true"]]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (action === "add") {
|
||||
const title = option(args, "--title");
|
||||
if (!title) throw new Error("todo-note add requires --title <text>");
|
||||
const beforeIds = new Set(flattenTodos(instance.todos).map((row) => row.item.id));
|
||||
const parentToken = option(args, "--parent");
|
||||
const parentId = parentToken === null ? undefined : resolveTodo(instance, parentToken).item.id;
|
||||
const updated = await applyAction(fetcher, instance.id, { type: "addTodo", title, ...(parentId ? { parentId } : {}) });
|
||||
const added = flattenTodos(updated.todos).find((row) => !beforeIds.has(row.item.id)) ?? null;
|
||||
if (added === null) throw new Error("todo-note add succeeded but the new todo was not found");
|
||||
return json ? { ok: true, instance: updated, todo: added.item } : mutationResult("todo-note add", "add", updated, added);
|
||||
}
|
||||
|
||||
const selected = resolveTodo(instance, todoToken);
|
||||
|
||||
if (action === "update") {
|
||||
const title = option(args, "--title");
|
||||
if (!title) throw new Error("todo-note update requires --title <text>");
|
||||
const updated = await applyAction(fetcher, instance.id, { type: "updateTodoTitle", todoId: selected.item.id, title });
|
||||
const changed = resolveTodo(updated, selected.item.id);
|
||||
return json ? { ok: true, instance: updated, todo: changed.item } : mutationResult("todo-note update", "update", updated, changed);
|
||||
}
|
||||
|
||||
if (action === "complete" || action === "done" || action === "reopen") {
|
||||
const desiredCompleted = action !== "reopen";
|
||||
if (selected.item.completed === desiredCompleted) {
|
||||
return json
|
||||
? { ok: true, changed: false, instance, todo: selected.item }
|
||||
: mutationResult(`todo-note ${action}`, action, instance, selected, false);
|
||||
}
|
||||
const updated = await applyAction(fetcher, instance.id, { type: "toggleTodoCompleted", todoId: selected.item.id });
|
||||
const changed = resolveTodo(updated, selected.item.id);
|
||||
return json ? { ok: true, changed: true, instance: updated, todo: changed.item } : mutationResult(`todo-note ${action}`, action, updated, changed);
|
||||
}
|
||||
|
||||
if (action === "remind") {
|
||||
const clear = hasFlag(args, "--clear");
|
||||
const reminderAt = clear ? null : option(args, "--at");
|
||||
if (!clear && reminderAt === null) throw new Error("todo-note remind requires --at <ISO> or --clear");
|
||||
if (reminderAt !== null && Number.isNaN(new Date(reminderAt).getTime())) throw new Error("--at must be a valid ISO date/time");
|
||||
const updated = await applyAction(fetcher, instance.id, { type: "setTodoReminder", todoId: selected.item.id, reminderAt });
|
||||
const changed = resolveTodo(updated, selected.item.id);
|
||||
return json ? { ok: true, instance: updated, todo: changed.item } : mutationResult("todo-note remind", "remind", updated, changed);
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
if (!hasFlag(args, "--confirm")) throw new Error("todo-note delete requires --confirm");
|
||||
const updated = await applyAction(fetcher, instance.id, { type: "deleteTodo", todoId: selected.item.id });
|
||||
return json ? { ok: true, deleted: true, instance: updated, todo: selected.item } : rendered("todo-note delete", [
|
||||
"TODO NOTE DELETE",
|
||||
...table(["LIST", "TODO", "DELETED", "TITLE"], [[instance.name, selected.item.id, "true", short(selected.item.title, 72)]]),
|
||||
]);
|
||||
}
|
||||
|
||||
throw new Error("todo-note command must be one of: list, show, add, update, complete, reopen, remind, delete, undo, redo, create, rename, delete-list, health, help");
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
self_repo=$(CDPATH= cd -- "$self_dir/.." && pwd)
|
||||
repo=${UNIDESK_REPO_ROOT:-$self_repo}
|
||||
if [ ! -f "$repo/scripts/cli.ts" ]; then repo=/root/unidesk; fi
|
||||
|
||||
exec bun "$repo/scripts/cli.ts" "$@"
|
||||
Reference in New Issue
Block a user