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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,9 +7,10 @@ npm install -g @ours.network/install@nightly
7
7
  ours-install
8
8
  ```
9
9
 
10
- With no arguments, the console form collects the installation choices: server
11
- and clients (default), runtime mode, installation directory, install/update,
12
- Human identity name, ports, client integrations and Fleet settings. Linux x64
10
+ With no arguments, the wizard explains each step and offers keyboard choices.
11
+ Use arrow keys and Enter to choose, and Space to select integrations. Recommended
12
+ settings keep advanced ports and package overrides out of the usual flow. Text
13
+ input is used for your name and explicitly customized paths or ports. Linux x64
13
14
  recommends native mode; macOS and Windows recommend Docker. On Windows run the
14
15
  installer inside WSL with Docker Desktop integration. This recommendation concerns
15
16
  packaging and isolation, not a guarantee that emulated x64 is faster on ARM Macs.
@@ -55,6 +56,44 @@ The installer embeds exact component versions and SHA-512 values for its release
55
56
  An existing server's retained release selects matching local clients. Secrets are
56
57
  read from protected files, and client setup never copies the daemon API master.
57
58
 
59
+ ## Migrate an existing global installation
60
+
61
+ The wizard detects the old `~/.ours/config.json` and offers to keep its identities,
62
+ messages, keys and settings in the new installation. It follows the configured
63
+ state directory, including a custom location. An equivalent unattended preset is:
64
+
65
+ ```sh
66
+ ours-install all --mode docker --state-dir "$HOME/ours-docker" \
67
+ --migrate-from "$HOME/.ours/config.json" --compatible --integrations codex
68
+ ```
69
+
70
+ Run the old daemon before the first migration so the installer can record and
71
+ verify its identities. The new runtime is downloaded/built first. Then the old
72
+ boot service and daemon stop, and the complete daemon state is copied while its
73
+ writer lock is held. Docker migration imports that copy into its named storage
74
+ volume. Existing Human/role names and CIDs must match before applications start;
75
+ migration never creates a replacement Human.
76
+
77
+ The original state/configuration remain unchanged. A private `legacy-backup`
78
+ directory stores the original config/service definition, and
79
+ `legacy-client/profile.json` is a prepared connection profile for the new server.
80
+ Selected integrations are configured by the usual client setup. The global `ours` command is switched to a launcher for the managed installation
81
+ only after verifying ownership of its existing npm entry; foreign or read-only
82
+ commands are refused before migration. Ordinary `ours identity list` is checked
83
+ after cutover. Lifecycle commands manage the new installation, and selection
84
+ overrides cannot accidentally return to the old daemon. The old configuration is
85
+ not reinterpreted as a client profile. Do not start the old state alongside the
86
+ migrated daemon. Reinstalling the global CLI with npm can replace the launcher;
87
+ repeat the same migration command to verify and repair that routing.
88
+
89
+ Repeat the same command after an interruption. Once the destination has started,
90
+ retries repair it in place instead of replacing it with an older source snapshot.
91
+ The source is kept stopped after an activation failure to avoid diverging identity
92
+ sessions. Migration requires local Linux/macOS (or WSL) with `node:sqlite` support,
93
+ a verifiable old CLI/service, and a separate target directory. External PostgreSQL
94
+ history or unsupported external MCP/token-delivery paths are refused before
95
+ shutdown. Unrelated application data outside the daemon tree is not copied.
96
+
58
97
  ## Selected network installations (2.0)
59
98
 
60
99
  The installed package carries its compatible `assets/sources.json` policy.
@@ -71,7 +110,7 @@ ours-install server start --state-dir /private/ours-install
71
110
  ours-install server restart --state-dir /private/ours-install
72
111
  ```
73
112
 
74
- Choose one mode for a private installation root. The private `installation.json`
113
+ Choose one mode and a folder for its programs and data. The private `installation.json`
75
114
  records mode, instance selection, configuration/source paths and service selection;
76
115
  it never records master bytes or an issued-token registry. The selected policy
77
116
  is resolved to an exact, role-filtered `sources.json` before acquisition.
package/assets/Dockerfile CHANGED
@@ -3,7 +3,9 @@ FROM node:24 AS toolchain
3
3
  RUN apt-get update && apt-get install -y --no-install-recommends build-essential python3 ca-certificates git && rm -rf /var/lib/apt/lists/*
4
4
  FROM toolchain AS build
5
5
  WORKDIR /opt/ours
6
- COPY sources.json /opt/ours/sources.json
6
+ # The host selection is private (0600); this non-secret image policy must be
7
+ # readable by arbitrary runtime UIDs while remaining root-owned and non-writable.
8
+ COPY --chmod=644 sources.json /opt/ours/sources.json
7
9
  COPY scripts/build/*.mjs /build-scripts/
8
10
  COPY scripts/maintenance/build-context.mjs scripts/maintenance/provenance-compare.mjs scripts/maintenance/release-graph.mjs /maintenance/
9
11
  RUN --mount=type=secret,id=github_token --mount=type=cache,id=ours-dist-2-npm,target=/root/.npm,sharing=locked node /build-scripts/build.mjs
@@ -21,6 +23,7 @@ COPY scripts/runtime/runtime-common.mjs /opt/ours/docker/runtime-common.mjs
21
23
  COPY scripts/runtime/check-client.mjs /opt/ours/docker/check-client.mjs
22
24
  COPY scripts/runtime/check-start.mjs /opt/ours/docker/check-start.mjs
23
25
  COPY scripts/runtime/client-setup.mjs /opt/ours/docker/client-setup.mjs
26
+ COPY scripts/runtime/legacy-import.mjs /opt/ours/docker/legacy-import.mjs
24
27
  COPY scripts/runtime/entrypoint.sh /opt/ours/docker/entrypoint.sh
25
28
  COPY scripts/runtime/healthcheck.mjs /opt/ours/docker/healthcheck.mjs
26
29
  COPY scripts/runtime/start-telegram.sh scripts/runtime/health-telegram.sh scripts/runtime/start-cowork.sh scripts/runtime/health-cowork.sh scripts/runtime/start-messenger.sh scripts/runtime/health-messenger.mjs /opt/ours/docker/
@@ -177,6 +177,26 @@ services:
177
177
  start_period: 30s
178
178
  retries: 12
179
179
 
180
+ legacy-import:
181
+ profiles: [maintenance]
182
+ build:
183
+ <<: *source-build
184
+ target: maintenance
185
+ platform: linux/amd64
186
+ image: "${OURS_MAINTENANCE_IMAGE:-ours-compose-2-maintenance:local}"
187
+ user: "0:0"
188
+ network_mode: none
189
+ read_only: true
190
+ cap_drop: [ALL]
191
+ cap_add: [CHOWN, DAC_OVERRIDE]
192
+ security_opt: [no-new-privileges:true]
193
+ entrypoint: [node, /opt/ours/docker/legacy-import.mjs]
194
+ environment:
195
+ OURS_UID: "${OURS_UID:-1000}"
196
+ OURS_GID: "${OURS_GID:-1000}"
197
+ volumes:
198
+ - {type: volume, source: server-storage, target: /storage, volume: {nocopy: true}}
199
+
180
200
  state-operation:
181
201
  profiles: [maintenance]
182
202
  build:
@@ -565,9 +565,9 @@
565
565
  ]
566
566
  },
567
567
  "node_modules/@anthropic-ai/sdk": {
568
- "version": "0.126.0",
569
- "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.126.0.tgz",
570
- "integrity": "sha512-VhiZl6rA/8uC+MgDaOhEcAWaIZ2tPnIY885jlZqxrGrutUW2nqCtophBtlsX0tk5kChBUjV/1NVjL3y0M4OwUg==",
568
+ "version": "0.127.0",
569
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.127.0.tgz",
570
+ "integrity": "sha512-OKiVbG9LHCxjn4fBJZJFy/Bz3hqE1RYpwaeSEDNheQnvz0XNKmGT/Lhkz2Sf1a27Ldt3vVv+BkU81I0U/8EpoQ==",
571
571
  "license": "MIT",
572
572
  "optional": true,
573
573
  "peer": true,
@@ -811,9 +811,9 @@
811
811
  }
812
812
  },
813
813
  "node_modules/@fastify/websocket": {
814
- "version": "11.3.0",
815
- "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz",
816
- "integrity": "sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==",
814
+ "version": "11.3.1",
815
+ "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.1.tgz",
816
+ "integrity": "sha512-3117Fteg0MC9zRl0ilNAQelRb+PNDvNuWe0yJ+jnLthaPPZnlml+GraGpesNUEQvLl8+3C+W4aKZz10XmQ10Mg==",
817
817
  "funding": [
818
818
  {
819
819
  "type": "github",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "channel": "nightly",
4
- "installerVersion": "1.2.1-nightly.2",
4
+ "installerVersion": "1.2.1-nightly.4",
5
5
  "packages": {
6
6
  "@ours.network/sdk": {
7
7
  "version": "3.8.1-nightly.3",
@@ -0,0 +1,103 @@
1
+ /** Import a stopped, read-only host migration stage into the managed Docker volume. */
2
+ import * as fs from 'node:fs';
3
+ import { dirname, join, resolve, isAbsolute } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { randomUUID } from 'node:crypto';
6
+ // Repository layout and the maintenance image intentionally place this binding differently.
7
+ const nativeModule = new URL('../maintenance/state-native.mjs', import.meta.url);
8
+ const { publishNoReplace } = await import(fs.existsSync(nativeModule) ? nativeModule.href : new URL('./state-native.mjs', import.meta.url).href);
9
+
10
+ const MARKER = '.ours-legacy-import.json';
11
+ const COMPONENTS = ['mcp', 'daemon'];
12
+ const fail = message => { throw Error(`Legacy volume import: ${message}`); };
13
+ function canonical(path) {
14
+ if (typeof path !== 'string' || !isAbsolute(path) || resolve(path) !== path) fail('paths must be normalized absolute paths');
15
+ if (fs.realpathSync(path) !== path) fail('symlink paths are not supported');
16
+ }
17
+ function privateEntry(path, uid, gid, directory) {
18
+ const stat = fs.lstatSync(path);
19
+ if (!(directory ? stat.isDirectory() : stat.isFile()) || stat.isSymbolicLink()
20
+ || stat.uid !== uid || stat.gid !== gid
21
+ || (directory ? (stat.mode & 0o7777) !== 0o700 : ((stat.mode & 0o7077) !== 0 || !(stat.mode & 0o400)))
22
+ || (!directory && stat.nlink !== 1)) fail('unsafe ownership, permissions, or linked state entry');
23
+ return stat;
24
+ }
25
+ function scan(root, uid, gid) {
26
+ const entries = [];
27
+ function visit(path, relative) {
28
+ const stat = fs.lstatSync(path), directory = stat.isDirectory();
29
+ privateEntry(path, uid, gid, directory); entries.push({ relative, directory, mode: stat.mode & 0o700 });
30
+ if (directory) for (const name of fs.readdirSync(path).sort()) visit(join(path, name), relative ? join(relative, name) : name);
31
+ }
32
+ canonical(root); visit(root, ''); return entries;
33
+ }
34
+ function readReceipt(root, uid, gid, targetRoot) {
35
+ privateEntry(join(root, MARKER), uid, gid, false);
36
+ const value = JSON.parse(fs.readFileSync(join(root, MARKER), 'utf8'));
37
+ const keys = ['sourceStateDir', 'sourceConfigPath', 'targetRoot', 'rootName'];
38
+ if (!value || typeof value !== 'object' || Array.isArray(value)
39
+ || Object.keys(value).sort().join('\0') !== [...keys].sort().join('\0')
40
+ || keys.some(key => typeof value[key] !== 'string' || !value[key])
41
+ || value.targetRoot !== targetRoot
42
+ || ['sourceStateDir', 'sourceConfigPath', 'targetRoot'].some(key => !isAbsolute(value[key]) || resolve(value[key]) !== value[key])) fail('invalid or mismatched migration receipt');
43
+ return Object.fromEntries(keys.map(key => [key, value[key]]));
44
+ }
45
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
46
+ function existingDestination(path, expected, uid, gid, targetRoot) {
47
+ if (!fs.existsSync(path)) return false;
48
+ canonical(path); privateEntry(path, uid, gid, true);
49
+ if (!fs.readdirSync(path).length) return false;
50
+ if (!fs.existsSync(join(path, MARKER)) || !same(readReceipt(path, uid, gid, targetRoot), expected)) fail('nonmatching existing destination');
51
+ return true;
52
+ }
53
+ function makeDirectory(path, uid, gid) {
54
+ if (!fs.existsSync(path)) { fs.mkdirSync(path, { mode: 0o700 }); fs.chownSync(path, uid, gid); }
55
+ canonical(path); privateEntry(path, uid, gid, true);
56
+ }
57
+
58
+ /** Caller holds installation exclusion and has stopped all destination writers. */
59
+ export function importLegacyVolume({ sourceRoot = '/legacy-import', storageRoot = '/storage', targetRoot, uid, gid }) {
60
+ if (![uid, gid].every(value => Number.isSafeInteger(value) && value >= 0)) fail('numeric target UID/GID required');
61
+ if (typeof targetRoot !== 'string' || !isAbsolute(targetRoot) || resolve(targetRoot) !== targetRoot) fail('expected target root required');
62
+ canonical(sourceRoot); canonical(storageRoot);
63
+ const sourceOwner = fs.lstatSync(sourceRoot);
64
+ privateEntry(sourceRoot, sourceOwner.uid, sourceOwner.gid, true);
65
+ const sources = COMPONENTS.map(component => {
66
+ const path = join(sourceRoot, component);
67
+ const entries = scan(path, sourceOwner.uid, sourceOwner.gid);
68
+ return { component, path, entries, receipt: readReceipt(path, sourceOwner.uid, sourceOwner.gid, targetRoot) };
69
+ });
70
+ if (!same(sources[0].receipt, sources[1].receipt)) fail('source daemon and MCP receipts differ');
71
+ const rootStat = fs.lstatSync(storageRoot);
72
+ const freshRoot = rootStat.isDirectory() && rootStat.uid === 0 && rootStat.gid === 0 && fs.readdirSync(storageRoot).length === 0;
73
+ if (!freshRoot) privateEntry(storageRoot, uid, gid, true);
74
+ const state = join(storageRoot, 'state');
75
+ if (fs.existsSync(state)) { canonical(state); privateEntry(state, uid, gid, true); }
76
+ // Validate every existing destination before changing even an empty volume.
77
+ for (const source of sources) source.reused = existingDestination(join(state, source.component), source.receipt, uid, gid, targetRoot);
78
+ if (freshRoot) { fs.chmodSync(storageRoot, 0o700); fs.chownSync(storageRoot, uid, gid); }
79
+ makeDirectory(state, uid, gid);
80
+ for (const source of sources) {
81
+ if (source.reused) continue;
82
+ const destination = join(state, source.component), staging = join(state, `.legacy-import-${randomUUID()}`);
83
+ try {
84
+ fs.cpSync(source.path, staging, { recursive: true, errorOnExist: true, force: false });
85
+ for (const entry of [...source.entries].reverse()) {
86
+ const path = entry.relative ? join(staging, entry.relative) : staging;
87
+ fs.chmodSync(path, entry.mode); fs.chownSync(path, uid, gid);
88
+ }
89
+ scan(staging, uid, gid);
90
+ if (!same(readReceipt(staging, uid, gid, targetRoot), source.receipt)) fail('copied receipt changed');
91
+ if (fs.existsSync(destination)) fs.rmdirSync(destination); // empty skeleton only
92
+ publishNoReplace(staging, destination);
93
+ } finally { fs.rmSync(staging, { recursive: true, force: true }); }
94
+ }
95
+ return { imported: sources.filter(source => !source.reused).map(source => source.component), retained: sources.filter(source => source.reused).map(source => source.component) };
96
+ }
97
+
98
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
99
+ try {
100
+ importLegacyVolume({ targetRoot: process.env.OURS_LEGACY_TARGET_ROOT, uid: Number(process.env.OURS_UID), gid: Number(process.env.OURS_GID) });
101
+ console.log('Legacy daemon and MCP state imported into the selected volume.');
102
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
103
+ }
@@ -2,7 +2,7 @@
2
2
  "release": {
3
3
  "schema": 1,
4
4
  "channel": "nightly",
5
- "installerVersion": "1.2.1-nightly.2",
5
+ "installerVersion": "1.2.1-nightly.4",
6
6
  "packages": {
7
7
  "@ours.network/sdk": {
8
8
  "version": "3.8.1-nightly.3",
package/lib/effects.mjs CHANGED
@@ -21,7 +21,7 @@ import { maintenanceServices, installationPaths, validateInstallation, consumerS
21
21
  import { validateHostProfile } from './target.mjs';
22
22
  import { createServerOnboarding } from './server-onboarding.mjs';
23
23
  import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
24
- import { askYesNo, askLine as askLineOnTty } from './prompt.mjs';
24
+ import { select as selectOnTty, multiselect as multiselectOnTty, askLine as askLineOnTty } from './prompt.mjs';
25
25
  import { classifyHarnessProbe } from './logic.mjs';
26
26
  import { classifyStateDir } from './detect.mjs';
27
27
  import { BASE_RECORDS, CONTEXT, readBuildRecords, equalBuildRecords, initializeBuildMarker } from '../assets/scripts/maintenance/build-context.mjs';
@@ -444,7 +444,9 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
444
444
  hasClaudePlugin,
445
445
  out: out ?? ((line) => process.stdout.write(`${line}\n`)),
446
446
  // Never called when assumeYes: the orchestrator takes the default itself.
447
- ask: async (prompt, def = false) => (ttyFd == null ? def : askYesNo(write, ttyFd, ` ${prompt} `, def)),
447
+ select: async (question, choices, def) => selectOnTty(write, ttyFd, question, choices, def),
448
+ multiselect: async (question, choices, defaults = []) => multiselectOnTty(write, ttyFd, question, choices, defaults),
449
+ ask: async (question, def = false) => selectOnTty(write, ttyFd, question, [{ value: true, label: 'Yes' }, { value: false, label: 'No' }], def),
448
450
  askLine: async (prompt, def = '') => (ttyFd == null ? def : askLineOnTty(write, ttyFd, ` ${prompt} `, def)),
449
451
  };
450
452
  return Object.assign(effects, networkEffects(effects));
@@ -680,7 +682,17 @@ export function networkEffects(effects) {
680
682
  }
681
683
  }
682
684
  },
683
- async initializeSelection(record, manifest) {
685
+ async prepareLegacyDockerImport(record) {
686
+ if (record.mode === 'docker') await compose(record, ['build', 'legacy-import'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
687
+ },
688
+ async importLegacyDockerState(record) {
689
+ if (record.mode !== 'docker') return;
690
+ const source = join(record.root, 'storage', 'state');
691
+ if (source.includes(':')) throw new Error('Docker migration requires an installation path without colon characters');
692
+ await compose(record, ['run', '--rm', '--no-deps', '-T', '--volume', `${source}:/legacy-import:ro`,
693
+ '--env', `OURS_LEGACY_TARGET_ROOT=${record.root}`, 'legacy-import']);
694
+ },
695
+ async initializeSelection(record, manifest, { retainConfig = false } = {}) {
684
696
  if (typeof manifest === 'string') manifest = JSON.parse(readFileSync(manifest, 'utf8'));
685
697
  selectSourcePackages(manifest, 'server');
686
698
  const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
@@ -688,8 +700,9 @@ export function networkEffects(effects) {
688
700
  if (record.schema === 2) {
689
701
  for (const path of [join(record.root, 'storage'), installationPaths(record).state, installationPaths(record).daemon]) ensurePrivateDirectory(path);
690
702
  }
691
- writePrivateNew(record.sourcesPath, bytes);
692
- writePrivateNew(record.configPath, JSON.stringify({ stateDir: record.mode === 'docker' ? '/var/lib/ours' : installationPaths(record).daemon, port: record.port, apiVisibility: 'owner' }, null, 2) + '\n');
703
+ if (!retainConfig || !existsSync(record.sourcesPath)) writePrivateNew(record.sourcesPath, bytes);
704
+ else if (!readFileSync(record.sourcesPath).equals(bytes)) throw new Error('Migration source selection changed');
705
+ if (!retainConfig) writePrivateNew(record.configPath, JSON.stringify({ stateDir: record.mode === 'docker' ? '/var/lib/ours' : installationPaths(record).daemon, port: record.port, apiVisibility: 'owner' }, null, 2) + '\n');
693
706
  },
694
707
  async prepareInstallation(record, { runtimeOnly = false } = {}) {
695
708
  let copied = false;
@@ -0,0 +1,202 @@
1
+ /** Local-only migration: preserve opaque daemon state and retire its old launcher. */
2
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
3
+ import { join, basename } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { inspectLegacyState, stageLegacyState, withLegacyStateLock, ensureLegacyLockSupport } from './legacy-state.mjs';
6
+ import { inspectManagedCli, installManagedCli } from './managed-cli.mjs';
7
+ import { atomicWriteConfig } from './config.mjs';
8
+ import { classifyUnit, unitPathForStateDir, validateInstallation } from './plan.mjs';
9
+
10
+ const journalPath = root => join(root, 'legacy-migration.json');
11
+ const parse = result => JSON.parse(result.stdout);
12
+ const phases = ['prepared', 'stopped', 'copied', 'activating', 'verified', 'complete'];
13
+ function readJournal(root) {
14
+ const path = journalPath(root);
15
+ if (!existsSync(path)) return null;
16
+ const j = JSON.parse(readFileSync(path));
17
+ if (j.schema !== 1 || j.targetRoot !== root || !phases.includes(j.phase) || !Array.isArray(j.identities)) throw new Error('Invalid legacy migration journal');
18
+ return j;
19
+ }
20
+ function selection(source) {
21
+ return { env: { OURS_CONFIG: source.configPath, OURS_STATE_DIR: source.stateDir, OURS_PORT: String(source.config.port ?? 3050), OURS_API_TOKEN: undefined, OURS_DAEMON_ID: undefined, OURS_DAEMON_URL: undefined, OURS_DAEMON_CREDENTIAL_PATH: undefined }, sensitive: true };
22
+ }
23
+ async function oldCommand(effects, source, args, options = {}) {
24
+ return effects.run(source.originalProgram ?? 'ours', [...args, '--config', source.configPath, '--state-dir', source.stateDir, '--json'], { ...selection(source), ...options });
25
+ }
26
+ function validateIdentities(rows, source) {
27
+ if (!Array.isArray(rows) || !rows.length || rows.some(r => !['root', 'role'].includes(r.kind) || typeof r.cid !== 'string' || !r.cid || typeof r.name !== 'string')) throw new Error('Legacy identities are not fully restored; migration requires a healthy source daemon');
28
+ const roots = rows.filter(r => r.kind === 'root');
29
+ if (roots.length !== 1 || roots[0].name !== source.rootName) throw new Error('Legacy Human identity does not match its stored root');
30
+ return rows.map(({ name, kind, cid }) => ({ name, kind, cid })).sort((a,b) => a.name.localeCompare(b.name));
31
+ }
32
+
33
+ export async function inspectLegacyMigration(options, effects, deps = {}) {
34
+ const inspect = deps.inspectLegacyState ?? inspectLegacyState;
35
+ await (deps.ensureLegacyLockSupport ?? ensureLegacyLockSupport)();
36
+ const cliPlan = options.dryRun ? null : await (deps.inspectManagedCli ?? inspectManagedCli)(effects, options.stateDir);
37
+ const journal = readJournal(options.stateDir);
38
+ if (journal) {
39
+ if (journal.sourceConfig !== options.migrateFrom) throw new Error('Another legacy source is already selected for this target');
40
+ return { journal, cliPlan, source: journal.phase === 'complete' ? null : { ...inspect(options.migrateFrom, options.stateDir), originalProgram: cliPlan?.originalProgram } };
41
+ }
42
+ const source = { ...inspect(options.migrateFrom, options.stateDir), originalProgram: cliPlan?.originalProgram };
43
+ if (options.mode === 'docker' && options.stateDir.includes(':')) throw new Error('Docker migration requires an installation path without colon characters');
44
+ if (effects.readJson(join(options.stateDir, 'installation.json'))) throw new Error('Legacy migration requires a new managed installation root');
45
+ if (existsSync(options.stateDir) && readdirSync(options.stateDir).length) throw new Error('Legacy migration target must be empty');
46
+ if (options.dryRun) return { source, journal: { schema: 1, sourceConfig: source.configPath, sourceStateDir: source.stateDir, targetRoot: options.stateDir, phase: 'prepared', identities: [], service: null } };
47
+ // The owning CLI verifies endpoint, process and state directory. No PID guessing.
48
+ const status = parse(await oldCommand(effects, source, ['daemon', 'status']));
49
+ if (status.state !== 'running' || status.stateDir !== source.stateDir) throw new Error('Start the legacy daemon before migration so its identities can be verified');
50
+ const identities = validateIdentities(parse(await oldCommand(effects, source, ['identity', 'list'])), source);
51
+ const service = parse(await oldCommand(effects, source, ['daemon', 'uninstall-service', '--dry-run']));
52
+ if (service.conflict) {
53
+ const legacy = effects.platform?.platform === 'linux' && source.stateDir === join(effects.home, '.ours')
54
+ ? unitPathForStateDir(source.stateDir, effects.home) : null;
55
+ if (!legacy?.ok || legacy.path !== service.serviceFile || classifyUnit(effects.readText(legacy.path)).kind !== 'legacy') {
56
+ throw new Error('Legacy boot service ownership could not be verified: ' + service.conflict.message);
57
+ }
58
+ service.legacyUnit = legacy.unit;
59
+ }
60
+ return { source, cliPlan, journal: { schema: 1, sourceConfig: source.configPath, sourceStateDir: source.stateDir, targetRoot: options.stateDir,
61
+ phase: 'prepared', identities, service, sourcePort: source.config.port ?? 3050 } };
62
+ }
63
+
64
+ export async function prepareMigrationCliRuntime(record, effects) {
65
+ const root = join(record.root, 'launcher-runtime');
66
+ const entry = join(root, 'node_modules', '@ours.network', 'install', 'install.mjs');
67
+ const ready = join(root, '.ready');
68
+ if (existsSync(ready) && existsSync(entry)) return entry;
69
+ mkdirSync(root, { recursive: true, mode: 0o700 });
70
+ effects.out('Preparing permanent management commands so ours will keep working after this installer exits.');
71
+ const packageRoot = fileURLToPath(new URL('..', import.meta.url));
72
+ const packed = parse(await effects.run('npm', ['pack', packageRoot, '--ignore-scripts', '--pack-destination', root, '--json']));
73
+ if (!Array.isArray(packed) || packed.length !== 1 || typeof packed[0].filename !== 'string' || basename(packed[0].filename) !== packed[0].filename) throw new Error('Cannot prepare the persistent installer package');
74
+ await effects.run('npm', ['install', '--prefix', root, '--prefer-offline', '--ignore-scripts', '--no-audit', '--no-fund', join(root, packed[0].filename)], { stream: true });
75
+ if (!existsSync(entry)) throw new Error('Persistent installer entry is missing');
76
+ await effects.run(process.execPath, [entry, '--help']);
77
+ atomicWriteConfig(ready, 'ready\n');
78
+ return entry;
79
+ }
80
+
81
+ /** Called under the normal installation lock; retries never overwrite imported state. */
82
+ export async function executeLegacyMigration(args, effects, install, deps = {}) {
83
+ const stage = deps.stageLegacyState ?? stageLegacyState;
84
+ const lock = deps.withLegacyStateLock ?? withLegacyStateLock;
85
+ const save = (root, value) => (deps.atomicWriteConfig ?? atomicWriteConfig)(journalPath(root), JSON.stringify(value, null, 2) + '\n');
86
+ const prepareCli = deps.prepareMigrationCliRuntime ?? prepareMigrationCliRuntime;
87
+ const inspected = args.legacyPlan ?? await inspectLegacyMigration(args, effects, deps);
88
+ let journal = readJournal(args.stateDir) ?? inspected.journal;
89
+ if (journal.phase === 'complete') {
90
+ const retained = effects.readJson(join(args.stateDir, 'installation.json'));
91
+ inspected.cliPlan.installerPath = await prepareCli(retained, effects);
92
+ await (deps.installManagedCli ?? installManagedCli)(retained, inspected.cliPlan, effects);
93
+ const rows = validateIdentities(parse(await effects.run('ours', ['identity', 'list', '--json'])), { rootName: journal.identities.find(row => row.kind === 'root')?.name });
94
+ if (JSON.stringify(rows) !== JSON.stringify(journal.identities)) throw new Error('Completed migration identity verification failed; no replacement Human was created');
95
+ effects.out('Legacy migration already completed; retained identities verified.');
96
+ return 0;
97
+ }
98
+ const source = inspected.source;
99
+ let record;
100
+ let sourceLock;
101
+ let activationAttempted = phases.indexOf(journal.phase) >= phases.indexOf('activating');
102
+ const wrapped = { ...effects,
103
+ newInstallation(root, mode) {
104
+ if (journal.record) {
105
+ if (journal.record.root !== root || journal.record.mode !== mode) throw new Error('Migration target selection changed');
106
+ return validateInstallation(journal.record, root);
107
+ }
108
+ return effects.newInstallation(root, mode);
109
+ },
110
+ async initializeSelection(selected, manifest) {
111
+ record = selected;
112
+ selected.legacyMigrationSource = args.migrateFrom;
113
+ journal.record = selected;
114
+ journal.sourcePolicy = args.sourcePolicy;
115
+ journal.sourceManifest = manifest;
116
+ save(selected.root, journal);
117
+ effects.writeJson(join(selected.root, 'installation.json'), JSON.stringify(selected, null, 2) + '\n');
118
+ await effects.initializeSelection(selected, manifest, { retainConfig: true });
119
+ },
120
+ async serverPreflight(selected, operation, options) {
121
+ if (journal.record && !existsSync(selected.sourcesPath)) {
122
+ if (!journal.sourceManifest) throw new Error('Migration journal lacks its retained package selection');
123
+ await effects.initializeSelection(selected, journal.sourceManifest, { retainConfig: true });
124
+ }
125
+ return effects.serverPreflight(selected, operation, options);
126
+ },
127
+ async prepareInstallation(selected) {
128
+ record = selected;
129
+ if (!existsSync(selected.sourcesPath)) {
130
+ const manifest = journal.sourceManifest ?? await effects.resolveSourcePolicy(args.sourcePolicy, 'server');
131
+ await effects.initializeSelection(selected, manifest, { retainConfig: true });
132
+ }
133
+ await effects.prepareInstallation(selected, { runtimeOnly: true });
134
+ if (selected.mode === 'docker') await effects.prepareLegacyDockerImport(selected);
135
+ inspected.cliPlan.installerPath = await prepareCli(selected, effects);
136
+ journal.cliInstallerPath = inspected.cliPlan.installerPath;
137
+ if (activationAttempted) await effects.serverLifecycle(selected, 'stop');
138
+ // Backups contain secrets; the enclosing installation is private.
139
+ const backup = join(selected.root, 'legacy-backup');
140
+ mkdirSync(backup, { mode: 0o700, recursive: true });
141
+ const configBackup = join(backup, 'config.json');
142
+ if (!existsSync(configBackup)) writeFileSync(configBackup, readFileSync(source.configPath), { flag: 'wx', mode: 0o600 });
143
+ if (journal.service.serviceFile && existsSync(journal.service.serviceFile) && !existsSync(join(backup, 'service'))) {
144
+ writeFileSync(join(backup, 'service'), readFileSync(journal.service.serviceFile), { flag: 'wx', mode: 0o600 });
145
+ }
146
+ save(selected.root, journal);
147
+ if (!activationAttempted) {
148
+ effects.out('Migration: retire the old boot service and stop its daemon.');
149
+ if (journal.service.legacyUnit) await effects.run('systemctl', ['--user', 'disable', '--now', journal.service.legacyUnit]);
150
+ else await oldCommand(effects, source, ['daemon', 'uninstall-service', '--yes']);
151
+ await oldCommand(effects, source, ['daemon', 'stop']);
152
+ journal.phase = 'stopped'; save(selected.root, journal);
153
+ }
154
+ // Hold the SDK-compatible owner lock until activation finishes, including failures.
155
+ sourceLock = await lock(source.stateDir);
156
+ effects.out('Migration: copy retained identities, keys, history and session state.');
157
+ stage(source, selected);
158
+ if (selected.mode === 'docker') await effects.importLegacyDockerState(selected);
159
+ if (!activationAttempted) { journal.phase = 'copied'; save(selected.root, journal); }
160
+ await effects.prepareInstallation(selected);
161
+ },
162
+ async serverLifecycle(selected, operation, services) {
163
+ if (operation === 'start') {
164
+ activationAttempted = true;
165
+ journal.phase = 'activating'; save(selected.root, journal);
166
+ }
167
+ return effects.serverLifecycle(selected, operation, services);
168
+ },
169
+ async serverEnsureIdentity(selected) {
170
+ const rows = await effects.serverListIdentities(selected);
171
+ const actual = validateIdentities(rows, source);
172
+ if (JSON.stringify(actual) !== JSON.stringify(journal.identities)) throw new Error('Migrated identities differ from the source; no replacement Human was created');
173
+ journal.phase = 'verified'; save(selected.root, journal);
174
+ const root = actual.find(r => r.kind === 'root');
175
+ return { ...root, created: false };
176
+ },
177
+ };
178
+ try {
179
+ const result = await install({ ...args, migrate: true }, wrapped);
180
+ if (result !== 0) throw new Error('Migrated server setup did not complete');
181
+ // Publish an explicit host profile; a daemon --config file is not a client profile.
182
+ const directory = join(record.root, 'legacy-client');
183
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
184
+ const credentialPath = join(directory, 'credential');
185
+ if (!existsSync(credentialPath)) await effects.serverAccess(record, 'access-issue', { output: credentialPath });
186
+ const profile = { endpoint: `http://127.0.0.1:${record.port}`, expectedInstanceId: record.instanceId, credentialPath };
187
+ atomicWriteConfig(join(directory, 'profile.json'), JSON.stringify(profile, null, 2) + '\n');
188
+ await (deps.installManagedCli ?? installManagedCli)(record, inspected.cliPlan, effects);
189
+ const defaultRows = validateIdentities(parse(await effects.run('ours', ['identity', 'list', '--json'])), source);
190
+ if (JSON.stringify(defaultRows) !== JSON.stringify(journal.identities)) throw new Error('Default ours command does not select the migrated identities');
191
+ journal.phase = 'complete'; save(record.root, journal);
192
+ effects.out(`Migration complete. Original state remains at ${source.stateDir}; original config/service are backed up in ${join(record.root, 'legacy-backup')}. Do not start the original state alongside the migrated daemon.`);
193
+ return 0;
194
+ } catch (error) {
195
+ if (record && activationAttempted) {
196
+ try { await effects.serverLifecycle(record, 'stop'); }
197
+ catch { throw new Error(`Migration activation failed and destination shutdown could not be confirmed. Keep the source stopped; repair this same target. ${error.message}`); }
198
+ throw new Error(`Migration paused after activation; source remains stopped to avoid diverging identity sessions. Repeat the same migration command to repair the retained target. ${error.message}`);
199
+ }
200
+ throw new Error(`Migration paused before activation; source data is retained. Repeat the same migration command. ${error.message}`);
201
+ } finally { await sourceLock?.close(); }
202
+ }