@ours.network/install 1.2.1-nightly.1 → 1.2.1-nightly.3
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 +94 -126
- package/assets/Dockerfile +1 -0
- package/assets/docker-compose.yaml +20 -0
- package/assets/release-lock.json +6 -6
- package/assets/release.json +1 -1
- package/assets/scripts/runtime/legacy-import.mjs +103 -0
- package/assets/sources.json +1 -1
- package/install.mjs +2 -2
- package/lib/build-transition.mjs +13 -7
- package/lib/effects.mjs +99 -30
- 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 +69 -24
- package/lib/prompt.mjs +113 -119
- package/lib/server-onboarding.mjs +114 -0
- package/lib/setup-options.mjs +253 -0
- package/lib/setup.mjs +155 -0
- package/lib/usage.mjs +49 -44
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -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, () =>
|
|
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) {
|
|
@@ -1266,6 +1269,7 @@ async function executeServerCommand(args, effects) {
|
|
|
1266
1269
|
} else {
|
|
1267
1270
|
if (args.operation !== 'install' || !args.mode) throw new Error('First server install requires --mode');
|
|
1268
1271
|
record = effects.newInstallation(args.stateDir, args.mode);
|
|
1272
|
+
for (const key of ['port', 'coworkPort', 'messengerPort']) if (args[key] !== undefined) record[key] = args[key];
|
|
1269
1273
|
}
|
|
1270
1274
|
if (record.layoutConversion && !['install', 'start', 'stop', 'status'].includes(args.operation)) {
|
|
1271
1275
|
throw new Error('Layout conversion is incomplete; resume with server install or server start before changing state or authority');
|
|
@@ -1273,14 +1277,32 @@ async function executeServerCommand(args, effects) {
|
|
|
1273
1277
|
if (record.buildTransition && !['stop', 'status', record.buildTransition.operation].includes(args.operation)) {
|
|
1274
1278
|
throw new Error(`Server build activation is incomplete; repeat server ${record.buildTransition.operation} before other mutations`);
|
|
1275
1279
|
}
|
|
1280
|
+
const showInstallProgress = args.operation === 'install' && !(existing && (record.schema === 1 || record.layoutConversion));
|
|
1281
|
+
const installStageCount = (existing ? 7 : 9) + (args.identityName ? 2 : 0);
|
|
1282
|
+
let completedInstallStages = 0;
|
|
1283
|
+
const installStage = async (label, explanation, action) => {
|
|
1284
|
+
if (!showInstallProgress) return action();
|
|
1285
|
+
effects.out(progress(completedInstallStages, installStageCount, label, explanation));
|
|
1286
|
+
try {
|
|
1287
|
+
const result = await action();
|
|
1288
|
+
completedInstallStages += 1;
|
|
1289
|
+
effects.out(ok(`${label} complete`));
|
|
1290
|
+
return result;
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
effects.out(warn(`Server installation stopped during ${label.toLowerCase()}.`));
|
|
1293
|
+
throw error;
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1276
1296
|
if ((!existing || args.operation === 'update') && !record.buildTransition) {
|
|
1277
|
-
|
|
1278
|
-
|
|
1297
|
+
args.resolvedSources = await installStage('Package selection', 'Resolve the selected server packages before installation.', async () => {
|
|
1298
|
+
const policy = args.sourcePolicy ?? (args.sources ? effects.readJson(args.sources) : effects.packagedSourcePolicy());
|
|
1299
|
+
return effects.resolveSourcePolicy(policy, 'server');
|
|
1300
|
+
});
|
|
1279
1301
|
}
|
|
1280
1302
|
if (!existing && args.sources) record.sourcePolicyHash = effects.sourcePolicyHash(args.sources);
|
|
1281
|
-
await effects.serverPreflight(record, args.operation, {
|
|
1282
|
-
existing, sourcePath: args.sources ?? record.sourcesPath, sourceManifest: args.resolvedSources,
|
|
1283
|
-
});
|
|
1303
|
+
await installStage('Prerequisite checks', record.mode === 'docker' ? 'Check Docker Engine and Docker Compose.' : 'Check native tools and the user service manager.', () => effects.serverPreflight(record, args.operation, {
|
|
1304
|
+
existing, sourcePath: args.sources ?? record.sourcesPath, sourceManifest: args.resolvedSources, identityName: args.identityName,
|
|
1305
|
+
}));
|
|
1284
1306
|
if (existing && (record.schema === 1 || record.layoutConversion)
|
|
1285
1307
|
&& ['install', 'start', 'restart', 'update', 'rebuild'].includes(args.operation)) {
|
|
1286
1308
|
record = record.mode === 'docker'
|
|
@@ -1295,16 +1317,27 @@ async function executeServerCommand(args, effects) {
|
|
|
1295
1317
|
await effects.stopPendingConversion(record);
|
|
1296
1318
|
} else if (args.operation === 'install') {
|
|
1297
1319
|
if (!existing) {
|
|
1298
|
-
await
|
|
1299
|
-
|
|
1320
|
+
await installStage('Installation setup', 'Save the selected packages and installation settings.', async () => {
|
|
1321
|
+
await effects.initializeSelection(record, args.resolvedSources);
|
|
1322
|
+
effects.writeJson(recordPath, JSON.stringify(record, null, 2) + '\n');
|
|
1323
|
+
});
|
|
1300
1324
|
}
|
|
1301
|
-
await effects.prepareInstallation(record);
|
|
1325
|
+
await installStage('Runtime preparation', record.mode === 'docker' ? 'Prepare the Docker runtime. Downloads and builds may take several minutes.' : 'Download and prepare the native runtime packages. This may take several minutes.', () => effects.prepareInstallation(record));
|
|
1302
1326
|
// A repeated setup repairs delivery with the retained master and exact packages.
|
|
1303
|
-
await effects.serverLifecycle(record, 'stop');
|
|
1304
|
-
await effects.serverAccess(record, 'access-init', { migrate: !!args.migrate });
|
|
1305
|
-
await effects.serverAccess(record, 'access-issue');
|
|
1306
|
-
await effects.recordInstallationBuild(record);
|
|
1307
|
-
|
|
1327
|
+
await installStage('Service shutdown', 'Stop managed services before configuring access.', () => effects.serverLifecycle(record, 'stop'));
|
|
1328
|
+
await installStage('Credential initialization', 'Initialize or retain the installation credentials.', () => effects.serverAccess(record, 'access-init', { migrate: !!args.migrate }));
|
|
1329
|
+
await installStage('Credential delivery', 'Prepare access for the selected services.', () => effects.serverAccess(record, 'access-issue'));
|
|
1330
|
+
await installStage('Build verification', 'Record the installed runtime and selected package versions.', () => effects.recordInstallationBuild(record));
|
|
1331
|
+
if (args.identityName) {
|
|
1332
|
+
await installStage('Daemon startup', 'Start the daemon and restore its retained identities.', () => effects.serverLifecycle(record, 'start', ['daemon']));
|
|
1333
|
+
const identity = await installStage('Human identity', 'Keep the existing Human identity, or create it on a fresh daemon.', () => effects.serverEnsureIdentity(record, args.identityName));
|
|
1334
|
+
record.messengerIdentity = identity.name;
|
|
1335
|
+
effects.writeJson(recordPath, JSON.stringify(record, null, 2) + '\n');
|
|
1336
|
+
await installStage('Application startup', 'Start the selected applications and check readiness.', () => effects.serverLifecycle(record, 'start', record.services.filter(name => name !== 'daemon')));
|
|
1337
|
+
} else {
|
|
1338
|
+
await installStage('Service startup', 'Start the daemon and selected services, then check readiness.', () => effects.serverLifecycle(record, 'start'));
|
|
1339
|
+
}
|
|
1340
|
+
if (showInstallProgress) effects.out(progress(installStageCount, installStageCount, 'Installation complete', 'The selected services are ready.'));
|
|
1308
1341
|
} else if (['backup', 'restore', 'reset'].includes(args.operation)) {
|
|
1309
1342
|
await effects.serverMaintenance(record, args);
|
|
1310
1343
|
} else if (['update', 'rebuild'].includes(args.operation)) {
|
|
@@ -1336,23 +1369,24 @@ async function executeServerCommand(args, effects) {
|
|
|
1336
1369
|
layoutConversion: record.schema === 1 ? 'preparation-pending' : 'activation-pending',
|
|
1337
1370
|
} : {}),
|
|
1338
1371
|
}));
|
|
1372
|
+
if (args.operation === 'status') return EXIT_OK;
|
|
1339
1373
|
}
|
|
1340
1374
|
effects.out(ok(`Server ${args.operation} completed for ${record.root}`));
|
|
1341
1375
|
return EXIT_OK;
|
|
1342
1376
|
}
|
|
1343
1377
|
|
|
1344
|
-
async function runClientCommand(command, effects) {
|
|
1378
|
+
export async function runClientCommand(command, effects) {
|
|
1345
1379
|
const managedPath = join(effects.home, '.ours-client', 'profile.json');
|
|
1346
1380
|
const saved = effects.readManagedClientProfile();
|
|
1347
1381
|
let configPath = command.config;
|
|
1348
1382
|
if (!configPath && saved) {
|
|
1349
|
-
if (effects.interactive && !await effects.ask(`Reuse saved client server ${saved.endpoint}?`, true))
|
|
1383
|
+
if (!command.preset && effects.interactive && !await effects.ask(`Reuse saved client server ${saved.endpoint}?`, true))
|
|
1350
1384
|
throw new InstallUsageError('Saved client default retained; use an explicit prepared profile to validate replacement input');
|
|
1351
1385
|
configPath = managedPath;
|
|
1352
1386
|
}
|
|
1353
1387
|
let profile;
|
|
1354
1388
|
if (configPath) profile = validateHostProfile(effects.readProfile(configPath));
|
|
1355
|
-
else if (effects.interactive) {
|
|
1389
|
+
else if (!command.preset && effects.interactive) {
|
|
1356
1390
|
const endpoint = await effects.askLine('Server HTTP endpoint: ', 'http://127.0.0.1:3050');
|
|
1357
1391
|
const credentialPath = await effects.askLine('Private issued-token file: ', '');
|
|
1358
1392
|
if (!credentialPath) throw new InstallUsageError('Client setup requires an issued-token file');
|
|
@@ -1369,40 +1403,51 @@ async function runClientCommand(command, effects) {
|
|
|
1369
1403
|
await effects.verifyPackagedMcp(configPath || profile);
|
|
1370
1404
|
const settings = saved?.installer ?? (configPath ? effects.readJson(configPath)?.installer : undefined);
|
|
1371
1405
|
const settingsBase = saved ? dirname(managedPath) : configPath ? dirname(configPath) : process.cwd();
|
|
1372
|
-
let integrations = settings?.integrations;
|
|
1373
|
-
if (!integrations && effects.interactive) {
|
|
1406
|
+
let integrations = command.integrations ?? settings?.integrations;
|
|
1407
|
+
if (!integrations && !command.preset && effects.interactive) {
|
|
1374
1408
|
integrations = [];
|
|
1375
1409
|
for (const name of ['codex', 'claude-code', 'fleet']) if (await effects.ask(`Install ${name}?`, name !== 'fleet')) integrations.push(name);
|
|
1376
1410
|
}
|
|
1377
1411
|
if (!Array.isArray(integrations) || !integrations.length || integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name)) || new Set(integrations).size !== integrations.length) throw new InstallUsageError('installer.integrations must select codex, claude-code and/or fleet');
|
|
1378
|
-
let fleetSettingsPath = settings?.fleetSettingsPath;
|
|
1412
|
+
let fleetSettingsPath = command.preset ? command.fleetSettingsPath : settings?.fleetSettingsPath;
|
|
1413
|
+
if (command.nonInteractive && integrations.includes('fleet') && !fleetSettingsPath) throw new InstallUsageError('Fleet in CLI mode requires --fleet-settings; no interactive wizard will be opened');
|
|
1379
1414
|
if (fleetSettingsPath !== undefined && (typeof fleetSettingsPath !== 'string' || !fleetSettingsPath))
|
|
1380
1415
|
throw new InstallUsageError('installer.fleetSettingsPath must be a non-empty path when supplied');
|
|
1381
1416
|
if (fleetSettingsPath) fleetSettingsPath = resolve(settingsBase, fleetSettingsPath);
|
|
1382
1417
|
const selectedClients = [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
|
|
1383
1418
|
let sourcesPath = settings?.sourcesPath;
|
|
1384
1419
|
let resolvedSources;
|
|
1385
|
-
if (
|
|
1420
|
+
if (command.sourcePolicy) {
|
|
1421
|
+
resolvedSources = await effects.resolveSourcePolicy(command.sourcePolicy, 'client', selectedClients);
|
|
1422
|
+
sourcesPath = undefined;
|
|
1423
|
+
} else if (saved) sourcesPath = resolve(settingsBase, sourcesPath);
|
|
1386
1424
|
else {
|
|
1387
1425
|
if (sourcesPath) sourcesPath = resolve(settingsBase, sourcesPath);
|
|
1388
1426
|
const policy = sourcesPath ? effects.readJson(sourcesPath) : effects.packagedSourcePolicy();
|
|
1389
1427
|
resolvedSources = await effects.resolveSourcePolicy(policy, 'client', selectedClients);
|
|
1390
1428
|
}
|
|
1391
|
-
|
|
1429
|
+
effects.out(progress(0, 4, 'Client configuration', 'Prepare the selected integrations and private connection profile.'));
|
|
1430
|
+
const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh: !!command.preset });
|
|
1392
1431
|
try {
|
|
1393
1432
|
await effects.verifyHostProfile(imported.configPath);
|
|
1394
|
-
|
|
1433
|
+
effects.out(progress(1, 4, 'Client packages', 'Acquire and verify the selected client package versions.'));
|
|
1434
|
+
const exactSuite = await effects.acquireClientPackages(imported.configPath, imported.settings.sourcesPath, integrations, { refresh: !!command.preset });
|
|
1395
1435
|
const args = { assumeYes: true, dryRun: false, channel: 'latest', clientIntegrations: integrations,
|
|
1396
1436
|
acquiredFleet: exactSuite.fleetBin, fleetSettingsPath: imported.settings.fleetSettingsPath };
|
|
1397
1437
|
const target = { mode: 'host-profile', managed: true, configPath: imported.configPath, profile: imported.profile, endpoint: imported.profile.endpoint };
|
|
1438
|
+
effects.out(progress(2, 4, 'Client integrations', 'Register the selected agent integrations.'));
|
|
1398
1439
|
const summary = await runHarnessPhase(args, effects, { target, isDefaultStateDir: false, exactSuite });
|
|
1399
|
-
if (integrations.includes('fleet'))
|
|
1440
|
+
if (integrations.includes('fleet')) {
|
|
1441
|
+
effects.out(progress(3, 4, 'Fleet configuration', 'Apply prepared settings or open the selected Fleet wizard.'));
|
|
1442
|
+
summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir: false }));
|
|
1443
|
+
}
|
|
1400
1444
|
const incomplete = integrations.filter(name => !summary.some(row => row.key === name && row.state === 'installed'));
|
|
1401
1445
|
if (effects.env.OURS_CONFIG && resolve(effects.env.OURS_CONFIG) !== imported.configPath)
|
|
1402
1446
|
effects.out(warn('This shell has an explicit OURS_CONFIG override. Unset it for new clients to use the saved default; installer did not edit your shell.'));
|
|
1403
1447
|
effects.out(incomplete.length
|
|
1404
1448
|
? warn(`Client setup incomplete (${incomplete.join(', ')}); saved profile and settings retained. Re-run ours-install client install.`)
|
|
1405
1449
|
: ok(`Client setup complete. New clients discover ${imported.configPath}; no OURS_CONFIG export is required.`));
|
|
1450
|
+
if (!incomplete.length) effects.out(progress(4, 4, 'Client setup complete', 'All selected integrations are configured.'));
|
|
1406
1451
|
return incomplete.length ? EXIT_REFUSED : EXIT_OK;
|
|
1407
1452
|
} catch (error) {
|
|
1408
1453
|
effects.out(warn(`Client setup incomplete: ${reason(error)}. Saved profile and settings retained; re-run ours-install client install.`));
|