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: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ The MCP server (`@datacore-one/mcp`) is installed and configured for both Claude
| `datacore update` | Pull repos, install MCP, rebuild context |
| `datacore doctor` | Check dependencies, MCP config, system health |
| `datacore ingest <path>` | Import files with AI processing |
| `datacore join --prepare <machine>` | On your existing machine: register a new machine and write its join bundle |
| `datacore join <bundle>` | On the new machine: mirror the existing install (see below) |

### Admin

Expand All @@ -67,6 +69,37 @@ The MCP server (`@datacore-one/mcp`) is installed and configured for both Claude
| `datacore snapshot create` | Create installation snapshot |
| `datacore snapshot restore` | Restore from snapshot |

### A second machine: `datacore join`

One command makes a new machine a mirror of your existing installation: the
same person, the same access, so sessions there behave the same.

```bash
# on the existing machine
datacore join --prepare dayshift # registers it, writes ~/.datacore/join/dayshift.join.yaml
scp ~/.datacore/join/dayshift.join.yaml dayshift:

# on the new machine
npx @datacore-one/cli join ~/dayshift.join.yaml
```

`--prepare` adds the machine to your private roster with its own ledger actor
(refusing one another machine already writes as) and to your principal's
hosts and writers, then writes one bundle (mode 600): the snapshot with the
private registry and the gitignored local files (`*.local.yaml` config,
`install.yaml` roles, `CLAUDE.local.md` layers). Every carried file is scanned
for secrets; a hit stops it before anything changes.

`join <bundle>` sets up root, spaces and modules from the snapshot, lands the
local files without overwriting any the machine already has (the rules of
`local_files_sync.py`), registers the machine, writes
`~/.datacore/identity.env`, and joins the credential store. Credentials are
granted on the store side only: the new machine prints its public key and
the steps for you, waits, then clones the store and runs `creds sync` and
`creds doctor`. It ends with checks (identity, branches, clean spaces,
`datacore doctor`) and a list of anything left to do. Running it again
continues where it stopped.

### Automation

| Command | Description |
Expand Down
82 changes: 78 additions & 4 deletions src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Usage:

Setup:
init Set up a new Datacore installation
join Make this machine a mirror of your existing install
update Pull repos, modules, install MCP, rebuild context
doctor Check dependencies and system status
ingest <path> Import files during setup
Expand Down Expand Up @@ -65,6 +66,10 @@ export function showResourceHelp(resource: string): void {
showInitHelp()
return
}
if (resource === 'join') {
showJoinHelp()
return
}
if (resource === 'update') {
showUpdateHelp()
return
Expand All @@ -89,7 +94,7 @@ export function showResourceHelp(resource: string): void {
if (!RESOURCES.includes(resource as Resource)) {
console.log(`Unknown command: ${resource}

Available commands: init, update, doctor, ingest, sync, today, tomorrow, space, module, config, nightshift, cron, snapshot, app
Available commands: init, join, update, doctor, ingest, sync, today, tomorrow, space, module, config, nightshift, cron, snapshot, app

Run 'datacore help' for overview.`)
return
Expand All @@ -111,9 +116,12 @@ Actions:`)
// `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`)
Setting up another machine as a mirror of this one (spaces, modules,
private registry, local config, identity, credentials):
on the existing machine: datacore join --prepare <new-machine>
on the new machine: datacore join <bundle>
Spaces and modules only: datacore snapshot create --settings, then
datacore init --from-snapshot datacore.lock.yaml`)
}

console.log(`
Expand All @@ -122,6 +130,72 @@ For detailed help on an action:
datacore help ${resource} <action>`)
}

function showJoinHelp(): void {
console.log(`datacore join - Make a new machine a mirror of your existing installation

Usage:
datacore join --prepare <new-machine> [options] (on the EXISTING machine)
datacore join <bundle> [options] (on the NEW machine)

The same person, the same access: sessions on the new machine behave as they
do on the existing one.

On the existing machine, --prepare:
- adds the new machine to the private roster (.datacore/registry/
infrastructure.yaml: kind workstation, ssh alias, its own ledger actor)
and to the owner's hosts and writes_as in principals.yaml
- refuses an actor another machine already writes as: two machines writing
as one actor fork that writer's log
- writes ONE bundle file (mode 600): a snapshot with the private registry,
plus the gitignored local files (.datacore/config/*.local.yaml, the jobs
manifest.local.yaml, install.yaml roles, CLAUDE.local.md at the root and in
each space). Every carried file is scanned; a line that looks like a secret
stops it, and nothing is changed.
- prints the one command to run on the new machine

On the new machine, join <bundle>:
1. init --from-snapshot: root, spaces and modules, each on its branch
2. the local files, with the rules of .datacore/lib/local_files_sync.py
(read from the installation when it has it): a file this machine already
has is never overwritten; roster files gain only keys they lack
3. the same registration here, and ~/.datacore/identity.env
(DATACORE_ACTOR=<actor>); refuses if this machine already writes as
someone else
4. the credential store: this machine cannot grant itself access. It prints
its ssh public key and what the owner must do on the store side (copy
the source machine's instances/<name>.yaml, push, authorise the key),
waits, then clones the store and runs its install, creds sync and
creds doctor. No secret value is ever printed.
5. checks: identity, every repo on its branch, every space clean,
datacore doctor; then a summary of what is done and what is left

Running join again continues where it stopped.

Options (--prepare):
--actor <name> Ledger actor for the new machine (default: its name,
which should be its short hostname)
--owner <principal> Whose machine it is (default: the principal whose
decision is final, else the only human)
--output <file> Where to write the bundle
(default: ~/.datacore/join/<new-machine>.join.yaml)
--dry-run Check everything, change and write nothing

Options (join <bundle>):
--yes / --answers <file> Passed to init (see datacore init --help)
--no-wait Do not wait for the owner's credential steps; exit 2
and run the same command again later
--format json Machine-readable result

Exit codes: 0 joined, 1 refused or failed, 2 waiting for the owner.

Examples:
# on the existing machine
datacore join --prepare dayshift
scp ~/.datacore/join/dayshift.join.yaml dayshift:
# on the new machine
npx @datacore-one/cli join ~/dayshift.join.yaml`)
}

function showUpdateHelp(): void {
console.log(`datacore update - Update your Datacore installation

Expand Down
145 changes: 145 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,11 @@ async function handleMeta(
break
}

case 'join': {
await handleJoin(cmdArgs, flags, format)
break
}

case 'doctor': {
const result = runDoctor()
if (format === 'json') {
Expand Down Expand Up @@ -421,6 +426,146 @@ async function handleMeta(
}
}

/**
* `datacore join --prepare <name>` on the existing machine, then
* `datacore join <bundle>` on the new one. See src/lib/join.ts.
*/
async function handleJoin(
cmdArgs: string[],
flags: Record<string, string | boolean>,
format: 'json' | 'human'
): Promise<void> {
const { prepareJoin, runJoin, JoinRefusal } = await import('./lib/join')
const str = (v: string | boolean | undefined) => (typeof v === 'string' ? v : undefined)
try {
if (flags.prepare !== undefined) {
if (flags.prepare === true) {
errorLog("--prepare needs the new machine's name: datacore join --prepare <new-machine>")
process.exitCode = 1
return
}
const r = prepareJoin({
name: flags.prepare as string,
actor: str(flags.actor),
owner: str(flags.owner),
output: str(flags.output),
dryRun: flags['dry-run'] === true,
})
if (format === 'json') {
output(r, format)
return
}
const dry = flags['dry-run'] === true
success(dry ? `Dry run: nothing written. The bundle would go to ${r.path}` : `Join bundle written: ${r.path} (mode 600, no secrets)`)
console.log(` Machine: ${r.machine.name} actor: ${r.machine.actor}${r.machine.owner ? ` owner: ${r.machine.owner}` : ''}`)
if (r.registered.length) {
console.log(` ${dry ? 'Would register' : 'Registered'} on this machine:`)
for (const c of r.registered) console.log(` + ${c}`)
} else {
console.log(' Already registered on this machine.')
}
for (const n of r.notes) warn(n)
console.log(` Local files carried: ${r.files.length} (rules from ${r.rulesFrom})`)
for (const s of r.skipped) console.log(` - not carried: ${s}`)
if (r.ownerSteps.length) {
console.log()
console.log('Credentials: the new machine will print its key and wait for you to do this:')
for (const l of r.ownerSteps) console.log(` ${l}`)
}
console.log()
console.log('Copy the bundle to the new machine, then run there:')
console.log(` ${r.copyHint}`)
console.log(` ${r.command}`)
console.log()
info('Delete the bundle from both machines once the join is done: it holds your private registry.')
return
}

const bundlePath = cmdArgs[0]
if (!bundlePath) {
throw new CLIError('ERR_INVALID_ARGUMENT', 'Missing bundle file',
'On the existing machine: datacore join --prepare <new-machine>; then here: datacore join <bundle>')
}
let answers: InitAnswers | undefined
const answersPath = str(flags.answers)
if (answersPath) {
try {
answers = JSON.parse(readFileSync(answersPath, 'utf-8')) as InitAnswers
} catch (e) {
errorLog(`Could not read answers file ${answersPath}: ${(e as Error).message}`)
process.exitCode = 1
return
}
}
const canWait = format === 'human' && process.stdin.isTTY === true && flags['no-wait'] !== true
let shown = false
const r = await runJoin(bundlePath, {
initialized: isInitialized,
init: async (lockPath) => {
const res = await initDatacore({
fromSnapshot: lockPath,
nonInteractive: flags.yes === true,
answers,
skipChecks: flags['skip-checks'] === true,
stream: format === 'human',
verbose: flags.verbose === true,
})
return { ok: res.success, errors: res.errors, warnings: res.warnings }
},
doctor: () => {
const v = doctorVerdict(runDoctor().status)
return { ok: v.level === 'ok', message: v.message }
},
waitForOwner: canWait
? async (cred) => {
console.log()
if (!shown) {
warn(`Credentials: ${cred.detail}`)
for (const l of cred.ownerSteps) console.log(` ${l}`)
shown = true
} else {
warn(`Still waiting: ${cred.detail}`)
}
await prompt('Press Enter once that is done (Ctrl-C stops; the same join command continues later)')
return true
}
: undefined,
})

if (format === 'json') {
output(r, format)
} else {
console.log()
console.log(`Joined as ${r.machine.name} (actor ${r.machine.actor}):`)
for (const d of r.done) console.log(` ✓ ${d}`)
for (const w of r.warnings) warn(w)
for (const e of r.errors) errorLog(e)
if (r.checks.length) {
console.log()
console.log('Checks:')
for (const c of r.checks) console.log(` ${c.ok ? '✓' : '✗'} ${c.name}: ${c.detail}`)
}
console.log()
if (r.ownerTodo.length) {
console.log('Still to do:')
for (const t of r.ownerTodo) console.log(` ${t}`)
} else {
success('This machine mirrors your installation. Nothing left to do.')
info(`Delete the bundle now: rm ${bundlePath}`)
}
}
process.exitCode = r.ok ? 0 : r.credentials.status === 'waiting-for-owner' ? 2 : 1
} catch (e) {
if (e instanceof JoinRefusal) {
if (format === 'json') output({ ok: false, refused: e.message }, format)
else errorLog(`Refused: ${e.message}`)
process.exitCode = 1
return
}
throw e
}
}

async function handleResource(
resource: string,
action: string,
Expand Down
Loading
Loading