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.
132 lines
5.0 KiB
TypeScript
132 lines
5.0 KiB
TypeScript
/**
|
|
* Parse CLI-style args arriving as a single whitespace-delimited string.
|
|
*
|
|
* Extracts known annotate flags from the remainder, which is treated as the
|
|
* target path. Leading `@` is
|
|
* stripped via the shared at-reference helper — reference-mode is primary.
|
|
* Scoped-package-style literal `@` paths are handled by a fallback that the
|
|
* downstream resolver opts into (see at-reference.ts).
|
|
*
|
|
* Used by the OpenCode plugin and Pi extension, where the whole args string
|
|
* arrives pre-joined from the harness slash-command dispatcher. The Claude
|
|
* Code binary parses argv directly with indexOf/splice and does not use
|
|
* this helper.
|
|
*
|
|
* Implementation: walks the raw string once, preserving whitespace runs and
|
|
* non-whitespace tokens as separate segments. Only known flag tokens
|
|
* (whole-word match) plus one adjacent whitespace run are removed.
|
|
* This keeps double-spaces and tabs inside file paths intact — which
|
|
* matches the pre-PR behavior on `main`, where OpenCode and Pi passed
|
|
* the raw args string straight through to the filesystem resolver.
|
|
*
|
|
* Remaining edge: if a path literally contains a known flag as a standalone
|
|
* whitespace-separated token (e.g. `"Feature --gate spec.md"`), that token
|
|
* is stripped. Supporting this would need shell-style quoting, which isn't
|
|
* worth the complexity for a vanishingly rare naming pattern.
|
|
*/
|
|
|
|
import { stripAtPrefix } from "./at-reference";
|
|
import { stripWrappingQuotes } from "./resolve-file";
|
|
|
|
export interface ParsedAnnotateArgs {
|
|
/**
|
|
* Primary resolution path with any leading `@` stripped (reference-mode
|
|
* convention). Most call sites should use this directly.
|
|
*/
|
|
filePath: string;
|
|
/**
|
|
* Raw path with the `@` prefix preserved (if the user supplied one).
|
|
* Callers that want the literal-`@` fallback for scoped-package-style
|
|
* paths pair this with `resolveAtReference` from at-reference.ts.
|
|
*/
|
|
rawFilePath: string;
|
|
gate: boolean;
|
|
json: boolean;
|
|
hook: boolean;
|
|
renderHtml: boolean;
|
|
renderMarkdown: boolean;
|
|
noJina: boolean;
|
|
/** --app: force a live app session (recognized only with `liveFlags`). */
|
|
app: boolean;
|
|
/** --static: force the classic conversion pipeline (only with `liveFlags`). */
|
|
static: boolean;
|
|
}
|
|
|
|
type Segment = { type: "ws" | "tok"; text: string };
|
|
|
|
const FLAG_MAP = {
|
|
"--gate": "gate",
|
|
"--json": "json",
|
|
"--hook": "hook",
|
|
"--render-html": "renderHtml",
|
|
"--markdown": "renderMarkdown",
|
|
"--no-jina": "noJina",
|
|
} as const satisfies Record<string, keyof Omit<ParsedAnnotateArgs, "filePath" | "rawFilePath">>;
|
|
|
|
/** Live-mode flags, recognized only where the host actually supports live
|
|
* app sessions (`liveFlags: true` — Pi today). A host that cannot act on
|
|
* --app must NOT silently strip it: leaving the token in the path keeps the
|
|
* legacy "File not found: --app ..." error, which is honest about the flag
|
|
* being unsupported there. */
|
|
const LIVE_FLAG_MAP = {
|
|
"--app": "app",
|
|
"--static": "static",
|
|
} as const satisfies Record<string, keyof Omit<ParsedAnnotateArgs, "filePath" | "rawFilePath">>;
|
|
|
|
export interface ParseAnnotateArgsOptions {
|
|
/** Recognize --app / --static (hosts with live app annotation support). */
|
|
liveFlags?: boolean;
|
|
}
|
|
|
|
export function parseAnnotateArgs(raw: string, opts?: ParseAnnotateArgsOptions): ParsedAnnotateArgs {
|
|
const s = (raw ?? "").trim();
|
|
const flags = { gate: false, json: false, hook: false, renderHtml: false, renderMarkdown: false, noJina: false, app: false, static: false };
|
|
const flagMap: Record<string, keyof typeof flags> = opts?.liveFlags
|
|
? { ...FLAG_MAP, ...LIVE_FLAG_MAP }
|
|
: { ...FLAG_MAP };
|
|
|
|
const segments: Segment[] = [];
|
|
for (let i = 0; i < s.length;) {
|
|
const isWs = /\s/.test(s[i]);
|
|
const start = i;
|
|
while (i < s.length && /\s/.test(s[i]) === isWs) i++;
|
|
segments.push({ type: isWs ? "ws" : "tok", text: s.slice(start, i) });
|
|
}
|
|
|
|
const keep = segments.map(() => true);
|
|
for (let j = 0; j < segments.length; j++) {
|
|
const seg = segments[j];
|
|
if (seg.type !== "tok") continue;
|
|
const key = flagMap[seg.text];
|
|
if (!key) continue;
|
|
|
|
flags[key] = true;
|
|
keep[j] = false;
|
|
|
|
// Drop one adjacent whitespace run so removed flags don't leave dangling
|
|
// spaces. Prefer trailing whitespace; fall back to leading if at the end.
|
|
if (j + 1 < segments.length && segments[j + 1].type === "ws") {
|
|
keep[j + 1] = false;
|
|
} else if (j > 0 && segments[j - 1].type === "ws") {
|
|
keep[j - 1] = false;
|
|
}
|
|
}
|
|
|
|
// Trim covers the case where two adjacent flags (`... --gate --json`)
|
|
// both claim the single whitespace between them, leaving a trailing space
|
|
// after the kept token. Wrapping quotes come from OpenCode/Pi users who
|
|
// quote paths with spaces (shell muscle memory); strip them here so
|
|
// downstream callers never see tokenization artifacts.
|
|
const rawFilePath = stripWrappingQuotes(
|
|
segments
|
|
.filter((_, j) => keep[j])
|
|
.map((seg) => seg.text)
|
|
.join("")
|
|
.trim(),
|
|
);
|
|
|
|
if (flags.hook) flags.gate = true;
|
|
|
|
return { filePath: stripAtPrefix(rawFilePath), rawFilePath, ...flags };
|
|
}
|