Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ Actions:`)
console.log(` ${action.padEnd(18)} ${getActionDescription(resource as Resource, action)}`)
}

if (resource === 'space') {
// `space join` clones ONE space. A whole second machine (every space and
// module) is the snapshot route, which nothing used to point to.
console.log(`
Setting up another machine with ALL your spaces and modules:
on the existing machine: datacore snapshot create --settings
on the new machine: datacore init --from-snapshot datacore.lock.yaml`)
}

console.log(`
For detailed help on an action:
datacore ${resource} <action> --help
Expand Down Expand Up @@ -173,6 +182,10 @@ Options:
--skip-checks Skip dependency checks
--print-questions Print the setup questions as JSON and exit
--answers <file> Answer the questions from a JSON file
--from-snapshot <file>
Set up a second machine from a snapshot of an existing
one: clones its spaces and modules, and never creates
a template space the snapshot provides

Environment:
DATACORE_ROOT Installation directory (overrides ~/Data)
Expand All @@ -194,6 +207,11 @@ Examples:
# Custom location
datacore init --path ~/my-data

# Second machine: on the first one run
# datacore snapshot create --settings (writes ~/Data/datacore.lock.yaml)
# copy that file here, then
datacore init --from-snapshot datacore.lock.yaml

# Agent asks, then answers on the user's behalf
datacore init --print-questions > q.json
datacore init --answers answers.json`)
Expand Down Expand Up @@ -640,6 +658,9 @@ Arguments:

Options:
--settings Include base settings in snapshot
--with-registry Also include the PRIVATE registry files (repositories,
infrastructure, principals). They are gitignored, and a
second machine needs them; treat the file as private.
--format json Output as JSON

Examples:
Expand All @@ -654,8 +675,16 @@ Usage:

Description:
Restores Datacore installation from a snapshot file:
- Installs missing modules (at exact commits)
- Creates missing spaces (if git sources available)
- Installs missing modules at their snapshot commits, ON their branch
(so the next datacore update can pull them)
- Clones missing spaces (if git sources available), also on their branch
- Skips core modules: they ship inside the Datacore repo itself
- Writes private registry files the snapshot carries (--with-registry),
only where this machine has none

On a NEW machine use: datacore init --from-snapshot <file>
(restore after a plain init would find init's template 0-personal and
skip the real one)

Arguments:
[path] Snapshot file (default: ~/Data/datacore.lock.yaml)
Expand Down
60 changes: 49 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { showHelp, showResourceHelp, showActionHelp } from './help'
import { detectFormat, output, success, info, warn, error as errorLog } from './format'
import { CLIError } from './errors'
import { readFileSync } from 'fs'
import { runDoctor } from './lib/dependency'
import { runDoctor, doctorVerdict } from './lib/dependency'
import { loadConfig, getConfigValue, setConfigValue, getAllConfig } from './config'
import { listSpaces, createSpace, initSpaceGit, detectForges, createSpaceRemote, joinSpace, auditSpace }
from './lib/space'
Expand Down Expand Up @@ -68,6 +68,19 @@ async function handleMeta(
}
}

const fromSnapshot = typeof flags['from-snapshot'] === 'string' ? flags['from-snapshot'] as string : undefined
if (flags['from-snapshot'] === true) {
errorLog('--from-snapshot needs a file: datacore init --from-snapshot datacore.lock.yaml')
process.exitCode = 1
break
}
if (fromSnapshot && isInitialized() && !flags.force) {
// Already set up: restore adds what is missing without re-running setup.
info('Datacore is already set up here. To add the spaces and modules the snapshot has, run:')
info(` datacore snapshot restore ${fromSnapshot}`)
break
}

if (isInitialized() && !flags.force) {
if (format === 'json') {
output({ success: true, message: 'Datacore already initialized', hint: 'Use datacore update or --force to re-initialize' }, format)
Expand All @@ -86,6 +99,7 @@ async function handleMeta(
stream: format === 'human',
verbose: flags.verbose === true,
force: flags.force === true,
fromSnapshot,
})

if (format === 'json') {
Expand Down Expand Up @@ -217,7 +231,10 @@ async function handleMeta(
}
if (result.mcpConfig) {
console.log('MCP Server:')
console.log(` ${result.mcpConfig.claudeDesktop ? '✓' : '✗'} Claude Desktop config`)
// null: no Claude Desktop on this machine (none exists for Linux) -- nothing to check.
if (result.mcpConfig.claudeDesktop !== null) {
console.log(` ${result.mcpConfig.claudeDesktop ? '✓' : '✗'} Claude Desktop config`)
}
console.log(` ${result.mcpConfig.claudeCode ? '✓' : '✗'} Claude Code .mcp.json`)
console.log()
}
Expand All @@ -239,13 +256,23 @@ async function handleMeta(
console.log()
}

if (result.status === 'ready') {
success('System ready for Datacore')
} else if (result.status === 'missing_required') {
errorLog('Missing required dependencies')
if (result.ledger?.length) {
console.log('Ledger:')
for (const l of result.ledger) {
console.log(` ${l.ok === true ? '✓' : l.ok === false ? '✗' : '?'} ${l.name}: ${l.detail}`)
if (l.ok !== true && l.fix) console.log(` Fix: ${l.fix}`)
}
console.log()
}

const verdict = doctorVerdict(result.status)
if (verdict.level === 'ok') {
success(verdict.message)
} else if (verdict.level === 'error') {
errorLog(verdict.message)
process.exitCode = 1
} else {
warn('Missing recommended dependencies')
warn(verdict.message)
}
}
break
Expand Down Expand Up @@ -616,8 +643,10 @@ async function handleResource(
info('No modules to update')
} else {
for (const r of results) {
if (r.updated) {
success(`Updated: ${r.name}`)
if (r.core) {
info(`${r.name}: core module (updated with Datacore)`)
} else if (r.updated) {
success(`Updated: ${r.name}${r.reattached ? ` (was detached, back on ${r.reattached})` : ''}`)
} else {
warn(`${r.name}: ${r.error || 'not updated'}`)
}
Expand Down Expand Up @@ -875,10 +904,11 @@ async function handleResource(
switch (action) {
case 'create': {
const includeSettings = flags.settings === true
const includeRegistry = flags['with-registry'] === true
const outputPath = cmdArgs[0]

info('Creating snapshot...')
const snapshot = createSnapshot({ includeSettings })
const snapshot = createSnapshot({ includeSettings, includeRegistry })
const savedPath = saveSnapshot(snapshot, outputPath)

if (format === 'json') {
Expand All @@ -891,8 +921,12 @@ async function handleResource(
if (includeSettings) {
console.log(' Settings: included')
}
if (snapshot.registry) {
console.log(` Private registry: ${Object.keys(snapshot.registry).join(', ')}`)
warn('This file now holds private registry data: copy it directly, do not commit or share it')
}
console.log()
info('Share this file to replicate your setup')
info('On another machine: datacore init --from-snapshot <this file>')
}
break
}
Expand Down Expand Up @@ -937,6 +971,10 @@ async function handleResource(
errorLog(` ${name}: ${error}`)
}
}
if (result.registryWritten.length > 0) {
console.log('Registry files written:')
for (const name of result.registryWritten) success(` ${name}`)
}
if (result.spacesCreated.length > 0) {
console.log('Spaces created:')
for (const name of result.spacesCreated) {
Expand Down
62 changes: 42 additions & 20 deletions src/lib/dependency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { detectPlatform, getInstallCommand, getPlatformInfo, type Platform } fro
import { dataDir as resolveDataDir } from './paths'
import { classifyVerifyFailure, findPython, PYTHON_MIN_VERSION } from './python'
import { listSpaces } from './space'
import { harnessConfigs } from './harness'

function commandExists(cmd: string): boolean {
// Same lookup init uses, so doctor and the installer cannot disagree about
Expand Down Expand Up @@ -249,32 +250,39 @@ export function checkCodePermissions(): { enableAll: boolean; mcpAllowed: boolea
return { enableAll, mcpAllowed }
}

export function checkMcpConfig(): { claudeDesktop: boolean; claudeCode: boolean } {
// Check Claude Desktop config
const home = homeDir()
const desktopPaths = [
join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), // macOS
join(home, '.config', 'claude', 'claude_desktop_config.json'), // Linux
]

let claudeDesktop = false
for (const p of desktopPaths) {
try {
if (existsSync(p)) {
const content = JSON.parse(readFileSync(p, 'utf-8'))
if (content?.mcpServers?.datacore) {
claudeDesktop = true
break
/**
* Is Datacore wired into Claude Desktop and Claude Code?
*
* `claudeDesktop` is null when the question does not apply: there is no
* Claude Desktop for Linux, and elsewhere the app may simply not be installed.
* Reporting "✗ Claude Desktop config" on a headless server sent people
* looking for a problem that does not exist.
*/
export function checkMcpConfig(opts: { home?: string; platform?: NodeJS.Platform; dataDir?: string } = {}):
{ claudeDesktop: boolean | null; claudeCode: boolean } {
const home = opts.home ?? homeDir()
const platform = opts.platform ?? process.platform
const data = opts.dataDir ?? resolveDataDir()

let claudeDesktop: boolean | null = null
if (platform !== 'linux') {
const desktop = harnessConfigs(home, platform, process.env).find((h) => h.name === 'claude-desktop')!
if (existsSync(desktop.detect)) {
claudeDesktop = false
try {
if (existsSync(desktop.path)) {
const content = JSON.parse(readFileSync(desktop.path, 'utf-8'))
if (content?.mcpServers?.datacore) claudeDesktop = true
}
} catch {
// Ignore parse errors
}
} catch {
// Ignore parse errors
}
}

// Check Claude Code .mcp.json
// Check Claude Code .mcp.json -- in the data dir, which is not always ~/Data.
let claudeCode = false
const mcpJsonPath = join(home, 'Data', '.mcp.json')
const mcpJsonPath = join(data, '.mcp.json')
try {
if (existsSync(mcpJsonPath)) {
const content = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'))
Expand All @@ -289,6 +297,20 @@ export function checkMcpConfig(): { claudeDesktop: boolean; claudeCode: boolean
return { claudeDesktop, claudeCode }
}

/**
* The closing line of `datacore doctor`. Every non-ready status used to print
* "Missing recommended dependencies" -- including a degraded ledger, with
* every dependency present.
*/
export function doctorVerdict(status: DoctorResult['status']): { level: 'ok' | 'warn' | 'error'; message: string } {
switch (status) {
case 'ready': return { level: 'ok', message: 'System ready for Datacore' }
case 'missing_required': return { level: 'error', message: 'Missing required dependencies' }
case 'missing_recommended': return { level: 'warn', message: 'Missing recommended dependencies' }
case 'ledger_degraded': return { level: 'warn', message: 'Ledger checks failed (see Ledger above)' }
}
}

export function checkDependencies(): DependencyCheck[] {
const platform = detectPlatform()

Expand Down
94 changes: 94 additions & 0 deletions src/lib/gitbranch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Keeping clones ON a branch.
*
* `snapshot restore` used to `git checkout <commit>`, which leaves a detached
* HEAD, and every later `git pull` then fails with "You are not currently on a
* branch". On a real second-machine install that was 27 of 37 modules failing
* the first `datacore update` after a successful restore.
*
* Two operations live here: put a fresh clone on its branch at an exact commit
* (restore), and move an already-detached clone back onto its default branch
* (update) -- the second only when no commit would be left behind.
*/
import { execFileSync } from './exec'

function git(cwd: string, args: string[], timeout = 60000): { ok: boolean; out: string; err: string } {
try {
const out = execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout })
return { ok: true, out: out.trim(), err: '' }
} catch (e) {
const x = e as { stdout?: string | Buffer; stderr?: string | Buffer; message?: string }
return { ok: false, out: String(x.stdout ?? '').trim(), err: String(x.stderr ?? x.message ?? '').trim() }
}
}

/** The branch origin's HEAD points at, else main or master if origin has one. */
export function defaultBranch(path: string): string | null {
const head = git(path, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
if (head.ok && head.out.startsWith('origin/')) return head.out.slice('origin/'.length)
for (const b of ['main', 'master']) {
if (git(path, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${b}`]).ok) return b
}
return null
}

export function isDetached(path: string): boolean {
return !git(path, ['symbolic-ref', '-q', 'HEAD']).ok
}

/**
* Put a FRESH clone on `branch` (or the default branch) at `commit`, tracking
* origin. Only for a clone made a moment ago: `switch -C` moves the local
* branch, which on a fresh clone holds nothing that is not also on origin.
*/
export function checkoutOnBranch(path: string, commit: string, branch?: string): { ok: boolean; warning?: string } {
const b = branch || defaultBranch(path)
if (!b) {
const r = git(path, ['checkout', '-q', commit])
return r.ok
? { ok: true, warning: `${path}: origin has no default branch, left at ${commit.slice(0, 7)} (detached)` }
: { ok: false, warning: r.err }
}
const sw = git(path, ['switch', '-q', '-C', b, commit])
if (!sw.ok) return { ok: false, warning: sw.err }
if (git(path, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${b}`]).ok) {
git(path, ['branch', '-q', `--set-upstream-to=origin/${b}`, b])
}
return { ok: true }
}

/**
* Move a detached clone back onto its default branch so `git pull` works.
*
* Never discards work: if HEAD holds commits that are on neither the local nor
* the remote default branch, nothing is changed and the error says how to keep
* them. A dirty working tree that would be overwritten makes `git switch`
* refuse, and that refusal is passed on unchanged.
*/
export function reattach(path: string): { ok: true; switched: boolean; branch?: string } | { ok: false; error: string } {
if (!isDetached(path)) return { ok: true, switched: false }
git(path, ['fetch', '-q', 'origin'])
const b = defaultBranch(path)
if (!b) return { ok: false, error: `${path} is on a detached HEAD and origin has no default branch to return to` }

const remote = `refs/remotes/origin/${b}`
const local = `refs/heads/${b}`
const hasLocal = git(path, ['rev-parse', '--verify', '--quiet', local]).ok
const contained = git(path, ['merge-base', '--is-ancestor', 'HEAD', remote]).ok
|| (hasLocal && git(path, ['merge-base', '--is-ancestor', 'HEAD', local]).ok)
if (!contained) {
const n = git(path, ['rev-list', '--count', 'HEAD', '--not', remote, ...(hasLocal ? [local] : [])])
const count = n.ok ? n.out : 'some'
return {
ok: false,
error: `detached HEAD with ${count} commit(s) not on any branch (${b} does not contain them); nothing was changed. ` +
`Keep them with: git -C ${path} switch -c <new-branch-name>, then merge into ${b}`,
}
}

const sw = hasLocal
? git(path, ['switch', '-q', b])
: git(path, ['switch', '-q', '-c', b, '--track', `origin/${b}`])
if (!sw.ok) return { ok: false, error: `could not switch from detached HEAD to ${b}: ${sw.err.split('\n')[0]}` }
return { ok: true, switched: true, branch: b }
}
Loading
Loading