@ours.network/install 1.2.1-nightly.2 → 1.2.1-nightly.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ import * as fs from 'node:fs';
2
+ import * as net from 'node:net';
3
+ import { homedir } from 'node:os';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { dirname, join, resolve, isAbsolute, sep } from 'node:path';
6
+ import { installationPaths } from './plan.mjs';
7
+ import { copyPrivateTree, scanSource } from '../assets/scripts/maintenance/state-archive.mjs';
8
+ import { publishNoReplace } from '../assets/scripts/maintenance/state-native.mjs';
9
+
10
+ const MARKER = '.ours-legacy-import.json';
11
+ const uid = () => process.getuid();
12
+ const ownership = () => ({ uid: uid(), gid: process.getgid() });
13
+ function canonical(path) {
14
+ if (typeof path !== 'string' || !isAbsolute(path) || resolve(path) !== path) throw Error('Legacy migration requires normalized absolute paths');
15
+ let cursor = path;
16
+ while (true) {
17
+ try { if (fs.realpathSync(cursor) !== cursor) throw Error('Legacy migration refuses symlink paths'); return path; }
18
+ catch (error) { if (error.code !== 'ENOENT') throw error; const parent = dirname(cursor); if (parent === cursor) throw error; cursor = parent; }
19
+ }
20
+ }
21
+ function privatePath(path, directory = false) {
22
+ canonical(path); const stat = fs.lstatSync(path);
23
+ if (!(directory ? stat.isDirectory() : stat.isFile()) || stat.uid !== uid()
24
+ || (stat.mode & 0o7777) !== (directory ? 0o700 : 0o600) || (!directory && stat.nlink !== 1))
25
+ throw Error('Legacy migration requires private owner-only regular files and directories');
26
+ return stat;
27
+ }
28
+ function objectFile(path) {
29
+ privatePath(path); const value = JSON.parse(fs.readFileSync(path, 'utf8'));
30
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('Legacy configuration must be an object');
31
+ return value;
32
+ }
33
+ const contains = (parent, child) => parent === child || child.startsWith(parent + sep);
34
+ function inspectRoot(stateDir) {
35
+ const root = objectFile(join(stateDir, 'root.json'));
36
+ if (root.v !== 1 || typeof root.name !== 'string' || !root.name || root.name !== root.name.trim()
37
+ || root.name.normalize('NFC') !== root.name || root.name.length > 64 || /[\x00-\x1f\x7f/\\]/.test(root.name)
38
+ || ['.', '..', 'contact-book', 'root.json', 'bindings.json'].includes(root.name)) throw Error('Legacy root marker is invalid');
39
+ privatePath(join(stateDir, root.name), true);
40
+ for (const file of ['identity.key', 'state_data.bin']) if (privatePath(join(stateDir, root.name, file)).size === 0) throw Error('Legacy root identity is incomplete');
41
+ // CID and hierarchy are packet facts, not fields in root.json. The caller
42
+ // must compare authenticated identity-list results before/after activation.
43
+ return root.name;
44
+ }
45
+ export function inspectLegacyState(configPath, targetRoot) {
46
+ canonical(configPath); canonical(targetRoot);
47
+ const config = objectFile(configPath);
48
+ const defaultState = join(homedir(), '.ours');
49
+ if (config.stateDir === undefined && configPath !== join(defaultState, 'config.json')) throw Error('Custom legacy config requires explicit stateDir');
50
+ const stateDir = config.stateDir === undefined ? canonical(defaultState) : canonical(config.stateDir);
51
+ privatePath(stateDir, true);
52
+ if (contains(stateDir, targetRoot) || contains(targetRoot, stateDir) || contains(targetRoot, configPath)) throw Error('Legacy source and target roots must be disjoint');
53
+ if (config.database && (typeof config.database !== 'object' || Array.isArray(config.database)
54
+ || (config.database.provider !== undefined && config.database.provider !== 'sqlite') || config.database.url))
55
+ throw Error('Legacy migration does not support external or non-SQLite history');
56
+ const entries = scanSource(stateDir, ownership());
57
+ if (entries.some(entry => entry.name.split('/').includes('.ours-provenance'))) throw Error('Legacy unmanaged migration does not support existing build provenance');
58
+ if (entries.some(entry => entry.name.endsWith('/history-postgresql.json'))) throw Error('Legacy migration cannot copy external PostgreSQL history');
59
+ if (fs.existsSync(join(stateDir, '.mcp'))) privatePath(join(stateDir, '.mcp'), true);
60
+ if (config.networkMcp !== undefined) {
61
+ const mcp = config.networkMcp;
62
+ if (!mcp || typeof mcp !== 'object' || Array.isArray(mcp)
63
+ || mcp.applicationConfigPath !== join(stateDir, '.mcp/config.json')
64
+ || !mcp.profile || mcp.profile.credentialPath !== join(stateDir, 'daemon-token'))
65
+ throw Error('Legacy migration requires embedded MCP configuration and credentials; external MCP paths are unsupported');
66
+ }
67
+ if (config.apiTokenDeliveryFiles !== undefined && (!Array.isArray(config.apiTokenDeliveryFiles)
68
+ || config.apiTokenDeliveryFiles.some(path => path !== join(stateDir, 'daemon-token'))))
69
+ throw Error('Legacy migration does not support external token delivery paths');
70
+ return { configPath, stateDir, config, rootCid: null, rootName: inspectRoot(stateDir) };
71
+ }
72
+ const receipt = (source, record) => ({ sourceStateDir: source.stateDir, sourceConfigPath: source.configPath, targetRoot: record.root, rootName: source.rootName });
73
+ function writePrivate(path, value) {
74
+ fs.writeFileSync(path, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); fs.chmodSync(path, 0o600);
75
+ }
76
+
77
+ function publishLegacyMcp(source, destination, expected, profile) {
78
+ canonical(destination);
79
+ if (fs.existsSync(destination)) {
80
+ privatePath(destination, true);
81
+ if (fs.existsSync(join(destination, MARKER))) {
82
+ if (JSON.stringify(objectFile(join(destination, MARKER))) !== JSON.stringify(expected)) throw Error('Legacy MCP destination receipt mismatch');
83
+ return;
84
+ }
85
+ if (fs.readdirSync(destination).length) throw Error('Legacy MCP destination must be absent or empty');
86
+ }
87
+ const staging = join(dirname(destination), `.legacy-mcp-${randomUUID()}`);
88
+ try {
89
+ const embedded = join(source.stateDir, '.mcp');
90
+ if (fs.existsSync(embedded)) copyPrivateTree(embedded, staging, ownership());
91
+ else fs.mkdirSync(staging, { mode: 0o700 });
92
+ writePrivate(join(staging, 'profile.json'), profile);
93
+ writePrivate(join(staging, MARKER), expected);
94
+ scanSource(staging, ownership());
95
+ if (fs.existsSync(destination)) fs.rmdirSync(destination);
96
+ publishNoReplace(staging, destination);
97
+ } finally { fs.rmSync(staging, { recursive: true, force: true }); }
98
+ }
99
+
100
+ // Caller excludes source writers and owns installation exclusion through activation.
101
+ export function stageLegacyState(source, record) {
102
+ if (record.schema !== 2 || !['packages', 'docker'].includes(record.mode)
103
+ || !Number.isInteger(record.port) || record.port < 1 || record.port > 65535) throw Error('Unsupported legacy migration destination');
104
+ const fresh = inspectLegacyState(source.configPath, record.root);
105
+ if (fresh.stateDir !== source.stateDir || fresh.rootName !== source.rootName) throw Error('Legacy source selection changed');
106
+ const paths = installationPaths(record);
107
+ const destination = paths.daemon, parent = dirname(destination);
108
+ canonical(destination); privatePath(parent, true);
109
+ const expected = receipt(fresh, record);
110
+ if (fs.existsSync(destination)) {
111
+ privatePath(destination, true);
112
+ if (fs.existsSync(join(destination, MARKER))) {
113
+ const saved = objectFile(join(destination, MARKER));
114
+ if (JSON.stringify(saved) !== JSON.stringify(expected) || inspectRoot(destination) !== fresh.rootName) throw Error('Legacy destination receipt does not match selected source');
115
+ const mcpReceipt = objectFile(join(paths.mcp, MARKER));
116
+ if (JSON.stringify(mcpReceipt) !== JSON.stringify(expected)) throw Error('Legacy MCP destination receipt does not match selected source');
117
+ return { ...expected, destination, reused: true };
118
+ }
119
+ if (fs.readdirSync(destination).length) throw Error('Legacy destination must be absent or empty');
120
+ }
121
+ const staging = join(parent, `.legacy-${randomUUID()}`);
122
+ try {
123
+ copyPrivateTree(fresh.stateDir, staging, ownership());
124
+ // Preserve the untouched source as the rollback copy. Only copied PID hints
125
+ // are discarded; opaque identities, authority, SQLite sidecars all survive.
126
+ for (const name of ['ours-cli-daemon.json', 'daemon.pid', 'startup-progress.json']) fs.rmSync(join(staging, name), { force: true });
127
+ const config = structuredClone(fresh.config);
128
+ config.stateDir = record.mode === 'docker' ? '/var/lib/ours' : destination;
129
+ config.port = record.mode === 'docker' ? 3050 : record.port; config.apiVisibility = 'owner';
130
+ delete config.apiToken; delete config.apiTokenDeliveryFiles;
131
+ config.networkMcp = {
132
+ profile: { endpoint: `http://127.0.0.1:${config.port}`, expectedInstanceId: record.instanceId, credentialPath: join(config.stateDir, 'daemon-token') },
133
+ applicationConfigPath: record.mode === 'docker' ? '/var/lib/ours-mcp/config.json' : join(paths.mcp, 'config.json'),
134
+ };
135
+ // Publish MCP separately before daemon publication. Its receipt allows a
136
+ // retry after an interrupted second rename, without overwriting live data.
137
+ publishLegacyMcp(fresh, paths.mcp, expected, config.networkMcp.profile);
138
+ writePrivate(join(staging, 'config.json'), config); writePrivate(join(staging, MARKER), expected);
139
+ scanSource(staging, ownership());
140
+ if (fs.existsSync(destination)) fs.rmdirSync(destination); // succeeds only for the empty skeleton
141
+ publishNoReplace(staging, destination);
142
+ return { ...expected, destination, reused: false };
143
+ } finally { fs.rmSync(staging, { recursive: true, force: true }); }
144
+ }
145
+
146
+ let Database;
147
+ export async function ensureLegacyLockSupport() {
148
+ if (!['linux', 'darwin'].includes(process.platform) || typeof process.getuid !== 'function') throw Error('Legacy migration requires Linux/macOS source locking');
149
+ try { ({ DatabaseSync: Database } = await import('node:sqlite')); }
150
+ catch { throw Error('Legacy migration requires Node with node:sqlite support; upgrade Node before stopping the source daemon'); }
151
+ }
152
+
153
+ // Protocol adapted from SDK src/internal/state-root-lock.ts: permanent SQLite
154
+ // guard serializes stale socket probe/unlink/rebind; socket ownership lasts for
155
+ // the callback. Never replace the guard inode or unlink on ambiguous probes.
156
+ function lockDirectory(path) {
157
+ try { fs.mkdirSync(path, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
158
+ privatePath(path, true);
159
+ }
160
+ function probe(path) {
161
+ return new Promise(resolveProbe => {
162
+ const socket = net.createConnection(path); let finished = false;
163
+ const done = result => { if (finished) return; finished = true; socket.destroy(); resolveProbe(result); };
164
+ socket.setTimeout(1000);
165
+ socket.once('connect', () => done('live'));
166
+ socket.once('timeout', () => done('ambiguous'));
167
+ socket.once('error', error => done(['ECONNREFUSED', 'ENOENT'].includes(error.code) ? 'stale' : 'ambiguous'));
168
+ });
169
+ }
170
+ async function socketLock(stateDir, path) {
171
+ for (let attempt = 0; attempt < 8; attempt++) {
172
+ const server = net.createServer(socket => { socket.on('error', () => {}); socket.end(JSON.stringify({ pid: process.pid, stateDir, startedAt: new Date().toISOString() }) + '\n'); });
173
+ try {
174
+ await new Promise((accept, reject) => { server.once('error', reject); server.listen(path, () => { server.removeListener('error', reject); accept(); }); });
175
+ server.unref(); return server;
176
+ } catch (error) {
177
+ try { server.close(); } catch { /* not listening */ }
178
+ if (error.code !== 'EADDRINUSE') throw error;
179
+ const result = await probe(path);
180
+ if (result !== 'stale') throw Error(`Legacy source lock is ${result}; refusing to remove a potentially live owner`);
181
+ try { fs.unlinkSync(path); } catch (failure) { if (failure.code !== 'ENOENT') throw failure; }
182
+ }
183
+ }
184
+ throw Error('Could not acquire legacy source lock');
185
+ }
186
+ export async function withLegacyStateLock(stateDir, callback) {
187
+ await ensureLegacyLockSupport(); canonical(stateDir); privatePath(stateDir, true);
188
+ const base = `/tmp/ours-${uid()}`; lockDirectory(base); lockDirectory(join(base, 'locks'));
189
+ const path = join(base, 'locks', createHash('sha256').update(stateDir).digest('hex') + '.sock');
190
+ if (Buffer.byteLength(path) > 103) throw Error('Legacy lock socket path exceeds portable limit');
191
+ const guardPath = path + '.guard';
192
+ try { const fd = fs.openSync(guardPath, fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600); fs.closeSync(fd); }
193
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
194
+ const before = privatePath(guardPath), guard = new Database(guardPath); let server;
195
+ try {
196
+ const after = privatePath(guardPath);
197
+ if (before.dev !== after.dev || before.ino !== after.ino || before.mode !== after.mode || before.uid !== after.uid) throw Error('Legacy acquisition guard changed');
198
+ guard.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE'); server = await socketLock(stateDir, path);
199
+ } finally { guard.close(); }
200
+ let closed = false;
201
+ const close = async () => { if (closed) return; closed = true; await new Promise(resolveClose => server.close(resolveClose)); };
202
+ if (callback === undefined) return { stateDir, close };
203
+ try { return await callback(); }
204
+ finally { await close(); }
205
+ }
@@ -0,0 +1,164 @@
1
+ /** Replace only the verified, user-owned npm CLI entry after managed cutover. */
2
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+
7
+ const marker = '// ours-managed-cli-v1 ';
8
+ const backupName = 'managed-cli-original.json';
9
+ const sha = bytes => createHash('sha256').update(bytes).digest('hex');
10
+ const uid = () => process.getuid();
11
+ function absolute(path) {
12
+ if (typeof path !== 'string' || !isAbsolute(path) || resolve(path) !== path || /[\r\n\0]/.test(path)) throw new Error('Managed CLI requires normalized absolute paths');
13
+ return path;
14
+ }
15
+ function owned(path, { directory = false, writable = false } = {}) {
16
+ const stat = lstatSync(path);
17
+ if (stat.uid !== uid() || (directory ? !stat.isDirectory() : (!stat.isFile() || stat.nlink !== 1)) || (stat.mode & 0o022)) throw new Error(`Managed CLI refuses foreign or unsafe ownership: ${path}`);
18
+ if (directory && realpathSync(path) !== path) throw new Error('Managed CLI directory must be canonical');
19
+ if (writable) {
20
+ if (!(stat.mode & 0o200)) throw new Error(`Managed CLI path is read-only: ${path}`);
21
+ accessSync(path, constants.W_OK | (directory ? constants.X_OK : 0));
22
+ }
23
+ return stat;
24
+ }
25
+ function snapshot(path) {
26
+ const stat = lstatSync(path);
27
+ if (stat.uid !== uid() || (!stat.isSymbolicLink() && (!stat.isFile() || stat.nlink !== 1))) throw new Error('Managed CLI entry is not an owned file or symlink');
28
+ if (!stat.isSymbolicLink() && (stat.mode & 0o022)) throw new Error('Managed CLI entry is writable by another user');
29
+ return { kind: stat.isSymbolicLink() ? 'symlink' : 'file', dev: stat.dev, ino: stat.ino, uid: stat.uid, mode: stat.mode & 0o7777,
30
+ size: stat.size, mtimeMs: stat.mtimeMs, ...(stat.isSymbolicLink() ? { link: readlinkSync(path), resolved: realpathSync(path) } : {}), digest: sha(readFileSync(path)) };
31
+ }
32
+ function readBackup(targetRoot) {
33
+ const path = join(targetRoot, 'legacy-backup', backupName);
34
+ owned(dirname(path), { directory: true }); owned(path);
35
+ const value = JSON.parse(readFileSync(path, 'utf8'));
36
+ if (value.schema !== 1 || value.targetRoot !== targetRoot || !value.original || typeof value.originalProgram !== 'string') throw new Error('Invalid managed CLI backup');
37
+ return value;
38
+ }
39
+
40
+ export function buildManagedCli(recordPath, installerPath) {
41
+ absolute(recordPath); absolute(installerPath);
42
+ const binding = { schema: 1, targetRoot: dirname(recordPath), recordPath, installerPath };
43
+ return `#!/usr/bin/env node
44
+ ${marker}${JSON.stringify(binding)}
45
+ 'use strict';
46
+ const fs = require('node:fs');
47
+ const path = require('node:path');
48
+ const { spawnSync } = require('node:child_process');
49
+ const binding = ${JSON.stringify(binding)};
50
+ function fail(message) { console.error('ours: ' + message); process.exit(2); }
51
+ const args = process.argv.slice(2);
52
+ if (args.some(arg => /^(?:--config|--state-dir|--port|--daemon-id|--daemon-url|--endpoint|--credential-path)(?:=|$)/.test(arg))) fail('This command belongs to the managed installation. Use ours-install to select or manage another installation.');
53
+ let record;
54
+ try {
55
+ const stat = fs.lstatSync(binding.recordPath);
56
+ if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid() || (stat.mode & 0o077) || fs.realpathSync(binding.recordPath) !== binding.recordPath) throw new Error('unsafe record');
57
+ record = JSON.parse(fs.readFileSync(binding.recordPath, 'utf8'));
58
+ } catch { fail('The managed installation record is missing or unsafe; repair it with ours-install.'); }
59
+ if (record.schema !== 2 || record.root !== binding.targetRoot || !['packages', 'docker'].includes(record.mode)
60
+ || typeof record.instanceId !== 'string' || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(record.instanceId)
61
+ || !Number.isInteger(record.port) || record.port < 1 || record.port > 65535
62
+ || record.workDir !== path.join(record.root, 'runtime') || record.configPath !== path.join(record.root, 'storage/state/daemon/config.json')
63
+ || (record.mode === 'docker' && (typeof record.project !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/.test(record.project)))) fail('The managed installation selection is invalid.');
64
+ const env = { ...process.env };
65
+ for (const key of Object.keys(env)) if (key.startsWith('OURS_')) delete env[key];
66
+ const lifecycle = args[0] === 'daemon' && ['start', 'stop', 'restart', 'status'].includes(args[1]);
67
+ const version = args[0] === 'version' || args[0] === '--version' || args[0] === '-V';
68
+ let command, commandArgs;
69
+ if (lifecycle) {
70
+ if (args.slice(2).some(arg => arg !== '--json') || (args[1] !== 'status' && args.includes('--json'))) fail('Use ours-install server for lifecycle options; --json is supported for status only.');
71
+ command = process.execPath;
72
+ commandArgs = [binding.installerPath, 'server', args[1], '--state-dir', record.root];
73
+ } else {
74
+ if (args[0] === 'daemon') fail('Use ours-install server to manage this installation and its services.');
75
+ if (args[0] === 'config' && args[1] !== 'show') fail('Use ours-install server to manage configuration and credentials.');
76
+ if (record.mode === 'docker') {
77
+ command = 'docker';
78
+ commandArgs = ['exec', '-i', record.project + '-daemon-1', 'node', '/opt/ours/node_modules/@ours.network/cli/dist/cli.js', ...args];
79
+ if (!version) commandArgs.push('--config', '/var/lib/ours/config.json', '--state-dir', '/var/lib/ours');
80
+ } else {
81
+ command = path.join(record.workDir, 'node_modules/.bin/ours');
82
+ commandArgs = [...args];
83
+ if (!version) commandArgs.push('--config', record.configPath, '--state-dir', path.join(record.root, 'storage/state/daemon'));
84
+ Object.assign(env, { OURS_CONFIG: record.configPath, OURS_STATE_DIR: path.join(record.root, 'storage/state/daemon'), OURS_PORT: String(record.port), OURS_DAEMON_ID: record.instanceId });
85
+ }
86
+ }
87
+ const result = spawnSync(command, commandArgs, { stdio: 'inherit', env });
88
+ if (result.error) fail('Could not start the managed command: ' + result.error.message);
89
+ if (result.signal) { process.kill(process.pid, result.signal); process.exit(1); }
90
+ process.exit(result.status ?? 1);
91
+ `;
92
+ }
93
+
94
+ export async function inspectManagedCli(effects, targetRoot) {
95
+ if (!['linux', 'darwin'].includes(effects.platform?.platform ?? process.platform) || typeof process.getuid !== 'function') throw new Error('Managed CLI cutover supports Linux/macOS only');
96
+ absolute(targetRoot);
97
+ const output = async args => {
98
+ const result = await effects.run(args[0], args.slice(1));
99
+ if (result.code !== undefined && result.code !== 0) throw new Error('Cannot determine the existing global CLI selection');
100
+ return absolute(result.stdout.trim());
101
+ };
102
+ const npmRoot = await output(['npm', 'root', '--global']);
103
+ const prefix = await output(['npm', 'prefix', '--global']);
104
+ const binPath = join(prefix, 'bin', 'ours');
105
+ const selected = await output(['which', 'ours']);
106
+ if (selected !== binPath) throw new Error('PATH selects another ours command; select the verified npm global bin before migration');
107
+ owned(dirname(binPath), { directory: true, writable: true });
108
+ const packageRoot = realpathSync(join(npmRoot, '@ours.network/cli'));
109
+ const packagePath = join(packageRoot, 'package.json'); owned(packagePath);
110
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
111
+ if (pkg.name !== '@ours.network/cli' || typeof pkg.bin?.ours !== 'string') throw new Error('Global package does not declare the expected ours CLI');
112
+ const originalProgram = realpathSync(resolve(packageRoot, pkg.bin.ours));
113
+ if (!originalProgram.startsWith(packageRoot + '/')) throw new Error('Global CLI bin escapes its package');
114
+ owned(originalProgram); accessSync(originalProgram, constants.R_OK | constants.X_OK);
115
+ const before = snapshot(binPath);
116
+ let installed = false;
117
+ let installerPath = fileURLToPath(new URL('../install.mjs', import.meta.url));
118
+ if (before.kind === 'symlink') {
119
+ if (before.resolved !== originalProgram) throw new Error('Global ours symlink does not target the verified npm CLI');
120
+ } else {
121
+ const contents = readFileSync(binPath, 'utf8');
122
+ const line = contents.split('\n')[1];
123
+ if (!line?.startsWith(marker)) throw new Error('Refusing to replace an unknown ours executable');
124
+ const binding = JSON.parse(line.slice(marker.length));
125
+ if (binding.schema !== 1 || binding.targetRoot !== targetRoot || binding.recordPath !== join(targetRoot, 'installation.json')) throw new Error('Managed CLI belongs to another installation');
126
+ installerPath = absolute(binding.installerPath);
127
+ if (contents !== buildManagedCli(binding.recordPath, installerPath)) throw new Error('Managed CLI launcher was modified');
128
+ const backup = readBackup(targetRoot);
129
+ if (backup.binPath !== binPath || backup.originalProgram !== originalProgram) throw new Error('Managed CLI backup selects another original executable');
130
+ installed = true;
131
+ }
132
+ return { schema: 1, targetRoot, binPath, originalProgram, installerPath, before, installed };
133
+ }
134
+
135
+ export async function installManagedCli(record, cliPlan, effects, { rename = renameSync } = {}) {
136
+ if (record.root !== cliPlan.targetRoot) throw new Error('Managed CLI target changed after inspection');
137
+ const fresh = await inspectManagedCli(effects, record.root);
138
+ if (fresh.binPath !== cliPlan.binPath || fresh.originalProgram !== cliPlan.originalProgram || JSON.stringify(fresh.before) !== JSON.stringify(cliPlan.before)) throw new Error('Global CLI changed after inspection; cutover refused');
139
+ const installerPath = absolute(cliPlan.installerPath);
140
+ owned(installerPath);
141
+ if (realpathSync(installerPath) !== installerPath) throw new Error('Managed installer path must be canonical');
142
+ if (fresh.installed && fresh.installerPath !== installerPath) throw new Error('Managed installer selection changed after inspection');
143
+ if (fresh.installed) return { binPath: fresh.binPath, originalProgram: fresh.originalProgram, changed: false };
144
+ owned(record.root, { directory: true });
145
+ const backupDir = join(record.root, 'legacy-backup');
146
+ if (!existsSync(backupDir)) mkdirSync(backupDir, { mode: 0o700 });
147
+ const backupStat = owned(backupDir, { directory: true });
148
+ if (backupStat.mode & 0o077) throw new Error('CLI backup directory must be private');
149
+ const backupPath = join(backupDir, backupName);
150
+ const backup = { schema: 1, targetRoot: record.root, binPath: fresh.binPath, originalProgram: fresh.originalProgram,
151
+ original: fresh.before, ...(fresh.before.kind === 'file' ? { bytes: readFileSync(fresh.binPath).toString('base64') } : {}) };
152
+ if (existsSync(backupPath)) {
153
+ const original = readBackup(record.root);
154
+ if (original.binPath !== backup.binPath || original.originalProgram !== backup.originalProgram) throw new Error('Existing CLI backup differs from the selected original');
155
+ } else writeFileSync(backupPath, JSON.stringify(backup, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
156
+ const contents = buildManagedCli(join(record.root, 'installation.json'), installerPath);
157
+ const temporary = join(dirname(fresh.binPath), `.ours-managed-${randomUUID()}`);
158
+ try {
159
+ writeFileSync(temporary, contents, { flag: 'wx', mode: 0o700 }); chmodSync(temporary, 0o755);
160
+ if (JSON.stringify(snapshot(fresh.binPath)) !== JSON.stringify(fresh.before)) throw new Error('Global CLI changed during cutover');
161
+ rename(temporary, fresh.binPath);
162
+ } finally { rmSync(temporary, { force: true }); }
163
+ return { binPath: fresh.binPath, originalProgram: fresh.originalProgram, changed: true };
164
+ }
@@ -1,3 +1,4 @@
1
+ import { executeLegacyMigration } from './legacy-migration.mjs';
1
2
  // ours-install v3 — the orchestrator.
2
3
  //
3
4
  // This is the part that cannot be pure: it walks the flow, renders the screens
@@ -1250,7 +1251,9 @@ export async function runInstall(argv, effects) {
1250
1251
 
1251
1252
  export async function runServerCommand(args, effects) {
1252
1253
  if (args.operation === 'status') return executeServerCommand(args, effects);
1253
- return effects.withInstallationLock(args.stateDir, () => executeServerCommand(args, effects));
1254
+ return effects.withInstallationLock(args.stateDir, () => args.migrateFrom
1255
+ ? executeLegacyMigration(args, effects, executeServerCommand)
1256
+ : executeServerCommand(args, effects));
1254
1257
  }
1255
1258
 
1256
1259
  async function executeServerCommand(args, effects) {
@@ -1366,6 +1369,7 @@ async function executeServerCommand(args, effects) {
1366
1369
  layoutConversion: record.schema === 1 ? 'preparation-pending' : 'activation-pending',
1367
1370
  } : {}),
1368
1371
  }));
1372
+ if (args.operation === 'status') return EXIT_OK;
1369
1373
  }
1370
1374
  effects.out(ok(`Server ${args.operation} completed for ${record.root}`));
1371
1375
  return EXIT_OK;