From 751a27af4775396c8a7564297c129a333d69904c Mon Sep 17 00:00:00 2001 From: drepkovsky Date: Sun, 14 Jun 2026 17:43:08 +0200 Subject: [PATCH] Add web Unshare + live share-state in /api/board - `/api/board` annotates every task/spec/knowledge/design with its current share (link, gist url, sharedAt) read from shares.json, so the viewer knows what's already published. - The web ShareButton initializes from that state: an already-shared artifact opens showing its URL with Copy / Re-share / **Unshare**. - Unshare is a loopback-only `DELETE /api/share` -> removeShare: deletes the backing gist and clears the index, mirroring the CLI `share rm`. - Bump to 0.9.0. --- CHANGELOG.md | 4 ++++ README.md | 2 +- package.json | 2 +- src/share.ts | 4 ++-- src/web.ts | 40 +++++++++++++++++++++++++++++++++++----- src/web/app.js | 44 +++++++++++++++++++++++++++++++++----------- src/web/styles.css | 8 ++++++++ 7 files changed, 84 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 660416b..ab03795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Notable changes to `@questpie/agent-board` (CLI binaries: `agent-board`, `agent` ## Unreleased +## 0.9.0 — 2026-06-14 + +- **Unshare from the web viewer**: every task/spec/knowledge/design detail now reflects its live share state. `/api/board` reports each artifact's published link, so an already-shared item opens showing its URL with **Copy link**, **Re-share**, and a new **Unshare** action — a loopback-only `DELETE /api/share` that deletes the backing gist and clears the `shares.json` entry. The board now mirrors the CLI's `share rm`: you can see what's shared and revoke it without leaving the viewer. + ## 0.8.0 — 2026-06-14 - **Share a single artifact as a public link**: `agent-board share ` publishes one board artifact as a secret GitHub gist (through your existing `gh` auth, `gist` scope) rendered by a zero-build static viewer (`docs/share`, hosted on the repo's GitHub Pages). A design's HTML bundle is inlined into one self-contained file — local stylesheets, scripts, images, and CSS `url(...)` become inline content and data URLs, while CDN/absolute references are left alone — so a recipient sees the mockup with nothing installed; specs, tasks, and knowledge render as Markdown. Re-sharing updates the same gist, so the link stays stable; shares are tracked in a non-invasive `shares.json` at the project root. `agent-board share list` and `agent-board share rm ` manage them. A secret gist is link-private, not access-controlled; point shares at a different viewer with `AGENT_BOARD_SHARE_VIEWER`. The web viewer (`agent-board web`) also gained a **Share** button on every task, spec, knowledge, and design detail — a single, loopback-only `POST /api/share` endpoint (the board's first mutating route, gated to localhost so binding `--host 0.0.0.0` can't let a LAN peer publish on your behalf). diff --git a/README.md b/README.md index e7dfe44..0db1ae6 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ agent-board share rm design # delete a share and its backing gist Each share is stored as a **secret GitHub gist** (created through your existing `gh` auth) and rendered by a small static viewer. A design's HTML bundle is inlined into one file — local stylesheets, scripts, images, and `url(...)` assets become inline content and data URLs, while CDN/absolute references are left alone — so the recipient sees the mockup exactly as the board does, with nothing to install. Re-running `share` on the same artifact updates the same gist, so the URL stays stable. -The web viewer carries the same action: every task, spec, knowledge, and design detail has a **Share** button that posts to a localhost-only endpoint and shows the link inline, so you can publish without leaving the board. +The web viewer carries the same actions: every task, spec, knowledge, and design detail shows its live share state — already-shared items open with their link, **Copy**, **Re-share**, and **Unshare** (revoke). It all runs through a localhost-only endpoint, so you can publish and revoke without leaving the board. Setup is one-time: authenticate `gh` with the `gist` scope (`gh auth login`), and enable GitHub Pages for the viewer (served from `docs/share`). Until Pages is live the command also prints the raw gist URL as a fallback. Point shares at a different viewer with `AGENT_BOARD_SHARE_VIEWER`. diff --git a/package.json b/package.json index 3707cbc..da8a80f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@questpie/agent-board", - "version": "0.8.0", + "version": "0.9.0", "description": "Markdown task board and execution contract for coding agents", "license": "MIT", "repository": { diff --git a/src/share.ts b/src/share.ts index 9181269..8df792d 100644 --- a/src/share.ts +++ b/src/share.ts @@ -264,7 +264,7 @@ function indexPath(workspace: Workspace): string { return join(workspace.projectPath, "shares.json"); } -async function readShareIndex(workspace: Workspace): Promise> { +export async function readShareIndex(workspace: Workspace): Promise> { try { const parsed = JSON.parse(await readFile(indexPath(workspace), "utf8")); return parsed && typeof parsed === "object" ? (parsed as Record) : {}; @@ -277,7 +277,7 @@ async function writeShareIndex(workspace: Workspace, index: Record { const url = new URL(req.url); try { if (req.method === "POST" && url.pathname === "/api/share") return await handleShare(req, url, server); + if (req.method === "DELETE" && url.pathname === "/api/share") return await handleUnshare(req, url, server); if (url.pathname.startsWith("/api/")) return await handleApi(url); if (url.pathname.startsWith("/wireframes/")) return await handleWireframeAsset(url); return await serveStatic(url.pathname); @@ -93,6 +94,11 @@ async function board( listFlowRuns(workspace), ]); const wireframes = await listWireframes(workspace); + const shares = await readShareIndex(workspace); + const sharedFor = (kind: "design" | "spec" | "task" | "knowledge", scope: string, id: string) => { + const record = shares[shareKey(kind, scope, id)]; + return record ? { url: record.url, gistUrl: record.gistUrl, sharedAt: record.sharedAt } : null; + }; const goalSummaries = await Promise.all( goals.map(async (g) => ({ id: g.id, title: g.title, active: g.active, updated: await goalUpdatedMs(g.path) })), ); @@ -100,10 +106,10 @@ async function board( projects: projects.map((p) => ({ slug: p.slug, repo_path: p.repo_path })), current: { project: workspace.projectSlug, goal: workspace.goalSlug, repo: workspace.repoPath }, goals: goalSummaries, - tasks: tasks.map((t) => ({ meta: t.meta, body: t.body })), - specs: specs.map((s) => ({ scope: s.scope, meta: s.meta, body: s.body })), - knowledge: knowledge.map((k) => ({ scope: k.scope, meta: k.meta, body: k.body })), - wireframes: wireframes.map((w) => ({ scope: w.scope, meta: w.meta, body: w.body, url: wireframeWebPath(workspace, w) })), + tasks: tasks.map((t) => ({ meta: t.meta, body: t.body, shared: sharedFor("task", workspace.goalSlug, t.meta.id) })), + specs: specs.map((s) => ({ scope: s.scope, meta: s.meta, body: s.body, shared: sharedFor("spec", s.scope, s.meta.id) })), + knowledge: knowledge.map((k) => ({ scope: k.scope, meta: k.meta, body: k.body, shared: sharedFor("knowledge", k.scope, k.meta.id) })), + wireframes: wireframes.map((w) => ({ scope: w.scope, meta: w.meta, body: w.body, url: wireframeWebPath(workspace, w), shared: sharedFor("design", w.scope, w.meta.id) })), flows: flows.map((f) => ({ name: f.name })), runs, }; @@ -431,6 +437,30 @@ async function handleShare( return json(await shareArtifact(workspace, kind, id)); } +// Revoke a share: delete the backing gist and drop it from the index. Also +// loopback-only — same reasoning as handleShare. +async function handleUnshare( + req: Request, + url: URL, + server: ReturnType, +): Promise { + const peer = server.requestIP(req); + if (peer && !isLoopback(peer.address)) { + return json({ error: "Sharing is only available from localhost." }, 403); + } + const projects = await listProjects(); + if (!projects.length) return json({ error: "No agent-board project found." }, 404); + const workspace = resolveWs( + projects, + url.searchParams.get("project") ?? undefined, + url.searchParams.get("goal") ?? undefined, + ); + const id = (url.searchParams.get("id") ?? "").trim(); + if (!id) return json({ error: "Missing artifact id." }, 400); + const kind = parseShareKind(String(url.searchParams.get("kind") ?? "")); + return json(await removeShare(workspace, kind, id)); +} + function isLoopback(address: string): boolean { return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1"; } diff --git a/src/web/app.js b/src/web/app.js index 35de66e..280da80 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -474,23 +474,43 @@ function TasksTab({ board, selId, setSelId, go }) { `; } -function ShareButton({ kind, id, current }) { - const [state, setState] = useState({ status: "idle" }); +function ShareButton({ kind, id, current, shared }) { + const [state, setState] = useState(() => + shared ? { status: "done", viewerUrl: shared.url, gistUrl: shared.gistUrl } : { status: "idle" }, + ); const [copied, setCopied] = useState(false); + const [busy, setBusy] = useState(false); + const q = `project=${encodeURIComponent(current.project)}&goal=${encodeURIComponent(current.goal)}`; const share = useCallback(async () => { - setState({ status: "loading" }); + setState((s) => ({ ...s, status: "loading" })); try { - const res = await fetch( - `/api/share?project=${encodeURIComponent(current.project)}&goal=${encodeURIComponent(current.goal)}`, - { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ kind, id }) }, - ); + const res = await fetch(`/api/share?${q}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ kind, id }), + }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data?.error || res.statusText); setState({ status: "done", ...data }); } catch (e) { setState({ status: "error", message: e.message }); } - }, [kind, id, current]); + }, [kind, id, q]); + const unshare = useCallback(async () => { + setBusy(true); + try { + const res = await fetch(`/api/share?${q}&kind=${encodeURIComponent(kind)}&id=${encodeURIComponent(id)}`, { + method: "DELETE", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error || res.statusText); + setState({ status: "idle" }); + } catch (e) { + setState((s) => ({ ...s, warn: e.message })); + } finally { + setBusy(false); + } + }, [kind, id, q]); const copy = useCallback(async () => { try { await navigator.clipboard.writeText(state.viewerUrl); @@ -507,7 +527,9 @@ function ShareButton({ kind, id, current }) { + + ${state.warn ? html`` : null} ${(state.warnings || []).map((w) => html``)} `; } @@ -544,7 +566,7 @@ function TaskDetail({ board, task, setSelId, go }) {

${m.title}

${m.id}
-
<${ShareButton} key=${m.id} kind="task" id=${m.id} current=${board.current} />
+
<${ShareButton} key=${m.id} kind="task" id=${m.id} current=${board.current} shared=${task.shared} />
${fields.map( ([k, v, mono]) => html`
@@ -656,7 +678,7 @@ function DocsTab({ items, kind, selId, setSelId, current }) {

${selected.meta.title}

${selected.meta.id} · updated ${fmtDate(selected.meta.updated)}
-
<${ShareButton} key=${selected.meta.id} kind=${kind === "knowledge" ? "knowledge" : "spec"} id=${selected.meta.id} current=${current} />
+
<${ShareButton} key=${selected.meta.id} kind=${kind === "knowledge" ? "knowledge" : "spec"} id=${selected.meta.id} current=${current} shared=${selected.shared} />
<${Markdown} source=${selected.body} /> ` : html`
Select a ${kind.replace(/s$/, "")} to read it.
`} @@ -757,7 +779,7 @@ function WireframesTab({ items, selId, setSelId, current }) {
Open -
<${ShareButton} key=${selected.meta.id} kind="design" id=${selected.meta.id} current=${current} />
+
<${ShareButton} key=${selected.meta.id} kind="design" id=${selected.meta.id} current=${current} shared=${selected.shared} />