mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
e1ce7dabe1
Favicon style switcher in Settings > Theme: the Totman mascot or the historical dark-navy P tile (byte-identical to the pre-Totman asset, sha256 pinned). Served server-side from first paint in both runtimes; opt-in for hosts of the published UI package. Contributed by @FNDEVVE
334 lines
13 KiB
TypeScript
334 lines
13 KiB
TypeScript
/**
|
|
* Shared route handlers used by plan, review, and annotate servers.
|
|
*
|
|
* Eliminates duplication of /api/image, /api/upload, /api/draft, unmatched API
|
|
* responses, and the server-ready handler across all three server files. Also
|
|
* shares /api/agents for plan + review.
|
|
*/
|
|
|
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
import { dirname } from "node:path";
|
|
import { openBrowser as openBrowserImpl } from "./browser";
|
|
import { isUrlHostOverridden } from "./remote";
|
|
import { writeUrlQr } from "./qr";
|
|
import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image";
|
|
import { saveDraft, loadDraft, deleteDraft, getDraftGeneration } from "./draft";
|
|
import { CLASSIC_FAVICON_SVG, FAVICON_PNG_BYTES } from "@plannotator/shared/favicon";
|
|
import { getServerConfig } from "./config";
|
|
import { saveToObsidian, saveToBear, saveToOctarine } from "./integrations";
|
|
import type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult } from "./integrations";
|
|
import { listReferenceSkills, readReferenceSkillContent } from "./review-skill-loader";
|
|
|
|
function normalizeDraftGeneration(value: unknown): number | undefined {
|
|
if (typeof value !== "number") return undefined;
|
|
return Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
}
|
|
|
|
export function readDraftGenerationFromUrl(req: Request): number | undefined {
|
|
const url = new URL(req.url);
|
|
const raw = url.searchParams.get("generation") ?? url.searchParams.get("draftGeneration");
|
|
if (raw === null) return undefined;
|
|
const value = Number(raw);
|
|
return normalizeDraftGeneration(value);
|
|
}
|
|
|
|
export function readDraftGenerationFromBody(body: unknown): number | undefined {
|
|
if (!body || typeof body !== "object") return undefined;
|
|
return normalizeDraftGeneration((body as { draftGeneration?: unknown }).draftGeneration);
|
|
}
|
|
|
|
/** Serve images from local paths or temp uploads. Used by all 3 servers. */
|
|
export async function handleImage(req: Request): Promise<Response> {
|
|
const url = new URL(req.url);
|
|
const imagePath = url.searchParams.get("path");
|
|
if (!imagePath) {
|
|
return new Response("Missing path parameter", { status: 400 });
|
|
}
|
|
const validation = validateImagePath(imagePath);
|
|
if (!validation.valid) {
|
|
return new Response(validation.error!, { status: 403 });
|
|
}
|
|
try {
|
|
const file = Bun.file(validation.resolved);
|
|
if (await file.exists()) {
|
|
return new Response(file);
|
|
}
|
|
// If not found and a base directory is provided, try resolving relative to it
|
|
const base = url.searchParams.get("base");
|
|
if (base && !imagePath.startsWith("/")) {
|
|
const { resolve: resolvePath } = await import("path");
|
|
const fromBase = resolvePath(base, imagePath);
|
|
const baseValidation = validateImagePath(fromBase);
|
|
if (baseValidation.valid) {
|
|
const baseFile = Bun.file(baseValidation.resolved);
|
|
if (await baseFile.exists()) {
|
|
return new Response(baseFile);
|
|
}
|
|
}
|
|
}
|
|
return new Response("File not found", { status: 404 });
|
|
} catch {
|
|
return new Response("Failed to read file", { status: 500 });
|
|
}
|
|
}
|
|
|
|
/** Upload image to temp dir, return path. Used by all 3 servers. */
|
|
export async function handleUpload(req: Request): Promise<Response> {
|
|
try {
|
|
const formData = await req.formData();
|
|
const file = formData.get("file") as File;
|
|
if (!file) {
|
|
return new Response("No file provided", { status: 400 });
|
|
}
|
|
|
|
const extResult = validateUploadExtension(file.name);
|
|
if (!extResult.valid) {
|
|
return Response.json({ error: extResult.error }, { status: 400 });
|
|
}
|
|
mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`;
|
|
|
|
await Bun.write(tempPath, file);
|
|
return Response.json({ path: tempPath, originalName: file.name });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Upload failed";
|
|
return Response.json({ error: message }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/** OpenCode agent client interface (subset of OpenCode SDK) */
|
|
export interface OpencodeClient {
|
|
app: {
|
|
agents: (options?: object) => Promise<{
|
|
data?: Array<{ name: string; description?: string; mode: string; hidden?: boolean }>;
|
|
}>;
|
|
};
|
|
}
|
|
|
|
/** List available agents. Used by plan + review servers (OpenCode only). */
|
|
export async function handleAgents(opencodeClient?: OpencodeClient): Promise<Response> {
|
|
if (!opencodeClient) {
|
|
return Response.json({ agents: [] });
|
|
}
|
|
|
|
try {
|
|
const result = await opencodeClient.app.agents({});
|
|
const agents = (result.data ?? [])
|
|
.filter((a) => a.mode === "primary" && !a.hidden)
|
|
.map((a) => ({ id: a.name, name: a.name, description: a.description }));
|
|
|
|
return Response.json({ agents });
|
|
} catch {
|
|
return Response.json({ agents: [], error: "Failed to fetch agents" });
|
|
}
|
|
}
|
|
|
|
/** Save annotation draft. Used by all 3 servers. */
|
|
export async function handleDraftSave(req: Request, contentKey: string): Promise<Response> {
|
|
try {
|
|
const body = await req.json();
|
|
saveDraft(contentKey, body);
|
|
return Response.json({ ok: true });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Failed to save draft";
|
|
console.error(`[draft] save failed: ${message}`);
|
|
return Response.json({ error: message }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/** Load annotation draft. Used by all 3 servers. */
|
|
export function handleDraftLoad(contentKey: string): Response {
|
|
const draft = loadDraft(contentKey);
|
|
if (!draft) {
|
|
const draftGeneration = getDraftGeneration(contentKey);
|
|
return Response.json(
|
|
{ found: false, ...(draftGeneration !== null ? { draftGeneration } : {}) },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
return Response.json(draft);
|
|
}
|
|
|
|
/** Delete annotation draft. Used by all 3 servers. */
|
|
export function handleDraftDelete(contentKey: string, req?: Request): Response {
|
|
deleteDraft(contentKey, req ? readDraftGenerationFromUrl(req) : undefined);
|
|
return Response.json({ ok: true });
|
|
}
|
|
|
|
/**
|
|
* List global agent skills for comment skill references. Used by plan +
|
|
* annotate servers. Takes no client input (fixed roots only) and degrades to an
|
|
* empty catalog on any failure so the composer never breaks.
|
|
*/
|
|
export function handleReferenceSkills(): Response {
|
|
try {
|
|
return Response.json({ skills: listReferenceSkills() });
|
|
} catch (err) {
|
|
console.error(
|
|
`[plannotator] Skill catalog failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
return Response.json({ skills: [] });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serve a referenced skill's SKILL.md contents for feedback injection
|
|
* (`?name=<skill>`). Used by plan + annotate servers. The name is matched
|
|
* against discovered skills only — it is never used as a path — so traversal
|
|
* and absolute-path inputs answer 404, never a file outside the skill roots.
|
|
*/
|
|
export function handleReferenceSkillContent(req: Request): Response {
|
|
try {
|
|
const name = new URL(req.url).searchParams.get("name") ?? "";
|
|
const skill = readReferenceSkillContent(name);
|
|
if (!skill) {
|
|
return Response.json({ error: "Skill not found" }, { status: 404 });
|
|
}
|
|
return Response.json({ skill });
|
|
} catch (err) {
|
|
console.error(
|
|
`[plannotator] Skill content failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
return Response.json({ error: "Skill content failed" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/** Return the shared JSON response for an unmatched API route. */
|
|
export function handleApiNotFound(path: string): Response {
|
|
return Response.json({ error: "Not found", path }, { status: 404 });
|
|
}
|
|
|
|
/**
|
|
* Serve the app favicon. Used by all 3 servers (plus goal-setup).
|
|
*
|
|
* Reads the persisted style so a classic user's first painted frame is already
|
|
* classic. Without this the static route always answered Totman and the
|
|
* preference only landed once React mounted, i.e. a visible flash on every load.
|
|
*
|
|
* The route is one URL with two possible payloads, so the response declares its
|
|
* real type and is not cached: the entry HTML's <link rel="icon"> deliberately
|
|
* carries no `type`/`sizes` (see apps/hook/index.html), leaving Content-Type the
|
|
* single source of truth, and a day-long cache would re-paint the old icon after
|
|
* a switch. loadConfig() is a small unmemoized JSON read, once per page load.
|
|
*/
|
|
export function handleFavicon(): Response {
|
|
if (getServerConfig(null).favicon === "classic") {
|
|
return new Response(CLASSIC_FAVICON_SVG, {
|
|
headers: { "Content-Type": "image/svg+xml", "Cache-Control": "no-cache" },
|
|
});
|
|
}
|
|
return new Response(FAVICON_PNG_BYTES, {
|
|
headers: { "Content-Type": "image/png", "Cache-Control": "no-cache" },
|
|
});
|
|
}
|
|
|
|
interface ServerReadyOptions {
|
|
readyFile?: string;
|
|
skipBrowserOpen?: boolean;
|
|
openBrowser?: typeof openBrowserImpl;
|
|
}
|
|
|
|
export interface ServerReadyMetadata {
|
|
url: string;
|
|
isRemote: boolean;
|
|
port: number;
|
|
}
|
|
|
|
export function writeServerReadyMetadata(readyFile: string, metadata: ServerReadyMetadata): void {
|
|
mkdirSync(dirname(readyFile), { recursive: true });
|
|
appendFileSync(readyFile, `${JSON.stringify(metadata)}\n`, "utf8");
|
|
}
|
|
|
|
export function isCodexDesktopHost(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
return env.__CFBundleIdentifier === "com.openai.codex";
|
|
}
|
|
|
|
/** Attempt to open the browser for the session URL. */
|
|
export async function handleServerReady(
|
|
url: string,
|
|
isRemote: boolean,
|
|
port: number,
|
|
options: ServerReadyOptions = {},
|
|
): Promise<void> {
|
|
const readyFile = options.readyFile ?? process.env.PLANNOTATOR_READY_FILE;
|
|
if (readyFile) {
|
|
try {
|
|
writeServerReadyMetadata(readyFile, { url, isRemote, port });
|
|
} catch (error) {
|
|
if (options.readyFile) throw error;
|
|
// Best effort: host plugins use this side channel to open the browser.
|
|
}
|
|
}
|
|
|
|
// A remote/SSH session can't pop a browser on the user's machine, so the
|
|
// session URL must be visible in the terminal — independently of whether URL
|
|
// sharing is enabled. The share link (gated on sharing) is an extra; this
|
|
// reachable URL is the lifeline. Without it, a sharing-disabled remote user
|
|
// saw no URL at all and the agent hung waiting on the review.
|
|
if (isRemote) {
|
|
// With an advertised-URL host override the link is directly reachable
|
|
// (e.g. over a tailnet), so the port-forwarding advice would be wrong.
|
|
if (isUrlHostOverridden()) {
|
|
process.stderr.write(`\n Plannotator session ready — open on your device:\n ${url}\n\n`);
|
|
// The URL makes a device hop; a QR skips the retyping (TTY only). Only
|
|
// for overridden hosts: a QR of a localhost URL scans to nowhere.
|
|
writeUrlQr(url);
|
|
} else {
|
|
process.stderr.write(
|
|
`\n Plannotator session ready — open on your local machine (forward port ${port} if needed):\n ${url}\n\n`,
|
|
);
|
|
}
|
|
} else if (isCodexDesktopHost()) {
|
|
process.stderr.write(`\n Plannotator session ready:\n ${url}\n\n`);
|
|
}
|
|
|
|
const skipBrowserOpen = options.skipBrowserOpen ?? process.env.PLANNOTATOR_SKIP_BROWSER_OPEN === "1";
|
|
if (skipBrowserOpen) return;
|
|
|
|
const opened = await (options.openBrowser ?? openBrowserImpl)(url, { isRemote, useGlimpse: true });
|
|
|
|
// Local fallback lifeline: if the browser couldn't be opened (headless box,
|
|
// devcontainer with no display, broken open/xdg-open), the user otherwise has
|
|
// no URL and the agent hangs at waitForDecision. Remote already printed the
|
|
// URL above; only cover the local case here to avoid a double print.
|
|
if (!opened && !isRemote) {
|
|
process.stderr.write(`\n Plannotator session ready — open in your browser:\n ${url}\n\n`);
|
|
}
|
|
}
|
|
|
|
/** Save to external note apps (Obsidian, Bear, Octarine). Used by plan + annotate servers. */
|
|
export async function handleSaveNotes(req: Request): Promise<Response> {
|
|
const results: { obsidian?: IntegrationResult; bear?: IntegrationResult; octarine?: IntegrationResult } = {};
|
|
|
|
try {
|
|
const body = (await req.json()) as {
|
|
obsidian?: ObsidianConfig;
|
|
bear?: BearConfig;
|
|
octarine?: OctarineConfig;
|
|
};
|
|
|
|
const promises: Promise<void>[] = [];
|
|
if (body.obsidian?.vaultPath && body.obsidian?.plan) {
|
|
promises.push(saveToObsidian(body.obsidian).then(r => { results.obsidian = r; }));
|
|
}
|
|
if (body.bear?.plan) {
|
|
promises.push(saveToBear(body.bear).then(r => { results.bear = r; }));
|
|
}
|
|
if (body.octarine?.plan && body.octarine?.workspace) {
|
|
promises.push(saveToOctarine(body.octarine).then(r => { results.octarine = r; }));
|
|
}
|
|
await Promise.allSettled(promises);
|
|
|
|
for (const [name, result] of Object.entries(results)) {
|
|
if (!result?.success && result) {
|
|
console.error(`[${name}] Save failed: ${result.error}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(`[Save Notes] Error:`, err);
|
|
return Response.json({ error: "Save failed" }, { status: 500 });
|
|
}
|
|
|
|
return Response.json({ ok: true, results });
|
|
}
|