@ours.network/install 1.2.1-nightly.3 → 1.2.1-nightly.31
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/GATEWAY_SETUP.md +178 -0
- package/README.md +353 -9
- package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
- package/assets/Dockerfile +17 -13
- package/assets/Dockerfile.gateway +5 -0
- package/assets/docker-compose.yaml +4 -1
- package/assets/fleet-settings.example.json +48 -0
- package/assets/gateway-entrypoint.sh +37 -0
- package/assets/release-lock.json +1167 -4499
- package/assets/release.json +37 -19
- package/assets/scripts/build/build-common.mjs +2 -1
- package/assets/scripts/build/build-sdk.mjs +6 -1
- package/assets/scripts/maintenance/build-context.mjs +1 -1
- package/assets/scripts/maintenance/daemon-owner.mjs +15 -0
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +7 -3
- package/assets/scripts/maintenance/release-graph.mjs +18 -1
- package/assets/scripts/maintenance/state-operation.mjs +4 -2
- package/assets/scripts/runtime/client-setup.mjs +80 -6
- package/assets/scripts/runtime/diagnostics.mjs +72 -0
- package/assets/scripts/runtime/entrypoint.sh +1 -1
- package/assets/scripts/runtime/health-messenger.mjs +7 -6
- package/assets/scripts/runtime/notifications-gateway.mjs +82 -0
- package/assets/scripts/runtime/start-messenger.sh +25 -4
- package/assets/scripts/runtime/supervise.mjs +35 -0
- package/assets/sources.json +54 -28
- package/install.mjs +7 -2
- package/lib/build-transition.mjs +3 -0
- package/lib/client-cli.mjs +127 -0
- package/lib/container-engine.mjs +127 -0
- package/lib/diagnostics.mjs +1 -0
- package/lib/docker-conversion-runtime.mjs +3 -2
- package/lib/docker-layout-installation.mjs +2 -1
- package/lib/docker-runtime-repair.mjs +160 -0
- package/lib/effects.mjs +298 -132
- package/lib/gateway-transition.mjs +87 -0
- package/lib/gateway.mjs +158 -0
- package/lib/legacy-migration.mjs +13 -2
- package/lib/orchestrate.mjs +135 -43
- package/lib/plan.mjs +27 -11
- package/lib/podman-preflight.mjs +40 -0
- package/lib/server-onboarding.mjs +164 -16
- package/lib/server-ports.mjs +17 -0
- package/lib/setup-options.mjs +88 -27
- package/lib/setup.mjs +35 -18
- package/lib/target.mjs +24 -8
- package/lib/uninstall.mjs +2 -2
- package/lib/usage.mjs +19 -6
- package/lib/workspace-setup.mjs +83 -0
- package/package.json +4 -2
package/assets/sources.json
CHANGED
|
@@ -2,82 +2,108 @@
|
|
|
2
2
|
"release": {
|
|
3
3
|
"schema": 1,
|
|
4
4
|
"channel": "nightly",
|
|
5
|
-
"installerVersion": "1.2.1-nightly.
|
|
5
|
+
"installerVersion": "1.2.1-nightly.31",
|
|
6
6
|
"packages": {
|
|
7
7
|
"@ours.network/sdk": {
|
|
8
|
-
"version": "3.8.1-nightly.
|
|
9
|
-
"integrity": "sha512-
|
|
8
|
+
"version": "3.8.1-nightly.15",
|
|
9
|
+
"integrity": "sha512-ynKaKmKtINBqYk3kNovsgEO/3TiFY1CC9FgZzrOH+H6onnM3J//Q8cBb9sun8dl9mhB4mKZYgvneSiOejYwZUw=="
|
|
10
10
|
},
|
|
11
11
|
"@ours.network/cli": {
|
|
12
|
-
"version": "2.8.1-nightly.
|
|
13
|
-
"integrity": "sha512-
|
|
12
|
+
"version": "2.8.1-nightly.13",
|
|
13
|
+
"integrity": "sha512-1BeAG12vh4ruBOeJ54l60qWUhLzPLRNKnGtRiJpQmSXkamNQglrPQeXa9xW3taAM4Vg5uYgEhsz6+ieM+1TUSA=="
|
|
14
14
|
},
|
|
15
15
|
"@ours.network/tg-connector": {
|
|
16
|
-
"version": "1.0.1-nightly.
|
|
17
|
-
"integrity": "sha512
|
|
16
|
+
"version": "1.0.1-nightly.9",
|
|
17
|
+
"integrity": "sha512-wm0V8c97aIg7p26L9My36I3BE9Cy2Xc5W7WgHg6Vo+hmPvE+jXmen86yA4+DxIu9VYxoVSMtpBbu76485eCeNw=="
|
|
18
18
|
},
|
|
19
19
|
"@ours.network/cowork": {
|
|
20
|
-
"version": "1.3.
|
|
21
|
-
"integrity": "sha512-
|
|
20
|
+
"version": "1.3.3-nightly.20261001.6d49d3a",
|
|
21
|
+
"integrity": "sha512-tqoLOWc4O8CGwLdO1j6ukm6Vn57EE0zbuxKo4BWd0QVwu9ObKgHCEMOKPgW2mXHeESXSv9m7edcIrnK8I5gD+w=="
|
|
22
22
|
},
|
|
23
23
|
"@ours.network/messenger-server": {
|
|
24
|
-
"version": "1.0.31-nightly.
|
|
25
|
-
"integrity": "sha512-
|
|
24
|
+
"version": "1.0.31-nightly.14",
|
|
25
|
+
"integrity": "sha512-0lMoIadihMQqN0sdW20DF4a94Nc6bx0lnaCckSnn5VqOmvqELNdG3kRZ1Heh9VwkPEu6qNM27AR7tWpTbzvJCg=="
|
|
26
|
+
},
|
|
27
|
+
"@ours.network/notifications": {
|
|
28
|
+
"version": "0.3.1-nightly.1",
|
|
29
|
+
"integrity": "sha512-rIkrtixoHqBfgCKDDCEYFl4W2Zzh0hOw3axX2+GppQ9gYkujKrk9M2KLGoYhu8RwwTMRl497jgMcTBe7UAHCgA=="
|
|
26
30
|
},
|
|
27
31
|
"@ours.network/fleet": {
|
|
28
|
-
"version": "1.2.0-nightly.
|
|
29
|
-
"integrity": "sha512-
|
|
32
|
+
"version": "1.2.0-nightly.39",
|
|
33
|
+
"integrity": "sha512-ixcky/b22x2slW+NmPz2BVQVLFV+jkTFzRGFKpDLug2mugk6xIJiVyY50o8G5c+JXAjB+EN9pzIIrTH21Y48BA=="
|
|
30
34
|
},
|
|
31
35
|
"@ours.network/mcp": {
|
|
32
|
-
"version": "1.2.0-nightly.
|
|
33
|
-
"integrity": "sha512-
|
|
36
|
+
"version": "1.2.0-nightly.10",
|
|
37
|
+
"integrity": "sha512-59sxJOcQagNRR+43Z5ScFlqFDBIlZSMQi8iz9mpxp5Jjl38B0dmCFsv5e+srbEYrV3xaOg9ZBsT17OUABFAseQ=="
|
|
34
38
|
},
|
|
35
39
|
"@ours.network/codex": {
|
|
36
|
-
"version": "1.2.0-nightly.
|
|
37
|
-
"integrity": "sha512-
|
|
40
|
+
"version": "1.2.0-nightly.10",
|
|
41
|
+
"integrity": "sha512-7hV4R0w8tiQ/JKFU5MxtM7fihd33tzWDgT+Bn8cidasxI5zmTLqDEIv6om5LM/xk7UqcPw5gDN2kcJYiybnP9Q=="
|
|
38
42
|
},
|
|
39
43
|
"@ours.network/claude-code": {
|
|
40
|
-
"version": "1.2.0-nightly.
|
|
41
|
-
"integrity": "sha512-
|
|
44
|
+
"version": "1.2.0-nightly.10",
|
|
45
|
+
"integrity": "sha512-C3+H/vXespanVj9E1uQAWno9RFrI5KlK2SHSc7dnmRRuFVyRsoVcbvlpVI3nZ8snFaKVzbetebZr7vxd73AWSg=="
|
|
46
|
+
},
|
|
47
|
+
"@ours.network/daemon": {
|
|
48
|
+
"version": "3.8.1-nightly.7",
|
|
49
|
+
"integrity": "sha512-dDFDm+xERplzu4yXKvLjKGNaIdr9u4ku5/4jiI4g8do3qOX9VS0tF1+O67uSu4jqkypg860UdZRdn9Zce72H/A=="
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"hostCli": {
|
|
53
|
+
"@ours.network/sdk": {
|
|
54
|
+
"version": "3.8.1-nightly.15",
|
|
55
|
+
"integrity": "sha512-ynKaKmKtINBqYk3kNovsgEO/3TiFY1CC9FgZzrOH+H6onnM3J//Q8cBb9sun8dl9mhB4mKZYgvneSiOejYwZUw=="
|
|
56
|
+
},
|
|
57
|
+
"@ours.network/cli": {
|
|
58
|
+
"version": "2.8.1-nightly.13",
|
|
59
|
+
"integrity": "sha512-1BeAG12vh4ruBOeJ54l60qWUhLzPLRNKnGtRiJpQmSXkamNQglrPQeXa9xW3taAM4Vg5uYgEhsz6+ieM+1TUSA=="
|
|
42
60
|
}
|
|
43
61
|
}
|
|
44
62
|
},
|
|
45
63
|
"packages": {
|
|
46
64
|
"@ours.network/sdk": {
|
|
47
65
|
"type": "npm",
|
|
48
|
-
"version": "3.8.1-nightly.
|
|
66
|
+
"version": "3.8.1-nightly.15"
|
|
49
67
|
},
|
|
50
68
|
"@ours.network/cli": {
|
|
51
69
|
"type": "npm",
|
|
52
|
-
"version": "2.8.1-nightly.
|
|
70
|
+
"version": "2.8.1-nightly.13"
|
|
53
71
|
},
|
|
54
72
|
"@ours.network/tg-connector": {
|
|
55
73
|
"type": "npm",
|
|
56
|
-
"version": "1.0.1-nightly.
|
|
74
|
+
"version": "1.0.1-nightly.9"
|
|
57
75
|
},
|
|
58
76
|
"@ours.network/cowork": {
|
|
59
77
|
"type": "npm",
|
|
60
|
-
"version": "1.3.
|
|
78
|
+
"version": "1.3.3-nightly.20261001.6d49d3a"
|
|
61
79
|
},
|
|
62
80
|
"@ours.network/messenger-server": {
|
|
63
81
|
"type": "npm",
|
|
64
|
-
"version": "1.0.31-nightly.
|
|
82
|
+
"version": "1.0.31-nightly.14"
|
|
83
|
+
},
|
|
84
|
+
"@ours.network/notifications": {
|
|
85
|
+
"type": "npm",
|
|
86
|
+
"version": "0.3.1-nightly.1"
|
|
65
87
|
},
|
|
66
88
|
"@ours.network/fleet": {
|
|
67
89
|
"type": "npm",
|
|
68
|
-
"version": "1.2.0-nightly.
|
|
90
|
+
"version": "1.2.0-nightly.39"
|
|
69
91
|
},
|
|
70
92
|
"@ours.network/mcp": {
|
|
71
93
|
"type": "npm",
|
|
72
|
-
"version": "1.2.0-nightly.
|
|
94
|
+
"version": "1.2.0-nightly.10"
|
|
73
95
|
},
|
|
74
96
|
"@ours.network/codex": {
|
|
75
97
|
"type": "npm",
|
|
76
|
-
"version": "1.2.0-nightly.
|
|
98
|
+
"version": "1.2.0-nightly.10"
|
|
77
99
|
},
|
|
78
100
|
"@ours.network/claude-code": {
|
|
79
101
|
"type": "npm",
|
|
80
|
-
"version": "1.2.0-nightly.
|
|
102
|
+
"version": "1.2.0-nightly.10"
|
|
103
|
+
},
|
|
104
|
+
"@ours.network/daemon": {
|
|
105
|
+
"type": "npm",
|
|
106
|
+
"version": "3.8.1-nightly.7"
|
|
81
107
|
}
|
|
82
108
|
}
|
|
83
109
|
}
|
package/install.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
|
|
5
5
|
import { realEffects } from './lib/effects.mjs';
|
|
6
|
+
import { installerFailure } from './lib/diagnostics.mjs';
|
|
7
|
+
import { runWorkspaceSetup } from './lib/workspace-setup.mjs';
|
|
6
8
|
import { runSetup } from './lib/setup.mjs';
|
|
7
9
|
import { closeSync, makeWriter, openTty } from './lib/ui.mjs';
|
|
8
10
|
|
|
@@ -10,7 +12,10 @@ const version = JSON.parse(readFileSync(new URL('./package.json', import.meta.ur
|
|
|
10
12
|
const ttyFd = openTty();
|
|
11
13
|
|
|
12
14
|
try {
|
|
13
|
-
const
|
|
15
|
+
const args=process.argv.slice(2);
|
|
16
|
+
const workspace=args.some(v=>v==='--setup-workspace' || v.startsWith('--setup-workspace=') || v==='--setup-workspace-file' || v.startsWith('--setup-workspace-file='));
|
|
17
|
+
const execute=workspace ? (args,effects)=>runWorkspaceSetup(args,effects,runSetup) : runSetup;
|
|
18
|
+
const code = await execute(args, realEffects({
|
|
14
19
|
write: makeWriter(ttyFd),
|
|
15
20
|
ttyFd,
|
|
16
21
|
env: process.env,
|
|
@@ -18,7 +23,7 @@ try {
|
|
|
18
23
|
}));
|
|
19
24
|
process.exitCode = code;
|
|
20
25
|
} catch (error) {
|
|
21
|
-
process.stderr.write(`ours-install: ${
|
|
26
|
+
process.stderr.write(`ours-install: ${installerFailure(error)}\n`);
|
|
22
27
|
process.exitCode = 1;
|
|
23
28
|
} finally {
|
|
24
29
|
if (ttyFd != null) {
|
package/lib/build-transition.mjs
CHANGED
|
@@ -52,7 +52,10 @@ export async function serverBuildTransition(record, args, effects) {
|
|
|
52
52
|
save(transition);
|
|
53
53
|
}
|
|
54
54
|
await stage('Verify retained state and identities', () => effects.validateServerBuildState(record));
|
|
55
|
+
// Repeatable: an updated runtime may need volume state it introduced, such as notification credentials.
|
|
56
|
+
if (record.gateway) await stage('Prepare stored state for the updated runtime', () => effects.prepareServerVolumes(record));
|
|
55
57
|
await stage('Restore previously running services and check readiness', () => effects.serverLifecycle(record, 'start', runningServices));
|
|
58
|
+
if (record.gateway && ['daemon', 'cowork', 'gateway'].every(name => runningServices.includes(name))) await stage('Verify authenticated gateway management', () => effects.verifyGateway(record));
|
|
56
59
|
const completed = { ...record, sourcePolicyHash: candidate.sourcePolicyHash };
|
|
57
60
|
delete completed.buildTransition;
|
|
58
61
|
effects.writeJson(path, JSON.stringify(completed, null, 2) + '\n');
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, writeFileSync, realpathSync, unlinkSync, chmodSync, symlinkSync, renameSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, isAbsolute, dirname } from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { buildManagedCli } from './managed-cli.mjs';
|
|
5
|
+
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { releaseBinding, verifyReleaseGraph } from '../assets/scripts/maintenance/release-graph.mjs';
|
|
8
|
+
|
|
9
|
+
const marker = '// ours-managed-cli-v1 ';
|
|
10
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
11
|
+
function stat(path) {
|
|
12
|
+
try { return lstatSync(path); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
13
|
+
}
|
|
14
|
+
function privateAcquisition(home, root) {
|
|
15
|
+
for (const path of [join(home, '.ours-client-install'), root]) {
|
|
16
|
+
const value = lstatSync(path);
|
|
17
|
+
if (!value.isDirectory() || value.uid !== process.getuid() || (value.mode & 0o077) || realpathSync(path) !== path) throw new Error('Unsafe private CLI directory');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Publish the native client, retaining a verified migration launcher for rollback. */
|
|
21
|
+
export async function publishClientCli(effects, packagePath, { policy = {}, isolated = false } = {}) {
|
|
22
|
+
const prefix = (await effects.run('npm', ['prefix', '--global'])).stdout.trim();
|
|
23
|
+
if (!isAbsolute(prefix) || resolve(prefix) !== prefix) throw new Error('npm global prefix must be an absolute normalized path');
|
|
24
|
+
const entry = join(prefix, 'bin', 'ours');
|
|
25
|
+
const before = stat(entry);
|
|
26
|
+
let previous;
|
|
27
|
+
if (before) {
|
|
28
|
+
if (before.uid !== process.getuid()) throw new Error('Refusing to replace a CLI owned by another user');
|
|
29
|
+
if (before.isSymbolicLink()) {
|
|
30
|
+
const resolved = realpathSync(entry);
|
|
31
|
+
const privateBase = join(effects.home, '.ours-client-install') + '/';
|
|
32
|
+
if (resolved.startsWith(privateBase)) {
|
|
33
|
+
const packageRoot = dirname(dirname(resolved));
|
|
34
|
+
const acquisitionRoot = dirname(dirname(dirname(packageRoot)));
|
|
35
|
+
privateAcquisition(effects.home, acquisitionRoot);
|
|
36
|
+
const retained = JSON.parse(readFileSync(join(acquisitionRoot, 'sources.json')));
|
|
37
|
+
const binding = releaseBinding(retained);
|
|
38
|
+
if (binding && binding.scope !== 'host-cli') throw new Error('Unrecognized private CLI entry');
|
|
39
|
+
verifyReleaseGraph(acquisitionRoot, retained);
|
|
40
|
+
if (!binding) {
|
|
41
|
+
// Development selections publish through npm's global package entry.
|
|
42
|
+
// Require that exact retained selection and global target on retry;
|
|
43
|
+
// integrity-bound private host CLI releases keep their stricter path.
|
|
44
|
+
const npmRoot = (await effects.run('npm', ['root', '--global'])).stdout.trim();
|
|
45
|
+
const selected = retained.packages?.['@ours.network/cli'];
|
|
46
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json')));
|
|
47
|
+
if (!isAbsolute(npmRoot) || realpathSync(join(npmRoot, '@ours.network/cli')) !== packageRoot || selected?.type !== 'npm' || selected.version !== pkg.version) throw new Error('Unrecognized private CLI entry');
|
|
48
|
+
}
|
|
49
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json')));
|
|
50
|
+
if (pkg.name !== '@ours.network/cli' || resolve(packageRoot, pkg.bin?.ours ?? '') !== resolved) throw new Error('Unrecognized private CLI target');
|
|
51
|
+
} else {
|
|
52
|
+
const npmRoot = (await effects.run('npm', ['root', '--global'])).stdout.trim();
|
|
53
|
+
if (!isAbsolute(npmRoot)) throw new Error('Invalid npm global package root');
|
|
54
|
+
const root = realpathSync(join(npmRoot, '@ours.network/cli'));
|
|
55
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
56
|
+
if (manifest.name !== '@ours.network/cli' || typeof manifest.bin?.ours !== 'string' || resolved !== realpathSync(resolve(root, manifest.bin.ours))) throw new Error('Refusing to replace an unrelated ours symlink');
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
if (!before.isFile() || before.nlink !== 1 || (before.mode & 0o022)) throw new Error('Refusing to replace an unsafe CLI entry');
|
|
60
|
+
previous = readFileSync(entry);
|
|
61
|
+
const line = previous.toString().split('\n')[1];
|
|
62
|
+
if (!line?.startsWith(marker)) throw new Error('Refusing to replace an unknown ours executable');
|
|
63
|
+
const binding = JSON.parse(line.slice(marker.length));
|
|
64
|
+
if (binding.schema !== 1 || previous.toString() !== buildManagedCli(binding.recordPath, binding.installerPath)) throw new Error('Managed CLI launcher was modified; no replacement performed');
|
|
65
|
+
const backup = join(effects.home, '.ours-client', `previous-cli-${hash(previous)}.cjs`);
|
|
66
|
+
const backupDir = lstatSync(join(effects.home, '.ours-client'));
|
|
67
|
+
if (!backupDir.isDirectory() || backupDir.uid !== process.getuid() || (backupDir.mode & 0o7777) !== 0o700) throw new Error('Managed CLI backup requires a private owned directory');
|
|
68
|
+
const saved = stat(backup);
|
|
69
|
+
if (!saved) writeFileSync(backup, previous, { mode: 0o600, flag: 'wx' });
|
|
70
|
+
else if (!saved.isFile() || saved.nlink !== 1 || saved.uid !== process.getuid() || (saved.mode & 0o7777) !== 0o600 || saved.size !== previous.length || !readFileSync(backup).equals(previous)) throw new Error('Managed CLI backup is unsafe or differs');
|
|
71
|
+
const current = lstatSync(entry);
|
|
72
|
+
if (current.dev !== before.dev || current.ino !== before.ino || !readFileSync(entry).equals(previous)) throw new Error('CLI changed during publication');
|
|
73
|
+
if (!isolated) unlinkSync(entry);
|
|
74
|
+
effects.out(`Previous managed CLI saved at ${backup}; server maintenance remains available through ours-install server.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (isolated) {
|
|
78
|
+
const root = dirname(dirname(dirname(packagePath)));
|
|
79
|
+
if (!realpathSync(root).startsWith(join(effects.home, '.ours-client-install') + '/') || releaseBinding(policy)?.scope !== 'host-cli') throw new Error('Invalid private CLI acquisition');
|
|
80
|
+
privateAcquisition(effects.home, root);
|
|
81
|
+
verifyReleaseGraph(root, policy);
|
|
82
|
+
const selected = JSON.parse(readFileSync(join(packagePath, 'package.json')));
|
|
83
|
+
const target = realpathSync(resolve(packagePath, selected.bin.ours));
|
|
84
|
+
if (!target.startsWith(realpathSync(packagePath) + '/')) throw new Error('CLI entry escapes selected package');
|
|
85
|
+
const sdkEntry = realpathSync(createRequire(target).resolve('@ours.network/sdk'));
|
|
86
|
+
if (!sdkEntry.startsWith(root + '/')) throw new Error('Private CLI SDK escaped verified acquisition');
|
|
87
|
+
const sdk = JSON.parse(readFileSync(join(dirname(dirname(sdkEntry)), 'package.json')));
|
|
88
|
+
if (sdk.name !== '@ours.network/sdk' || sdk.version !== policy.release.packages['@ours.network/sdk'].version) throw new Error('Private CLI SDK differs from selection');
|
|
89
|
+
mkdirSync(dirname(entry), { recursive: true });
|
|
90
|
+
const binDir = lstatSync(dirname(entry));
|
|
91
|
+
if (!binDir.isDirectory() || binDir.uid !== process.getuid() || (binDir.mode & 0o022)) throw new Error(`CLI bin directory ${dirname(entry)} must be owned by you and not writable by group or others; run chmod go-w on it, or point npm's global prefix at a directory you own`);
|
|
92
|
+
const current = stat(entry);
|
|
93
|
+
if (before ? !current || current.dev !== before.dev || current.ino !== before.ino : current) throw new Error('CLI entry changed during publication');
|
|
94
|
+
const staged = entry + '.' + randomUUID();
|
|
95
|
+
try {
|
|
96
|
+
symlinkSync(target, staged);
|
|
97
|
+
renameSync(staged, entry);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (stat(staged)) unlinkSync(staged);
|
|
100
|
+
if (previous && !stat(entry)) writeFileSync(entry, previous, { mode: before.mode & 0o777, flag: 'wx' });
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
if (realpathSync(entry) !== target) throw new Error('Private CLI entry changed during publication');
|
|
104
|
+
verifyReleaseGraph(root, policy);
|
|
105
|
+
return entry;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await effects.run('npm', ['install', '--global', '--install-links=false', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', packagePath]);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (previous && !stat(entry)) {
|
|
111
|
+
writeFileSync(entry, previous, { mode: before.mode & 0o777, flag: 'wx' });
|
|
112
|
+
chmodSync(entry, before.mode & 0o777);
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
const published = stat(entry);
|
|
117
|
+
if (!published?.isSymbolicLink() || published.uid !== process.getuid()) throw new Error('CLI publication did not create the expected owned npm entry');
|
|
118
|
+
const npmRoot = (await effects.run('npm', ['root', '--global'])).stdout.trim();
|
|
119
|
+
if (!isAbsolute(npmRoot) || resolve(npmRoot) !== npmRoot) throw new Error('Invalid npm global package root');
|
|
120
|
+
const root = realpathSync(join(npmRoot, '@ours.network/cli'));
|
|
121
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
122
|
+
const selected = JSON.parse(readFileSync(join(packagePath, 'package.json'), 'utf8'));
|
|
123
|
+
const target = typeof manifest.bin?.ours === 'string' ? resolve(root, manifest.bin.ours) : '';
|
|
124
|
+
if (manifest.name !== '@ours.network/cli' || manifest.version !== selected.version || !target.startsWith(root + '/') || realpathSync(entry) !== realpathSync(target) || !stat(target)?.isFile()) throw new Error('Published CLI does not match the selected package');
|
|
125
|
+
if (!readFileSync(target).equals(readFileSync(resolve(packagePath, selected.bin.ours)))) throw new Error('Published CLI entry differs from the selected artifact');
|
|
126
|
+
return entry;
|
|
127
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const engineName = record => record.containerEngine === undefined ? 'docker' : record.containerEngine;
|
|
5
|
+
const path = value => typeof value === 'string' && isAbsolute(value) && resolve(value) === value && !/[\x00-\x1f]/.test(value);
|
|
6
|
+
export function validateContainerEngine(record) {
|
|
7
|
+
const engine = engineName(record);
|
|
8
|
+
if (!['docker', 'podman'].includes(engine) || (record.mode !== 'docker' && (record.containerEngine !== undefined || record.containerBinding !== undefined))) throw new Error('Invalid container engine selection');
|
|
9
|
+
const b = record.containerBinding;
|
|
10
|
+
if (engine === 'docker') {
|
|
11
|
+
if (b !== undefined) throw new Error('Unexpected Docker backend binding');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (!b || b.version !== 1 || !Number.isInteger(b.uid) || b.uid <= 0 || !path(b.graphRoot) || !path(b.runRoot)
|
|
15
|
+
|| !/^[a-f0-9]{64}$/.test(b.namespaceDigest ?? '') || !path(b.socket) || !path(b.provider) || typeof b.driver !== 'string' || !/^[a-z0-9]+$/.test(b.driver)
|
|
16
|
+
|| Object.keys(b).some(k => !['version', 'uid', 'graphRoot', 'runRoot', 'socket', 'provider', 'driver', 'namespaceDigest'].includes(k))) throw new Error('Invalid rootless Podman backend binding');
|
|
17
|
+
}
|
|
18
|
+
function podmanEnvironment(record, env, uid) {
|
|
19
|
+
validateContainerEngine(record);
|
|
20
|
+
const b = record.containerBinding;
|
|
21
|
+
if (uid !== b.uid) throw new Error('Podman installation belongs to another rootless owner');
|
|
22
|
+
for (const key of ['CONTAINER_HOST', 'CONTAINER_CONNECTION', 'CONTAINERS_STORAGE_CONF', 'CONTAINERS_CONF', 'PODMAN_USERNS', 'STORAGE_DRIVER', 'STORAGE_OPTS']) {
|
|
23
|
+
if (env[key]) throw new Error(`${key} conflicts with the retained local Podman backend`);
|
|
24
|
+
}
|
|
25
|
+
if ((env.OURS_PODMAN_SOCKET && env.OURS_PODMAN_SOCKET !== b.socket) || env.DOCKER_CONTEXT || (env.DOCKER_HOST && env.DOCKER_HOST !== `unix://${b.socket}`)
|
|
26
|
+
|| (env.PODMAN_COMPOSE_PROVIDER && env.PODMAN_COMPOSE_PROVIDER !== b.provider)) throw new Error('Environment conflicts with the retained Podman backend/provider');
|
|
27
|
+
return { DOCKER_CONTEXT: undefined, DOCKER_HOST: `unix://${b.socket}`, PODMAN_COMPOSE_PROVIDER: b.provider, PODMAN_COMPOSE_WARNING_LOGS: 'false' };
|
|
28
|
+
}
|
|
29
|
+
/** The only container command boundary. Legacy records keep their Docker path. */
|
|
30
|
+
export async function runContainer(effects, record, args, options = {}) {
|
|
31
|
+
if (engineName(record) === 'docker') return effects.run('docker', args, options);
|
|
32
|
+
const env = { ...(effects.env ?? {}), ...(options.env ?? {}) };
|
|
33
|
+
const selected = podmanEnvironment(record, env, process.getuid?.());
|
|
34
|
+
const b = record.containerBinding;
|
|
35
|
+
if (args[0] === 'build' && !args.some(arg => arg === '--format' || arg.startsWith('--format='))) args = ['build', '--format', 'docker', ...args.slice(1)];
|
|
36
|
+
// Docker reports a missing object as 1; Podman inspect uses 125. Probe exact
|
|
37
|
+
// existence separately so transport/permission failures are never absence.
|
|
38
|
+
if (['image', 'volume'].includes(args[0]) && args[1] === 'inspect' && options.allowCodes?.includes(1)) {
|
|
39
|
+
const found = await runContainer(effects, record, [args[0], 'exists', args.at(-1)], { ...options, allowCodes: [1] });
|
|
40
|
+
if (found.code === 1) return { code: 1, stdout: '', ok: false };
|
|
41
|
+
}
|
|
42
|
+
// Native commands and the Compose provider must address the SAME local store.
|
|
43
|
+
const prefix = args[0] === 'compose' ? ['--remote', '--url', `unix://${b.socket}`] : ['--remote=false', '--root', b.graphRoot, '--runroot', b.runRoot, '--storage-driver', b.driver];
|
|
44
|
+
const result = await effects.run('podman', [...prefix, ...args],
|
|
45
|
+
{ ...options, env: { ...options.env, ...selected } });
|
|
46
|
+
if (args[0] === 'image' && args[1] === 'inspect' && !args.includes('--format') && result.code === 0) {
|
|
47
|
+
const images = JSON.parse(result.stdout);
|
|
48
|
+
for (const value of images) if (/^[a-f0-9]{64}$/.test(value.Id)) value.Id = `sha256:${value.Id}`;
|
|
49
|
+
return { ...result, stdout: JSON.stringify(images) };
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
export function namespaceDigest(info) {
|
|
54
|
+
const maps = info.host?.idMappings;
|
|
55
|
+
const normalized = ['uidmap', 'gidmap'].map(key => {
|
|
56
|
+
const rows = maps?.[key];
|
|
57
|
+
if (!Array.isArray(rows) || !rows.length) throw new Error('Podman user namespace mappings are unavailable');
|
|
58
|
+
return rows.map(row => {
|
|
59
|
+
const values = [row.container_id, row.host_id, row.size];
|
|
60
|
+
if (values.some(value => !Number.isInteger(value) || value < 0) || row.size < 1) throw new Error('Invalid Podman namespace mapping');
|
|
61
|
+
return values;
|
|
62
|
+
}).sort((a, b) => a[0] - b[0]);
|
|
63
|
+
});
|
|
64
|
+
return createHash('sha256').update(JSON.stringify(normalized)).digest('hex');
|
|
65
|
+
}
|
|
66
|
+
export async function bindPodman(effects, record) {
|
|
67
|
+
if (effects.platform.platform !== 'linux' || process.getuid?.() === 0) throw new Error('Podman server installation requires a non-root Linux user');
|
|
68
|
+
const env = effects.env ?? {};
|
|
69
|
+
const provider = record.containerBinding?.provider ?? env.PODMAN_COMPOSE_PROVIDER;
|
|
70
|
+
if (!path(provider)) throw new Error('Set PODMAN_COMPOSE_PROVIDER to an absolute standalone Docker Compose executable path');
|
|
71
|
+
// Reject ambient redirection before even probing an unbound store.
|
|
72
|
+
for (const key of ['CONTAINER_HOST', 'CONTAINER_CONNECTION', 'CONTAINERS_STORAGE_CONF', 'CONTAINERS_CONF', 'PODMAN_USERNS', 'STORAGE_DRIVER', 'STORAGE_OPTS', 'DOCKER_CONTEXT']) {
|
|
73
|
+
if (env[key]) throw new Error(`${key} conflicts with local rootless Podman`);
|
|
74
|
+
}
|
|
75
|
+
const info = JSON.parse((await effects.run('podman', ['--remote=false', 'info', '--format', 'json'])).stdout);
|
|
76
|
+
if (info.host?.security?.rootless !== true) throw new Error('Podman must report rootless=true');
|
|
77
|
+
const socket = record.containerBinding?.socket ?? env.OURS_PODMAN_SOCKET ?? info.host.remoteSocket?.path;
|
|
78
|
+
const observed = { version: 1, uid: process.getuid(), graphRoot: info.store?.graphRoot, runRoot: info.store?.runRoot, driver: info.store?.graphDriverName, socket, provider, namespaceDigest: namespaceDigest(info) };
|
|
79
|
+
validateContainerEngine({ ...record, containerBinding: observed });
|
|
80
|
+
if (record.containerBinding && Object.keys(observed).some(k => observed[k] !== record.containerBinding[k])) throw new Error('Podman backend differs from retained installation');
|
|
81
|
+
podmanEnvironment({ ...record, containerBinding: observed }, env, process.getuid());
|
|
82
|
+
const api = JSON.parse((await effects.run('podman', ['--remote', '--url', `unix://${socket}`, 'info', '--format', 'json'])).stdout);
|
|
83
|
+
if (api.host?.security?.rootless !== true || api.store?.graphRoot !== observed.graphRoot || api.store?.runRoot !== observed.runRoot || api.store?.graphDriverName !== observed.driver || namespaceDigest(api) !== observed.namespaceDigest) throw new Error('Podman socket does not address the selected rootless storage; enable the user podman.socket');
|
|
84
|
+
const version = (await effects.run(provider, ['version', '--short'])).stdout.trim();
|
|
85
|
+
const match = /^v?(\d+)\.(\d+)\./.exec(version);
|
|
86
|
+
if (!match || +match[1] < 2 || (+match[1] === 2 && +match[2] < 35)) throw new Error('Standalone Docker Compose 2.35+ is required');
|
|
87
|
+
record.containerBinding = observed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolve Compose rather than maintaining a second, drifting build definition. */
|
|
91
|
+
export function nativeBuildCommands(config, services, env = {}) {
|
|
92
|
+
const commands = [], seen = new Set();
|
|
93
|
+
for (const name of services) {
|
|
94
|
+
const service = config.services?.[name], build = service?.build;
|
|
95
|
+
if (!build || typeof build !== 'object' || !path(build.context) || !service.image) throw new Error(`Missing resolved build definition for ${name}`);
|
|
96
|
+
const supported = ['context', 'dockerfile', 'target', 'args', 'secrets', 'cache_from', 'cache_to', 'platforms', 'labels', 'no_cache', 'pull'];
|
|
97
|
+
if (Object.keys(build).some(k => !supported.includes(k))) throw new Error(`Unsupported Podman build option for ${name}`);
|
|
98
|
+
const args = ['build', '--format', 'docker', '--layers', '--file', resolve(build.context, build.dockerfile ?? 'Dockerfile'), '--tag', service.image];
|
|
99
|
+
if (build.target) args.push('--target', build.target);
|
|
100
|
+
const platforms = build.platforms ?? (service.platform ? [service.platform] : []);
|
|
101
|
+
if (platforms.length > 1) throw new Error('Multi-platform Podman builds are not supported');
|
|
102
|
+
if (platforms.length) args.push('--platform', platforms[0]);
|
|
103
|
+
for (const [key, value] of Object.entries(build.args ?? {})) {
|
|
104
|
+
if (value === null) throw new Error(`Unresolved build argument: ${key}`);
|
|
105
|
+
args.push('--build-arg', `${key}=${value}`);
|
|
106
|
+
}
|
|
107
|
+
for (const [key, value] of Object.entries(build.labels ?? {})) args.push('--label', `${key}=${value}`);
|
|
108
|
+
for (const key of ['cache_from', 'cache_to']) for (const value of build[key] ?? []) args.push('--' + key.replace('_', '-'), value);
|
|
109
|
+
if (build.no_cache) args.push('--no-cache');
|
|
110
|
+
if (build.pull) args.push('--pull=always');
|
|
111
|
+
for (const secret of build.secrets ?? []) {
|
|
112
|
+
const source = typeof secret === 'string' ? secret : secret.source;
|
|
113
|
+
const id = typeof secret === 'string' ? source : secret.target ?? source;
|
|
114
|
+
const definition = config.secrets?.[source];
|
|
115
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error('Invalid build secret ID');
|
|
116
|
+
if (definition?.environment) {
|
|
117
|
+
// Native local Podman supports type=env; values never enter argv/files.
|
|
118
|
+
if (env[definition.environment]) args.push('--secret', `id=${id},type=env,env=${definition.environment}`);
|
|
119
|
+
} else if (definition?.file && path(definition.file)) args.push('--secret', `id=${id},src=${definition.file}`);
|
|
120
|
+
else throw new Error(`Unsupported build secret: ${source}`);
|
|
121
|
+
}
|
|
122
|
+
args.push(build.context);
|
|
123
|
+
const key = JSON.stringify(args);
|
|
124
|
+
if (!seen.has(key)) { seen.add(key); commands.push(args); }
|
|
125
|
+
}
|
|
126
|
+
return commands;
|
|
127
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { redactDiagnostic, commandFailure, installerFailure } from '../assets/scripts/runtime/diagnostics.mjs';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runContainer } from './container-engine.mjs';
|
|
1
2
|
import * as fs from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -33,7 +34,7 @@ ENTRYPOINT ["node", "/opt/ours/docker/state-operation.mjs"]
|
|
|
33
34
|
`);
|
|
34
35
|
// Build maintenance first: both targets inherit the same retained runtime.
|
|
35
36
|
for (const target of ['maintenance', 'runtime']) {
|
|
36
|
-
await effects
|
|
37
|
+
await runContainer(effects, record, ['build', '--platform', 'linux/amd64', '--target', target,
|
|
37
38
|
'--tag', `${record.project}:${target}`, context]);
|
|
38
39
|
}
|
|
39
40
|
atomicWriteConfig(compose, fs.readFileSync(join(assets, 'docker-compose.yaml')));
|
|
@@ -82,7 +83,7 @@ export async function runDockerConversion(record, selected, operation, label, ef
|
|
|
82
83
|
const path = join(record.root, `.conversion-compose-${randomUUID()}.json`);
|
|
83
84
|
try {
|
|
84
85
|
fs.writeFileSync(path, JSON.stringify(definition), { mode: 0o600, flag: 'wx' });
|
|
85
|
-
const result = await effects
|
|
86
|
+
const result = await runContainer(effects, record, ['compose', '--file', path, '--project-name', record.project,
|
|
86
87
|
'run', '--rm', '--no-deps', '-T', 'state-operation', operation, ...(label ? [label] : [])]);
|
|
87
88
|
if (operation === 'cleanup') {
|
|
88
89
|
const summary = JSON.parse(result.stdout);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runContainer } from './container-engine.mjs';
|
|
1
2
|
import * as fs from 'node:fs';
|
|
2
3
|
import { join, basename } from 'node:path';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -46,7 +47,7 @@ export async function convertDockerInstallation(record, operation, effects) {
|
|
|
46
47
|
await effects.serverLifecycle(record, 'start', running);
|
|
47
48
|
const cleanup = await effects.runDockerConversion(record, volumes, 'cleanup');
|
|
48
49
|
if (cleanup.emptyVolumes.length) {
|
|
49
|
-
await effects
|
|
50
|
+
await runContainer(effects, record, ['volume', 'rm', ...cleanup.emptyVolumes.map(alias => volumes.sources[alias])]);
|
|
50
51
|
}
|
|
51
52
|
try {
|
|
52
53
|
const stat = fs.lstatSync(source.configPath);
|