pi-weave 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * weave-view server — loopback-only node:http server (docs/weave-view.md §4).
3
+ *
4
+ * Harness-agnostic on purpose: it only uses core + node builtins, so a
5
+ * future Claude Code / opencode adapter can reuse it as-is. Pi-specific bits
6
+ * (commands, browser exec) live in `src/pi/index.ts`.
7
+ */
8
+
9
+ import { execFile } from "node:child_process";
10
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
11
+ import { promisify } from "node:util";
12
+ import { platform } from "node:os";
13
+ import { getNote, resolveNotePath, resolveVaultRoot, type GraphModel } from "../../core";
14
+ import { renderPage } from "./page";
15
+
16
+ const execFileAsync = promisify(execFile);
17
+ import {
18
+ buildCurrentGraph,
19
+ readNoteForView,
20
+ readOkfFileForView,
21
+ type ViewNote,
22
+ } from "../../core";
23
+
24
+ // Re-export the moved readers so existing import sites (tests) keep passing.
25
+ export { buildCurrentGraph, readNoteForView, readOkfFileForView, type ViewNote } from "../../core";
26
+
27
+ export interface ViewerServer {
28
+ /** Resolved base URL, e.g. http://127.0.0.1:53217 */
29
+ url: string;
30
+ port: number;
31
+ /** Close the server. Idempotent — safe to call multiple times. */
32
+ stop(): Promise<void>;
33
+ }
34
+
35
+ export interface StartViewerOptions {
36
+ cwd: string;
37
+ /** Vault override (defaults to resolveVaultRoot(), honoring PI_WEAVE_VAULT). */
38
+ vaultRoot?: string;
39
+ /** Explicit port; defaults to PI_WEAVE_VIEW_PORT or 0 (OS-assigned). */
40
+ port?: number;
41
+ /**
42
+ * Override the OS command used to open a note in the editor (test seam).
43
+ * Defaults to openNoteCommand().
44
+ */
45
+ openCommand?: (notePath: string) => { command: string; args: string[] };
46
+ }
47
+
48
+ /**
49
+ * The OS command used to open a note file in the user's editor. Respects
50
+ * $EDITOR / $VISUAL (which may carry args, e.g. "code --wait"); falls back
51
+ * to the platform default opener. Kept separate from the route so the
52
+ * mapping is unit-testable without stubbing globals (mirrors browserCommand).
53
+ */
54
+ export function openNoteCommand(
55
+ notePath: string,
56
+ env: NodeJS.ProcessEnv = process.env,
57
+ ): { command: string; args: string[] } {
58
+ const editor = (env.EDITOR || env.VISUAL || "").trim();
59
+ if (editor) {
60
+ const parts = editor.split(/\s+/).filter(Boolean);
61
+ const command = parts[0] ?? "";
62
+ return { command, args: [...parts.slice(1), notePath] };
63
+ }
64
+ if (platform() === "darwin") return { command: "open", args: [notePath] };
65
+ if (platform() === "win32") return { command: "cmd", args: ["/c", "start", "", notePath] };
66
+ return { command: "xdg-open", args: [notePath] };
67
+ }
68
+
69
+ /**
70
+ * Open a note in the OS editor. The slug is validated against the vault
71
+ * (traversal-safe) before any shell-out; returns false when the note does
72
+ * not exist or the slug is unsafe.
73
+ */
74
+ export async function openNoteInEditor(
75
+ vaultRoot: string,
76
+ slug: string,
77
+ openCommand: (notePath: string) => { command: string; args: string[] } = openNoteCommand,
78
+ ): Promise<boolean> {
79
+ const path = resolveNotePath(vaultRoot, slug);
80
+ if (path === null) return false; // traversal-safe
81
+ const note = await getNote(vaultRoot, slug);
82
+ if (note === null) return false;
83
+ const { command, args } = openCommand(path);
84
+ if (!command) return false;
85
+ await execFileAsync(command, args);
86
+ return true;
87
+ }
88
+
89
+ /**
90
+ * CSP: page JS and CSS are inline by design (zero external resources), so
91
+ * inline styles/scripts are allowed while everything else stays 'self'.
92
+ * (Extends docs/weave-view.md §4's header, which blocked inline script and
93
+ * would break the page.)
94
+ */
95
+ const CSP = "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'";
96
+
97
+ // `readNoteForView`, `readOkfFileForView`, and `buildCurrentGraph` moved to
98
+ // core (src/core/graph/current.ts) and re-exported above; the HTTP route wires
99
+ // them in below.
100
+
101
+ function route(
102
+ page: string,
103
+ graph: () => Promise<GraphModel>,
104
+ noteBySlug: (slug: string) => Promise<ViewNote | null>,
105
+ okfBody: (rel: string) => Promise<{ path: string; body: string } | null>,
106
+ openNote: (slug: string) => Promise<boolean>,
107
+ res: ServerResponse,
108
+ req: IncomingMessage,
109
+ ): void {
110
+ const path = decodeURIComponent((req.url ?? "/").split("?")[0] ?? "/");
111
+ if (req.method === "GET" && path === "/") {
112
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": CSP });
113
+ res.end(page);
114
+ return;
115
+ }
116
+ if (req.method === "GET" && path === "/graph.json") {
117
+ graph()
118
+ .then((model) => {
119
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-security-policy": CSP });
120
+ res.end(JSON.stringify(model));
121
+ })
122
+ .catch((err: unknown) => {
123
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
124
+ res.end(`pi-weave viewer: failed to build graph: ${err instanceof Error ? err.message : String(err)}`);
125
+ });
126
+ return;
127
+ }
128
+ if (req.method === "POST" && path.startsWith("/open/")) {
129
+ openNote(path.slice("/open/".length))
130
+ .then((ok) => {
131
+ if (!ok) {
132
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
133
+ res.end("no such note\n");
134
+ return;
135
+ }
136
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
137
+ res.end("opened\n");
138
+ })
139
+ .catch(() => {
140
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
141
+ res.end("pi-weave viewer: failed to open note\n");
142
+ });
143
+ return;
144
+ }
145
+ if (req.method === "GET" && path.startsWith("/note/")) {
146
+ noteBySlug(path.slice("/note/".length))
147
+ .then((note) => {
148
+ if (note === null) {
149
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
150
+ res.end("no such note\n");
151
+ return;
152
+ }
153
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-security-policy": CSP });
154
+ res.end(JSON.stringify(note));
155
+ })
156
+ .catch(() => {
157
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
158
+ res.end("pi-weave viewer: failed to read note\n");
159
+ });
160
+ return;
161
+ }
162
+ if (req.method === "GET" && path.startsWith("/okffile/")) {
163
+ okfBody(path.slice("/okffile/".length))
164
+ .then((file) => {
165
+ if (file === null) {
166
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
167
+ res.end("no such okf file\n");
168
+ return;
169
+ }
170
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-security-policy": CSP });
171
+ res.end(JSON.stringify(file));
172
+ })
173
+ .catch(() => {
174
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
175
+ res.end("pi-weave viewer: failed to read okf file\n");
176
+ });
177
+ return;
178
+ }
179
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
180
+ res.end("not found\n");
181
+ }
182
+
183
+ export async function startViewer(options: StartViewerOptions): Promise<ViewerServer> {
184
+ const page = renderPage();
185
+ const cwd = options.cwd;
186
+ const vaultRoot = options.vaultRoot ?? resolveVaultRoot();
187
+ const openCommand = options.openCommand ?? openNoteCommand;
188
+ const server: Server = createServer((req, res) => {
189
+ route(
190
+ page,
191
+ () => buildCurrentGraph(cwd, vaultRoot),
192
+ (slug) => readNoteForView(vaultRoot, slug),
193
+ (rel) => readOkfFileForView(cwd, rel),
194
+ (slug) => openNoteInEditor(vaultRoot, slug, openCommand),
195
+ res,
196
+ req,
197
+ );
198
+ });
199
+
200
+ const parsed = Number.parseInt(process.env.PI_WEAVE_VIEW_PORT ?? "", 10);
201
+ const port = options.port ?? (Number.isFinite(parsed) ? parsed : 0);
202
+
203
+ await new Promise<void>((resolve, reject) => {
204
+ server.once("error", reject);
205
+ server.listen(port, "127.0.0.1", () => {
206
+ server.off("error", reject);
207
+ resolve();
208
+ });
209
+ });
210
+
211
+ const address = server.address();
212
+ const boundPort = typeof address === "object" && address !== null ? address.port : port;
213
+
214
+ let closed = false;
215
+ return {
216
+ url: `http://127.0.0.1:${boundPort}`,
217
+ port: boundPort,
218
+ stop: () =>
219
+ new Promise<void>((resolve) => {
220
+ if (closed) {
221
+ resolve();
222
+ return;
223
+ }
224
+ closed = true;
225
+ server.close(() => resolve());
226
+ server.closeAllConnections();
227
+ }),
228
+ };
229
+ }