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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/GATEWAY_SETUP.md +178 -0
  2. package/README.md +453 -9
  3. package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
  4. package/assets/Dockerfile +14 -12
  5. package/assets/Dockerfile.gateway +5 -0
  6. package/assets/docker-compose.yaml +8 -1
  7. package/assets/fleet-settings.example.json +48 -0
  8. package/assets/gateway-entrypoint.sh +37 -0
  9. package/assets/release-lock.json +1139 -4470
  10. package/assets/release.json +37 -19
  11. package/assets/scripts/build/build-common.mjs +2 -1
  12. package/assets/scripts/build/build-sdk.mjs +6 -1
  13. package/assets/scripts/maintenance/build-context.mjs +1 -1
  14. package/assets/scripts/maintenance/daemon-owner.mjs +15 -0
  15. package/assets/scripts/maintenance/docker-layout-conversion.mjs +7 -3
  16. package/assets/scripts/maintenance/release-graph.mjs +18 -1
  17. package/assets/scripts/maintenance/state-operation.mjs +12 -2
  18. package/assets/scripts/runtime/README.md +7 -0
  19. package/assets/scripts/runtime/client-setup.mjs +80 -6
  20. package/assets/scripts/runtime/dependency-ready.mjs +13 -0
  21. package/assets/scripts/runtime/diagnostics.mjs +72 -0
  22. package/assets/scripts/runtime/entrypoint.sh +5 -1
  23. package/assets/scripts/runtime/health-messenger.mjs +7 -6
  24. package/assets/scripts/runtime/notifications-gateway.mjs +90 -0
  25. package/assets/scripts/runtime/recover.mjs +165 -0
  26. package/assets/scripts/runtime/start-cowork.sh +5 -0
  27. package/assets/scripts/runtime/start-messenger.sh +29 -4
  28. package/assets/scripts/runtime/start-telegram.sh +5 -0
  29. package/assets/scripts/runtime/supervise.mjs +35 -0
  30. package/assets/sources.json +54 -28
  31. package/install.mjs +7 -2
  32. package/lib/boot-readiness.mjs +43 -0
  33. package/lib/build-transition.mjs +3 -0
  34. package/lib/client-cli.mjs +127 -0
  35. package/lib/container-engine.mjs +127 -0
  36. package/lib/diagnostics.mjs +1 -0
  37. package/lib/docker-conversion-runtime.mjs +3 -2
  38. package/lib/docker-layout-installation.mjs +2 -1
  39. package/lib/docker-recovery.mjs +55 -0
  40. package/lib/docker-runtime-repair.mjs +160 -0
  41. package/lib/effects.mjs +323 -132
  42. package/lib/gateway-transition.mjs +87 -0
  43. package/lib/gateway.mjs +158 -0
  44. package/lib/legacy-migration.mjs +13 -2
  45. package/lib/orchestrate-uninstall.mjs +18 -6
  46. package/lib/orchestrate.mjs +193 -43
  47. package/lib/plan.mjs +27 -11
  48. package/lib/podman-preflight.mjs +40 -0
  49. package/lib/server-onboarding.mjs +164 -16
  50. package/lib/server-ports.mjs +17 -0
  51. package/lib/setup-options.mjs +99 -28
  52. package/lib/setup.mjs +35 -17
  53. package/lib/target.mjs +24 -8
  54. package/lib/uninstall.mjs +2 -2
  55. package/lib/usage.mjs +21 -7
  56. package/lib/workspace-setup.mjs +83 -0
  57. package/package.json +4 -2
@@ -0,0 +1,165 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { readdirSync, readFileSync } from 'node:fs';
5
+
6
+ // The container owns recovery; no Docker socket, host service or stored state is
7
+ // accessed. Every launch re-enters the locked, provenance-checking entrypoint.
8
+ export async function runRecovery({ command, health, dependency, graceMs = 90_000,
9
+ intervalMs = 5_000, probeTimeoutMs = 15_000,
10
+ killAfterMs = 20_000, retries = 3, failures = 3, ownerPidEnvironment, log = console.error }) {
11
+ let stopping = false, active;
12
+ const probes = new Set();
13
+ const controller = new AbortController();
14
+ const pause = ms => sleep(ms, undefined, { signal: controller.signal }).catch(() => {});
15
+ const processFields = id => {
16
+ try {
17
+ const stat = readFileSync(`/proc/${id}/stat`, 'utf8');
18
+ return stat.slice(stat.lastIndexOf(')') + 2).split(' ');
19
+ } catch { return null; }
20
+ };
21
+ const groups = child => {
22
+ if (!child?.pid) return [];
23
+ child.groups ??= new Set([child.pid]);
24
+ // Cowork's serve supervisor explicitly marks a detached worker with its
25
+ // owner PID. The marker survives owner death, unlike PPID ancestry. No
26
+ // unrelated process or unmarked group is selected, including Docker exec.
27
+ if (ownerPidEnvironment && !child.probe) for (const id of readdirSync('/proc')) {
28
+ if (!/^\d+$/.test(id)) continue;
29
+ try {
30
+ const env = readFileSync(`/proc/${id}/environ`, 'utf8').split('\0');
31
+ if (!env.includes(`${ownerPidEnvironment}=${child.pid}`)) continue;
32
+ const fields = processFields(id);
33
+ if (fields && !['Z', 'X'].includes(fields[0])) child.groups.add(Number(fields[2]));
34
+ } catch { /* foreign or exited process */ }
35
+ }
36
+ return [...child.groups].filter(pid => Number.isInteger(pid) && pid > 1 && pid !== process.pid);
37
+ };
38
+ const signalGroup = (child, signal, all = true) => {
39
+ const selected = all ? groups(child) : child?.pid ? [child.pid] : [];
40
+ for (const pid of selected) try { process.kill(-pid, signal); }
41
+ catch (error) { if (error.code !== 'ESRCH') throw error; }
42
+ };
43
+ const launch = (args, probe = false) => {
44
+ const child = spawn(args[0], args.slice(1), { detached: true,
45
+ stdio: probe ? 'ignore' : 'inherit', env: { ...process.env, OURS_RECOVERY_CHILD: '1' } });
46
+ child.probe = probe;
47
+ child.done = new Promise(resolve => {
48
+ child.once('error', () => resolve(1));
49
+ child.once('exit', (code) => resolve(code ?? 1));
50
+ });
51
+ return child;
52
+ };
53
+ const groupAlive = child => {
54
+ const selected = groups(child);
55
+ return readdirSync('/proc').some(id => {
56
+ if (!/^\d+$/.test(id)) return false;
57
+ const fields = processFields(id);
58
+ return fields && selected.includes(Number(fields[2])) && !['Z', 'X'].includes(fields[0]);
59
+ });
60
+ };
61
+ const terminate = async child => {
62
+ groups(child);
63
+ // Graceful shutdown reaches only the owning entrypoint; Cowork retains
64
+ // exclusive IPC control of its worker until the force-kill boundary.
65
+ signalGroup(child, 'SIGTERM', false);
66
+ let timer;
67
+ await Promise.race([child.done, new Promise(resolve => { timer = setTimeout(() => {
68
+ signalGroup(child, 'SIGKILL'); resolve();
69
+ }, killAfterMs); })]);
70
+ clearTimeout(timer);
71
+ // A leader may exit before a stubborn grandchild. Kill the remaining group
72
+ // even when child.done won the race, then await kernel lock release.
73
+ signalGroup(child, 'SIGKILL');
74
+ await child.done;
75
+ const deadline = Date.now() + killAfterMs;
76
+ while (groupAlive(child)) {
77
+ if (Date.now() >= deadline) throw new Error('Recovery refused: child process group did not stop');
78
+ await sleep(25);
79
+ }
80
+ };
81
+ const probe = async args => {
82
+ const child = launch(args, true);
83
+ probes.add(child);
84
+ let timer;
85
+ const code = await Promise.race([child.done, new Promise(resolve => {
86
+ timer = setTimeout(() => { signalGroup(child, 'SIGKILL'); resolve(1); }, probeTimeoutMs);
87
+ })]);
88
+ clearTimeout(timer);
89
+ await terminate(child);
90
+ probes.delete(child);
91
+ return code === 0;
92
+ };
93
+ const stop = () => { stopping = true; controller.abort(); signalGroup(active, 'SIGTERM', false); for (const child of probes) signalGroup(child, 'SIGKILL'); };
94
+ process.on('SIGTERM', stop); process.on('SIGINT', stop);
95
+ try {
96
+ let recoveries = 0;
97
+ while (!stopping) {
98
+ if (recoveries > retries) {
99
+ log(`OURS recovery exhausted (${retries} automatic attempts); children stopped. Inspect logs and use server restart after correcting the cause.`);
100
+ while (!stopping) await pause(60_000);
101
+ break;
102
+ }
103
+ // A dependency outage is a wait, not a local failure. Never spend a
104
+ // consumer's circuit on daemon boot order, restart or unavailability.
105
+ if (dependency) {
106
+ let waiting = false;
107
+ while (!stopping && !await probe(dependency)) {
108
+ if (!waiting) log('OURS waiting for the authenticated daemon; recovery budget retained');
109
+ waiting = true;
110
+ await pause(intervalMs);
111
+ }
112
+ }
113
+ if (stopping) break;
114
+ if (recoveries) log(`OURS recovery attempt ${recoveries}/${retries}`);
115
+ active = launch(command);
116
+ let exited = false, monitoring = true;
117
+ const generation = new AbortController();
118
+ const wait = ms => sleep(ms, undefined, { signal: generation.signal }).catch(() => {});
119
+ const cancel = () => generation.abort();
120
+ controller.signal.addEventListener('abort', cancel, { once: true });
121
+ active.done.then(() => { exited = true; generation.abort(); });
122
+ const watch = async () => {
123
+ await wait(graceMs);
124
+ let failed = 0;
125
+ while (monitoring && !stopping && !exited) {
126
+ const healthy = await probe(health);
127
+ if (!monitoring || stopping || exited) break;
128
+ if (!healthy && dependency && !await probe(dependency)) failed = 0;
129
+ else failed = healthy ? 0 : failed + 1;
130
+ if (failed >= failures) { log('OURS service health failed repeatedly; stopping children before recovery'); return; }
131
+ await wait(intervalMs);
132
+ }
133
+ };
134
+ const watching = watch();
135
+ await Promise.race([active.done, watching]);
136
+ monitoring = false; generation.abort();
137
+ await terminate(active);
138
+ await watching;
139
+ controller.signal.removeEventListener('abort', cancel);
140
+ active = undefined;
141
+ if (!stopping) {
142
+ if (!dependency || await probe(dependency)) recoveries++;
143
+ await pause(intervalMs);
144
+ }
145
+ }
146
+ } finally {
147
+ if (active) await terminate(active);
148
+ process.off('SIGTERM', stop); process.off('SIGINT', stop);
149
+ }
150
+ }
151
+
152
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
153
+ const service = process.argv[2], runtime = process.env.OURS_RUNTIME_ROOT ?? '/opt/ours';
154
+ const health = {
155
+ daemon: ['node', `${runtime}/docker/healthcheck.mjs`],
156
+ telegram: ['/bin/sh', `${runtime}/docker/health-telegram.sh`],
157
+ cowork: ['/bin/sh', `${runtime}/docker/health-cowork.sh`],
158
+ messenger: ['node', `${runtime}/docker/health-messenger.mjs`],
159
+ }[service];
160
+ if (!health) throw new Error('Unknown recovery service');
161
+ const entry = service === 'daemon' ? 'entrypoint.sh' : `start-${service}.sh`;
162
+ await runRecovery({ command: ['/bin/sh', '-e', `${runtime}/docker/${entry}`], health,
163
+ ...(service === 'cowork' ? { ownerPidEnvironment: 'OURS_COWORK_SUPERVISOR_PID' } : {}),
164
+ ...(service !== 'daemon' ? { dependency: ['node', `${runtime}/docker/dependency-ready.mjs`, service] } : {}) });
165
+ }
@@ -1,4 +1,9 @@
1
1
  #!/bin/sh
2
+ set -e
3
+ # Re-enter this entrypoint for every recovery, including its locks and checks.
4
+ if [ "${OURS_RECOVERY_CHILD:-}" != 1 ]; then
5
+ exec node "${OURS_RUNTIME_ROOT:-/opt/ours}/docker/recover.mjs" cowork
6
+ fi
2
7
  umask 077
3
8
  exec 3</var/lib/ours-cowork
4
9
  flock -n 3
@@ -1,6 +1,31 @@
1
1
  #!/bin/sh
2
+ # Re-enter this entrypoint for every recovery, including its locks and checks.
3
+ if [ "${OURS_RECOVERY_CHILD:-}" != 1 ]; then
4
+ exec node "${OURS_RUNTIME_ROOT:-/opt/ours}/docker/recover.mjs" messenger
5
+ fi
6
+ set -e
2
7
  umask 077
3
- exec 3</var/lib/ours-messenger
4
- flock -n 3
5
- node /opt/ours/docker/check-client.mjs messenger
6
- exec node /opt/ours/node_modules/@ours.network/messenger-server/dist/cli.js serve
8
+ runtime="${OURS_RUNTIME_ROOT:-/opt/ours}"
9
+ state="${OURS_MESSENGER_STATE_DIR:-/var/lib/ours-messenger}"
10
+ exec 3<"$state"
11
+ flock -n 3 || { echo "OURS Messenger state is in use by another writer" >&2; exit 1; }
12
+ node "$runtime/docker/check-client.mjs" messenger
13
+ messenger="[\"node\",\"$runtime/node_modules/@ours.network/messenger-server/dist/cli.js\",\"serve\"]"
14
+ # Notifications run beside Messenger when this runtime ships the service and
15
+ # its private configuration was prepared. Messenger's producer reaches it on loopback.
16
+ config="${OURS_NOTIFICATIONS_CONFIG:-}"
17
+ if [ -n "$config" ] && [ -f "$runtime/node_modules/@ours.network/notifications/package.json" ] && [ -f "$config" ]; then
18
+ notifications="$(dirname "$config")"
19
+ exec 4<"$notifications"
20
+ # Every writer of this state starts here and holds lock 4 for its lifetime;
21
+ # the kernel releases it when the process dies, even on SIGKILL. Holding it
22
+ # proves no other writer is alive, so a lock file left by an abrupt stop is stale.
23
+ flock -n 4 || { echo "OURS notification state is in use by another writer" >&2; exit 1; }
24
+ rm -f "$notifications/state.json.lock"
25
+ OURS_NOTIFICATIONS_ORIGIN="http://127.0.0.1:49677"
26
+ OURS_NOTIFICATIONS_PRODUCER_TOKEN="$(cat "${OURS_NOTIFICATIONS_PRODUCER_FILE:-/credentials/messenger/notifications-producer}")"
27
+ export OURS_NOTIFICATIONS_ORIGIN OURS_NOTIFICATIONS_PRODUCER_TOKEN
28
+ # Only Messenger receives its producer credential.
29
+ exec node "$runtime/docker/supervise.mjs" "[$messenger,[\"env\",\"-u\",\"OURS_NOTIFICATIONS_PRODUCER_TOKEN\",\"node\",\"$runtime/docker/notifications-gateway.mjs\"]]"
30
+ fi
31
+ exec node "$runtime/node_modules/@ours.network/messenger-server/dist/cli.js" serve
@@ -1,4 +1,9 @@
1
1
  #!/bin/sh
2
+ set -e
3
+ # Re-enter this entrypoint for every recovery, including its locks and checks.
4
+ if [ "${OURS_RECOVERY_CHILD:-}" != 1 ]; then
5
+ exec node "${OURS_RUNTIME_ROOT:-/opt/ours}/docker/recover.mjs" telegram
6
+ fi
2
7
  umask 077
3
8
  exec 3</var/lib/ours-telegram
4
9
  flock -n 3
@@ -0,0 +1,35 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /**
4
+ * Run the given commands as one unit: signals reach every child, and when any
5
+ * child exits (or cannot start) the others are stopped and the container exits
6
+ * with its status, so a failure is visible and recovers through the container's
7
+ * own restart. A child ignoring SIGTERM is killed after a bounded grace period,
8
+ * within Compose's stop grace period.
9
+ */
10
+ const commands = JSON.parse(process.argv[2] ?? '[]');
11
+ if (!Array.isArray(commands) || !commands.length || !commands.every(c => Array.isArray(c) && c.length && c.every(a => typeof a === 'string'))) {
12
+ console.error('usage: supervise.mjs JSON-ARRAY-OF-COMMANDS'); process.exit(2);
13
+ }
14
+ const killAfter = Number(process.env.OURS_SUPERVISE_KILL_AFTER_MS ?? 20_000);
15
+ let stopping = false, status = 0, remaining = commands.length;
16
+ const children = [];
17
+ const running = child => child.exitCode === null && child.signalCode === null && !child.failed;
18
+ const stop = signal => {
19
+ for (const child of children) if (running(child)) child.kill(signal);
20
+ if (signal !== 'SIGKILL') setTimeout(() => stop('SIGKILL'), killAfter);
21
+ };
22
+ const finished = (child, code, reason) => {
23
+ if (child.done) return;
24
+ child.done = true;
25
+ if (!stopping) { stopping = true; status = code; console.error(`Supervised process ${child.spawnfile} ${reason}; stopping the others`); stop('SIGTERM'); }
26
+ if (--remaining === 0) process.exit(status);
27
+ };
28
+ for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { if (!stopping) { stopping = true; stop(signal); } });
29
+ // Inherited descriptors 3 and 4 are the state-directory locks; every child holds them too.
30
+ for (const [file, ...args] of commands) {
31
+ const child = spawn(file, args, { stdio: ['inherit', 'inherit', 'inherit', 'inherit', 'inherit'] });
32
+ children.push(child);
33
+ child.on('error', error => { child.failed = true; finished(child, 1, `could not start (${error.code ?? 'error'})`); });
34
+ child.on('exit', (code, signal) => finished(child, code ?? 1, `exited (${signal ?? code})`));
35
+ }
@@ -2,82 +2,108 @@
2
2
  "release": {
3
3
  "schema": 1,
4
4
  "channel": "nightly",
5
- "installerVersion": "1.2.1-nightly.4",
5
+ "installerVersion": "1.2.1-nightly.40",
6
6
  "packages": {
7
7
  "@ours.network/sdk": {
8
- "version": "3.8.1-nightly.3",
9
- "integrity": "sha512-iUVCs+jryoeR2Ip/uHzpkJwbpoJeLDASUaE3lC+eYDj8dbI1MBwTpeOXFemUK052O6lA4ZWrhyRgE9pJU3a+Kw=="
8
+ "version": "3.8.1-nightly.17",
9
+ "integrity": "sha512-GCPbGzh0IxFJluyRk4y1O+9419kZcnHapnr42FQKs4u2kKU7EcDRLBlINqSYgF0YUCZyZUDv/9Rk1//GKs/6oQ=="
10
10
  },
11
11
  "@ours.network/cli": {
12
- "version": "2.8.1-nightly.1",
13
- "integrity": "sha512-1Rmbqlr790FhqQW/wQ//YEL7b0Q2KVSCA2TlpjaLRkj9Xh55rOoazBJjFuW/fUpTz3Z1B3MUPK9LWiYd+A0kNA=="
12
+ "version": "2.8.1-nightly.15",
13
+ "integrity": "sha512-ht96LfYH7Du19YWeht+5eRsZxuF1Fobfj6sPSH+aFgWD9hYdrWTeG/5rrCTAClQflnl49Qaskk4n/TclkWrkRw=="
14
14
  },
15
15
  "@ours.network/tg-connector": {
16
- "version": "1.0.1-nightly.1",
17
- "integrity": "sha512-/Do7EfINknDks3yYndOi9aLc1alxB50qk+WKv8DRuZpDB89DDMQ+DoSGQerdvsMp32nQ3LICL8TPjcdFMZfx8Q=="
16
+ "version": "1.0.1-nightly.11",
17
+ "integrity": "sha512-ZT/51g+YgjCpPoYKF+6zuWhn/rAUa49Qq/mChOHZmZf2WJR16VLPGCc31LDA26kRE+kQtdkWiuGf2v0XOa7CXg=="
18
18
  },
19
19
  "@ours.network/cowork": {
20
- "version": "1.3.2-nightly.20260918.4d9242e",
21
- "integrity": "sha512-04D2lJ97SrTrkVRRbdTZ0y+KvQdG/bpnmFFjsyUqQTij+5zEkNizTG/xhcCL8m7Mb0XvVFL8WzJ62Xgh5ahlRw=="
20
+ "version": "1.3.3-nightly.20261005.f549477",
21
+ "integrity": "sha512-9RYvgAPrsKtzAOUSYsNLws3zhse3BvKu4Z2xC5vUYniWhyU8s2RiHO6bH7oBrjcRyjpCiFwnNpOE6oobi1Vllg=="
22
22
  },
23
23
  "@ours.network/messenger-server": {
24
- "version": "1.0.31-nightly.1",
25
- "integrity": "sha512-XB6N+Ppd4txmI0+2ZVFMWJxXi4DtMIDlFSHOzszNM5hopFebpw1Df+KKCK4Cgd/LZUkoOOYWGEYpib9m3RbHWw=="
24
+ "version": "1.0.31-nightly.17",
25
+ "integrity": "sha512-03qDDGRgN9pBGsBsCcvArhPuYfMblqsaXS9HinxAzOSiI3PaoNOh+4MxIAOoHXoE/12xxCDf7kbMdEAR0CPIfg=="
26
+ },
27
+ "@ours.network/notifications": {
28
+ "version": "0.3.1-nightly.2",
29
+ "integrity": "sha512-OjnrJ8ZuOfKYT4KAmiRLU7czfiuacs6rShwOvvqxqBaUKcs1UwjsYwHARNb6erMxG4am3xxcE0ItahL+d2vt4A=="
26
30
  },
27
31
  "@ours.network/fleet": {
28
- "version": "1.2.0-nightly.1",
29
- "integrity": "sha512-vioCobVbosmp9BgYo2XccjpPoVQd1wwV+Mt64Bm41/Vxtro2j85PUdy4AWcsY9jEFnJmj/UQfc5E5AxvlpPnrw=="
32
+ "version": "1.2.0-nightly.52",
33
+ "integrity": "sha512-nIwGknps0FBDhdtivXAFalRbZ67XHAdNcs6AiTBE0ULYeLcohdfqoFqyAS0iZQPUeEUhxkPTKfVuESKeQKP/ow=="
30
34
  },
31
35
  "@ours.network/mcp": {
32
- "version": "1.2.0-nightly.3",
33
- "integrity": "sha512-z0N5jn1jj9iwGRqU1d3AM2RX9mCr4T/WhASEKr+Vh52yNDMzhVjFlx0U0v8aS+GTfOQaPZTfL3TGMc17ETjh3g=="
36
+ "version": "1.2.0-nightly.12",
37
+ "integrity": "sha512-ZvwCaIgMaMcUrLhWNaWWOErIHwtvQyOZHfophf+rrjnlG0eBpBwmwQPuABS7jPuQvi69pBIT4jUWN3CArgxzRQ=="
34
38
  },
35
39
  "@ours.network/codex": {
36
- "version": "1.2.0-nightly.3",
37
- "integrity": "sha512-dIWDqbo6hJzujegHRO6x1SsBnh5p1c6gRGj/WkPha/hcN7s/27P4jqBnJ7czQyVGiNVFZ1aAxj4c9+CDLzHENw=="
40
+ "version": "1.2.0-nightly.12",
41
+ "integrity": "sha512-O3emBbe4KXfD9nDsgjN3Z8J11aSMz4KAdiZo4EPZIqEypOXCaRnbZqpuJpd14FZpFHSHGEfel6KgP0mZYFYtRA=="
38
42
  },
39
43
  "@ours.network/claude-code": {
40
- "version": "1.2.0-nightly.3",
41
- "integrity": "sha512-7ta6KnvoHB2i/yoaykhXvzKFQTIOPtDHeTzG1PRwHjdTJ4vcGEh4jWYMd2c1jjj73lC3q9qW3LYSRutIJovtQg=="
44
+ "version": "1.2.0-nightly.12",
45
+ "integrity": "sha512-122cnztQqVkXvXy4y4zJQAVVD5gBTqdj5gkf2dTVnR/g0Nzw5uBxdch0xkhyluqmjN74MHuklv2YRr9Gs8vbbQ=="
46
+ },
47
+ "@ours.network/daemon": {
48
+ "version": "3.8.1-nightly.9",
49
+ "integrity": "sha512-KQUAmd/Ae97DcaxKFinsVuyQiREUuXp6uiv0FeOsU59patpFKUcw7gYpmifBeywnvsDVrS+XX33CubYYkpMacw=="
50
+ }
51
+ },
52
+ "hostCli": {
53
+ "@ours.network/sdk": {
54
+ "version": "3.8.1-nightly.17",
55
+ "integrity": "sha512-GCPbGzh0IxFJluyRk4y1O+9419kZcnHapnr42FQKs4u2kKU7EcDRLBlINqSYgF0YUCZyZUDv/9Rk1//GKs/6oQ=="
56
+ },
57
+ "@ours.network/cli": {
58
+ "version": "2.8.1-nightly.15",
59
+ "integrity": "sha512-ht96LfYH7Du19YWeht+5eRsZxuF1Fobfj6sPSH+aFgWD9hYdrWTeG/5rrCTAClQflnl49Qaskk4n/TclkWrkRw=="
42
60
  }
43
61
  }
44
62
  },
45
63
  "packages": {
46
64
  "@ours.network/sdk": {
47
65
  "type": "npm",
48
- "version": "3.8.1-nightly.3"
66
+ "version": "3.8.1-nightly.17"
49
67
  },
50
68
  "@ours.network/cli": {
51
69
  "type": "npm",
52
- "version": "2.8.1-nightly.1"
70
+ "version": "2.8.1-nightly.15"
53
71
  },
54
72
  "@ours.network/tg-connector": {
55
73
  "type": "npm",
56
- "version": "1.0.1-nightly.1"
74
+ "version": "1.0.1-nightly.11"
57
75
  },
58
76
  "@ours.network/cowork": {
59
77
  "type": "npm",
60
- "version": "1.3.2-nightly.20260918.4d9242e"
78
+ "version": "1.3.3-nightly.20261005.f549477"
61
79
  },
62
80
  "@ours.network/messenger-server": {
63
81
  "type": "npm",
64
- "version": "1.0.31-nightly.1"
82
+ "version": "1.0.31-nightly.17"
83
+ },
84
+ "@ours.network/notifications": {
85
+ "type": "npm",
86
+ "version": "0.3.1-nightly.2"
65
87
  },
66
88
  "@ours.network/fleet": {
67
89
  "type": "npm",
68
- "version": "1.2.0-nightly.1"
90
+ "version": "1.2.0-nightly.52"
69
91
  },
70
92
  "@ours.network/mcp": {
71
93
  "type": "npm",
72
- "version": "1.2.0-nightly.3"
94
+ "version": "1.2.0-nightly.12"
73
95
  },
74
96
  "@ours.network/codex": {
75
97
  "type": "npm",
76
- "version": "1.2.0-nightly.3"
98
+ "version": "1.2.0-nightly.12"
77
99
  },
78
100
  "@ours.network/claude-code": {
79
101
  "type": "npm",
80
- "version": "1.2.0-nightly.3"
102
+ "version": "1.2.0-nightly.12"
103
+ },
104
+ "@ours.network/daemon": {
105
+ "type": "npm",
106
+ "version": "3.8.1-nightly.9"
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 code = await runSetup(process.argv.slice(2), realEffects({
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: ${error instanceof Error ? error.message : String(error)}\n`);
26
+ process.stderr.write(`ours-install: ${installerFailure(error)}\n`);
22
27
  process.exitCode = 1;
23
28
  } finally {
24
29
  if (ttyFd != null) {
@@ -0,0 +1,43 @@
1
+ /** Diagnose availability separately from boot enablement. Never alter host services. */
2
+ export async function reportBootReadiness(effects, record) {
3
+ const platform = effects.platform.platform;
4
+ if (record.mode === 'packages') {
5
+ if (platform === 'linux') {
6
+ try {
7
+ const linger = await effects.run('loginctl', ['show-user', String(process.getuid()), '--property=Linger', '--value']);
8
+ if (linger.stdout.trim() !== 'yes') throw new Error('linger disabled');
9
+ effects.out('Native user-service boot requires enabled package-owned units; user linger is enabled.');
10
+ } catch { effects.out('Native user services require a login session; pre-login host boot is not verified. Ask the operator to enable user linger.'); }
11
+ } else effects.out('Native launchd services start in the macOS GUI login session; pre-login boot and hung-process recovery are not supplied.');
12
+ return;
13
+ }
14
+ if (record.mode !== 'docker' || (record.containerEngine ?? 'docker') !== 'docker') return;
15
+ if (platform !== 'linux') {
16
+ effects.out('Container recovery requires Docker Desktop to start at sign-in. Enable its startup setting; this installer does not arrange pre-login host boot on this platform.');
17
+ return;
18
+ }
19
+ try {
20
+ const context = JSON.parse((await effects.run('docker', ['context', 'inspect'])).stdout);
21
+ const endpoint = effects.env.DOCKER_HOST ?? context[0]?.Endpoints?.docker?.Host;
22
+ if (effects.env.DOCKER_CONTEXT || !endpoint?.startsWith('unix://') || endpoint.includes('/.docker/desktop/')) {
23
+ effects.out('Container recovery requires the selected remote/Desktop Docker Engine to start independently; local systemd boot cannot be verified for this context.');
24
+ return;
25
+ }
26
+ const info = JSON.parse((await effects.run('docker', ['info', '--format', '{{json .SecurityOptions}}'])).stdout);
27
+ const rootless = info.some(option => option.startsWith('name=rootless'));
28
+ const sockets = rootless ? [`unix:///run/user/${process.getuid()}/docker.sock`] : ['unix:///var/run/docker.sock', 'unix:///run/docker.sock'];
29
+ if (!sockets.includes(endpoint)) {
30
+ effects.out('The selected custom Docker socket is reachable, but its boot service binding is unverified; enable and verify the service owning that Engine.');
31
+ return;
32
+ }
33
+ const enabled = await effects.run('systemctl', [...(rootless ? ['--user'] : []), 'is-enabled', 'docker.service']);
34
+ if (enabled.stdout.trim() !== 'enabled') throw new Error('persistent Docker service enablement is unverified');
35
+ if (rootless) {
36
+ const linger = await effects.run('loginctl', ['show-user', String(process.getuid()), '--property=Linger', '--value']);
37
+ if (linger.stdout.trim() !== 'yes') throw new Error('rootless user linger is disabled');
38
+ }
39
+ effects.out(`Docker boot service is enabled${rootless ? ' with user linger' : ''}; managed containers recover when that Engine starts.`);
40
+ } catch {
41
+ effects.out('Docker is reachable, but automatic host boot is not verified. Ask the operator to enable the selected Docker Engine boot service (and user linger for rootless Docker); container restart policy cannot start an unavailable Engine.');
42
+ }
43
+ }
@@ -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
+ }