diff --git a/bun.lock b/bun.lock
index e22cb8f..84eed56 100644
--- a/bun.lock
+++ b/bun.lock
@@ -18,7 +18,7 @@
"devDependencies": {
"@effect/tsgo": "^0.45.0",
"@oxlint/plugins": "1.82.0",
- "@timmo001/oxlint-rules": "0.3.0",
+ "@timmo001/oxlint-rules": "0.3.3",
"@types/bun": "^1.3.14",
"@types/proper-lockfile": "^4.1.4",
"oxlint": "1.82.0",
@@ -142,7 +142,7 @@
"@timmo001/effect-gh": ["@timmo001/effect-gh@github:timmo001/effect-gh#b1bce7c", { "peerDependencies": { "effect": "4.0.0-rc.115" } }, "timmo001-effect-gh-b1bce7c", "sha512-MV4HrF/WFOG/pNGmxccPe+ZSeBzA1FoyhTU9SnvtLk2kCtBWbm8Y5UWssYWiZVnZPuRBMdWeouYW5O5rq5APOQ=="],
- "@timmo001/oxlint-rules": ["@timmo001/oxlint-rules@0.3.0", "", { "peerDependencies": { "@oxlint/plugins": "1.81.0", "oxlint": "1.81.0" }, "bin": { "oxlint-rules": "dist/cli.js" } }, "sha512-TvpyUOFeQFfiLXYkFWFI7a4H6LXc/T0ILqP9w8VxBxldauATVsePddgN+rA4Ks2n63d8pkLBWedgfeVBx+8o/Q=="],
+ "@timmo001/oxlint-rules": ["@timmo001/oxlint-rules@0.3.3", "", { "peerDependencies": { "@oxlint/plugins": "1.82.0", "oxlint": "1.82.0" }, "bin": { "oxlint-rules": "dist/cli.js" } }, "sha512-Hshoa5LI9HV1VJRUOz7iAhdz1l12ygXB1bfp2bhYkaA1O3zvzUqAIDZa4GGuhbA9z8j3vDG1tzmxbgA7H+/cDQ=="],
"@tuiparts/core": ["@tuiparts/core@0.0.6", "", { "peerDependencies": { "@opentui/core": "^0.4.3" } }, "sha512-DNqtRprlpMJGUHVTm+L0R/P2Gbn392NZdzna8YSPQkgfNjyroSKpZieg1jTTnYAQn7cz5LpiwtDjw3RCUBrulw=="],
diff --git a/capture/public/service-worker.js b/capture/public/service-worker.js
index 4e92941..e6c0eeb 100644
--- a/capture/public/service-worker.js
+++ b/capture/public/service-worker.js
@@ -1,4 +1,5 @@
const CACHE_NAME = "notes-capture-v2";
+
const PUBLIC_ASSETS = ["/manifest.webmanifest", "/icons/icon.svg"];
self.addEventListener("install", (event) => {
diff --git a/capture/src/capture/http.ts b/capture/src/capture/http.ts
index 689566c..343bd5f 100644
--- a/capture/src/capture/http.ts
+++ b/capture/src/capture/http.ts
@@ -17,6 +17,7 @@ export const GENERIC_CAPTURE_ERROR =
const CaptureErrorResponse = Schema.Struct({
error: Schema.Literals(Object.values(CAPTURE_ERRORS)),
});
+
const decodeCaptureErrorOption =
Schema.decodeUnknownOption(CaptureErrorResponse);
@@ -25,5 +26,6 @@ export const decodeCaptureError = (value: Input) =>
export function captureErrorMessage(value: Input): string {
const error = decodeCaptureError(value);
+
return error ? `${error}. Your text is still here.` : GENERIC_CAPTURE_ERROR;
}
diff --git a/capture/src/capture/issuePayload.ts b/capture/src/capture/issuePayload.ts
index 6867b3d..d713680 100644
--- a/capture/src/capture/issuePayload.ts
+++ b/capture/src/capture/issuePayload.ts
@@ -11,7 +11,9 @@ export interface IssuePayload {
function defaultTitle(text: string): string {
const firstLine = text.split("\n", 1)[0]?.trim() ?? "";
+
if (firstLine.length <= 72) return firstLine;
+
return `${firstLine.slice(0, 69).trimEnd()}...`;
}
@@ -20,6 +22,7 @@ export function buildIssuePayload(
queueLabel: string,
): IssuePayload {
const title = capture.titleHint?.trim() || defaultTitle(capture.text);
+
return {
title,
labels: [queueLabel],
diff --git a/capture/src/capture/repositories.ts b/capture/src/capture/repositories.ts
index 3149e8d..5dfd3fa 100644
--- a/capture/src/capture/repositories.ts
+++ b/capture/src/capture/repositories.ts
@@ -17,12 +17,15 @@ export function parseRepositoryOptions(
if (!raw) return undefined;
const options = Schema.decodeUnknownSync(RepositoryOptions)(JSON.parse(raw));
+
if (options.length === 0) return undefined;
const repositories = new Set(options.map((option) => option.repository));
+
if (repositories.size !== options.length) {
throw new Error("Capture repositories contain duplicates");
}
+
return options;
}
@@ -31,16 +34,20 @@ export function validateTargetRepository(
options: readonly RepositoryOption[] | undefined,
): void {
if (selectedRepository === undefined) return;
+
if (options?.some(({ repository }) => repository === selectedRepository)) {
return;
}
+
throw new Error("Capture repository is not allowed");
}
export function splitRepository(repository: string): readonly [string, string] {
const separator = repository.indexOf("/");
+
if (separator <= 0 || separator === repository.length - 1) {
throw new Error("Capture repository is invalid");
}
+
return [repository.slice(0, separator), repository.slice(separator + 1)];
}
diff --git a/capture/src/capture/services/AccessAuth.ts b/capture/src/capture/services/AccessAuth.ts
index 7dab21a..fd8f64d 100644
--- a/capture/src/capture/services/AccessAuth.ts
+++ b/capture/src/capture/services/AccessAuth.ts
@@ -1,4 +1,4 @@
-import { Schema } from "effect";
+import { Option, Schema } from "effect";
import { createRemoteJWKSet, jwtVerify } from "jose";
export interface AccessIdentity {
@@ -16,20 +16,24 @@ export async function verifyAccessRequest(
config: AccessConfig,
): Promise {
const token = request.headers.get("Cf-Access-Jwt-Assertion");
+
if (!token || config.audience === "configure-after-access-app-creation") {
throw new Error("Cloudflare Access authentication is not configured");
}
const issuer = `https://${config.teamDomain}`;
const keys = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`));
+
const { payload } = await jwtVerify(token, keys, {
audience: config.audience,
issuer,
});
+
if (!payload.sub) throw new Error("Cloudflare Access token has no subject");
const email = Schema.decodeUnknownOption(Schema.String)(payload.email);
- return email._tag === "Some"
+
+ return Option.isSome(email)
? { subject: payload.sub, email: email.value }
: { subject: payload.sub };
}
diff --git a/capture/src/capture/services/GitHubIssues.ts b/capture/src/capture/services/GitHubIssues.ts
index 3e82c31..a3bf494 100644
--- a/capture/src/capture/services/GitHubIssues.ts
+++ b/capture/src/capture/services/GitHubIssues.ts
@@ -41,11 +41,13 @@ export async function createGitHubIssue(
body: JSON.stringify(payload),
},
);
+
if (!response.ok) {
throw new Error(`GitHub issue creation failed (${response.status})`);
}
let result: typeof GitHubIssueResponse.Type;
+
try {
result = Schema.decodeUnknownSync(GitHubIssueResponse)(
await response.json(),
@@ -53,5 +55,6 @@ export async function createGitHubIssue(
} catch {
throw new Error("GitHub returned an invalid issue response");
}
+
return { number: result.number, url: result.html_url };
}
diff --git a/capture/src/middleware.ts b/capture/src/middleware.ts
index c80b9d9..c467d7c 100644
--- a/capture/src/middleware.ts
+++ b/capture/src/middleware.ts
@@ -10,6 +10,7 @@ export const onRequest = defineMiddleware(async ({ request }, next) => {
audience: env.ACCESS_AUD,
teamDomain: env.ACCESS_TEAM_DOMAIN,
});
+
return next();
} catch {
return new Response("Unauthorized", { status: 401 });
diff --git a/capture/src/pages/api/captures.ts b/capture/src/pages/api/captures.ts
index cf84c5e..90997f9 100644
--- a/capture/src/pages/api/captures.ts
+++ b/capture/src/pages/api/captures.ts
@@ -32,30 +32,37 @@ export const POST = (async ({ request }) => {
reason: "content-type",
status: 415,
});
+
return json({ error: CAPTURE_ERRORS.expectedJson }, 415);
}
+
const length = Number(request.headers.get("Content-Length") ?? 0);
+
if (length > MAX_REQUEST_BYTES) {
console.warn("Capture submission rejected", {
reason: "declared-size",
status: 413,
bytes: length,
});
+
return json({ error: CAPTURE_ERRORS.tooLarge }, 413);
}
const raw = await request.text();
const bytes = new TextEncoder().encode(raw).byteLength;
+
if (bytes > MAX_REQUEST_BYTES) {
console.warn("Capture submission rejected", {
reason: "measured-size",
status: 413,
bytes,
});
+
return json({ error: CAPTURE_ERRORS.tooLarge }, 413);
}
let capture: Capture;
+
try {
capture = decodeCapture(JSON.parse(raw));
} catch {
@@ -63,11 +70,13 @@ export const POST = (async ({ request }) => {
reason: "invalid-capture",
status: 400,
});
+
return json({ error: CAPTURE_ERRORS.invalidCapture }, 400);
}
const defaultRepository = `${env.GITHUB_OWNER}/${env.GITHUB_REPO}`;
let repositories: readonly RepositoryOption[] | undefined;
+
try {
repositories = parseRepositoryOptions(env.CAPTURE_REPOSITORIES);
} catch {
@@ -76,11 +85,13 @@ export const POST = (async ({ request }) => {
status: 500,
requestId: capture.requestId,
});
+
return json({ error: CAPTURE_ERRORS.invalidConfiguration }, 500);
}
let owner: string;
let repository: string;
+
try {
validateTargetRepository(capture.repository, repositories);
[owner, repository] = splitRepository(defaultRepository);
@@ -90,6 +101,7 @@ export const POST = (async ({ request }) => {
status: 400,
requestId: capture.requestId,
});
+
return json({ error: CAPTURE_ERRORS.invalidRepository }, 400);
}
@@ -105,6 +117,7 @@ export const POST = (async ({ request }) => {
}),
}),
);
+
return json(issue, 201);
} catch {
console.error("Capture submission failed", {
@@ -112,6 +125,7 @@ export const POST = (async ({ request }) => {
status: 502,
requestId: capture.requestId,
});
+
return json({ error: CAPTURE_ERRORS.queueFailed }, 502);
}
}) satisfies APIRoute;
diff --git a/capture/src/scripts/capture.ts b/capture/src/scripts/capture.ts
index 8c9e50a..cf2ea5e 100644
--- a/capture/src/scripts/capture.ts
+++ b/capture/src/scripts/capture.ts
@@ -1,5 +1,5 @@
import { captureErrorMessage, GENERIC_CAPTURE_ERROR } from "../capture/http.js";
-import { Schema } from "effect";
+import { Option, Schema } from "effect";
import {
filterRepositories,
REPOSITORY_STORAGE_KEY,
@@ -7,8 +7,11 @@ import {
} from "./repositoryPicker.js";
const form = document.querySelector("[data-capture-form]");
+
const textarea = document.querySelector("#capture");
+
const status = document.querySelector("[data-status]");
+
const repositoryPicker = document.querySelector(
"[data-repository-picker]",
);
@@ -18,27 +21,34 @@ if (!form || !textarea || !status) {
}
let preserveRepositorySelection = () => {};
+
let repositoryForCapture: string | undefined;
if (repositoryPicker) {
const repositoryValue = repositoryPicker.querySelector(
"[data-repository-value]",
);
+
const repositoryLabel = repositoryPicker.querySelector(
"[data-repository-label]",
);
+
const repositoryTrigger = repositoryPicker.querySelector(
"[data-repository-trigger]",
);
+
const popover = repositoryPicker.querySelector(
"[data-repository-popover]",
);
+
const search = repositoryPicker.querySelector(
"[data-repository-search]",
);
+
const empty = repositoryPicker.querySelector(
"[data-repository-empty]",
);
+
const options = Array.from(
repositoryPicker.querySelectorAll(
"[data-repository-option]",
@@ -57,10 +67,12 @@ if (repositoryPicker) {
}
let selectedRepository = repositoryValue.value;
+
const selectRepository = (repository: string) => {
const selected = options.find(
(option) => option.dataset.repositoryOption === repository,
);
+
if (!selected) return;
repositoryValue.value = repository;
@@ -72,6 +84,7 @@ if (repositoryPicker) {
);
selectedRepository = repository;
repositoryForCapture = repository;
+
for (const option of options) {
option.setAttribute(
"aria-pressed",
@@ -81,11 +94,13 @@ if (repositoryPicker) {
};
let storedRepository: string | null = null;
+
try {
storedRepository = localStorage.getItem(REPOSITORY_STORAGE_KEY);
} catch {
// Storage can be unavailable without preventing capture submission.
}
+
selectRepository(
restoreRepository(
storedRepository,
@@ -102,13 +117,16 @@ if (repositoryPicker) {
for (const option of options) {
option.addEventListener("click", () => {
const repository = option.dataset.repositoryOption;
+
if (repository === undefined) return;
selectRepository(repository);
+
try {
localStorage.setItem(REPOSITORY_STORAGE_KEY, repository);
} catch {
// Keep the in-page selection when persistence is blocked.
}
+
popover.hidePopover?.();
});
}
@@ -123,9 +141,11 @@ if (repositoryPicker) {
search.value,
),
);
+
for (const option of options) {
option.hidden = !visible.has(option.dataset.repositoryOption ?? "");
}
+
empty.hidden = visible.size > 0;
});
@@ -141,11 +161,13 @@ if (repositoryPicker) {
form.addEventListener("submit", async (event) => {
event.preventDefault();
const submit = form.querySelector("[type=submit]");
+
if (!submit || !textarea.value.trim()) return;
submit.disabled = true;
status.textContent = "Adding note...";
let responseError: string | undefined;
+
try {
const capture = {
version: 1,
@@ -155,26 +177,34 @@ form.addEventListener("submit", async (event) => {
source: "text",
repository: repositoryForCapture,
};
+
const response = await fetch("/api/captures", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(capture),
});
+
const result = await response.json();
+
if (!response.ok) {
responseError = captureErrorMessage(result);
throw new Error("Capture request failed");
}
+
const issue = Schema.decodeUnknownOption(
Schema.Struct({ url: Schema.String }),
)(result);
- if (issue._tag === "None") {
+
+ if (Option.isNone(issue)) {
throw new Error("Capture request failed");
}
+
const issueUrl = new URL(issue.value.url);
+
if (issueUrl.protocol !== "https:" || issueUrl.hostname !== "github.com") {
throw new Error("Unexpected issue URL");
}
+
form.reset();
preserveRepositorySelection();
const link = document.createElement("a");
diff --git a/capture/src/scripts/repositoryPicker.ts b/capture/src/scripts/repositoryPicker.ts
index 9884fb0..d6cdcaa 100644
--- a/capture/src/scripts/repositoryPicker.ts
+++ b/capture/src/scripts/repositoryPicker.ts
@@ -5,11 +5,12 @@ export function filterRepositories(
query: string,
): readonly string[] {
const normalizedQuery = query.trim().toLocaleLowerCase();
- return options
- .filter(({ searchText }) =>
- searchText.toLocaleLowerCase().includes(normalizedQuery),
- )
- .map(({ repository }) => repository);
+
+ return options.flatMap(({ repository, searchText }) =>
+ searchText.toLocaleLowerCase().includes(normalizedQuery)
+ ? [repository]
+ : [],
+ );
}
export function restoreRepository(
diff --git a/capture/tests/capture/GitHubIssues.test.ts b/capture/tests/capture/GitHubIssues.test.ts
index e91c938..9175122 100644
--- a/capture/tests/capture/GitHubIssues.test.ts
+++ b/capture/tests/capture/GitHubIssues.test.ts
@@ -21,6 +21,7 @@ describe("createGitHubIssue", () => {
test("does not expose the response body on failure", async () => {
let message = "";
+
try {
await createGitHubIssue(
{ title: "Test", body: "Body", labels: ["agent:ready"] },
@@ -30,6 +31,7 @@ describe("createGitHubIssue", () => {
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
+
expect(message).toBe("GitHub issue creation failed (403)");
});
});
diff --git a/docs/scripts/generate-cli-reference.ts b/docs/scripts/generate-cli-reference.ts
index eec99b5..2a04f64 100644
--- a/docs/scripts/generate-cli-reference.ts
+++ b/docs/scripts/generate-cli-reference.ts
@@ -6,8 +6,11 @@ import { renderHelp } from "../../src/cli/help.ts";
import { notesCommand } from "../../src/index.ts";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+
const outFile = path.join(root, "src/content/docs/cli/commands.md");
+
const commands = notesCommand.subcommands.flatMap((group) => group.commands);
+
const lines = [
"---",
"title: Command Reference",
@@ -34,5 +37,7 @@ for (const command of commands) {
}
await mkdir(path.dirname(outFile), { recursive: true });
+
await writeFile(outFile, `${lines.join("\n").trimEnd()}\n`);
+
console.log(`Wrote ${path.relative(root, outFile)}`);
diff --git a/docs/scripts/generate-mcp-reference.ts b/docs/scripts/generate-mcp-reference.ts
index 60b2c5f..3d0def0 100644
--- a/docs/scripts/generate-mcp-reference.ts
+++ b/docs/scripts/generate-mcp-reference.ts
@@ -4,22 +4,35 @@ import path from "node:path";
import { mcpTools } from "../../src/mcp/toolMetadata.ts";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+
const outFile = path.join(root, "src/content/docs/mcp/tools.md");
+
const lines: string[] = [];
+
const push = (line = "") => lines.push(line);
+
const code = (text: string) => `\`${text}\``;
push("---");
+
push("title: MCP Tool Reference");
+
push("description: Generated reference for the notes MCP tools.");
+
push("sidebar:");
+
push(" order: 2");
+
push("---");
+
push();
+
push(
"",
);
+
push();
+
for (const tool of mcpTools) {
push(`## ${code(tool.name)}`);
push();
@@ -28,14 +41,17 @@ for (const tool of mcpTools) {
push(`CLI equivalent: ${code(tool.cli)}`);
push();
const entries = Object.entries(tool.parameters);
+
if (entries.length) {
push("| Parameter | Type | Default | CLI | Description |");
push("| --- | --- | --- | --- | --- |");
+
for (const [name, parameter] of entries) {
push(
`| ${code(name)} | ${parameter.type} | ${parameter.default ?? ""} | ${parameter.cli ? code(parameter.cli) : ""} | ${parameter.description} |`,
);
}
+
push();
} else {
push("No parameters.");
@@ -44,6 +60,7 @@ for (const tool of mcpTools) {
}
await mkdir(path.dirname(outFile), { recursive: true });
+
await writeFile(
outFile,
`${lines
@@ -51,4 +68,5 @@ await writeFile(
.replace(/\n{3,}/g, "\n\n")
.trimEnd()}\n`,
);
+
console.log(`Wrote ${path.relative(root, outFile)}`);
diff --git a/docs/scripts/generate-og.mjs b/docs/scripts/generate-og.mjs
index f03f93d..1d0b57d 100644
--- a/docs/scripts/generate-og.mjs
+++ b/docs/scripts/generate-og.mjs
@@ -8,12 +8,14 @@ import sharp from "sharp";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const W = 1200;
+
const H = 630;
// Embed the logo as a nested SVG at a fixed position and size.
const logoRaw = (
await readFile(path.join(root, "src/assets/logo.svg"), "utf8")
).trim();
+
const logo = logoRaw.replace(
'