@ours.network/install 1.2.0-nightly.2 → 1.2.1-nightly.10
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.
- package/README.md +151 -131
- package/assets/Dockerfile +6 -3
- package/assets/docker-compose.yaml +21 -1
- package/assets/release-lock.json +7161 -0
- package/assets/release.json +57 -0
- package/assets/scripts/build/build-common.mjs +1 -1
- package/assets/scripts/build/build-sdk.mjs +6 -1
- package/assets/scripts/build/record-build.mjs +2 -0
- package/assets/scripts/maintenance/build-context.mjs +1 -1
- package/assets/scripts/maintenance/daemon-owner.mjs +15 -0
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +3 -1
- package/assets/scripts/maintenance/release-graph.mjs +111 -0
- package/assets/scripts/maintenance/state-operation.mjs +4 -2
- package/assets/scripts/runtime/client-setup.mjs +3 -3
- package/assets/scripts/runtime/entrypoint.sh +1 -1
- package/assets/scripts/runtime/legacy-import.mjs +103 -0
- package/assets/scripts/runtime/runtime-common.mjs +2 -0
- package/assets/sources.json +96 -16
- package/install.mjs +2 -2
- package/install.sh +1 -1
- package/lib/build-transition.mjs +13 -7
- package/lib/client-cli.mjs +117 -0
- package/lib/docker-conversion-runtime.mjs +1 -0
- package/lib/docker-runtime-repair.mjs +125 -0
- package/lib/effects.mjs +163 -85
- package/lib/fleet-settings.mjs +43 -0
- package/lib/legacy-migration.mjs +202 -0
- package/lib/legacy-state.mjs +205 -0
- package/lib/managed-cli.mjs +164 -0
- package/lib/orchestrate.mjs +162 -58
- package/lib/plan.mjs +13 -7
- package/lib/prompt.mjs +113 -119
- package/lib/server-onboarding.mjs +114 -0
- package/lib/setup-options.mjs +253 -0
- package/lib/setup.mjs +154 -0
- package/lib/target.mjs +4 -4
- package/lib/uninstall.mjs +2 -2
- package/lib/usage.mjs +49 -44
- package/package.json +5 -4
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/** Local-only migration: preserve opaque daemon state and retire its old launcher. */
|
|
2
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join, basename } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { inspectLegacyState, stageLegacyState, withLegacyStateLock, ensureLegacyLockSupport } from './legacy-state.mjs';
|
|
6
|
+
import { inspectManagedCli, installManagedCli } from './managed-cli.mjs';
|
|
7
|
+
import { atomicWriteConfig } from './config.mjs';
|
|
8
|
+
import { classifyUnit, unitPathForStateDir, validateInstallation } from './plan.mjs';
|
|
9
|
+
|
|
10
|
+
const journalPath = root => join(root, 'legacy-migration.json');
|
|
11
|
+
const parse = result => JSON.parse(result.stdout);
|
|
12
|
+
const phases = ['prepared', 'stopped', 'copied', 'activating', 'verified', 'complete'];
|
|
13
|
+
function readJournal(root) {
|
|
14
|
+
const path = journalPath(root);
|
|
15
|
+
if (!existsSync(path)) return null;
|
|
16
|
+
const j = JSON.parse(readFileSync(path));
|
|
17
|
+
if (j.schema !== 1 || j.targetRoot !== root || !phases.includes(j.phase) || !Array.isArray(j.identities)) throw new Error('Invalid legacy migration journal');
|
|
18
|
+
return j;
|
|
19
|
+
}
|
|
20
|
+
function selection(source) {
|
|
21
|
+
return { env: { OURS_CONFIG: source.configPath, OURS_STATE_DIR: source.stateDir, OURS_PORT: String(source.config.port ?? 3050), OURS_API_TOKEN: undefined, OURS_DAEMON_ID: undefined, OURS_DAEMON_URL: undefined, OURS_DAEMON_CREDENTIAL_PATH: undefined }, sensitive: true };
|
|
22
|
+
}
|
|
23
|
+
async function oldCommand(effects, source, args, options = {}) {
|
|
24
|
+
return effects.run(source.originalProgram ?? 'ours', [...args, '--config', source.configPath, '--state-dir', source.stateDir, '--json'], { ...selection(source), ...options });
|
|
25
|
+
}
|
|
26
|
+
function validateIdentities(rows, source) {
|
|
27
|
+
if (!Array.isArray(rows) || !rows.length || rows.some(r => !['root', 'role'].includes(r.kind) || typeof r.cid !== 'string' || !r.cid || typeof r.name !== 'string')) throw new Error('Legacy identities are not fully restored; migration requires a healthy source daemon');
|
|
28
|
+
const roots = rows.filter(r => r.kind === 'root');
|
|
29
|
+
if (roots.length !== 1 || roots[0].name !== source.rootName) throw new Error('Legacy Human identity does not match its stored root');
|
|
30
|
+
return rows.map(({ name, kind, cid }) => ({ name, kind, cid })).sort((a,b) => a.name.localeCompare(b.name));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function inspectLegacyMigration(options, effects, deps = {}) {
|
|
34
|
+
const inspect = deps.inspectLegacyState ?? inspectLegacyState;
|
|
35
|
+
await (deps.ensureLegacyLockSupport ?? ensureLegacyLockSupport)();
|
|
36
|
+
const cliPlan = options.dryRun ? null : await (deps.inspectManagedCli ?? inspectManagedCli)(effects, options.stateDir);
|
|
37
|
+
const journal = readJournal(options.stateDir);
|
|
38
|
+
if (journal) {
|
|
39
|
+
if (journal.sourceConfig !== options.migrateFrom) throw new Error('Another legacy source is already selected for this target');
|
|
40
|
+
return { journal, cliPlan, source: journal.phase === 'complete' ? null : { ...inspect(options.migrateFrom, options.stateDir), originalProgram: cliPlan?.originalProgram } };
|
|
41
|
+
}
|
|
42
|
+
const source = { ...inspect(options.migrateFrom, options.stateDir), originalProgram: cliPlan?.originalProgram };
|
|
43
|
+
if (options.mode === 'docker' && options.stateDir.includes(':')) throw new Error('Docker migration requires an installation path without colon characters');
|
|
44
|
+
if (effects.readJson(join(options.stateDir, 'installation.json'))) throw new Error('Legacy migration requires a new managed installation root');
|
|
45
|
+
if (existsSync(options.stateDir) && readdirSync(options.stateDir).length) throw new Error('Legacy migration target must be empty');
|
|
46
|
+
if (options.dryRun) return { source, journal: { schema: 1, sourceConfig: source.configPath, sourceStateDir: source.stateDir, targetRoot: options.stateDir, phase: 'prepared', identities: [], service: null } };
|
|
47
|
+
// The owning CLI verifies endpoint, process and state directory. No PID guessing.
|
|
48
|
+
const status = parse(await oldCommand(effects, source, ['daemon', 'status']));
|
|
49
|
+
if (status.state !== 'running' || status.stateDir !== source.stateDir) throw new Error('Start the legacy daemon before migration so its identities can be verified');
|
|
50
|
+
const identities = validateIdentities(parse(await oldCommand(effects, source, ['identity', 'list'])), source);
|
|
51
|
+
const service = parse(await oldCommand(effects, source, ['daemon', 'uninstall-service', '--dry-run']));
|
|
52
|
+
if (service.conflict) {
|
|
53
|
+
const legacy = effects.platform?.platform === 'linux' && source.stateDir === join(effects.home, '.ours')
|
|
54
|
+
? unitPathForStateDir(source.stateDir, effects.home) : null;
|
|
55
|
+
if (!legacy?.ok || legacy.path !== service.serviceFile || classifyUnit(effects.readText(legacy.path)).kind !== 'legacy') {
|
|
56
|
+
throw new Error('Legacy boot service ownership could not be verified: ' + service.conflict.message);
|
|
57
|
+
}
|
|
58
|
+
service.legacyUnit = legacy.unit;
|
|
59
|
+
}
|
|
60
|
+
return { source, cliPlan, journal: { schema: 1, sourceConfig: source.configPath, sourceStateDir: source.stateDir, targetRoot: options.stateDir,
|
|
61
|
+
phase: 'prepared', identities, service, sourcePort: source.config.port ?? 3050 } };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function prepareMigrationCliRuntime(record, effects) {
|
|
65
|
+
const root = join(record.root, 'launcher-runtime');
|
|
66
|
+
const entry = join(root, 'node_modules', '@ours.network', 'install', 'install.mjs');
|
|
67
|
+
const ready = join(root, '.ready');
|
|
68
|
+
if (existsSync(ready) && existsSync(entry)) return entry;
|
|
69
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
70
|
+
effects.out('Preparing permanent management commands so ours will keep working after this installer exits.');
|
|
71
|
+
const packageRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
72
|
+
const packed = parse(await effects.run('npm', ['pack', packageRoot, '--ignore-scripts', '--pack-destination', root, '--json']));
|
|
73
|
+
if (!Array.isArray(packed) || packed.length !== 1 || typeof packed[0].filename !== 'string' || basename(packed[0].filename) !== packed[0].filename) throw new Error('Cannot prepare the persistent installer package');
|
|
74
|
+
await effects.run('npm', ['install', '--prefix', root, '--prefer-offline', '--ignore-scripts', '--no-audit', '--no-fund', join(root, packed[0].filename)], { stream: true });
|
|
75
|
+
if (!existsSync(entry)) throw new Error('Persistent installer entry is missing');
|
|
76
|
+
await effects.run(process.execPath, [entry, '--help']);
|
|
77
|
+
atomicWriteConfig(ready, 'ready\n');
|
|
78
|
+
return entry;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Called under the normal installation lock; retries never overwrite imported state. */
|
|
82
|
+
export async function executeLegacyMigration(args, effects, install, deps = {}) {
|
|
83
|
+
const stage = deps.stageLegacyState ?? stageLegacyState;
|
|
84
|
+
const lock = deps.withLegacyStateLock ?? withLegacyStateLock;
|
|
85
|
+
const save = (root, value) => (deps.atomicWriteConfig ?? atomicWriteConfig)(journalPath(root), JSON.stringify(value, null, 2) + '\n');
|
|
86
|
+
const prepareCli = deps.prepareMigrationCliRuntime ?? prepareMigrationCliRuntime;
|
|
87
|
+
const inspected = args.legacyPlan ?? await inspectLegacyMigration(args, effects, deps);
|
|
88
|
+
let journal = readJournal(args.stateDir) ?? inspected.journal;
|
|
89
|
+
if (journal.phase === 'complete') {
|
|
90
|
+
const retained = effects.readJson(join(args.stateDir, 'installation.json'));
|
|
91
|
+
inspected.cliPlan.installerPath = await prepareCli(retained, effects);
|
|
92
|
+
await (deps.installManagedCli ?? installManagedCli)(retained, inspected.cliPlan, effects);
|
|
93
|
+
const rows = validateIdentities(parse(await effects.run('ours', ['identity', 'list', '--json'])), { rootName: journal.identities.find(row => row.kind === 'root')?.name });
|
|
94
|
+
if (JSON.stringify(rows) !== JSON.stringify(journal.identities)) throw new Error('Completed migration identity verification failed; no replacement Human was created');
|
|
95
|
+
effects.out('Legacy migration already completed; retained identities verified.');
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
const source = inspected.source;
|
|
99
|
+
let record;
|
|
100
|
+
let sourceLock;
|
|
101
|
+
let activationAttempted = phases.indexOf(journal.phase) >= phases.indexOf('activating');
|
|
102
|
+
const wrapped = { ...effects,
|
|
103
|
+
newInstallation(root, mode) {
|
|
104
|
+
if (journal.record) {
|
|
105
|
+
if (journal.record.root !== root || journal.record.mode !== mode) throw new Error('Migration target selection changed');
|
|
106
|
+
return validateInstallation(journal.record, root);
|
|
107
|
+
}
|
|
108
|
+
return effects.newInstallation(root, mode);
|
|
109
|
+
},
|
|
110
|
+
async initializeSelection(selected, manifest) {
|
|
111
|
+
record = selected;
|
|
112
|
+
selected.legacyMigrationSource = args.migrateFrom;
|
|
113
|
+
journal.record = selected;
|
|
114
|
+
journal.sourcePolicy = args.sourcePolicy;
|
|
115
|
+
journal.sourceManifest = manifest;
|
|
116
|
+
save(selected.root, journal);
|
|
117
|
+
effects.writeJson(join(selected.root, 'installation.json'), JSON.stringify(selected, null, 2) + '\n');
|
|
118
|
+
await effects.initializeSelection(selected, manifest, { retainConfig: true });
|
|
119
|
+
},
|
|
120
|
+
async serverPreflight(selected, operation, options) {
|
|
121
|
+
if (journal.record && !existsSync(selected.sourcesPath)) {
|
|
122
|
+
if (!journal.sourceManifest) throw new Error('Migration journal lacks its retained package selection');
|
|
123
|
+
await effects.initializeSelection(selected, journal.sourceManifest, { retainConfig: true });
|
|
124
|
+
}
|
|
125
|
+
return effects.serverPreflight(selected, operation, options);
|
|
126
|
+
},
|
|
127
|
+
async prepareInstallation(selected) {
|
|
128
|
+
record = selected;
|
|
129
|
+
if (!existsSync(selected.sourcesPath)) {
|
|
130
|
+
const manifest = journal.sourceManifest ?? await effects.resolveSourcePolicy(args.sourcePolicy, 'server');
|
|
131
|
+
await effects.initializeSelection(selected, manifest, { retainConfig: true });
|
|
132
|
+
}
|
|
133
|
+
await effects.prepareInstallation(selected, { runtimeOnly: true });
|
|
134
|
+
if (selected.mode === 'docker') await effects.prepareLegacyDockerImport(selected);
|
|
135
|
+
inspected.cliPlan.installerPath = await prepareCli(selected, effects);
|
|
136
|
+
journal.cliInstallerPath = inspected.cliPlan.installerPath;
|
|
137
|
+
if (activationAttempted) await effects.serverLifecycle(selected, 'stop');
|
|
138
|
+
// Backups contain secrets; the enclosing installation is private.
|
|
139
|
+
const backup = join(selected.root, 'legacy-backup');
|
|
140
|
+
mkdirSync(backup, { mode: 0o700, recursive: true });
|
|
141
|
+
const configBackup = join(backup, 'config.json');
|
|
142
|
+
if (!existsSync(configBackup)) writeFileSync(configBackup, readFileSync(source.configPath), { flag: 'wx', mode: 0o600 });
|
|
143
|
+
if (journal.service.serviceFile && existsSync(journal.service.serviceFile) && !existsSync(join(backup, 'service'))) {
|
|
144
|
+
writeFileSync(join(backup, 'service'), readFileSync(journal.service.serviceFile), { flag: 'wx', mode: 0o600 });
|
|
145
|
+
}
|
|
146
|
+
save(selected.root, journal);
|
|
147
|
+
if (!activationAttempted) {
|
|
148
|
+
effects.out('Migration: retire the old boot service and stop its daemon.');
|
|
149
|
+
if (journal.service.legacyUnit) await effects.run('systemctl', ['--user', 'disable', '--now', journal.service.legacyUnit]);
|
|
150
|
+
else await oldCommand(effects, source, ['daemon', 'uninstall-service', '--yes']);
|
|
151
|
+
await oldCommand(effects, source, ['daemon', 'stop']);
|
|
152
|
+
journal.phase = 'stopped'; save(selected.root, journal);
|
|
153
|
+
}
|
|
154
|
+
// Hold the SDK-compatible owner lock until activation finishes, including failures.
|
|
155
|
+
sourceLock = await lock(source.stateDir);
|
|
156
|
+
effects.out('Migration: copy retained identities, keys, history and session state.');
|
|
157
|
+
stage(source, selected);
|
|
158
|
+
if (selected.mode === 'docker') await effects.importLegacyDockerState(selected);
|
|
159
|
+
if (!activationAttempted) { journal.phase = 'copied'; save(selected.root, journal); }
|
|
160
|
+
await effects.prepareInstallation(selected);
|
|
161
|
+
},
|
|
162
|
+
async serverLifecycle(selected, operation, services) {
|
|
163
|
+
if (operation === 'start') {
|
|
164
|
+
activationAttempted = true;
|
|
165
|
+
journal.phase = 'activating'; save(selected.root, journal);
|
|
166
|
+
}
|
|
167
|
+
return effects.serverLifecycle(selected, operation, services);
|
|
168
|
+
},
|
|
169
|
+
async serverEnsureIdentity(selected) {
|
|
170
|
+
const rows = await effects.serverListIdentities(selected);
|
|
171
|
+
const actual = validateIdentities(rows, source);
|
|
172
|
+
if (JSON.stringify(actual) !== JSON.stringify(journal.identities)) throw new Error('Migrated identities differ from the source; no replacement Human was created');
|
|
173
|
+
journal.phase = 'verified'; save(selected.root, journal);
|
|
174
|
+
const root = actual.find(r => r.kind === 'root');
|
|
175
|
+
return { ...root, created: false };
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
try {
|
|
179
|
+
const result = await install({ ...args, migrate: true }, wrapped);
|
|
180
|
+
if (result !== 0) throw new Error('Migrated server setup did not complete');
|
|
181
|
+
// Publish an explicit host profile; a daemon --config file is not a client profile.
|
|
182
|
+
const directory = join(record.root, 'legacy-client');
|
|
183
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
184
|
+
const credentialPath = join(directory, 'credential');
|
|
185
|
+
if (!existsSync(credentialPath)) await effects.serverAccess(record, 'access-issue', { output: credentialPath });
|
|
186
|
+
const profile = { endpoint: `http://127.0.0.1:${record.port}`, expectedInstanceId: record.instanceId, credentialPath };
|
|
187
|
+
atomicWriteConfig(join(directory, 'profile.json'), JSON.stringify(profile, null, 2) + '\n');
|
|
188
|
+
await (deps.installManagedCli ?? installManagedCli)(record, inspected.cliPlan, effects);
|
|
189
|
+
const defaultRows = validateIdentities(parse(await effects.run('ours', ['identity', 'list', '--json'])), source);
|
|
190
|
+
if (JSON.stringify(defaultRows) !== JSON.stringify(journal.identities)) throw new Error('Default ours command does not select the migrated identities');
|
|
191
|
+
journal.phase = 'complete'; save(record.root, journal);
|
|
192
|
+
effects.out(`Migration complete. Original state remains at ${source.stateDir}; original config/service are backed up in ${join(record.root, 'legacy-backup')}. Do not start the original state alongside the migrated daemon.`);
|
|
193
|
+
return 0;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (record && activationAttempted) {
|
|
196
|
+
try { await effects.serverLifecycle(record, 'stop'); }
|
|
197
|
+
catch { throw new Error(`Migration activation failed and destination shutdown could not be confirmed. Keep the source stopped; repair this same target. ${error.message}`); }
|
|
198
|
+
throw new Error(`Migration paused after activation; source remains stopped to avoid diverging identity sessions. Repeat the same migration command to repair the retained target. ${error.message}`);
|
|
199
|
+
}
|
|
200
|
+
throw new Error(`Migration paused before activation; source data is retained. Repeat the same migration command. ${error.message}`);
|
|
201
|
+
} finally { await sourceLock?.close(); }
|
|
202
|
+
}
|
|
@@ -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
|
+
}
|