diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index ec6c126..ea4fc6b 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -25,6 +25,16 @@ jobs: run: | Remove-Item Env:HOME -ErrorAction SilentlyContinue bun scripts/windows-smoke.ts + - name: Harnesses get working MCP entries (Codex, Antigravity) in a throwaway home + shell: pwsh + run: | + $t = Join-Path $env:RUNNER_TEMP 'home' + New-Item -ItemType Directory -Force $t | Out-Null + $env:USERPROFILE = $t + $env:DATACORE_ROOT = Join-Path $t 'Data' + Remove-Item Env:HOME -ErrorAction SilentlyContinue + bun scripts/harness-smoke.ts + Get-Content (Join-Path $t '.codex\config.toml') - name: Built CLI detects installed tools (HOME unset, as on a user's machine) shell: pwsh run: | diff --git a/package.json b/package.json index a9c6fe9..460501f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@datacore-one/cli", - "version": "2.5.1", + "version": "2.6.0", "description": "CLI for setting up and managing Datacore installations", "type": "module", "main": "dist/index.js", diff --git a/scripts/harness-smoke.ts b/scripts/harness-smoke.ts new file mode 100644 index 0000000..0f0eb08 --- /dev/null +++ b/scripts/harness-smoke.ts @@ -0,0 +1,36 @@ +/** + * End-to-end: a machine with Codex and Antigravity installed gets both MCP + * servers registered in each, with commands that exist. Uses a throwaway + * home; run with HOME/USERPROFILE and DATACORE_ROOT pointing at temp dirs. + */ +import { existsSync, mkdirSync, readFileSync } from 'fs' +import { join } from 'path' +import { homeDir } from '../src/lib/exec' +import { configureOtherHarnesses, mcpServerEntries } from '../src/lib/upgrade' + +const home = homeDir() +mkdirSync(join(home, '.codex'), { recursive: true }) +mkdirSync(join(home, '.gemini', 'config'), { recursive: true }) +const result = { updated: [] as string[], warnings: [] as string[], alreadyCurrent: [] as string[] } +configureOtherHarnesses(false, result) +console.log('updated:', result.updated, 'warnings:', result.warnings) + +let failed = 0 +const check = (name: string, ok: boolean, detail: unknown) => { console.log(`${ok ? 'ok ' : 'FAIL'} ${name}: ${detail}`); if (!ok) failed++ } +const toml = readFileSync(join(home, '.codex', 'config.toml'), 'utf-8') +check('codex has datacore', toml.includes('[mcp_servers.datacore]'), '') +check('codex has plur', toml.includes('[mcp_servers.plur]'), '') +const agy = JSON.parse(readFileSync(join(home, '.gemini', 'config', 'mcp_config.json'), 'utf-8')) +check('antigravity has both', !!agy.mcpServers?.datacore && !!agy.mcpServers?.plur, Object.keys(agy.mcpServers ?? {})) +const plur = mcpServerEntries().plur! +// Strict in CI, where the workflow installs @plur-ai/mcp first; locally the +// throwaway home has no PLUR in it, so only check what could be found. +if (process.env.CI || plur.command.includes('/') || plur.command.includes('\\')) { + check('plur entry launches something that exists', existsSync(plur.command) && (!plur.args || existsSync(plur.args[0]!)), JSON.stringify(plur)) +} +if (process.platform === 'win32') check('Windows entry runs node, not a .cmd shim', /node\.exe$/i.test(plur.command), plur.command) +const again = { updated: [] as string[], warnings: [] as string[], alreadyCurrent: [] as string[] } +configureOtherHarnesses(false, again) +check('second run changes nothing', again.updated.length === 0, again.alreadyCurrent) +if (failed) process.exit(1) +console.log('harness smoke passed') diff --git a/src/help.ts b/src/help.ts index 3d486f5..71921c4 100644 --- a/src/help.ts +++ b/src/help.ts @@ -125,14 +125,20 @@ Description: All steps are idempotent — safe to run repeatedly. Steps: - 1. Git pull all repos (root + spaces) + 0. Update this CLI if npm has a newer one, then continue under it + 1. Git pull all repos (root + spaces); on a forked install, merge + datacore-one/datacore into your fork and push it (merge, never rebase; + stops without changing anything on uncommitted changes or a conflict) 2. Git pull all installed modules - 3. Install/update MCP server (npm install -g @datacore-one/mcp) - 4. Configure MCP for Claude Code/Desktop (asks on first run) - 5. Rebuild CLAUDE.md from layers - 6. Update installation snapshot + 3. Install or upgrade the MCP servers (@datacore-one/mcp, @plur-ai/mcp) + 4. Configure MCP for Claude Code/Desktop (asks on first run) and every + other installed harness: Cursor, Codex, Antigravity, Gemini CLI, Windsurf + 5. Enable the git safety hooks if they are off + 6. Rebuild CLAUDE.md from layers + 7. Update installation snapshot Options: + --skip-self-update Keep this CLI version --skip-modules Skip module updates --skip-deps Skip dependency installation --yes, -y Non-interactive (default to Claude Code) diff --git a/src/index.ts b/src/index.ts index 81a4deb..47a4f41 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { listModules, installModule, updateModules, removeModule } from './lib/m import { createSnapshot, saveSnapshot, loadSnapshot, diffSnapshot, restoreFromSnapshot, lockFileExists } from './lib/snapshot' import * as app from './lib/app' import { VERSION } from './version' +import { maybeNotifyUpdate, selfUpdate } from './lib/selfupdate' import { homeDir } from './lib/exec' @@ -144,6 +145,16 @@ async function handleMeta( break } + // Update the CLI first and let the new version do the rest: otherwise the + // update logic that runs is always the one being replaced. + if (flags['skip-self-update'] !== true) { + const code = await selfUpdate(VERSION, process.argv.slice(2), format === 'human') + if (code !== null) { + process.exitCode = code + break + } + } + const updateResult = await updateDatacore({ stream: format === 'human', skipModules: flags['skip-modules'] === true, @@ -1220,6 +1231,9 @@ async function main() { switch (parsed.type) { case 'meta': await handleMeta(parsed.command, parsed.args, parsed.flags, format) + // `update` just did the check itself; everything else gets the one-line + // notice (human terminals only, at most one registry request a day). + if (format === 'human' && parsed.command !== 'update') await maybeNotifyUpdate(VERSION) break case 'resource': diff --git a/src/lib/harness.ts b/src/lib/harness.ts new file mode 100644 index 0000000..201d63b --- /dev/null +++ b/src/lib/harness.ts @@ -0,0 +1,176 @@ +/** + * Register Datacore and PLUR with every AI harness on the machine. + * + * init wired Claude Code and Claude Desktop only, so a user working in Cursor, + * Codex or Antigravity finished a clean install with an agent that had no + * datacore_* or plur_* tools — and no error anywhere to say so. + * + * Rules, the same ones the Claude writers in upgrade.ts follow: + * - a harness is configured only if it is already installed (its config + * directory exists); nothing is installed on the user's behalf; + * - servers are ADDED when missing and never overwritten, so a user's own + * entry wins; + * - a config that does not parse is refused, never replaced. + * + * Cursor goes through .datacore/adapters/cursor/install.py, which owns the + * Cursor-specific parts (the ~40-tool cap, tool profiles, hooks) and writes a + * workspace config for ~/Data. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' +import { execFileSync, homeDir } from './exec' +import { findPython } from './python' + +export interface McpEntry { + command: string + args?: string[] +} + +export interface HarnessConfig { + name: string + label: string + /** The config file to write. */ + path: string + /** Present only when the harness is installed. */ + detect: string + format: 'json' | 'toml' +} + +export function harnessConfigs(home: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): HarnessConfig[] { + const desktopDir = platform === 'darwin' + ? join(home, 'Library', 'Application Support', 'Claude') + : platform === 'win32' + ? join(env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'Claude') + : join(home, '.config', 'Claude') + return [ + { name: 'claude-desktop', label: 'Claude Desktop', path: join(desktopDir, 'claude_desktop_config.json'), detect: desktopDir, format: 'json' }, + { name: 'codex', label: 'Codex', path: join(home, '.codex', 'config.toml'), detect: join(home, '.codex'), format: 'toml' }, + { name: 'antigravity', label: 'Antigravity', path: join(home, '.gemini', 'config', 'mcp_config.json'), detect: join(home, '.gemini', 'config'), format: 'json' }, + { name: 'gemini-cli', label: 'Gemini CLI', path: join(home, '.gemini', 'settings.json'), detect: join(home, '.gemini', 'settings.json'), format: 'json' }, + { name: 'windsurf', label: 'Windsurf', path: join(home, '.codeium', 'windsurf', 'mcp_config.json'), detect: join(home, '.codeium', 'windsurf'), format: 'json' }, + ] +} + +/** Add missing servers under `mcpServers`. Throws on a config that does not parse. */ +export function addServersJson(text: string | null, servers: Record): { text: string; added: string[] } { + const config = (text && text.trim() ? JSON.parse(text) : {}) as Record + if (typeof config !== 'object' || config === null || Array.isArray(config)) throw new Error('config is not a JSON object') + const existing = (config.mcpServers ?? {}) as Record + const added: string[] = [] + for (const [name, entry] of Object.entries(servers)) { + if (!existing[name]) { + existing[name] = entry + added.push(name) + } + } + config.mcpServers = existing + return { text: JSON.stringify(config, null, 2) + '\n', added } +} + +function tomlString(s: string): string { + return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +/** Append a `[mcp_servers.]` table per missing server; the rest is kept byte for byte. */ +export function addServersToml(text: string | null, servers: Record): { text: string; added: string[] } { + let out = text ?? '' + const added: string[] = [] + for (const [name, entry] of Object.entries(servers)) { + const header = new RegExp(`^\\[mcp_servers\\.(${name}|"${name}")\\]\\s*$`, 'm') + if (header.test(out)) continue + if (out && !out.endsWith('\n')) out += '\n' + if (out) out += '\n' + out += `[mcp_servers.${name}]\ncommand = ${tomlString(entry.command)}\n` + if (entry.args?.length) out += `args = [${entry.args.map(tomlString).join(', ')}]\n` + added.push(name) + } + return { text: out, added } +} + +/** + * On Windows an npm "binary" is a .cmd shim, and most MCP clients spawn + * without a shell, which Node refuses for .cmd files. So the entry runs node + * on the package's script directly. + */ +export function windowsMcpEntry(nodePath: string, script: string): McpEntry { + return { command: nodePath, args: [script] } +} + +/** The script a .cmd shim would run: \node_modules\\. */ +export function windowsScriptFor(npmPrefix: string, pkg: string, bin: string): string | null { + try { + const pkgDir = join(npmPrefix, 'node_modules', ...pkg.split('/')) + const manifest = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8')) as { bin?: string | Record } + const rel = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[bin] + return rel ? join(pkgDir, rel) : null + } catch { + return null + } +} + +export interface WireResult { + configured: string[] + alreadyCurrent: string[] + warnings: string[] +} + +/** + * Write `servers` into every installed harness. `servers` comes from the same + * builder the Claude Code config uses, so every harness launches the same + * binaries. + */ +export function wireHarnesses(servers: Record, dataDir: string, opts: { + home?: string + platform?: NodeJS.Platform + env?: NodeJS.ProcessEnv + cursorAdapter?: boolean + /** Harnesses configured elsewhere (Claude Desktop has its own flow in upgrade.ts). */ + skip?: string[] +} = {}): WireResult { + const home = opts.home ?? homeDir() + const platform = opts.platform ?? process.platform + const env = opts.env ?? process.env + const res: WireResult = { configured: [], alreadyCurrent: [], warnings: [] } + + for (const h of harnessConfigs(home, platform, env)) { + if (opts.skip?.includes(h.name) || !existsSync(h.detect)) continue + try { + const before = existsSync(h.path) ? readFileSync(h.path, 'utf-8') : null + const r = h.format === 'toml' ? addServersToml(before, servers) : addServersJson(before, servers) + if (r.added.length === 0) { + res.alreadyCurrent.push(h.label) + continue + } + mkdirSync(dirname(h.path), { recursive: true }) + writeFileSync(h.path, r.text) + res.configured.push(`${h.label} (${r.added.join(', ')})`) + } catch (e) { + res.warnings.push(`${h.label}: ${h.path} could not be read as ${h.format.toUpperCase()} — left untouched (${(e as Error).message})`) + } + } + + if (opts.cursorAdapter !== false) { + const cursor = wireCursor(home, dataDir) + if (cursor) (cursor.ok ? res.configured : res.warnings).push(cursor.message) + } + return res +} + +/** Cursor, through the adapter that knows its limits. Null when Cursor is absent. */ +function wireCursor(home: string, dataDir: string): { ok: boolean; message: string } | null { + if (!existsSync(join(home, '.cursor'))) return null + const adapter = join(dataDir, '.datacore', 'adapters', 'cursor', 'install.py') + if (!existsSync(adapter)) { + return { ok: false, message: 'Cursor: this installation predates the Cursor adapter — run `datacore update`, then `datacore init` again' } + } + const python = findPython(dataDir) + if (!python) return { ok: false, message: 'Cursor: no Python 3.10+ found to run the Cursor adapter' } + try { + execFileSync(python, [adapter, 'install', '--root', dataDir], { cwd: dataDir, stdio: 'pipe', timeout: 60000 }) + return { ok: true, message: `Cursor (open ${dataDir} as the workspace)` } + } catch (e) { + const err = String((e as { stderr?: Buffer }).stderr ?? (e as Error).message).trim().split('\n').pop() + return { ok: false, message: `Cursor adapter failed: ${err}` } + } +} diff --git a/src/lib/init.ts b/src/lib/init.ts index cc87306..d7b6cb8 100644 --- a/src/lib/init.ts +++ b/src/lib/init.ts @@ -976,6 +976,10 @@ function configureMcpServer(isTTY: boolean | undefined, result: InitResult): voi configureMcpForDesktop(!!isTTY, updateResult) configureMcpForCode(!!isTTY, updateResult) + // Every other harness on the machine: Cursor, Codex, Antigravity, Gemini + // CLI, Windsurf. A Cursor user's install used to finish with no tools. + const { configureOtherHarnesses } = require('./upgrade') + configureOtherHarnesses(!!isTTY, updateResult) // Transfer warnings to init result for (const w of updateResult.warnings) { diff --git a/src/lib/selfupdate.ts b/src/lib/selfupdate.ts new file mode 100644 index 0000000..ab7a01c --- /dev/null +++ b/src/lib/selfupdate.ts @@ -0,0 +1,190 @@ +/** + * Keeping an installation current: the CLI itself, the MCP servers, and the + * system repo when it is a fork. + * + * Three gaps closed here, all found 2026-09-26: + * - checkForUpdate() existed and nothing called it, so nobody was told a + * new CLI existed; + * - update installed datacore-mcp when missing and never upgraded it, and + * never touched plur-mcp; + * - update pulled `origin`, which for a forked install is the user's own + * fork. GitHub does not sync forks, so every forked install stayed at the + * version it was installed with while update said "up to date". + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { join } from 'path' +import { execFileSync, homeDir, runShell } from './exec' + +// ─── Versions ──────────────────────────────────────────────────────────────── + +function parts(v: string): number[] { + return (v.match(/\d+/g) ?? []).slice(0, 3).map(Number) +} + +export function isNewer(latest: string, current: string): boolean { + const a = parts(latest), b = parts(current) + for (let i = 0; i < 3; i++) { + if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0) + } + return false +} + +export async function latestVersion(pkg: string): Promise { + try { + const res = await fetch(`https://registry.npmjs.org/${pkg}/latest`, { signal: AbortSignal.timeout(3000) }) + if (!res.ok) return null + return ((await res.json()) as { version?: string }).version ?? null + } catch { + return null + } +} + +// ─── The once-a-day notice ─────────────────────────────────────────────────── + +const DAY_MS = 24 * 60 * 60 * 1000 + +export function shouldCheckForUpdates(lastCheckedIso: string | undefined, now: number): boolean { + const last = lastCheckedIso ? Date.parse(lastCheckedIso) : NaN + return Number.isNaN(last) || now - last >= DAY_MS +} + +export function updateNotice(current: string, latest: string): string { + return `Datacore ${latest} is available (you have ${current}) — run: datacore update` +} + +function checkStatePath(): string { + return join(homeDir(), '.datacore', 'update-check.json') +} + +/** + * Print one line when a newer CLI exists. Human terminals only, at most one + * registry request a day, never throws, never delays a command by more than + * the 3 s fetch timeout (and only on the day it checks). + */ +export async function maybeNotifyUpdate(current: string): Promise { + if (!process.stdout.isTTY || process.env.CI || process.env.DATACORE_NO_UPDATE_CHECK) return + try { + const path = checkStatePath() + let state: { checkedAt?: string; latest?: string } = {} + try { state = JSON.parse(readFileSync(path, 'utf-8')) } catch { /* first run */ } + if (shouldCheckForUpdates(state.checkedAt, Date.now())) { + const latest = await latestVersion('@datacore-one/cli') + state = { checkedAt: new Date().toISOString(), latest: latest ?? state.latest } + mkdirSync(join(homeDir(), '.datacore'), { recursive: true }) + writeFileSync(path, JSON.stringify(state) + '\n') + } + if (state.latest && isNewer(state.latest, current)) { + process.stderr.write(`\n \x1b[33m↑\x1b[0m ${updateNotice(current, state.latest)}\n`) + } + } catch { /* a notice must never break a command */ } +} + +// ─── Self-update ───────────────────────────────────────────────────────────── + +/** + * Install the latest CLI and re-run `datacore update` under it, so the rest of + * the update is done by the new code. DATACORE_SELF_UPDATED stops a loop if the + * registry and the installed version ever disagree. + * + * Returns the child's exit code when it re-ran, or null to carry on here. + */ +export async function selfUpdate(current: string, argv: string[], isTTY: boolean): Promise { + if (process.env.DATACORE_SELF_UPDATED) return null + const latest = await latestVersion('@datacore-one/cli') + if (!latest || !isNewer(latest, current)) return null + if (isTTY) console.log(` Updating the Datacore CLI ${current} → ${latest}...`) + try { + runShell('npm install -g @datacore-one/cli@latest', { stdio: isTTY ? 'inherit' : 'pipe', timeout: 300000 }) + } catch { + if (isTTY) console.log(' Could not update the CLI (continuing with this version): npm install -g @datacore-one/cli@latest') + return null + } + try { + execFileSync('datacore', argv, { stdio: 'inherit', env: { ...process.env, DATACORE_SELF_UPDATED: '1' } }) + return 0 + } catch (e) { + return (e as { status?: number }).status ?? 1 + } +} + +/** npm packages behind the MCP servers, upgraded when the registry is ahead. */ +export const MCP_PACKAGES: Array<{ pkg: string; bin: string }> = [ + { pkg: '@datacore-one/mcp', bin: 'datacore-mcp' }, + { pkg: '@plur-ai/mcp', bin: 'plur-mcp' }, +] + +export async function upgradeMcpPackages(installedVersion: (bin: string) => string | undefined, isTTY: boolean): + Promise<{ upgraded: string[]; failed: string[] }> { + const upgraded: string[] = [], failed: string[] = [] + for (const { pkg, bin } of MCP_PACKAGES) { + const have = installedVersion(bin) + if (!have) continue // not installed: install is a separate step + const latest = await latestVersion(pkg) + if (!latest || !isNewer(latest, have)) continue + try { + runShell(`npm install -g ${pkg}@latest`, { stdio: isTTY ? 'inherit' : 'pipe', timeout: 300000 }) + upgraded.push(`${bin} ${have} → ${latest}`) + } catch { + failed.push(`${bin}: npm install -g ${pkg}@latest`) + } + } + return { upgraded, failed } +} + +// ─── Fork ← upstream ───────────────────────────────────────────────────────── + +export interface MergeUpstreamResult { + status: 'no-upstream' | 'up-to-date' | 'merged' | 'dirty' | 'conflict' | 'failed' + files?: string[] + pushed?: boolean + detail?: string +} + +function git(repo: string, args: string[], timeout = 120000): { ok: boolean; out: string; err: string } { + try { + const out = execFileSync('git', args, { cwd: repo, encoding: 'utf-8', stdio: 'pipe', timeout }) + return { ok: true, out: String(out).trim(), err: '' } + } catch (e) { + const x = e as { stdout?: Buffer | string; stderr?: Buffer | string; message?: string } + return { ok: false, out: String(x.stdout ?? '').trim(), err: String(x.stderr ?? x.message ?? '').trim() } + } +} + +/** + * Merge upstream's default branch into a fork clone, then push to the fork. + * + * Merge, never rebase or reset: the user's own commits stay where they are. + * Refuses on uncommitted changes to tracked files; aborts a conflicting merge + * so the repo is exactly as it was, and names the files. The push goes + * through the pre-push hook, which scans pushes to forks of protected repos. + */ +export function mergeUpstream(repo: string): MergeUpstreamResult { + if (!existsSync(join(repo, '.git'))) return { status: 'failed', detail: 'not a git repository' } + if (!git(repo, ['remote', 'get-url', 'upstream']).ok) return { status: 'no-upstream' } + + const fetched = git(repo, ['fetch', '--quiet', 'upstream'], 300000) + if (!fetched.ok) return { status: 'failed', detail: `fetch upstream: ${fetched.err.split('\n')[0]}` } + + const branch = ['main', 'master'].find((b) => git(repo, ['rev-parse', '--verify', '--quiet', `refs/remotes/upstream/${b}`]).ok) + if (!branch) return { status: 'failed', detail: 'upstream has no main or master branch' } + const ref = `upstream/${branch}` + + if (git(repo, ['merge-base', '--is-ancestor', ref, 'HEAD']).ok) return { status: 'up-to-date' } + + if (!git(repo, ['diff', '--quiet']).ok || !git(repo, ['diff', '--cached', '--quiet']).ok) { + return { status: 'dirty', detail: 'commit or stash your changes to tracked files, then run datacore update again' } + } + + const merged = git(repo, ['merge', '--no-edit', ref]) + if (!merged.ok) { + const files = git(repo, ['diff', '--name-only', '--diff-filter=U']).out.split('\n').filter(Boolean) + git(repo, ['merge', '--abort']) + return files.length + ? { status: 'conflict', files } + : { status: 'failed', detail: merged.err.split('\n')[0] } + } + + const pushed = git(repo, ['push', '--quiet', 'origin', 'HEAD'], 300000) + return { status: 'merged', pushed: pushed.ok, detail: pushed.ok ? undefined : `push to your fork failed: ${pushed.err.split('\n').pop()}` } +} diff --git a/src/lib/upgrade.ts b/src/lib/upgrade.ts index 4a53b68..800d258 100644 --- a/src/lib/upgrade.ts +++ b/src/lib/upgrade.ts @@ -11,6 +11,8 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs' import { join } from 'path' import { execFileSync, homeDir, npmBinCandidates, runShell, which } from './exec' +import { harnessConfigs, windowsMcpEntry, windowsScriptFor, wireHarnesses, type McpEntry } from './harness' +import { mergeUpstream, upgradeMcpPackages } from './selfupdate' import { createInterface } from 'readline' import { detectPlatform, getInstallCommand, type Platform } from './platform' import { updateModules, listModules } from './module' @@ -68,6 +70,23 @@ const c = { // ─── Helpers ────────────────────────────────────────────────────────────────── +/** Point git at the repo's own safety hooks when nothing else is configured. */ +function ensureSafetyHooks(isTTY: boolean, result: UpdateResult): void { + const hooksDir = join(DATA_DIR(), '.datacore', 'githooks') + if (!existsSync(hooksDir) || !existsSync(join(DATA_DIR(), '.git'))) return + try { + execFileSync('git', ['config', 'core.hooksPath'], { cwd: DATA_DIR(), stdio: 'pipe' }) + return // set already (by init, or deliberately by the user) + } catch { /* unset: git exits 1 */ } + try { + execFileSync('git', ['config', 'core.hooksPath', '.datacore/githooks'], { cwd: DATA_DIR(), stdio: 'pipe' }) + if (isTTY) console.log(` ${c.green}✓${c.reset} safety hooks enabled ${c.dim}(core.hooksPath)${c.reset}`) + result.updated.push('Git safety hooks enabled') + } catch { + result.warnings.push('Could not enable git safety hooks: git -C ~/Data config core.hooksPath .datacore/githooks') + } +} + /** Where we install when npm's global prefix is not writable (see init.ts). */ export const FALLBACK_NPM_PREFIX = join(homeDir(), '.datacore', 'npm') @@ -92,6 +111,10 @@ export function resolveBinary(cmd: string): string | null { } catch { /* npm not answering — fall through */ } // The fallback is installed with `-g --prefix`, so it has the same layout. candidates.push(...npmBinCandidates(FALLBACK_NPM_PREFIX, cmd)) + // PLUR's own installer puts a stable launcher here (`plur init`), which is + // what its configs point at. Without it an install with a working PLUR + // reported plur-mcp as missing and wrote an entry naming a bare command. + candidates.push(join(homeDir(), '.plur', 'bin', cmd)) for (const c of candidates) { if (existsSync(c)) return c @@ -162,6 +185,25 @@ function updateRepos( const pullResults = pullAll({ stream: false }) + // A forked install's `origin` is the user's own fork, which GitHub never + // syncs, so the pull above brings in nothing from upstream. Merge upstream + // explicitly; an install cloned straight from upstream has no `upstream` + // remote and is fully served by the pull. + const up = mergeUpstream(DATA_DIR()) + if (up.status === 'merged') { + if (isTTY) console.log(` ${c.green}✓${c.reset} Data ${c.dim}(merged latest from datacore-one/datacore${up.pushed ? ', pushed to your fork' : ''})${c.reset}`) + result.updated.push('Repo: Data (merged upstream)') + if (!up.pushed && up.detail) result.warnings.push(up.detail) + } else if (up.status === 'dirty') { + if (isTTY) console.log(` ${c.yellow}⚠${c.reset} Data ${c.dim}(not updated from upstream: uncommitted changes)${c.reset}`) + result.warnings.push(`Data not updated from upstream: ${up.detail}`) + } else if (up.status === 'conflict') { + if (isTTY) console.log(` ${c.yellow}⚠${c.reset} Data ${c.dim}(upstream conflicts with your changes — nothing was changed)${c.reset}`) + result.warnings.push(`Data not updated from upstream: your changes conflict in ${up.files!.join(', ')}. Nothing was changed; resolve with: git -C ~/Data merge upstream/main`) + } else if (up.status === 'failed') { + result.warnings.push(`Data not updated from upstream: ${up.detail}`) + } + for (const r of pullResults) { if (r.success) { const upToDate = r.output.includes('Already up to date') || r.output.includes('Current branch') @@ -292,6 +334,10 @@ const MCP_BINARIES: Record = { datacore: 'datacore-mcp', plur: 'plur-mcp', } +const MCP_PACKAGE: Record = { + datacore: '@datacore-one/mcp', + plur: '@plur-ai/mcp', +} /** * The MCP entry for a server, preferring an ABSOLUTE path. @@ -306,11 +352,52 @@ const MCP_BINARIES: Record = { * An absolute path is immune to whatever PATH the MCP client happens to run * with, which is not the same PATH as the shell that ran the installer. */ -function mcpEntry(name: string): { command: string } { +function mcpEntry(name: string): McpEntry { const bin = MCP_BINARIES[name]! + if (process.platform === 'win32') { + // The resolved "binary" is an npm .cmd shim, which MCP clients spawning + // without a shell cannot launch. Run node on the package's script. + const prefixes: string[] = [FALLBACK_NPM_PREFIX] + try { + const p = execFileSync('npm', ['config', 'get', 'prefix'], { stdio: 'pipe' }).toString().trim() + if (p && p !== 'undefined') prefixes.unshift(p) + } catch { /* fall back to our own prefix */ } + for (const prefix of prefixes) { + const script = windowsScriptFor(prefix, MCP_PACKAGE[name]!, bin) + // The system node, not process.execPath: that is whatever runtime this + // CLI happens to run under (bun in the smoke test, a version manager's + // node elsewhere), and the MCP client will outlive this process. + if (script && existsSync(script)) return windowsMcpEntry(which('node') ?? process.execPath, script) + } + } return { command: resolveBinary(bin) ?? bin } } +/** Both server entries, as every harness should launch them. */ +export function mcpServerEntries(): Record { + return Object.fromEntries(Object.keys(MCP_BINARIES).map((n) => [n, mcpEntry(n)])) +} + +/** + * Codex, Antigravity, Gemini CLI, Windsurf and Cursor — whichever are + * installed. Claude Code and Claude Desktop keep their own writers above. + */ +export function configureOtherHarnesses(isTTY: boolean, result: { updated: string[]; warnings: string[]; alreadyCurrent: string[] }): void { + const r = wireHarnesses(mcpServerEntries(), DATA_DIR(), { skip: ['claude-desktop'] }) + for (const h of r.configured) { + if (isTTY) console.log(` ${c.green}✓${c.reset} ${h} configured`) + result.updated.push(`MCP configured for ${h}`) + } + for (const h of r.alreadyCurrent) { + if (isTTY) console.log(` ${c.green}✓${c.reset} ${h} ${c.dim}(already configured)${c.reset}`) + result.alreadyCurrent.push(`MCP: ${h}`) + } + for (const w of r.warnings) { + if (isTTY) console.log(` ${c.yellow}⚠${c.reset} ${w}`) + result.warnings.push(w) + } +} + /** Add any missing server to `servers`. Returns the names actually added. */ function addMissingServers(servers: Record): string[] { const added: string[] = [] @@ -335,11 +422,10 @@ export function unresolvableMcpServers(): string[] { type McpTarget = 'code' | 'desktop' | 'both' function detectClaudeDesktopConfigDir(): string | null { - const home = homeDir() - const paths = [ - join(home, 'Library', 'Application Support', 'Claude'), // macOS - join(home, '.config', 'claude'), // Linux - ] + // One source of truth for where each harness lives (this list had no + // Windows entry, so Claude Desktop was never configured there). + const desktop = harnessConfigs(homeDir(), process.platform, process.env).find((h) => h.name === 'claude-desktop')! + const paths = [desktop.detect, join(homeDir(), '.config', 'claude')] for (const dir of paths) { if (existsSync(dir)) return dir } @@ -652,6 +738,10 @@ export async function updateDatacore(options: UpdateOptions = {}): Promise commandExists(bin) ? getVersionString(bin, '--version') : undefined, isTTY) + for (const u of mcp.upgraded) result.updated.push(u) + for (const f of mcp.failed) result.warnings.push(`Could not upgrade ${f}`) const venv = ensureDatacoreVenv(DATA_DIR()) // An error, not a warning: without it the MCP server cannot start. if (venv.python) result.updated.push('Python dependencies (.datacore/venv)') @@ -661,8 +751,13 @@ export async function updateDatacore(options: UpdateOptions = {}): Promise { + it('creates the file content when there is none', () => { + const r = addServersJson(null, servers) + expect(r.added).toEqual(['datacore', 'plur']) + expect(JSON.parse(r.text).mcpServers.datacore.command).toBe('/usr/local/bin/datacore-mcp') + }) + it('keeps every other key and server, and never overwrites an existing entry', () => { + const before = JSON.stringify({ theme: 'dark', mcpServers: { other: { command: 'x' }, plur: { command: 'mine' } } }) + const r = addServersJson(before, servers) + const d = JSON.parse(r.text) + expect(r.added).toEqual(['datacore']) + expect(d.theme).toBe('dark') + expect(d.mcpServers.other.command).toBe('x') + expect(d.mcpServers.plur.command).toBe('mine') + }) + it('reports nothing added when both are present', () => { + const first = addServersJson(null, servers).text + expect(addServersJson(first, servers).added).toEqual([]) + }) + it('refuses a config that does not parse rather than overwrite it', () => { + expect(() => addServersJson('{ not json', servers)).toThrow() + }) +}) + +describe('TOML config (Codex)', () => { + it('appends a block per missing server and keeps the rest verbatim', () => { + const before = 'model = "gpt-5.4"\n\n[mcp_servers.plur]\ncommand = "mine"\n' + const r = addServersToml(before, servers) + expect(r.added).toEqual(['datacore']) + expect(r.text.startsWith(before)).toBe(true) + expect(r.text).toContain('[mcp_servers.datacore]\ncommand = "/usr/local/bin/datacore-mcp"') + expect(r.text.match(/\[mcp_servers\.plur\]/g)?.length).toBe(1) + }) + it('escapes Windows paths and writes args', () => { + const r = addServersToml('', { datacore: { command: 'C:\\Program Files\\nodejs\\node.exe', args: ['C:\\npm\\x.js'] } }) + expect(r.text).toContain('command = "C:\\\\Program Files\\\\nodejs\\\\node.exe"') + expect(r.text).toContain('args = ["C:\\\\npm\\\\x.js"]') + }) + it('is idempotent', () => { + const once = addServersToml('', servers).text + expect(addServersToml(once, servers).added).toEqual([]) + }) +}) + +describe('where each harness keeps its config', () => { + const home = '/home/ana' + const byName = (p: NodeJS.Platform, env: NodeJS.ProcessEnv = {}) => + Object.fromEntries(harnessConfigs(home, p, env).map((h) => [h.name, h.path])) + + it('knows Codex, Antigravity, Gemini CLI and Windsurf', () => { + const m = byName('linux') + expect(m['codex']).toBe(join(home, '.codex', 'config.toml')) + expect(m['antigravity']).toBe(join(home, '.gemini', 'config', 'mcp_config.json')) + expect(m['gemini-cli']).toBe(join(home, '.gemini', 'settings.json')) + expect(m['windsurf']).toBe(join(home, '.codeium', 'windsurf', 'mcp_config.json')) + }) + it('finds Claude Desktop under APPDATA on Windows (it was never looked for there)', () => { + expect(byName('win32', { APPDATA: 'C:\\Users\\ana\\AppData\\Roaming' })['claude-desktop']) + .toBe(join('C:\\Users\\ana\\AppData\\Roaming', 'Claude', 'claude_desktop_config.json')) + }) + it('finds Claude Desktop in Application Support on macOS', () => { + expect(byName('darwin')['claude-desktop']) + .toBe(join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')) + }) +}) + +describe('Windows MCP entries', () => { + it('launch node with the package script, since clients cannot spawn npm .cmd shims', () => { + expect(windowsMcpEntry('C:\\Program Files\\nodejs\\node.exe', 'C:\\npm\\node_modules\\@plur-ai\\mcp\\dist\\index.js')) + .toEqual({ command: 'C:\\Program Files\\nodejs\\node.exe', args: ['C:\\npm\\node_modules\\@plur-ai\\mcp\\dist\\index.js'] }) + }) +}) diff --git a/tests/update.test.ts b/tests/update.test.ts new file mode 100644 index 0000000..fb24539 --- /dev/null +++ b/tests/update.test.ts @@ -0,0 +1,124 @@ +/** + * `datacore update` brings a fork up to date with upstream. + * + * init forks datacore-one/datacore, clones the fork, and adds `upstream`. update + * then pulled `origin` — the user's own fork, which GitHub never syncs — so + * every forked install stayed at the version it was installed with while + * update reported "up to date". These tests use real repos: an upstream, a + * fork, and the user's clone of the fork. + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'fs' +import { execFileSync } from 'child_process' +import { tmpdir } from 'os' +import { join } from 'path' +import { mergeUpstream, isNewer, shouldCheckForUpdates, updateNotice } from '../src/lib/selfupdate' + +let dir: string +// Real repos, real fetch/merge/push: ~2 s idle, far more on a loaded machine. +const GIT_TIMEOUT = 30000 +const git = (cwd: string, ...args: string[]) => + execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'user.email=t@example.com', '-c', 'user.name=T', ...args], + { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim() + +/** upstream (bare) -> fork (bare, a copy) -> clone (the user's ~/Data). */ +function setup(): { upstreamWork: string; fork: string; clone: string } { + dir = mkdtempSync(join(tmpdir(), 'dc-cli-update-')) + const upstreamWork = join(dir, 'upstream-work') + execFileSync('git', ['init', '-q', '-b', 'main', upstreamWork]) + writeFileSync(join(upstreamWork, 'README.md'), 'v1\n') + git(upstreamWork, 'add', '.'); git(upstreamWork, 'commit', '-q', '-m', 'v1') + const upstream = join(dir, 'upstream.git') + git(dir, 'clone', '-q', '--bare', upstreamWork, upstream) + const fork = join(dir, 'fork.git') + git(dir, 'clone', '-q', '--bare', upstream, fork) + const clone = join(dir, 'Data') + git(dir, 'clone', '-q', fork, clone) + git(clone, 'remote', 'add', 'upstream', upstream) + // mergeUpstream runs plain git: give the clone an identity and no hooks, so + // the result does not depend on the machine's global config. + git(clone, 'config', 'user.email', 't@example.com') + git(clone, 'config', 'user.name', 'T') + git(clone, 'config', 'core.hooksPath', '/dev/null') + // upstream moves on after the fork was made + writeFileSync(join(upstreamWork, 'README.md'), 'v2\n') + git(upstreamWork, 'commit', '-q', '-am', 'v2') + git(upstreamWork, 'push', '-q', upstream, 'main') + return { upstreamWork, fork, clone } +} + +beforeEach(() => { dir = '' }) +afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }) }) + +describe('mergeUpstream', () => { + it('merges upstream into the fork clone and pushes it to the fork', () => { + const { fork, clone } = setup() + const r = mergeUpstream(clone) + expect(r.status).toBe('merged') + expect(readFileSync(join(clone, 'README.md'), 'utf-8')).toBe('v2\n') + expect(r.pushed).toBe(true) + expect(git(fork, 'log', '-1', '--format=%s', 'main')).toBe('v2') + }, GIT_TIMEOUT) + + it('keeps the user\'s own commits (merge, never rebase or reset)', () => { + const { clone } = setup() + writeFileSync(join(clone, 'mine.md'), 'my change\n') + git(clone, 'add', '.'); git(clone, 'commit', '-q', '-m', 'mine') + expect(mergeUpstream(clone).status).toBe('merged') + expect(existsSync(join(clone, 'mine.md'))).toBe(true) + expect(readFileSync(join(clone, 'README.md'), 'utf-8')).toBe('v2\n') + }, GIT_TIMEOUT) + + it('reports up-to-date the second time', () => { + const { clone } = setup() + mergeUpstream(clone) + expect(mergeUpstream(clone).status).toBe('up-to-date') + }, GIT_TIMEOUT) + + it('refuses when tracked files have uncommitted changes, and changes nothing', () => { + const { clone } = setup() + writeFileSync(join(clone, 'README.md'), 'editing\n') + const head = git(clone, 'rev-parse', 'HEAD') + const r = mergeUpstream(clone) + expect(r.status).toBe('dirty') + expect(git(clone, 'rev-parse', 'HEAD')).toBe(head) + expect(readFileSync(join(clone, 'README.md'), 'utf-8')).toBe('editing\n') + }, GIT_TIMEOUT) + + it('aborts a conflicting merge, leaves the repo clean, and names the files', () => { + const { clone } = setup() + writeFileSync(join(clone, 'README.md'), 'my v2\n') + git(clone, 'commit', '-q', '-am', 'mine') + const head = git(clone, 'rev-parse', 'HEAD') + const r = mergeUpstream(clone) + expect(r.status).toBe('conflict') + expect(r.files).toEqual(['README.md']) + expect(git(clone, 'rev-parse', 'HEAD')).toBe(head) + expect(git(clone, 'status', '--porcelain')).toBe('') + }, GIT_TIMEOUT) + + it('does nothing for an install cloned straight from upstream (no upstream remote)', () => { + const { clone } = setup() + git(clone, 'remote', 'remove', 'upstream') + expect(mergeUpstream(clone).status).toBe('no-upstream') + }, GIT_TIMEOUT) +}) + +describe('version check', () => { + it('compares versions numerically', () => { + expect(isNewer('2.10.0', '2.9.9')).toBe(true) + expect(isNewer('2.5.1', '2.5.1')).toBe(false) + expect(isNewer('2.5.0', '2.5.1')).toBe(false) + }) + it('checks at most once a day', () => { + const now = Date.parse('2026-09-26T12:00:00Z') + expect(shouldCheckForUpdates(undefined, now)).toBe(true) + expect(shouldCheckForUpdates('2026-09-26T01:00:00Z', now)).toBe(false) + expect(shouldCheckForUpdates('2026-09-25T11:00:00Z', now)).toBe(true) + expect(shouldCheckForUpdates('garbage', now)).toBe(true) + }) + it('names the command to run', () => { + expect(updateNotice('2.5.1', '2.6.0')).toContain('datacore update') + expect(updateNotice('2.5.1', '2.6.0')).toContain('2.6.0') + }) +})