@ours.network/install 1.1.0 → 1.2.0-nightly.1

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.
Files changed (49) hide show
  1. package/README.md +196 -6
  2. package/assets/Dockerfile +41 -0
  3. package/assets/docker-compose.yaml +222 -0
  4. package/assets/scripts/README.md +20 -0
  5. package/assets/scripts/build/README.md +39 -0
  6. package/assets/scripts/build/build-common.mjs +37 -0
  7. package/assets/scripts/build/build-cowork.mjs +2 -0
  8. package/assets/scripts/build/build-fleet.mjs +2 -0
  9. package/assets/scripts/build/build-mcp.mjs +9 -0
  10. package/assets/scripts/build/build-messenger.mjs +2 -0
  11. package/assets/scripts/build/build-sdk.mjs +9 -0
  12. package/assets/scripts/build/build-telegram.mjs +2 -0
  13. package/assets/scripts/build/build.mjs +49 -0
  14. package/assets/scripts/build/record-build.mjs +31 -0
  15. package/assets/scripts/maintenance/README.md +35 -0
  16. package/assets/scripts/maintenance/build-context.mjs +162 -0
  17. package/assets/scripts/maintenance/docker-layout-conversion.mjs +238 -0
  18. package/assets/scripts/maintenance/provenance-compare.mjs +160 -0
  19. package/assets/scripts/maintenance/state-archive.mjs +238 -0
  20. package/assets/scripts/maintenance/state-native.mjs +56 -0
  21. package/assets/scripts/maintenance/state-operation.mjs +249 -0
  22. package/assets/scripts/runtime/README.md +24 -0
  23. package/assets/scripts/runtime/check-client.mjs +21 -0
  24. package/assets/scripts/runtime/check-start.mjs +15 -0
  25. package/assets/scripts/runtime/client-setup.mjs +197 -0
  26. package/assets/scripts/runtime/entrypoint.sh +13 -0
  27. package/assets/scripts/runtime/health-cowork.sh +11 -0
  28. package/assets/scripts/runtime/health-messenger.mjs +6 -0
  29. package/assets/scripts/runtime/health-telegram.sh +8 -0
  30. package/assets/scripts/runtime/healthcheck.mjs +17 -0
  31. package/assets/scripts/runtime/runtime-common.mjs +47 -0
  32. package/assets/scripts/runtime/start-cowork.sh +6 -0
  33. package/assets/scripts/runtime/start-messenger.sh +6 -0
  34. package/assets/scripts/runtime/start-telegram.sh +6 -0
  35. package/assets/sources.json +21 -0
  36. package/install.sh +2 -1
  37. package/lib/build-transition.mjs +56 -0
  38. package/lib/docker-conversion-runtime.mjs +96 -0
  39. package/lib/docker-layout-installation.mjs +62 -0
  40. package/lib/effects.mjs +945 -11
  41. package/lib/extras.mjs +23 -68
  42. package/lib/layout-conversion.mjs +297 -0
  43. package/lib/orchestrate-uninstall.mjs +30 -1
  44. package/lib/orchestrate.mjs +265 -18
  45. package/lib/plan.mjs +194 -1
  46. package/lib/target.mjs +100 -0
  47. package/lib/usage.mjs +27 -2
  48. package/package.json +9 -2
  49. 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. ours-mcp is only a per-session
7
- // stdio adapter with no unit, and these extra phases preserve that boundary.
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 config = stateDir ? cfgPath(stateDir) : null;
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 — installed and staged, never started implicitly
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
- const yamlString = (value) => JSON.stringify(String(value));
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
- config: resolvedHome ? defaultFleetConfig({ home: resolvedHome, stateDir, isDefaultStateDir }) : null,
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. It already contains a stopped FleetCoordinator,\n'
323
- + ' a fleet-health watchdog, and a 10-minute coordinator health loop. Ask\n'
324
- + ' before changing permissions or starting the fleet.',
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,