@ours.network/install 1.2.1-nightly.2 → 1.2.1-nightly.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -4
- package/assets/Dockerfile +1 -0
- package/assets/docker-compose.yaml +20 -0
- package/assets/release-lock.json +6 -6
- package/assets/release.json +1 -1
- package/assets/scripts/runtime/legacy-import.mjs +103 -0
- package/assets/sources.json +1 -1
- package/lib/effects.mjs +18 -5
- package/lib/legacy-migration.mjs +202 -0
- package/lib/legacy-state.mjs +205 -0
- package/lib/managed-cli.mjs +164 -0
- package/lib/orchestrate.mjs +5 -1
- package/lib/prompt.mjs +113 -119
- package/lib/server-onboarding.mjs +1 -0
- package/lib/setup-options.mjs +127 -31
- package/lib/setup.mjs +12 -2
- package/lib/usage.mjs +4 -2
- package/package.json +1 -1
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
|
|
11
|
-
and
|
|
12
|
-
|
|
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
|
|
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
|
@@ -21,6 +21,7 @@ COPY scripts/runtime/runtime-common.mjs /opt/ours/docker/runtime-common.mjs
|
|
|
21
21
|
COPY scripts/runtime/check-client.mjs /opt/ours/docker/check-client.mjs
|
|
22
22
|
COPY scripts/runtime/check-start.mjs /opt/ours/docker/check-start.mjs
|
|
23
23
|
COPY scripts/runtime/client-setup.mjs /opt/ours/docker/client-setup.mjs
|
|
24
|
+
COPY scripts/runtime/legacy-import.mjs /opt/ours/docker/legacy-import.mjs
|
|
24
25
|
COPY scripts/runtime/entrypoint.sh /opt/ours/docker/entrypoint.sh
|
|
25
26
|
COPY scripts/runtime/healthcheck.mjs /opt/ours/docker/healthcheck.mjs
|
|
26
27
|
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:
|
package/assets/release-lock.json
CHANGED
|
@@ -565,9 +565,9 @@
|
|
|
565
565
|
]
|
|
566
566
|
},
|
|
567
567
|
"node_modules/@anthropic-ai/sdk": {
|
|
568
|
-
"version": "0.
|
|
569
|
-
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.
|
|
570
|
-
"integrity": "sha512-
|
|
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.
|
|
815
|
-
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.
|
|
816
|
-
"integrity": "sha512-
|
|
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",
|
package/assets/release.json
CHANGED
|
@@ -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
|
+
}
|
package/assets/sources.json
CHANGED
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|