diff --git a/README.md b/README.md index 6adffe6..12e9e0e 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ This is particularly useful for questions that span multiple files or concepts, | `mgrep` / `mgrep search [path]` | Natural-language search with many `grep`-style flags (`-i`, `-r`, `-m`...). | | `mgrep watch` | Index current repo and keep the Mixedbread store in sync via file watchers. | | `mgrep login` & `mgrep logout` | Manage device-based authentication with Mixedbread. | +| `mgrep switch-org` | Switch to a different organization. | | `mgrep install-claude-code` | Authenticate, add the Mixedbread mgrep plugin to Claude Code. | | `mgrep install-opencode` | Authenticate and add the Mixedbread mgrep to OpenCode. | | `mgrep install-codex` | Authenticate and add the Mixedbread mgrep to Codex. | @@ -207,6 +208,7 @@ directory for a pattern. | `--agentic` | Enable agentic search to automatically refine queries and perform multiple searches | | `-s`, `--sync` | Sync the local files to the store before searching | | `-d`, `--dry-run` | Dry run the search process (no actual file syncing) | +| `-S`, `--shared` | Enable shared mode for multi-user collaboration | | `--no-rerank` | Disable reranking of search results | | `--max-file-size ` | Maximum file size in bytes to upload (overrides config) | | `--max-file-count ` | Maximum number of files to upload (overrides config) | @@ -235,12 +237,14 @@ root of the repository. The `.mgrepignore` file follows the same syntax as the | Option | Description | | --- | --- | | `-d`, `--dry-run` | Dry run the watch process (no actual file syncing) | +| `-S`, `--shared` | Enable shared mode for multi-user collaboration | | `--max-file-size ` | Maximum file size in bytes to upload (overrides config) | | `--max-file-count ` | Maximum number of files to upload (overrides config) | **Examples:** ```bash mgrep watch # index the current repository and keep the Mixedbread store in sync via file watchers +mgrep watch --shared # index with shared mode for multi-user collaboration mgrep watch --max-file-size 1048576 # limit uploads to files under 1MB mgrep watch --max-file-count 5000 # limit sync to 5000 changed files or fewer ``` @@ -254,6 +258,69 @@ mgrep watch --max-file-count 5000 # limit sync to 5000 changed files or fewer - Results include relative paths plus contextual hints (line ranges for text, page numbers for PDFs, etc.) for a skim-friendly experience. - Because stores are cloud-backed, agents and teammates can query the same corpus without re-uploading. +## Multi-User / Shared Mode + +When multiple users in an organization want to share the same store for a project, use **shared mode**. Each user uploads files with their own absolute paths. Search uses regex suffix matching to find results across all users, regardless of their local directory structure. + +### Enabling Shared Mode + +You can enable shared mode in three ways: + +1. **CLI flag**: Add `-S` or `--shared` to your commands + ```bash + mgrep watch --shared + mgrep --shared "where is auth configured?" + ``` + +2. **Environment variable**: Set `MGREP_SHARED=true` + ```bash + export MGREP_SHARED=true + mgrep watch + ``` + +3. **Config file**: Add `shared: true` to your `.mgreprc.yaml` + ```yaml + shared: true + maxFileSize: 10485760 + ``` + +### How It Works + +Without shared mode, files are stored with absolute paths and search uses `starts_with` to scope results to the current user's directory. This works fine for single users. + +With shared mode enabled: +- Files are still stored with **absolute paths** (each user keeps their own paths) +- Search uses **regex suffix matching** to find results from all users in the store +- Any team member can sync and search the store regardless of their local path + +### Multi-User Workflow + +1. **First user indexes the project:** + ```bash + cd /Users/alice/projects/myapp + mgrep watch --shared --store myapp-team + ``` + +2. **Other team members join:** + ```bash + cd /home/bob/code/myapp + mgrep --shared --store myapp-team "how does authentication work?" + ``` + +3. **All users search the same store:** + ```bash + mgrep --shared --store myapp-team "database connection pooling" + ``` + +### Organization Support + +mgrep supports Mixedbread organizations for team collaboration: + +- **Login with organization**: When you log in, you'll be prompted to select an organization if you belong to multiple +- **Switch organizations**: Use `mgrep switch-org` to change your active organization + +Stores are scoped to organizations, so different teams can have stores with the same name without conflicts. + ## Configuration mgrep can be configured via config files, environment variables, or CLI flags. @@ -268,11 +335,14 @@ maxFileSize: 5242880 # Maximum number of files to sync (upload/delete) per operation (default: 1000) maxFileCount: 5000 + +# Enable shared mode for multi-user collaboration (default: false) +shared: true ``` **Configuration precedence** (highest to lowest): -1. CLI flags (`--max-file-size`, `--max-file-count`) -2. Environment variables (`MGREP_MAX_FILE_SIZE`, `MGREP_MAX_FILE_COUNT`) +1. CLI flags (`--max-file-size`, `--max-file-count`, `--shared`) +2. Environment variables (`MGREP_MAX_FILE_SIZE`, `MGREP_MAX_FILE_COUNT`, `MGREP_SHARED`) 3. Local config file (`.mgreprc.yaml` in project directory) 4. Global config file (`~/.config/mgrep/config.yaml`) 5. Default values @@ -310,6 +380,7 @@ searches. - `MGREP_MAX_FILE_SIZE`: Maximum file size in bytes to upload (default: `1048576` / 1MB) - `MGREP_MAX_FILE_COUNT`: Maximum number of files to sync per operation (default: `1000`) +- `MGREP_SHARED`: Enable shared mode for multi-user collaboration (set to `1` or `true` to enable) **Examples:** ```bash diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..dfc430d --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,162 @@ +import * as fs from "node:fs"; +import type { Command } from "commander"; +import { Command as CommanderCommand } from "commander"; +import { + CONFIG_KEYS, + type ConfigKey, + DEFAULT_CONFIG, + getGlobalConfigFilePath, + readGlobalConfig, + saveGlobalConfig, + writeGlobalConfig, +} from "../lib/config.js"; + +/** + * Parses a string value into the correct type for a given config key. + * + * @param key - The config key + * @param value - The raw string value + * @returns The parsed value + */ +function parseConfigValue( + key: ConfigKey, + value: string, +): number | boolean { + switch (key) { + case "maxFileSize": + case "maxFileCount": { + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + throw new Error(`Value for "${key}" must be a positive integer.`); + } + return parsed; + } + case "shared": { + const lower = value.toLowerCase(); + if ( + lower === "true" || + lower === "1" || + lower === "yes" || + lower === "y" + ) { + return true; + } + if ( + lower === "false" || + lower === "0" || + lower === "no" || + lower === "n" + ) { + return false; + } + throw new Error( + `Value for "${key}" must be a boolean (true/false, 1/0, yes/no).`, + ); + } + } +} + +/** + * Validates that a string is a valid config key. + * + * @param key - The string to validate + * @returns The validated config key + */ +function validateKey(key: string): ConfigKey { + if (!CONFIG_KEYS.includes(key as ConfigKey)) { + throw new Error( + `Unknown config key "${key}". Valid keys: ${CONFIG_KEYS.join(", ")}`, + ); + } + return key as ConfigKey; +} + +const set = new CommanderCommand("set") + .description("Set a global config value") + .argument("", `Config key (${CONFIG_KEYS.join(", ")})`) + .argument("", "Config value") + .action((rawKey: string, rawValue: string) => { + try { + const key = validateKey(rawKey); + const value = parseConfigValue(key, rawValue); + writeGlobalConfig({ [key]: value }); + console.log(`Set ${key} = ${value}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exitCode = 1; + } + }); + +const get = new CommanderCommand("get") + .description("Get a global config value") + .argument("", `Config key (${CONFIG_KEYS.join(", ")})`) + .action((rawKey: string) => { + try { + const key = validateKey(rawKey); + const globalConfig = readGlobalConfig(); + const value = globalConfig[key]; + if (value === undefined) { + console.log(`${key} = ${DEFAULT_CONFIG[key]} (default)`); + } else { + console.log(`${key} = ${value}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exitCode = 1; + } + }); + +const list = new CommanderCommand("list") + .description("List all global config values") + .action(() => { + const globalConfig = readGlobalConfig(); + for (const key of CONFIG_KEYS) { + const value = globalConfig[key]; + if (value === undefined) { + console.log(`${key} = ${DEFAULT_CONFIG[key]} (default)`); + } else { + console.log(`${key} = ${value}`); + } + } + }); + +const reset = new CommanderCommand("reset") + .description("Reset global config to defaults") + .argument("[key]", "Config key to reset (omit to reset all)") + .action((rawKey?: string) => { + try { + if (rawKey) { + const key = validateKey(rawKey); + const globalConfig = readGlobalConfig(); + delete globalConfig[key]; + if (Object.keys(globalConfig).length === 0) { + const filePath = getGlobalConfigFilePath(); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } else { + saveGlobalConfig(globalConfig); + } + console.log(`Reset ${key} to default (${DEFAULT_CONFIG[key]})`); + } else { + const filePath = getGlobalConfigFilePath(); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + console.log("Reset all config to defaults"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); + process.exitCode = 1; + } + }); + +export const config: Command = new CommanderCommand("config") + .description("Manage global mgrep configuration") + .addCommand(set) + .addCommand(get) + .addCommand(list) + .addCommand(reset); diff --git a/src/commands/logout.ts b/src/commands/logout.ts index ca65d20..1476ad8 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -1,6 +1,7 @@ import { outro } from "@clack/prompts"; import chalk from "chalk"; import { Command } from "commander"; +import { clearCachedOrganization } from "../lib/organizations.js"; import { deleteToken, getStoredToken } from "../lib/token.js"; export async function logoutAction() { @@ -11,6 +12,7 @@ export async function logoutAction() { } await deleteToken(); + await clearCachedOrganization(); outro(chalk.green("✅ Successfully logged out")); } diff --git a/src/commands/search.ts b/src/commands/search.ts index 880379e..25233e3 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,4 +1,4 @@ -import { join, normalize } from "node:path"; +import { isAbsolute, join, normalize } from "node:path"; import type { Command } from "commander"; import { Command as CommanderCommand, InvalidArgumentError } from "commander"; import { @@ -15,6 +15,7 @@ import type { SearchResponse, Store, } from "../lib/store.js"; +import type { SearchFilter } from "@mixedbread/sdk/resources/shared"; import { createIndexingSpinner, formatDryRunSummary, @@ -24,6 +25,7 @@ import { isAtOrAboveHomeDirectory, MaxFileCountExceededError, QuotaExceededError, + toRelativePath, } from "../lib/utils.js"; function extractSources(response: AskResponse): { [key: number]: ChunkType } { @@ -97,8 +99,18 @@ function formatChunk(chunk: ChunkType, show_content: boolean) { return `${url} (${(chunk.score * 100).toFixed(2)}% match)${content ? `\n${content}` : ""}`; } - const path = - (chunk.metadata as FileMetadata)?.path?.replace(pwd, "") ?? "Unknown path"; + const storedPath = (chunk.metadata as FileMetadata)?.path ?? "Unknown path"; + let displayPath: string; + if (isAbsolute(storedPath) && storedPath.startsWith(pwd)) { + // Absolute path from current user: strip pwd prefix + displayPath = storedPath.replace(pwd, "").replace(/^[\\/]/, ""); + } else if (isAbsolute(storedPath)) { + // Absolute path from another user (shared mode): show full path + displayPath = storedPath; + } else { + displayPath = storedPath; + } + let line_range = ""; let content = ""; switch (chunk.type) { @@ -124,7 +136,7 @@ function formatChunk(chunk: ChunkType, show_content: boolean) { break; } - return `.${path}${line_range} (${(chunk.score * 100).toFixed(2)}% match)${content ? `\n${content}` : ""}`; + return `./${displayPath}${line_range} (${(chunk.score * 100).toFixed(2)}% match)${content ? `\n${content}` : ""}`; } function parseBooleanEnv( @@ -256,6 +268,10 @@ export const search: Command = new CommanderCommand("search") "Enable agentic search to automatically refine queries and perform multiple searches", parseBooleanEnv(process.env.MGREP_AGENTIC, false), ) + .option( + "-S, --shared", + "Enable shared mode for multi-user collaboration (uses regex suffix matching for search)", + ) .argument("", "The pattern to search for") .argument("[path]", "The path to search in") .allowUnknownOption(true) @@ -273,6 +289,7 @@ export const search: Command = new CommanderCommand("search") maxFileCount?: number; web: boolean; agentic: boolean; + shared?: boolean; } = cmd.optsWithGlobals(); if (exec_path?.startsWith("--")) { exec_path = ""; @@ -282,14 +299,19 @@ export const search: Command = new CommanderCommand("search") const cliOptions: CliConfigOptions = { maxFileSize: options.maxFileSize, maxFileCount: options.maxFileCount, + shared: options.shared, }; const config = loadConfig(root, cliOptions); - const search_path = exec_path?.startsWith("/") - ? exec_path - : normalize(join(root, exec_path ?? "")); + const search_path = + exec_path && isAbsolute(exec_path) + ? exec_path + : normalize(join(root, exec_path ?? "")); + + // In shared mode, sync from project root; in normal mode, sync from search path + const syncRoot = config.shared ? root : search_path; - if (options.sync && isAtOrAboveHomeDirectory(search_path)) { + if (options.sync && isAtOrAboveHomeDirectory(syncRoot)) { console.error( "Error: Cannot sync home directory or any parent directory.", ); @@ -307,7 +329,7 @@ export const search: Command = new CommanderCommand("search") const shouldReturn = await syncFiles( store, options.store, - search_path, + syncRoot, options.dryRun, config, ); @@ -320,15 +342,37 @@ export const search: Command = new CommanderCommand("search") ? [options.store, "mixedbread/web"] : [options.store]; - const filters = { - all: [ - { - key: "path", - operator: "starts_with" as const, - value: search_path, - }, - ], - }; + // In shared mode, use regex suffix matching so results from all users are found + // In normal mode, use starts_with with the absolute path + // "regex" operator is supported by the API but not yet in SDK types + let filters: { all: Array<{ key: string; operator: string; value: string }> } | undefined; + if (config.shared) { + const relativePath = toRelativePath(search_path, root); + if (relativePath) { + // Searching a subdirectory — match any absolute path ending with this suffix + const escaped = relativePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + filters = { + all: [ + { + key: "path", + operator: "regex", + value: `.*/${escaped}($|/.*)`, + }, + ], + }; + } + // If relativePath is empty (searching from root), no filter — match all files in store + } else { + filters = { + all: [ + { + key: "path", + operator: "starts_with", + value: search_path, + }, + ], + }; + } const searchOptions = { rerank: options.rerank, @@ -342,7 +386,7 @@ export const search: Command = new CommanderCommand("search") pattern, parseInt(options.maxCount, 10), searchOptions, - filters, + filters as SearchFilter | undefined, ); response = formatSearchResponse(results, options.content); } else { @@ -351,7 +395,7 @@ export const search: Command = new CommanderCommand("search") pattern, parseInt(options.maxCount, 10), searchOptions, - filters, + filters as SearchFilter | undefined, ); response = formatAskResponse(results, options.content); } diff --git a/src/commands/watch.ts b/src/commands/watch.ts index b334bb6..333d82f 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import chalk from "chalk"; import { Command, InvalidArgumentError } from "commander"; import { type CliConfigOptions, loadConfig } from "../lib/config.js"; import { createFileSystem, createStore } from "../lib/context.js"; @@ -22,6 +23,7 @@ export interface WatchOptions { dryRun: boolean; maxFileSize?: number; maxFileCount?: number; + shared?: boolean; } export async function startWatch(options: WatchOptions): Promise { @@ -66,8 +68,13 @@ export async function startWatch(options: WatchOptions): Promise { const cliOptions: CliConfigOptions = { maxFileSize: options.maxFileSize, maxFileCount: options.maxFileCount, + shared: options.shared, }; const config = loadConfig(watchRoot, cliOptions); + console.debug(`Store: ${chalk.cyan(options.store)}`); + if (config.shared) { + console.debug(chalk.yellow("Shared mode enabled")); + } console.debug("Watching for file changes in", watchRoot); const { spinner, onProgress } = createIndexingSpinner(watchRoot); @@ -215,6 +222,10 @@ export const watch = new Command("watch") return parsed; }, ) + .option( + "-S, --shared", + "Enable shared mode for multi-user collaboration", + ) .description("Watch for file changes") .action(async (_args, cmd) => { const options: WatchOptions = cmd.optsWithGlobals(); diff --git a/src/index.ts b/src/index.ts index 9eafb04..de3ab3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { program } from "commander"; +import { config } from "./commands/config.js"; import { login } from "./commands/login.js"; import { logout } from "./commands/logout.js"; import { search } from "./commands/search.js"; @@ -39,6 +40,7 @@ program program.addCommand(search, { isDefault: true }); program.addCommand(watch); +program.addCommand(config); program.addCommand(installClaudeCode); program.addCommand(uninstallClaudeCode); program.addCommand(installCodex); diff --git a/src/lib/config.ts b/src/lib/config.ts index c2ace33..c32ffa9 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -5,7 +5,7 @@ import YAML from "yaml"; import { z } from "zod"; const LOCAL_CONFIG_FILES = [".mgreprc.yaml", ".mgreprc.yml"] as const; -const GLOBAL_CONFIG_DIR = ".config/mgrep"; +export const GLOBAL_CONFIG_DIR = ".config/mgrep"; const GLOBAL_CONFIG_FILES = ["config.yaml", "config.yml"] as const; const ENV_PREFIX = "MGREP_"; const DEFAULT_MAX_FILE_SIZE = 1 * 1024 * 1024; @@ -14,6 +14,7 @@ const DEFAULT_MAX_FILE_COUNT = 1000; const ConfigSchema = z.object({ maxFileSize: z.number().positive().optional(), maxFileCount: z.number().positive().optional(), + shared: z.boolean().optional(), }); /** @@ -22,6 +23,7 @@ const ConfigSchema = z.object({ export interface CliConfigOptions { maxFileSize?: number; maxFileCount?: number; + shared?: boolean; } /** @@ -41,11 +43,20 @@ export interface MgrepConfig { * @default 1000 */ maxFileCount: number; + + /** + * Enable shared mode for multi-user collaboration. + * When enabled, search uses regex suffix matching to find results across + * users who may have different absolute paths for the same project. + * @default false + */ + shared: boolean; } -const DEFAULT_CONFIG: MgrepConfig = { +export const DEFAULT_CONFIG: MgrepConfig = { maxFileSize: DEFAULT_MAX_FILE_SIZE, maxFileCount: DEFAULT_MAX_FILE_COUNT, + shared: false, }; const configCache = new Map(); @@ -91,7 +102,7 @@ function findConfig(candidates: string[]): Partial | null { return null; } -function getGlobalConfigPaths(): string[] { +export function getGlobalConfigPaths(): string[] { const configDir = path.join(os.homedir(), GLOBAL_CONFIG_DIR); return GLOBAL_CONFIG_FILES.map((file) => path.join(configDir, file)); } @@ -100,6 +111,21 @@ function getLocalConfigPaths(dir: string): string[] { return LOCAL_CONFIG_FILES.map((file) => path.join(dir, file)); } +/** + * Parses a boolean from an environment variable string + */ +function parseBooleanEnv(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + const lower = value.toLowerCase(); + if (lower === "1" || lower === "true" || lower === "yes" || lower === "y") { + return true; + } + if (lower === "0" || lower === "false" || lower === "no" || lower === "n") { + return false; + } + return undefined; +} + /** * Loads configuration from environment variables * @@ -124,6 +150,11 @@ function loadEnvConfig(): Partial { } } + const sharedEnv = parseBooleanEnv(process.env[`${ENV_PREFIX}SHARED`]); + if (sharedEnv !== undefined) { + config.shared = sharedEnv; + } + return config; } @@ -176,6 +207,9 @@ function filterUndefinedCliOptions( if (options.maxFileCount !== undefined) { result.maxFileCount = options.maxFileCount; } + if (options.shared !== undefined) { + result.shared = options.shared; + } return result; } @@ -224,3 +258,54 @@ export function formatFileSize(bytes: number): string { return `${size.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`; } + +/** + * Reads the global config file, returning raw parsed values (without defaults). + * + * @returns The parsed config values or an empty object if no global config exists + */ +export function readGlobalConfig(): Partial { + return findConfig(getGlobalConfigPaths()) ?? {}; +} + +/** + * Returns the path to the global config file, creating the directory if needed. + */ +export function getGlobalConfigFilePath(): string { + const configDir = path.join(os.homedir(), GLOBAL_CONFIG_DIR); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + return path.join(configDir, GLOBAL_CONFIG_FILES[0]); +} + +/** + * Writes a partial config to the global config file. + * Merges with existing global config values. + * + * @param updates - The config values to write + */ +export function writeGlobalConfig(updates: Partial): void { + const existing = readGlobalConfig(); + const merged = { ...existing, ...updates }; + const filePath = getGlobalConfigFilePath(); + fs.writeFileSync(filePath, YAML.stringify(merged), "utf-8"); + clearConfigCache(); +} + +/** + * Saves a config object directly to the global config file without merging. + * + * @param config - The complete config values to save + */ +export function saveGlobalConfig(config: Partial): void { + const filePath = getGlobalConfigFilePath(); + fs.writeFileSync(filePath, YAML.stringify(config), "utf-8"); + clearConfigCache(); +} + +/** + * Valid configuration key names + */ +export const CONFIG_KEYS = ["maxFileSize", "maxFileCount", "shared"] as const; +export type ConfigKey = (typeof CONFIG_KEYS)[number]; diff --git a/src/lib/organizations.ts b/src/lib/organizations.ts index 28cde68..4347c64 100644 --- a/src/lib/organizations.ts +++ b/src/lib/organizations.ts @@ -1,8 +1,24 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { cancel, isCancel, select } from "@clack/prompts"; import type { Organization } from "better-auth/plugins/organization"; import chalk from "chalk"; import { authClient, SERVER_URL } from "./auth.js"; +const CONFIG_DIR = path.join(os.homedir(), ".mgrep"); +const ORG_CACHE_FILE = path.join(CONFIG_DIR, "organization.json"); + +interface CachedOrganization { + id: string; + name: string; + slug: string; + cached_at: string; +} + +/** + * Gets the current organization from the server session + */ export async function getCurrentOrganization(accessToken: string) { const { data: session } = await authClient.getSession({ fetchOptions: { @@ -17,6 +33,49 @@ export async function getCurrentOrganization(accessToken: string) { : null; } +/** + * Caches organization info locally for display purposes + */ +export async function cacheOrganization(org: Organization): Promise { + try { + await fs.mkdir(CONFIG_DIR, { recursive: true }); + const cacheData: CachedOrganization = { + id: org.id, + name: org.name, + slug: org.slug, + cached_at: new Date().toISOString(), + }; + await fs.writeFile(ORG_CACHE_FILE, JSON.stringify(cacheData, null, 2)); + } catch { + // Silently fail - caching is optional + } +} + +/** + * Gets cached organization info for display (does not require network) + */ +export async function getCachedOrganization(): Promise { + try { + const data = await fs.readFile(ORG_CACHE_FILE, "utf-8"); + return JSON.parse(data); + } catch { + return null; + } +} + +/** + * Clears the cached organization info + */ +export async function clearCachedOrganization(): Promise { + try { + await fs.unlink(ORG_CACHE_FILE); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } +} + export async function listOrganizations(accessToken: string) { const response = await fetch(`${SERVER_URL}/api/auth/organization/list`, { headers: { @@ -77,5 +136,9 @@ export async function selectOrganization( process.exit(1); } + // Cache the selected organization for display purposes + await cacheOrganization(selectedOrg); + return selectedOrg; } + diff --git a/src/lib/store.ts b/src/lib/store.ts index ef95bb7..5ebebcb 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -280,6 +280,7 @@ export class MixedbreadStore implements Store { }, }; } + } interface TestStoreDB { @@ -440,15 +441,17 @@ export class TestStore implements Store { for (const file of Object.values(db.files)) { if (filters?.all) { - const pathFilter = filters.all.find( - (f) => "key" in f && f.key === "path" && f.operator === "starts_with", - ); - if ( - pathFilter && - "value" in pathFilter && - file.metadata && - !file.metadata.path.startsWith(pathFilter.value as string) - ) { + let excluded = false; + for (const f of filters.all) { + if (!("key" in f) || f.key !== "path" || !("value" in f) || !file.metadata) continue; + if (f.operator === "starts_with" && !file.metadata.path.startsWith(f.value as string)) { + excluded = true; + } + if ((f.operator as string) === "regex" && !new RegExp(f.value as string).test(file.metadata.path)) { + excluded = true; + } + } + if (excluded) { continue; } } @@ -519,4 +522,5 @@ export class TestStore implements Store { const db = await this.load(); return db.info; } + } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index abd0b5e..81ce393 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -198,6 +198,23 @@ export async function ensureAuthenticated(): Promise { await loginAction(); } +/** + * Converts an absolute file path to a relative path from the project root. + * Used in shared mode to store files with relative paths. + * + * @param absolutePath - The absolute file path + * @param projectRoot - The project root directory + * @returns The relative path from the project root + */ +export function toRelativePath( + absolutePath: string, + projectRoot: string, +): string { + const relative = path.relative(projectRoot, absolutePath); + // Ensure consistent forward slashes for cross-platform compatibility + return relative.split(path.sep).join("/"); +} + export async function deleteFile( store: Store, storeId: string, @@ -206,6 +223,16 @@ export async function deleteFile( await store.deleteFile(storeId, filePath); } +/** + * Uploads a file to the store. + * + * @param store - The store instance + * @param storeId - The ID of the store + * @param filePath - The absolute path to the file on disk + * @param fileName - The file name for display + * @param config - Optional configuration + * @returns True if the file was uploaded, false if skipped + */ export async function uploadFile( store: Store, storeId: string, @@ -226,6 +253,7 @@ export async function uploadFile( } const hash = await computeBufferHash(buffer); + const options = { external_id: filePath, overwrite: true, @@ -292,6 +320,7 @@ export async function initialSync( const repoFileSet = new Set(repoFiles); + // Find files to delete - files in store within repoRoot but not on disk const filesToDelete = Array.from(storeMetadata.keys()).filter( (filePath) => isSubpath(repoRoot, filePath) && !repoFileSet.has(filePath), ); @@ -302,15 +331,12 @@ export async function initialSync( return false; } const stored = storeMetadata.get(filePath); - // If not in store, it needs uploading if (!stored) { return true; } - // If no mtime stored, we need to check (conservative) if (!stored.mtime) { return true; } - // Check mtime to see if file might have changed try { const stat = fs.statSync(filePath); return stat.mtimeMs > stored.mtime; diff --git a/test/test.bats b/test/test.bats index f78845d..dc62e8e 100755 --- a/test/test.bats +++ b/test/test.bats @@ -505,3 +505,162 @@ teardown() { assert_output --partial 'file-in-foo.txt' refute_output --partial 'file-in-foobar.txt' } + +@test "Shared mode flag is recognized by watch" { + run mgrep watch --shared --dry-run + + assert_success + assert_output --partial 'Shared mode enabled' +} + +@test "Shared mode flag is recognized by search" { + run mgrep search --shared test + + assert_success + assert_output --partial 'test.txt' +} + +@test "Shared mode via config file" { + rm "$BATS_TMPDIR/mgrep-test-store.json" + echo 'shared: true' > "$BATS_TMPDIR/test-store/.mgreprc.yaml" + + cd "$BATS_TMPDIR/test-store" + run mgrep watch --dry-run + + assert_success + assert_output --partial 'Shared mode enabled' +} + +@test "Shared mode via environment variable" { + rm "$BATS_TMPDIR/mgrep-test-store.json" + + cd "$BATS_TMPDIR/test-store" + export MGREP_SHARED=true + run mgrep watch --dry-run + unset MGREP_SHARED + + assert_success + assert_output --partial 'Shared mode enabled' +} + +@test "Shared mode stores files with absolute paths" { + rm "$BATS_TMPDIR/mgrep-test-store.json" + mkdir -p "$BATS_TMPDIR/shared-test" + echo "Shared file content" > "$BATS_TMPDIR/shared-test/shared.txt" + + cd "$BATS_TMPDIR/shared-test" + run mgrep search --shared --sync shared + + assert_success + # In shared mode, files are still stored with absolute paths + # but displayed as relative from pwd + assert_output --partial 'shared.txt' +} + +@test "Config set and get maxFileSize" { + export HOME="$BATS_TMPDIR/config-home-1" + mkdir -p "$HOME" + + run mgrep config set maxFileSize 2097152 + assert_success + assert_output --partial 'Set maxFileSize = 2097152' + + run mgrep config get maxFileSize + assert_success + assert_output --partial 'maxFileSize = 2097152' +} + +@test "Config set and get shared" { + export HOME="$BATS_TMPDIR/config-home-2" + mkdir -p "$HOME" + + run mgrep config set shared true + assert_success + assert_output --partial 'Set shared = true' + + run mgrep config get shared + assert_success + assert_output --partial 'shared = true' +} + +@test "Config list shows all values" { + export HOME="$BATS_TMPDIR/config-home-3" + mkdir -p "$HOME" + + run mgrep config list + assert_success + assert_output --partial 'maxFileSize' + assert_output --partial 'maxFileCount' + assert_output --partial 'shared' +} + +@test "Config reset removes a key" { + export HOME="$BATS_TMPDIR/config-home-4" + mkdir -p "$HOME" + + run mgrep config set shared true + assert_success + + run mgrep config reset shared + assert_success + assert_output --partial 'Reset shared to default' + + run mgrep config get shared + assert_success + assert_output --partial 'default' +} + +@test "Config reset all removes config file" { + export HOME="$BATS_TMPDIR/config-home-5" + mkdir -p "$HOME" + + run mgrep config set maxFileSize 999 + assert_success + + run mgrep config reset + assert_success + assert_output --partial 'Reset all config to defaults' + + run mgrep config list + assert_success + assert_output --partial 'default' +} + +@test "Config set rejects invalid key" { + export HOME="$BATS_TMPDIR/config-home-6" + mkdir -p "$HOME" + + run mgrep config set invalidKey 123 + assert_failure + assert_output --partial 'Unknown config key' +} + +@test "Config set rejects invalid value for maxFileSize" { + export HOME="$BATS_TMPDIR/config-home-7" + mkdir -p "$HOME" + + run mgrep config set maxFileSize notanumber + assert_failure + assert_output --partial 'must be a positive integer' +} + +@test "Shared mode search with subdirectory" { + rm "$BATS_TMPDIR/mgrep-test-store.json" + mkdir -p "$BATS_TMPDIR/shared-subdir-test/sub" + echo "Root file" > "$BATS_TMPDIR/shared-subdir-test/root.txt" + echo "Sub file" > "$BATS_TMPDIR/shared-subdir-test/sub/sub.txt" + + cd "$BATS_TMPDIR/shared-subdir-test" + run mgrep search --shared --sync file + + assert_success + assert_output --partial 'root.txt' + assert_output --partial 'sub.txt' + + # Search only in subdirectory + run mgrep search --shared file sub + + assert_success + assert_output --partial 'sub.txt' + refute_output --partial 'root.txt' +}