@ours.network/install 1.1.1 → 1.2.0-nightly.2
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 +196 -6
- package/assets/Dockerfile +41 -0
- package/assets/docker-compose.yaml +222 -0
- package/assets/scripts/README.md +20 -0
- package/assets/scripts/build/README.md +39 -0
- package/assets/scripts/build/build-common.mjs +37 -0
- package/assets/scripts/build/build-cowork.mjs +2 -0
- package/assets/scripts/build/build-fleet.mjs +2 -0
- package/assets/scripts/build/build-mcp.mjs +9 -0
- package/assets/scripts/build/build-messenger.mjs +2 -0
- package/assets/scripts/build/build-sdk.mjs +9 -0
- package/assets/scripts/build/build-telegram.mjs +2 -0
- package/assets/scripts/build/build.mjs +49 -0
- package/assets/scripts/build/record-build.mjs +31 -0
- package/assets/scripts/maintenance/README.md +35 -0
- package/assets/scripts/maintenance/build-context.mjs +162 -0
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +238 -0
- package/assets/scripts/maintenance/provenance-compare.mjs +160 -0
- package/assets/scripts/maintenance/state-archive.mjs +238 -0
- package/assets/scripts/maintenance/state-native.mjs +56 -0
- package/assets/scripts/maintenance/state-operation.mjs +249 -0
- package/assets/scripts/runtime/README.md +24 -0
- package/assets/scripts/runtime/check-client.mjs +21 -0
- package/assets/scripts/runtime/check-start.mjs +15 -0
- package/assets/scripts/runtime/client-setup.mjs +197 -0
- package/assets/scripts/runtime/entrypoint.sh +13 -0
- package/assets/scripts/runtime/health-cowork.sh +11 -0
- package/assets/scripts/runtime/health-messenger.mjs +6 -0
- package/assets/scripts/runtime/health-telegram.sh +8 -0
- package/assets/scripts/runtime/healthcheck.mjs +17 -0
- package/assets/scripts/runtime/runtime-common.mjs +47 -0
- package/assets/scripts/runtime/start-cowork.sh +6 -0
- package/assets/scripts/runtime/start-messenger.sh +6 -0
- package/assets/scripts/runtime/start-telegram.sh +6 -0
- package/assets/sources.json +21 -0
- package/install.sh +2 -1
- package/lib/build-transition.mjs +56 -0
- package/lib/docker-conversion-runtime.mjs +96 -0
- package/lib/docker-layout-installation.mjs +62 -0
- package/lib/effects.mjs +945 -11
- package/lib/extras.mjs +23 -68
- package/lib/layout-conversion.mjs +297 -0
- package/lib/orchestrate-uninstall.mjs +30 -1
- package/lib/orchestrate.mjs +265 -18
- package/lib/plan.mjs +194 -1
- package/lib/target.mjs +100 -0
- package/lib/usage.mjs +27 -2
- package/package.json +9 -2
- package/uninstall.sh +2 -0
package/lib/extras.mjs
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// The v3 installer keeps harness plugins, ours-fleet, voice setup and the
|
|
4
4
|
// copy-paste hand-off prompt.
|
|
5
5
|
//
|
|
6
|
-
// The shared daemon belongs to the operator CLI.
|
|
7
|
-
//
|
|
6
|
+
// The shared daemon belongs to the operator CLI. Client profiles select that
|
|
7
|
+
// daemon for MCP-backed harnesses and Fleet; these phases preserve the selection.
|
|
8
8
|
//
|
|
9
9
|
// Pure, like target.mjs / plan.mjs / components.mjs: no I/O, no subprocess, no
|
|
10
10
|
// terminal. Every function takes what was observed and returns a plan; the
|
|
@@ -14,6 +14,9 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
14
14
|
import { pkgSpec } from './logic.mjs';
|
|
15
15
|
|
|
16
16
|
const cfgPath = (stateDir) => join(resolve(stateDir), 'config.json');
|
|
17
|
+
export const shellQuote = (value) => /^[A-Za-z0-9_./:@%+=,-]+$/.test(value)
|
|
18
|
+
? value
|
|
19
|
+
: `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
17
20
|
|
|
18
21
|
// -----------------------------------------------------------------------------
|
|
19
22
|
// Harness plugins
|
|
@@ -125,18 +128,20 @@ export function planHarnessPlugins({
|
|
|
125
128
|
harnesses = [],
|
|
126
129
|
stateDir,
|
|
127
130
|
isDefaultStateDir,
|
|
131
|
+
configPath = null,
|
|
128
132
|
channel = 'latest',
|
|
129
133
|
assumeYes = false,
|
|
130
134
|
answers = {},
|
|
131
135
|
} = {}) {
|
|
132
|
-
const
|
|
136
|
+
const explicitProfile = typeof configPath === 'string' && configPath !== '';
|
|
137
|
+
const config = explicitProfile ? resolve(configPath) : stateDir ? cfgPath(stateDir) : null;
|
|
133
138
|
return harnesses.map((h) => {
|
|
134
139
|
const name = String(h?.name ?? '');
|
|
135
140
|
const known = HARNESSES.find((k) => k.name === name);
|
|
136
141
|
const label = known?.label ?? name;
|
|
137
142
|
const status = String(h?.status ?? 'absent');
|
|
138
143
|
const support = HARNESS_ENV_SUPPORT[name] ?? 'printed';
|
|
139
|
-
const applies = !isDefaultStateDir && support === 'applied';
|
|
144
|
+
const applies = explicitProfile || (!isDefaultStateDir && support === 'applied');
|
|
140
145
|
|
|
141
146
|
const base = {
|
|
142
147
|
name,
|
|
@@ -144,14 +149,17 @@ export function planHarnessPlugins({
|
|
|
144
149
|
status,
|
|
145
150
|
// Default state directory → today's behaviour, byte for byte: no env
|
|
146
151
|
// anywhere, nothing extra printed, nothing claimed.
|
|
147
|
-
envSupport: isDefaultStateDir ? 'none' : support,
|
|
152
|
+
envSupport: explicitProfile ? support : isDefaultStateDir ? 'none' : support,
|
|
148
153
|
env: applies ? { OURS_CONFIG: config } : {},
|
|
149
|
-
envLine: isDefaultStateDir || applies ? null : `export OURS_CONFIG=${config}`,
|
|
150
|
-
claimsPair: isDefaultStateDir ? true : applies,
|
|
154
|
+
envLine: explicitProfile && support === 'printed' ? `export OURS_CONFIG=${shellQuote(config)}` : isDefaultStateDir || applies ? null : `export OURS_CONFIG=${shellQuote(config)}`,
|
|
155
|
+
claimsPair: explicitProfile ? support === 'applied' : isDefaultStateDir ? true : applies,
|
|
151
156
|
manual: manualSteps[name] ? manualSteps[name](channel) : [],
|
|
152
157
|
};
|
|
153
158
|
|
|
154
159
|
if (!known) return { ...base, action: 'skip', reason: 'unknown harness' };
|
|
160
|
+
if (explicitProfile && name === 'hermes') {
|
|
161
|
+
return { ...base, env: {}, envLine: null, claimsPair: false, action: 'skip', reason: 'not selected for host-profile setup' };
|
|
162
|
+
}
|
|
155
163
|
if (status === 'absent') return { ...base, action: 'skip', reason: 'not installed' };
|
|
156
164
|
|
|
157
165
|
const wanted = assumeYes ? true : answers[name] !== false;
|
|
@@ -210,61 +218,12 @@ export function restartHints(summary = []) {
|
|
|
210
218
|
}
|
|
211
219
|
|
|
212
220
|
// -----------------------------------------------------------------------------
|
|
213
|
-
// ours-fleet —
|
|
221
|
+
// ours-fleet — configured by its native wizard, never started implicitly
|
|
214
222
|
// -----------------------------------------------------------------------------
|
|
215
223
|
|
|
216
224
|
export const fleetConfigPath = (home) => join(resolve(home), 'fleet.yaml');
|
|
217
225
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
/** A conservative, useful first fleet: one coordinator, one watchdog, one loop. */
|
|
221
|
-
export function defaultFleetConfig({ home, stateDir, isDefaultStateDir } = {}) {
|
|
222
|
-
const cwd = resolve(home);
|
|
223
|
-
const config = stateDir ? cfgPath(stateDir) : null;
|
|
224
|
-
const roleEnv = isDefaultStateDir || !config
|
|
225
|
-
? ''
|
|
226
|
-
: `\n env:\n OURS_CONFIG: ${yamlString(config)}`;
|
|
227
|
-
return `# Generated by ours-install. Review this file before starting Fleet.\n`
|
|
228
|
-
+ `defaults:\n`
|
|
229
|
-
+ ` harness: codex\n`
|
|
230
|
-
+ ` session: acp\n`
|
|
231
|
-
+ ` permissions:\n`
|
|
232
|
-
+ ` approval: allow\n`
|
|
233
|
-
+ ` filesystem: workspace\n`
|
|
234
|
-
+ ` unattended: wait\n`
|
|
235
|
-
+ ` monitor:\n`
|
|
236
|
-
+ ` mode: fleet\n\n`
|
|
237
|
-
+ `roles:\n`
|
|
238
|
-
+ ` FleetCoordinator:\n`
|
|
239
|
-
+ ` identity: FleetCoordinator\n`
|
|
240
|
-
+ ` cwd: ${yamlString(cwd)}\n`
|
|
241
|
-
+ ` mission: Coordinate durable agent work, delegate bounded tasks, and report material outcomes.\n`
|
|
242
|
-
+ ` bio: Fleet coordinator for this host; engage it to assign work or check agent status.\n`
|
|
243
|
-
+ ` persona: |\n`
|
|
244
|
-
+ ` Keep a concise durable worklog. Preserve user state, verify delegated results,\n`
|
|
245
|
-
+ ` and escalate decisions that require new authority. Report material progress only.${roleEnv}\n\n`
|
|
246
|
-
+ `watchdogs:\n`
|
|
247
|
-
+ ` fleet-health:\n`
|
|
248
|
-
+ ` coordinator: FleetCoordinator\n`
|
|
249
|
-
+ ` watch: [FleetCoordinator]\n`
|
|
250
|
-
+ ` harness: codex\n`
|
|
251
|
-
+ ` session: acp\n`
|
|
252
|
-
+ ` interval: 10m\n`
|
|
253
|
-
+ ` timeout: 8m\n\n`
|
|
254
|
-
+ `loops:\n`
|
|
255
|
-
+ ` coordinator_health:\n`
|
|
256
|
-
+ ` roles: [FleetCoordinator]\n`
|
|
257
|
-
+ ` interval: 10m\n`
|
|
258
|
-
+ ` initial_delay: 10m\n`
|
|
259
|
-
+ ` enabled: true\n`
|
|
260
|
-
+ ` prompt: |\n`
|
|
261
|
-
+ ` Perform one bounded fleet health pass. Reconcile active work, specialist state,\n`
|
|
262
|
-
+ ` declared blockers, and CI. Unstick only safe in-scope work. If nothing material\n`
|
|
263
|
-
+ ` changed, complete silently.\n`;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export function planFleet({ home, stateDir, isDefaultStateDir, wanted = true, channel = 'latest' } = {}) {
|
|
267
|
-
const config = stateDir ? cfgPath(stateDir) : null;
|
|
226
|
+
export function planFleet({ home, stateDir, isDefaultStateDir, configPath = null, settingsPath = null, wanted = true, channel = 'latest' } = {}) {
|
|
268
227
|
const resolvedHome = home ?? (stateDir ? dirname(resolve(stateDir)) : null);
|
|
269
228
|
const path = resolvedHome ? fleetConfigPath(resolvedHome) : null;
|
|
270
229
|
const plan = {
|
|
@@ -282,14 +241,10 @@ export function planFleet({ home, stateDir, isDefaultStateDir, wanted = true, ch
|
|
|
282
241
|
// tg-connector fatal. lib/logic.mjs is the single source of that mapping and
|
|
283
242
|
// this defers to it rather than restating it.
|
|
284
243
|
install: ['npm', 'i', '-g', pkgSpec('fleet', channel)],
|
|
285
|
-
init: ['ours-fleet', 'init'],
|
|
244
|
+
init: ['ours-fleet', 'init', ...(path ? ['--configuration', path] : []),
|
|
245
|
+
...(settingsPath ? ['--settings', resolve(settingsPath)] : [])],
|
|
286
246
|
configPath: path,
|
|
287
|
-
|
|
288
|
-
writes: path ? [path] : [],
|
|
289
|
-
roleEnv: isDefaultStateDir ? {} : { OURS_CONFIG: config },
|
|
290
|
-
instruction: isDefaultStateDir
|
|
291
|
-
? `review ${path}, then run ours-fleet doctor and ours-fleet up when you are ready`
|
|
292
|
-
: `review ${path}; its coordinator is pinned to this daemon with OURS_CONFIG=${config}`,
|
|
247
|
+
instruction: `review ${path}, then run ours-fleet doctor and ours-fleet up when you are ready`,
|
|
293
248
|
};
|
|
294
249
|
return wanted ? { ...plan, action: 'install' } : { ...plan, action: 'skip', offerOnRerun: true };
|
|
295
250
|
}
|
|
@@ -319,9 +274,9 @@ export function buildHandoffPromptV3({
|
|
|
319
274
|
}
|
|
320
275
|
if (fleet) {
|
|
321
276
|
steps.push(
|
|
322
|
-
'Review ~/fleet.yaml with me.
|
|
323
|
-
+ '
|
|
324
|
-
+ ' before
|
|
277
|
+
'Review the Fleet wizard output in ~/fleet.yaml with me. Keep its selected\n'
|
|
278
|
+
+ ' models, roles, templates, and permissions unless I ask to change them.\n'
|
|
279
|
+
+ ' Ask before starting the fleet.',
|
|
325
280
|
);
|
|
326
281
|
}
|
|
327
282
|
if (telegram) {
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { installationPaths, validateInstallation, SERVER_SERVICES } from './plan.mjs';
|
|
5
|
+
import { copyPrivateTree, createArchive, validateArchive, scanSource } from '../assets/scripts/maintenance/state-archive.mjs';
|
|
6
|
+
import { publishNoReplace } from '../assets/scripts/maintenance/state-native.mjs';
|
|
7
|
+
|
|
8
|
+
import { recordNames, readBuildRecords, initializeBuildMarker } from '../assets/scripts/maintenance/build-context.mjs';
|
|
9
|
+
const PROVENANCE_DIRECTORY = '.ours-provenance';
|
|
10
|
+
const COMPONENTS = [...SERVER_SERVICES, 'mcp'];
|
|
11
|
+
const CONSUMERS = SERVER_SERVICES.filter(service => service !== 'daemon');
|
|
12
|
+
|
|
13
|
+
function privateFile(path) {
|
|
14
|
+
const stat = fs.lstatSync(path);
|
|
15
|
+
if (!stat.isFile() || stat.uid !== process.getuid() || stat.gid !== process.getgid() || (stat.mode & 0o7777) !== 0o600) {
|
|
16
|
+
throw new Error(`Unsafe conversion source file: ${path}`);
|
|
17
|
+
}
|
|
18
|
+
return fs.readFileSync(path);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readConfig(path) {
|
|
22
|
+
const value = JSON.parse(privateFile(path));
|
|
23
|
+
if (!value || Array.isArray(value) || typeof value !== 'object') {
|
|
24
|
+
throw new Error('Conversion source configuration must be an object');
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeConfig(path, value) {
|
|
30
|
+
fs.writeFileSync(path, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
|
|
31
|
+
fs.chmodSync(path, 0o600);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function conversionPaths(record, staging) {
|
|
35
|
+
const paths = installationPaths(record);
|
|
36
|
+
const parent = join(record.root, 'storage', '.maintenance');
|
|
37
|
+
if (dirname(staging) !== parent || resolve(staging) !== staging || fs.realpathSync(parent) !== parent) {
|
|
38
|
+
throw new Error('Conversion staging must use the selected private maintenance directory');
|
|
39
|
+
}
|
|
40
|
+
const parentStat = fs.lstatSync(parent);
|
|
41
|
+
if (!parentStat.isDirectory() || parentStat.uid !== process.getuid() || parentStat.gid !== process.getgid() || (parentStat.mode & 0o7777) !== 0o700) {
|
|
42
|
+
throw new Error('Conversion staging parent must be private and owned by the operator');
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
paths,
|
|
46
|
+
staged: path => join(staging, relative(paths.state, path)),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// DEPRECATED (introduced in 2.0): legacy managed-layout conversion only.
|
|
51
|
+
// Removal target: 3.0 after supported upgrades no longer need this reader.
|
|
52
|
+
// Retain protected backups and supported archive import.
|
|
53
|
+
// The caller holds installation exclusion and has stopped/prepared all writers.
|
|
54
|
+
export async function stageLegacyPackageState(sourceRecord, staging) {
|
|
55
|
+
validateInstallation(sourceRecord, sourceRecord.root);
|
|
56
|
+
if (sourceRecord.schema !== 1 || sourceRecord.mode !== 'packages') {
|
|
57
|
+
throw new Error('Select a legacy managed package installation');
|
|
58
|
+
}
|
|
59
|
+
const source = installationPaths(sourceRecord);
|
|
60
|
+
const targetRecord = { ...sourceRecord, schema: 2 };
|
|
61
|
+
targetRecord.configPath = installationPaths(targetRecord).config;
|
|
62
|
+
const { paths: target, staged } = conversionPaths(targetRecord, staging);
|
|
63
|
+
const ownership = { uid: process.getuid(), gid: process.getgid() };
|
|
64
|
+
const configBytes = privateFile(source.config);
|
|
65
|
+
const config = readConfig(source.config);
|
|
66
|
+
const nestedConfig = join(source.daemon, 'config.json');
|
|
67
|
+
if (fs.existsSync(nestedConfig) && !privateFile(nestedConfig).equals(configBytes)) {
|
|
68
|
+
throw new Error('Conflicting daemon config sources require explicit resolution');
|
|
69
|
+
}
|
|
70
|
+
const applicationConfig = config.networkMcp?.applicationConfigPath;
|
|
71
|
+
if (applicationConfig && applicationConfig !== join(source.mcp, 'config.json')) {
|
|
72
|
+
throw new Error('External MCP configuration ownership requires explicit resolution');
|
|
73
|
+
}
|
|
74
|
+
if (config.networkMcp && (
|
|
75
|
+
config.networkMcp.profile?.credentialPath !== join(source.daemon, 'daemon-token') ||
|
|
76
|
+
config.networkMcp.profile?.expectedInstanceId !== sourceRecord.instanceId
|
|
77
|
+
)) {
|
|
78
|
+
throw new Error('MCP profile does not select this managed installation');
|
|
79
|
+
}
|
|
80
|
+
if (fs.existsSync(join(source.daemon, '.mcp'))) {
|
|
81
|
+
throw new Error('Conflicting embedded and separate MCP sources require explicit resolution');
|
|
82
|
+
}
|
|
83
|
+
const provenance = readBuildRecords(sourceRecord.workDir);
|
|
84
|
+
fs.mkdirSync(staging, { mode: 0o700 });
|
|
85
|
+
try {
|
|
86
|
+
for (const component of COMPONENTS) {
|
|
87
|
+
copyPrivateTree(source[component], staged(target[component]), ownership);
|
|
88
|
+
if (component === 'mcp') continue;
|
|
89
|
+
const marker = join(staged(target[component]), PROVENANCE_DIRECTORY);
|
|
90
|
+
initializeBuildMarker(marker, provenance);
|
|
91
|
+
}
|
|
92
|
+
fs.writeFileSync(staged(target.config), configBytes, { mode: 0o600 });
|
|
93
|
+
for (const consumer of CONSUMERS) {
|
|
94
|
+
const destination = staged(target.credentials[consumer]);
|
|
95
|
+
fs.mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
|
|
96
|
+
fs.writeFileSync(destination, privateFile(source.credentials[consumer]), { mode: 0o600, flag: 'wx' });
|
|
97
|
+
}
|
|
98
|
+
scanSource(staging, ownership);
|
|
99
|
+
return targetRecord;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Call only after archiving and validating the still-unmodified staged payload. */
|
|
107
|
+
export function bindConvertedPackageState(targetRecord, staging) {
|
|
108
|
+
validateInstallation(targetRecord, targetRecord.root);
|
|
109
|
+
const { paths, staged } = conversionPaths(targetRecord, staging);
|
|
110
|
+
const configPath = staged(paths.config);
|
|
111
|
+
const config = readConfig(configPath);
|
|
112
|
+
config.stateDir = paths.daemon;
|
|
113
|
+
if (config.networkMcp) {
|
|
114
|
+
config.networkMcp.applicationConfigPath = join(paths.mcp, 'config.json');
|
|
115
|
+
config.networkMcp.profile.credentialPath = join(paths.daemon, 'daemon-token');
|
|
116
|
+
writeConfig(join(staged(paths.mcp), 'profile.json'), config.networkMcp.profile);
|
|
117
|
+
}
|
|
118
|
+
writeConfig(configPath, config);
|
|
119
|
+
const coworkPath = join(staged(paths.cowork), 'config.json');
|
|
120
|
+
if (fs.existsSync(coworkPath)) {
|
|
121
|
+
const cowork = readConfig(coworkPath);
|
|
122
|
+
cowork.stateDir = paths.cowork;
|
|
123
|
+
writeConfig(coworkPath, cowork);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Prepare stopped source data; publication and service activation belong to the caller. */
|
|
128
|
+
export async function prepareLegacyPackageState(sourceRecord, staging, backupPath) {
|
|
129
|
+
validateInstallation(sourceRecord, sourceRecord.root);
|
|
130
|
+
const backupParent = join(sourceRecord.root, 'storage', 'backups');
|
|
131
|
+
if (dirname(backupPath) !== backupParent || resolve(backupPath) !== backupPath || fs.realpathSync(backupParent) !== backupParent) {
|
|
132
|
+
throw new Error('Conversion backup must use the selected backup directory');
|
|
133
|
+
}
|
|
134
|
+
const targetRecord = await stageLegacyPackageState(sourceRecord, staging);
|
|
135
|
+
try {
|
|
136
|
+
const provenance = readBuildRecords(sourceRecord.workDir);
|
|
137
|
+
// createArchive validates the complete archive before publishing it without replacement.
|
|
138
|
+
// Keep original configuration bytes in the backup, then bind the deployment copy.
|
|
139
|
+
await createArchive(staging, backupPath, {
|
|
140
|
+
domain: 'server',
|
|
141
|
+
uid: process.getuid(),
|
|
142
|
+
gid: process.getgid(),
|
|
143
|
+
provenance,
|
|
144
|
+
});
|
|
145
|
+
bindConvertedPackageState(targetRecord, staging);
|
|
146
|
+
return targetRecord;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function pathExists(path) {
|
|
154
|
+
try { fs.lstatSync(path); return true; }
|
|
155
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function ensureConversionDirectory(path) {
|
|
159
|
+
if (!pathExists(path)) fs.mkdirSync(path, { mode: 0o700 });
|
|
160
|
+
const stat = fs.lstatSync(path);
|
|
161
|
+
if (!stat.isDirectory() || stat.uid !== process.getuid() || stat.gid !== process.getgid()
|
|
162
|
+
|| (stat.mode & 0o7777) !== 0o700 || fs.realpathSync(path) !== path) {
|
|
163
|
+
throw new Error(`Unsafe conversion directory: ${path}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function removePrivateTree(path) {
|
|
168
|
+
if (!pathExists(path)) return;
|
|
169
|
+
scanSource(path, { uid: process.getuid(), gid: process.getgid() });
|
|
170
|
+
fs.rmSync(path, { recursive: true });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function validateConvertedPackageState(record, tree = installationPaths(record).state) {
|
|
174
|
+
const paths = installationPaths(record);
|
|
175
|
+
const physical = path => join(tree, relative(paths.state, path));
|
|
176
|
+
scanSource(tree, { uid: process.getuid(), gid: process.getgid() });
|
|
177
|
+
const records = readBuildRecords(record.workDir);
|
|
178
|
+
const BUILD_RECORDS = recordNames(records);
|
|
179
|
+
for (const component of SERVER_SERVICES) {
|
|
180
|
+
const marker = join(physical(paths[component]), PROVENANCE_DIRECTORY);
|
|
181
|
+
if (fs.readdirSync(marker).sort().join() !== [...BUILD_RECORDS].sort().join()) {
|
|
182
|
+
throw new Error(`Incomplete converted ${component} provenance`);
|
|
183
|
+
}
|
|
184
|
+
for (const name of BUILD_RECORDS) {
|
|
185
|
+
if (!privateFile(join(marker, name)).equals(records[name])) {
|
|
186
|
+
throw new Error(`Converted ${component} differs from the selected build`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const configPath = join(physical(paths[component]), 'config.json');
|
|
190
|
+
if (pathExists(configPath)) readConfig(configPath);
|
|
191
|
+
}
|
|
192
|
+
const daemon = readConfig(physical(paths.config));
|
|
193
|
+
const profile = readConfig(join(physical(paths.mcp), 'profile.json'));
|
|
194
|
+
if (daemon.stateDir !== paths.daemon || daemon.port !== record.port
|
|
195
|
+
|| daemon.networkMcp?.applicationConfigPath !== join(paths.mcp, 'config.json')
|
|
196
|
+
|| profile.expectedInstanceId !== record.instanceId
|
|
197
|
+
|| profile.credentialPath !== join(paths.daemon, 'daemon-token')
|
|
198
|
+
|| profile.endpoint !== `http://127.0.0.1:${record.port}`
|
|
199
|
+
|| JSON.stringify(daemon.networkMcp.profile) !== JSON.stringify(profile)) {
|
|
200
|
+
throw new Error('Converted daemon/MCP deployment configuration is inconsistent');
|
|
201
|
+
}
|
|
202
|
+
if (pathExists(join(physical(paths.mcp), 'config.json'))) readConfig(join(physical(paths.mcp), 'config.json'));
|
|
203
|
+
if (readConfig(join(physical(paths.cowork), 'config.json')).stateDir !== paths.cowork) {
|
|
204
|
+
throw new Error('Converted Cowork configuration selects another state directory');
|
|
205
|
+
}
|
|
206
|
+
for (const credential of [profile.credentialPath, ...Object.values(paths.credentials)]) {
|
|
207
|
+
if (!privateFile(physical(credential)).length) throw new Error('Converted managed credential is empty');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The caller holds the installation lock. The record is the sole selection commit. */
|
|
212
|
+
export async function convertPackageInstallation(record, operation, effects) {
|
|
213
|
+
validateInstallation(record, record.root);
|
|
214
|
+
if (record.mode !== 'packages' || (record.schema !== 1 && !record.layoutConversion)) {
|
|
215
|
+
throw new Error('Select a legacy or pending package installation');
|
|
216
|
+
}
|
|
217
|
+
const selectionPath = join(record.root, 'installation.json');
|
|
218
|
+
const writeSelection = value => effects.writeJson(selectionPath, JSON.stringify(value, null, 2) + '\n');
|
|
219
|
+
const source = record.layoutConversion?.sourceRecord ?? record;
|
|
220
|
+
const resumingPublished = record.schema === 2;
|
|
221
|
+
const storage = join(record.root, 'storage');
|
|
222
|
+
const maintenance = join(storage, '.maintenance');
|
|
223
|
+
const backups = join(storage, 'backups');
|
|
224
|
+
const targetState = join(storage, 'state');
|
|
225
|
+
const staging = join(maintenance, 'layout-conversion');
|
|
226
|
+
|
|
227
|
+
if (record.schema === 1) {
|
|
228
|
+
if (readConfig(source.configPath).stateDir !== installationPaths(source).daemon) {
|
|
229
|
+
throw new Error('Daemon authority source differs from the selected legacy state');
|
|
230
|
+
}
|
|
231
|
+
ensureConversionDirectory(record.root);
|
|
232
|
+
for (const path of [storage, maintenance, backups]) ensureConversionDirectory(path);
|
|
233
|
+
if (!record.layoutConversion && (pathExists(targetState) || pathExists(staging))) {
|
|
234
|
+
throw new Error('Unowned conversion destination already exists');
|
|
235
|
+
}
|
|
236
|
+
await effects.prepareInstallation(source, { runtimeOnly: true });
|
|
237
|
+
if (!record.layoutConversion) {
|
|
238
|
+
const runningServices = await effects.serverLifecycle(source, 'status');
|
|
239
|
+
record = {
|
|
240
|
+
...source,
|
|
241
|
+
layoutConversion: {
|
|
242
|
+
version: 1, sourceRecord: source, runningServices,
|
|
243
|
+
backupPath: join(backups, `before-layout-2-${randomUUID()}`),
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
writeSelection(record);
|
|
247
|
+
}
|
|
248
|
+
await effects.retireLegacyServices(source);
|
|
249
|
+
await effects.prepareLegacyPackageSource(source);
|
|
250
|
+
removePrivateTree(staging);
|
|
251
|
+
try {
|
|
252
|
+
const target = await stageLegacyPackageState(source, staging);
|
|
253
|
+
const archiveOptions = {
|
|
254
|
+
domain: 'server', uid: process.getuid(), gid: process.getgid(),
|
|
255
|
+
provenance: readBuildRecords(source.workDir),
|
|
256
|
+
};
|
|
257
|
+
const backupPath = record.layoutConversion.backupPath;
|
|
258
|
+
if (pathExists(backupPath)) await validateArchive(backupPath, archiveOptions);
|
|
259
|
+
else await createArchive(staging, backupPath, archiveOptions);
|
|
260
|
+
await effects.retainConvertedPackageAuthority(source, join(staging, 'daemon'));
|
|
261
|
+
bindConvertedPackageState(target, staging);
|
|
262
|
+
validateConvertedPackageState(target, staging);
|
|
263
|
+
// A schema-1 pending record reserves this destination. Rebuild an interrupted
|
|
264
|
+
// publication from the stopped source; never merge two copies of state.
|
|
265
|
+
removePrivateTree(targetState);
|
|
266
|
+
publishNoReplace(staging, targetState);
|
|
267
|
+
record = { ...target, layoutConversion: record.layoutConversion };
|
|
268
|
+
writeSelection(record);
|
|
269
|
+
} finally {
|
|
270
|
+
removePrivateTree(staging);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const selected = ['install', 'start'].includes(operation)
|
|
275
|
+
? record.services : record.layoutConversion.runningServices;
|
|
276
|
+
if (resumingPublished) {
|
|
277
|
+
// A previous readiness failure can leave some new-layout services running.
|
|
278
|
+
// Offline validation and residue preparation require stopping those writers.
|
|
279
|
+
await effects.serverLifecycle(record, 'stop');
|
|
280
|
+
await effects.prepareLegacyPackageSource(record);
|
|
281
|
+
}
|
|
282
|
+
validateConvertedPackageState(record);
|
|
283
|
+
await effects.serverLifecycle(record, 'start', selected);
|
|
284
|
+
|
|
285
|
+
// Selection is already committed: cleanup retries stay on the new state.
|
|
286
|
+
const oldPaths = installationPaths(source);
|
|
287
|
+
for (const component of COMPONENTS) removePrivateTree(oldPaths[component]);
|
|
288
|
+
removePrivateTree(join(source.root, 'credentials'));
|
|
289
|
+
if (pathExists(source.configPath)) {
|
|
290
|
+
privateFile(source.configPath);
|
|
291
|
+
fs.unlinkSync(source.configPath);
|
|
292
|
+
}
|
|
293
|
+
const finished = { ...record };
|
|
294
|
+
delete finished.layoutConversion;
|
|
295
|
+
writeSelection(finished);
|
|
296
|
+
return finished;
|
|
297
|
+
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// here that cannot be undone by re-running the installer.
|
|
19
19
|
|
|
20
20
|
import { join } from 'node:path';
|
|
21
|
-
import { parseInstallArgs, InstallUsageError } from './target.mjs';
|
|
21
|
+
import { parseInstallArgs, resolveProfileSelection, profileEnv, InstallUsageError } from './target.mjs';
|
|
22
22
|
import { planUninstall, planComponentDetach, planStatePurge, stripManagedBlock, planHarnessSelection, selectHarnesses, planGlobalPackages, planPluginRemoval, parseUninstallEnv } from './uninstall.mjs';
|
|
23
23
|
import { tgConfigPath, coworkConfigPath } from './components.mjs';
|
|
24
24
|
import { configJournal, reportRollback } from './journal.mjs';
|
|
@@ -91,6 +91,35 @@ export async function runUninstall(argv, effects) {
|
|
|
91
91
|
return EXIT_REFUSED;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
let profileSelection;
|
|
95
|
+
try {
|
|
96
|
+
profileSelection = resolveProfileSelection({
|
|
97
|
+
args, env: effects.env, home: effects.home, exists: effects.exists, readProfile: effects.readProfile,
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error instanceof InstallUsageError) {
|
|
101
|
+
effects.out(warn(`ours: ${error.message}. Nothing was changed.`));
|
|
102
|
+
return EXIT_REFUSED;
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
if (profileSelection.mode === 'host-profile') {
|
|
107
|
+
effects.out(info(`removing client attachments for external daemon ${profileSelection.profile.endpoint}; daemon and Compose services are untouched`));
|
|
108
|
+
const discoveredPlugins = planPluginRemoval({
|
|
109
|
+
home: effects.home, env: effects.env, exists: effects.exists,
|
|
110
|
+
lastDaemon: true, explicitSelection: contract.engaged,
|
|
111
|
+
});
|
|
112
|
+
const harnesses = discoveredPlugins.harnesses.filter((harness) => harness.key !== 'hermes');
|
|
113
|
+
const plugins = { ...discoveredPlugins, harnesses, packages: harnesses.map((harness) => harness.pkg) };
|
|
114
|
+
const outcome = await runPluginPhase(plugins, { args, effects, selection: contract.engaged ? contract.harnesses : null });
|
|
115
|
+
const env = profileEnv(profileSelection);
|
|
116
|
+
for (const pkg of outcome.packages) {
|
|
117
|
+
await perform(effects, args.dryRun, `npm rm -g ${pkg}`, () => effects.run('npm', ['rm', '-g', pkg], { env }));
|
|
118
|
+
}
|
|
119
|
+
effects.out(info(`operator-owned host profile ${profileSelection.configPath} and shared credential ${profileSelection.profile.credentialPath} kept${purge ? ' under --purge' : ''}`));
|
|
120
|
+
return EXIT_OK;
|
|
121
|
+
}
|
|
122
|
+
|
|
94
123
|
// OURS_UNINSTALL_DAEMON decides whether this is an uninstall of the daemon at
|
|
95
124
|
// all. Before this gate, OURS_UNINSTALL="hermes" — a script asking for one
|
|
96
125
|
// harness plugin — tore down the daemon, its service and the global packages,
|