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
3,129 changes: 0 additions & 3,129 deletions dist/cli.js

This file was deleted.

1 change: 0 additions & 1 deletion dist/cli.js.map

This file was deleted.

211 changes: 211 additions & 0 deletions docs/REVIEW-2026-06-10.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@
},
"files": [
"dist",
"scripts"
"scripts",
"!scripts/generate-test-data.ts",
"!scripts/task-launch-e2e.mjs",
"!scripts/task-launch-e2e-config.mjs"
],
"scripts": {
"prepare": "husky",
"prepublishOnly": "tsup",
"dev": "next dev",
"build": "next build",
"build:cli": "tsup",
Expand Down
24 changes: 18 additions & 6 deletions scripts/generate-test-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,8 @@ import { join } from "path";
import { homedir } from "os";
import { randomUUID } from "crypto";

const CLAUDE_DIR = join(homedir(), ".claude", "projects");

// Clean previous test data
try {
rmSync(CLAUDE_DIR, { recursive: true, force: true });
} catch {}
const REAL_CLAUDE_DIR = join(homedir(), ".claude", "projects");
const CLAUDE_DIR = process.env.DEVLOG_TEST_DATA_DIR ?? REAL_CLAUDE_DIR;

const projects = [
{
Expand Down Expand Up @@ -343,8 +339,24 @@ function generateSessionJsonl(
}

function main() {
// Writing into the real Claude Code history dir is opt-in only.
if (CLAUDE_DIR === REAL_CLAUDE_DIR && !process.argv.includes("--force")) {
console.log("Refusing to write test data into your real Claude Code history dir:");
console.log(` ${REAL_CLAUDE_DIR}`);
console.log("\nRe-run with --force to write there anyway, or set DEVLOG_TEST_DATA_DIR to a scratch dir.");
return;
}

console.log("Generating rich test data...\n");

// Clean previous test data — only the project dirs this script generates,
// never the directory itself (it holds the user's real session history).
for (const project of projects) {
try {
rmSync(join(CLAUDE_DIR, project.encoded), { recursive: true, force: true });
} catch {}
}

for (const project of projects) {
const projectDir = join(CLAUDE_DIR, project.encoded);
mkdirSync(projectDir, { recursive: true });
Expand Down
16 changes: 15 additions & 1 deletion src/app/api/worktrees/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import {
getWorktreeFilesChanged,
} from "@/core/worktree-manager";
import { resolveProjectId } from "@/lib/api-utils";
import {
validateBranchName,
validateWorktreeName,
} from "@/core/worktree-validation";

export async function GET(req: NextRequest) {
try {
Expand All @@ -31,13 +35,23 @@ export async function POST(req: NextRequest) {
try {
const { name, branch, baseBranch } = await req.json();

if (!name || !branch) {
if (typeof name !== "string" || typeof branch !== "string" || !name || !branch) {
return NextResponse.json(
{ error: "name and branch are required" },
{ status: 400 }
);
}

for (const check of [
validateWorktreeName(name),
validateBranchName(branch),
...(baseBranch !== undefined ? [validateBranchName(String(baseBranch))] : []),
]) {
if (!check.ok) {
return NextResponse.json({ error: check.error }, { status: 400 });
}
}

const projectId = resolveProjectId(req);
const wt = await createWorktree(name, branch, baseBranch, projectId);
return NextResponse.json(wt, { status: 201 });
Expand Down
6 changes: 3 additions & 3 deletions src/cli/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ import { execFile, type ChildProcess } from "child_process";
import path from "path";
import chalk from "chalk";
import type { GlobalOptions } from "../../core/types.js";
import { resolvePackageRoot } from "../lib/package-root.js";

export async function serveCommand(
options: { port?: string },
globalOpts: GlobalOptions
): Promise<void> {
const port = options.port ?? "3333";
const projectRoot = path.resolve(import.meta.dirname, "..", "..", "..");
const projectRoot = resolvePackageRoot(import.meta.dirname);

// Check if Next.js source exists (running from repo vs npm install)
const nextConfigPath = path.join(projectRoot, "next.config.ts");
const fs = await import("fs");
if (!fs.existsSync(nextConfigPath)) {
if (!projectRoot || !fs.existsSync(path.join(projectRoot, "next.config.ts"))) {
console.error(chalk.red("\n Dashboard requires running from the DevLog source repo."));
console.error(chalk.dim(" Clone https://github.com/moose-lab/DevLog and run from there.\n"));
process.exit(1);
Expand Down
9 changes: 6 additions & 3 deletions src/cli/commands/setup-statusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { homedir } from "os";
import { execFileSync } from "child_process";
import { fileURLToPath } from "url";
import chalk from "chalk";
import { resolvePackageRoot } from "../lib/package-root.js";
import { ensureInit } from "../../core/config.js";
import { discoverProjects, computeStats } from "../../core/discovery.js";
import { updateCacheFromStats } from "../../core/cache.js";
Expand Down Expand Up @@ -117,10 +118,12 @@ export async function setupStatuslineCommand(): Promise<void> {
mkdirSync(binDir, { recursive: true });

// Copy source script or generate inline
const thisFile = fileURLToPath(import.meta.url);
const scriptSrc = join(dirname(thisFile), "..", "..", "scripts", "tmux-claude-status.sh");
const packageRoot = resolvePackageRoot(dirname(fileURLToPath(import.meta.url)));
const scriptSrc = packageRoot
? join(packageRoot, "scripts", "tmux-claude-status.sh")
: null;

if (existsSync(scriptSrc)) {
if (scriptSrc && existsSync(scriptSrc)) {
copyFileSync(scriptSrc, scriptDest);
} else {
writeFileSync(scriptDest, generateTmuxScript(devlogBin), "utf-8");
Expand Down
11 changes: 7 additions & 4 deletions src/cli/commands/setup-tmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { homedir } from "os";
import { execFileSync } from "child_process";
import { fileURLToPath } from "url";
import chalk from "chalk";
import { resolvePackageRoot } from "../lib/package-root.js";

interface Hook {
type: string;
Expand Down Expand Up @@ -140,11 +141,13 @@ export async function setupTmuxCommand(): Promise<void> {
const destPath = join(destDir, "tmux-claude-status.sh");
mkdirSync(destDir, { recursive: true });

// Resolve the source script relative to this file (dist/commands/setup-tmux.js)
const thisFile = fileURLToPath(import.meta.url);
const srcPath = join(dirname(thisFile), "..", "..", "scripts", "tmux-claude-status.sh");
// Resolve the source script from the package root (works from the bundled dist)
const packageRoot = resolvePackageRoot(dirname(fileURLToPath(import.meta.url)));
const srcPath = packageRoot
? join(packageRoot, "scripts", "tmux-claude-status.sh")
: null;

if (existsSync(srcPath)) {
if (srcPath && existsSync(srcPath)) {
copyFileSync(srcPath, destPath);
} else {
// Fallback: generate the script inline with hardcoded DEVLOG_BIN
Expand Down
36 changes: 36 additions & 0 deletions src/cli/lib/package-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import fs from "fs";
import path from "path";

const PACKAGE_NAME = "@moose-lab/devlog";

/**
* Resolves the DevLog package root by walking up from `startDir`.
*
* The CLI is bundled into a single dist/cli.js, so fixed `..` hops from
* import.meta.dirname land in different places depending on bundler layout
* (and currently resolve above the repo root). Anchoring on package.json
* works from the source repo, the bundled dist, and an npm installation.
*
* Prefers the directory whose package.json is named `@moose-lab/devlog`;
* falls back to the nearest package.json, or null when none exists.
*/
export function resolvePackageRoot(startDir: string): string | null {
let dir = path.resolve(startDir);
let nearest: string | null = null;

for (;;) {
const pkgPath = path.join(dir, "package.json");
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: string };
if (pkg.name === PACKAGE_NAME) return dir;
} catch {
// unreadable/invalid package.json still marks a package boundary
}
nearest ??= dir;
}
const parent = path.dirname(dir);
if (parent === dir) return nearest;
dir = parent;
}
}
43 changes: 43 additions & 0 deletions src/core/__tests__/control-plane-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SCHEMA } from "../db-schema";
import {
applyControlPlaneEvent,
parseControlPlaneProtocolText,
peekControlPlaneGate,
resolveControlPlaneGate,
} from "../control-plane-protocol";

Expand Down Expand Up @@ -148,3 +149,45 @@ test("resolveControlPlaneGate clears gate state without clearing current stage",
assert.equal(session.gate_status, null);
assert.equal(task.gate_status, null);
});

test("peekControlPlaneGate reads the pending gate without clearing it (CR-4)", () => {
const db = makeDb();
db.prepare("INSERT INTO tasks (id, project_id, title) VALUES ('task-1', 'test', 'Task')").run();
db.prepare(
"INSERT INTO sessions (id, project_id, task_id, status) VALUES ('session-1', 'test', 'task-1', 'paused')",
).run();
const applied = applyControlPlaneEvent(
db,
"session-1",
{
type: "gate",
question: "Continue?",
options: ["Continue"],
stage: "2/3 · approval",
},
{
now: () => new Date("2026-06-08T08:00:00.000Z"),
createId: () => "gate-test",
},
);
assert.ok(applied?.gateStatus);

// Peeking must not consume the gate — delivery may still fail, and the
// human's answer would otherwise be lost with the UI reporting success.
const peeked = peekControlPlaneGate(db, "session-1");
assert.deepEqual(peeked?.gateStatus, applied.gateStatus);

const session = db
.prepare("SELECT gate_status FROM sessions WHERE id = 'session-1'")
.get() as { gate_status: string | null };
const task = db
.prepare("SELECT gate_status FROM tasks WHERE id = 'task-1'")
.get() as { gate_status: string | null };
assert.notEqual(session.gate_status, null);
assert.notEqual(task.gate_status, null);

// Resolving afterwards clears it exactly once.
const resolved = resolveControlPlaneGate(db, "session-1");
assert.deepEqual(resolved?.gateStatus, applied.gateStatus);
assert.equal(peekControlPlaneGate(db, "session-1"), null);
});
Loading
Loading