diff --git a/.gitignore b/.gitignore index 497feb7a..7e609030 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ drizzle/meta/ coverage/ .DS_Store data/ +.paperclip/ +.pnpm-store/ diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 00000000..2934375a --- /dev/null +++ b/cli/package.json @@ -0,0 +1,26 @@ +{ + "name": "@paperclip/cli", + "version": "0.0.1", + "private": true, + "type": "module", + "bin": { + "paperclip": "./src/index.ts" + }, + "scripts": { + "dev": "tsx src/index.ts", + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clack/prompts": "^0.10.0", + "@paperclip/db": "workspace:*", + "@paperclip/shared": "workspace:*", + "commander": "^13.1.0", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3" + } +} diff --git a/cli/src/checks/config-check.ts b/cli/src/checks/config-check.ts new file mode 100644 index 00000000..6b81b7a1 --- /dev/null +++ b/cli/src/checks/config-check.ts @@ -0,0 +1,33 @@ +import { readConfig, configExists, resolveConfigPath } from "../config/store.js"; +import type { CheckResult } from "./index.js"; + +export function configCheck(configPath?: string): CheckResult { + const filePath = resolveConfigPath(configPath); + + if (!configExists(configPath)) { + return { + name: "Config file", + status: "fail", + message: `Config file not found at ${filePath}`, + canRepair: false, + repairHint: "Run `paperclip onboard` to create one", + }; + } + + try { + readConfig(configPath); + return { + name: "Config file", + status: "pass", + message: `Valid config at ${filePath}`, + }; + } catch (err) { + return { + name: "Config file", + status: "fail", + message: `Invalid config: ${err instanceof Error ? err.message : String(err)}`, + canRepair: false, + repairHint: "Run `paperclip onboard` to recreate", + }; + } +} diff --git a/cli/src/checks/database-check.ts b/cli/src/checks/database-check.ts new file mode 100644 index 00000000..864409b0 --- /dev/null +++ b/cli/src/checks/database-check.ts @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { PaperclipConfig } from "../config/schema.js"; +import type { CheckResult } from "./index.js"; + +export async function databaseCheck(config: PaperclipConfig): Promise { + if (config.database.mode === "postgres") { + if (!config.database.connectionString) { + return { + name: "Database", + status: "fail", + message: "PostgreSQL mode selected but no connection string configured", + canRepair: false, + repairHint: "Run `paperclip configure --section database`", + }; + } + + try { + const { createDb } = await import("@paperclip/db"); + const db = createDb(config.database.connectionString); + await db.execute("SELECT 1"); + return { + name: "Database", + status: "pass", + message: "PostgreSQL connection successful", + }; + } catch (err) { + return { + name: "Database", + status: "fail", + message: `Cannot connect to PostgreSQL: ${err instanceof Error ? err.message : String(err)}`, + canRepair: false, + repairHint: "Check your connection string and ensure PostgreSQL is running", + }; + } + } + + // PGlite mode — check data dir + const dataDir = path.resolve(config.database.pgliteDataDir); + if (!fs.existsSync(dataDir)) { + return { + name: "Database", + status: "warn", + message: `PGlite data directory does not exist: ${dataDir}`, + canRepair: true, + repair: () => { + fs.mkdirSync(dataDir, { recursive: true }); + }, + }; + } + + return { + name: "Database", + status: "pass", + message: `PGlite data directory exists: ${dataDir}`, + }; +} diff --git a/cli/src/checks/index.ts b/cli/src/checks/index.ts new file mode 100644 index 00000000..a9107e64 --- /dev/null +++ b/cli/src/checks/index.ts @@ -0,0 +1,14 @@ +export interface CheckResult { + name: string; + status: "pass" | "warn" | "fail"; + message: string; + canRepair?: boolean; + repair?: () => void | Promise; + repairHint?: string; +} + +export { configCheck } from "./config-check.js"; +export { databaseCheck } from "./database-check.js"; +export { llmCheck } from "./llm-check.js"; +export { logCheck } from "./log-check.js"; +export { portCheck } from "./port-check.js"; diff --git a/cli/src/checks/llm-check.ts b/cli/src/checks/llm-check.ts new file mode 100644 index 00000000..ca14515b --- /dev/null +++ b/cli/src/checks/llm-check.ts @@ -0,0 +1,86 @@ +import type { PaperclipConfig } from "../config/schema.js"; +import type { CheckResult } from "./index.js"; + +export async function llmCheck(config: PaperclipConfig): Promise { + if (!config.llm) { + return { + name: "LLM provider", + status: "warn", + message: "No LLM provider configured", + canRepair: false, + repairHint: "Run `paperclip configure --section llm` to set one up", + }; + } + + if (!config.llm.apiKey) { + return { + name: "LLM provider", + status: "warn", + message: `${config.llm.provider} configured but no API key set`, + canRepair: false, + repairHint: "Run `paperclip configure --section llm`", + }; + } + + try { + if (config.llm.provider === "claude") { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": config.llm.apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + max_tokens: 1, + messages: [{ role: "user", content: "hi" }], + }), + }); + if (res.ok || res.status === 400) { + return { name: "LLM provider", status: "pass", message: "Claude API key is valid" }; + } + if (res.status === 401) { + return { + name: "LLM provider", + status: "fail", + message: "Claude API key is invalid (401)", + canRepair: false, + repairHint: "Run `paperclip configure --section llm`", + }; + } + return { + name: "LLM provider", + status: "warn", + message: `Claude API returned status ${res.status}`, + }; + } else { + const res = await fetch("https://api.openai.com/v1/models", { + headers: { Authorization: `Bearer ${config.llm.apiKey}` }, + }); + if (res.ok) { + return { name: "LLM provider", status: "pass", message: "OpenAI API key is valid" }; + } + if (res.status === 401) { + return { + name: "LLM provider", + status: "fail", + message: "OpenAI API key is invalid (401)", + canRepair: false, + repairHint: "Run `paperclip configure --section llm`", + }; + } + return { + name: "LLM provider", + status: "warn", + message: `OpenAI API returned status ${res.status}`, + }; + } + } catch { + return { + name: "LLM provider", + status: "warn", + message: "Could not reach API to validate key", + }; + } +} diff --git a/cli/src/checks/log-check.ts b/cli/src/checks/log-check.ts new file mode 100644 index 00000000..336811bd --- /dev/null +++ b/cli/src/checks/log-check.ts @@ -0,0 +1,37 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { PaperclipConfig } from "../config/schema.js"; +import type { CheckResult } from "./index.js"; + +export function logCheck(config: PaperclipConfig): CheckResult { + const logDir = path.resolve(config.logging.logDir); + + if (!fs.existsSync(logDir)) { + return { + name: "Log directory", + status: "warn", + message: `Log directory does not exist: ${logDir}`, + canRepair: true, + repair: () => { + fs.mkdirSync(logDir, { recursive: true }); + }, + }; + } + + try { + fs.accessSync(logDir, fs.constants.W_OK); + return { + name: "Log directory", + status: "pass", + message: `Log directory is writable: ${logDir}`, + }; + } catch { + return { + name: "Log directory", + status: "fail", + message: `Log directory is not writable: ${logDir}`, + canRepair: false, + repairHint: "Check file permissions on the log directory", + }; + } +} diff --git a/cli/src/checks/port-check.ts b/cli/src/checks/port-check.ts new file mode 100644 index 00000000..d7d9720e --- /dev/null +++ b/cli/src/checks/port-check.ts @@ -0,0 +1,24 @@ +import type { PaperclipConfig } from "../config/schema.js"; +import { checkPort } from "../utils/net.js"; +import type { CheckResult } from "./index.js"; + +export async function portCheck(config: PaperclipConfig): Promise { + const port = config.server.port; + const result = await checkPort(port); + + if (result.available) { + return { + name: "Server port", + status: "pass", + message: `Port ${port} is available`, + }; + } + + return { + name: "Server port", + status: "warn", + message: result.error ?? `Port ${port} is not available`, + canRepair: false, + repairHint: `Check what's using port ${port} with: lsof -i :${port}`, + }; +} diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts new file mode 100644 index 00000000..aaaf1a1c --- /dev/null +++ b/cli/src/commands/configure.ts @@ -0,0 +1,113 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { readConfig, writeConfig, configExists } from "../config/store.js"; +import type { PaperclipConfig } from "../config/schema.js"; +import { promptDatabase } from "../prompts/database.js"; +import { promptLlm } from "../prompts/llm.js"; +import { promptLogging } from "../prompts/logging.js"; +import { promptServer } from "../prompts/server.js"; + +type Section = "llm" | "database" | "logging" | "server"; + +const SECTION_LABELS: Record = { + llm: "LLM Provider", + database: "Database", + logging: "Logging", + server: "Server", +}; + +export async function configure(opts: { + config?: string; + section?: string; +}): Promise { + p.intro(pc.bgCyan(pc.black(" paperclip configure "))); + + if (!configExists(opts.config)) { + p.log.error("No config file found. Run `paperclip onboard` first."); + p.outro(""); + return; + } + + const config = readConfig(opts.config); + if (!config) { + p.log.error("Could not read config file. Run `paperclip onboard` to recreate."); + p.outro(""); + return; + } + + let section: Section | undefined = opts.section as Section | undefined; + + if (section && !SECTION_LABELS[section]) { + p.log.error(`Unknown section: ${section}. Choose from: ${Object.keys(SECTION_LABELS).join(", ")}`); + p.outro(""); + return; + } + + // Section selection loop + let continueLoop = true; + while (continueLoop) { + if (!section) { + const choice = await p.select({ + message: "Which section do you want to configure?", + options: Object.entries(SECTION_LABELS).map(([value, label]) => ({ + value: value as Section, + label, + })), + }); + + if (p.isCancel(choice)) { + p.cancel("Configuration cancelled."); + return; + } + + section = choice; + } + + p.log.step(pc.bold(SECTION_LABELS[section])); + + switch (section) { + case "database": + config.database = await promptDatabase(); + break; + case "llm": { + const llm = await promptLlm(); + if (llm) { + config.llm = llm; + } else { + delete config.llm; + } + break; + } + case "logging": + config.logging = await promptLogging(); + break; + case "server": + config.server = await promptServer(); + break; + } + + config.$meta.updatedAt = new Date().toISOString(); + config.$meta.source = "configure"; + + writeConfig(config, opts.config); + p.log.success(`${SECTION_LABELS[section]} configuration updated.`); + + // If section was provided via CLI flag, don't loop + if (opts.section) { + continueLoop = false; + } else { + const another = await p.confirm({ + message: "Configure another section?", + initialValue: false, + }); + + if (p.isCancel(another) || !another) { + continueLoop = false; + } else { + section = undefined; // Reset to show picker again + } + } + } + + p.outro("Configuration saved."); +} diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts new file mode 100644 index 00000000..e870ac72 --- /dev/null +++ b/cli/src/commands/doctor.ts @@ -0,0 +1,120 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { readConfig } from "../config/store.js"; +import { + configCheck, + databaseCheck, + llmCheck, + logCheck, + portCheck, + type CheckResult, +} from "../checks/index.js"; + +const STATUS_ICON = { + pass: pc.green("✓"), + warn: pc.yellow("!"), + fail: pc.red("✗"), +} as const; + +export async function doctor(opts: { + config?: string; + repair?: boolean; + yes?: boolean; +}): Promise { + p.intro(pc.bgCyan(pc.black(" paperclip doctor "))); + + const results: CheckResult[] = []; + + // 1. Config check (must pass before others) + const cfgResult = configCheck(opts.config); + results.push(cfgResult); + printResult(cfgResult); + + if (cfgResult.status === "fail") { + printSummary(results); + return; + } + + const config = readConfig(opts.config)!; + + // 2. Database check + const dbResult = await databaseCheck(config); + results.push(dbResult); + printResult(dbResult); + await maybeRepair(dbResult, opts); + + // 3. LLM check + const llmResult = await llmCheck(config); + results.push(llmResult); + printResult(llmResult); + + // 4. Log directory check + const logResult = logCheck(config); + results.push(logResult); + printResult(logResult); + await maybeRepair(logResult, opts); + + // 5. Port check + const portResult = await portCheck(config); + results.push(portResult); + printResult(portResult); + + // Summary + printSummary(results); +} + +function printResult(result: CheckResult): void { + const icon = STATUS_ICON[result.status]; + p.log.message(`${icon} ${pc.bold(result.name)}: ${result.message}`); + if (result.status !== "pass" && result.repairHint) { + p.log.message(` ${pc.dim(result.repairHint)}`); + } +} + +async function maybeRepair( + result: CheckResult, + opts: { repair?: boolean; yes?: boolean }, +): Promise { + if (result.status === "pass" || !result.canRepair || !result.repair) return; + if (!opts.repair) return; + + let shouldRepair = opts.yes; + if (!shouldRepair) { + const answer = await p.confirm({ + message: `Repair "${result.name}"?`, + initialValue: true, + }); + if (p.isCancel(answer)) return; + shouldRepair = answer; + } + + if (shouldRepair) { + try { + await result.repair(); + p.log.success(`Repaired: ${result.name}`); + } catch (err) { + p.log.error(`Repair failed: ${err instanceof Error ? err.message : String(err)}`); + } + } +} + +function printSummary(results: CheckResult[]): void { + const passed = results.filter((r) => r.status === "pass").length; + const warned = results.filter((r) => r.status === "warn").length; + const failed = results.filter((r) => r.status === "fail").length; + + const parts: string[] = []; + parts.push(pc.green(`${passed} passed`)); + if (warned) parts.push(pc.yellow(`${warned} warnings`)); + if (failed) parts.push(pc.red(`${failed} failed`)); + + p.note(parts.join(", "), "Summary"); + + if (failed > 0) { + p.outro(pc.red("Some checks failed. Fix the issues above and re-run doctor.")); + } else if (warned > 0) { + p.outro(pc.yellow("All critical checks passed with some warnings.")); + } else { + p.outro(pc.green("All checks passed!")); + } +} diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts new file mode 100644 index 00000000..eb860777 --- /dev/null +++ b/cli/src/commands/onboard.ts @@ -0,0 +1,127 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { configExists, readConfig, writeConfig } from "../config/store.js"; +import type { PaperclipConfig } from "../config/schema.js"; +import { promptDatabase } from "../prompts/database.js"; +import { promptLlm } from "../prompts/llm.js"; +import { promptLogging } from "../prompts/logging.js"; +import { promptServer } from "../prompts/server.js"; + +export async function onboard(opts: { config?: string }): Promise { + p.intro(pc.bgCyan(pc.black(" paperclip onboard "))); + + // Check for existing config + if (configExists(opts.config)) { + const existing = readConfig(opts.config); + if (existing) { + const overwrite = await p.confirm({ + message: "A config file already exists. Overwrite it?", + initialValue: false, + }); + + if (p.isCancel(overwrite) || !overwrite) { + p.cancel("Keeping existing configuration."); + return; + } + } + } + + // Database + p.log.step(pc.bold("Database")); + const database = await promptDatabase(); + + if (database.mode === "postgres" && database.connectionString) { + const s = p.spinner(); + s.start("Testing database connection..."); + try { + const { createDb } = await import("@paperclip/db"); + const db = createDb(database.connectionString); + await db.execute("SELECT 1"); + s.stop("Database connection successful"); + } catch (err) { + s.stop(pc.yellow("Could not connect to database — you can fix this later with `paperclip doctor`")); + } + } + + // LLM + p.log.step(pc.bold("LLM Provider")); + const llm = await promptLlm(); + + if (llm?.apiKey) { + const s = p.spinner(); + s.start("Validating API key..."); + try { + if (llm.provider === "claude") { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": llm.apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + max_tokens: 1, + messages: [{ role: "user", content: "hi" }], + }), + }); + if (res.ok || res.status === 400) { + s.stop("API key is valid"); + } else if (res.status === 401) { + s.stop(pc.yellow("API key appears invalid — you can update it later")); + } else { + s.stop(pc.yellow("Could not validate API key — continuing anyway")); + } + } else { + const res = await fetch("https://api.openai.com/v1/models", { + headers: { Authorization: `Bearer ${llm.apiKey}` }, + }); + if (res.ok) { + s.stop("API key is valid"); + } else if (res.status === 401) { + s.stop(pc.yellow("API key appears invalid — you can update it later")); + } else { + s.stop(pc.yellow("Could not validate API key — continuing anyway")); + } + } + } catch { + s.stop(pc.yellow("Could not reach API — continuing anyway")); + } + } + + // Logging + p.log.step(pc.bold("Logging")); + const logging = await promptLogging(); + + // Server + p.log.step(pc.bold("Server")); + const server = await promptServer(); + + // Assemble and write config + const config: PaperclipConfig = { + $meta: { + version: 1, + updatedAt: new Date().toISOString(), + source: "onboard", + }, + ...(llm && { llm }), + database, + logging, + server, + }; + + writeConfig(config, opts.config); + + p.note( + [ + `Database: ${database.mode}`, + llm ? `LLM: ${llm.provider}` : "LLM: not configured", + `Logging: ${logging.mode} → ${logging.logDir}`, + `Server: port ${server.port}`, + ].join("\n"), + "Configuration saved", + ); + + p.log.info(`Run ${pc.cyan("pnpm paperclip doctor")} to verify your setup.`); + p.outro("You're all set!"); +} diff --git a/cli/src/config/schema.ts b/cli/src/config/schema.ts new file mode 100644 index 00000000..5a07ec75 --- /dev/null +++ b/cli/src/config/schema.ts @@ -0,0 +1,14 @@ +export { + paperclipConfigSchema, + configMetaSchema, + llmConfigSchema, + databaseConfigSchema, + loggingConfigSchema, + serverConfigSchema, + type PaperclipConfig, + type LlmConfig, + type DatabaseConfig, + type LoggingConfig, + type ServerConfig, + type ConfigMeta, +} from "@paperclip/shared"; diff --git a/cli/src/config/store.ts b/cli/src/config/store.ts new file mode 100644 index 00000000..18e91785 --- /dev/null +++ b/cli/src/config/store.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import path from "node:path"; +import { paperclipConfigSchema, type PaperclipConfig } from "./schema.js"; + +const DEFAULT_CONFIG_PATH = ".paperclip/config.json"; + +export function resolveConfigPath(overridePath?: string): string { + if (overridePath) return path.resolve(overridePath); + if (process.env.PAPERCLIP_CONFIG) return path.resolve(process.env.PAPERCLIP_CONFIG); + return path.resolve(process.cwd(), DEFAULT_CONFIG_PATH); +} + +export function readConfig(configPath?: string): PaperclipConfig | null { + const filePath = resolveConfigPath(configPath); + if (!fs.existsSync(filePath)) return null; + const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")); + return paperclipConfigSchema.parse(raw); +} + +export function writeConfig( + config: PaperclipConfig, + configPath?: string, +): void { + const filePath = resolveConfigPath(configPath); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + + // Backup existing config before overwriting + if (fs.existsSync(filePath)) { + const backupPath = filePath + ".backup"; + fs.copyFileSync(filePath, backupPath); + fs.chmodSync(backupPath, 0o600); + } + + fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n", { + mode: 0o600, + }); +} + +export function configExists(configPath?: string): boolean { + return fs.existsSync(resolveConfigPath(configPath)); +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 00000000..d22b872a --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import { Command } from "commander"; +import { onboard } from "./commands/onboard.js"; +import { doctor } from "./commands/doctor.js"; +import { configure } from "./commands/configure.js"; + +const program = new Command(); + +program + .name("paperclip") + .description("Paperclip CLI — setup, diagnose, and configure your instance") + .version("0.0.1"); + +program + .command("onboard") + .description("Interactive first-run setup wizard") + .option("-c, --config ", "Path to config file") + .action(onboard); + +program + .command("doctor") + .description("Run diagnostic checks on your Paperclip setup") + .option("-c, --config ", "Path to config file") + .option("--repair", "Attempt to repair issues automatically") + .alias("--fix") + .option("-y, --yes", "Skip repair confirmation prompts") + .action(doctor); + +program + .command("configure") + .description("Update configuration sections") + .option("-c, --config ", "Path to config file") + .option("-s, --section
", "Section to configure (llm, database, logging, server)") + .action(configure); + +program.parse(); diff --git a/cli/src/prompts/database.ts b/cli/src/prompts/database.ts new file mode 100644 index 00000000..4b6de544 --- /dev/null +++ b/cli/src/prompts/database.ts @@ -0,0 +1,48 @@ +import * as p from "@clack/prompts"; +import type { DatabaseConfig } from "../config/schema.js"; + +export async function promptDatabase(): Promise { + const mode = await p.select({ + message: "Database mode", + options: [ + { value: "pglite" as const, label: "PGlite (embedded, no setup needed)", hint: "recommended" }, + { value: "postgres" as const, label: "PostgreSQL (external server)" }, + ], + }); + + if (p.isCancel(mode)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + + if (mode === "postgres") { + const connectionString = await p.text({ + message: "PostgreSQL connection string", + placeholder: "postgres://user:pass@localhost:5432/paperclip", + validate: (val) => { + if (!val) return "Connection string is required for PostgreSQL mode"; + if (!val.startsWith("postgres")) return "Must be a postgres:// or postgresql:// URL"; + }, + }); + + if (p.isCancel(connectionString)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + + return { mode: "postgres", connectionString, pgliteDataDir: "./data/pglite" }; + } + + const pgliteDataDir = await p.text({ + message: "PGlite data directory", + defaultValue: "./data/pglite", + placeholder: "./data/pglite", + }); + + if (p.isCancel(pgliteDataDir)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + + return { mode: "pglite", pgliteDataDir: pgliteDataDir || "./data/pglite" }; +} diff --git a/cli/src/prompts/llm.ts b/cli/src/prompts/llm.ts new file mode 100644 index 00000000..7816f50d --- /dev/null +++ b/cli/src/prompts/llm.ts @@ -0,0 +1,43 @@ +import * as p from "@clack/prompts"; +import type { LlmConfig } from "../config/schema.js"; + +export async function promptLlm(): Promise { + const configureLlm = await p.confirm({ + message: "Configure an LLM provider now?", + initialValue: false, + }); + + if (p.isCancel(configureLlm)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + + if (!configureLlm) return undefined; + + const provider = await p.select({ + message: "LLM provider", + options: [ + { value: "claude" as const, label: "Claude (Anthropic)" }, + { value: "openai" as const, label: "OpenAI" }, + ], + }); + + if (p.isCancel(provider)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + + const apiKey = await p.password({ + message: `${provider === "claude" ? "Anthropic" : "OpenAI"} API key`, + validate: (val) => { + if (!val) return "API key is required"; + }, + }); + + if (p.isCancel(apiKey)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + + return { provider, apiKey }; +} diff --git a/cli/src/prompts/logging.ts b/cli/src/prompts/logging.ts new file mode 100644 index 00000000..734e2186 --- /dev/null +++ b/cli/src/prompts/logging.ts @@ -0,0 +1,35 @@ +import * as p from "@clack/prompts"; +import type { LoggingConfig } from "../config/schema.js"; + +export async function promptLogging(): Promise { + const mode = await p.select({ + message: "Logging mode", + options: [ + { value: "file" as const, label: "File-based logging", hint: "recommended" }, + { value: "cloud" as const, label: "Cloud logging", hint: "coming soon" }, + ], + }); + + if (p.isCancel(mode)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + + if (mode === "file") { + const logDir = await p.text({ + message: "Log directory", + defaultValue: "./data/logs", + placeholder: "./data/logs", + }); + + if (p.isCancel(logDir)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + + return { mode: "file", logDir: logDir || "./data/logs" }; + } + + p.note("Cloud logging is coming soon. Using file-based logging for now."); + return { mode: "file", logDir: "./data/logs" }; +} diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts new file mode 100644 index 00000000..69e1cba6 --- /dev/null +++ b/cli/src/prompts/server.ts @@ -0,0 +1,35 @@ +import * as p from "@clack/prompts"; +import type { ServerConfig } from "../config/schema.js"; + +export async function promptServer(): Promise { + const portStr = await p.text({ + message: "Server port", + defaultValue: "3100", + placeholder: "3100", + validate: (val) => { + const n = Number(val); + if (isNaN(n) || n < 1 || n > 65535 || !Number.isInteger(n)) { + return "Must be an integer between 1 and 65535"; + } + }, + }); + + if (p.isCancel(portStr)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + + const port = Number(portStr) || 3100; + + const serveUi = await p.confirm({ + message: "Serve the UI from the server?", + initialValue: false, + }); + + if (p.isCancel(serveUi)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + + return { port, serveUi }; +} diff --git a/cli/src/utils/net.ts b/cli/src/utils/net.ts new file mode 100644 index 00000000..0b5597b0 --- /dev/null +++ b/cli/src/utils/net.ts @@ -0,0 +1,18 @@ +import net from "node:net"; + +export function checkPort(port: number): Promise<{ available: boolean; error?: string }> { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + resolve({ available: false, error: `Port ${port} is already in use` }); + } else { + resolve({ available: false, error: err.message }); + } + }); + server.once("listening", () => { + server.close(() => resolve({ available: true })); + }); + server.listen(port, "127.0.0.1"); + }); +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 00000000..e4600622 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/package.json b/package.json index cb6e094a..10cdd8f0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "test": "vitest", "test:run": "vitest run", "db:generate": "pnpm --filter @paperclip/db generate", - "db:migrate": "pnpm --filter @paperclip/db migrate" + "db:migrate": "pnpm --filter @paperclip/db migrate", + "paperclip": "tsx cli/src/index.ts" }, "devDependencies": { "typescript": "^5.7.3", diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts new file mode 100644 index 00000000..e6489c1a --- /dev/null +++ b/packages/shared/src/config-schema.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +export const configMetaSchema = z.object({ + version: z.literal(1), + updatedAt: z.string(), + source: z.enum(["onboard", "configure", "doctor"]), +}); + +export const llmConfigSchema = z.object({ + provider: z.enum(["claude", "openai"]), + apiKey: z.string().optional(), +}); + +export const databaseConfigSchema = z.object({ + mode: z.enum(["pglite", "postgres"]), + connectionString: z.string().optional(), + pgliteDataDir: z.string().default("./data/pglite"), +}); + +export const loggingConfigSchema = z.object({ + mode: z.enum(["file", "cloud"]), + logDir: z.string().default("./data/logs"), +}); + +export const serverConfigSchema = z.object({ + port: z.number().int().min(1).max(65535).default(3100), + serveUi: z.boolean().default(false), +}); + +export const paperclipConfigSchema = z.object({ + $meta: configMetaSchema, + llm: llmConfigSchema.optional(), + database: databaseConfigSchema, + logging: loggingConfigSchema, + server: serverConfigSchema, +}); + +export type PaperclipConfig = z.infer; +export type LlmConfig = z.infer; +export type DatabaseConfig = z.infer; +export type LoggingConfig = z.infer; +export type ServerConfig = z.infer; +export type ConfigMeta = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 986da386..b3667505 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -95,3 +95,18 @@ export { } from "./validators/index.js"; export { API_PREFIX, API } from "./api.js"; + +export { + paperclipConfigSchema, + configMetaSchema, + llmConfigSchema, + databaseConfigSchema, + loggingConfigSchema, + serverConfigSchema, + type PaperclipConfig, + type LlmConfig, + type DatabaseConfig, + type LoggingConfig, + type ServerConfig, + type ConfigMeta, +} from "./config-schema.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 015da6c0..472a6866 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,34 @@ importers: specifier: ^3.0.5 version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + cli: + dependencies: + '@clack/prompts': + specifier: ^0.10.0 + version: 0.10.1 + '@paperclip/db': + specifier: workspace:* + version: link:../packages/db + '@paperclip/shared': + specifier: workspace:* + version: link:../packages/shared + commander: + specifier: ^13.1.0 + version: 13.1.0 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/db: dependencies: '@electric-sql/pglite': @@ -254,6 +282,12 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@clack/core@0.4.2': + resolution: {integrity: sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==} + + '@clack/prompts@0.10.1': + resolution: {integrity: sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1720,6 +1754,9 @@ packages: '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/node@22.19.11': + resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} + '@types/node@25.2.3': resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} @@ -1866,6 +1903,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -2575,6 +2616,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -2670,6 +2714,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -2965,6 +3012,17 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@clack/core@0.4.2': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.10.1': + dependencies: + '@clack/core': 0.4.2 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@drizzle-team/brocli@0.10.2': {} '@electric-sql/pglite@0.3.15': {} @@ -4209,6 +4267,10 @@ snapshots: '@types/methods@1.1.4': {} + '@types/node@22.19.11': + dependencies: + undici-types: 6.21.0 + '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -4391,6 +4453,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@13.1.0: {} + component-emitter@1.3.1: {} content-disposition@1.0.1: {} @@ -5122,6 +5186,8 @@ snapshots: siginfo@2.0.0: {} + sisteransi@1.0.5: {} + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -5213,6 +5279,8 @@ snapshots: typescript@5.9.3: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} unpipe@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c2419471..411fe263 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - packages/* - server - ui + - cli diff --git a/server/src/config-file.ts b/server/src/config-file.ts new file mode 100644 index 00000000..6b8260c7 --- /dev/null +++ b/server/src/config-file.ts @@ -0,0 +1,18 @@ +import fs from "node:fs"; +import path from "node:path"; +import { paperclipConfigSchema, type PaperclipConfig } from "@paperclip/shared"; + +export function readConfigFile(): PaperclipConfig | null { + const configPath = process.env.PAPERCLIP_CONFIG + ? path.resolve(process.env.PAPERCLIP_CONFIG) + : path.resolve(process.cwd(), ".paperclip/config.json"); + + if (!fs.existsSync(configPath)) return null; + + try { + const raw = JSON.parse(fs.readFileSync(configPath, "utf-8")); + return paperclipConfigSchema.parse(raw); + } catch { + return null; + } +} diff --git a/server/src/config.ts b/server/src/config.ts index c2387f88..07b0115d 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,3 +1,5 @@ +import { readConfigFile } from "./config-file.js"; + export interface Config { port: number; databaseUrl: string | undefined; @@ -7,10 +9,20 @@ export interface Config { } export function loadConfig(): Config { + const fileConfig = readConfigFile(); + + const fileDbUrl = + fileConfig?.database.mode === "postgres" + ? fileConfig.database.connectionString + : undefined; + return { - port: Number(process.env.PORT) || 3100, - databaseUrl: process.env.DATABASE_URL, - serveUi: process.env.SERVE_UI === "true", + port: Number(process.env.PORT) || fileConfig?.server.port || 3100, + databaseUrl: process.env.DATABASE_URL ?? fileDbUrl, + serveUi: + process.env.SERVE_UI !== undefined + ? process.env.SERVE_UI === "true" + : fileConfig?.server.serveUi ?? false, heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false", heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000), }; diff --git a/ui/src/pages/Companies.tsx b/ui/src/pages/Companies.tsx index c026113a..cd61adb4 100644 --- a/ui/src/pages/Companies.tsx +++ b/ui/src/pages/Companies.tsx @@ -128,10 +128,18 @@ export function Companies() { const isEditing = editingId === company.id; return ( - + ); })}