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:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
129
skills/publish/SKILL.md
Normal 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)
|
||||||
@@ -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
353
src/commands/publish.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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">🔒</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
217
test/publish.test.ts
Normal 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('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user