plotlink-ows 0.1.14 → 0.1.18

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.
Files changed (37) hide show
  1. package/README.md +49 -57
  2. package/app/db.ts +1 -1
  3. package/app/lib/paths.ts +0 -2
  4. package/app/lib/publish.ts +150 -39
  5. package/app/prisma/schema.prisma +0 -36
  6. package/app/routes/dashboard.ts +53 -56
  7. package/app/routes/publish.ts +55 -24
  8. package/app/routes/stories.ts +156 -0
  9. package/app/routes/terminal.ts +154 -0
  10. package/app/routes/wallet.ts +40 -10
  11. package/app/server.ts +29 -81
  12. package/app/web/App.tsx +4 -6
  13. package/app/web/components/Dashboard.tsx +15 -47
  14. package/app/web/components/Layout.tsx +70 -103
  15. package/app/web/components/PreviewPanel.tsx +149 -0
  16. package/app/web/components/Settings.tsx +3 -84
  17. package/app/web/components/StoriesPage.tsx +157 -0
  18. package/app/web/components/StoryBrowser.tsx +137 -0
  19. package/app/web/components/TerminalPanel.tsx +122 -0
  20. package/app/web/components/WalletCard.tsx +14 -8
  21. package/app/web/dist/assets/index-D5gfwaEX.css +32 -0
  22. package/app/web/dist/assets/index-pBt5Q_bN.js +117 -0
  23. package/app/web/dist/index.html +3 -3
  24. package/app/web/dist/plotlink-logo.svg +5 -0
  25. package/app/web/public/plotlink-logo.svg +5 -0
  26. package/bin/plotlink-ows.js +13 -12
  27. package/package.json +9 -5
  28. package/app/lib/llm-client.ts +0 -265
  29. package/app/lib/writer-prompt.ts +0 -44
  30. package/app/routes/chat.ts +0 -135
  31. package/app/routes/config.ts +0 -210
  32. package/app/routes/oauth.ts +0 -150
  33. package/app/web/components/Chat.tsx +0 -272
  34. package/app/web/components/LLMSetup.tsx +0 -291
  35. package/app/web/components/Publish.tsx +0 -245
  36. package/app/web/dist/assets/index-C9kXlYO_.css +0 -2
  37. package/app/web/dist/assets/index-CJiiaLHs.js +0 -9
@@ -1,7 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { streamSSE } from "hono/streaming";
3
- import { db } from "../db";
4
- import { publishStoryline, getEthBalance, estimatePublishCost } from "../lib/publish";
3
+ import { publishStoryline, publishPlot, getEthBalance, estimatePublishCost } from "../lib/publish";
5
4
  import { keccak256, toBytes } from "viem";
6
5
  import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
7
6
 
@@ -64,41 +63,73 @@ publish.get("/preflight", async (c) => {
64
63
  }
65
64
  });
66
65
 
67
- /** POST /api/publish/:draftId — publish a draft on-chain (streams progress) */
68
- publish.post("/:draftId", async (c) => {
69
- const draftId = c.req.param("draftId");
66
+ /** POST /api/publish/file — publish a story file on-chain (streams progress) */
67
+ publish.post("/file", async (c) => {
68
+ const body = await c.req.json<{
69
+ storyName: string;
70
+ fileName: string;
71
+ title: string;
72
+ content: string;
73
+ genre?: string;
74
+ storylineId?: number;
75
+ }>();
70
76
 
71
- const draft = await db.draft.findUnique({ where: { id: draftId } });
72
- if (!draft) return c.json({ error: "Draft not found" }, 404);
73
- if (draft.status === "published") return c.json({ error: "Already published" }, 409);
77
+ if (!body.title || !body.content) {
78
+ return c.json({ error: "title and content required" }, 400);
79
+ }
74
80
 
75
81
  // Get wallet
76
- const wallets = listAgentWallets();
82
+ let wallets;
83
+ try {
84
+ wallets = listAgentWallets();
85
+ } catch (err) {
86
+ console.error("[publish/file] listAgentWallets error:", err);
87
+ return c.json({ error: `OWS wallet error: ${err instanceof Error ? err.message : String(err)}` }, 500);
88
+ }
77
89
  const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
78
90
  if (!wallet) return c.json({ error: "No OWS wallet" }, 400);
79
91
 
92
+ console.log("[publish/file] Starting publish for", body.storyName, body.fileName, "wallet:", wallet.name);
93
+
94
+ // Determine if this is genesis (createStoryline) or plot (chainPlot)
95
+ const isPlot = body.fileName.match(/^plot-\d+\.md$/);
96
+
80
97
  return streamSSE(c, async (stream) => {
81
98
  try {
82
- const result = await publishStoryline(
83
- wallet.name,
84
- draft.title,
85
- draft.content,
86
- draft.genre || undefined,
87
- async (progress) => {
88
- await stream.writeSSE({ data: JSON.stringify(progress) });
89
- },
90
- );
99
+ let result;
100
+ if (isPlot && body.storylineId) {
101
+ // Chain plot to existing storyline
102
+ result = await publishPlot(
103
+ wallet.name,
104
+ body.storylineId,
105
+ body.title,
106
+ body.content,
107
+ body.genre,
108
+ async (progress) => {
109
+ await stream.writeSSE({ data: JSON.stringify(progress) });
110
+ },
111
+ );
112
+ } else {
113
+ // Create new storyline (genesis or first file)
114
+ result = await publishStoryline(
115
+ wallet.name,
116
+ body.title,
117
+ body.content,
118
+ body.genre,
119
+ async (progress) => {
120
+ await stream.writeSSE({ data: JSON.stringify(progress) });
121
+ },
122
+ );
123
+ }
91
124
 
92
- // Only mark published after tx confirmed (publishStoryline waits for confirmation)
93
- await db.draft.update({
94
- where: { id: draftId },
95
- data: {
96
- status: "published",
125
+ await stream.writeSSE({
126
+ data: JSON.stringify({
127
+ step: "done",
97
128
  txHash: result.txHash,
98
129
  storylineId: result.storylineId,
99
130
  contentCid: result.contentCid,
100
131
  gasCost: result.gasCost,
101
- },
132
+ }),
102
133
  });
103
134
  } catch (err: unknown) {
104
135
  const message = err instanceof Error ? err.message : "Publish failed";
@@ -0,0 +1,156 @@
1
+ import { Hono } from "hono";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const STORIES_DIR = path.join(__dirname, "..", "..", "stories");
8
+
9
+ const stories = new Hono();
10
+
11
+ /** Sanitize path params to prevent directory traversal */
12
+ function safeName(name: string): string | null {
13
+ if (!name || name.includes("..") || name.includes("/") || name.includes("\\") || name.startsWith(".")) {
14
+ return null;
15
+ }
16
+ return name;
17
+ }
18
+
19
+ interface FileStatus {
20
+ file: string;
21
+ status: "published" | "pending" | "draft";
22
+ txHash?: string;
23
+ storylineId?: number;
24
+ contentCid?: string;
25
+ publishedAt?: string;
26
+ gasCost?: string;
27
+ }
28
+
29
+ interface StoryInfo {
30
+ name: string;
31
+ files: FileStatus[];
32
+ hasStructure: boolean;
33
+ hasGenesis: boolean;
34
+ plotCount: number;
35
+ publishedCount: number;
36
+ }
37
+
38
+ function readPublishStatus(storyDir: string): Record<string, FileStatus> {
39
+ const statusFile = path.join(storyDir, ".publish-status.json");
40
+ try {
41
+ if (fs.existsSync(statusFile)) {
42
+ return JSON.parse(fs.readFileSync(statusFile, "utf-8"));
43
+ }
44
+ } catch { /* ignore */ }
45
+ return {};
46
+ }
47
+
48
+ function writePublishStatus(storyDir: string, status: Record<string, FileStatus>) {
49
+ const statusFile = path.join(storyDir, ".publish-status.json");
50
+ fs.writeFileSync(statusFile, JSON.stringify(status, null, 2) + "\n");
51
+ }
52
+
53
+ function scanStory(storyDir: string, name: string): StoryInfo {
54
+ const publishStatus = readPublishStatus(storyDir);
55
+ const entries = fs.readdirSync(storyDir).filter((f) => f.endsWith(".md"));
56
+
57
+ const files: FileStatus[] = entries.map((file) => {
58
+ const existing = publishStatus[file];
59
+ if (existing?.status === "published") {
60
+ return existing;
61
+ }
62
+ return { file, status: "pending" as const };
63
+ });
64
+
65
+ const hasStructure = entries.includes("structure.md");
66
+ const hasGenesis = entries.includes("genesis.md");
67
+ const plotCount = entries.filter((f) => f.match(/^plot-\d+\.md$/)).length;
68
+ const publishedCount = files.filter((f) => f.status === "published").length;
69
+
70
+ return { name, files, hasStructure, hasGenesis, plotCount, publishedCount };
71
+ }
72
+
73
+ /** GET /api/stories — list all stories */
74
+ stories.get("/", (c) => {
75
+ if (!fs.existsSync(STORIES_DIR)) {
76
+ return c.json({ stories: [] });
77
+ }
78
+
79
+ const dirs = fs.readdirSync(STORIES_DIR, { withFileTypes: true })
80
+ .filter((d) => d.isDirectory() && !d.name.startsWith("."))
81
+ .map((d) => d.name)
82
+ .sort();
83
+
84
+ const result = dirs.map((name) => scanStory(path.join(STORIES_DIR, name), name));
85
+ return c.json({ stories: result });
86
+ });
87
+
88
+ /** GET /api/stories/:name — single story detail */
89
+ stories.get("/:name", (c) => {
90
+ const name = safeName(c.req.param("name"));
91
+ if (!name) return c.json({ error: "Invalid story name" }, 400);
92
+ const storyDir = path.join(STORIES_DIR, name);
93
+
94
+ if (!fs.existsSync(storyDir) || !fs.statSync(storyDir).isDirectory()) {
95
+ return c.json({ error: "Story not found" }, 404);
96
+ }
97
+
98
+ const info = scanStory(storyDir, name);
99
+
100
+ // Include file contents
101
+ const filesWithContent = info.files.map((f) => {
102
+ const filePath = path.join(storyDir, f.file);
103
+ const content = fs.readFileSync(filePath, "utf-8");
104
+ return { ...f, content };
105
+ });
106
+
107
+ return c.json({ ...info, files: filesWithContent });
108
+ });
109
+
110
+ /** GET /api/stories/:name/:file — single file content */
111
+ stories.get("/:name/:file", (c) => {
112
+ const name = safeName(c.req.param("name"));
113
+ const file = safeName(c.req.param("file"));
114
+ if (!name || !file) return c.json({ error: "Invalid path" }, 400);
115
+ const filePath = path.join(STORIES_DIR, name, file);
116
+
117
+ if (!fs.existsSync(filePath)) {
118
+ return c.json({ error: "File not found" }, 404);
119
+ }
120
+
121
+ const content = fs.readFileSync(filePath, "utf-8");
122
+ const publishStatus = readPublishStatus(path.join(STORIES_DIR, name));
123
+ const status = publishStatus[file] || { file, status: "pending" };
124
+
125
+ return c.json({ ...status, content });
126
+ });
127
+
128
+ /** POST /api/stories/:name/:file/publish-status — update publish status after publishing */
129
+ stories.post("/:name/:file/publish-status", async (c) => {
130
+ const name = safeName(c.req.param("name"));
131
+ const file = safeName(c.req.param("file"));
132
+ if (!name || !file) return c.json({ error: "Invalid path" }, 400);
133
+ const storyDir = path.join(STORIES_DIR, name);
134
+ const body = await c.req.json<{
135
+ txHash: string;
136
+ storylineId?: number;
137
+ contentCid: string;
138
+ gasCost?: string;
139
+ }>();
140
+
141
+ const status = readPublishStatus(storyDir);
142
+ status[file] = {
143
+ file,
144
+ status: "published",
145
+ txHash: body.txHash,
146
+ storylineId: body.storylineId,
147
+ contentCid: body.contentCid,
148
+ gasCost: body.gasCost,
149
+ publishedAt: new Date().toISOString(),
150
+ };
151
+ writePublishStatus(storyDir, status);
152
+
153
+ return c.json({ ok: true });
154
+ });
155
+
156
+ export { stories as storiesRoutes, readPublishStatus, STORIES_DIR };
@@ -0,0 +1,154 @@
1
+ import { Hono } from "hono";
2
+ import * as pty from "node-pty";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const STORIES_DIR = path.join(__dirname, "..", "..", "stories");
8
+
9
+ const terminal = new Hono();
10
+
11
+ // Active PTY sessions keyed by session ID
12
+ const ptySessions = new Map<
13
+ string,
14
+ { term: pty.IPty; ws: WebSocket | null; state: "running" | "stopped" }
15
+ >();
16
+
17
+ /** POST /api/terminal/spawn — spawn Claude CLI in stories/ */
18
+ terminal.post("/spawn", (c) => {
19
+ const sessionId = "default";
20
+
21
+ const existing = ptySessions.get(sessionId);
22
+ if (existing?.term && existing.state === "running") {
23
+ return c.json({ ok: true, pid: existing.term.pid, reused: true });
24
+ }
25
+
26
+ try {
27
+ const shell = process.env.SHELL || "/bin/zsh";
28
+ const term = pty.spawn(shell, ["-l", "-c", "claude"], {
29
+ name: "xterm-256color",
30
+ cols: 120,
31
+ rows: 30,
32
+ cwd: STORIES_DIR,
33
+ env: process.env as Record<string, string>,
34
+ });
35
+
36
+ ptySessions.set(sessionId, { term, ws: null, state: "running" });
37
+
38
+ term.onExit(({ exitCode }) => {
39
+ const session = ptySessions.get(sessionId);
40
+ if (session?.term === term) {
41
+ session.state = "stopped";
42
+ if (session.ws && session.ws.readyState <= 1) {
43
+ session.ws.close(1000, `exited:${exitCode}`);
44
+ }
45
+ session.ws = null;
46
+ }
47
+ });
48
+
49
+ return c.json({ ok: true, pid: term.pid });
50
+ } catch (err: unknown) {
51
+ const message = err instanceof Error ? err.message : "Failed to spawn PTY";
52
+ return c.json({ ok: false, error: message }, 500);
53
+ }
54
+ });
55
+
56
+ /** POST /api/terminal/stop — kill PTY */
57
+ terminal.post("/stop", (c) => {
58
+ const session = ptySessions.get("default");
59
+ if (session?.term && session.state === "running") {
60
+ session.term.kill();
61
+ session.state = "stopped";
62
+ return c.json({ ok: true });
63
+ }
64
+ return c.json({ ok: true, message: "not running" });
65
+ });
66
+
67
+ /** GET /api/terminal/status */
68
+ terminal.get("/status", (c) => {
69
+ const session = ptySessions.get("default");
70
+ return c.json({
71
+ running: session?.state === "running",
72
+ pid: session?.term?.pid ?? null,
73
+ });
74
+ });
75
+
76
+ /**
77
+ * Attach a raw WebSocket to the PTY session.
78
+ * Called from server.ts WebSocket upgrade handler.
79
+ */
80
+ export function attachTerminalWs(ws: WebSocket) {
81
+ const sessionId = "default";
82
+ let session = ptySessions.get(sessionId);
83
+
84
+ // Lazy spawn if no PTY exists
85
+ if (!session || session.state !== "running") {
86
+ try {
87
+ const shell = process.env.SHELL || "/bin/zsh";
88
+ const term = pty.spawn(shell, ["-l", "-c", "claude"], {
89
+ name: "xterm-256color",
90
+ cols: 120,
91
+ rows: 30,
92
+ cwd: STORIES_DIR,
93
+ env: process.env as Record<string, string>,
94
+ });
95
+
96
+ session = { term, ws: null, state: "running" };
97
+ ptySessions.set(sessionId, session);
98
+
99
+ term.onExit(({ exitCode }) => {
100
+ const s = ptySessions.get(sessionId);
101
+ if (s?.term === term) {
102
+ s.state = "stopped";
103
+ if (s.ws && s.ws.readyState <= 1) {
104
+ s.ws.close(1000, `exited:${exitCode}`);
105
+ }
106
+ s.ws = null;
107
+ }
108
+ });
109
+ } catch (err) {
110
+ console.error("PTY spawn failed:", err);
111
+ ws.close(1011, "pty-spawn-failed");
112
+ return;
113
+ }
114
+ }
115
+
116
+ // Replace previous WS
117
+ if (session.ws && session.ws !== ws && session.ws.readyState <= 1) {
118
+ session.ws.close(1000, "replaced");
119
+ }
120
+ session.ws = ws;
121
+
122
+ // PTY output → browser
123
+ const dataDisposable = session.term.onData((data: string) => {
124
+ if (ws.readyState === WebSocket.OPEN) {
125
+ ws.send(data);
126
+ }
127
+ });
128
+
129
+ // Browser input → PTY
130
+ ws.addEventListener("message", (event: MessageEvent) => {
131
+ if (!session?.term || session.state !== "running") return;
132
+ const str = typeof event.data === "string" ? event.data : event.data.toString();
133
+ try {
134
+ const parsed = JSON.parse(str);
135
+ if (parsed.type === "resize" && parsed.cols && parsed.rows) {
136
+ session.term.resize(parsed.cols, parsed.rows);
137
+ return;
138
+ }
139
+ } catch {
140
+ // Not JSON — raw input
141
+ }
142
+ session.term.write(str);
143
+ });
144
+
145
+ // Cleanup on close (keep PTY running)
146
+ ws.addEventListener("close", () => {
147
+ dataDisposable.dispose();
148
+ if (session?.ws === ws) {
149
+ session.ws = null;
150
+ }
151
+ });
152
+ }
153
+
154
+ export { terminal as terminalRoutes };
@@ -34,22 +34,50 @@ wallet.get("/", async (c) => {
34
34
 
35
35
  const address = getBaseAddress(plotlinkWallet);
36
36
 
37
- // Fetch USDC balance on Base via RPC
37
+ // Fetch balances on Base via RPC
38
+ let ethBalance = "0";
38
39
  let usdcBalance = "0";
40
+ let plotBalance = "0";
41
+ const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
42
+
39
43
  if (address) {
44
+ const addrPadded = "000000000000000000000000" + address.slice(2).toLowerCase();
45
+ const balanceOfSig = "0x70a08231" + addrPadded;
46
+
40
47
  try {
41
- const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // USDC on Base mainnet
42
- const balanceOfSig = "0x70a08231000000000000000000000000" + address.slice(2).toLowerCase();
43
- const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
44
- const res = await fetch(rpcUrl, {
48
+ // ETH balance
49
+ const ethRes = await fetch(rpcUrl, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }),
53
+ });
54
+ const ethData = await ethRes.json() as { result?: string };
55
+ if (ethData.result && ethData.result !== "0x" && ethData.result !== "0x0") {
56
+ ethBalance = (Number(BigInt(ethData.result)) / 1e18).toFixed(6);
57
+ }
58
+
59
+ // USDC balance (6 decimals)
60
+ const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
61
+ const usdcRes = await fetch(rpcUrl, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "eth_call", params: [{ to: USDC_BASE, data: balanceOfSig }, "latest"] }),
65
+ });
66
+ const usdcData = await usdcRes.json() as { result?: string };
67
+ if (usdcData.result && usdcData.result !== "0x") {
68
+ usdcBalance = (Number(BigInt(usdcData.result)) / 1e6).toFixed(2);
69
+ }
70
+
71
+ // PLOT balance (18 decimals)
72
+ const PLOT = "0x4F567DACBF9D15A6acBe4A47FC2Ade0719Fb63C4";
73
+ const plotRes = await fetch(rpcUrl, {
45
74
  method: "POST",
46
75
  headers: { "Content-Type": "application/json" },
47
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to: USDC_BASE, data: balanceOfSig }, "latest"] }),
76
+ body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "eth_call", params: [{ to: PLOT, data: balanceOfSig }, "latest"] }),
48
77
  });
49
- const data = await res.json() as { result?: string };
50
- if (data.result && data.result !== "0x") {
51
- const raw = BigInt(data.result);
52
- usdcBalance = (Number(raw) / 1e6).toFixed(2); // USDC has 6 decimals
78
+ const plotData = await plotRes.json() as { result?: string };
79
+ if (plotData.result && plotData.result !== "0x") {
80
+ plotBalance = (Number(BigInt(plotData.result)) / 1e18).toFixed(4);
53
81
  }
54
82
  } catch { /* balance fetch best-effort */ }
55
83
  }
@@ -59,7 +87,9 @@ wallet.get("/", async (c) => {
59
87
  walletId: plotlinkWallet.id,
60
88
  name: plotlinkWallet.name,
61
89
  address,
90
+ ethBalance,
62
91
  usdcBalance,
92
+ plotBalance,
63
93
  accounts: plotlinkWallet.accounts,
64
94
  });
65
95
  } catch (err: unknown) {
package/app/server.ts CHANGED
@@ -10,15 +10,13 @@ dotenv.config({ path: ENV_FILE });
10
10
  import { Hono } from "hono";
11
11
  import { cors } from "hono/cors";
12
12
  import { serve } from "@hono/node-server";
13
- import { createNodeWebSocket } from "@hono/node-ws";
14
13
  import { serveStatic } from "@hono/node-server/serve-static";
15
14
  import { authRoutes, requireAuth } from "./routes/auth";
16
- import { configRoutes } from "./routes/config";
17
15
  import { walletRoutes } from "./routes/wallet";
18
- import { oauthRoutes } from "./routes/oauth";
19
- import { chatRoutes } from "./routes/chat";
20
16
  import { publishRoutes } from "./routes/publish";
21
17
  import { dashboardRoutes } from "./routes/dashboard";
18
+ import { terminalRoutes, attachTerminalWs } from "./routes/terminal";
19
+ import { storiesRoutes } from "./routes/stories";
22
20
  import { initDb } from "./db";
23
21
  import { execSync } from "child_process";
24
22
  import fs from "fs";
@@ -26,98 +24,26 @@ import fs from "fs";
26
24
  const __dirname = __dirnamePre;
27
25
 
28
26
  const app = new Hono();
29
- const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });
30
-
31
27
  // CORS for local dev
32
28
  app.use("/*", cors({ origin: "http://localhost:5173", credentials: true }));
33
29
 
34
30
  // API routes
35
31
  app.route("/api/auth", authRoutes);
36
32
  // Protected routes
37
- app.use("/api/config/*", requireAuth);
38
33
  app.use("/api/wallet/*", requireAuth);
39
- // OAuth: protect start/status but NOT callback (provider redirects without auth)
40
- app.use("/api/oauth/:provider/start", requireAuth);
41
- app.use("/api/oauth/:provider/status", requireAuth);
42
- app.route("/api/config", configRoutes);
43
34
  app.route("/api/wallet", walletRoutes);
44
- app.route("/api/oauth", oauthRoutes);
45
- app.use("/api/chat/*", requireAuth);
46
35
  app.use("/api/publish/*", requireAuth);
47
- app.route("/api/chat", chatRoutes);
48
36
  app.route("/api/publish", publishRoutes);
49
37
  app.use("/api/dashboard/*", requireAuth);
50
38
  app.route("/api/dashboard", dashboardRoutes);
39
+ app.use("/api/terminal/*", requireAuth);
40
+ app.route("/api/terminal", terminalRoutes);
41
+ app.use("/api/stories/*", requireAuth);
42
+ app.route("/api/stories", storiesRoutes);
51
43
 
52
44
  // Health check
53
45
  app.get("/api/health", (c) => c.json({ status: "ok" }));
54
46
 
55
- // WebSocket chat endpoint (auth via query param since WS can't send headers)
56
- app.get(
57
- "/ws/chat",
58
- upgradeWebSocket((c) => {
59
- const wsToken = new URL(c.req.url).searchParams.get("token");
60
- let authenticated = false;
61
-
62
- return {
63
- async onOpen(_event, ws) {
64
- if (!wsToken) { ws.close(4001, "Missing token"); return; }
65
- const { db } = await import("./db");
66
- const session = await db.session.findUnique({ where: { token: wsToken } });
67
- if (!session || session.expiresAt < new Date()) { ws.close(4001, "Invalid token"); return; }
68
- authenticated = true;
69
- },
70
- async onMessage(event, ws) {
71
- if (!authenticated) { ws.close(4001, "Not authenticated"); return; }
72
- try {
73
- const data = JSON.parse(String(event.data));
74
- if (data.type === "message" && data.sessionId && data.content) {
75
- const { db } = await import("./db");
76
- const { streamChat } = await import("./lib/llm-client");
77
- const { WRITER_SYSTEM_PROMPT } = await import("./lib/writer-prompt");
78
-
79
- // Save user message
80
- await db.message.create({
81
- data: { sessionId: data.sessionId, role: "user", content: data.content },
82
- });
83
-
84
- // Build context
85
- const messages = await db.message.findMany({
86
- where: { sessionId: data.sessionId },
87
- orderBy: { createdAt: "asc" },
88
- });
89
-
90
- const chatMessages = [
91
- { role: "system" as const, content: WRITER_SYSTEM_PROMPT },
92
- ...messages.map((m: { role: string; content: string }) => ({
93
- role: m.role as "user" | "assistant" | "system",
94
- content: m.content,
95
- })),
96
- ];
97
-
98
- // Stream response
99
- let fullResponse = "";
100
- for await (const chunk of streamChat(chatMessages)) {
101
- fullResponse += chunk;
102
- ws.send(JSON.stringify({ type: "chunk", content: chunk }));
103
- }
104
-
105
- // Save assistant message
106
- await db.message.create({
107
- data: { sessionId: data.sessionId, role: "assistant", content: fullResponse },
108
- });
109
-
110
- ws.send(JSON.stringify({ type: "done" }));
111
- }
112
- } catch (err: unknown) {
113
- const message = err instanceof Error ? err.message : "WebSocket error";
114
- ws.send(JSON.stringify({ type: "error", message }));
115
- }
116
- },
117
- };
118
- }),
119
- );
120
-
121
47
  // In production, serve the built frontend
122
48
  const distPath = path.join(__dirname, "web", "dist");
123
49
  if (fs.existsSync(distPath)) {
@@ -143,12 +69,34 @@ async function start() {
143
69
  // Initialize database connection
144
70
  await initDb();
145
71
 
72
+ // Ensure stories directory exists
73
+ const storiesDir = path.join(__dirname, "..", "stories");
74
+ if (!fs.existsSync(storiesDir)) fs.mkdirSync(storiesDir, { recursive: true });
75
+
146
76
  const port = Number(process.env.APP_PORT) || 7777;
147
77
  const server = serve({ fetch: app.fetch, port }, (info) => {
148
78
  console.log(`\n PlotLink OWS running at http://localhost:${info.port}\n`);
149
79
  });
150
80
 
151
- injectWebSocket(server);
81
+ // Terminal WebSocket: raw WS on /ws/terminal (bypasses Hono for raw PTY relay)
82
+ const { WebSocketServer } = await import("ws");
83
+ const wss = new WebSocketServer({ noServer: true });
84
+ // server from serve() IS an http.Server
85
+ (server as any).on("upgrade", (req: any, socket: any, head: any) => {
86
+ const url = new URL(req.url || "", `http://localhost:${port}`);
87
+ if (url.pathname === "/ws/terminal") {
88
+ // Auth check: verify token from query params
89
+ const wsToken = url.searchParams.get("token");
90
+ if (!wsToken) { socket.destroy(); return; }
91
+ import("./db").then(async ({ db }) => {
92
+ const session = await db.session.findUnique({ where: { token: wsToken } });
93
+ if (!session || session.expiresAt < new Date()) { socket.destroy(); return; }
94
+ wss.handleUpgrade(req, socket, head, (ws) => {
95
+ attachTerminalWs(ws as unknown as WebSocket);
96
+ });
97
+ }).catch(() => socket.destroy());
98
+ }
99
+ });
152
100
  }
153
101
 
154
102
  start().catch(console.error);
package/app/web/App.tsx CHANGED
@@ -3,8 +3,6 @@ import { Login } from "./components/Login";
3
3
  import { Setup } from "./components/Setup";
4
4
  import { Layout } from "./components/Layout";
5
5
 
6
- const API_BASE = "http://localhost:7777";
7
-
8
6
  export function App() {
9
7
  const [token, setToken] = useState<string | null>(() =>
10
8
  localStorage.getItem("ows-token"),
@@ -14,7 +12,7 @@ export function App() {
14
12
 
15
13
  useEffect(() => {
16
14
  // Check if passphrase is configured (first-run detection)
17
- fetch(`${API_BASE}/api/auth/status`)
15
+ fetch(`/api/auth/status`)
18
16
  .then((r) => r.json())
19
17
  .then((d) => setConfigured(d.configured))
20
18
  .catch(() => setConfigured(null));
@@ -26,7 +24,7 @@ export function App() {
26
24
  setChecking(false);
27
25
  return;
28
26
  }
29
- fetch(`${API_BASE}/api/auth/verify`, {
27
+ fetch(`/api/auth/verify`, {
30
28
  headers: { Authorization: `Bearer ${token}` },
31
29
  })
32
30
  .then((r) => {
@@ -44,7 +42,7 @@ export function App() {
44
42
 
45
43
  const handleLogin = async (passphrase: string): Promise<string | null> => {
46
44
  try {
47
- const res = await fetch(`${API_BASE}/api/auth/login`, {
45
+ const res = await fetch(`/api/auth/login`, {
48
46
  method: "POST",
49
47
  headers: { "Content-Type": "application/json" },
50
48
  body: JSON.stringify({ passphrase }),
@@ -61,7 +59,7 @@ export function App() {
61
59
 
62
60
  const handleSetup = async (passphrase: string): Promise<string | null> => {
63
61
  try {
64
- const res = await fetch(`${API_BASE}/api/auth/setup`, {
62
+ const res = await fetch(`/api/auth/setup`, {
65
63
  method: "POST",
66
64
  headers: { "Content-Type": "application/json" },
67
65
  body: JSON.stringify({ passphrase }),