From 2ff5c67b7a716650aadb8e4aff67b383622f5b34 Mon Sep 17 00:00:00 2001 From: plur9 Date: Sat, 26 Sep 2026 19:53:49 +0200 Subject: [PATCH 1/2] 2.6.0: update keeps forks current, self-updates, and wires every harness update: - merges datacore-one/datacore into a forked install and pushes the fork (merge, never rebase; refuses on uncommitted changes, aborts a conflict untouched and names the files). A fork's origin is never synced by GitHub, so forked installs were frozen at install time while update said "up to date". - updates the CLI first and re-runs under the new version - upgrades @datacore-one/mcp and @plur-ai/mcp when npm is ahead (they were only ever installed when missing) - enables core.hooksPath on installs from before 2.3.0 init and update register both MCP servers with every installed harness: Cursor (via the repo's adapter), Codex, Antigravity, Gemini CLI, Windsurf, plus Claude Desktop on Windows, which was never looked for. On Windows the entries run node on the package script, since clients cannot spawn .cmd. Every other command prints one line when a newer CLI exists (terminals only, one registry request a day). doctor now finds PLUR's launcher in ~/.plur/bin. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GSzwHUALnUZdoW7v21dDtr --- .github/workflows/windows.yml | 10 ++ package.json | 2 +- scripts/harness-smoke.ts | 36 +++++++ src/help.ts | 16 ++- src/index.ts | 14 +++ src/lib/harness.ts | 176 +++++++++++++++++++++++++++++++ src/lib/init.ts | 4 + src/lib/selfupdate.ts | 190 ++++++++++++++++++++++++++++++++++ src/lib/upgrade.ts | 106 +++++++++++++++++-- tests/harness.test.ts | 89 ++++++++++++++++ tests/update.test.ts | 124 ++++++++++++++++++++++ 11 files changed, 754 insertions(+), 13 deletions(-) create mode 100644 scripts/harness-smoke.ts create mode 100644 src/lib/harness.ts create mode 100644 src/lib/selfupdate.ts create mode 100644 tests/harness.test.ts create mode 100644 tests/update.test.ts 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..90cd79b 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,49 @@ 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) + if (script && existsSync(script)) return windowsMcpEntry(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 +419,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 +735,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 +748,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') + }) +}) From e1588e93ae0fa2ba2b9e64547833c1fcf85e5c0d Mon Sep 17 00:00:00 2001 From: plur9 Date: Sat, 26 Sep 2026 19:58:53 +0200 Subject: [PATCH 2/2] Windows MCP entries name the system node, not the runtime running the CLI Caught by the windows-latest harness smoke: under bun the entry named bun.exe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GSzwHUALnUZdoW7v21dDtr --- src/lib/upgrade.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/upgrade.ts b/src/lib/upgrade.ts index 90cd79b..800d258 100644 --- a/src/lib/upgrade.ts +++ b/src/lib/upgrade.ts @@ -364,7 +364,10 @@ function mcpEntry(name: string): McpEntry { } catch { /* fall back to our own prefix */ } for (const prefix of prefixes) { const script = windowsScriptFor(prefix, MCP_PACKAGE[name]!, bin) - if (script && existsSync(script)) return windowsMcpEntry(process.execPath, script) + // 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 }