diff --git a/packages/cli/README.md b/packages/cli/README.md index b2ee86d..7e7a4fd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,6 @@ # @spool-lab/cli -Command-line interface for [Spool](https://spool.pro) — search your AI sessions and manage connector plugins from the terminal. +Command-line interface for [Spool](https://spool.pro) — search your AI sessions from the terminal. ## Install @@ -37,37 +37,10 @@ spool sync # Index new AI sessions (Claude, Codex, Gemini) spool sync --watch # Keep watching for new sessions ``` -### Connector management - -```bash -spool connector list # List installed connectors -spool connector list --json # Output as JSON -spool connector install # Install from npm -spool connector install @spool-lab/connector-github -y # Skip confirmation -spool connector uninstall # Remove a connector + data -spool connector status # Detailed sync state + auth check -spool connector sync # Run sync manually -spool connector sync --reset # Clear data and resync -spool connector update # Check all for npm updates -spool connector update --apply # Apply available updates -``` - -## Available connectors - -Browse the full list at [spool.pro/connectors](https://spool.pro/connectors). Some examples: - -```bash -spool connector install @spool-lab/connector-github -spool connector install @spool-lab/connector-twitter-bookmarks -spool connector install @spool-lab/connector-reddit -spool connector install @graydawnc/connector-youtube -``` - ## Data location All data is stored locally in `~/.spool/`: -- `spool.db` — SQLite database with sessions, messages, and captures -- `connectors/` — installed connector plugins +- `spool.db` — SQLite database with sessions and messages ## License diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 24b6117..4e06b93 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -101,7 +101,7 @@ const SCHEMA_SQL = ` ); INSERT INTO sources (name, base_path) VALUES ('claude','~/.claude/projects'),('codex','~/.codex/sessions'), - ('gemini','~/.gemini/tmp'),('connector',''); + ('gemini','~/.gemini/tmp'); CREATE TABLE projects ( id INTEGER PRIMARY KEY, source_id INTEGER NOT NULL REFERENCES sources(id), @@ -163,38 +163,6 @@ const SCHEMA_SQL = ` content='session_search', content_rowid='session_id', tokenize='trigram' ); - CREATE TABLE captures ( - id INTEGER PRIMARY KEY, source_id INTEGER NOT NULL REFERENCES sources(id), - capture_uuid TEXT NOT NULL UNIQUE, url TEXT NOT NULL, - title TEXT NOT NULL DEFAULT '', content_text TEXT NOT NULL DEFAULT '', - author TEXT, platform TEXT NOT NULL, platform_id TEXT, - content_type TEXT NOT NULL DEFAULT 'page', thumbnail_url TEXT, - metadata TEXT NOT NULL DEFAULT '{}', captured_at TEXT NOT NULL, - indexed_at TEXT NOT NULL DEFAULT (datetime('now')), raw_json TEXT - ); - CREATE VIRTUAL TABLE captures_fts USING fts5( - title, content_text, content='captures', content_rowid='id', - tokenize='unicode61 remove_diacritics 1' - ); - CREATE VIRTUAL TABLE captures_fts_trigram USING fts5( - title, content_text, content='captures', content_rowid='id', tokenize='trigram' - ); - - CREATE TABLE capture_connectors ( - capture_id INTEGER NOT NULL REFERENCES captures(id) ON DELETE CASCADE, - connector_id TEXT NOT NULL, PRIMARY KEY (capture_id, connector_id) - ); - CREATE INDEX idx_capture_connectors_connector ON capture_connectors(connector_id); - - CREATE TABLE connector_sync_state ( - connector_id TEXT PRIMARY KEY, head_cursor TEXT, head_item_id TEXT, - tail_cursor TEXT, tail_complete INTEGER NOT NULL DEFAULT 0, - last_forward_sync_at TEXT, last_backfill_sync_at TEXT, - total_synced INTEGER NOT NULL DEFAULT 0, consecutive_errors INTEGER NOT NULL DEFAULT 0, - enabled INTEGER NOT NULL DEFAULT 1, config_json TEXT NOT NULL DEFAULT '{}', - last_error_at TEXT, last_error_code TEXT, last_error_message TEXT - ); - CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, content_text) VALUES(NEW.id, NEW.content_text); INSERT INTO messages_fts_trigram(rowid, content_text) VALUES(NEW.id, NEW.content_text); @@ -381,121 +349,3 @@ describe('sync', () => { } }) }) - -describe('connector install', () => { - it('exits with error when package arg is missing', () => { - const out = runFail(['connector', 'install']) - expect(out).toContain("missing required argument") - }) - - it('fails gracefully on nonexistent package', () => { - const dir = mkdtempSync(join(tmpdir(), 'spool-cli-install-')) - try { - const out = runFail(['connector', 'install', '@spool-lab/nonexistent-pkg-test', '-y'], { SPOOL_DATA_DIR: dir }) - expect(out).toContain('Failed') - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) -}) - -describe('connector sync', () => { - it('lists connectors or reports none when no arg given', () => { - const dir = mkdtempSync(join(tmpdir(), 'spool-cli-csync-')) - try { - let out: string - try { - out = run(['connector', 'sync'], { SPOOL_DATA_DIR: dir }) - } catch { - out = runFail(['connector', 'sync'], { SPOOL_DATA_DIR: dir }) - } - expect(out).toMatch(/Available connectors|No connectors installed/) - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('exits with error for unknown connector', () => { - const dir = mkdtempSync(join(tmpdir(), 'spool-cli-csync-')) - try { - const out = runFail(['connector', 'sync', 'nonexistent-connector'], { SPOOL_DATA_DIR: dir }) - expect(out).toContain('Unknown connector') - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) -}) - -describe('connector list', () => { - it('lists connectors or reports none', () => { - const dir = mkdtempSync(join(tmpdir(), 'spool-cli-clist-')) - try { - const out = run(['connector', 'list'], { SPOOL_DATA_DIR: dir }) - expect(out).toMatch(/items|No connectors installed/) - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('outputs JSON with --json', () => { - const dir = mkdtempSync(join(tmpdir(), 'spool-cli-clist-')) - try { - const out = run(['connector', 'list', '--json'], { SPOOL_DATA_DIR: dir }) - const parsed = JSON.parse(out) - expect(Array.isArray(parsed)).toBe(true) - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) -}) - -describe('connector status', () => { - it('exits with error for unknown connector', () => { - const dir = mkdtempSync(join(tmpdir(), 'spool-cli-cstatus-')) - try { - const out = runFail(['connector', 'status', 'nonexistent-connector'], { SPOOL_DATA_DIR: dir }) - expect(out).toContain('Unknown connector') - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('exits with error when id is missing', () => { - const out = runFail(['connector', 'status']) - expect(out).toContain("missing required argument") - }) -}) - -describe('connector uninstall', () => { - it('exits with error for unknown connector', () => { - const dir = mkdtempSync(join(tmpdir(), 'spool-cli-cuninstall-')) - try { - const out = runFail(['connector', 'uninstall', 'nonexistent-connector', '-y'], { SPOOL_DATA_DIR: dir }) - expect(out).toContain('Unknown connector') - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) -}) - -describe('connector update', () => { - it('checks for updates without error', () => { - const dir = mkdtempSync(join(tmpdir(), 'spool-cli-cupdate-')) - try { - const out = run(['connector', 'update'], { SPOOL_DATA_DIR: dir }) - expect(out).toMatch(/up to date|No connectors to check|→/) - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('exits with error for unknown connector', () => { - const dir = mkdtempSync(join(tmpdir(), 'spool-cli-cupdate-')) - try { - const out = runFail(['connector', 'update', 'nonexistent-connector'], { SPOOL_DATA_DIR: dir }) - expect(out).toContain('Unknown connector') - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) -}) diff --git a/packages/cli/src/commands/connector-shared.ts b/packages/cli/src/commands/connector-shared.ts deleted file mode 100644 index fc1436b..0000000 --- a/packages/cli/src/commands/connector-shared.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { homedir } from 'node:os' -import { join } from 'node:path' -import { ProxyAgent, type Dispatcher } from 'undici' -import { - getDB, - ConnectorRegistry, - TrustStore, - PrerequisiteChecker, - loadConnectors, -} from '@spool-lab/core' -import type { PrerequisitesCapability } from '@spool-lab/core' -import type Database from 'better-sqlite3' - -function getProxyUrl(): string | undefined { - const fromEnv = process.env['https_proxy'] || process.env['HTTPS_PROXY'] - || process.env['http_proxy'] || process.env['HTTP_PROXY'] - if (fromEnv) return fromEnv - - if (process.platform === 'darwin') { - try { - const { execFileSync } = require('node:child_process') as typeof import('node:child_process') - const out = execFileSync('scutil', ['--proxy'], { encoding: 'utf8', timeout: 3000 }) - const httpsEnabled = /HTTPSEnable\s*:\s*1/.test(out) - if (httpsEnabled) { - const host = out.match(/HTTPSProxy\s*:\s*(\S+)/)?.[1] - const port = out.match(/HTTPSPort\s*:\s*(\d+)/)?.[1] - if (host && port) return `http://${host}:${port}` - } - const httpEnabled = /HTTPEnable\s*:\s*1/.test(out) - if (httpEnabled) { - const host = out.match(/HTTPProxy\s*:\s*(\S+)/)?.[1] - const port = out.match(/HTTPPort\s*:\s*(\d+)/)?.[1] - if (host && port) return `http://${host}:${port}` - } - } catch {} - } - - return undefined -} - -let _proxyDispatcher: Dispatcher | undefined -function getProxyDispatcher(): Dispatcher | undefined { - const url = getProxyUrl() - if (!url) return undefined - if (!_proxyDispatcher) _proxyDispatcher = new ProxyAgent(url) - return _proxyDispatcher -} - -export function proxyFetch(input: string | URL | Request, init?: RequestInit): Promise { - const dispatcher = getProxyDispatcher() - if (!dispatcher) return globalThis.fetch(input, init) - return globalThis.fetch(input, { ...init, dispatcher } as unknown as RequestInit) -} - -export interface BootstrapResult { - db: Database.Database - registry: ConnectorRegistry - spoolDir: string - connectorsDir: string - trustStore: TrustStore - versions: Map -} - -export async function bootstrap(opts?: { readonly?: boolean }): Promise { - const db = getDB(opts?.readonly) - const registry = new ConnectorRegistry() - const spoolDir = join(homedir(), '.spool') - const connectorsDir = join(spoolDir, 'connectors') - const trustStore = new TrustStore(spoolDir) - - const { makeFetchCapability, makeChromeCookiesCapability, makeSqliteCapability, makeExecCapability, makeLogCapabilityFor } = - await import('@spool-lab/core') - - const execImpl = makeExecCapability() - const prereqChecker = new PrerequisiteChecker(execImpl) - - const report = await loadConnectors({ - connectorsDir, - capabilityImpls: { - fetch: makeFetchCapability(proxyFetch), - cookies: makeChromeCookiesCapability(), - sqlite: makeSqliteCapability(), - exec: execImpl, - logFor: (id: string) => makeLogCapabilityFor(id), - prerequisitesFor: (packageId: string): PrerequisitesCapability => ({ - check: () => { - const pkg = registry.getPackage(packageId) - if (!pkg) return Promise.resolve([]) - return prereqChecker.check(pkg) - }, - }), - }, - registry, - log: { info: () => {}, warn: console.warn, error: console.error }, - trustStore, - }) - - const versions = new Map() - for (const r of report.loadResults) { - if (r.status === 'loaded') { - versions.set(r.name, r.version) - } - } - - return { db, registry, spoolDir, connectorsDir, trustStore, versions } -} diff --git a/packages/cli/src/commands/connector.ts b/packages/cli/src/commands/connector.ts deleted file mode 100644 index c10b279..0000000 --- a/packages/cli/src/commands/connector.ts +++ /dev/null @@ -1,489 +0,0 @@ -import { Command } from 'commander' -import { join } from 'node:path' -import { readFileSync } from 'node:fs' -import { homedir } from 'node:os' -import { - downloadAndInstall, - uninstallConnector, - checkForUpdates, - loadSyncState, - SyncEngine, - TrustStore, - deleteConnectorItems, -} from '@spool-lab/core' -import type { SetupStep } from '@spool-lab/core' -import * as readline from 'node:readline' -import { bootstrap, proxyFetch } from './connector-shared.js' - -// ── list ─────────────────────────────────────────────────────────────────── - -const listSubcommand = new Command('list') - .description('List installed connectors') - .option('--json', 'Output as JSON') - .action(async (opts: { json?: boolean }) => { - const { db, registry, versions } = await bootstrap({ readonly: true }) - const connectors = registry.list() - - if (connectors.length === 0) { - if (opts.json) { - console.log('[]') - } else { - console.log('No connectors installed.') - } - return - } - - if (opts.json) { - const data = connectors.map(c => { - const state = loadSyncState(db, c.id) - const pkg = registry.getPackage(c.id) ?? registry.listPackages().find(p => p.connectors.some(pc => pc.id === c.id)) - return { - id: c.id, - label: c.label, - platform: c.platform, - packageName: pkg?.packageName ?? c.id, - version: versions.get(pkg?.packageName ?? '') ?? 'unknown', - totalSynced: state.totalSynced, - lastSync: state.lastForwardSyncAt, - hasError: state.consecutiveErrors > 0, - } - }) - console.log(JSON.stringify(data, null, 2)) - return - } - - for (const c of connectors) { - const state = loadSyncState(db, c.id) - const pkg = registry.getPackage(c.id) ?? registry.listPackages().find(p => p.connectors.some(pc => pc.id === c.id)) - const version = versions.get(pkg?.packageName ?? '') ?? '?' - const items = String(state.totalSynced).padStart(5) - const lastSync = state.lastForwardSyncAt - ? timeSince(state.lastForwardSyncAt) - : 'never' - const errorMark = state.consecutiveErrors > 0 ? ' [ERR]' : '' - console.log(` ${c.id.padEnd(24)} v${version.padEnd(8)} ${items} items synced ${lastSync}${errorMark}`) - } - }) - -// ── status ───────────────────────────────────────────────────────────────── - -const statusSubcommand = new Command('status') - .description('Show detailed status of a connector') - .argument('', 'Connector ID') - .action(async (connectorId: string) => { - const { db, registry, versions } = await bootstrap({ readonly: true }) - - if (!registry.has(connectorId)) { - console.error(`Unknown connector: ${connectorId}`) - console.error(`Available: ${registry.list().map(c => c.id).join(', ')}`) - process.exit(1) - } - - const connector = registry.get(connectorId) - const state = loadSyncState(db, connectorId) - const pkg = registry.getPackage(connectorId) ?? registry.listPackages().find(p => p.connectors.some(c => c.id === connectorId)) - const version = versions.get(pkg?.packageName ?? '') ?? 'unknown' - - const itemCount = (db.prepare( - 'SELECT COUNT(*) as cnt FROM capture_connectors WHERE connector_id = ?', - ).get(connectorId) as { cnt: number }).cnt - - console.log(`Connector: ${connector.label} (${connector.id})`) - console.log(`Platform: ${connector.platform}`) - console.log(`Package: ${pkg?.packageName ?? 'unknown'}`) - console.log(`Version: ${version}`) - console.log(`Items in DB: ${itemCount}`) - console.log(`Total synced: ${state.totalSynced}`) - console.log(``) - console.log(`Forward sync: ${state.lastForwardSyncAt ?? 'never'}`) - console.log(`Backfill: ${state.lastBackfillSyncAt ?? 'never'}`) - console.log(`Tail done: ${state.tailComplete ? 'yes' : 'no'}`) - - if (state.consecutiveErrors > 0) { - console.log(``) - console.log(`Errors: ${state.consecutiveErrors} consecutive`) - console.log(`Last error: [${state.lastErrorCode}] ${state.lastErrorMessage}`) - console.log(`Error at: ${state.lastErrorAt}`) - } - - // Auth check - console.log(``) - process.stdout.write('Auth: checking...') - const auth = await connector.checkAuth() - process.stdout.write(`\rAuth: ${auth.ok ? 'ok' : 'FAILED'} \n`) - if (!auth.ok) { - if (auth.message) console.log(` Message: ${auth.message}`) - if (auth.hint) console.log(` Hint: ${auth.hint}`) - printSetupSteps(auth.setup) - } - }) - -// ── install ──────────────────────────────────────────────────────────────── - -const installSubcommand = new Command('install') - .description('Install a connector plugin from npm') - .argument('', 'npm package name (e.g. @spool-lab/connector-hackernews-hot)') - .option('-y, --yes', 'Skip confirmation prompt') - .action(async (packageName: string, opts: { yes?: boolean }) => { - const isFirstParty = packageName.startsWith('@spool-lab/') - - if (!opts.yes) { - const warning = isFirstParty - ? `Install official connector "${packageName}"?` - : `Install community connector "${packageName}"? It will run code on your machine.` - - const confirmed = await confirm(`${warning} [y/N] `) - if (!confirmed) { - console.log('Cancelled.') - process.exit(0) - } - } - - const spoolDir = join(homedir(), '.spool') - const connectorsDir = join(spoolDir, 'connectors') - - console.log(`Installing ${packageName}...`) - try { - const result = await downloadAndInstall(packageName, connectorsDir, proxyFetch) - - if (!isFirstParty) { - const trustStore = new TrustStore(spoolDir) - trustStore.add(packageName) - } - - console.log(`Installed ${result.name} v${result.version}`) - console.log(` → ${result.installPath}`) - - const connectorIds = readConnectorIds(result.installPath) - if (connectorIds.length > 0) { - const syncCmds = connectorIds.map(id => `spool connector sync ${id}`).join('\n ') - console.log(`Run:\n ${syncCmds}\nor restart the Spool app to activate.`) - } - } catch (err) { - console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`) - process.exit(1) - } - }) - -// ── uninstall ────────────────────────────────────────────────────────────── - -const uninstallSubcommand = new Command('uninstall') - .description('Uninstall a connector plugin') - .argument('', 'Connector ID') - .option('-y, --yes', 'Skip confirmation prompt') - .option('-f, --force', 'Proceed even if the Spool app is running') - .action(async (connectorId: string, opts: { yes?: boolean; force?: boolean }) => { - if (!opts.force && isSpoolAppRunning()) { - console.error('The Spool app is currently running.') - console.error('Please quit the app first, or use --force to proceed anyway.') - process.exit(1) - } - - const { db, registry, connectorsDir, trustStore } = await bootstrap() - - const pkg = registry.getPackage(connectorId) - ?? registry.listPackages().find(p => p.connectors.some(c => c.id === connectorId)) - - if (!pkg) { - console.error(`Unknown connector: ${connectorId}`) - console.error(`Available: ${registry.list().map(c => c.id).join(', ')}`) - process.exit(1) - } - - const allConnectorIds = pkg.connectors.map(c => c.id) - const siblingIds = allConnectorIds.filter(id => id !== connectorId) - - if (!opts.yes) { - let prompt = `Uninstall "${pkg.packageName}"?` - if (siblingIds.length > 0) { - prompt += ` This will also remove: ${siblingIds.join(', ')}` - } - prompt += ' This will delete all synced data for this connector.' - const confirmed = await confirm(`${prompt} [y/N] `) - if (!confirmed) { - console.log('Cancelled.') - process.exit(0) - } - } - - try { - // Delete connector files - uninstallConnector(pkg.packageName, connectorsDir) - trustStore.remove(pkg.packageName) - - // Clean DB data for all connectors in the package (matches app behavior). - // deleteConnectorItems handles capture_connectors + stars + captures together. - for (const cid of allConnectorIds) { - tryRun(() => db.prepare('DELETE FROM connector_sync_state WHERE connector_id = ?').run(cid)) - tryRun(() => deleteConnectorItems(db, cid)) - } - - console.log(`Uninstalled ${pkg.packageName}`) - if (opts.force) { - console.log('Restart the Spool app to apply.') - } - } catch (err) { - console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`) - process.exit(1) - } - }) - -// ── sync ─────────────────────────────────────────────────────────────────── - -const syncSubcommand = new Command('sync') - .description('Sync a connector until fully complete') - .argument('[connector-id]', 'Connector ID (omit to list available)') - .option('--reset', 'Delete all data for this connector and sync from scratch') - .option('--delay ', 'Delay between page requests in ms', '600') - .action(async (connectorId: string | undefined, opts: { reset?: boolean; delay?: string }) => { - const { db, registry } = await bootstrap() - - const available = registry.list().map(c => c.id) - - if (!connectorId) { - if (available.length === 0) { - console.error('No connectors installed.') - process.exit(1) - } - console.log('Available connectors:') - for (const id of available) console.log(` ${id}`) - console.log('\nUsage: spool connector sync ') - process.exit(0) - } - - if (!registry.has(connectorId)) { - console.error(`Unknown connector: ${connectorId}`) - console.error(`Available: ${available.join(', ')}`) - process.exit(1) - } - - if (isSpoolAppRunning()) { - console.warn('Warning: The Spool app is running. Concurrent syncs may cause conflicts.') - } - - const connector = registry.get(connectorId) - - const auth = await connector.checkAuth() - if (!auth.ok) { - console.error('Auth failed.') - if (auth.message) console.error(` ${auth.message}`) - if (auth.hint) console.error(` Hint: ${auth.hint}`) - printSetupSteps(auth.setup) - process.exit(1) - } - - if (opts.reset) { - console.log(`Resetting ${connectorId}...`) - db.prepare('DELETE FROM capture_connectors WHERE connector_id = ?').run(connectorId) - db.prepare(` - DELETE FROM captures - WHERE source_id = (SELECT id FROM sources WHERE name = 'connector') - AND NOT EXISTS (SELECT 1 FROM capture_connectors WHERE capture_id = captures.id) - `).run() - db.prepare('DELETE FROM connector_sync_state WHERE connector_id = ?').run(connectorId) - console.log('Data cleared.') - } - - const engine = new SyncEngine(db) - const delayMs = parseInt(opts.delay ?? '600', 10) - const startedAt = Date.now() - - console.log(`Syncing ${connector.label}... (Ctrl+C to stop)`) - - let aborted = false - const controller = new AbortController() - process.on('SIGINT', () => { - if (aborted) process.exit(1) - console.log('\nStopping after current page...') - aborted = true - controller.abort() - }) - - const result = await engine.sync(connector, { - direction: 'both', - delayMs, - maxMinutes: 0, - signal: controller.signal, - onProgress: (p) => { - const elapsed = ((Date.now() - startedAt) / 1000).toFixed(0) - process.stdout.write( - `\r ${p.phase} page ${p.page} · ${p.added} new · ${elapsed}s elapsed`, - ) - }, - }) - - process.stdout.write('\n') - - const row = db.prepare( - 'SELECT COUNT(*) as cnt FROM capture_connectors WHERE connector_id = ?', - ).get(connectorId) as { cnt: number } - - console.log(`Done.`) - console.log(` stop reason: ${result.stopReason}`) - console.log(` pages fetched: ${result.pages}`) - console.log(` new items: ${result.added}`) - console.log(` total in DB: ${row.cnt}`) - - if (result.error) { - console.error(` error [${result.error.code}]: ${result.error.message}`) - } - - process.exit(result.error ? 1 : 0) - }) - -// ── update ───────────────────────────────────────────────────────────────── - -const updateSubcommand = new Command('update') - .description('Check for connector updates from npm') - .argument('[id]', 'Connector ID (omit to check all)') - .option('--apply', 'Apply available updates') - .action(async (connectorId: string | undefined, opts: { apply?: boolean }) => { - const { registry, versions, connectorsDir } = await bootstrap({ readonly: true }) - - const packages = registry.listPackages() - let toCheck = packages.map(p => ({ - packageName: p.packageName, - currentVersion: versions.get(p.packageName) ?? '0.0.0', - })) - - if (connectorId) { - const pkg = registry.getPackage(connectorId) - ?? packages.find(p => p.connectors.some(c => c.id === connectorId)) - if (!pkg) { - console.error(`Unknown connector: ${connectorId}`) - process.exit(1) - } - toCheck = toCheck.filter(c => c.packageName === pkg.packageName) - } - - if (toCheck.length === 0) { - console.log('No connectors to check.') - return - } - - console.log('Checking for updates...') - const updates = await checkForUpdates(toCheck, proxyFetch) - - if (updates.size === 0) { - console.log('All connectors are up to date.') - return - } - - for (const [name, info] of updates) { - console.log(` ${name} ${info.current} → ${info.latest}`) - } - - if (!opts.apply) { - console.log(`\nRun with --apply to install updates.`) - return - } - - if (isSpoolAppRunning()) { - console.error('The Spool app is currently running. Please quit the app first before applying updates.') - process.exit(1) - } - - for (const [name, info] of updates) { - process.stdout.write(`Updating ${name}...`) - try { - await downloadAndInstall(name, connectorsDir, proxyFetch) - console.log(` ${info.current} → ${info.latest}`) - } catch (err) { - console.log(` FAILED: ${err instanceof Error ? err.message : String(err)}`) - } - } - console.log('Restart the Spool app to apply.') - }) - -// ── main command ─────────────────────────────────────────────────────────── - -export const connectorCommand = new Command('connector') - .description('Manage connector plugins') - .addCommand(listSubcommand) - .addCommand(statusSubcommand) - .addCommand(installSubcommand) - .addCommand(uninstallSubcommand) - .addCommand(syncSubcommand) - .addCommand(updateSubcommand) - -function confirm(question: string): Promise { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close() - resolve(answer.toLowerCase() === 'y') - }) - }) -} - -function timeSince(iso: string): string { - const ms = Date.now() - new Date(iso).getTime() - const mins = Math.floor(ms / 60_000) - if (mins < 60) return `${mins}m ago` - const hours = Math.floor(mins / 60) - if (hours < 24) return `${hours}h ago` - const days = Math.floor(hours / 24) - return `${days}d ago` -} - -const STATUS_ICON: Record = { - ok: '[ok]', - missing: '[MISSING]', - outdated: '[OUTDATED]', - error: '[ERROR]', - pending: '[pending]', -} - -function printSetupSteps(steps?: SetupStep[]): void { - if (!steps || steps.length === 0) return - console.log(' Prerequisites:') - for (const s of steps) { - const icon = STATUS_ICON[s.status] ?? `[${s.status}]` - console.log(` ${icon} ${s.label}`) - if (s.hint) console.log(` ${s.hint}`) - if (s.status === 'missing' && s.install) { - const inst = s.install - if (inst.kind === 'cli') { - const cmd = inst.command[process.platform as 'darwin' | 'linux' | 'win32'] - if (cmd) console.log(` → ${cmd}`) - } else if (inst.kind === 'site-session') { - console.log(` → Open ${inst.openUrl} and log in`) - } else if (inst.kind === 'browser-extension' && inst.manual) { - for (const step of inst.manual.steps) { - console.log(` → ${step}`) - } - } - } - if (s.docsUrl) console.log(` docs: ${s.docsUrl}`) - } -} - -function readConnectorIds(installPath: string): string[] { - try { - const pkg = JSON.parse(readFileSync(join(installPath, 'package.json'), 'utf8')) - if (Array.isArray(pkg.spool?.connectors)) { - return pkg.spool.connectors.map((c: { id: string }) => c.id) - } - if (pkg.spool?.id) return [pkg.spool.id] - } catch {} - return [] -} - -function tryRun(fn: () => void): void { - try { fn() } catch { /* best-effort — FTS triggers may fail on corrupted rows */ } -} - -function isSpoolAppRunning(): boolean { - try { - const { execFileSync } = require('node:child_process') as typeof import('node:child_process') - if (process.platform === 'win32') { - const out = execFileSync('tasklist', ['/FI', 'IMAGENAME eq Spool.exe', '/NH'], { encoding: 'utf8', stdio: 'pipe' }) - return out.includes('Spool.exe') - } - // macOS and Linux - execFileSync('pgrep', ['-xi', 'spool'], { stdio: 'pipe' }) - return true - } catch { - return false - } -} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e740c22..646d297 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,7 +7,6 @@ import { syncCommand } from './commands/sync.js' import { listCommand } from './commands/list.js' import { statusCommand } from './commands/status.js' import { showCommand } from './commands/show.js' -import { connectorCommand } from './commands/connector.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) as { version: string } @@ -22,6 +21,5 @@ program.addCommand(syncCommand) program.addCommand(listCommand) program.addCommand(statusCommand) program.addCommand(showCommand) -program.addCommand(connectorCommand) program.parse()