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

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 (58) hide show
  1. package/GATEWAY_SETUP.md +178 -0
  2. package/README.md +471 -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 +1177 -4508
  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/recover-notifications.mjs +26 -0
  17. package/assets/scripts/maintenance/release-graph.mjs +18 -1
  18. package/assets/scripts/maintenance/state-operation.mjs +62 -5
  19. package/assets/scripts/runtime/README.md +7 -0
  20. package/assets/scripts/runtime/client-setup.mjs +80 -6
  21. package/assets/scripts/runtime/dependency-ready.mjs +13 -0
  22. package/assets/scripts/runtime/diagnostics.mjs +72 -0
  23. package/assets/scripts/runtime/entrypoint.sh +5 -1
  24. package/assets/scripts/runtime/health-messenger.mjs +7 -6
  25. package/assets/scripts/runtime/notifications-gateway.mjs +90 -0
  26. package/assets/scripts/runtime/recover.mjs +165 -0
  27. package/assets/scripts/runtime/start-cowork.sh +5 -0
  28. package/assets/scripts/runtime/start-messenger.sh +29 -4
  29. package/assets/scripts/runtime/start-telegram.sh +5 -0
  30. package/assets/scripts/runtime/supervise.mjs +35 -0
  31. package/assets/sources.json +54 -28
  32. package/install.mjs +7 -2
  33. package/lib/boot-readiness.mjs +43 -0
  34. package/lib/build-transition.mjs +16 -0
  35. package/lib/client-cli.mjs +127 -0
  36. package/lib/container-engine.mjs +127 -0
  37. package/lib/diagnostics.mjs +1 -0
  38. package/lib/docker-conversion-runtime.mjs +3 -2
  39. package/lib/docker-layout-installation.mjs +2 -1
  40. package/lib/docker-recovery.mjs +55 -0
  41. package/lib/docker-runtime-repair.mjs +160 -0
  42. package/lib/effects.mjs +357 -133
  43. package/lib/gateway-transition.mjs +87 -0
  44. package/lib/gateway.mjs +158 -0
  45. package/lib/legacy-migration.mjs +13 -2
  46. package/lib/orchestrate-uninstall.mjs +18 -6
  47. package/lib/orchestrate.mjs +193 -43
  48. package/lib/plan.mjs +27 -11
  49. package/lib/podman-preflight.mjs +40 -0
  50. package/lib/server-onboarding.mjs +164 -16
  51. package/lib/server-ports.mjs +17 -0
  52. package/lib/setup-options.mjs +99 -28
  53. package/lib/setup.mjs +35 -17
  54. package/lib/target.mjs +24 -8
  55. package/lib/uninstall.mjs +2 -2
  56. package/lib/usage.mjs +21 -7
  57. package/lib/workspace-setup.mjs +83 -0
  58. package/package.json +4 -2
@@ -0,0 +1,90 @@
1
+ import { createServer, request } from 'node:http';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { readFileSync, statSync } from 'node:fs';
4
+
5
+ /** Browser context has no authority at the notification service. */
6
+ const BROWSER = ['cookie', 'origin', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user'];
7
+ const SERVER_CREDENTIAL = 'x-ours-api-token', PRODUCER = 'x-ours-notifications-producer';
8
+
9
+ /** true: accepted; false: explicitly denied; null: the daemon could not decide. */
10
+ export function daemonCredentialCheck(daemonUrl) {
11
+ return token => new Promise(resolve => {
12
+ const req = request(new URL('/identities', daemonUrl), { method: 'GET', headers: { [SERVER_CREDENTIAL]: token }, timeout: 5000 }, res => {
13
+ res.resume(); resolve(res.statusCode >= 200 && res.statusCode < 300 ? true : [401,403].includes(res.statusCode) ? false : null);
14
+ });
15
+ req.on('timeout', () => req.destroy());
16
+ req.on('error', () => resolve(null));
17
+ req.end();
18
+ });
19
+ }
20
+
21
+ /**
22
+ * Front the notification service with the server's own authentication.
23
+ * A request carrying a server API credential is checked with the daemon; only then
24
+ * does it act as this installation's owner (or, for producer lifecycle routes, as the named
25
+ * producer). A request without one keeps its own Authorization, which the service
26
+ * checks against its scoped tokens. Nothing is injected for an unauthenticated caller.
27
+ */
28
+ export function createNotificationsAdapter({ service, userToken, verifyServerCredential }) {
29
+ const authorize = async (req) => {
30
+ for (const name of BROWSER) delete req.headers[name];
31
+ const credential = req.headers[SERVER_CREDENTIAL], producer = req.headers[PRODUCER];
32
+ delete req.headers[SERVER_CREDENTIAL]; delete req.headers[PRODUCER];
33
+ if (credential === undefined) return producer === undefined ? 200 : 401;
34
+ if (typeof credential !== 'string' || !credential) return 401;
35
+ let verified;
36
+ try { verified = await verifyServerCredential(credential); } catch { return 503; }
37
+ if (verified !== true) return verified === false ? 401 : 503;
38
+ const path = new URL(req.url ?? '/', 'http://localhost').pathname;
39
+ if (producer !== undefined && !(req.method === 'POST' && ['/api/v1/send', '/api/v1/delete-target'].includes(path))) return 401;
40
+ req.headers.authorization = `Bearer ${producer ?? userToken}`;
41
+ return 200;
42
+ };
43
+ const server = createServer(async (req, res) => {
44
+ if (req.url === '/healthz' && req.method === 'GET') { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); return; }
45
+ const status = await authorize(req);
46
+ if (status !== 200) {
47
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...(status === 503 ? { 'retry-after': '1' } : {}) });
48
+ res.end(status === 503 ? '{"error":{"code":"authentication_unavailable","message":"Server authentication is temporarily unavailable"}}' : '{"error":"authentication required"}'); return;
49
+ }
50
+ service.server.emit('request', req, res);
51
+ });
52
+ // Upgraded connections leave the HTTP server's connection tracking, so shutdown ends them itself.
53
+ const upgraded = new Set();
54
+ server.on('upgrade', async (req, socket, head) => {
55
+ upgraded.add(socket); socket.once('close', () => upgraded.delete(socket));
56
+ const status = await authorize(req);
57
+ if (status !== 200) { socket.end(`HTTP/1.1 ${status} ${status === 503 ? 'Service Unavailable' : 'Unauthorized'}\r\nConnection: close\r\n${status === 503 ? 'Retry-After: 1\r\n' : ''}Content-Length: 0\r\n\r\n`); return; }
58
+ service.server.emit('upgrade', req, socket, head);
59
+ });
60
+ return { server, async close() {
61
+ // Stop accepting first; the service then ends its presence sockets and saves state.
62
+ const listening = new Promise(resolve => server.close(() => resolve()));
63
+ server.closeAllConnections();
64
+ await service.close();
65
+ for (const socket of upgraded) socket.destroy();
66
+ await listening;
67
+ } };
68
+ }
69
+
70
+ /** The installer-generated private configuration also names the owner token this adapter injects. */
71
+ export function readPrivateConfig(path) {
72
+ const stat = statSync(path);
73
+ if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid()) throw new Error('Notification configuration must be a private owner file');
74
+ const config = JSON.parse(readFileSync(path, 'utf8'));
75
+ if (!Array.isArray(config.users) || config.users.length !== 1 || typeof config.users[0]?.token !== 'string') throw new Error('Notification configuration must select one owner');
76
+ return config;
77
+ }
78
+
79
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
80
+ const config = readPrivateConfig(process.env.OURS_NOTIFICATIONS_CONFIG ?? '/var/lib/ours-notifications/config.json');
81
+ const { createNotificationService } = await import('/opt/ours/node_modules/@ours.network/notifications/dist/server.js');
82
+ const adapter = createNotificationsAdapter({
83
+ service: createNotificationService(config),
84
+ userToken: config.users[0].token,
85
+ verifyServerCredential: daemonCredentialCheck(process.env.OURS_DAEMON_URL ?? 'http://daemon:3050'),
86
+ });
87
+ await new Promise((resolve, reject) => { adapter.server.once('error', reject); adapter.server.listen(config.port, config.host, resolve); });
88
+ console.log('Notification service ready');
89
+ for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, async () => { await adapter.close(); process.exit(0); });
90
+ }
@@ -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.41",
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.54",
33
+ "integrity": "sha512-Sh3t8iRGBoYzbyslS+IKuqQ573Zn+M0GVhR0cGepy/hlyNMAwXD2TBM+j7d8QiA1+ALugkMhKnanivE97JBmhQ=="
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.13",
37
+ "integrity": "sha512-/SxWOeiQfOu0i+jV3su2khYQ4eUCH+exe6CjSUZ9u5FmTUfXmg7cBnPOzFT2PKbNulsSLf9vnU+QBGBwSa/Iag=="
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.13",
41
+ "integrity": "sha512-m5tcTu6QoMhrcrKgglTyG+nxe1nwRLc2t7nPJcjVRqf/5m8e9CRGqXssrioOAS/wD7qGKl2jPVwJr/0ZmMltVQ=="
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.13",
45
+ "integrity": "sha512-KnVGMyZAL9i8vqFTvI3V6OEpbOqk4YH106jfeB0JlTCRkk0Oy5H1rx764GucIxmYCiasAF1NKExHe2tDLlmiXQ=="
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.54"
69
91
  },
70
92
  "@ours.network/mcp": {
71
93
  "type": "npm",
72
- "version": "1.2.0-nightly.3"
94
+ "version": "1.2.0-nightly.13"
73
95
  },
74
96
  "@ours.network/codex": {
75
97
  "type": "npm",
76
- "version": "1.2.0-nightly.3"
98
+ "version": "1.2.0-nightly.13"
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.13"
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
+ }
@@ -19,6 +19,7 @@ export async function serverBuildTransition(record, args, effects) {
19
19
  effects.writeJson(path, JSON.stringify(record, null, 2) + '\n');
20
20
  };
21
21
  let transition = record.buildTransition;
22
+ const resuming = !!transition;
22
23
  if (!transition) {
23
24
  const candidate = await stage('Prepare the updated runtime', () => effects.prepareServerBuild(record, args));
24
25
  try {
@@ -41,18 +42,33 @@ export async function serverBuildTransition(record, args, effects) {
41
42
  // A failed readiness check can leave some new services running. Every retry
42
43
  // excludes those writers again and retains the original requested running set.
43
44
  await stage('Stop services before updating stored state', () => effects.retireServerBuildRuntime(record));
45
+ const recover = resuming && record.mode === 'docker' && transition.operation === 'update' && transition.compatible;
46
+ if (recover && transition.phase === 'prepared'
47
+ && await stage('Verify and recover retained Notifications provenance', () => effects.recoverServerBuildNotifications(record, candidate, 'prepared'))) {
48
+ transition = { ...transition, phase: 'state-updated' };
49
+ save(transition);
50
+ }
44
51
  if (transition.phase === 'prepared') {
45
52
  await stage('Update stored state while retaining identities and credentials', () => effects.updateServerBuildState(record, candidate, transition.compatible, transition.operation));
46
53
  transition = { ...transition, phase: 'state-updated' };
47
54
  save(transition);
48
55
  }
56
+ // Old installers omitted Notifications from the already completed state
57
+ // operation. The shipped helper validates both retained generations before
58
+ // repairing that one domain; the candidate and all lifecycle guards remain.
59
+ if (recover) {
60
+ await stage('Verify and recover retained Notifications provenance', () => effects.recoverServerBuildNotifications(record, candidate, transition.phase));
61
+ }
49
62
  if (transition.phase === 'state-updated') {
50
63
  await stage('Activate the prepared runtime', () => effects.publishServerBuild(record, candidate));
51
64
  transition = { ...transition, phase: 'runtime-activated' };
52
65
  save(transition);
53
66
  }
54
67
  await stage('Verify retained state and identities', () => effects.validateServerBuildState(record));
68
+ // Repeatable: an updated runtime may need volume state it introduced, such as notification credentials.
69
+ if (record.gateway) await stage('Prepare stored state for the updated runtime', () => effects.prepareServerVolumes(record));
55
70
  await stage('Restore previously running services and check readiness', () => effects.serverLifecycle(record, 'start', runningServices));
71
+ if (record.gateway && ['daemon', 'cowork', 'gateway'].every(name => runningServices.includes(name))) await stage('Verify authenticated gateway management', () => effects.verifyGateway(record));
56
72
  const completed = { ...record, sourcePolicyHash: candidate.sourcePolicyHash };
57
73
  delete completed.buildTransition;
58
74
  effects.writeJson(path, JSON.stringify(completed, null, 2) + '\n');