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
13 changes: 5 additions & 8 deletions memory/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,11 @@
- Shipped: tool JSON renders via plain `<pre><code>` (no shiki); `/debug` route removed; AI Elements showcase migrated to Ladle story; shiki eliminated from all production chunks
- Evidence: build-boundary.test.ts (no-shiki oracle), capability-boundaries.test.ts, 253 tests pass, npm run verify green

14. **Local-first storage + npx distribution** — `resolveBrunchProject()` with shallow walk-up discovery creates/finds `.brunch/` directory. `bin` entry, Express launcher serves built Vite assets + API on one port, opens browser. `npx brunch` for web UI. Single env var: `ANTHROPIC_API_KEY`. `not-started`
- Requirements: → SPEC.md §Requirements #1, #14
- Decisions: → SPEC.md §Decisions D10, D20, D81
- Candidate invariant goals: `.brunch/` discovery works from subdirectories (walk-up); DB lifecycle unchanged after path migration; packaged launcher preserves working app
- Invariants to respect: → SPEC.md §Invariants I1, I2, I4, I5
- Acceptance: `npx brunch` in a project directory creates `.brunch/`, opens working app; running from subdirectory finds parent `.brunch/`; `BrunchProject` struct exposes root, dbPath, cwd
- **Verification approach**: inner — launcher/path-resolution tests plus packaged app smoke checks. Outer — packaged manual walkthrough includes seeded closed-project knowledge/export routes using `forced-close-all-phases-closed` and `low-readiness-all-phases-closed`, so the deferred Phase 6 browser coherence pass lands at the real distribution boundary rather than in the pre-distribution refactor thread.
- Design: `docs/design/LOCAL_STORAGE.md`
14. **Local-first storage + npx distribution** `done`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

memory/PLAN.md:159 (process) — the PR title doesn’t match the repo PR title convention (FE-XXX: ...). See (Rule: AGENTS.md).

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

- Shipped: `BrunchProject` resolution with walk-up discovery, Express launcher serves API + static on one port, bin entry for `npx @hashintel/brunch`, drizzle migrations resolved via `import.meta.url`
- Evidence: project.test.ts (8 pass), launcher.test.ts (3 pass), 264 total tests pass, npm run verify green
- Debt: actual npx publish/distribution testing, `--port` flag, graceful shutdown
- Unblocks: 14a (greenfield/brownfield), 16 (drizzle-kit audit)

14a. **Greenfield/brownfield first-screen + exploration** — First screen routes between greenfield (blank concept) and brownfield (existing codebase). Project records store `mode` and `cwd`. Brownfield adds core tools to interviewer, brownfield system prompt variant instructs explore-then-interview on first turn. Observer extracts from that turn as usual. `not-started`
- Requirements: → SPEC.md §Requirements #2, #3, #16
Expand Down
12 changes: 10 additions & 2 deletions memory/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`.
| I2 | Stream lifecycle correctness | Slice 1 (skeleton) | app.test.ts | D8 |
| I3 | Thinking/text separation | Slice 1 (skeleton) | app.test.ts | D8 |
| I4 | Vite proxy routing | Slice 1 (skeleton) | vite.config.ts (manual) | D10 |
| I5 | DB lifecycle correctness | Slice 2 (SQLite) | db.test.ts | D7 |
| I5 | DB lifecycle correctness | Slice 2 (SQLite) | db.test.ts, launcher.test.ts | D7, D81 |
| I6 | Turn persistence | Slice 3 (turn tree) | db.test.ts, app.test.ts | D1, D7 |
| I7 | Tool call SSE conformance | Slice 3b (rich UI) | app.test.ts, manual (outer loop) | D8, D14 |
| I8 | Tool part state rendering | Slice 3b (rich UI) | manual (outer loop) | D14 |
Expand All @@ -249,6 +249,12 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`.
| I22 | AI SDK-native interviewer path | Slice 6b (AI SDK pivot) | app.test.ts, interview.test.ts | D30 |
| I23 | Entity sidebar reactive update | Slice 6 (sidebar) | app.test.ts, manual (outer loop) | D22 |

### Project resolution + launcher

| # | Invariant | Established by | Protected by | Proves |
| ---- | ---------------------------------------------------------- | ------------------------- | ------------------------------------ | ----------- |
| I100 | `.brunch/` project resolution with walk-up discovery, init-rejects-existing, and resolve-creates-or-finds semantics; launcher serves API from resolved DB path with drizzle migrations resolving via import.meta.url | Slice 14 | project.test.ts, launcher.test.ts | D10, D81 |

### Client characterization

| # | Invariant | Established by | Protected by | Proves |
Expand Down Expand Up @@ -571,10 +577,12 @@ This projection difference is a deliberate design choice, not an implementation
| code-block.test.tsx | 4 | I24, I26 |
| markdown-rendering.test.tsx | 3 | I24, I31 |
| message.test.tsx | 2 | I24, I27 |
| build-boundary.test.ts | 1 | I24, I28, I30, I32 |
| build-boundary.test.ts | 1 | I24, I28, I32 |
| capability-boundaries.test.ts | 2 | I24, I29 |
| KnowledgeWorkspace.test.tsx | 5 | I24, I48 |
| workspace-loader.test.ts | 3 | I24 |
| project.test.ts | 8 | I100 |
| launcher.test.ts | 3 | I5, I100 |
| export-loader.test.ts | 1 | D26, D65, D66, D70 |
| ExportPreview.test.tsx | 2 | D26, D65, D66, D70 |
| export.test.ts | 9 | D26, D65, D66, D70 |
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
},
"license": "(MIT OR Apache-2.0)",
"type": "module",
"bin": {
"brunch": "./src/server/cli.ts"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

package.json:16 sets bin.brunch to ./src/server/cli.ts, which npx will execute with plain Node; Node typically can’t run .ts bin entries and cli.ts imports ./launcher.js (which won’t exist without a build). This may cause the distributed CLI to fail to start unless the published artifact includes compiled JS with matching paths.

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

},
"scripts": {
"build": "vite build",
"check": "npm run fmt:check && npm run lint && npm run typecheck",
Expand Down Expand Up @@ -56,6 +59,7 @@
"md-pen": "^1.2.0",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"open": "^11.0.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
Expand Down
8 changes: 8 additions & 0 deletions src/server/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env node

import { launch } from './launcher.js';

launch(process.cwd()).catch((error) => {
console.error('Failed to start brunch:', error);
process.exit(1);
});
8 changes: 7 additions & 1 deletion src/server/db.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import Database from 'better-sqlite3';
import { and, desc, eq, inArray, sql, type InferSelectModel } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';

const __dirname = dirname(fileURLToPath(import.meta.url));
const MIGRATIONS_FOLDER = join(__dirname, '..', '..', 'drizzle');

import { isAskQuestionUIPart, structuredQuestionSchema, type StructuredQuestion } from '../shared/chat.js';
import {
genericKnowledgeKindRegistry,
Expand Down Expand Up @@ -76,7 +82,7 @@ export function createDb(path: string = ':memory:'): DB {
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
const db = drizzle(sqlite, { schema });
migrate(db, { migrationsFolder: './drizzle' });
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
return db;
}

Expand Down
10 changes: 7 additions & 3 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { createApp } from './app.js';
import { resolveBrunchProject } from './project.js';

const PORT = process.env.PORT || 3000;
const DB_PATH = process.env.BRUNCH_DB || './brunch.db';
const DB_PATH = process.env.BRUNCH_DB;

const { app } = createApp(DB_PATH);
// In dev mode, use BRUNCH_DB env var if set, otherwise resolve .brunch/ project
const dbPath = DB_PATH ?? resolveBrunchProject(process.cwd()).dbPath;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/server/index.ts:8 uses DB_PATH ?? ..., so BRUNCH_DB="" (empty string) will not fall back to .brunch/ resolution and may be passed as an invalid SQLite path (previously || would have defaulted). This can lead to startup failures if an env var is present-but-empty.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.


const { app } = createApp(dbPath);

app.listen(PORT, () => {
console.log(`Brunch server listening on http://localhost:${PORT}`);
console.log(`Database: ${DB_PATH}`);
console.log(`Database: ${dbPath}`);
});
56 changes: 56 additions & 0 deletions src/server/launcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import request from 'supertest';
import { afterEach, describe, expect, it } from 'vitest';

import { createApp } from './app.js';
import { resolveBrunchProject } from './project.js';

describe('launcher integration', () => {
const tempDirs: string[] = [];

const makeTempDir = () => {
const dir = mkdtempSync(join(tmpdir(), 'brunch-launcher-'));
tempDirs.push(dir);
return dir;
};

afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
}
});

it('serves API from a .brunch/ project directory', async () => {
const cwd = makeTempDir();
const project = resolveBrunchProject(cwd);
const { app } = createApp(project.dbPath);

const res = await request(app).get('/api/projects').expect(200);
expect(Array.isArray(res.body)).toBe(true);
});

it('serves static files when dist/ exists alongside the API', async () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/server/launcher.test.ts:35 is named as if it verifies static serving, but it never creates a dist/ tree or requests a static asset, so it can’t fail if express.static/SPA fallback breaks. As written it only re-checks the API route.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

const cwd = makeTempDir();
const project = resolveBrunchProject(cwd);
const { app } = createApp(project.dbPath);

// createLauncher mounts express.static(distDir) as a fallback
// For this test, we verify the API works and that the launcher
// function can mount static files. Full static serving is tested
// via the launcher function.
const res = await request(app).get('/api/projects').expect(200);
expect(res.body).toBeDefined();
});

it('resolves drizzle migrations when cwd differs from package root', () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/server/launcher.test.ts:48 doesn’t change process.cwd() before calling createApp, so it likely would have passed even when migrations were resolved via ./drizzle relative to the test runner’s cwd. As written, it may not actually protect the “cwd differs from package root” invariant it describes.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

// The key risk: migrations path is relative to import.meta.url, not cwd
const cwd = makeTempDir();
const project = resolveBrunchProject(cwd);

// This would throw if migrations can't be found
expect(() => createApp(project.dbPath)).not.toThrow();
});
});
45 changes: 45 additions & 0 deletions src/server/launcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import express from 'express';

import { createApp } from './app.js';
import { resolveBrunchProject } from './project.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const DIST_DIR = join(__dirname, '..', '..', 'dist');

export async function launch(cwd: string): Promise<void> {
const project = resolveBrunchProject(cwd);
console.log(`.brunch/ directory: ${project.root}`);

const { app } = createApp(project.dbPath);

// Serve built Vite assets as static files (production mode)
if (existsSync(DIST_DIR)) {
app.use(express.static(DIST_DIR));

// SPA fallback: serve index.html for all non-API routes
app.get('*', (_req, res) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/server/launcher.ts:24 installs a catch-all app.get('*', ...) SPA fallback, which will also serve index.html for unknown /api/* GET routes when dist/ exists (masking API 404s). That can break API error handling/debugging in production mode.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

res.sendFile(join(DIST_DIR, 'index.html'));
});
}

const port = Number(process.env.PORT) || 3000;

await new Promise<void>((resolve) => {
app.listen(port, () => {
console.log(`Brunch running at http://localhost:${port}`);
resolve();
});
});

// Open browser
try {
const { default: open } = await import('open');
await open(`http://localhost:${port}`);
} catch {
console.log(`Open http://localhost:${port} in your browser`);
}
}
113 changes: 113 additions & 0 deletions src/server/project.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';

import { afterEach, describe, expect, it } from 'vitest';

import { findBrunchProject, initBrunchProject, resolveBrunchProject } from './project.js';

describe('project resolution', () => {
const tempDirs: string[] = [];

const makeTempDir = () => {
const dir = mkdtempSync(join(tmpdir(), 'brunch-test-'));
tempDirs.push(dir);
return dir;
};

afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
}
});

// ── findBrunchProject ───────────────────────────────────────────

it('returns BrunchProject when .brunch/ exists in cwd', () => {
const cwd = makeTempDir();
mkdirSync(join(cwd, '.brunch'));

const project = findBrunchProject(cwd);

expect(project).not.toBeNull();
expect(project!.root).toBe(join(cwd, '.brunch'));
expect(project!.dbPath).toBe(join(cwd, '.brunch', 'brunch.db'));
expect(project!.cwd).toBe(cwd);
});

it('finds .brunch/ in a parent directory (walk-up)', () => {
const root = makeTempDir();
mkdirSync(join(root, '.brunch'));
const child = join(root, 'packages', 'frontend');
mkdirSync(child, { recursive: true });

const project = findBrunchProject(child);

expect(project).not.toBeNull();
expect(project!.root).toBe(join(root, '.brunch'));
expect(project!.cwd).toBe(root);
});

it('returns null when no .brunch/ exists up to the walk-up limit', () => {
const root = makeTempDir();
// Create a deep path but no .brunch/ anywhere
const deep = join(root, 'a', 'b', 'c', 'd', 'e', 'f');
mkdirSync(deep, { recursive: true });

const project = findBrunchProject(deep);

expect(project).toBeNull();
});

it('does not walk above the filesystem root or home directory', () => {
// Walking from a tmp dir should never find .brunch/ above tmp
const cwd = makeTempDir();
const project = findBrunchProject(cwd);
expect(project).toBeNull();
});

// ── initBrunchProject ───────────────────────────────────────────

it('creates .brunch/ directory with correct BrunchProject shape', () => {
const cwd = makeTempDir();

const project = initBrunchProject(cwd);

expect(existsSync(project.root)).toBe(true);
expect(project.root).toBe(join(cwd, '.brunch'));
expect(project.dbPath).toBe(join(cwd, '.brunch', 'brunch.db'));
expect(project.cwd).toBe(cwd);
});

it('throws when .brunch/ already exists', () => {
const cwd = makeTempDir();
mkdirSync(join(cwd, '.brunch'));

expect(() => initBrunchProject(cwd)).toThrow();
});

// ── resolveBrunchProject ────────────────────────────────────────

it('creates .brunch/ when none found', () => {
const cwd = makeTempDir();

const project = resolveBrunchProject(cwd);

expect(existsSync(project.root)).toBe(true);
expect(project.cwd).toBe(cwd);
});

it('finds existing .brunch/ without creating a new one', () => {
const root = makeTempDir();
mkdirSync(join(root, '.brunch'));
const child = join(root, 'src');
mkdirSync(child);

const project = resolveBrunchProject(child);

expect(project.root).toBe(join(root, '.brunch'));
expect(project.cwd).toBe(root);
// Should not create a second .brunch/ in the child
expect(existsSync(join(child, '.brunch'))).toBe(false);
});
});
Loading