mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
89f0b6628e
Phase 2 of live app annotation: full parity on Pi over one shared implementation instead of drifting copies. - Extract every proxy decision into packages/shared/live-proxy-core.ts (HTML injector state machine, loopback/Host/Origin predicates, CSP/X-Frame-Options policy, redirect rewrite, WS origin gate, bridge assembly, liveAppDraftIdentity) and the CLI probe + live-mode messages into packages/shared/live-probe.ts. packages/server/live-proxy.ts is now a thin Bun transport over the core; its test suite passes unmodified. - Add packages/shared/live-proxy-node.ts, the node:http transport the Pi extension runs: streaming request/response piping through the shared injector, and WebSocket (HMR) passthrough that replays the client's handshake upstream over raw TCP and pipes the sockets byte-for-byte. Transport tests run the proxy in a real node child process, because Bun's node:http shim drops writes to an upgrade event's socket. - Wire Pi: /plannotator-annotate probes loopback URLs live-first with the shared probe (same 3s timeout, same <500 gate, same messages), recognizes --app/--static via parseAnnotateArgs's liveFlags opt-in (OpenCode deliberately does not opt in), and serves mode annotate-app from serverAnnotate.ts with the shared per-target draft identity, live sessions excluded from history/submissions, the remote hard-off throw, and guarded live-proxy shutdown. - Vendor live-proxy-core/live-probe/live-proxy-node plus the dependency-free bridge-script constants to generated/. - Docs: AGENTS.md phase-gate passages, marketing annotate page, Pi README.
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
/**
|
|
* Annotate target resolution for the direct CLI (`plannotator annotate`).
|
|
*
|
|
* Extracted from the annotate branch of index.ts so the resolution pipeline
|
|
* can be re-run once by the tolerant token fallback (#1182) and unit tested.
|
|
* The behavior of a single resolution pass is unchanged: the same branch
|
|
* order (URL, folder, HTML, document), the same messages, and the same
|
|
* progress lines, emitted through `log` at the same points as before.
|
|
*
|
|
* Failures are returned instead of exiting; the caller maps them onto the
|
|
* existing exit behavior. `notFound` is true only for the "the input named
|
|
* nothing" terminal, which is the sole hook point for the token fallback.
|
|
* Every target-specific failure (unsupported type, oversized file, empty
|
|
* folder, unreachable URL, ambiguous name) keeps `notFound` false so it
|
|
* surfaces verbatim.
|
|
*/
|
|
|
|
import { existsSync, statSync } from "fs";
|
|
import path from "path";
|
|
import { resolveAtReference, stripAtPrefix } from "@plannotator/shared/at-reference";
|
|
import { loadConfig, resolveUseJina } from "@plannotator/shared/config";
|
|
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
|
|
import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common";
|
|
import {
|
|
buildAnnotatableDocRegex,
|
|
buildAnnotatableExtensionsHint,
|
|
} from "@plannotator/shared/annotatable";
|
|
import {
|
|
getExtraMarkdownExtensions,
|
|
MAX_ANNOTATABLE_FILE_BYTES,
|
|
hasMarkdownFiles,
|
|
resolveMarkdownFile,
|
|
resolveUserPath,
|
|
} from "@plannotator/shared/resolve-file";
|
|
import { isConvertedSource, urlToMarkdown } from "@plannotator/shared/url-to-markdown";
|
|
import { isLoopbackHostname } from "@plannotator/server/live-proxy";
|
|
import {
|
|
LIVE_APP_REQUIRES_HTTP_MESSAGE,
|
|
LIVE_APP_REQUIRES_LOOPBACK_MESSAGE,
|
|
LIVE_APP_REQUIRES_URL_MESSAGE,
|
|
buildForceAppFailureMessage,
|
|
buildLiveProbeFallbackNotice,
|
|
classifyLiveAppCandidate,
|
|
probeLiveAppTarget,
|
|
} from "@plannotator/shared/live-probe";
|
|
|
|
export interface AnnotateResolutionSuccess {
|
|
ok: true;
|
|
markdown: string;
|
|
rawHtml?: string;
|
|
absolutePath: string;
|
|
folderPath?: string;
|
|
annotateMode: "annotate" | "annotate-folder";
|
|
sourceInfo?: string;
|
|
sourceConverted: boolean;
|
|
isUrl: boolean;
|
|
/** Loopback HTML target resolved as a LIVE app session (server mode
|
|
* "annotate-app"): the URL is proxied, not converted. */
|
|
liveApp?: boolean;
|
|
}
|
|
|
|
export interface AnnotateResolutionFailure {
|
|
ok: false;
|
|
/** True only when the input resolved to nothing at all. */
|
|
notFound: boolean;
|
|
message: string;
|
|
}
|
|
|
|
export type AnnotateResolutionResult =
|
|
| AnnotateResolutionSuccess
|
|
| AnnotateResolutionFailure;
|
|
|
|
/** The loopback gate for the live-app probe: the proxy-side predicate is the
|
|
* single source of truth (localhost, IPv6 loopback, or a LITERAL IPv4
|
|
* address in 127.0.0.0/8, never a string prefix, which DNS names like
|
|
* 127.0.0.1.evil.example would satisfy). Re-exported for the tests. */
|
|
export { isLoopbackHostname };
|
|
|
|
export async function resolveAnnotateTarget(options: {
|
|
rawFilePath: string;
|
|
projectRoot: string;
|
|
noJina: boolean;
|
|
renderMarkdown: boolean;
|
|
/**
|
|
* Extra extensions the user registered as markdown (#1307). Defaults to the
|
|
* process-wide set resolved from config.json; passed explicitly by callers
|
|
* that already hold a resolved list.
|
|
*/
|
|
extraMarkdownExtensions?: readonly string[];
|
|
/** --app: force live mode; loud startup failure when it cannot apply. */
|
|
forceApp?: boolean;
|
|
/** --static: force the classic conversion pipeline on loopback URLs. */
|
|
forceStatic?: boolean;
|
|
log?: (line: string) => void;
|
|
}): Promise<AnnotateResolutionResult> {
|
|
const { rawFilePath, projectRoot, noJina, renderMarkdown, forceApp = false, forceStatic = false } = options;
|
|
const extraMarkdownExtensions =
|
|
options.extraMarkdownExtensions ?? getExtraMarkdownExtensions();
|
|
const log = options.log ?? ((line: string) => console.error(line));
|
|
|
|
// Primary resolution strips the `@` reference marker; rawFilePath is
|
|
// preserved so each branch can fall back to the literal form below
|
|
// (scoped-package-style names).
|
|
const filePath = stripAtPrefix(rawFilePath);
|
|
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
log(`[DEBUG] Project root: ${projectRoot}`);
|
|
log(`[DEBUG] File path arg: ${filePath}`);
|
|
}
|
|
|
|
// --- URL annotation ---
|
|
const isUrl = /^https?:\/\//i.test(filePath);
|
|
|
|
// --app is contracted to fail loudly whenever it cannot apply; a file or
|
|
// folder target silently swallowing it would hide the flag's typo'd use.
|
|
if (!isUrl && forceApp) {
|
|
return {
|
|
ok: false,
|
|
notFound: false,
|
|
message: LIVE_APP_REQUIRES_URL_MESSAGE,
|
|
};
|
|
}
|
|
|
|
if (isUrl) {
|
|
// --- Live app detection (shared with the Pi extension) ---
|
|
// Loopback http URLs default to LIVE mode when a quick probe returns an
|
|
// HTML page; --static forces the classic conversion pipeline; --app
|
|
// forces live mode and fails loudly when it cannot apply. Non-loopback
|
|
// URLs keep the conversion pipeline untouched. Probe semantics and
|
|
// messages live in @plannotator/shared/live-probe so every host judges
|
|
// the same URL identically.
|
|
const { parsed: parsedUrl, loopback } = classifyLiveAppCandidate(filePath);
|
|
|
|
if (forceApp && !loopback) {
|
|
return {
|
|
ok: false,
|
|
notFound: false,
|
|
message: LIVE_APP_REQUIRES_LOOPBACK_MESSAGE,
|
|
};
|
|
}
|
|
if (forceApp && parsedUrl?.protocol === "https:") {
|
|
// The live proxy is http-only.
|
|
return {
|
|
ok: false,
|
|
notFound: false,
|
|
message: LIVE_APP_REQUIRES_HTTP_MESSAGE,
|
|
};
|
|
}
|
|
|
|
if (loopback && parsedUrl?.protocol === "http:" && !forceStatic) {
|
|
const probe = await probeLiveAppTarget(filePath, parsedUrl);
|
|
if (probe.liveEligible) {
|
|
log(`Live app: ${filePath}`);
|
|
return {
|
|
ok: true,
|
|
markdown: "",
|
|
absolutePath: filePath,
|
|
annotateMode: "annotate",
|
|
liveApp: true,
|
|
sourceInfo: filePath,
|
|
sourceConverted: false,
|
|
isUrl,
|
|
};
|
|
}
|
|
if (forceApp) {
|
|
return {
|
|
ok: false,
|
|
notFound: false,
|
|
message: buildForceAppFailureMessage(filePath, probe),
|
|
};
|
|
}
|
|
// Probe failure or non-HTML without --app: fall through to the static
|
|
// pipeline, whose own error surfaces verbatim (preserves the legacy
|
|
// behavior for dead URLs and JSON endpoints). A failed probe says so
|
|
// first: a dev server still starting up probes as unreachable, and a
|
|
// silent downgrade to static conversion reads as a broken live session.
|
|
if (probe.probeError !== null) {
|
|
log(buildLiveProbeFallbackNotice(filePath, probe.probeError));
|
|
}
|
|
}
|
|
|
|
const useJina = resolveUseJina(noJina, loadConfig());
|
|
log(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`);
|
|
let markdown: string;
|
|
let sourceConverted: boolean;
|
|
try {
|
|
const result = await urlToMarkdown(filePath, { useJina });
|
|
markdown = result.markdown;
|
|
sourceConverted = isConvertedSource(result.source);
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
log(`[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`);
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
notFound: false,
|
|
message: `Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
markdown,
|
|
absolutePath: filePath, // Use URL as the "path" for display
|
|
annotateMode: "annotate",
|
|
sourceInfo: filePath, // Full URL for source attribution
|
|
sourceConverted,
|
|
isUrl,
|
|
};
|
|
}
|
|
|
|
// Folder check with literal-@ fallback for scoped-package-style names.
|
|
const folderCandidate = resolveAtReference(rawFilePath, (c) => {
|
|
try {
|
|
return statSync(resolveUserPath(c, projectRoot)).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
if (folderCandidate !== null) {
|
|
const resolvedArg = resolveUserPath(folderCandidate, projectRoot);
|
|
// Folder annotation mode (markdown/plain text/config + HTML files)
|
|
if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, buildAnnotatableDocRegex(extraMarkdownExtensions))) {
|
|
return {
|
|
ok: false,
|
|
notFound: false,
|
|
message: `No annotatable files (markdown, plain-text, config, or HTML) found in ${resolvedArg}`,
|
|
};
|
|
}
|
|
log(`Folder: ${resolvedArg}`);
|
|
return {
|
|
ok: true,
|
|
markdown: "",
|
|
absolutePath: resolvedArg,
|
|
folderPath: resolvedArg,
|
|
annotateMode: "annotate-folder",
|
|
sourceConverted: false,
|
|
isUrl,
|
|
};
|
|
}
|
|
|
|
// HTML check with the same literal-@ fallback semantics.
|
|
const htmlCandidate = resolveAtReference(rawFilePath, (c) => {
|
|
const abs = resolveUserPath(c, projectRoot);
|
|
return /\.html?$/i.test(abs) && existsSync(abs);
|
|
});
|
|
|
|
if (htmlCandidate !== null) {
|
|
const resolvedArg = resolveUserPath(htmlCandidate, projectRoot);
|
|
const htmlFile = Bun.file(resolvedArg);
|
|
const html = await htmlFile.text();
|
|
const renderHtmlForFile = !renderMarkdown;
|
|
let markdown: string;
|
|
let rawHtml: string | undefined;
|
|
let sourceConverted = false;
|
|
if (renderHtmlForFile) {
|
|
rawHtml = html;
|
|
markdown = "";
|
|
} else {
|
|
markdown = htmlToMarkdown(html);
|
|
sourceConverted = true;
|
|
}
|
|
log(`${renderHtmlForFile ? "Raw HTML" : "Converted"}: ${resolvedArg}`);
|
|
return {
|
|
ok: true,
|
|
markdown,
|
|
rawHtml,
|
|
absolutePath: resolvedArg,
|
|
annotateMode: "annotate",
|
|
sourceInfo: path.basename(resolvedArg),
|
|
sourceConverted,
|
|
isUrl,
|
|
};
|
|
}
|
|
|
|
// Single markdown/plain-text file annotation mode
|
|
// Strip-first with literal-@ fallback (scoped-package-style names).
|
|
let resolved = resolveMarkdownFile(filePath, projectRoot, { extraMarkdownExtensions });
|
|
if (resolved.kind === "not_found" && rawFilePath !== filePath) {
|
|
resolved = resolveMarkdownFile(rawFilePath, projectRoot, { extraMarkdownExtensions });
|
|
}
|
|
|
|
if (resolved.kind === "ambiguous") {
|
|
return {
|
|
ok: false,
|
|
notFound: false,
|
|
message: [
|
|
`Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`,
|
|
...resolved.matches.map((match) => ` ${match}`),
|
|
].join("\n"),
|
|
};
|
|
}
|
|
if (resolved.kind !== "found") {
|
|
// Check if file exists but has unsupported type
|
|
const resolvedPath = resolveUserPath(resolved.input, projectRoot);
|
|
const fileExists = existsSync(resolvedPath);
|
|
|
|
if (fileExists) {
|
|
const ext = path.extname(resolvedPath).toLowerCase();
|
|
return {
|
|
ok: false,
|
|
notFound: false,
|
|
message:
|
|
`File type not supported: ${ext}\n` +
|
|
`Supported types: ${buildAnnotatableExtensionsHint(extraMarkdownExtensions)}\n` +
|
|
`For code review, use: plannotator review [file]`,
|
|
};
|
|
}
|
|
return {
|
|
ok: false,
|
|
notFound: true,
|
|
message: `File not found: ${resolved.input}`,
|
|
};
|
|
}
|
|
|
|
const absolutePath = resolved.path;
|
|
if (Bun.file(absolutePath).size > MAX_ANNOTATABLE_FILE_BYTES) {
|
|
return {
|
|
ok: false,
|
|
notFound: false,
|
|
message: `File too large to annotate (max 2MB): ${absolutePath}`,
|
|
};
|
|
}
|
|
const markdown = await Bun.file(absolutePath).text();
|
|
log(`Resolved: ${absolutePath}`);
|
|
return {
|
|
ok: true,
|
|
markdown,
|
|
absolutePath,
|
|
annotateMode: "annotate",
|
|
sourceConverted: false,
|
|
isUrl,
|
|
};
|
|
}
|