docs: preserve parallel UniDesk updates

This commit is contained in:
Codex
2026-06-04 10:21:52 +00:00
parent 85b93d2cba
commit c5d85d5409
7 changed files with 320 additions and 45 deletions
+152 -1
View File
@@ -4,6 +4,138 @@ import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot } from "./config";
import { jsonByteLength, previewJson } from "./preview";
// Todo Note misleading-404 rewrite (issue #198) — CLI-side interim workaround
// for the upstream todo_note catch-all handler. The upstream repo
// (https://gitee.com/Lyon1998/todo_note) wraps every unregistered path in
// `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,
// which makes agents and humans misdiagnose a path typo as a write lock.
// When the upstream fix lands, this rewrite becomes a no-op: the upstream
// body will not match `isTodoNoteMisleadingRouteNotFoundBody` anymore and
// the CLI will pass the upstream response through unchanged.
//
// Source of truth for the writable endpoint list below is
// docs/reference/microservices.md "Todo Note On Main Server" and
// apps/server/src/server.ts in the upstream todo_note repo. If the
// upstream registered routes change, update TODO_NOTE_WRITABLE_ENDPOINTS
// and TODO_NOTE_ACTION_TYPES in lock-step.
interface TodoNoteEndpoint {
method: string;
path: string;
hint: string;
}
export const TODO_NOTE_ACTION_TYPES = [
"addTodo",
"updateTodoTitle",
"toggleTodoCompleted",
"toggleTodoExpanded",
"setAllTodosExpanded",
"moveTodo",
"deleteTodo",
"renameInstance",
"setTodoReminder",
] as const;
export const TODO_NOTE_WRITABLE_ENDPOINTS: readonly TodoNoteEndpoint[] = [
{ method: "POST", path: "/api/instances", hint: "Create a new todo list; body {name}." },
{ method: "DELETE", path: "/api/instances/:instanceId", hint: "Delete a todo list." },
{
method: "POST",
path: "/api/instances/:instanceId/actions",
hint: "Apply a typed action; body {action: {type, ...}}. Use the action queue pattern, not REST collection paths like /api/instances/:id/todos.",
},
{ method: "POST", path: "/api/instances/:instanceId/undo", hint: "Undo the last applied action." },
{ method: "POST", path: "/api/instances/:instanceId/redo", hint: "Redo the last undone action." },
];
function isTodoNoteMisleadingRouteNotFoundBody(body: unknown): boolean {
if (typeof body !== "object" || body === null || Array.isArray(body)) return false;
const record = body as Record<string, unknown>;
return record.error === "Todo Note is running in backend-only mode" && record.ok === false;
}
function splitPathQuery(path: string): string {
const questionIndex = path.indexOf("?");
return questionIndex === -1 ? path : path.slice(0, questionIndex);
}
export function matchTodoNoteEndpoint(method: string, path: string): TodoNoteEndpoint | null {
const upperMethod = method.toUpperCase();
const targetSegments = splitPathQuery(path).split("/").filter(Boolean);
for (const endpoint of TODO_NOTE_WRITABLE_ENDPOINTS) {
if (endpoint.method !== upperMethod) continue;
const patternSegments = endpoint.path.split("/").filter(Boolean);
if (patternSegments.length !== targetSegments.length) continue;
let matched = true;
for (let i = 0; i < patternSegments.length; i++) {
const pattern = patternSegments[i];
const actual = targetSegments[i];
if (pattern.startsWith(":")) continue;
if (pattern !== actual) { matched = false; break; }
}
if (matched) return endpoint;
}
return null;
}
export function buildTodoNoteRouteNotFoundDiagnostic(method: string, path: string): Record<string, unknown> {
return {
ok: false,
error: "Todo Note route not found",
backendOnly: true,
method,
path,
writableApiEndpoints: TODO_NOTE_WRITABLE_ENDPOINTS.map((endpoint) => ({ method: endpoint.method, path: endpoint.path, hint: endpoint.hint })),
actionTypes: [...TODO_NOTE_ACTION_TYPES],
hint: "Write operations use the action queue pattern: POST /api/instances/:instanceId/actions with body {action: {type, ...}}. See docs/reference/microservices.md (Todo Note On Main Server) and the upstream apps/server/src/server.ts:97-126 for the registered action types. The CLI rewrote this 404 because the upstream catch-all body was misleading (issue #198).",
issueReference: "pikasTech/unidesk#198",
};
}
export function rewriteTodoNoteMisleadingRouteNotFound(serviceId: string, method: string, path: string, response: unknown): unknown {
if (serviceId !== "todo-note") return response;
if (typeof response !== "object" || response === null || Array.isArray(response)) return response;
const record = response as Record<string, unknown>;
if (record.status !== 404) return response;
if (!isTodoNoteMisleadingRouteNotFoundBody(record.body)) return response;
const diagnostic = buildTodoNoteRouteNotFoundDiagnostic(method, path);
return {
...record,
body: diagnostic,
bodyRewritten: true,
rewriteReason: "Todo Note catch-all 404 body was misleading; CLI wrapped it with a structured route diagnostic. See docs/reference/cli.md and pikasTech/unidesk issue #198 for context.",
upstreamBody: record.body,
};
}
export function runTodoNoteCheckPath(method: string, path: string): Record<string, unknown> {
const matched = matchTodoNoteEndpoint(method, path);
if (matched) {
return {
ok: true,
service: "todo-note",
method,
path,
matched: true,
endpoint: { method: matched.method, path: matched.path, hint: matched.hint },
hint: "Path and method match a registered Todo Note write endpoint; safe to send the request.",
};
}
return {
ok: false,
command: `microservice proxy todo-note ${path} --method ${method} --check-path`,
data: {
ok: false,
status: 404,
method,
path,
checkPath: true,
...buildTodoNoteRouteNotFoundDiagnostic(method, path),
},
};
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
@@ -840,9 +972,28 @@ export async function runMicroserviceCommand(_config: UniDeskConfig, args: strin
const body = requestBodyOption(args);
const full = hasFlag(args, "--full");
const raw = hasFlag(args, "--raw");
const checkPath = hasFlag(args, "--check-path");
const method = methodOption(args, body !== undefined);
if (checkPath) {
if (id !== "todo-note") {
return {
ok: false,
command: `microservice proxy ${id} ${path} --method ${method} --check-path`,
data: {
ok: false,
error: `--check-path currently only supports service=todo-note; got ${id}`,
supportedServices: ["todo-note"],
hint: "Open docs/reference/cli.md (microservice proxy --check-path) for the supported endpoint catalog. Other services do not yet ship a CLI-side writable endpoint list; file a follow-up if you need one.",
},
};
}
return runTodoNoteCheckPath(method, path);
}
const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000);
const maxResponseBytes = full ? Math.min(Math.max(maxBodyBytes, 120_000), 5_000_000) : Math.min(Math.max(maxBodyBytes * 3, 240_000), 1_500_000);
return summarizeMicroserviceProxyResponse(coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method: methodOption(args, body !== undefined), body, maxResponseBytes }), args);
const fetched = coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method, body, maxResponseBytes });
const rewritten = rewriteTodoNoteMisleadingRouteNotFound(id, method, path, fetched);
return summarizeMicroserviceProxyResponse(rewritten, args);
}
throw new Error("microservice command must be one of: list, status, health, diagnostics, tunnel-self-test, proxy");
}