@ours.network/install 1.2.1-nightly.38 → 1.2.1-nightly.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -2
- package/assets/Dockerfile +1 -1
- package/assets/docker-compose.yaml +4 -0
- package/assets/release-lock.json +10 -10
- package/assets/release.json +3 -3
- package/assets/scripts/runtime/README.md +7 -0
- package/assets/scripts/runtime/dependency-ready.mjs +13 -0
- package/assets/scripts/runtime/entrypoint.sh +4 -0
- package/assets/scripts/runtime/recover.mjs +165 -0
- package/assets/scripts/runtime/start-cowork.sh +5 -0
- package/assets/scripts/runtime/start-messenger.sh +4 -0
- package/assets/scripts/runtime/start-telegram.sh +5 -0
- package/assets/sources.json +4 -4
- package/lib/boot-readiness.mjs +43 -0
- package/lib/docker-recovery.mjs +55 -0
- package/lib/effects.mjs +19 -1
- package/lib/orchestrate-uninstall.mjs +18 -6
- package/lib/orchestrate.mjs +2 -0
- package/lib/setup.mjs +1 -1
- package/lib/target.mjs +1 -1
- package/lib/usage.mjs +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -637,7 +637,7 @@ Existing installation records keep their ports and identities. A listener acquir
|
|
|
637
637
|
by another process after preflight still causes startup to fail safely; no foreign
|
|
638
638
|
process is stopped and no retained installation is silently moved.
|
|
639
639
|
|
|
640
|
-
Workspace setup pins Fleet `1.2.0-nightly.
|
|
640
|
+
Workspace setup pins Fleet `1.2.0-nightly.52` with its recorded registry integrity. This release includes first-time Fleet setup through the host API for the account App, direct App access to the workspace services with the device credential, v2 positional one-use grants and automatic QR/connection-code output from `ours-fleet setup-tunnel`, optional private file/stdin inputs for legacy v1 payloads, a separate `ours-fleet link-device` command for additional devices, host prerequisite checks before the one-time workspace proof (`setup-tunnel --check`), `setup-tunnel --resume` to finish a setup that stopped after the host saved its private pending setup record (not when the proof's answer was lost, or after the setup window expired with an unconfirmed non-default port), and persists the available Fleet loopback port used by the workspace tunnel. It also accepts a setup-generated configuration left behind after the host enrollment state was removed, runs the host Codex when it is at least as new as the packaged one, offers only the Codex models the signed-in account can use, and removes the temporary setup contact after confirmation. Fleet serves no pages and opens no browser: every screen is in the App. Fleet also serves the workspace notification routes, produces notifications through the server gateway with the imported producer credential, accepts the App's device credential on the notification presence socket so an open App receives no push, and defaults agent wakes to `monitor.interrupt: after_tool` (Hermes: no interrupt; role snapshots saved before this default keep no interrupt). Existing installation identities and configured ports remain retained; a retained-port collision fails safely.
|
|
641
641
|
|
|
642
642
|
### App tunnel setup prerelease sequencing
|
|
643
643
|
|
|
@@ -654,7 +654,7 @@ connection code automatically; the App checks readiness and opens the code field
|
|
|
654
654
|
Private `--file`/`--stdin` transports remain optional and are the only transports
|
|
655
655
|
for legacy v1 payloads with long-lived credentials.
|
|
656
656
|
|
|
657
|
-
The nightly installer release manifest selects published Fleet `1.2.0-nightly.
|
|
657
|
+
The nightly installer release manifest selects published Fleet `1.2.0-nightly.52`
|
|
658
658
|
and its actual registry integrity through the normal release manifest/source-policy
|
|
659
659
|
generators. This release includes v2 argument and private-input support. The former
|
|
660
660
|
nightly.41 selection predates this capability. Merge and publish the repinned
|
|
@@ -668,3 +668,60 @@ conversation using the credential's fixed user/source scope. Browser inbox
|
|
|
668
668
|
`/api/v1/delete` continues to require the user credential. The notification
|
|
669
669
|
service must support the scoped lifecycle route; this change does not repin or
|
|
670
670
|
publish an installer release.
|
|
671
|
+
|
|
672
|
+
## Boot and failure recovery
|
|
673
|
+
|
|
674
|
+
Docker long-running daemon, Telegram, Cowork, Messenger/Notifications and gateway
|
|
675
|
+
containers use `unless-stopped`. They resume when the selected Docker Engine
|
|
676
|
+
starts, and Docker restarts a failed container with its built-in backoff. A manual
|
|
677
|
+
`docker stop`, `ours-install server stop`, or uninstall stays stopped across
|
|
678
|
+
Engine restarts. Administrative/maintenance jobs have no automatic restart.
|
|
679
|
+
Docker health status alone does not restart a container.
|
|
680
|
+
|
|
681
|
+
The four application containers also supervise their locked entrypoints. After
|
|
682
|
+
90 seconds of startup grace, probes run every 5 seconds with a 15-second timeout.
|
|
683
|
+
Three consecutive local health failures or a child exit stop its entire process
|
|
684
|
+
group (TERM, then KILL within 20 seconds) before restarting. Cowork's detached
|
|
685
|
+
workers carry an explicit supervisor-PID marker; recovery also kills those
|
|
686
|
+
owned groups and waits for them to stop before launching a replacement. Each container start
|
|
687
|
+
allows three automatic child recovery attempts. Exhaustion leaves the container
|
|
688
|
+
running but unhealthy, with its children stopped and a diagnostic in its logs.
|
|
689
|
+
Correct the cause and use `ours-install server restart --state-dir PATH` to
|
|
690
|
+
reset that circuit; `server start` alone does not reset a running circuit holder.
|
|
691
|
+
Consumers wait for authenticated readiness of their pinned daemon instance and
|
|
692
|
+
do not spend their recovery budget on a daemon outage. The gateway uses Docker
|
|
693
|
+
process recovery only; a gateway hang requires operator restart.
|
|
694
|
+
|
|
695
|
+
Re-run `server install` to deliver recovery to an existing installation without
|
|
696
|
+
changing its retained exact packages, identity, credentials, volumes or config.
|
|
697
|
+
This explicit install starts its selected services, as before. `server update`
|
|
698
|
+
with reviewed storage compatibility delivers the new selected release and
|
|
699
|
+
restores only previously running services. Retained runtime scripts are refreshed
|
|
700
|
+
and rebuilt on install; an interrupted refresh retains a rebuild marker. The
|
|
701
|
+
restart policy overlay also covers retained Compose files. Reconciliation updates
|
|
702
|
+
only this project's recorded long-running containers and does not start or
|
|
703
|
+
recreate a deliberately stopped container. `server start` can reconcile policies
|
|
704
|
+
but does not retrofit health supervision into an old image: run install/update.
|
|
705
|
+
Backup, migration and update still stop writers before maintenance, and a failed
|
|
706
|
+
maintenance operation keeps them stopped. `server uninstall` (also `ours-uninstall --state-dir PATH` for a managed
|
|
707
|
+
schema-2 installation) stops and removes managed services, retaining volumes,
|
|
708
|
+
identity and credentials. It refuses unfinished transitions and data purge.
|
|
709
|
+
Recovery introduces no host watchdog or service.
|
|
710
|
+
|
|
711
|
+
Host boot is a separate prerequisite. On Linux, enable the selected Docker
|
|
712
|
+
Engine system service; rootless Docker additionally needs its user service and
|
|
713
|
+
user linger. The installer reports read-only boot diagnostics and does not enable
|
|
714
|
+
or restart host services. A reachable remote Engine or Docker Desktop context
|
|
715
|
+
cannot prove boot recovery on this machine. A custom Unix socket also has an
|
|
716
|
+
unverified boot-service binding; a host unit cannot establish its startup. On macOS/Windows with WSL, enable Docker
|
|
717
|
+
Desktop startup at sign-in; this does not provide pre-login startup. Rootless
|
|
718
|
+
Podman's existing enabled socket/restart-service and linger preflight remains.
|
|
719
|
+
Native package mode retains its package-owned systemd user/launchd services:
|
|
720
|
+
Linux needs user linger for pre-login startup, macOS needs a GUI login session,
|
|
721
|
+
and native hung-process recovery is not supplied by this Docker supervisor.
|
|
722
|
+
Fleet stays on the host with Fleet-owned lifecycle and crash protection. Installing
|
|
723
|
+
the exact Fleet nightly binary does not upgrade or restart an existing supervisor.
|
|
724
|
+
The published no-policy core delivery is a verified recovery omission that
|
|
725
|
+
reproduces the reported symptom. The original incident cause remains unconfirmed:
|
|
726
|
+
actual host Engine availability, installed policies and pre-reboot stopped state
|
|
727
|
+
still require Owner confirmation.
|
package/assets/Dockerfile
CHANGED
|
@@ -27,7 +27,7 @@ COPY --chmod=644 scripts/runtime/diagnostics.mjs /opt/ours/docker/diagnostics.mj
|
|
|
27
27
|
COPY --chmod=644 scripts/runtime/legacy-import.mjs /opt/ours/docker/legacy-import.mjs
|
|
28
28
|
COPY --chmod=644 scripts/runtime/entrypoint.sh /opt/ours/docker/entrypoint.sh
|
|
29
29
|
COPY --chmod=644 scripts/runtime/healthcheck.mjs /opt/ours/docker/healthcheck.mjs
|
|
30
|
-
COPY --chmod=644 scripts/runtime/start-telegram.sh scripts/runtime/health-telegram.sh scripts/runtime/start-cowork.sh scripts/runtime/health-cowork.sh scripts/runtime/start-messenger.sh scripts/runtime/health-messenger.mjs scripts/runtime/notifications-gateway.mjs scripts/runtime/supervise.mjs /opt/ours/docker/
|
|
30
|
+
COPY --chmod=644 scripts/runtime/start-telegram.sh scripts/runtime/health-telegram.sh scripts/runtime/start-cowork.sh scripts/runtime/health-cowork.sh scripts/runtime/start-messenger.sh scripts/runtime/health-messenger.mjs scripts/runtime/notifications-gateway.mjs scripts/runtime/supervise.mjs scripts/runtime/recover.mjs scripts/runtime/dependency-ready.mjs /opt/ours/docker/
|
|
31
31
|
RUN chmod 755 /opt/ours/maintenance /opt/ours/docker
|
|
32
32
|
FROM node:24 AS maintenance
|
|
33
33
|
LABEL network.ours.build-context="1"
|
|
@@ -83,6 +83,7 @@ services:
|
|
|
83
83
|
|
|
84
84
|
daemon:
|
|
85
85
|
<<: *runtime
|
|
86
|
+
restart: unless-stopped
|
|
86
87
|
environment: *owner-environment
|
|
87
88
|
volumes:
|
|
88
89
|
- {type: volume, source: server-storage, target: /var/lib/ours, volume: {nocopy: true, subpath: state/daemon}}
|
|
@@ -107,6 +108,7 @@ services:
|
|
|
107
108
|
|
|
108
109
|
telegram:
|
|
109
110
|
<<: *runtime
|
|
111
|
+
restart: unless-stopped
|
|
110
112
|
entrypoint: [/bin/sh, -e, /opt/ours/docker/start-telegram.sh]
|
|
111
113
|
environment:
|
|
112
114
|
HOME: /tmp
|
|
@@ -129,6 +131,7 @@ services:
|
|
|
129
131
|
|
|
130
132
|
cowork:
|
|
131
133
|
<<: *runtime
|
|
134
|
+
restart: unless-stopped
|
|
132
135
|
entrypoint: [/bin/sh, -e, /opt/ours/docker/start-cowork.sh]
|
|
133
136
|
environment:
|
|
134
137
|
HOME: /tmp
|
|
@@ -153,6 +156,7 @@ services:
|
|
|
153
156
|
|
|
154
157
|
messenger:
|
|
155
158
|
<<: *runtime
|
|
159
|
+
restart: unless-stopped
|
|
156
160
|
entrypoint: [/bin/sh, -e, /opt/ours/docker/start-messenger.sh]
|
|
157
161
|
environment:
|
|
158
162
|
HOME: /tmp
|
package/assets/release-lock.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"@ours.network/codex": "1.2.0-nightly.12",
|
|
14
14
|
"@ours.network/cowork": "1.3.3-nightly.20261005.f549477",
|
|
15
15
|
"@ours.network/daemon": "3.8.1-nightly.9",
|
|
16
|
-
"@ours.network/fleet": "1.2.0-nightly.
|
|
16
|
+
"@ours.network/fleet": "1.2.0-nightly.52",
|
|
17
17
|
"@ours.network/mcp": "1.2.0-nightly.12",
|
|
18
18
|
"@ours.network/messenger-server": "1.0.31-nightly.17",
|
|
19
19
|
"@ours.network/notifications": "0.3.1-nightly.2",
|
|
@@ -542,9 +542,9 @@
|
|
|
542
542
|
]
|
|
543
543
|
},
|
|
544
544
|
"node_modules/@anthropic-ai/sdk": {
|
|
545
|
-
"version": "0.
|
|
546
|
-
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.
|
|
547
|
-
"integrity": "sha512-
|
|
545
|
+
"version": "0.132.0",
|
|
546
|
+
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.132.0.tgz",
|
|
547
|
+
"integrity": "sha512-hsKXmb+GjIgGpaJRF7rf2yKFa1SPyVJPQhZLNPDYA4J5YRrcVh4jn/aozLRKHWXGCGM9MPw16S+ry+zKIKCr7Q==",
|
|
548
548
|
"license": "MIT",
|
|
549
549
|
"optional": true,
|
|
550
550
|
"peer": true,
|
|
@@ -565,9 +565,9 @@
|
|
|
565
565
|
}
|
|
566
566
|
},
|
|
567
567
|
"node_modules/@babel/runtime": {
|
|
568
|
-
"version": "7.29.
|
|
569
|
-
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.
|
|
570
|
-
"integrity": "sha512-
|
|
568
|
+
"version": "7.29.10",
|
|
569
|
+
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
|
|
570
|
+
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
|
571
571
|
"license": "MIT",
|
|
572
572
|
"optional": true,
|
|
573
573
|
"peer": true,
|
|
@@ -1002,9 +1002,9 @@
|
|
|
1002
1002
|
}
|
|
1003
1003
|
},
|
|
1004
1004
|
"node_modules/@ours.network/fleet": {
|
|
1005
|
-
"version": "1.2.0-nightly.
|
|
1006
|
-
"resolved": "https://registry.npmjs.org/@ours.network/fleet/-/fleet-1.2.0-nightly.
|
|
1007
|
-
"integrity": "sha512-
|
|
1005
|
+
"version": "1.2.0-nightly.52",
|
|
1006
|
+
"resolved": "https://registry.npmjs.org/@ours.network/fleet/-/fleet-1.2.0-nightly.52.tgz",
|
|
1007
|
+
"integrity": "sha512-nIwGknps0FBDhdtivXAFalRbZ67XHAdNcs6AiTBE0ULYeLcohdfqoFqyAS0iZQPUeEUhxkPTKfVuESKeQKP/ow==",
|
|
1008
1008
|
"license": "FSL-1.1-Apache-2.0",
|
|
1009
1009
|
"dependencies": {
|
|
1010
1010
|
"@agentclientprotocol/sdk": "^1.3.0",
|
package/assets/release.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"channel": "nightly",
|
|
4
|
-
"installerVersion": "1.2.1-nightly.
|
|
4
|
+
"installerVersion": "1.2.1-nightly.39",
|
|
5
5
|
"packages": {
|
|
6
6
|
"@ours.network/sdk": {
|
|
7
7
|
"version": "3.8.1-nightly.17",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"integrity": "sha512-OjnrJ8ZuOfKYT4KAmiRLU7czfiuacs6rShwOvvqxqBaUKcs1UwjsYwHARNb6erMxG4am3xxcE0ItahL+d2vt4A=="
|
|
29
29
|
},
|
|
30
30
|
"@ours.network/fleet": {
|
|
31
|
-
"version": "1.2.0-nightly.
|
|
32
|
-
"integrity": "sha512-
|
|
31
|
+
"version": "1.2.0-nightly.52",
|
|
32
|
+
"integrity": "sha512-nIwGknps0FBDhdtivXAFalRbZ67XHAdNcs6AiTBE0ULYeLcohdfqoFqyAS0iZQPUeEUhxkPTKfVuESKeQKP/ow=="
|
|
33
33
|
},
|
|
34
34
|
"@ours.network/mcp": {
|
|
35
35
|
"version": "1.2.0-nightly.12",
|
|
@@ -22,3 +22,10 @@ mode. Docker uses one named `server-storage` volume: `state/daemon`, `state/mcp`
|
|
|
22
22
|
Preparation mounts the storage root; running applications mount only their own
|
|
23
23
|
state children and selected credentials. Backups and maintenance staging are
|
|
24
24
|
outside `state/`. The owner-lock volume contains transient runtime locks.
|
|
25
|
+
|
|
26
|
+
`recover.mjs` supervises the locked entrypoints with timed health probes and three
|
|
27
|
+
automatic recovery attempts per container start. It kills the entire child group
|
|
28
|
+
and waits for lock release before a new generation. Exhaustion holds unhealthy
|
|
29
|
+
until explicit restart. `dependency-ready.mjs` waits for the authenticated pinned
|
|
30
|
+
daemon without consuming the consumer's failure circuit. Host boot still depends
|
|
31
|
+
on an independently configured Docker Engine or Desktop startup.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { attachOursClient } from '@ours.network/sdk/client';
|
|
2
|
+
let client;
|
|
3
|
+
try {
|
|
4
|
+
const service = process.argv[2];
|
|
5
|
+
if (!['telegram', 'cowork', 'messenger'].includes(service)) throw new Error('Unknown dependency');
|
|
6
|
+
client = await attachOursClient({ endpoint: 'http://daemon:3050',
|
|
7
|
+
expectedInstanceId: process.env[service === 'telegram' ? 'OURS_TG_DAEMON_ID' : 'OURS_DAEMON_ID'],
|
|
8
|
+
credentialPath: `/credentials/${service}/daemon-token`,
|
|
9
|
+
requiredCapabilities: ['external-sessions-v1'], sessionMode: 'external',
|
|
10
|
+
leaseToken: `container-dependency-${service}`, env: {} });
|
|
11
|
+
await client.listIdentities();
|
|
12
|
+
} catch { process.exitCode = 1; }
|
|
13
|
+
finally { await client?.close(); }
|
|
@@ -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,4 +1,8 @@
|
|
|
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
|
|
2
6
|
set -e
|
|
3
7
|
umask 077
|
|
4
8
|
runtime="${OURS_RUNTIME_ROOT:-/opt/ours}"
|
|
@@ -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
|
package/assets/sources.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"release": {
|
|
3
3
|
"schema": 1,
|
|
4
4
|
"channel": "nightly",
|
|
5
|
-
"installerVersion": "1.2.1-nightly.
|
|
5
|
+
"installerVersion": "1.2.1-nightly.39",
|
|
6
6
|
"packages": {
|
|
7
7
|
"@ours.network/sdk": {
|
|
8
8
|
"version": "3.8.1-nightly.17",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"integrity": "sha512-OjnrJ8ZuOfKYT4KAmiRLU7czfiuacs6rShwOvvqxqBaUKcs1UwjsYwHARNb6erMxG4am3xxcE0ItahL+d2vt4A=="
|
|
30
30
|
},
|
|
31
31
|
"@ours.network/fleet": {
|
|
32
|
-
"version": "1.2.0-nightly.
|
|
33
|
-
"integrity": "sha512-
|
|
32
|
+
"version": "1.2.0-nightly.52",
|
|
33
|
+
"integrity": "sha512-nIwGknps0FBDhdtivXAFalRbZ67XHAdNcs6AiTBE0ULYeLcohdfqoFqyAS0iZQPUeEUhxkPTKfVuESKeQKP/ow=="
|
|
34
34
|
},
|
|
35
35
|
"@ours.network/mcp": {
|
|
36
36
|
"version": "1.2.0-nightly.12",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
},
|
|
88
88
|
"@ours.network/fleet": {
|
|
89
89
|
"type": "npm",
|
|
90
|
-
"version": "1.2.0-nightly.
|
|
90
|
+
"version": "1.2.0-nightly.52"
|
|
91
91
|
},
|
|
92
92
|
"@ours.network/mcp": {
|
|
93
93
|
"type": "npm",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync, lstatSync, realpathSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { atomicWriteConfig } from './config.mjs';
|
|
4
|
+
import { engineName, runContainer } from './container-engine.mjs';
|
|
5
|
+
|
|
6
|
+
const assets = new URL('../assets/', import.meta.url);
|
|
7
|
+
const files = ['scripts/runtime/recover.mjs', 'scripts/runtime/dependency-ready.mjs',
|
|
8
|
+
...['entrypoint.sh', 'start-telegram.sh', 'start-cowork.sh', 'start-messenger.sh'].map(f => `scripts/runtime/${f}`)];
|
|
9
|
+
const owned = (path, directory = false) => {
|
|
10
|
+
const s = lstatSync(path);
|
|
11
|
+
if (!(directory ? s.isDirectory() : s.isFile()) || s.uid !== process.getuid() || (!directory && s.nlink !== 1)
|
|
12
|
+
|| realpathSync(path) !== path || (s.mode & 0o7000)) throw new Error('Unsafe retained recovery delivery');
|
|
13
|
+
};
|
|
14
|
+
/** Refresh installer-owned runtime scripts without changing source selection or state. */
|
|
15
|
+
export function refreshDockerRecovery(record) {
|
|
16
|
+
for (const dir of [record.root, record.workDir, join(record.workDir, 'scripts'), join(record.workDir, 'scripts/runtime')]) owned(dir, true);
|
|
17
|
+
const marker = join(record.workDir, '.recovery-rebuild');
|
|
18
|
+
let changed = false;
|
|
19
|
+
const replace = (name, bytes) => {
|
|
20
|
+
const path = join(record.workDir, name);
|
|
21
|
+
if (existsSync(path)) { owned(path); if (readFileSync(path).equals(bytes)) return; }
|
|
22
|
+
// Persist before edits, so interruption cannot reuse an image lacking recovery.
|
|
23
|
+
atomicWriteConfig(marker, 'required\n'); changed = true;
|
|
24
|
+
atomicWriteConfig(path, bytes.toString());
|
|
25
|
+
};
|
|
26
|
+
for (const name of files) replace(name, readFileSync(new URL(name, assets)));
|
|
27
|
+
const dockerfile = join(record.workDir, 'Dockerfile'); owned(dockerfile);
|
|
28
|
+
const text = readFileSync(dockerfile, 'utf8');
|
|
29
|
+
const line = 'COPY --chmod=644 scripts/runtime/recover.mjs scripts/runtime/dependency-ready.mjs /opt/ours/docker/';
|
|
30
|
+
if (!text.includes('scripts/runtime/recover.mjs')) replace('Dockerfile', Buffer.from(text.replace('FROM node:24 AS maintenance', `${line}\nFROM node:24 AS maintenance`)));
|
|
31
|
+
return changed || existsSync(marker);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** An overlay also applies to retained Compose files, with one-shot jobs untouched. */
|
|
35
|
+
export function recoveryCompose(record) {
|
|
36
|
+
return { services: Object.fromEntries(record.services.map(name => [name, { restart: 'unless-stopped' }])) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Change only exact project-owned long-running containers; never start/recreate. */
|
|
40
|
+
export async function reconcileDockerRecovery(record, effects) {
|
|
41
|
+
if (engineName(record) !== 'docker') return;
|
|
42
|
+
const query = await runContainer(effects, record, ['ps', '-aq', '--no-trunc', '--filter', `label=com.docker.compose.project=${record.project}`]);
|
|
43
|
+
const ids = query.stdout.split(/\s+/).filter(Boolean);
|
|
44
|
+
if (!ids.length) return;
|
|
45
|
+
const inspected = JSON.parse((await runContainer(effects, record, ['inspect', ...ids])).stdout);
|
|
46
|
+
const selected = [];
|
|
47
|
+
for (const c of inspected) {
|
|
48
|
+
const labels = c.Config?.Labels;
|
|
49
|
+
if (labels?.['com.docker.compose.project'] !== record.project) throw new Error('Recovery container project ownership differs');
|
|
50
|
+
if (!record.services.includes(labels['com.docker.compose.service']) || labels['com.docker.compose.oneoff']?.toLowerCase() !== 'false') continue;
|
|
51
|
+
if (!ids.includes(c.Id)) throw new Error('Recovery container identity differs');
|
|
52
|
+
if (c.HostConfig?.RestartPolicy?.Name !== 'unless-stopped') selected.push(c.Id);
|
|
53
|
+
}
|
|
54
|
+
if (selected.length) await runContainer(effects, record, ['update', '--restart', 'unless-stopped', ...selected]);
|
|
55
|
+
}
|
package/lib/effects.mjs
CHANGED
|
@@ -30,6 +30,8 @@ import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
|
|
|
30
30
|
import { select as selectOnTty, multiselect as multiselectOnTty, askLine as askLineOnTty } from './prompt.mjs';
|
|
31
31
|
import { classifyHarnessProbe } from './logic.mjs';
|
|
32
32
|
import { qualifyDockerRuntime, refreshDockerPolicyCopy } from './docker-runtime-repair.mjs';
|
|
33
|
+
import { refreshDockerRecovery, recoveryCompose, reconcileDockerRecovery } from './docker-recovery.mjs';
|
|
34
|
+
import { reportBootReadiness } from './boot-readiness.mjs';
|
|
33
35
|
import { classifyStateDir } from './detect.mjs';
|
|
34
36
|
import { BASE_RECORDS, CONTEXT, readBuildRecords, equalBuildRecords, initializeBuildMarker } from '../assets/scripts/maintenance/build-context.mjs';
|
|
35
37
|
import { hostCliPolicy, releaseBinding, verifyReleaseGraph, verifyRuntimeRelease } from '../assets/scripts/maintenance/release-graph.mjs';
|
|
@@ -555,6 +557,7 @@ export function networkEffects(effects) {
|
|
|
555
557
|
record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
|
|
556
558
|
? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
|
|
557
559
|
...(record.gateway ? ['--file', join(record.workDir, 'docker-compose.gateway.yaml')] : []),
|
|
560
|
+
...(existsSync(join(record.workDir, 'compose.recovery.json')) ? ['--file', join(record.workDir, 'compose.recovery.json')] : []),
|
|
558
561
|
...(engineName(record) === 'podman' ? ['--file', join(record.workDir, 'compose.podman.json')] : []),
|
|
559
562
|
'--project-name', record.project, ...args,
|
|
560
563
|
];
|
|
@@ -824,6 +827,7 @@ export function networkEffects(effects) {
|
|
|
824
827
|
}
|
|
825
828
|
},
|
|
826
829
|
async prepareInstallation(record, { runtimeOnly = false } = {}) {
|
|
830
|
+
if (!runtimeOnly) await reportBootReadiness(effects, record);
|
|
827
831
|
let copied = false;
|
|
828
832
|
if (!existsSync(record.workDir)) {
|
|
829
833
|
cpSync(INSTALLER_ASSETS, record.workDir, { recursive: true, errorOnExist: true, force: false });
|
|
@@ -838,6 +842,8 @@ export function networkEffects(effects) {
|
|
|
838
842
|
if (record.mode === 'docker') {
|
|
839
843
|
if (engineName(record) === 'podman') atomicWriteConfig(join(record.workDir, 'compose.podman.json'), JSON.stringify({ services: Object.fromEntries(record.services.map(name => [name, { restart: 'unless-stopped' }])) }));
|
|
840
844
|
const rebuildPrivateScripts = refreshDockerPolicyCopy(record);
|
|
845
|
+
const rebuildRecovery = refreshDockerRecovery(record);
|
|
846
|
+
atomicWriteConfig(join(record.workDir, 'compose.recovery.json'), JSON.stringify(recoveryCompose(record)));
|
|
841
847
|
if (record.gateway) {
|
|
842
848
|
atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
|
|
843
849
|
atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
|
|
@@ -847,9 +853,10 @@ export function networkEffects(effects) {
|
|
|
847
853
|
const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
848
854
|
writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
|
|
849
855
|
const image = await container(record, ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
|
|
850
|
-
if (image.code !== 0 || rebuildPrivateScripts) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
856
|
+
if (image.code !== 0 || rebuildPrivateScripts || rebuildRecovery) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
851
857
|
await effects.qualifyDockerRuntime(record);
|
|
852
858
|
if (rebuildPrivateScripts) rmSync(join(record.workDir, '.script-permissions-rebuild'));
|
|
859
|
+
if (rebuildRecovery) rmSync(join(record.workDir, '.recovery-rebuild'));
|
|
853
860
|
if (record.gateway) {
|
|
854
861
|
await effects.qualifyGatewayRuntime(record);
|
|
855
862
|
await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
@@ -1250,6 +1257,15 @@ export function networkEffects(effects) {
|
|
|
1250
1257
|
}
|
|
1251
1258
|
await nativeLifecycle(record, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
|
|
1252
1259
|
},
|
|
1260
|
+
async serverUninstall(record) {
|
|
1261
|
+
if (record.schema !== 2 || record.layoutConversion || record.buildTransition) throw new Error('Complete retained installation transitions before uninstall');
|
|
1262
|
+
if (record.mode === 'docker') {
|
|
1263
|
+
await effects.serverLifecycle(record, 'stop');
|
|
1264
|
+
await requireCleanContainerExit(record, record.services);
|
|
1265
|
+
await compose(record, ['rm', '-f', ...record.services]);
|
|
1266
|
+
} else await nativeLifecycle(record, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
|
|
1267
|
+
effects.out('Managed services removed; installation selection, stored identities, credentials and volumes retained.');
|
|
1268
|
+
},
|
|
1253
1269
|
async serverLifecycle(record, operation, selected = record.services) {
|
|
1254
1270
|
record = buildRuntime(record);
|
|
1255
1271
|
if (record.mode === 'docker') {
|
|
@@ -1266,6 +1282,8 @@ export function networkEffects(effects) {
|
|
|
1266
1282
|
if ((await effects.serverLifecycle(record, 'status', selected)).length) throw new Error('Writers did not stop');
|
|
1267
1283
|
return;
|
|
1268
1284
|
}
|
|
1285
|
+
if (existsSync(record.workDir)) atomicWriteConfig(join(record.workDir, 'compose.recovery.json'), JSON.stringify(recoveryCompose(record)));
|
|
1286
|
+
await reconcileDockerRecovery(record, effects);
|
|
1269
1287
|
const start = async service => {
|
|
1270
1288
|
effects.out(`Starting ${service}; waiting for readiness...`);
|
|
1271
1289
|
// Gateway has no persistent state. Recreate its network endpoint on each
|
|
@@ -75,12 +75,6 @@ export async function runUninstall(argv, effects) {
|
|
|
75
75
|
if (args.version) { effects.out(`ours-uninstall v${effects.version ?? '?'}`); return EXIT_OK; }
|
|
76
76
|
const purge = argv.includes('--purge');
|
|
77
77
|
const dir = args.stateDir;
|
|
78
|
-
const config = effects.readJson(join(dir, 'config.json'));
|
|
79
|
-
const endpoint = `http://127.0.0.1:${typeof config?.port === 'number' ? config.port : 3050}`;
|
|
80
|
-
|
|
81
|
-
effects.out(heading(`ours-uninstall --state-dir ${dir}`));
|
|
82
|
-
if (args.dryRun) effects.out(info('dry-run: nothing will be removed or stopped'));
|
|
83
|
-
|
|
84
78
|
// Read the documented OURS_UNINSTALL_* contract before any
|
|
85
79
|
// file is opened. A variable this uninstaller cannot deliver stops the run
|
|
86
80
|
// here, naming itself and naming the replacement, rather than being silently
|
|
@@ -91,6 +85,24 @@ export async function runUninstall(argv, effects) {
|
|
|
91
85
|
return EXIT_REFUSED;
|
|
92
86
|
}
|
|
93
87
|
|
|
88
|
+
const managed = effects.readJson(join(dir, 'installation.json'));
|
|
89
|
+
if (managed?.schema === 2 && ['docker', 'packages'].includes(managed.mode)) {
|
|
90
|
+
if (contract.engaged || args.config || effects.env.OURS_CONFIG) {
|
|
91
|
+
effects.out(warn('Managed server uninstall does not support legacy component/profile selections. Nothing was changed; use an explicit server uninstall with those selectors cleared.'));
|
|
92
|
+
return EXIT_REFUSED;
|
|
93
|
+
}
|
|
94
|
+
if (purge) { effects.out(warn('Managed server uninstall retains identities and volumes; --purge is not supported.')); return EXIT_REFUSED; }
|
|
95
|
+
if (args.dryRun) { effects.out(info('[dry-run] would stop and remove managed server services, retaining all stored data.')); return EXIT_OK; }
|
|
96
|
+
const { runServerCommand } = await import('./orchestrate.mjs');
|
|
97
|
+
return runServerCommand({ role: 'server', operation: 'uninstall', stateDir: dir }, effects);
|
|
98
|
+
}
|
|
99
|
+
const config = effects.readJson(join(dir, 'config.json'));
|
|
100
|
+
const endpoint = `http://127.0.0.1:${typeof config?.port === 'number' ? config.port : 3050}`;
|
|
101
|
+
|
|
102
|
+
effects.out(heading(`ours-uninstall --state-dir ${dir}`));
|
|
103
|
+
if (args.dryRun) effects.out(info('dry-run: nothing will be removed or stopped'));
|
|
104
|
+
|
|
105
|
+
|
|
94
106
|
let profileSelection;
|
|
95
107
|
try {
|
|
96
108
|
profileSelection = resolveProfileSelection({
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -1470,6 +1470,8 @@ async function executeServerCommand(args, effects) {
|
|
|
1470
1470
|
}
|
|
1471
1471
|
if (record.gateway) await effects.verifyGateway(record);
|
|
1472
1472
|
if (showInstallProgress) effects.out(progress(installStageCount, installStageCount, 'Installation complete', 'The selected services are ready.'));
|
|
1473
|
+
} else if (args.operation === 'uninstall') {
|
|
1474
|
+
await effects.serverUninstall(record);
|
|
1473
1475
|
} else if (args.operation === 'gateway-enable') {
|
|
1474
1476
|
await enableGateway(record, args, effects);
|
|
1475
1477
|
} else if (['backup', 'restore', 'reset'].includes(args.operation)) {
|
package/lib/setup.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { validateIdentityName } from './server-onboarding.mjs';
|
|
|
13
13
|
import { validateFleetSettings } from './fleet-settings.mjs';
|
|
14
14
|
import { installerFailure } from './diagnostics.mjs';
|
|
15
15
|
|
|
16
|
-
const maintenance = new Set(['status', 'start', 'stop', 'restart', 'rebuild', 'gateway-enable', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
|
|
16
|
+
const maintenance = new Set(['status', 'start', 'stop', 'restart', 'uninstall', 'rebuild', 'gateway-enable', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
|
|
17
17
|
|
|
18
18
|
export function completeReleasePolicy(retained, supplied) {
|
|
19
19
|
if (retained?.release) {
|
package/lib/target.mjs
CHANGED
|
@@ -468,7 +468,7 @@ export function searchFreePort(isTaken, { floor = FREE_PORT_FLOOR, reserved = IN
|
|
|
468
468
|
export function parseNetworkArgs(argv) {
|
|
469
469
|
if (!['server', 'client'].includes(argv[0])) return null;
|
|
470
470
|
const [role, operation] = argv;
|
|
471
|
-
const operations = role === 'client' ? ['install'] : ['install', 'status', 'start', 'stop', 'restart', 'access-issue', 'access-replace', 'backup', 'restore', 'reset', 'update', 'rebuild', 'gateway-enable'];
|
|
471
|
+
const operations = role === 'client' ? ['install'] : ['install', 'status', 'start', 'stop', 'restart', 'uninstall', 'access-issue', 'access-replace', 'backup', 'restore', 'reset', 'update', 'rebuild', 'gateway-enable'];
|
|
472
472
|
if (!operations.includes(operation)) throw new InstallUsageError(`Unsupported ${role} operation: ${operation ?? '(missing)'}`);
|
|
473
473
|
const allowed = role === 'client' ? ['config'] : ['state-dir'];
|
|
474
474
|
if (role === 'server' && operation === 'install') allowed.push('mode', 'sources', 'migrate', 'container-engine');
|
package/lib/usage.mjs
CHANGED
|
@@ -58,7 +58,7 @@ installer with preparation, identity restoration, update and readiness progress.
|
|
|
58
58
|
Fleet is configured but left stopped for operator review.
|
|
59
59
|
|
|
60
60
|
Scoped maintenance:
|
|
61
|
-
ours-install server status|start|stop|restart|rebuild --state-dir PATH
|
|
61
|
+
ours-install server status|start|stop|restart|rebuild|uninstall --state-dir PATH
|
|
62
62
|
ours-install server gateway-enable --state-dir PATH [--server-url URL]
|
|
63
63
|
ours-install server access-issue --state-dir PATH --output PATH
|
|
64
64
|
ours-install server access-replace --state-dir PATH --confirm
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/install",
|
|
3
|
-
"version": "1.2.1-nightly.
|
|
3
|
+
"version": "1.2.1-nightly.39",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The all-in-one ours.network installer: one shared daemon, MCP, cowork, Telegram, Fleet initialization, harness plugins, Human identity, progress UI, and guided next steps.",
|
|
6
6
|
"type": "module",
|