feat: gbrain publish -- shareable HTML with password protection

First code+skill pair: deterministic code does the work (strip private data,
encrypt with AES-256-GCM, generate self-contained HTML), the skill tells the
agent when and how to use it. 34 new tests.

See: https://x.com/garrytan/status/2042925773300908103
This commit is contained in:
Garry Tan
2026-04-11 21:22:44 -10:00
parent c8d6d59bef
commit edc2174661
8 changed files with 728 additions and 4 deletions

View File

@@ -49,6 +49,15 @@ All notable changes to GBrain will be documented in this file.
- **X-to-Brain gets eyes.** Image OCR, Filtered Stream real-time monitoring, - **X-to-Brain gets eyes.** Image OCR, Filtered Stream real-time monitoring,
6-dimension tweet rating rubric, outbound tweet monitoring, cron staggering. 6-dimension tweet rating rubric, outbound tweet monitoring, cron staggering.
- **Share brain pages without exposing the brain.** `gbrain publish` generates
beautiful, self-contained HTML from any brain page. Strips private data
(frontmatter, citations, confirmations, brain links, timeline) automatically.
Optional AES-256-GCM password gate with client-side decryption, no server
needed. Dark/light mode, mobile-optimized typography. This is the first
code+skill pair: deterministic code does the work, the skill tells the agent
when and how. See the [Thin Harness, Fat Skills](https://x.com/garrytan/status/2042925773300908103)
thread for the architecture philosophy.
### Changed ### Changed
- **Supabase Storage** now auto-selects upload method by file size: standard POST - **Supabase Storage** now auto-selects upload method by file size: standard POST
@@ -61,7 +70,10 @@ All notable changes to GBrain will be documented in this file.
uploaded, source_url, type). uploaded, source_url, type).
- **All skills** updated to reference actual `gbrain files` commands instead of - **All skills** updated to reference actual `gbrain files` commands instead of
theoretical patterns. theoretical patterns.
- **Publish skill** added to manifest (8th skill). First code+skill pair.
- Skills version bumped to 0.9.0. - Skills version bumped to 0.9.0.
- 34 new unit tests for publish (content stripping, encryption, password
generation, HTML output, citation patterns). Total: 376 pass.
## [0.8.0] - 2026-04-11 ## [0.8.0] - 2026-04-11

View File

@@ -52,6 +52,7 @@ markdown files (tool-agnostic, work with both CLI and plugin contexts).
- `docs/mcp/` — Per-client setup guides (Claude Desktop, Code, Cowork, Perplexity) - `docs/mcp/` — Per-client setup guides (Claude Desktop, Code, Cowork, Perplexity)
- `skills/_brain-filing-rules.md` — Cross-cutting brain filing rules (referenced by all brain-writing skills) - `skills/_brain-filing-rules.md` — Cross-cutting brain filing rules (referenced by all brain-writing skills)
- `skills/migrations/` — Version migration files with feature_pitch YAML frontmatter - `skills/migrations/` — Version migration files with feature_pitch YAML frontmatter
- `src/commands/publish.ts` — Deterministic brain page publisher (code+skill pair, zero LLM calls)
- `openclaw.plugin.json` — ClawHub bundle plugin manifest - `openclaw.plugin.json` — ClawHub bundle plugin manifest
## Commands ## Commands
@@ -79,7 +80,8 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/yaml-lite.test.ts` (YAML parsing), `test/check-update.test.ts` (version check + update CLI), `test/yaml-lite.test.ts` (YAML parsing), `test/check-update.test.ts` (version check + update CLI),
`test/pglite-engine.test.ts` (PGLite engine, all 37 BrainEngine methods), `test/pglite-engine.test.ts` (PGLite engine, all 37 BrainEngine methods),
`test/utils.test.ts` (shared SQL utilities), `test/engine-factory.test.ts` (engine factory + dynamic imports), `test/utils.test.ts` (shared SQL utilities), `test/engine-factory.test.ts` (engine factory + dynamic imports),
`test/integrations.test.ts` (recipe parsing, CLI routing, recipe validation). `test/integrations.test.ts` (recipe parsing, CLI routing, recipe validation),
`test/publish.test.ts` (content stripping, encryption, password generation, HTML output).
E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_URL`. E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_URL`.
- `bun run test:e2e` runs Tier 1 (mechanical, all operations, no API keys) - `bun run test:e2e` runs Tier 1 (mechanical, all operations, no API keys)

View File

@@ -4,10 +4,11 @@ title: "Thin Harness, Fat Skills"
subtitle: "How to Make AI Agents Actually Understand Your Data" subtitle: "How to Make AI Agents Actually Understand Your Data"
author: Garry Tan author: Garry Tan
created: 2026-04-09 created: 2026-04-09
updated: 2026-04-09 updated: 2026-04-11
tags: [ai, agents, gstack, harness-engineering, skills, architecture] tags: [ai, agents, gstack, harness-engineering, skills, architecture]
status: draft-v4 status: draft-v4
talk: "YC Spring 2026 Thin Harness, Fat Skills" talk: "YC Spring 2026 -- Thin Harness, Fat Skills"
thread: https://x.com/garrytan/status/2042925773300908103
--- ---
# Thin Harness, Fat Skills # Thin Harness, Fat Skills

View File

@@ -37,6 +37,11 @@
"name": "setup", "name": "setup",
"path": "setup/SKILL.md", "path": "setup/SKILL.md",
"description": "Set up GBrain: auto-provision Supabase or PGLite, AGENTS.md injection, first import" "description": "Set up GBrain: auto-provision Supabase or PGLite, AGENTS.md injection, first import"
},
{
"name": "publish",
"path": "publish/SKILL.md",
"description": "Share brain pages as beautiful password-protected HTML (code + skill pair, zero LLM calls)"
} }
], ],
"dependencies": { "dependencies": {

129
skills/publish/SKILL.md Normal file
View File

@@ -0,0 +1,129 @@
# Publish Skill
Share brain pages as beautiful, self-contained HTML documents. Optionally
password-protected with client-side AES-256-GCM encryption. No server needed.
This is a **code + skill pair**: the deterministic code (`gbrain publish`) does
the stripping, encrypting, and HTML generation. This skill tells you when and
how to use it. See [Thin Harness, Fat Skills](https://x.com/garrytan/status/2042925773300908103)
for the architecture philosophy.
## When to Publish
- User asks to share a brain page, create a shareable link, or says "give me a page"
- User wants to send a deal memo, person briefing, or research to someone external
- User asks to publish a data room analysis or trip plan
- Any time brain content needs to leave the brain without exposing the whole system
## Default: ALWAYS ENCRYPT
Brain content is private. Default to password-protected unless the user explicitly
says "open", "no password", or "public".
If no password is specified, auto-generate one. Share the password via a different
channel than the URL.
## Quick Reference
```bash
# Basic publish (outputs local HTML file)
gbrain publish brain/companies/acme.md
# Password protected (auto-generate password)
gbrain publish brain/companies/acme.md --password
# Password protected (specific password)
gbrain publish brain/companies/acme.md --password "secret123"
# Custom title
gbrain publish brain/companies/acme.md --password --title "Acme -- Deal Analysis"
# Custom output path
gbrain publish brain/companies/acme.md --out /tmp/acme-share.html
```
## What Gets Stripped
The publish command automatically removes all private/internal data:
| Stripped | Example | Why |
|---------|---------|-----|
| YAML frontmatter | `title:`, `type:`, `tags:` | Internal metadata |
| `[Source: ...]` citations | All formats | Provenance is internal |
| Confirmation numbers | `ABC123DEF` -> "on file" | PII/booking data |
| Brain cross-links | `[Jane](../people/jane.md)` -> `Jane` | Internal paths |
| Timeline section | Everything below `---` / `## Timeline` | Raw evidence log |
| "See also" lines | Internal references | Brain navigation |
**Preserved:** external URLs (`https://...`), all other content.
## Sharing Workflows
### Option A: Local file (simplest)
```bash
gbrain publish brain/people/jane-doe.md --password --out ~/Desktop/jane-briefing.html
```
Share the HTML file via email, Slack, Airdrop. Share the password separately.
### Option B: Upload to cloud storage
```bash
# Publish locally first
gbrain publish brain/companies/acme.md --password "secret" --out /tmp/acme.html
# Upload to Supabase Storage
gbrain files upload /tmp/acme.html --page shares/acme
# Get a signed URL (1-hour expiry)
gbrain files signed-url shares/acme/acme.html
```
Share the signed URL + password. URL expires in 1 hour. Re-generate as needed.
### Option C: Static hosting (Render, Netlify, S3)
Upload the HTML file to any static hosting service. The file is self-contained,
no server logic needed. Password-protected files work entirely client-side via
Web Crypto API.
### Option D: GitHub Pages / Gist
```bash
gbrain publish brain/trips/japan-2026.md --out trip.html
# Upload to a GitHub Gist or Pages repo
```
## Password Protection Details
- **Algorithm:** AES-256-GCM
- **Key derivation:** PBKDF2 with 100K iterations, SHA-256
- **Salt:** Random 16 bytes per encryption
- **IV:** Random 12 bytes per encryption
- **Decryption:** Client-side via Web Crypto API (SubtleCrypto)
- **No server auth needed** -- the HTML file is self-contained
- **"Remember on this device"** -- saves password in localStorage
When encrypted, the published HTML contains ONLY ciphertext. The plaintext is
not present anywhere in the file.
## Updating a Published Page
Re-run the publish command with the same output path:
```bash
gbrain publish brain/companies/acme.md --password "same-password" --out shares/acme.html
```
Same file, same URL (if hosted), updated content.
## Revoking Access
Delete the file. If using signed URLs, the URL expires automatically (1 hour).
If using static hosting, remove the file from the host.
## Tools Used
- `gbrain publish` -- deterministic HTML generation (no LLM calls)
- `gbrain files upload` -- upload to cloud storage (optional)
- `gbrain files signed-url` -- generate access links (optional)

View File

@@ -18,7 +18,7 @@ for (const op of operations) {
} }
// CLI-only commands that bypass the operation layer // CLI-only commands that bypass the operation layer
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate']); const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate']);
async function main() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
@@ -247,6 +247,11 @@ async function handleCliOnly(command: string, args: string[]) {
await runIntegrations(args); await runIntegrations(args);
return; return;
} }
if (command === 'publish') {
const { runPublish } = await import('./commands/publish.ts');
await runPublish(subArgs);
return;
}
// All remaining CLI-only commands need a DB connection // All remaining CLI-only commands need a DB connection
const engine = await connectEngine(); const engine = await connectEngine();

353
src/commands/publish.ts Normal file
View File

@@ -0,0 +1,353 @@
/**
* gbrain publish — Generate shareable HTML from brain markdown pages.
*
* Deterministic: zero LLM calls. The skill (skills/publish/SKILL.md)
* tells the agent when and how to use this. This code does the work.
*
* Usage:
* gbrain publish <page-path> # local HTML file
* gbrain publish <page-path> --password # auto-generated pw
* gbrain publish <page-path> --password "secret" # custom pw
* gbrain publish <page-path> --out /tmp/share.html # custom output
* gbrain publish <page-path> --title "Custom Title" # override title
*/
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
import { randomBytes, createCipheriv, pbkdf2Sync } from 'crypto';
import { dirname, basename } from 'path';
// ── Content stripping ──────────────────────────────────────────────
/** Strip private/internal data from brain markdown before publishing */
export function makeShareable(content: string): string {
let clean = content;
// Remove YAML frontmatter
clean = clean.replace(/^---[\s\S]*?---\n*/, '');
// Remove [Source: ...] citations (all formats)
clean = clean.replace(/\s*\[Source:[^\]]*\]/g, '');
// Remove confirmation numbers
clean = clean.replace(/\*\*Confirmation:\*\*\s*[A-Z0-9]{6,}/gi, '**Confirmation:** on file');
clean = clean.replace(/Confirmation[:#]?\s*[A-Z0-9]{6,}/gi, 'Confirmation: on file');
clean = clean.replace(/\bconf\s*#?\s*[A-Z0-9]{6,}/gi, 'Confirmation: on file');
// Remove brain cross-links but keep display text
clean = clean.replace(/\[([^\]]+)\]\(\.[^)]*\/[^)]+\)/g, '$1');
// Remove "See also" brain-internal lines
clean = clean.replace(/^-?\s*See also:.*$/gm, '');
// Remove Timeline section (below the --- separator near end)
clean = clean.replace(/\n---\n\n## Timeline[\s\S]*$/, '');
// Clean up excessive blank lines
clean = clean.replace(/\n{3,}/g, '\n\n');
return clean.trim();
}
// ── Title extraction ───────────────────────────────────────────────
export function extractTitle(markdown: string): string {
const match = markdown.match(/^#\s+(.+)$/m);
return match ? match[1].trim() : 'Document';
}
// ── Encryption ─────────────────────────────────────────────────────
export interface EncryptedContent {
salt: string;
iv: string;
ciphertext: string;
}
export function encryptContent(plaintext: string, password: string): EncryptedContent {
const salt = randomBytes(16);
const iv = randomBytes(12);
const key = pbkdf2Sync(password, salt, 100_000, 32, 'sha256');
const cipher = createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(plaintext, 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);
const authTag = cipher.getAuthTag();
return {
salt: salt.toString('base64'),
iv: iv.toString('base64'),
ciphertext: Buffer.concat([encrypted, authTag]).toString('base64'),
};
}
export function generatePassword(length: number = 16): string {
const chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const bytes = randomBytes(length);
return Array.from(bytes).map(b => chars[b % chars.length]).join('');
}
// ── HTML generation ────────────────────────────────────────────────
const CSS = `
:root {
--bg: #fafaf9; --fg: #1c1917; --muted: #78716c;
--accent: #d97706; --border: #e7e5e4; --card-bg: #ffffff;
--code-bg: #f5f5f4; --link: #2563eb; --error: #dc2626;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0c0a09; --fg: #fafaf9; --muted: #a8a29e;
--accent: #fbbf24; --border: #292524; --card-bg: #1c1917;
--code-bg: #1c1917; --link: #60a5fa; --error: #f87171;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'SF Pro', Roboto, sans-serif;
background: var(--bg); color: var(--fg);
line-height: 1.7; padding: 1rem;
max-width: 720px; margin: 0 auto; font-size: 15px;
}
h1 { font-size: 1.75rem; font-weight: 700; margin: 1.5rem 0 0.5rem; letter-spacing: -0.02em; }
h2 { font-size: 1.3rem; font-weight: 600; margin: 2rem 0 0.75rem; padding-bottom: 0.4rem; border-bottom: 2px solid var(--accent); }
h3 { font-size: 1.1rem; font-weight: 600; margin: 1.5rem 0 0.5rem; color: var(--accent); }
h4 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.4rem; }
p { margin: 0.5rem 0; }
blockquote { border-left: 3px solid var(--accent); padding: 0.75rem 1rem; margin: 1rem 0; background: var(--card-bg); border-radius: 0 8px 8px 0; font-style: italic; color: var(--muted); }
ul, ol { margin: 0.5rem 0; padding-left: 1.5rem; }
li { margin: 0.3rem 0; }
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
strong { font-weight: 600; }
code { background: var(--code-bg); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 14px; }
th, td { padding: 8px 12px; border: 1px solid var(--border); text-align: left; }
th { background: var(--card-bg); font-weight: 600; }
@media (max-width: 600px) {
body { font-size: 14px; padding: 0.75rem; }
h1 { font-size: 1.4rem; }
h2 { font-size: 1.15rem; }
table { font-size: 12px; }
th, td { padding: 6px 8px; }
}
`;
const PASSWORD_CSS = `
.pw-overlay {
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
background: var(--bg); z-index: 1000;
}
.pw-card {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 16px;
padding: 2.5rem; max-width: 380px; width: 90%; text-align: center;
box-shadow: 0 4px 24px rgba(0,0,0,0.1);
}
.pw-lock { font-size: 3rem; margin-bottom: 1rem; }
.pw-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem; }
.pw-subtitle { font-size: 0.85rem; color: var(--muted); margin-bottom: 1.5rem; }
.pw-input {
width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px;
background: var(--bg); color: var(--fg); font-size: 15px; margin-bottom: 1rem;
outline: none; transition: border-color 0.2s;
}
.pw-input:focus { border-color: var(--accent); }
.pw-btn {
width: 100%; padding: 10px 14px; border: none; border-radius: 8px;
background: var(--accent); color: #fff; font-size: 15px; font-weight: 600;
cursor: pointer; transition: opacity 0.2s;
}
.pw-btn:hover { opacity: 0.9; }
.pw-error { color: var(--error); font-size: 0.85rem; margin-top: 0.75rem; display: none; }
.pw-remember { display: flex; align-items: center; justify-content: center; gap: 6px; margin-bottom: 1rem; font-size: 0.85rem; color: var(--muted); cursor: pointer; }
.pw-remember input { cursor: pointer; }
@keyframes shake { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-8px)} 75%{transform:translateX(8px)} }
.shake { animation: shake 0.3s ease-in-out; }
`;
const DECRYPT_JS = `
const STORAGE_KEY = 'bp_' + location.pathname;
async function deriveKey(password, salt) {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
}
async function decryptContent(password) {
try {
const salt = Uint8Array.from(atob(window.__SALT), c => c.charCodeAt(0));
const iv = Uint8Array.from(atob(window.__IV), c => c.charCodeAt(0));
const data = Uint8Array.from(atob(window.__CT), c => c.charCodeAt(0));
const ciphertext = data.slice(0, data.length - 16);
const authTag = data.slice(data.length - 16);
const combined = new Uint8Array(ciphertext.length + authTag.length);
combined.set(ciphertext);
combined.set(authTag, ciphertext.length);
const key = await deriveKey(password, salt);
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, combined);
return new TextDecoder().decode(decrypted);
} catch {
return null;
}
}
async function unlock(pw, remember) {
const result = await decryptContent(pw);
if (result) {
if (remember) {
try { localStorage.setItem(STORAGE_KEY, pw); } catch {}
}
document.getElementById('pw-overlay').remove();
document.getElementById('content').innerHTML = marked.parse(result);
return true;
}
return false;
}
(async () => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved && await unlock(saved, false)) return;
} catch {}
document.getElementById('pw-form').addEventListener('submit', async (e) => {
e.preventDefault();
const input = document.getElementById('pw-input');
const error = document.getElementById('pw-error');
const card = document.querySelector('.pw-card');
const remember = document.getElementById('pw-remember').checked;
const pw = input.value;
if (await unlock(pw, remember)) return;
error.style.display = 'block';
error.textContent = 'Wrong password. Try again.';
card.classList.remove('shake');
void card.offsetWidth;
card.classList.add('shake');
input.value = '';
input.focus();
});
document.getElementById('pw-input').addEventListener('input', () => {
document.getElementById('pw-error').style.display = 'none';
});
})();
`;
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
interface GenerateHtmlOptions {
title: string;
markdown: string;
encrypted?: EncryptedContent | null;
}
export function generateHtml({ title, markdown, encrypted }: GenerateHtmlOptions): string {
const passwordHtml = encrypted ? `
<div id="pw-overlay" class="pw-overlay">
<div class="pw-card">
<div class="pw-lock">&#x1F512;</div>
<div class="pw-title">${escapeHtml(title)}</div>
<div class="pw-subtitle">This document is password protected</div>
<form id="pw-form">
<input type="password" id="pw-input" class="pw-input" placeholder="Enter password" autofocus>
<label class="pw-remember"><input type="checkbox" id="pw-remember" checked> Remember on this device</label>
<button type="submit" class="pw-btn">Unlock</button>
</form>
<div id="pw-error" class="pw-error"></div>
</div>
</div>` : '';
const encryptedVars = encrypted ? `
<script>
window.__SALT = ${JSON.stringify(encrypted.salt)};
window.__IV = ${JSON.stringify(encrypted.iv)};
window.__CT = ${JSON.stringify(encrypted.ciphertext)};
</script>` : '';
const contentScript = encrypted
? `<script>${DECRYPT_JS}<\/script>`
: `<script>
const md = ${JSON.stringify(markdown)};
document.getElementById('content').innerHTML = marked.parse(md);
<\/script>`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>
<style>${CSS}${encrypted ? PASSWORD_CSS : ''}</style>
</head>
<body>
${passwordHtml}
<div id="content"></div>
${encryptedVars}
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"><\/script>
${contentScript}
</body>
</html>`;
}
// ── CLI entry point ────────────────────────────────────────────────
export async function runPublish(args: string[]) {
const inputPath = args.find(a => !a.startsWith('--'));
const outIdx = args.indexOf('--out');
const titleIdx = args.indexOf('--title');
const pwIdx = args.indexOf('--password');
if (!inputPath) {
console.error('Usage: gbrain publish <page.md> [--password ["secret"]] [--title "Title"] [--out path]');
console.error('');
console.error(' Generates a shareable HTML page from brain markdown.');
console.error(' Strips private data (frontmatter, citations, timeline, brain links).');
console.error(' Optionally encrypts with AES-256-GCM (client-side, no server needed).');
console.error('');
console.error(' --password Auto-generate a password');
console.error(' --password "secret" Use a specific password');
console.error(' --title "Title" Override the page title');
console.error(' --out path Output file (default: <input-basename>.html)');
process.exit(1);
}
const raw = readFileSync(inputPath, 'utf-8');
const cleaned = makeShareable(raw);
const title = (titleIdx >= 0 ? args[titleIdx + 1] : null) || extractTitle(raw);
// Handle password
let encrypted: EncryptedContent | null = null;
if (pwIdx >= 0) {
const nextArg = args[pwIdx + 1];
const password = (nextArg && !nextArg.startsWith('--')) ? nextArg : generatePassword();
encrypted = encryptContent(cleaned, password);
if (!nextArg || nextArg.startsWith('--')) {
console.error(`Password: ${password}`);
}
}
const html = generateHtml({ title, markdown: cleaned, encrypted });
// Determine output path
const outPath = outIdx >= 0 ? args[outIdx + 1] : basename(inputPath, '.md') + '.html';
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, html);
console.log(`Published: ${outPath}`);
if (encrypted) {
console.log(' (password protected, AES-256-GCM encrypted)');
} else {
console.log(' (no password, content in cleartext)');
}
}

217
test/publish.test.ts Normal file
View File

@@ -0,0 +1,217 @@
import { describe, test, expect } from 'bun:test';
import {
makeShareable,
extractTitle,
encryptContent,
generatePassword,
generateHtml,
} from '../src/commands/publish.ts';
describe('makeShareable', () => {
test('strips YAML frontmatter', () => {
const input = '---\ntitle: Secret\ntype: person\n---\n\n# Jane Doe\n\nPublic content.';
const result = makeShareable(input);
expect(result).not.toContain('title: Secret');
expect(result).not.toContain('type: person');
expect(result).toContain('# Jane Doe');
expect(result).toContain('Public content.');
});
test('strips [Source: ...] citations', () => {
const input = 'Jane is CTO [Source: Crustdata enrichment, 2026-04-01] of Acme.';
expect(makeShareable(input)).toBe('Jane is CTO of Acme.');
});
test('strips multi-format citations', () => {
const input = 'Fact one [Source: User, meeting, 2026-04-01]. Fact two [Source: compiled from timeline].';
const result = makeShareable(input);
expect(result).not.toContain('[Source:');
expect(result).toContain('Fact one');
expect(result).toContain('Fact two');
});
test('redacts confirmation numbers', () => {
const input = '**Confirmation:** ABC123DEF456';
expect(makeShareable(input)).toContain('on file');
expect(makeShareable(input)).not.toContain('ABC123DEF456');
});
test('strips brain cross-links, keeps display text', () => {
const input = 'Works with [Jane Doe](../people/jane-doe.md) at Acme.';
const result = makeShareable(input);
expect(result).toBe('Works with Jane Doe at Acme.');
expect(result).not.toContain('../people/');
});
test('preserves external URLs', () => {
const input = 'See [their blog](https://example.com/blog) for details.';
expect(makeShareable(input)).toContain('https://example.com/blog');
});
test('removes See also lines', () => {
const input = '# Title\n\nContent.\n\n- See also: ../companies/acme.md\n\nMore content.';
const result = makeShareable(input);
expect(result).not.toContain('See also');
expect(result).toContain('More content');
});
test('removes Timeline section', () => {
const input = '# Title\n\nPublic content.\n\n---\n\n## Timeline\n\n- 2026-04-01 | Secret event';
const result = makeShareable(input);
expect(result).toContain('Public content.');
expect(result).not.toContain('Timeline');
expect(result).not.toContain('Secret event');
});
test('collapses excessive blank lines', () => {
const input = '# Title\n\n\n\n\nContent.';
expect(makeShareable(input)).toBe('# Title\n\nContent.');
});
test('handles empty input', () => {
expect(makeShareable('')).toBe('');
});
test('handles frontmatter-only input', () => {
const input = '---\ntitle: Test\n---\n';
expect(makeShareable(input)).toBe('');
});
test('strips .raw/ relative links', () => {
const input = 'See [raw data](.raw/crustdata.json) for source.';
const result = makeShareable(input);
expect(result).toBe('See raw data for source.');
});
});
describe('extractTitle', () => {
test('extracts H1 title', () => {
expect(extractTitle('# Jane Doe\n\nContent.')).toBe('Jane Doe');
});
test('extracts title with formatting', () => {
expect(extractTitle('# **Bold** Title\n\nContent.')).toBe('**Bold** Title');
});
test('returns "Document" when no H1', () => {
expect(extractTitle('No heading here.')).toBe('Document');
});
test('ignores H2 and lower', () => {
expect(extractTitle('## Not H1\n\nContent.')).toBe('Document');
});
test('picks first H1 when multiple exist', () => {
expect(extractTitle('# First\n\n# Second')).toBe('First');
});
});
describe('encryptContent', () => {
test('returns salt, iv, and ciphertext', () => {
const result = encryptContent('hello world', 'password123');
expect(result.salt).toBeTruthy();
expect(result.iv).toBeTruthy();
expect(result.ciphertext).toBeTruthy();
});
test('produces valid base64', () => {
const result = encryptContent('test content', 'pw');
expect(() => Buffer.from(result.salt, 'base64')).not.toThrow();
expect(() => Buffer.from(result.iv, 'base64')).not.toThrow();
expect(() => Buffer.from(result.ciphertext, 'base64')).not.toThrow();
});
test('different passwords produce different ciphertext', () => {
const a = encryptContent('same text', 'password1');
const b = encryptContent('same text', 'password2');
expect(a.ciphertext).not.toBe(b.ciphertext);
});
test('same password produces different output (random salt/iv)', () => {
const a = encryptContent('same text', 'same password');
const b = encryptContent('same text', 'same password');
expect(a.salt).not.toBe(b.salt);
expect(a.iv).not.toBe(b.iv);
});
test('handles unicode content', () => {
const result = encryptContent('Hello -- arrows -> and quotes "test"', 'pw');
expect(result.ciphertext).toBeTruthy();
});
test('handles empty string', () => {
const result = encryptContent('', 'pw');
expect(result.ciphertext).toBeTruthy();
});
});
describe('generatePassword', () => {
test('default length is 16', () => {
expect(generatePassword()).toHaveLength(16);
});
test('custom length', () => {
expect(generatePassword(8)).toHaveLength(8);
expect(generatePassword(32)).toHaveLength(32);
});
test('excludes ambiguous characters', () => {
// No 0, O, l, 1, I (all excluded from the charset)
for (let i = 0; i < 50; i++) {
const pw = generatePassword(32);
expect(pw).not.toMatch(/[0OlI1]/);
}
});
test('generates unique passwords', () => {
const passwords = new Set(Array.from({ length: 20 }, () => generatePassword()));
expect(passwords.size).toBe(20);
});
});
describe('generateHtml', () => {
test('generates valid HTML with title', () => {
const html = generateHtml({ title: 'Test Page', markdown: '# Hello\n\nWorld.' });
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('<title>Test Page</title>');
expect(html).toContain('marked.parse');
});
test('includes markdown content as JSON', () => {
const html = generateHtml({ title: 'T', markdown: '# Test Content' });
expect(html).toContain('# Test Content');
});
test('escapes HTML in title', () => {
const html = generateHtml({ title: '<script>alert("xss")</script>', markdown: 'x' });
expect(html).not.toContain('<script>alert("xss")</script>');
expect(html).toContain('&lt;script&gt;');
});
test('includes password UI when encrypted', () => {
const encrypted = encryptContent('secret', 'pw');
const html = generateHtml({ title: 'T', markdown: 'x', encrypted });
expect(html).toContain('pw-overlay');
expect(html).toContain('pw-form');
expect(html).toContain('Enter password');
expect(html).toContain('window.__SALT');
expect(html).toContain('window.__IV');
expect(html).toContain('window.__CT');
});
test('no password UI when unencrypted', () => {
const html = generateHtml({ title: 'T', markdown: 'x' });
expect(html).not.toContain('pw-overlay');
expect(html).not.toContain('window.__SALT');
});
test('includes dark mode CSS', () => {
const html = generateHtml({ title: 'T', markdown: 'x' });
expect(html).toContain('prefers-color-scheme: dark');
});
test('includes marked.js CDN', () => {
const html = generateHtml({ title: 'T', markdown: 'x' });
expect(html).toContain('cdn.jsdelivr.net/npm/marked');
});
});