Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Dripnex

> Markdown-first, offline-forever desktop note app.
> The hackable AI note taker. Local SQLite. Markdown is export.

## Stack

Expand Down Expand Up @@ -45,7 +45,8 @@ pnpm format # Format with Prettier
- **Markdown is sacred:** Never auto-modify user's markdown text
- **AST is ephemeral:** Parse for features, never persist as authority
- **Core is pure:** No Electron/React deps in packages/core
- **Offline-first:** No features require internet connection
- **AuthGate first:** Account is required to open the workspace. Sync is optional E2E after that.
- **Offline after login:** The workspace works without internet. Sync is a feature, not a requirement.
- **Test first:** Core domain changes require tests

## Native Dependencies (Critical)
Expand All @@ -54,7 +55,7 @@ Native modules like `better-sqlite3` require special handling in Electron + pnpm

- **Native deps only in `apps/desktop`**: Never add native dependencies directly to workspace packages
- **Workspace packages = pure TypeScript**: Use `peerDependencies` for native modules
- **electron-builder owns the rebuild**: Let `postinstall: electron-builder install-app-deps` handle native module compilation
- **electron-builder owns the rebuild:** Let `postinstall: electron-builder install-app-deps` handle native module compilation
- **No manual rebuilds**: Never add `@electron/rebuild` scripts or workarounds

Pattern for workspace packages with native deps:
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Dripnex

Markdown-first, offline-forever desktop note app.
The hackable AI note taker. Local SQLite. Markdown is export.

[![License: MIT](https://img.shields.io/badge/Core-MIT-green.svg)](./LICENSE)
[![Release](https://img.shields.io/github/v/release/dripnex/app)](https://github.com/dripnex/app/releases)
Expand All @@ -9,6 +9,8 @@ Markdown-first, offline-forever desktop note app.

This repository contains the **open-source core** of Dripnex. Core packages are licensed under MIT for community use and contributions. The desktop application and some commercial features remain proprietary.

Dripnex is an AI note taker you can hack (`init.js` / `styles.css` / satellite packs). AuthGate is the first window. Sync is optional and end-to-end after account. Official themes stay empty. There is no public marketplace.

## Quick Start

```bash
Expand Down
12 changes: 6 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@

## Vision

Dripnex is a **Markdown-first thinking workspace for builders**. Raycast meets Obsidian meets lightweight IDE for ideas.
Dripnex is the **hackable AI note taker**. Messy input becomes a document a person will send. SQLite is the store. `.md` is export.

Your notes survive the app. Your thinking stays yours. AI augments, never replaces.
AuthGate is the first window. Hack via `init.js` / `styles.css` / satellite packs.

---

## Core Principles

| # | Principle | What it means |
| --- | ------------------------ | -------------------------------------------------------------------------------- |
| 1 | **Markdown-first** | Users own their data. Plain `.md` files, git-friendly, export = exact copy. |
| 2 | **Local-first** | Fast, offline, no cloud dependency. Sync is optional, never required. |
| 1 | **SQLite is the store** | Notes live in local SQLite. Markdown is export, not how you open the app. |
| 2 | **Offline after login** | Fast local workspace after AuthGate. Sync is optional E2E, never required. |
| 3 | **Command-driven UX** | Raycast-like Cmd+K, keyboard-first. Every action is a command. |
| 4 | **AI as augmentation** | AI operates ON notes --- summarize, expand, extract. It never replaces thinking. |
| 5 | **Speed and minimalism** | Developer-grade ergonomics. Start fast, stay fast, no bloat. |
Expand Down Expand Up @@ -242,7 +242,7 @@ System prompt

| Step | What happens | Time |
| ---- | ------------------------------------------------------------- | ---- |
| 1 | Welcome screen: "Your thinking workspace" | 10s |
| 1 | AuthGate, then Welcome: "The hackable AI note taker" | 10s |
| 2 | Create first note (pre-filled template with instructions) | 30s |
| 3 | Command palette tutorial (Cmd+K highlight) | 20s |
| 4 | First AI action: summarize or generate tweet from sample note | 30s |
Expand All @@ -251,7 +251,7 @@ System prompt

**Principles:**

- No account required to start
- AuthGate is required (account first, then Welcome)
- No configuration required to start
- AI features work with a single API key entry
- Every step is skippable
Expand Down
15 changes: 8 additions & 7 deletions apps/desktop/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,17 @@ npx playwright install --with-deps

Set `DRIPNEX_E2E_KEEP_USERDATA=1` to keep the temp dir on failure for post-mortem inspection.

`openFirstNote()` clicks **Create Your First Note** and waits for `.cm-content`. Isolated e2e sets `DRIPNEX_E2E=1` so the auth gate does not block that path.
`openFirstNote()` clicks **Create Your First Note** and waits for `.cm-content`. Isolated e2e sets `DRIPNEX_E2E=1` so Playwright can skip AuthGate. That flag is test-only, not a production bypass. Human QA blocked by AuthGate is accepted.

## What we test

| Spec | What it covers |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `smoke.spec.ts` | App launches, main window renders, IPC bridge present, no uncaught console errors during initial mount. Opening a note loads CodeMirror without `[CodeMirror] plugin error` (regression for #311). |
| `notes.spec.ts` | Notes IPC contract — create / list / get roundtrip, FTS5 search returns freshly-created notes. Driven through the **preload bridge** (`window.dripnex.notes.*`), not the editor UI, so the contract survives renderer refactors. |
| `editor.spec.ts` | Types into `.cm-content` after creating a note, and Enter on a bullet list (continue-markup). Clicks the last `.cm-line` / uses `keyboard.type` — not `fill()`, which CodeMirror's contenteditable does not accept. |
| `tables.spec.ts` | Wide GFM table widget stays inside the scroller. **Skipped on CI** (`CI` env) — insert + leave-table decorations are still flaky under xvfb. Run headed locally. |
| Spec | What it covers |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `authgate.spec.ts` | Unsigned launch (no `DRIPNEX_E2E`) shows AuthGate: magic-link Sign in, no Welcome, no continue-locally. |
| `smoke.spec.ts` | App launches, main window renders, IPC bridge present, no uncaught console errors during initial mount. Opening a note loads CodeMirror without `[CodeMirror] plugin error` (regression for #311). |
| `notes.spec.ts` | Notes IPC contract — create / list / get roundtrip, FTS5 search returns freshly-created notes. Driven through the **preload bridge** (`window.dripnex.notes.*`), not the editor UI, so the contract survives renderer refactors. |
| `editor.spec.ts` | Types into `.cm-content` after creating a note, and Enter on a bullet list (continue-markup). Clicks the last `.cm-line` / uses `keyboard.type` — not `fill()`, which CodeMirror's contenteditable does not accept. |
| `tables.spec.ts` | Wide GFM table widget stays inside the scroller. **Skipped on CI** (`CI` env) — insert + leave-table decorations are still flaky under xvfb. Run headed locally. |

## What we still don't test

Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/e2e/authgate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { test, expect } from '@playwright/test';
import { launchApp } from './fixtures.js';

test.describe('AuthGate is the first window', () => {
test('unsigned launch shows magic-link Sign in, not Welcome or workspace', async () => {
const { window, cleanup } = await launchApp({ skipAuthGate: false });
try {
await expect(window.getByRole('tab', { name: 'Sign in' })).toBeVisible({
timeout: 15_000,
});
await expect(window.getByRole('heading', { name: 'Welcome back' })).toBeVisible();
await expect(window.getByText('The hackable AI note taker')).toBeVisible();
await expect(window.getByRole('button', { name: 'Email me a link' })).toBeVisible();
await expect(window.locator('canvas')).toHaveCount(1);

await expect(window.getByRole('button', { name: 'Create Your First Note' })).toHaveCount(0);
await expect(window.getByRole('button', { name: /continue locally/i })).toHaveCount(0);
} finally {
await cleanup();
}
});
});
14 changes: 12 additions & 2 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,24 @@ interface LaunchedApp {
cleanup: () => Promise<void>;
}

export interface LaunchAppOptions {
/**
* Playwright default: skip AuthGate (`DRIPNEX_E2E=1`).
* Production launches must not set this. Pass false to assert the gate.
*/
skipAuthGate?: boolean;
}

/**
* Launches the desktop app and waits for the first window to be ready.
*
* Uses a fresh temp `userData` so the test gets an empty database every
* time. Set DRIPNEX_E2E_KEEP_USERDATA=1 to keep the dir on failure for
* post-mortem.
*/
export async function launchApp(): Promise<LaunchedApp> {
export async function launchApp({
skipAuthGate = true,
}: LaunchAppOptions = {}): Promise<LaunchedApp> {
const userDataDir = await mkdtemp(join(tmpdir(), 'dripnex-e2e-'));

const app = await electron.launch({
Expand All @@ -45,7 +55,7 @@ export async function launchApp(): Promise<LaunchedApp> {
env: {
...process.env,
NODE_ENV: 'test',
DRIPNEX_E2E: '1',
...(skipAuthGate ? { DRIPNEX_E2E: '1' } : { DRIPNEX_E2E: '' }),
// Pin the data root explicitly so the app uses our temp dir for
// its SQLite database too, not just for Electron's userData.
DRIPNEX_DATA_DIR: userDataDir,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"productName": "Dripnex",
"version": "0.15.2",
"private": true,
"description": "Markdown-first, offline-forever note app for developers",
"description": "The hackable AI note taker. Local SQLite. Markdown is export.",
"author": {
"name": "Dripnex",
"email": "hello@dripnex.app"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GITHUB_CONNECT_REQUIRED } from '../../../../shared/githubBlob.js';
import { createGitHubService } from '../service.js';

const memory = vi.hoisted(() => ({ keys: new Map<string, string>() }));

vi.mock('../../../services/aiKeyStorage.js', () => ({
AiKeyStorage: class {
async getKey(provider: string) {
return memory.keys.get(provider) ?? null;
}
async saveKey(provider: string, apiKey: string) {
memory.keys.set(provider, apiKey);
}
async removeKey(provider: string) {
memory.keys.delete(provider);
}
},
}));

const TOKEN = 'ghp_secret_token_never_in_renderer';
const BLOB = 'https://github.com/acme/private/blob/main/src/a.ts#L2-L3';
const FILE = 'line1\nline2\nline3\nline4\n';

function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}

function textResponse(body: string, status = 200): Response {
return new Response(body, { status, headers: { 'Content-Type': 'text/plain' } });
}

function headerAuth(init?: RequestInit): string | null {
const headers = init?.headers;
if (!headers || typeof headers !== 'object' || headers instanceof Headers) {
if (headers instanceof Headers) return headers.get('Authorization');
return null;
}
const record = headers as Record<string, string>;
return record.Authorization ?? record.authorization ?? null;
}

describe('GitHub resolvePaste / status token boundary', () => {
const fetchMock = vi.fn<typeof fetch>();
let service: ReturnType<typeof createGitHubService>;

beforeEach(() => {
memory.keys.clear();
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
service = createGitHubService('/tmp/dripnex-gh-test');
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('status() returns login, never the stored token', async () => {
fetchMock.mockImplementation(async () => jsonResponse({ login: 'tomas', name: 'Tomás' }));
await service.connect(TOKEN);

const status = await service.status();
expect(status).toEqual({ connected: true, login: 'tomas', via: 'token' });
expect(status).not.toHaveProperty('token');
expect(JSON.stringify(status)).not.toContain(TOKEN);
expect(Object.keys(status).sort()).toEqual(['connected', 'login', 'via']);
});

it('embeds a private blob using the main-process token', async () => {
fetchMock.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith('/user')) return jsonResponse({ login: 'tomas', name: null });
if (url.includes('/contents/src/a.ts')) {
expect(headerAuth(init)).toBe(`Bearer ${TOKEN}`);
return textResponse(FILE);
}
return jsonResponse({}, 404);
});
await service.connect(TOKEN);

const result = await service.resolvePaste(BLOB);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.kind).toBe('embed');
expect(result.markdown).toContain('line2');
expect(result.markdown).toContain('line3');
expect(result.markdown).not.toContain('line1');
expect(result.markdown).not.toContain('line4');
expect(result.markdown).toContain(BLOB);
expect(JSON.stringify(result)).not.toContain(TOKEN);
});

it('asks to connect when a private blob 404s without a token', async () => {
fetchMock.mockImplementation(async () => textResponse('', 404));
const result = await service.resolvePaste(BLOB);
expect(result).toEqual({
success: false,
error: GITHUB_CONNECT_REQUIRED,
connectRequired: true,
});
expect(JSON.stringify(result)).not.toContain(TOKEN);
});

it('embeds a public blob without a stored token', async () => {
fetchMock.mockImplementation(async (input, init) => {
expect(headerAuth(init)).toBeNull();
expect(String(input)).toContain('/contents/src/a.ts');
return textResponse(FILE);
});
const result = await service.resolvePaste(BLOB);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.markdown).toContain('line2');
expect(result.markdown).toContain('```ts title=src/a.ts startLine=2 {2-3}');
});

it('turns a public issue URL into a titled link', async () => {
fetchMock.mockImplementation(async () => jsonResponse({ title: 'Fix the sync retry' }));
const result = await service.resolvePaste('https://github.com/acme/app/issues/12');
expect(result).toEqual({
success: true,
kind: 'link',
markdown: '[Fix the sync retry](<https://github.com/acme/app/issues/12>)',
});
});

it('does not turn an issue fragment into a second markdown link', async () => {
fetchMock.mockImplementation(async () => jsonResponse({ title: 'Fix' }));
const result = await service.resolvePaste(
'https://github.com/acme/app/issues/12#)[x](https://attacker.invalid)'
);
expect(result).toEqual({
success: true,
kind: 'link',
markdown: '[Fix](<https://github.com/acme/app/issues/12>)',
});
if (!result.success) return;
expect(result.markdown).not.toContain('attacker');
});
});
6 changes: 6 additions & 0 deletions apps/desktop/src/main/integrations/github/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export function registerGitHubHandlers(deps: { dataDir: string; notes?: GitHubNo
},
});

defineIpcHandler({
channel: 'integrations:github:resolvePaste',
args: z.tuple([z.string().min(1).max(2048)]),
handler: url => github.resolvePaste(url),
});

defineIpcHandler({
channel: 'integrations:github:importIssue',
args: z.tuple([z.string().max(512)]),
Expand Down
Loading
Loading