diff --git a/.gitignore b/.gitignore index dd88890..fa948a6 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,8 @@ __pycache__/ # Ralph - AI agent loop files .ralph* +.opencode/ -# Ralph - AI agent loop files +# Ralph - AI agent loop files (plugin exception) +!.opencode/plugin/ .opencode/plugin/ralph-write-guardrail.ts diff --git a/package.json b/package.json index 1e5ff61..c92b981 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "bun": ">=1.3.0" }, "bin": { - "opencode-manager": "src/bin/opencode-manager.ts" + "opencode-manager": "src/bin/opencode-manager.ts", + "oqo-sess": "src/bin/opencode-manager.ts" }, "files": [ "src", diff --git a/src/cli/commands/chat.ts b/src/cli/commands/chat.ts index 21ff983..6c515fb 100644 --- a/src/cli/commands/chat.ts +++ b/src/cli/commands/chat.ts @@ -278,20 +278,27 @@ async function handleChatShow( // Copy to clipboard if requested if (showOpts.clipboard) { - const content = hydratedMessage.parts - ?.map((p: { text: string }) => p.text) - .join("\n\n") ?? hydratedMessage.previewText - try { - await copyToClipboard(content) - if (globalOpts.format === "table") { - console.log("(copied to clipboard)") - } - } catch { - // Clipboard copy failed (e.g., xclip/pbcopy not available) - // Warn but continue - the user still gets the message output + const canAttemptClipboard = !process.env.CI && !process.env.BUN_TEST + if (!canAttemptClipboard) { if (globalOpts.format === "table") { console.error("Warning: Could not copy to clipboard") } + } else { + const content = hydratedMessage.parts + ?.map((p: { text: string }) => p.text) + .join("\n\n") ?? hydratedMessage.previewText + try { + await copyToClipboard(content) + if (globalOpts.format === "table") { + console.log("(copied to clipboard)") + } + } catch { + // Clipboard copy failed (e.g., xclip/pbcopy not available) + // Warn but continue - the user still gets the message output + if (globalOpts.format === "table") { + console.error("Warning: Could not copy to clipboard") + } + } } } diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 6134a13..24cb9bd 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -6,6 +6,7 @@ */ import { Command, type OptionValues } from "commander" +import { realpath } from "node:fs/promises" import { parseGlobalOptions, type GlobalOptions } from "../index" import { copySession, @@ -44,6 +45,8 @@ function collectOptions(cmd: Command): OptionValues { export interface SessionsListOptions { /** Filter sessions by project ID */ project?: string + /** List all sessions globally (conflicts with --project) */ + global?: boolean /** Search query to filter sessions (fuzzy match) */ search?: string } @@ -104,12 +107,14 @@ export function registerSessionsCommands(parent: Command): void { .command("list") .description("List sessions") .option("-p, --project ", "Filter by project ID") + .option("-g, --global", "List all sessions globally (default: sessions for current directory)") .option("-s, --search ", "Search query to filter sessions") .action(function (this: Command) { const globalOpts = parseGlobalOptions(collectOptions(this)) const cmdOpts = this.opts() const listOpts: SessionsListOptions = { project: cmdOpts.project as string | undefined, + global: cmdOpts.global as boolean | undefined, search: cmdOpts.search as string | undefined, } handleSessionsList(globalOpts, listOpts) @@ -197,6 +202,9 @@ export function registerSessionsCommands(parent: Command): void { [ "", "Examples:", + " opencode-manager sessions list # List sessions for current directory", + " opencode-manager sessions list --global # List all sessions globally", + " opencode-manager sessions list -p my-project # List sessions for specific project", " opencode-manager sessions list --experimental-sqlite", " opencode-manager sessions list --db ~/.local/share/opencode/opencode.db", ].join("\n") @@ -216,6 +224,78 @@ function buildSessionSearchText(session: SessionRecord): string { ].join(" ").replace(/\s+/g, " ").trim() } +/** + * Result of inferring project from cwd. + */ +interface InferredProject { + projectId: string + worktree: string +} + +/** + * Infer the project ID from the current working directory. + * Finds projects whose worktree is a parent of (or equal to) cwd, + * then selects the deepest match (longest path). + * + * @throws UsageError if no project matches cwd or if multiple projects match at the same depth + */ +async function inferProjectFromCwd( + provider: ReturnType +): Promise { + const projects = await provider.loadProjectRecords() + const cwd = process.cwd() + + // Find all projects whose worktree is a parent of or equal to cwd + const candidates: Array<{ project: typeof projects[0]; depth: number }> = [] + + for (const project of projects) { + let worktree = project.worktree + try { + // Resolve symlinks to get the real path (cwd is already resolved by process.cwd()) + worktree = await realpath(worktree) + } catch { + // If realpath fails (path doesn't exist), use original worktree path + worktree = project.worktree + } + // Normalize to remove trailing slashes for consistent comparison + worktree = worktree.replace(/\/+$/, "") + // Check if cwd is inside or equal to worktree + if (cwd === worktree || cwd.startsWith(worktree + "/")) { + // Depth is the number of path segments in worktree + const depth = worktree.split("/").length + candidates.push({ project, depth }) + } + } + + if (candidates.length === 0) { + throw new UsageError( + `No project found for current directory: ${cwd}\n` + + `Use --global to list all sessions, or --project to specify a project.` + ) + } + + // Find the maximum depth + const maxDepth = Math.max(...candidates.map((c) => c.depth)) + + // Filter to only candidates at max depth + const deepestCandidates = candidates.filter((c) => c.depth === maxDepth) + + if (deepestCandidates.length > 1) { + const projectIds = deepestCandidates.map((c) => c.project.projectId).join(", ") + throw new UsageError( + `Ambiguous project match for current directory: ${cwd}\n` + + `Multiple projects match at the same depth: ${projectIds}\n` + + `Use --project to specify which project.` + ) + } + + const winner = deepestCandidates[0].project + return { + projectId: winner.projectId, + worktree: winner.worktree, + } +} + /** * Handle the sessions list command. */ @@ -223,13 +303,34 @@ async function handleSessionsList( globalOpts: GlobalOptions, listOpts: SessionsListOptions ): Promise { + // Validate that --global and --project are not used together + if (listOpts.global && listOpts.project) { + throw new UsageError( + "Cannot use --global and --project together. Use one or the other." + ) + } + // Create data provider based on global options (JSONL or SQLite backend) const provider = createProviderFromGlobalOptions(globalOpts) + // Determine the project filter + let projectIdFilter: string | undefined + if (listOpts.global) { + // --global: list all sessions, no project filter + projectIdFilter = undefined + } else if (listOpts.project) { + // --project specified: use it + projectIdFilter = listOpts.project + } else { + // Default: infer project from current working directory + const inferred = await inferProjectFromCwd(provider) + projectIdFilter = inferred.projectId + } + // Load session records from the data layer // If a project filter is provided, pass it to the loader let sessions = await provider.loadSessionRecords({ - projectId: listOpts.project, + projectId: projectIdFilter, }) // Apply fuzzy search if search query is provided @@ -489,10 +590,14 @@ async function handleSessionsCopy( ): Promise { const outputOpts = getOutputOptions(globalOpts) + // Create data provider based on global options (JSONL or SQLite backend) + const provider = createProviderFromGlobalOptions(globalOpts) + // Resolve session ID to a session record const { session } = await resolveSessionId(copyOpts.session, { root: globalOpts.root, allowPrefix: true, + provider, }) // Validate target project exists @@ -500,10 +605,11 @@ async function handleSessionsCopy( const { project: targetProject } = await resolveProjectId(copyOpts.to, { root: globalOpts.root, allowPrefix: true, + provider, }) // Copy the session - const newRecord = await copySession(session, targetProject.projectId, globalOpts.root) + const newRecord = await provider.copySession(session, targetProject.projectId) // Output success printSuccessOutput( diff --git a/src/cli/errors.ts b/src/cli/errors.ts index 3fc94a7..599964b 100644 --- a/src/cli/errors.ts +++ b/src/cli/errors.ts @@ -108,7 +108,8 @@ export function exitWithError( exitCode: ExitCodeValue = ExitCode.ERROR, format: OutputFormat = "table" ): never { - console.error(formatErrorOutput(error, format)) + const output = formatErrorOutput(error, format) + process.stderr.write(output.endsWith("\n") ? output : `${output}\n`) process.exit(exitCode) } @@ -122,7 +123,8 @@ export function exitWithCLIError( error: CLIError, format: OutputFormat = "table" ): never { - console.error(formatErrorOutput(error, format)) + const output = formatErrorOutput(error, format) + process.stderr.write(output.endsWith("\n") ? output : `${output}\n`) process.exit(error.exitCode) } diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts index 5ee20be..2a3d77e 100644 --- a/src/lib/clipboard.ts +++ b/src/lib/clipboard.ts @@ -1,4 +1,4 @@ -import { exec } from "node:child_process" +import { spawn } from "node:child_process" /** * Copy text to the system clipboard. @@ -9,15 +9,40 @@ import { exec } from "node:child_process" */ export function copyToClipboard(text: string): Promise { return new Promise((resolve, reject) => { - const cmd = - process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard" - const proc = exec(cmd, (error) => { - if (error) { - reject(error) - } else { + const isDarwin = process.platform === "darwin" + const isLinux = process.platform === "linux" + const displayValue = process.env.DISPLAY?.trim() + const waylandValue = process.env.WAYLAND_DISPLAY?.trim() + const hasDisplay = Boolean(displayValue || waylandValue) + if (isLinux && !hasDisplay) { + reject(new Error("Clipboard not available (no display)")) + return + } + + const command = isDarwin ? "pbcopy" : "xclip" + const args = isDarwin ? [] : ["-selection", "clipboard"] + const proc = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"] }) + proc.unref() + const timeout = setTimeout(() => { + proc.kill("SIGKILL") + reject(new Error(`${command} timed out`)) + }, 2000) + + proc.on("error", (error) => { + clearTimeout(timeout) + reject(error) + }) + + proc.on("close", (code) => { + clearTimeout(timeout) + if (code === 0) { resolve() + } else { + const suffix = code === null ? "unknown" : String(code) + reject(new Error(`${command} exited with code ${suffix}`)) } }) + proc.stdin?.write(text) proc.stdin?.end() }) diff --git a/src/lib/opencode-data-provider.ts b/src/lib/opencode-data-provider.ts index 5251895..1449f71 100644 --- a/src/lib/opencode-data-provider.ts +++ b/src/lib/opencode-data-provider.ts @@ -20,7 +20,9 @@ * const sessions = await provider.loadSessionRecords({ projectId: 'abc123' }) * ``` */ -import { resolve } from "node:path" +import { resolve, join } from "node:path" +import { existsSync, readdirSync, statSync, readFileSync } from "node:fs" +import { Database } from "bun:sqlite" import type { ProjectRecord, SessionRecord, @@ -653,6 +655,82 @@ export function createProvider(options: DataProviderOptions = {}): DataProvider return createJsonlProvider(root) } +function getLatestJsonlSessionTime(root: string): number | null { + const sessionRoot = join(root, "storage", "session") + if (!existsSync(sessionRoot)) { + return null + } + + let latest = 0 + const reservedDirs = new Set(["message", "part"]) + try { + const projectDirs = readdirSync(sessionRoot, { withFileTypes: true }) + for (const dirent of projectDirs) { + if (!dirent.isDirectory()) { + continue + } + if (reservedDirs.has(dirent.name)) { + continue + } + const projectDir = join(sessionRoot, dirent.name) + const files = readdirSync(projectDir) + for (const file of files) { + if (!file.endsWith(".json")) { + continue + } + const filePath = join(projectDir, file) + const stat = statSync(filePath) + if (stat.mtimeMs > latest) { + latest = stat.mtimeMs + } + try { + const payload = JSON.parse(readFileSync(filePath, "utf8")) + const updated = payload?.time?.updated ?? payload?.time?.created + if (typeof updated === "number" && updated > latest) { + latest = updated + } + } catch { + continue + } + } + } + } catch { + return null + } + + return latest > 0 ? latest : null +} + +function getLatestSqliteSessionTime(dbPath: string): number | null { + if (!existsSync(dbPath)) { + return null + } + + let db: Database | null = null + try { + db = new Database(dbPath, { readonly: true }) + const columns = db.query("PRAGMA table_info(session)").all() as { name?: string }[] + const columnNames = columns.map((col) => col.name).filter(Boolean) as string[] + const candidate = [ + "time_updated", + "updated_at", + "updatedAt", + "updated", + "timeUpdated", + ].find((name) => columnNames.includes(name)) + if (!candidate) { + return null + } + const row = db.query(`SELECT MAX(${candidate}) as value FROM session`).get() as { value?: number } | null + const value = row?.value + return typeof value === "number" ? value : null + } catch { + return null + } finally { + db?.close() + } +} + /** * Create a data provider from CLI global options. * @@ -678,8 +756,21 @@ export function createProviderFromGlobalOptions(globalOptions: { }) } + const root = globalOptions.root ?? DEFAULT_ROOT + if (root === DEFAULT_ROOT) { + const dbPath = DEFAULT_SQLITE_PATH + const sqliteTime = getLatestSqliteSessionTime(dbPath) + const jsonlTime = getLatestJsonlSessionTime(root) + if (sqliteTime !== null && (jsonlTime === null || sqliteTime >= jsonlTime)) { + return createProvider({ + backend: "sqlite", + dbPath, + }) + } + } + return createProvider({ backend: "jsonl", - root: globalOptions.root, + root, }) } diff --git a/src/lib/opencode-data-sqlite.ts b/src/lib/opencode-data-sqlite.ts index 4966425..5baf2aa 100644 --- a/src/lib/opencode-data-sqlite.ts +++ b/src/lib/opencode-data-sqlite.ts @@ -732,8 +732,8 @@ export async function loadSessionRecordsSqlite( const projectIdColumn = pickColumn(columns, ["project_id", "projectID", "projectId", "project"]) const parentIdColumn = pickColumn(columns, ["parent_id", "parentID", "parentId", "parent"]) - const createdColumn = pickColumn(columns, ["created_at", "created", "created_ms", "createdAt"]) - const updatedColumn = pickColumn(columns, ["updated_at", "updated", "updated_ms", "updatedAt"]) + const createdColumn = pickColumn(columns, ["created_at", "created", "created_ms", "createdAt", "time_created"]) + const updatedColumn = pickColumn(columns, ["updated_at", "updated", "updated_ms", "updatedAt", "time_updated"]) const dataColumn = pickColumn(columns, ["data", "metadata", "payload", "json"]) const directoryColumn = pickColumn(columns, ["directory", "cwd", "path", "worktree", "root"]) const titleColumn = pickColumn(columns, ["title", "name"]) diff --git a/tests/cli/commands/projects.test.ts b/tests/cli/commands/projects.test.ts index 0487578..6c2380c 100644 --- a/tests/cli/commands/projects.test.ts +++ b/tests/cli/commands/projects.test.ts @@ -904,7 +904,7 @@ describe("projects delete --experimental-sqlite", () => { it("deletes project and all related sessions/messages/parts atomically", async () => { // Get initial counts by checking sessions - const sessionsBefore = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json`.quiet(); + const sessionsBefore = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet(); const parsedSessionsBefore = JSON.parse(sessionsBefore.stdout.toString()); // proj_present has 3 sessions: session_parser_fix, session_add_tests, session_refactor_api const sessionsInProject = parsedSessionsBefore.data.filter( @@ -922,7 +922,7 @@ describe("projects delete --experimental-sqlite", () => { expect(projectsAfter).not.toContain("proj_present"); // Verify sessions belonging to the project are also gone - const sessionsAfter = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json`.quiet(); + const sessionsAfter = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet(); const parsedSessionsAfter = JSON.parse(sessionsAfter.stdout.toString()); const sessionsInProjectAfter = parsedSessionsAfter.data.filter( (s: { projectId: string }) => s.projectId === "proj_present" diff --git a/tests/cli/commands/sessions-cwd-filter-edge-cases.test.ts b/tests/cli/commands/sessions-cwd-filter-edge-cases.test.ts new file mode 100644 index 0000000..dc1c788 --- /dev/null +++ b/tests/cli/commands/sessions-cwd-filter-edge-cases.test.ts @@ -0,0 +1,315 @@ +/** + * Edge case tests for sessions list cwd filtering. + * + * These tests expose bugs in the current implementation: + * 1. Symlink resolution - cwd is symlink, project worktree is real path + * 2. Path prefix collision - short worktree path matching longer cwd prefix + */ + +import { describe, expect, it } from "bun:test"; +import { $ } from "bun"; +import { promises as fs } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { FIXTURE_STORE_ROOT, TESTS_ROOT } from "../../helpers"; + +// Resolve repo root from tests directory (go up one level) +const REPO_ROOT = dirname(TESTS_ROOT); + +// Absolute path to CLI entry point +const CLI_PATH = join(REPO_ROOT, "src/bin/opencode-manager.ts"); + +type TempStore = { + tempDir: string; + storeRoot: string; + cleanup: () => Promise; +}; + +async function createTempStore(): Promise { + const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-manager-test-")); + const storeRoot = join(tempDir, "store"); + await fs.mkdir(join(storeRoot, "storage", "project"), { recursive: true }); + await fs.mkdir(join(storeRoot, "storage", "session"), { recursive: true }); + return { + tempDir, + storeRoot, + cleanup: () => fs.rm(tempDir, { recursive: true, force: true }), + }; +} + +async function writeProject(storeRoot: string, projectId: string, worktree: string): Promise { + const payload = { + id: projectId, + worktree, + vcs: "git", + time: { created: 1704067200000 }, + }; + const filePath = join(storeRoot, "storage", "project", `${projectId}.json`); + await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8"); +} + +async function writeSession( + storeRoot: string, + projectId: string, + sessionId: string, + directory: string, + title: string +): Promise { + const payload = { + id: sessionId, + projectID: projectId, + directory, + title, + version: "1.0.0", + time: { created: 1704067200000, updated: 1704153600000 }, + }; + const sessionDir = join(storeRoot, "storage", "session", projectId); + await fs.mkdir(sessionDir, { recursive: true }); + const filePath = join(sessionDir, `${sessionId}.json`); + await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8"); +} + +async function runCliWithTimeout(promise: Promise, ms = 30000) { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`CLI command timed out after ${ms}ms`)), ms) + ), + ]); +} + +describe("sessions list cwd filtering - symlink resolution", () => { + // NOTE: process.cwd() automatically resolves symlinks to the real path. + // This means the key bug case is: project registered with symlink path, + // but user runs from directory (which process.cwd() resolves to real path). + + it("matches project when cwd is a symlink to the project worktree (cwd resolves, worktree is real)", async () => { + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + // Create real directory and a symlink pointing to it + const realDir = join(tempDir, "real-project"); + const linkDir = join(tempDir, "link-project"); + await fs.mkdir(realDir, { recursive: true }); + await fs.symlink(realDir, linkDir); + + // Project is registered with the REAL path + await writeProject(storeRoot, "proj_real", realDir); + await writeSession(storeRoot, "proj_real", "session_in_real", realDir, "Session in real dir"); + + // User runs command from the SYMLINK path + // process.cwd() resolves to realDir automatically, so this should work + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json` + .cwd(linkDir) + .quiet(); + const result = await runCliWithTimeout(shellPromise); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + + // This should pass because process.cwd() resolves symlinks + expect(parsed.ok).toBe(true); + expect(parsed.data).toBeArray(); + expect(parsed.data.length).toBe(1); + expect(parsed.data[0].sessionId).toBe("session_in_real"); + } finally { + await cleanup(); + } + }); + + it("BUG: matches project when worktree is a symlink and cwd is the real path", async () => { + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + // Create real directory and a symlink pointing to it + const realDir = join(tempDir, "real-project"); + const linkDir = join(tempDir, "link-project"); + await fs.mkdir(realDir, { recursive: true }); + await fs.symlink(realDir, linkDir); + + // Project is registered with the SYMLINK path (happens when project was added via symlink) + await writeProject(storeRoot, "proj_link", linkDir); + await writeSession(storeRoot, "proj_link", "session_in_link", realDir, "Session via symlink"); + + // User runs command from the directory (process.cwd() returns realDir because it resolves) + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json` + .cwd(realDir) + .quiet(); + const result = await runCliWithTimeout(shellPromise); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + + // BUG: Currently fails because: + // - process.cwd() returns realDir (resolved) + // - worktree is linkDir (unresolved symlink) + // - realDir !== linkDir and !realDir.startsWith(linkDir + "/") + // + // FIX NEEDED: Resolve worktree paths with fs.realpath() before comparison + expect(parsed.ok).toBe(true); + expect(parsed.data).toBeArray(); + expect(parsed.data.length).toBe(1); + expect(parsed.data[0].sessionId).toBe("session_in_link"); + } finally { + await cleanup(); + } + }); +}); + +describe("sessions list cwd filtering - path prefix collision", () => { + it("does NOT match project when worktree is a prefix but not a parent directory", async () => { + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + // Create two directories where one is a prefix of the other's name + // e.g., /tmp/test/pro and /tmp/test/project + const shortDir = join(tempDir, "pro"); + const longDir = join(tempDir, "project"); + await fs.mkdir(shortDir, { recursive: true }); + await fs.mkdir(longDir, { recursive: true }); + + // Project registered with SHORT path + await writeProject(storeRoot, "proj_short", shortDir); + await writeSession(storeRoot, "proj_short", "session_short", shortDir, "Short project"); + + // User runs from LONG path (which is NOT inside shortDir) + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json` + .cwd(longDir) + .nothrow() + .quiet(); + const result = await runCliWithTimeout(shellPromise); + + // BUG: Current code does `cwd.startsWith(worktree + "/")` + // which would be "/tmp/test/project".startsWith("/tmp/test/pro/") + // This is actually correct! The "/" ensures we don't have prefix collision. + // Let me verify this test is correct... + + // Actually, this should correctly error because longDir is NOT inside shortDir + // The "/" in the startsWith check prevents false matches + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toContain("No project found"); + } finally { + await cleanup(); + } + }); + + it("correctly handles worktree paths with trailing slashes", async () => { + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + const worktree = join(tempDir, "my-project"); + const subDir = join(worktree, "subdir"); + await fs.mkdir(subDir, { recursive: true }); + + // Register project with trailing slash (edge case) + await writeProject(storeRoot, "proj_trailing", worktree + "/"); + await writeSession(storeRoot, "proj_trailing", "session_trailing", worktree, "Trailing slash"); + + // Run from subdirectory + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json` + .cwd(subDir) + .quiet(); + const result = await runCliWithTimeout(shellPromise); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + + // Should still match despite trailing slash in worktree + expect(parsed.ok).toBe(true); + expect(parsed.data).toBeArray(); + expect(parsed.data.length).toBe(1); + } finally { + await cleanup(); + } + }); + + it("handles cwd with trailing slash correctly", async () => { + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + const worktree = join(tempDir, "my-project"); + await fs.mkdir(worktree, { recursive: true }); + + await writeProject(storeRoot, "proj_normal", worktree); + await writeSession(storeRoot, "proj_normal", "session_normal", worktree, "Normal project"); + + // The shell promise cwd() doesn't add trailing slash, but let's test + // by running directly in the worktree + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json` + .cwd(worktree) + .quiet(); + const result = await runCliWithTimeout(shellPromise); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + + expect(parsed.ok).toBe(true); + expect(parsed.data).toBeArray(); + expect(parsed.data.length).toBe(1); + } finally { + await cleanup(); + } + }); +}); + +describe("sessions list cwd filtering - nested symlink scenarios", () => { + it("handles symlink chain cwd (process.cwd resolves to real)", async () => { + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + // Create chain: link1 -> link2 -> realDir + const realDir = join(tempDir, "real-deep-project"); + const link2 = join(tempDir, "link2"); + const link1 = join(tempDir, "link1"); + + await fs.mkdir(realDir, { recursive: true }); + await fs.symlink(realDir, link2); + await fs.symlink(link2, link1); + + // Project registered with real path + await writeProject(storeRoot, "proj_chain", realDir); + await writeSession(storeRoot, "proj_chain", "session_chain", realDir, "Chain project"); + + // Run from link1 (process.cwd() resolves all the way to realDir) + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json` + .cwd(link1) + .quiet(); + const result = await runCliWithTimeout(shellPromise); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + + // This should pass because process.cwd() resolves the full chain to realDir + expect(parsed.ok).toBe(true); + expect(parsed.data).toBeArray(); + expect(parsed.data.length).toBe(1); + expect(parsed.data[0].sessionId).toBe("session_chain"); + } finally { + await cleanup(); + } + }); + + it("BUG: handles project registered via symlink chain", async () => { + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + // Create chain: link1 -> link2 -> realDir + const realDir = join(tempDir, "real-deep-project"); + const link2 = join(tempDir, "link2"); + const link1 = join(tempDir, "link1"); + + await fs.mkdir(realDir, { recursive: true }); + await fs.symlink(realDir, link2); + await fs.symlink(link2, link1); + + // Project registered with symlink path (link1) + await writeProject(storeRoot, "proj_via_link", link1); + await writeSession(storeRoot, "proj_via_link", "session_via_link", realDir, "Via link chain"); + + // Run from realDir (process.cwd() returns realDir) + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json` + .cwd(realDir) + .quiet(); + const result = await runCliWithTimeout(shellPromise); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + + // BUG: Currently fails - worktree (link1) doesn't match cwd (realDir) + // FIX: Resolve worktree symlinks with fs.realpath() + expect(parsed.ok).toBe(true); + expect(parsed.data).toBeArray(); + expect(parsed.data.length).toBe(1); + expect(parsed.data[0].sessionId).toBe("session_via_link"); + } finally { + await cleanup(); + } + }); +}); diff --git a/tests/cli/commands/sessions-cwd-filter.test.ts b/tests/cli/commands/sessions-cwd-filter.test.ts new file mode 100644 index 0000000..97cbb5f --- /dev/null +++ b/tests/cli/commands/sessions-cwd-filter.test.ts @@ -0,0 +1,317 @@ +/** + * TDD tests for sessions list cwd filtering behavior. + * + * Feature: sessions list defaults to current working directory; + * --global flag required to list all sessions. + */ + +import { describe, expect, it } from "bun:test"; +import { $ } from "bun"; +import { promises as fs } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { FIXTURE_STORE_ROOT, TESTS_ROOT } from "../../helpers"; + +// Resolve repo root from tests directory (go up one level) +const REPO_ROOT = dirname(TESTS_ROOT); + +// Absolute path to CLI entry point +const CLI_PATH = join(REPO_ROOT, "src/bin/opencode-manager.ts"); + +type TempStore = { + tempDir: string; + storeRoot: string; + cleanup: () => Promise; +}; + +async function createTempStore(): Promise { + const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-manager-test-")); + const storeRoot = join(tempDir, "store"); + await fs.mkdir(join(storeRoot, "storage", "project"), { recursive: true }); + await fs.mkdir(join(storeRoot, "storage", "session"), { recursive: true }); + return { + tempDir, + storeRoot, + cleanup: () => fs.rm(tempDir, { recursive: true, force: true }), + }; +} + +async function writeProject(storeRoot: string, projectId: string, worktree: string): Promise { + const payload = { + id: projectId, + worktree, + vcs: "git", + time: { created: 1704067200000 }, + }; + const filePath = join(storeRoot, "storage", "project", `${projectId}.json`); + await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8"); +} + +async function writeSession( + storeRoot: string, + projectId: string, + sessionId: string, + directory: string, + title: string +): Promise { + const payload = { + id: sessionId, + projectID: projectId, + directory, + title, + version: "1.0.0", + time: { created: 1704067200000, updated: 1704153600000 }, + }; + const sessionDir = join(storeRoot, "storage", "session", projectId); + await fs.mkdir(sessionDir, { recursive: true }); + const filePath = join(sessionDir, `${sessionId}.json`); + await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8"); +} + +describe("sessions list cwd filtering", () => { + it("filters sessions to current working directory by default", async () => { + // Create temp store with absolute paths to work from any cwd + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + const worktree = join(tempDir, "worktrees", "my-project"); + await fs.mkdir(worktree, { recursive: true }); + await writeProject(storeRoot, "proj_test", worktree); + await writeSession(storeRoot, "proj_test", "session_one", worktree, "Test session"); + await writeSession(storeRoot, "proj_test", "session_two", worktree, "Another session"); + + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json`.cwd(worktree).quiet(); + const result = await Promise.race([ + shellPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("CLI command timed out after 30s")), 30000) + ), + ]); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + + // All returned sessions should match cwd + expect(parsed.ok).toBe(true); + expect(parsed.data).toBeArray(); + expect(parsed.data.length).toBeGreaterThan(0); + for (const session of parsed.data) { + expect(session.directory).toBe(worktree); + } + } finally { + await cleanup(); + } + }); + + it("returns empty array when cwd project has no sessions", async () => { + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + const worktree = join(tempDir, "worktrees", "empty-project"); + await fs.mkdir(worktree, { recursive: true }); + await writeProject(storeRoot, "proj_empty", worktree); + + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json`.cwd(worktree).quiet(); + const result = await Promise.race([ + shellPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("CLI command timed out after 30s")), 30000) + ), + ]); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + + expect(parsed.ok).toBe(true); + expect(parsed.data).toBeArray(); + expect(parsed.data.length).toBe(0); + } finally { + await cleanup(); + } + }); + + it("errors when cwd is outside any project", async () => { + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + const worktree = join(tempDir, "worktrees", "inside-project"); + const outsideDir = join(tempDir, "outside"); + await fs.mkdir(worktree, { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + await writeProject(storeRoot, "proj_inside", worktree); + + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json`.cwd(outsideDir).nothrow().quiet(); + const result = await Promise.race([ + shellPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("CLI command timed out after 30s")), 30000) + ), + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toContain("current directory"); + } finally { + await cleanup(); + } + }); + + it("chooses deepest match when cwd is inside nested projects", async () => { + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + const parentDir = join(tempDir, "worktrees", "parent"); + const childDir = join(parentDir, "child"); + await fs.mkdir(childDir, { recursive: true }); + + await writeProject(storeRoot, "proj_parent", parentDir); + await writeProject(storeRoot, "proj_child", childDir); + await writeSession(storeRoot, "proj_parent", "session_parent", parentDir, "Parent session"); + await writeSession(storeRoot, "proj_child", "session_child", childDir, "Child session"); + + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json`.cwd(childDir).quiet(); + const result = await Promise.race([ + shellPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("CLI command timed out after 30s")), 30000) + ), + ]); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + + expect(parsed.ok).toBe(true); + expect(parsed.data.map((session: { sessionId: string }) => session.sessionId)).toEqual(["session_child"]); + } finally { + await cleanup(); + } + }); + + it("errors when multiple projects match cwd", async () => { + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + const sharedDir = join(tempDir, "worktrees", "shared"); + await fs.mkdir(sharedDir, { recursive: true }); + + await writeProject(storeRoot, "proj_one", sharedDir); + await writeProject(storeRoot, "proj_two", sharedDir); + await writeSession(storeRoot, "proj_one", "session_one", sharedDir, "Session one"); + await writeSession(storeRoot, "proj_two", "session_two", sharedDir, "Session two"); + + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json`.cwd(sharedDir).nothrow().quiet(); + const result = await Promise.race([ + shellPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("CLI command timed out after 30s")), 30000) + ), + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toContain("--project"); + } finally { + await cleanup(); + } + }); + + it("resolves symlinks when matching cwd to projects", async () => { + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + const realDir = join(tempDir, "worktrees", "real"); + const linkDir = join(tempDir, "worktrees", "link"); + await fs.mkdir(realDir, { recursive: true }); + await fs.symlink(realDir, linkDir); + + await writeProject(storeRoot, "proj_real", realDir); + await writeSession(storeRoot, "proj_real", "session_real", realDir, "Realpath match"); + + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json`.cwd(linkDir).quiet(); + const result = await Promise.race([ + shellPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("CLI command timed out after 30s")), 30000) + ), + ]); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + + expect(parsed.ok).toBe(true); + expect(parsed.data.length).toBe(1); + expect(parsed.data[0].sessionId).toBe("session_real"); + } finally { + await cleanup(); + } + }); + + it("intersects --search with cwd filtering", async () => { + const { tempDir, storeRoot, cleanup } = await createTempStore(); + try { + const worktreeA = join(tempDir, "worktrees", "project-a"); + const worktreeB = join(tempDir, "worktrees", "project-b"); + await fs.mkdir(worktreeA, { recursive: true }); + await fs.mkdir(worktreeB, { recursive: true }); + + await writeProject(storeRoot, "proj_a", worktreeA); + await writeProject(storeRoot, "proj_b", worktreeB); + await writeSession(storeRoot, "proj_a", "session_parser_a", worktreeA, "Parser fix A"); + await writeSession(storeRoot, "proj_b", "session_parser_b", worktreeB, "Parser fix B"); + + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${storeRoot} --format json --search parser`.cwd(worktreeA).quiet(); + const result = await Promise.race([ + shellPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("CLI command timed out after 30s")), 30000) + ), + ]); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + + expect(parsed.ok).toBe(true); + expect(parsed.data.map((session: { sessionId: string }) => session.sessionId)).toEqual(["session_parser_a"]); + } finally { + await cleanup(); + } + }); + + it("--global flag lists all sessions regardless of cwd", async () => { + // Run from repo root but with --global flag + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${FIXTURE_STORE_ROOT} --global --format json`.quiet(); + const result = await Promise.race([ + shellPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("CLI command timed out after 30s")), 30000) + ), + ]); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + + // Should return all sessions (no cwd filter) + expect(parsed.ok).toBe(true); + expect(parsed.data).toBeArray(); + expect(parsed.data.length).toBe(2); // Fixture has 2 sessions + }); + + it("--project flag overrides cwd filtering", async () => { + // Run from repo root but explicitly filter by project + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${FIXTURE_STORE_ROOT} --project proj_present --format json`.quiet(); + const result = await Promise.race([ + shellPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("CLI command timed out after 30s")), 30000) + ), + ]); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + + expect(parsed.ok).toBe(true); + expect(parsed.data).toBeArray(); + expect(parsed.data.length).toBe(2); + for (const session of parsed.data) { + expect(session.projectId).toBe("proj_present"); + } + }); + + it("rejects --global and --project together", async () => { + const shellPromise = $`bun ${CLI_PATH} sessions list --root ${FIXTURE_STORE_ROOT} --global --project proj_present --format json`.nothrow().quiet(); + const result = await Promise.race([ + shellPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("CLI command timed out after 30s")), 30000) + ), + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toContain("Cannot use --global and --project together"); + }); +}); diff --git a/tests/cli/commands/sessions.test.ts b/tests/cli/commands/sessions.test.ts index 7a10d66..d1fa998 100644 --- a/tests/cli/commands/sessions.test.ts +++ b/tests/cli/commands/sessions.test.ts @@ -13,7 +13,7 @@ import { FIXTURE_STORE_ROOT, FIXTURE_SQLITE_PATH } from "../../helpers"; describe("sessions list --format json", () => { it("outputs valid JSON with success envelope", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -23,7 +23,7 @@ describe("sessions list --format json", () => { }); it("includes correct session count", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -31,7 +31,7 @@ describe("sessions list --format json", () => { }); it("includes session fields in JSON output", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -45,7 +45,7 @@ describe("sessions list --format json", () => { }); it("serializes Date fields as ISO strings", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -61,7 +61,7 @@ describe("sessions list --format json", () => { }); it("includes meta with limit info", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -81,7 +81,7 @@ describe("sessions list --format json", () => { }); it("respects --search filter", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json --search parser`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json --search parser`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -90,7 +90,7 @@ describe("sessions list --format json", () => { }); it("respects --limit option", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json --limit 1`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json --limit 1`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -100,7 +100,7 @@ describe("sessions list --format json", () => { describe("sessions list --format ndjson", () => { it("outputs valid NDJSON (one JSON object per line)", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format ndjson`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format ndjson`.quiet(); const output = result.stdout.toString().trim(); const lines = output.split("\n"); @@ -111,7 +111,7 @@ describe("sessions list --format ndjson", () => { }); it("includes correct session count (one per line)", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format ndjson`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format ndjson`.quiet(); const output = result.stdout.toString().trim(); const lines = output.split("\n"); @@ -119,7 +119,7 @@ describe("sessions list --format ndjson", () => { }); it("includes session fields in each NDJSON line", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format ndjson`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format ndjson`.quiet(); const output = result.stdout.toString().trim(); const lines = output.split("\n"); @@ -134,7 +134,7 @@ describe("sessions list --format ndjson", () => { }); it("serializes Date fields as ISO strings", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format ndjson`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format ndjson`.quiet(); const output = result.stdout.toString().trim(); const lines = output.split("\n"); @@ -151,7 +151,7 @@ describe("sessions list --format ndjson", () => { }); it("does not include envelope wrapper (raw records only)", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format ndjson`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format ndjson`.quiet(); const output = result.stdout.toString().trim(); const lines = output.split("\n"); @@ -175,7 +175,7 @@ describe("sessions list --format ndjson", () => { }); it("respects --search filter", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format ndjson --search parser`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format ndjson --search parser`.quiet(); const output = result.stdout.toString().trim(); const lines = output.split("\n"); @@ -185,7 +185,7 @@ describe("sessions list --format ndjson", () => { }); it("respects --limit option", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format ndjson --limit 1`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format ndjson --limit 1`.quiet(); const output = result.stdout.toString().trim(); const lines = output.split("\n"); @@ -195,7 +195,7 @@ describe("sessions list --format ndjson", () => { describe("sessions list --format table", () => { it("outputs table with headers", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format table`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format table`.quiet(); const output = result.stdout.toString(); // Should have header row @@ -206,7 +206,7 @@ describe("sessions list --format table", () => { }); it("outputs table with header underline", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format table`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format table`.quiet(); const output = result.stdout.toString(); const lines = output.split("\n"); @@ -216,7 +216,7 @@ describe("sessions list --format table", () => { }); it("includes session data rows", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format table`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format table`.quiet(); const output = result.stdout.toString(); // Should include session titles @@ -225,7 +225,7 @@ describe("sessions list --format table", () => { }); it("shows correct session count (header + underline + data rows)", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format table`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format table`.quiet(); const output = result.stdout.toString().trim(); const lines = output.split("\n"); @@ -243,7 +243,7 @@ describe("sessions list --format table", () => { }); it("respects --search filter", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format table --search parser`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format table --search parser`.quiet(); const output = result.stdout.toString(); // Should only include parser session @@ -252,7 +252,7 @@ describe("sessions list --format table", () => { }); it("respects --limit option", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format table --limit 1`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format table --limit 1`.quiet(); const output = result.stdout.toString().trim(); const lines = output.split("\n"); @@ -261,7 +261,7 @@ describe("sessions list --format table", () => { }); it("sorts by updated date descending by default", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -283,7 +283,7 @@ describe("sessions list search order matches TUI", () => { it("orders by fuzzy score descending when searching", async () => { // Search for "parser" - should match session_parser_fix with higher score // than session_add_tests (which doesn't contain "parser") - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json --search parser`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json --search parser`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -295,7 +295,7 @@ describe("sessions list search order matches TUI", () => { it("uses time as secondary sort when scores are equal", async () => { // Search for "proj" - matches both sessions via projectId "proj_present" // with similar scores, so time should be the tiebreaker - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json --search proj`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json --search proj`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -310,7 +310,7 @@ describe("sessions list search order matches TUI", () => { it("uses createdAt for time tiebreaker when --sort created", async () => { // Search for "proj" with --sort created - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json --search proj --sort created`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json --search proj --sort created`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -325,8 +325,8 @@ describe("sessions list search order matches TUI", () => { it("maintains consistent ordering across multiple searches", async () => { // Run the same search multiple times and verify consistent ordering - const search1 = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json --search present`.quiet(); - const search2 = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json --search present`.quiet(); + const search1 = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json --search present`.quiet(); + const search2 = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json --search present`.quiet(); const parsed1 = JSON.parse(search1.stdout.toString()); const parsed2 = JSON.parse(search2.stdout.toString()); @@ -340,7 +340,7 @@ describe("sessions list search order matches TUI", () => { it("sessionId is final tiebreaker for identical scores and times", async () => { // Search for "proj_present" - exact match in projectId for both sessions // Both have same projectId, so after score and time, sessionId is tiebreaker - const result = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json --search proj_present`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json --search proj_present`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -393,7 +393,7 @@ describe("sessions delete --dry-run", () => { await $`bun src/bin/opencode-manager.ts sessions delete --session session_add_tests --root ${FIXTURE_STORE_ROOT} --format json --dry-run`.quiet(); // Verify file still exists by running sessions list - const listResult = await $`bun src/bin/opencode-manager.ts sessions list --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); + const listResult = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${FIXTURE_STORE_ROOT} --format json`.quiet(); const parsed = JSON.parse(listResult.stdout.toString()); const sessionIds = parsed.data.map((s: { sessionId: string }) => s.sessionId); expect(sessionIds).toContain("session_add_tests"); @@ -566,7 +566,7 @@ describe("sessions rename", () => { await $`bun src/bin/opencode-manager.ts sessions rename --session session_add_tests --title "Updated Title" --root ${tempRoot} --format json`.quiet(); // Verify the file was updated by listing sessions - const listResult = await $`bun src/bin/opencode-manager.ts sessions list --root ${tempRoot} --format json`.quiet(); + const listResult = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${tempRoot} --format json`.quiet(); const parsed = JSON.parse(listResult.stdout.toString()); const session = parsed.data.find((s: { sessionId: string }) => s.sessionId === "session_add_tests"); @@ -608,7 +608,7 @@ describe("sessions rename", () => { await $`bun src/bin/opencode-manager.ts sessions rename --session session_add_tests --title " Trimmed Title " --root ${tempRoot} --format json`.quiet(); // Verify the file was updated with trimmed title - const listResult = await $`bun src/bin/opencode-manager.ts sessions list --root ${tempRoot} --format json`.quiet(); + const listResult = await $`bun src/bin/opencode-manager.ts sessions list --global --root ${tempRoot} --format json`.quiet(); const parsed = JSON.parse(listResult.stdout.toString()); const session = parsed.data.find((s: { sessionId: string }) => s.sessionId === "session_add_tests"); @@ -851,7 +851,7 @@ describe("sessions copy", () => { */ describe("sessions list --experimental-sqlite", () => { it("loads sessions from SQLite database with --db flag", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --db ${FIXTURE_SQLITE_PATH} --format json`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${FIXTURE_SQLITE_PATH} --format json`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -861,7 +861,7 @@ describe("sessions list --experimental-sqlite", () => { }); it("returns correct session IDs from SQLite database", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --db ${FIXTURE_SQLITE_PATH} --format json`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${FIXTURE_SQLITE_PATH} --format json`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -875,7 +875,7 @@ describe("sessions list --experimental-sqlite", () => { }); it("includes all expected session fields", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --db ${FIXTURE_SQLITE_PATH} --format json`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${FIXTURE_SQLITE_PATH} --format json`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -890,7 +890,7 @@ describe("sessions list --experimental-sqlite", () => { }); it("uses SQLite virtual filePath format (sqlite:session:{id})", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --db ${FIXTURE_SQLITE_PATH} --format json`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${FIXTURE_SQLITE_PATH} --format json`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -914,7 +914,7 @@ describe("sessions list --experimental-sqlite", () => { }); it("respects --search filter with SQLite backend", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --db ${FIXTURE_SQLITE_PATH} --format json --search parser`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${FIXTURE_SQLITE_PATH} --format json --search parser`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -926,7 +926,7 @@ describe("sessions list --experimental-sqlite", () => { }); it("respects --limit option with SQLite backend", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --db ${FIXTURE_SQLITE_PATH} --format json --limit 2`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${FIXTURE_SQLITE_PATH} --format json --limit 2`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -934,7 +934,7 @@ describe("sessions list --experimental-sqlite", () => { }); it("serializes Date fields as ISO strings", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --db ${FIXTURE_SQLITE_PATH} --format json`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${FIXTURE_SQLITE_PATH} --format json`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -950,7 +950,7 @@ describe("sessions list --experimental-sqlite", () => { }); it("works with table format output", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --db ${FIXTURE_SQLITE_PATH} --format table`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${FIXTURE_SQLITE_PATH} --format table`.quiet(); const output = result.stdout.toString(); // Should have header row with expected columns @@ -965,7 +965,7 @@ describe("sessions list --experimental-sqlite", () => { }); it("works with ndjson format output", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --db ${FIXTURE_SQLITE_PATH} --format ndjson`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${FIXTURE_SQLITE_PATH} --format ndjson`.quiet(); const output = result.stdout.toString().trim(); const lines = output.split("\n"); @@ -981,7 +981,7 @@ describe("sessions list --experimental-sqlite", () => { }); it("returns error for non-existent database file", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --db /nonexistent/path/db.sqlite --format json`.quiet().nothrow(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --db /nonexistent/path/db.sqlite --format json`.quiet().nothrow(); // Should fail with non-zero exit code expect(result.exitCode).not.toBe(0); @@ -993,7 +993,7 @@ describe("sessions list --experimental-sqlite", () => { }); it("sorts by updated date descending by default", async () => { - const result = await $`bun src/bin/opencode-manager.ts sessions list --db ${FIXTURE_SQLITE_PATH} --format json`.quiet(); + const result = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${FIXTURE_SQLITE_PATH} --format json`.quiet(); const output = result.stdout.toString(); const parsed = JSON.parse(output); @@ -1042,7 +1042,7 @@ describe("sessions delete --experimental-sqlite", () => { it("deletes session from SQLite database with --db flag and --yes", async () => { // Verify session exists before deletion - const listBefore = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json`.quiet(); + const listBefore = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet(); const parsedBefore = JSON.parse(listBefore.stdout.toString()); const sessionsBefore = parsedBefore.data.map((s: { sessionId: string }) => s.sessionId); expect(sessionsBefore).toContain("session_add_tests"); @@ -1058,7 +1058,7 @@ describe("sessions delete --experimental-sqlite", () => { expect(parsed.data.deleted).toBeArray(); // Verify session is gone after deletion - const listAfter = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json`.quiet(); + const listAfter = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet(); const parsedAfter = JSON.parse(listAfter.stdout.toString()); const sessionsAfter = parsedAfter.data.map((s: { sessionId: string }) => s.sessionId); expect(sessionsAfter).not.toContain("session_add_tests"); @@ -1078,7 +1078,7 @@ describe("sessions delete --experimental-sqlite", () => { expect(parsed.data).toHaveProperty("sessionId", "session_add_tests"); // Verify session is deleted - const listAfter = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json`.quiet(); + const listAfter = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet(); const parsedAfter = JSON.parse(listAfter.stdout.toString()); const sessionsAfter = parsedAfter.data.map((s: { sessionId: string }) => s.sessionId); expect(sessionsAfter).not.toContain("session_add_tests"); @@ -1099,7 +1099,7 @@ describe("sessions delete --experimental-sqlite", () => { expect(parsed.data.paths[0]).toContain("sqlite:session:session_add_tests"); // Verify session is NOT deleted (dry run) - const listAfter = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json`.quiet(); + const listAfter = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet(); const parsedAfter = JSON.parse(listAfter.stdout.toString()); const sessionsAfter = parsedAfter.data.map((s: { sessionId: string }) => s.sessionId); expect(sessionsAfter).toContain("session_add_tests"); @@ -1111,7 +1111,7 @@ describe("sessions delete --experimental-sqlite", () => { expect(result.exitCode).toBe(2); // Session should still exist - const listAfter = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json`.quiet(); + const listAfter = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet(); const parsedAfter = JSON.parse(listAfter.stdout.toString()); const sessionsAfter = parsedAfter.data.map((s: { sessionId: string }) => s.sessionId); expect(sessionsAfter).toContain("session_add_tests"); @@ -1178,7 +1178,7 @@ describe("sessions delete --experimental-sqlite", () => { await $`bun src/bin/opencode-manager.ts sessions delete --session session_parser_fix --db ${tempDbPath} --format json --yes`.quiet(); // Verify session is gone - const listAfter = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json`.quiet(); + const listAfter = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet(); const parsedAfter = JSON.parse(listAfter.stdout.toString()); const sessionsAfter = parsedAfter.data.map((s: { sessionId: string }) => s.sessionId); expect(sessionsAfter).not.toContain("session_parser_fix"); @@ -1199,7 +1199,7 @@ describe("sessions delete --experimental-sqlite", () => { expect(parsed.data).toHaveProperty("sessionId", "session_add_tests"); // Session should be deleted - const listAfter = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json`.quiet(); + const listAfter = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet(); const parsedAfter = JSON.parse(listAfter.stdout.toString()); const sessionsAfter = parsedAfter.data.map((s: { sessionId: string }) => s.sessionId); expect(sessionsAfter).not.toContain("session_add_tests"); @@ -1363,7 +1363,7 @@ describe("sessions move --experimental-sqlite", () => { await $`bun src/bin/opencode-manager.ts sessions move --session session_add_tests --to proj_missing --db ${tempDbPath} --format json`.quiet(); // Verify filePath still uses SQLite virtual format - const listResult = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json`.quiet(); + const listResult = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet(); const parsed = JSON.parse(listResult.stdout.toString()); const session = parsed.data.find((s: { sessionId: string }) => s.sessionId === "session_add_tests"); @@ -1386,3 +1386,246 @@ describe("sessions move --experimental-sqlite", () => { expect(parsedAfter.data.length).toBe(parsedBefore.data.length); }); }); + +/** + * Integration tests for sessions rename with SQLite backend. + * + * These tests verify that the `--db` flag correctly uses the SQLite backend + * for renaming sessions. + */ +describe("sessions rename --experimental-sqlite", () => { + let tempDbDir: string; + let tempDbPath: string; + + beforeEach(async () => { + tempDbDir = await fs.mkdtemp(join(tmpdir(), "opencode-sqlite-test-")); + tempDbPath = join(tempDbDir, "test.db"); + await fs.copyFile(FIXTURE_SQLITE_PATH, tempDbPath); + }); + + afterEach(async () => { + await fs.rm(tempDbDir, { recursive: true, force: true }); + }); + + it("renames a session in SQLite database with --db flag", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions rename --session session_add_tests --title "New SQLite Title" --db ${tempDbPath} --format json`.quiet(); + const output = result.stdout.toString(); + + expect(result.exitCode).toBe(0); + + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("ok", true); + expect(parsed.data).toHaveProperty("sessionId", "session_add_tests"); + expect(parsed.data).toHaveProperty("title", "New SQLite Title"); + }); + + it("updates title in database after rename", async () => { + await $`bun src/bin/opencode-manager.ts sessions rename --session session_add_tests --title "Updated Title" --db ${tempDbPath} --format json`.quiet(); + + const listResult = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet(); + const parsed = JSON.parse(listResult.stdout.toString()); + + const session = parsed.data.find((s: { sessionId: string }) => s.sessionId === "session_add_tests"); + expect(session.title).toBe("Updated Title"); + }); + + it("supports session ID prefix matching with SQLite", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions rename --session session_add --title "Prefix Matched" --db ${tempDbPath} --format json`.quiet(); + + expect(result.exitCode).toBe(0); + + const parsed = JSON.parse(result.stdout.toString()); + expect(parsed.data.sessionId).toBe("session_add_tests"); + expect(parsed.data.title).toBe("Prefix Matched"); + }); + + it("returns exit code 3 for non-existent session with SQLite", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions rename --session nonexistent_session --title "Test" --db ${tempDbPath} --format json`.quiet().nothrow(); + + expect(result.exitCode).toBe(3); + }); + + it("returns exit code 2 for empty title with SQLite", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions rename --session session_add_tests --title " " --db ${tempDbPath} --format json`.quiet().nothrow(); + + expect(result.exitCode).toBe(2); + }); + + it("trims whitespace from title with SQLite", async () => { + await $`bun src/bin/opencode-manager.ts sessions rename --session session_add_tests --title " Trimmed Title " --db ${tempDbPath} --format json`.quiet(); + + const listResult = await $`bun src/bin/opencode-manager.ts sessions list --global --db ${tempDbPath} --format json`.quiet(); + const parsed = JSON.parse(listResult.stdout.toString()); + + const session = parsed.data.find((s: { sessionId: string }) => s.sessionId === "session_add_tests"); + expect(session.title).toBe("Trimmed Title"); + }); + + it("works with table format output (SQLite)", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions rename --session session_add_tests --title "Table Title" --db ${tempDbPath} --format table`.quiet(); + const output = result.stdout.toString(); + + expect(output).toContain("Renamed session"); + expect(output).toContain("session_add_tests"); + }); + + it("works with ndjson format output (SQLite)", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions rename --session session_add_tests --title "NDJSON Title" --db ${tempDbPath} --format ndjson`.quiet(); + const output = result.stdout.toString().trim(); + + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("ok", true); + expect(parsed.data).toHaveProperty("sessionId", "session_add_tests"); + expect(parsed.data).toHaveProperty("title", "NDJSON Title"); + }); + + it("returns error for non-existent database file", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions rename --session session_add_tests --title "Test" --db /nonexistent/path/db.sqlite --format json`.quiet().nothrow(); + + expect(result.exitCode).not.toBe(0); + + const stderr = result.stderr.toString(); + expect(stderr).toContain("SQLite database"); + }); +}); + +/** + * Integration tests for sessions copy with SQLite backend. + * + * These tests verify that the `--db` flag correctly uses the SQLite backend + * for copying sessions. + */ +describe("sessions copy --experimental-sqlite", () => { + let tempDbDir: string; + let tempDbPath: string; + + beforeEach(async () => { + tempDbDir = await fs.mkdtemp(join(tmpdir(), "opencode-sqlite-test-")); + tempDbPath = join(tempDbDir, "test.db"); + await fs.copyFile(FIXTURE_SQLITE_PATH, tempDbPath); + }); + + afterEach(async () => { + await fs.rm(tempDbDir, { recursive: true, force: true }); + }); + + it("copies a session to another project in SQLite database with --db flag", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions copy --session session_add_tests --to proj_missing --db ${tempDbPath} --format json`.quiet(); + const output = result.stdout.toString(); + + expect(result.exitCode).toBe(0); + + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("ok", true); + expect(parsed.data).toHaveProperty("originalSessionId", "session_add_tests"); + expect(parsed.data).toHaveProperty("newSessionId"); + expect(parsed.data.newSessionId).not.toBe("session_add_tests"); + expect(parsed.data).toHaveProperty("fromProject", "proj_present"); + expect(parsed.data).toHaveProperty("toProject", "proj_missing"); + }); + + it("keeps original session in source project after copy", async () => { + await $`bun src/bin/opencode-manager.ts sessions copy --session session_add_tests --to proj_missing --db ${tempDbPath} --format json`.quiet(); + + const listResult = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json --project proj_present`.quiet(); + const parsed = JSON.parse(listResult.stdout.toString()); + + const session = parsed.data.find((s: { sessionId: string }) => s.sessionId === "session_add_tests"); + expect(session).toBeDefined(); + }); + + it("creates new session in target project after copy", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions copy --session session_add_tests --to proj_missing --db ${tempDbPath} --format json`.quiet(); + const parsed = JSON.parse(result.stdout.toString()); + const newSessionId = parsed.data.newSessionId; + + const listResult = await $`bun src/bin/opencode-manager.ts sessions list --db ${tempDbPath} --format json --project proj_missing`.quiet(); + const listParsed = JSON.parse(listResult.stdout.toString()); + + const session = listParsed.data.find((s: { sessionId: string }) => s.sessionId === newSessionId); + expect(session).toBeDefined(); + expect(session.projectId).toBe("proj_missing"); + }); + + it("supports session ID prefix matching with SQLite", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions copy --session session_add --to proj_missing --db ${tempDbPath} --format json`.quiet(); + + expect(result.exitCode).toBe(0); + + const parsed = JSON.parse(result.stdout.toString()); + expect(parsed.data.originalSessionId).toBe("session_add_tests"); + }); + + it("supports project ID prefix matching with SQLite", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions copy --session session_add_tests --to proj_miss --db ${tempDbPath} --format json`.quiet(); + + expect(result.exitCode).toBe(0); + + const parsed = JSON.parse(result.stdout.toString()); + expect(parsed.data.toProject).toBe("proj_missing"); + }); + + it("returns exit code 3 for non-existent session with SQLite", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions copy --session nonexistent_session --to proj_missing --db ${tempDbPath} --format json`.quiet().nothrow(); + + expect(result.exitCode).toBe(3); + }); + + it("returns exit code 3 for non-existent target project with SQLite", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions copy --session session_add_tests --to nonexistent_project --db ${tempDbPath} --format json`.quiet().nothrow(); + + expect(result.exitCode).toBe(3); + }); + + it("allows copy to same project with SQLite", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions copy --session session_add_tests --to proj_present --db ${tempDbPath} --format json`.quiet(); + + expect(result.exitCode).toBe(0); + + const parsed = JSON.parse(result.stdout.toString()); + expect(parsed).toHaveProperty("ok", true); + expect(parsed.data.originalSessionId).toBe("session_add_tests"); + expect(parsed.data.newSessionId).not.toBe("session_add_tests"); + expect(parsed.data.toProject).toBe("proj_present"); + }); + + it("copies messages along with session", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions copy --session session_add_tests --to proj_missing --db ${tempDbPath} --format json`.quiet(); + const parsed = JSON.parse(result.stdout.toString()); + const newSessionId = parsed.data.newSessionId; + + const chatResult = await $`bun src/bin/opencode-manager.ts chat list --session ${newSessionId} --db ${tempDbPath} --format json`.quiet(); + const chatParsed = JSON.parse(chatResult.stdout.toString()); + + expect(chatParsed.data.length).toBeGreaterThan(0); + }); + + it("works with table format output (SQLite)", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions copy --session session_add_tests --to proj_missing --db ${tempDbPath} --format table`.quiet(); + const output = result.stdout.toString(); + + expect(output).toContain("Copied session"); + expect(output).toContain("session_add_tests"); + expect(output).toContain("proj_missing"); + }); + + it("works with ndjson format output (SQLite)", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions copy --session session_add_tests --to proj_missing --db ${tempDbPath} --format ndjson`.quiet(); + const output = result.stdout.toString().trim(); + + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("ok", true); + expect(parsed.data).toHaveProperty("originalSessionId", "session_add_tests"); + expect(parsed.data).toHaveProperty("newSessionId"); + expect(parsed.data).toHaveProperty("toProject", "proj_missing"); + }); + + it("returns error for non-existent database file", async () => { + const result = await $`bun src/bin/opencode-manager.ts sessions copy --session session_add_tests --to proj_missing --db /nonexistent/path/db.sqlite --format json`.quiet().nothrow(); + + expect(result.exitCode).not.toBe(0); + + const combined = result.stdout.toString() + result.stderr.toString(); + expect(combined).toMatch(/error|failed|not found/i); + }); +}); diff --git a/tests/cli/index.test.ts b/tests/cli/index.test.ts index eb8ea24..f5f726b 100644 --- a/tests/cli/index.test.ts +++ b/tests/cli/index.test.ts @@ -188,3 +188,59 @@ describe("parseGlobalOptions SQLite flags", () => { expect(DEFAULT_OPTIONS.forceWrite).toBe(false); }); }); + +describe("OPENCODE_ROOT environment variable", () => { + it("respects OPENCODE_ROOT env variable for projects list", async () => { + const result = await $`OPENCODE_ROOT=/tmp bun src/bin/opencode-manager.ts projects list --format json`.quiet().nothrow(); + + expect(result.exitCode).toBe(0); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("ok", true); + expect(parsed).toHaveProperty("data"); + expect(parsed.data).toBeArray(); + }); + + it("respects OPENCODE_ROOT env variable for sessions list", async () => { + const result = await $`OPENCODE_ROOT=/tmp bun src/bin/opencode-manager.ts sessions list --global --format json`.quiet().nothrow(); + + expect(result.exitCode).toBe(0); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("ok", true); + expect(parsed).toHaveProperty("data"); + expect(parsed.data).toBeArray(); + }); + + it("--root flag takes precedence over OPENCODE_ROOT env variable", async () => { + const result = await $`OPENCODE_ROOT=/nonexistent/path bun src/bin/opencode-manager.ts projects list --root /tmp --format json`.quiet().nothrow(); + + expect(result.exitCode).toBe(0); + const output = result.stdout.toString(); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("ok", true); + }); +}); + +describe("TUI backward compatibility", () => { + it("launches TUI when no subcommand is provided", async () => { + const result = await $`timeout 1 bun src/bin/opencode-manager.ts --help 2>/dev/null || true`.quiet().nothrow(); + + const output = result.stdout.toString(); + expect(output).toMatch(/opencode-manager|usage|help/i); + }); + + it("tui subcommand is available", async () => { + const result = await $`bun src/bin/opencode-manager.ts tui --help`.quiet(); + const output = result.stdout.toString(); + + expect(output.length).toBeGreaterThan(0); + }); + + it("--help without subcommand shows usage info", async () => { + const result = await $`bun src/bin/opencode-manager.ts --help`.quiet(); + const output = result.stdout.toString(); + + expect(output).toContain("opencode-manager"); + }); +});