# Checkpoint 0: Project Setup - PRD > **Note:** The project was renamed from "gitlab-inbox" to "gitlore" and the CLI from "gi" to "lore". References to "gi" in this document should be read as "lore". **Version:** 1.0 **Status:** Ready for Implementation **Depends On:** None (first checkpoint) **Enables:** Checkpoint 1 (Issue Ingestion) --- ## Overview ### Objective Scaffold the `gi` CLI tool with verified GitLab API connectivity, database infrastructure, and foundational CLI commands. This checkpoint establishes the project foundation that all subsequent checkpoints build upon. ### Success Criteria | Criterion | Validation | |-----------|------------| | `gi init` writes config and validates against GitLab | `gi doctor` shows GitLab OK | | `gi auth-test` succeeds with real PAT | Shows username and display name | | Database migrations apply correctly | `gi doctor` shows DB OK | | SQLite pragmas set correctly | WAL, FK, busy_timeout verified | | App lock mechanism works | Concurrent runs blocked | | Config resolves from XDG paths | Works from any directory | --- ## Deliverables ### 1. Project Structure Create the following directory structure: ``` gitlab-inbox/ ├── src/ │ ├── cli/ │ │ ├── index.ts # CLI entry point (Commander.js) │ │ └── commands/ │ │ ├── init.ts # gi init │ │ ├── auth-test.ts # gi auth-test │ │ ├── doctor.ts # gi doctor │ │ ├── sync-status.ts # gi sync-status (stub for CP0) │ │ ├── backup.ts # gi backup │ │ └── reset.ts # gi reset │ ├── core/ │ │ ├── config.ts # Config loading/validation (Zod) │ │ ├── db.ts # Database connection + migrations │ │ ├── errors.ts # Custom error classes │ │ ├── logger.ts # pino logger setup │ │ └── paths.ts # XDG path resolution │ ├── gitlab/ │ │ ├── client.ts # GitLab API client with rate limiting │ │ └── types.ts # GitLab API response types │ └── types/ │ └── index.ts # Shared TypeScript types ├── tests/ │ ├── unit/ │ │ ├── config.test.ts │ │ ├── db.test.ts │ │ ├── paths.test.ts │ │ └── errors.test.ts │ ├── integration/ │ │ ├── gitlab-client.test.ts │ │ ├── app-lock.test.ts │ │ └── init.test.ts │ ├── live/ # Gated by GITLAB_LIVE_TESTS=1 │ │ └── gitlab-client.live.test.ts │ └── fixtures/ │ └── mock-responses/ ├── migrations/ │ └── 001_initial.sql ├── package.json ├── tsconfig.json ├── vitest.config.ts ├── eslint.config.js └── .gitignore ``` ### 2. Config + Data Locations (XDG Compliant) | Location | Default Path | Override | |----------|--------------|----------| | Config | `~/.config/gi/config.json` | `GI_CONFIG_PATH` env var or `--config` flag | | Database | `~/.local/share/gi/data.db` | `storage.dbPath` in config | | Backups | `~/.local/share/gi/backups/` | `storage.backupDir` in config | | Logs | stderr (not persisted) | `LOG_PATH` env var | **Config Resolution Order:** 1. `--config /path/to/config.json` (explicit CLI flag) 2. `GI_CONFIG_PATH` environment variable 3. `~/.config/gi/config.json` (XDG default) 4. `./gi.config.json` (local development fallback - useful during dev) **Implementation (`src/core/paths.ts`):** ```typescript import { homedir } from 'node:os'; import { join } from 'node:path'; import { existsSync } from 'node:fs'; export function getConfigPath(cliOverride?: string): string { // 1. CLI flag override if (cliOverride) return cliOverride; // 2. Environment variable if (process.env.GI_CONFIG_PATH) return process.env.GI_CONFIG_PATH; // 3. XDG default const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), '.config'); const xdgPath = join(xdgConfig, 'gi', 'config.json'); if (existsSync(xdgPath)) return xdgPath; // 4. Local fallback (for development) const localPath = join(process.cwd(), 'gi.config.json'); if (existsSync(localPath)) return localPath; // Return XDG path (will trigger not-found error if missing) return xdgPath; } export function getDataDir(): string { const xdgData = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'); return join(xdgData, 'gi'); } export function getDbPath(configOverride?: string): string { if (configOverride) return configOverride; return join(getDataDir(), 'data.db'); } export function getBackupDir(configOverride?: string): string { if (configOverride) return configOverride; return join(getDataDir(), 'backups'); } ``` ### 3. Timestamp Convention (Global) **All `*_at` integer columns are milliseconds since Unix epoch (UTC).** | Context | Format | Example | |---------|--------|---------| | Database columns | INTEGER (ms epoch) | `1706313600000` | | GitLab API responses | ISO 8601 string | `"2024-01-27T00:00:00.000Z"` | | CLI display | ISO 8601 or relative | `2024-01-27` or `3 days ago` | | Config durations | Seconds (with suffix in name) | `staleLockMinutes: 10` | **Conversion utilities (`src/core/time.ts`):** ```typescript // GitLab API → Database export function isoToMs(isoString: string): number { return new Date(isoString).getTime(); } // Database → Display export function msToIso(ms: number): string { return new Date(ms).toISOString(); } // Current time for database storage export function nowMs(): number { return Date.now(); } ``` --- ## Dependencies ### Runtime Dependencies ```json { "dependencies": { "better-sqlite3": "^11.0.0", "sqlite-vec": "^0.1.0", "commander": "^12.0.0", "zod": "^3.23.0", "pino": "^9.0.0", "pino-pretty": "^11.0.0", "ora": "^8.0.0", "chalk": "^5.3.0", "cli-table3": "^0.6.0", "inquirer": "^9.0.0" } } ``` ### Dev Dependencies ```json { "devDependencies": { "typescript": "^5.4.0", "@types/better-sqlite3": "^7.6.0", "@types/node": "^20.0.0", "vitest": "^1.6.0", "msw": "^2.3.0", "eslint": "^9.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "tsx": "^4.0.0" } } ``` --- ## Configuration Schema ### Config File Structure ```typescript // src/types/config.ts import { z } from 'zod'; export const ConfigSchema = z.object({ gitlab: z.object({ baseUrl: z.string().url(), tokenEnvVar: z.string().default('GITLAB_TOKEN'), }), projects: z.array(z.object({ path: z.string().min(1), })).min(1), sync: z.object({ backfillDays: z.number().int().positive().default(14), staleLockMinutes: z.number().int().positive().default(10), heartbeatIntervalSeconds: z.number().int().positive().default(30), cursorRewindSeconds: z.number().int().nonnegative().default(2), primaryConcurrency: z.number().int().positive().default(4), dependentConcurrency: z.number().int().positive().default(2), }).default({}), storage: z.object({ dbPath: z.string().optional(), backupDir: z.string().optional(), compressRawPayloads: z.boolean().default(true), }).default({}), embedding: z.object({ provider: z.literal('ollama').default('ollama'), model: z.string().default('nomic-embed-text'), baseUrl: z.string().url().default('http://localhost:11434'), concurrency: z.number().int().positive().default(4), }).default({}), }); export type Config = z.infer; ``` ### Example Config File ```json { "gitlab": { "baseUrl": "https://gitlab.example.com", "tokenEnvVar": "GITLAB_TOKEN" }, "projects": [ { "path": "group/project-one" }, { "path": "group/project-two" } ], "sync": { "backfillDays": 14, "staleLockMinutes": 10, "heartbeatIntervalSeconds": 30, "cursorRewindSeconds": 2, "primaryConcurrency": 4, "dependentConcurrency": 2 }, "storage": { "compressRawPayloads": true }, "embedding": { "provider": "ollama", "model": "nomic-embed-text", "baseUrl": "http://localhost:11434", "concurrency": 4 } } ``` --- ## Database Schema ### Migration 001_initial.sql ```sql -- Schema version tracking CREATE TABLE schema_version ( version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL, -- ms epoch UTC description TEXT ); INSERT INTO schema_version (version, applied_at, description) VALUES (1, strftime('%s', 'now') * 1000, 'Initial schema'); -- Projects table (configured targets) CREATE TABLE projects ( id INTEGER PRIMARY KEY, gitlab_project_id INTEGER UNIQUE NOT NULL, path_with_namespace TEXT NOT NULL, default_branch TEXT, web_url TEXT, created_at INTEGER, -- ms epoch UTC updated_at INTEGER, -- ms epoch UTC raw_payload_id INTEGER REFERENCES raw_payloads(id) ); CREATE INDEX idx_projects_path ON projects(path_with_namespace); -- Sync tracking for reliability CREATE TABLE sync_runs ( id INTEGER PRIMARY KEY, started_at INTEGER NOT NULL, -- ms epoch UTC heartbeat_at INTEGER NOT NULL, -- ms epoch UTC finished_at INTEGER, -- ms epoch UTC status TEXT NOT NULL, -- 'running' | 'succeeded' | 'failed' command TEXT NOT NULL, -- 'init' | 'ingest issues' | 'sync' | etc. error TEXT, metrics_json TEXT -- JSON blob of per-run counters/timing ); -- metrics_json schema (informational, not enforced): -- { -- "apiCalls": number, -- "rateLimitHits": number, -- "pagesFetched": number, -- "entitiesUpserted": number, -- "discussionsFetched": number, -- "notesUpserted": number, -- "docsRegenerated": number, -- "embeddingsCreated": number, -- "durationMs": number -- } -- Crash-safe single-flight lock (DB-enforced) CREATE TABLE app_locks ( name TEXT PRIMARY KEY, -- 'sync' owner TEXT NOT NULL, -- random run token (UUIDv4) acquired_at INTEGER NOT NULL, -- ms epoch UTC heartbeat_at INTEGER NOT NULL -- ms epoch UTC ); -- Sync cursors for primary resources only CREATE TABLE sync_cursors ( project_id INTEGER NOT NULL REFERENCES projects(id), resource_type TEXT NOT NULL, -- 'issues' | 'merge_requests' updated_at_cursor INTEGER, -- ms epoch UTC, last fully processed tie_breaker_id INTEGER, -- last fully processed gitlab_id PRIMARY KEY(project_id, resource_type) ); -- Raw payload storage (decoupled from entity tables) CREATE TABLE raw_payloads ( id INTEGER PRIMARY KEY, source TEXT NOT NULL, -- 'gitlab' project_id INTEGER REFERENCES projects(id), resource_type TEXT NOT NULL, -- 'project' | 'issue' | 'mr' | 'note' | 'discussion' gitlab_id TEXT NOT NULL, -- TEXT: discussion IDs are strings fetched_at INTEGER NOT NULL, -- ms epoch UTC content_encoding TEXT NOT NULL DEFAULT 'identity', -- 'identity' | 'gzip' payload_hash TEXT NOT NULL, -- SHA-256 of decoded JSON bytes (pre-compression) payload BLOB NOT NULL -- raw JSON or gzip-compressed JSON ); CREATE INDEX idx_raw_payloads_lookup ON raw_payloads(project_id, resource_type, gitlab_id); CREATE INDEX idx_raw_payloads_history ON raw_payloads(project_id, resource_type, gitlab_id, fetched_at); CREATE UNIQUE INDEX uq_raw_payloads_dedupe ON raw_payloads(project_id, resource_type, gitlab_id, payload_hash); ``` ### SQLite Runtime Pragmas Set on every database connection: ```typescript // src/core/db.ts import Database from 'better-sqlite3'; export function createConnection(dbPath: string): Database.Database { const db = new Database(dbPath); // Production-grade defaults for single-user CLI db.pragma('journal_mode = WAL'); db.pragma('synchronous = NORMAL'); // Safe for WAL on local disk db.pragma('foreign_keys = ON'); db.pragma('busy_timeout = 5000'); // 5s wait on lock contention db.pragma('temp_store = MEMORY'); // Small speed win return db; } ``` --- ## Error Classes ```typescript // src/core/errors.ts export class GiError extends Error { constructor( message: string, public readonly code: string, public readonly cause?: Error ) { super(message); this.name = 'GiError'; } } // Config errors export class ConfigNotFoundError extends GiError { constructor(searchedPath: string) { super( `Config file not found at ${searchedPath}. Run "gi init" first.`, 'CONFIG_NOT_FOUND' ); } } export class ConfigValidationError extends GiError { constructor(details: string) { super(`Invalid config: ${details}`, 'CONFIG_INVALID'); } } // GitLab API errors export class GitLabAuthError extends GiError { constructor() { super( 'GitLab authentication failed. Check your token has read_api scope.', 'GITLAB_AUTH_FAILED' ); } } export class GitLabNotFoundError extends GiError { constructor(resource: string) { super(`GitLab resource not found: ${resource}`, 'GITLAB_NOT_FOUND'); } } export class GitLabRateLimitError extends GiError { constructor(public readonly retryAfter: number) { super(`Rate limited. Retry after ${retryAfter}s`, 'GITLAB_RATE_LIMITED'); } } export class GitLabNetworkError extends GiError { constructor(baseUrl: string, cause?: Error) { super( `Cannot connect to GitLab at ${baseUrl}`, 'GITLAB_NETWORK_ERROR', cause ); } } // Database errors export class DatabaseLockError extends GiError { constructor(owner: string, acquiredAt: number) { super( `Another sync is running (owner: ${owner}, started: ${new Date(acquiredAt).toISOString()}). Use --force to override if stale.`, 'DB_LOCKED' ); } } export class MigrationError extends GiError { constructor(version: number, cause: Error) { super( `Migration ${version} failed: ${cause.message}`, 'MIGRATION_FAILED', cause ); } } // Token errors export class TokenNotSetError extends GiError { constructor(envVar: string) { super( `GitLab token not set. Export ${envVar} environment variable.`, 'TOKEN_NOT_SET' ); } } ``` --- ## Logging Configuration ```typescript // src/core/logger.ts import pino from 'pino'; // Logs go to stderr, results to stdout (allows clean JSON piping) export const logger = pino({ level: process.env.LOG_LEVEL || 'info', transport: process.env.NODE_ENV === 'production' ? undefined : { target: 'pino-pretty', options: { colorize: true, destination: 2, // stderr translateTime: 'SYS:standard', ignore: 'pid,hostname' } } }, pino.destination(2)); // Create child loggers for components export const dbLogger = logger.child({ component: 'db' }); export const gitlabLogger = logger.child({ component: 'gitlab' }); export const configLogger = logger.child({ component: 'config' }); ``` **Log Levels:** | Level | When to use | |-------|-------------| | `debug` | Detailed API calls, SQL queries, config resolution | | `info` | Sync start/complete, project counts, major milestones | | `warn` | Rate limits hit, retries, Ollama unavailable | | `error` | Failures that stop operations | --- ## CLI Commands (Checkpoint 0) ### `gi init` Interactive setup wizard that creates config at XDG path. **Flow:** 1. Check if config already exists → prompt to overwrite 2. Prompt for GitLab base URL 3. Prompt for project paths (comma-separated or one at a time) 4. Prompt for token env var name (default: GITLAB_TOKEN) 5. **Validate before writing:** - Token must be set in environment - Test auth with `GET /api/v4/user` - Validate each project path with `GET /api/v4/projects/:path` 6. Write config file 7. Initialize database with migrations 8. Insert validated projects into `projects` table **Flags:** - `--config `: Write config to specific path - `--force`: Skip overwrite confirmation - `--non-interactive`: Fail if prompts would be shown (for scripting) **Exit codes:** - `0`: Success - `1`: Validation failed (token, auth, project not found) - `2`: User cancelled ### `gi auth-test` Verify GitLab authentication. **Output:** ``` Authenticated as @johndoe (John Doe) GitLab: https://gitlab.example.com (v16.8.0) ``` **Exit codes:** - `0`: Auth successful - `1`: Auth failed ### `gi doctor` Check environment health. **Output:** ``` gi doctor Config ✓ Loaded from ~/.config/gi/config.json Database ✓ ~/.local/share/gi/data.db (schema v1) GitLab ✓ https://gitlab.example.com (authenticated as @johndoe) Projects ✓ 2 configured, 2 resolved Ollama ⚠ Not running (semantic search unavailable) Status: Ready (lexical search available, semantic search requires Ollama) ``` **Flags:** - `--json`: Output as JSON for scripting **JSON output schema:** ```typescript interface DoctorResult { success: boolean; // All required checks passed checks: { config: { status: 'ok' | 'error'; path?: string; error?: string }; database: { status: 'ok' | 'error'; path?: string; schemaVersion?: number; error?: string }; gitlab: { status: 'ok' | 'error'; url?: string; username?: string; error?: string }; projects: { status: 'ok' | 'error'; configured?: number; resolved?: number; error?: string }; ollama: { status: 'ok' | 'warning' | 'error'; url?: string; model?: string; error?: string }; }; } ``` ### `gi version` Show version information. **Output:** ``` gi version 0.1.0 ``` ### `gi backup` Create timestamped database backup. **Output:** ``` Created backup: ~/.local/share/gi/backups/data-2026-01-24T10-30-00.db ``` ### `gi reset --confirm` Delete database and reset all state. **Output:** ``` This will delete: - Database: ~/.local/share/gi/data.db - All sync cursors - All cached data Type 'yes' to confirm: yes Database reset. Run 'gi sync' to repopulate. ``` ### `gi sync-status` Show sync state (stub in CP0, full implementation in CP1). **Output (CP0 stub):** ``` No sync runs yet. Run 'gi sync' to start. ``` --- ## GitLab Client ### Core Client Implementation ```typescript // src/gitlab/client.ts import { GitLabAuthError, GitLabNotFoundError, GitLabRateLimitError, GitLabNetworkError } from '../core/errors'; import { gitlabLogger } from '../core/logger'; interface GitLabClientOptions { baseUrl: string; token: string; requestsPerSecond?: number; } interface GitLabUser { id: number; username: string; name: string; } interface GitLabProject { id: number; path_with_namespace: string; default_branch: string; web_url: string; created_at: string; updated_at: string; } export class GitLabClient { private baseUrl: string; private token: string; private rateLimiter: RateLimiter; constructor(options: GitLabClientOptions) { this.baseUrl = options.baseUrl.replace(/\/$/, ''); this.token = options.token; this.rateLimiter = new RateLimiter(options.requestsPerSecond ?? 10); } async getCurrentUser(): Promise { return this.request('/api/v4/user'); } async getProject(pathWithNamespace: string): Promise { const encoded = encodeURIComponent(pathWithNamespace); return this.request(`/api/v4/projects/${encoded}`); } private async request(path: string, options: RequestInit = {}): Promise { await this.rateLimiter.acquire(); const url = `${this.baseUrl}${path}`; gitlabLogger.debug({ url }, 'GitLab request'); let response: Response; try { response = await fetch(url, { ...options, headers: { 'PRIVATE-TOKEN': this.token, 'Accept': 'application/json', ...options.headers, }, }); } catch (err) { throw new GitLabNetworkError(this.baseUrl, err as Error); } if (response.status === 401) { throw new GitLabAuthError(); } if (response.status === 404) { throw new GitLabNotFoundError(path); } if (response.status === 429) { const retryAfter = parseInt(response.headers.get('Retry-After') || '60', 10); throw new GitLabRateLimitError(retryAfter); } if (!response.ok) { throw new Error(`GitLab API error: ${response.status} ${response.statusText}`); } return response.json() as Promise; } } // Simple rate limiter with jitter class RateLimiter { private lastRequest = 0; private minInterval: number; constructor(requestsPerSecond: number) { this.minInterval = 1000 / requestsPerSecond; } async acquire(): Promise { const now = Date.now(); const elapsed = now - this.lastRequest; if (elapsed < this.minInterval) { const jitter = Math.random() * 50; // 0-50ms jitter await new Promise(resolve => setTimeout(resolve, this.minInterval - elapsed + jitter)); } this.lastRequest = Date.now(); } } ``` --- ## App Lock Mechanism Crash-safe single-flight lock using heartbeat pattern. ```typescript // src/core/lock.ts import { randomUUID } from 'node:crypto'; import Database from 'better-sqlite3'; import { DatabaseLockError } from './errors'; import { dbLogger } from './logger'; import { nowMs } from './time'; interface LockOptions { name: string; staleLockMinutes: number; heartbeatIntervalSeconds: number; } export class AppLock { private db: Database.Database; private owner: string; private name: string; private staleLockMs: number; private heartbeatIntervalMs: number; private heartbeatTimer?: NodeJS.Timeout; private released = false; constructor(db: Database.Database, options: LockOptions) { this.db = db; this.owner = randomUUID(); this.name = options.name; this.staleLockMs = options.staleLockMinutes * 60 * 1000; this.heartbeatIntervalMs = options.heartbeatIntervalSeconds * 1000; } acquire(force = false): boolean { const now = nowMs(); return this.db.transaction(() => { const existing = this.db.prepare( 'SELECT owner, acquired_at, heartbeat_at FROM app_locks WHERE name = ?' ).get(this.name) as { owner: string; acquired_at: number; heartbeat_at: number } | undefined; if (!existing) { // No lock exists, acquire it this.db.prepare( 'INSERT INTO app_locks (name, owner, acquired_at, heartbeat_at) VALUES (?, ?, ?, ?)' ).run(this.name, this.owner, now, now); this.startHeartbeat(); dbLogger.info({ owner: this.owner }, 'Lock acquired (new)'); return true; } const isStale = (now - existing.heartbeat_at) > this.staleLockMs; if (isStale || force) { // Lock is stale or force override, take it this.db.prepare( 'UPDATE app_locks SET owner = ?, acquired_at = ?, heartbeat_at = ? WHERE name = ?' ).run(this.owner, now, now, this.name); this.startHeartbeat(); dbLogger.info({ owner: this.owner, previousOwner: existing.owner, wasStale: isStale }, 'Lock acquired (override)'); return true; } if (existing.owner === this.owner) { // Re-entrant, update heartbeat this.db.prepare( 'UPDATE app_locks SET heartbeat_at = ? WHERE name = ?' ).run(now, this.name); return true; } // Lock held by another active process throw new DatabaseLockError(existing.owner, existing.acquired_at); })(); } release(): void { if (this.released) return; this.released = true; if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); } this.db.prepare('DELETE FROM app_locks WHERE name = ? AND owner = ?') .run(this.name, this.owner); dbLogger.info({ owner: this.owner }, 'Lock released'); } private startHeartbeat(): void { this.heartbeatTimer = setInterval(() => { if (this.released) return; this.db.prepare('UPDATE app_locks SET heartbeat_at = ? WHERE name = ? AND owner = ?') .run(nowMs(), this.name, this.owner); dbLogger.debug({ owner: this.owner }, 'Heartbeat updated'); }, this.heartbeatIntervalMs); // Don't prevent process from exiting this.heartbeatTimer.unref(); } } ``` --- ## Raw Payload Handling ### Compression and Deduplication ```typescript // src/core/payloads.ts import { createHash } from 'node:crypto'; import { gzipSync, gunzipSync } from 'node:zlib'; import Database from 'better-sqlite3'; import { nowMs } from './time'; interface StorePayloadOptions { projectId: number | null; resourceType: string; gitlabId: string; payload: unknown; compress: boolean; } export function storePayload( db: Database.Database, options: StorePayloadOptions ): number | null { const jsonBytes = Buffer.from(JSON.stringify(options.payload)); const payloadHash = createHash('sha256').update(jsonBytes).digest('hex'); // Check for duplicate (same content already stored) const existing = db.prepare(` SELECT id FROM raw_payloads WHERE project_id IS ? AND resource_type = ? AND gitlab_id = ? AND payload_hash = ? `).get(options.projectId, options.resourceType, options.gitlabId, payloadHash) as { id: number } | undefined; if (existing) { // Duplicate content, return existing ID return existing.id; } const encoding = options.compress ? 'gzip' : 'identity'; const payloadBytes = options.compress ? gzipSync(jsonBytes) : jsonBytes; const result = db.prepare(` INSERT INTO raw_payloads (source, project_id, resource_type, gitlab_id, fetched_at, content_encoding, payload_hash, payload) VALUES ('gitlab', ?, ?, ?, ?, ?, ?, ?) `).run( options.projectId, options.resourceType, options.gitlabId, nowMs(), encoding, payloadHash, payloadBytes ); return result.lastInsertRowid as number; } export function readPayload( db: Database.Database, id: number ): unknown { const row = db.prepare( 'SELECT content_encoding, payload FROM raw_payloads WHERE id = ?' ).get(id) as { content_encoding: string; payload: Buffer } | undefined; if (!row) return null; const jsonBytes = row.content_encoding === 'gzip' ? gunzipSync(row.payload) : row.payload; return JSON.parse(jsonBytes.toString()); } ``` --- ## Automated Tests ### Unit Tests **`tests/unit/config.test.ts`** ```typescript describe('Config', () => { it('loads config from file path'); it('throws ConfigNotFoundError if file missing'); it('throws ConfigValidationError if required fields missing'); it('validates project paths are non-empty strings'); it('applies default values for optional fields'); it('loads from XDG path by default'); it('respects GI_CONFIG_PATH override'); it('respects --config flag override'); }); ``` **`tests/unit/db.test.ts`** ```typescript describe('Database', () => { it('creates database file if not exists'); it('applies migrations in order'); it('sets WAL journal mode'); it('enables foreign keys'); it('sets busy_timeout=5000'); it('sets synchronous=NORMAL'); it('sets temp_store=MEMORY'); it('tracks schema version'); }); ``` **`tests/unit/paths.test.ts`** ```typescript describe('Path Resolution', () => { it('uses XDG_CONFIG_HOME if set'); it('falls back to ~/.config/gi if XDG not set'); it('prefers --config flag over environment'); it('prefers environment over XDG default'); it('falls back to local gi.config.json in dev'); }); ``` ### Integration Tests **`tests/integration/gitlab-client.test.ts`** (mocked) ```typescript describe('GitLab Client', () => { it('authenticates with valid PAT'); it('returns 401 for invalid PAT'); it('fetches project by path'); it('handles rate limiting (429) with Retry-After'); it('respects rate limit (requests per second)'); it('adds jitter to rate limiting'); }); ``` **`tests/integration/app-lock.test.ts`** ```typescript describe('App Lock', () => { it('acquires lock successfully'); it('updates heartbeat during operation'); it('detects stale lock and recovers'); it('refuses concurrent acquisition'); it('allows force override'); it('releases lock on completion'); }); ``` **`tests/integration/init.test.ts`** ```typescript describe('gi init', () => { it('creates config file with valid structure'); it('validates GitLab URL format'); it('validates GitLab connection before writing config'); it('validates each project path exists in GitLab'); it('fails if token not set'); it('fails if GitLab auth fails'); it('fails if any project path not found'); it('prompts before overwriting existing config'); it('respects --force to skip confirmation'); it('generates config with sensible defaults'); it('creates data directory if missing'); }); ``` ### Live Tests (Gated) **`tests/live/gitlab-client.live.test.ts`** ```typescript // Only runs when GITLAB_LIVE_TESTS=1 describe('GitLab Client (Live)', () => { it('authenticates with real PAT'); it('fetches real project by path'); it('handles actual rate limiting'); }); ``` --- ## Manual Smoke Tests | Command | Expected Output | Pass Criteria | |---------|-----------------|---------------| | `gi --help` | Command list | Shows all available commands | | `gi version` | Version number | Shows installed version | | `gi init` | Interactive prompts | Creates valid config | | `gi init` (config exists) | Confirmation prompt | Warns before overwriting | | `gi init --force` | No prompt | Overwrites without asking | | `gi auth-test` | `Authenticated as @username` | Shows GitLab username | | `GITLAB_TOKEN=invalid gi auth-test` | Error message | Non-zero exit, clear error | | `gi doctor` | Status table | All required checks pass | | `gi doctor --json` | JSON object | Valid JSON, `success: true` | | `gi backup` | Backup path | Creates timestamped backup | | `gi sync-status` | No runs message | Stub output works | --- ## Definition of Done ### Gate (Must Pass) - [ ] `gi init` writes config to XDG path and validates projects against GitLab - [ ] `gi auth-test` succeeds with real PAT (live test, can be manual) - [ ] `gi doctor` reports DB ok + GitLab ok (Ollama may warn if not running) - [ ] DB migrations apply; WAL + FK enabled; busy_timeout + synchronous set - [ ] App lock mechanism works (concurrent runs blocked) - [ ] All unit tests pass - [ ] All integration tests pass (mocked) - [ ] ESLint passes with no errors - [ ] TypeScript compiles with strict mode ### Hardening (Optional Before CP1) - [ ] Additional negative-path tests (overwrite prompts, JSON outputs) - [ ] Edge cases: empty project list, invalid URLs, network timeouts - [ ] Config migration from old paths (if upgrading) - [ ] Live tests pass against real GitLab instance --- ## Implementation Order 1. **Project scaffold** (5 min) - package.json, tsconfig.json, vitest.config.ts, eslint.config.js - Directory structure - .gitignore 2. **Core utilities** (30 min) - `src/core/paths.ts` - XDG path resolution - `src/core/time.ts` - Timestamp utilities - `src/core/errors.ts` - Error classes - `src/core/logger.ts` - pino setup 3. **Config loading** (30 min) - `src/core/config.ts` - Zod schema, load/validate - Unit tests for config 4. **Database** (45 min) - `src/core/db.ts` - Connection, pragmas, migrations - `migrations/001_initial.sql` - Unit tests for DB - App lock mechanism 5. **GitLab client** (30 min) - `src/gitlab/client.ts` - API client with rate limiting - `src/gitlab/types.ts` - Response types - Integration tests (mocked) 6. **Raw payload handling** (20 min) - `src/core/payloads.ts` - Compression, deduplication, storage 7. **CLI commands** (60 min) - `src/cli/index.ts` - Commander setup - `gi init` - Full implementation - `gi auth-test` - Simple - `gi doctor` - Health checks - `gi version` - Version display - `gi backup` - Database backup - `gi reset` - Database reset - `gi sync-status` - Stub 8. **Final validation** (15 min) - Run all tests - Manual smoke tests - ESLint + TypeScript check --- ## Risks & Mitigations | Risk | Mitigation | |------|------------| | sqlite-vec installation fails | Document manual install steps; degrade to FTS-only | | better-sqlite3 native compilation | Provide prebuilt binaries in package | | XDG paths not writable | Fall back to cwd; show clear error | | GitLab API changes | Pin to known API version; document tested version | --- ## References - [SPEC.md](../SPEC.md) - Full system specification - [GitLab API Docs](https://docs.gitlab.com/ee/api/) - API reference - [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) - SQLite driver - [sqlite-vec](https://github.com/asg017/sqlite-vec) - Vector extension - [Commander.js](https://github.com/tj/commander.js) - CLI framework - [Zod](https://zod.dev) - Schema validation - [pino](https://getpino.io) - Logging