@ours.network/install 1.2.1-nightly.42 → 1.2.1-nightly.44
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 +94 -3
- package/assets/Dockerfile +1 -0
- package/assets/release-lock.json +120 -106
- package/assets/release.json +5 -5
- package/assets/scripts/build/build-common.mjs +9 -1
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +15 -4
- package/assets/scripts/maintenance/state-operation.mjs +15 -4
- package/assets/scripts/runtime/client-setup.mjs +26 -15
- package/assets/scripts/runtime/daemon-client-prefix.mjs +115 -0
- package/assets/sources.json +7 -7
- package/lib/boot-readiness.mjs +67 -14
- package/lib/docker-conversion-runtime.mjs +4 -2
- package/lib/docker-recovery.mjs +6 -1
- package/lib/effects.mjs +136 -8
- package/lib/orchestrate-uninstall.mjs +6 -1
- package/lib/orchestrate.mjs +26 -1
- package/lib/ownership.mjs +63 -0
- package/lib/plan.mjs +14 -4
- package/lib/product.mjs +69 -0
- package/lib/removal-plan.mjs +437 -0
- package/lib/removal.mjs +749 -0
- package/lib/server-onboarding.mjs +22 -4
- package/lib/setup-options.mjs +20 -1
- package/lib/setup.mjs +52 -9
- package/lib/usage.mjs +14 -2
- package/lib/welcome.mjs +219 -0
- package/package.json +1 -1
|
@@ -101,11 +101,21 @@ export function bindDockerLayout(staging, options) {
|
|
|
101
101
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** The installation's applications: all four, or the daemon alone for collaboration tools. */
|
|
105
|
+
export function selectedComponents(env = process.env) {
|
|
106
|
+
const raw = env.OURS_SERVER_APPLICATIONS;
|
|
107
|
+
if (raw === undefined || raw === '') return COMPONENTS;
|
|
108
|
+
const selected = raw.split(',');
|
|
109
|
+
if (selected[0] !== 'daemon' || new Set(selected).size !== selected.length || selected.some(name => !COMPONENTS.includes(name))) throw new Error('invalid OURS_SERVER_APPLICATIONS selection');
|
|
110
|
+
return selected;
|
|
111
|
+
}
|
|
112
|
+
|
|
104
113
|
/** Offline validation also covers components intentionally left stopped. */
|
|
105
114
|
export function validateDockerLayout(tree, options) {
|
|
115
|
+
const components = options.components ?? COMPONENTS;
|
|
106
116
|
validateBuildRecordSet(options.provenance);
|
|
107
117
|
scanSource(tree, options);
|
|
108
|
-
for (const component of
|
|
118
|
+
for (const component of components) {
|
|
109
119
|
if (fs.readdirSync(join(tree, component, '.ours-provenance')).sort().join() !== recordNames(options.provenance).sort().join()) throw new Error('Mixed component provenance');
|
|
110
120
|
for (const name of recordNames(options.provenance)) {
|
|
111
121
|
if (!privateBytes(join(tree, component, '.ours-provenance', name), options).equals(options.provenance[name])) {
|
|
@@ -123,11 +133,11 @@ export function validateDockerLayout(tree, options) {
|
|
|
123
133
|
if (daemon.stateDir !== DAEMON_STATE || daemon.port !== 3050
|
|
124
134
|
|| (daemon.networkMcp !== undefined && (daemon.networkMcp?.applicationConfigPath !== join(MCP_STATE, 'config.json')
|
|
125
135
|
|| JSON.stringify(daemon.networkMcp?.profile) !== JSON.stringify(profile)))
|
|
126
|
-
|| objectAt(join(tree, 'cowork/config.json'), options).stateDir !== '/var/lib/ours-cowork') {
|
|
136
|
+
|| (components.includes('cowork') && objectAt(join(tree, 'cowork/config.json'), options).stateDir !== '/var/lib/ours-cowork')) {
|
|
127
137
|
throw new Error('Converted Docker deployment configuration is inconsistent');
|
|
128
138
|
}
|
|
129
139
|
if (fs.existsSync(join(tree, 'mcp/config.json'))) objectAt(join(tree, 'mcp/config.json'), options);
|
|
130
|
-
for (const path of ['daemon/daemon-token', ...
|
|
140
|
+
for (const path of ['daemon/daemon-token', ...components.filter(name => name !== 'daemon').map(name => `credentials/${name}/daemon-token`)]) {
|
|
131
141
|
if (!privateBytes(join(tree, path), options).length) throw new Error('Converted managed credential is empty');
|
|
132
142
|
}
|
|
133
143
|
}
|
|
@@ -214,13 +224,14 @@ export async function runDockerLayoutCommand(argv, env = process.env) {
|
|
|
214
224
|
cli: daemonOwner({ OURS_DAEMON_BIN_DIR: '/opt/ours/node_modules/.bin', ...env }),
|
|
215
225
|
configPath: env.OURS_DAEMON_CONFIG || '/var/lib/ours/config.json',
|
|
216
226
|
provenance: readBuildRecords(build),
|
|
227
|
+
components: selectedComponents(env),
|
|
217
228
|
};
|
|
218
229
|
if (!options.uid || !options.gid) throw new Error('Conversion requires a non-root owner');
|
|
219
230
|
if (operation === 'cleanup') {
|
|
220
231
|
console.log(JSON.stringify({ emptyVolumes: cleanupDockerSource(source, options) }));
|
|
221
232
|
return;
|
|
222
233
|
}
|
|
223
|
-
try {
|
|
234
|
+
if (options.components.includes('cowork')) try {
|
|
224
235
|
execFileSync(env.OURS_COWORK_CLI_PATH || '/opt/ours/node_modules/.bin/ours-cowork', ['--json', 'prepare-backup'], {
|
|
225
236
|
env: { ...env, OURS_COWORK_CONFIG: env.OURS_COWORK_CONFIG || '/var/lib/ours-cowork/config.json',
|
|
226
237
|
OURS_COWORK_STATE_DIR: env.OURS_COWORK_STATE_DIR || '/var/lib/ours-cowork' },
|
|
@@ -13,6 +13,14 @@ import { daemonOwner } from './daemon-owner.mjs';
|
|
|
13
13
|
|
|
14
14
|
const PROVENANCE = '.ours-provenance';
|
|
15
15
|
const APPLICATIONS = ['daemon', 'telegram', 'cowork', 'messenger'];
|
|
16
|
+
/** The installation's applications; plugin-only installations select only the daemon. */
|
|
17
|
+
export function selectedApplications(env = process.env) {
|
|
18
|
+
const raw = env.OURS_SERVER_APPLICATIONS;
|
|
19
|
+
if (raw === undefined || raw === '') return APPLICATIONS;
|
|
20
|
+
const selected = raw.split(',');
|
|
21
|
+
if (selected[0] !== 'daemon' || new Set(selected).size !== selected.length || selected.some(name => !APPLICATIONS.includes(name))) fail('invalid OURS_SERVER_APPLICATIONS selection');
|
|
22
|
+
return selected;
|
|
23
|
+
}
|
|
16
24
|
const exists = path => { try { fs.lstatSync(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } };
|
|
17
25
|
const canonical = path => exists(path) ? fs.realpathSync(path) : join(canonical(dirname(path)), basename(path));
|
|
18
26
|
const sameRecords = (a, b) => recordNames(a).length === recordNames(b).length && recordNames(a).every(name => a[name].equals(b[name]));
|
|
@@ -26,11 +34,13 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
26
34
|
if (!value || resolve(value) !== value || canonical(value) !== value) fail(`${name} must select an absolute canonical path`);
|
|
27
35
|
return value;
|
|
28
36
|
};
|
|
37
|
+
const applications = selectedApplications(env);
|
|
29
38
|
const state = selected('OURS_STATE_ROOT'), live = selected('OURS_LIVE_ROOT'), build = selected('OURS_BUILD_ROOT');
|
|
30
39
|
if ([state, build].some(path => path === live || path.startsWith(live + '/'))) fail('maintenance and build records must be outside live application state');
|
|
31
40
|
const backups = join(state, 'backups'), maintenance = join(state, '.maintenance');
|
|
32
41
|
const domain = argv[1];
|
|
33
42
|
if (![...APPLICATIONS, 'server'].includes(domain)) fail('select server, daemon, telegram, cowork or messenger');
|
|
43
|
+
if (domain !== 'server' && !applications.includes(domain)) fail(`${domain} is not part of this installation`);
|
|
34
44
|
if (Object.hasOwn(env, 'OURS_STATE_DOMAIN') && env.OURS_STATE_DOMAIN !== domain) fail('selected volume domain does not match operation');
|
|
35
45
|
const compatible = argv.at(-1) === '--compatible';
|
|
36
46
|
if (compatible) argv = argv.slice(0, -1);
|
|
@@ -76,6 +86,7 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
76
86
|
}
|
|
77
87
|
function prepare() {
|
|
78
88
|
if (!['server', 'cowork'].includes(domain) && !paired) return;
|
|
89
|
+
if (!applications.includes('cowork')) return;
|
|
79
90
|
if (!env.OURS_COWORK_CLI_PATH || !env.OURS_COWORK_CONFIG) fail('Cowork maintenance requires its selected executable and configuration');
|
|
80
91
|
owner([env.OURS_COWORK_CLI_PATH, '--json', 'prepare-backup'], { OURS_COWORK_CONFIG: env.OURS_COWORK_CONFIG });
|
|
81
92
|
}
|
|
@@ -88,7 +99,7 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
88
99
|
} catch (e) { fs.closeSync(fd); throw e; }
|
|
89
100
|
}
|
|
90
101
|
function validateTree(tree, records) {
|
|
91
|
-
const roots = domain === 'server' ?
|
|
102
|
+
const roots = domain === 'server' ? applications.map(name => join(tree, name)) : [tree];
|
|
92
103
|
// Notifications shares Messenger's service, but owns a separate state directory.
|
|
93
104
|
// Older installations/backups may contain only its empty mount placeholder.
|
|
94
105
|
// Include every retained notification generation in full-server maintenance.
|
|
@@ -152,7 +163,7 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
152
163
|
fs.chmodSync(path, 0o600);
|
|
153
164
|
}
|
|
154
165
|
}
|
|
155
|
-
if (['server', 'cowork'].includes(domain)) bindConfig(join(tree, domain === 'server' ? 'cowork/config.json' : 'config.json'), env.OURS_COWORK_CONFIG, ['version', 'stateDir', 'rest']);
|
|
166
|
+
if (['server', 'cowork'].includes(domain) && applications.includes('cowork')) bindConfig(join(tree, domain === 'server' ? 'cowork/config.json' : 'config.json'), env.OURS_COWORK_CONFIG, ['version', 'stateDir', 'rest']);
|
|
156
167
|
}
|
|
157
168
|
function retainAuthority(staging) {
|
|
158
169
|
if (!['daemon', 'server'].includes(domain)) return;
|
|
@@ -161,7 +172,7 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
161
172
|
owner([daemonOwner(env), 'config', 'access-retain', '--config', env.OURS_DAEMON_CONFIG, '--target-state-dir', daemon, '--json']);
|
|
162
173
|
if (!commonTree) return;
|
|
163
174
|
const credentials = join(live, 'credentials'), destination = join(staging, 'credentials');
|
|
164
|
-
for (const name of
|
|
175
|
+
for (const name of applications.filter(name => name !== 'daemon')) if (!privateStat(join(credentials, name, 'daemon-token'), false, 0o600).size) fail('current managed credential is empty');
|
|
165
176
|
fs.rmSync(destination, { recursive: true }); copyTree(credentials, destination);
|
|
166
177
|
}
|
|
167
178
|
function replace(staging) {
|
|
@@ -191,7 +202,7 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
191
202
|
const previous = readRecords(selected('OURS_PREVIOUS_BUILD_ROOT'), false);
|
|
192
203
|
const expected = readRecords(selected('OURS_EXPECTED_BUILD_ROOT'), false);
|
|
193
204
|
if (!sameRecords(target, expected)) fail('recovery runtime differs from the retained candidate');
|
|
194
|
-
const core =
|
|
205
|
+
const core = applications.map(name => readRecords(join(live, name, PROVENANCE)));
|
|
195
206
|
const oldCore = core.every(records => sameRecords(records, previous));
|
|
196
207
|
const newCore = core.every(records => sameRecords(records, target));
|
|
197
208
|
if (!newCore && !(env.OURS_RECOVERY_PHASE === 'prepared' && oldCore)) fail('recovery core provenance differs from the retained candidate');
|
|
@@ -7,12 +7,18 @@ import {
|
|
|
7
7
|
realpathSync, renameSync, unlinkSync, writeFileSync, readdirSync, chmodSync, chownSync,
|
|
8
8
|
} from 'node:fs';
|
|
9
9
|
|
|
10
|
-
const DELIVERY_FILES = [
|
|
11
|
-
'/credentials/telegram/daemon-token',
|
|
12
|
-
'/credentials/cowork/daemon-token',
|
|
13
|
-
'/credentials/messenger/daemon-token',
|
|
14
|
-
];
|
|
15
10
|
const fail = (message) => { throw new Error(message); };
|
|
11
|
+
/** Applications of this installation; plugin-only installations have only the daemon. */
|
|
12
|
+
const APPLICATIONS = (() => {
|
|
13
|
+
const all = ['daemon', 'telegram', 'cowork', 'messenger'];
|
|
14
|
+
const raw = process.env.OURS_SERVER_APPLICATIONS;
|
|
15
|
+
if (raw === undefined || raw === '') return all;
|
|
16
|
+
const selected = raw.split(',');
|
|
17
|
+
if (selected[0] !== 'daemon' || new Set(selected).size !== selected.length || selected.some(name => !all.includes(name))) fail('Invalid OURS_SERVER_APPLICATIONS selection');
|
|
18
|
+
return selected;
|
|
19
|
+
})();
|
|
20
|
+
const CONSUMERS = APPLICATIONS.filter(name => name !== 'daemon');
|
|
21
|
+
const DELIVERY_FILES = CONSUMERS.map(name => `/credentials/${name}/daemon-token`);
|
|
16
22
|
let diagnosticStage = 'validation';
|
|
17
23
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
18
24
|
|
|
@@ -153,11 +159,11 @@ function prepare() {
|
|
|
153
159
|
process.umask(0o077);
|
|
154
160
|
validateCommon();
|
|
155
161
|
for (const path of ['/storage/state', '/storage/state/mcp', '/storage/state/credentials',
|
|
156
|
-
...
|
|
162
|
+
...CONSUMERS.map(name => `/storage/state/credentials/${name}`),
|
|
157
163
|
'/storage/backups', '/storage/.maintenance']) ensureDirectory(path);
|
|
158
164
|
const coworkPort = Number(process.env.OURS_COWORK_REST_PORT);
|
|
159
165
|
if (!Number.isSafeInteger(coworkPort) || coworkPort < 1 || coworkPort > 65535) fail('Configure a valid cowork REST port');
|
|
160
|
-
for (const domain of
|
|
166
|
+
for (const domain of APPLICATIONS) {
|
|
161
167
|
const data = `/storage/state/${domain}`;
|
|
162
168
|
ensureDirectory(data);
|
|
163
169
|
// access-init owns the fresh-state check; no installer files enter daemon
|
|
@@ -173,14 +179,16 @@ function prepare() {
|
|
|
173
179
|
fail('Existing daemon configuration differs; use the supported maintenance workflow');
|
|
174
180
|
}
|
|
175
181
|
} else atomicJson(path, expected);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
+
if (APPLICATIONS.includes('cowork')) {
|
|
183
|
+
const coworkPath = '/storage/state/cowork/config.json';
|
|
184
|
+
const cowork = composeConfig('cowork');
|
|
185
|
+
if (!existingPrivate(coworkPath, false)) atomicJson(coworkPath, cowork);
|
|
186
|
+
else {
|
|
187
|
+
const config = jsonObject(coworkPath);
|
|
188
|
+
if (config.version !== 1 || config.stateDir !== cowork.stateDir || config.rest?.enabled !== true || config.rest?.host !== '0.0.0.0') fail('Existing cowork configuration conflicts with Compose');
|
|
189
|
+
}
|
|
182
190
|
}
|
|
183
|
-
prepareNotifications();
|
|
191
|
+
if (APPLICATIONS.includes('messenger')) prepareNotifications();
|
|
184
192
|
console.log('OURS persistent volumes are ready');
|
|
185
193
|
}
|
|
186
194
|
|
|
@@ -258,7 +266,10 @@ function exactKeys(value, allowed, label) {
|
|
|
258
266
|
try {
|
|
259
267
|
const operation = process.argv[2];
|
|
260
268
|
if (operation === 'prepare') prepare();
|
|
261
|
-
else if (operation === 'telegram-input') {
|
|
269
|
+
else if (operation === 'telegram-input') {
|
|
270
|
+
if (!APPLICATIONS.includes('telegram')) fail('This installation has no Telegram connector');
|
|
271
|
+
prepare(); await telegramInput();
|
|
272
|
+
}
|
|
262
273
|
else if (['access-init', 'access-issue', 'access-replace'].includes(operation)) access(operation, process.argv[3]);
|
|
263
274
|
else fail('usage: client-setup.mjs prepare | telegram-input | access-init | access-issue [OUTPUT] | access-replace');
|
|
264
275
|
} catch (error) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createServer, request } from 'node:http';
|
|
3
|
+
import { connect } from 'node:net';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Plugin-only installations run no gateway container. Current client releases
|
|
8
|
+
* still address the daemon as <serverUrl>/daemon, so the daemon container itself
|
|
9
|
+
* serves that one prefix. Requests and responses pass through unchanged, including
|
|
10
|
+
* the client's own credential and instance checks; nothing is added or injected.
|
|
11
|
+
* Any other path, method form or host-absolute target is refused.
|
|
12
|
+
*/
|
|
13
|
+
export const PREFIX = '/daemon';
|
|
14
|
+
export const CAPABILITY = 'ours.daemon-prefix-v1';
|
|
15
|
+
const HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade'];
|
|
16
|
+
|
|
17
|
+
/** Only origin-form targets under the prefix; the remainder is the daemon path. */
|
|
18
|
+
export function daemonPath(target) {
|
|
19
|
+
if (typeof target !== 'string' || !target.startsWith(PREFIX + '/') || /[\s\\]/.test(target)) return null;
|
|
20
|
+
const rest = target.slice(PREFIX.length);
|
|
21
|
+
// A dot segment could step out of the prefix once normalized by the daemon.
|
|
22
|
+
for (const segment of rest.split('?')[0].split('/')) {
|
|
23
|
+
let decoded;
|
|
24
|
+
try { decoded = decodeURIComponent(segment); } catch { return null; }
|
|
25
|
+
if (decoded === '.' || decoded === '..' || decoded.includes('/')) return null;
|
|
26
|
+
}
|
|
27
|
+
return rest;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function discovery(instanceId) {
|
|
31
|
+
return { schema: 1, instanceId, services: { daemon: PREFIX }, capabilities: [CAPABILITY] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Headers as received, minus hop-by-hop ones; repeated headers keep every value. */
|
|
35
|
+
function headersFor(raw) {
|
|
36
|
+
const headers = {};
|
|
37
|
+
for (let i = 0; i < raw.length; i += 2) {
|
|
38
|
+
const name = raw[i].toLowerCase();
|
|
39
|
+
if (HOP.includes(name)) continue;
|
|
40
|
+
if (!Object.hasOwn(headers, name)) headers[name] = raw[i + 1];
|
|
41
|
+
else if (name === 'set-cookie') headers[name] = [].concat(headers[name], raw[i + 1]);
|
|
42
|
+
else headers[name] = `${headers[name]}, ${raw[i + 1]}`;
|
|
43
|
+
}
|
|
44
|
+
return headers;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createPrefixServer({ upstreamPort = 3050, upstreamHost = '127.0.0.1', instanceId }) {
|
|
48
|
+
const reply = (res, status, body) => {
|
|
49
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
50
|
+
res.end(JSON.stringify(body));
|
|
51
|
+
};
|
|
52
|
+
const server = createServer((req, res) => {
|
|
53
|
+
if (req.url === '/.well-known/ours' && req.method === 'GET') return reply(res, 200, discovery(instanceId));
|
|
54
|
+
if (req.url === PREFIX) { res.writeHead(308, { location: PREFIX + '/' }); res.end(); return; }
|
|
55
|
+
const path = daemonPath(req.url);
|
|
56
|
+
if (path === null) return reply(res, 404, { error: 'not found' });
|
|
57
|
+
const upstream = request({ host: upstreamHost, port: upstreamPort, method: req.method, path, headers: headersFor(req.rawHeaders) }, response => {
|
|
58
|
+
res.writeHead(response.statusCode ?? 502, headersFor(response.rawHeaders));
|
|
59
|
+
response.pipe(res);
|
|
60
|
+
response.on('error', () => res.destroy());
|
|
61
|
+
});
|
|
62
|
+
upstream.on('error', () => { if (!res.headersSent) reply(res, 502, { error: 'daemon unavailable' }); else res.destroy(); });
|
|
63
|
+
// A client that goes away cancels its long poll or stream at the daemon too.
|
|
64
|
+
res.on('close', () => { if (!res.writableFinished) upstream.destroy(); });
|
|
65
|
+
req.pipe(upstream);
|
|
66
|
+
});
|
|
67
|
+
server.on('upgrade', (req, socket, head) => {
|
|
68
|
+
const path = daemonPath(req.url);
|
|
69
|
+
if (path === null) { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); return; }
|
|
70
|
+
const upstream = connect(upstreamPort, upstreamHost, () => {
|
|
71
|
+
const lines = [`${req.method} ${path} HTTP/1.1`];
|
|
72
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
|
|
73
|
+
upstream.write(lines.join('\r\n') + '\r\n\r\n');
|
|
74
|
+
if (head?.length) upstream.write(head);
|
|
75
|
+
upstream.pipe(socket); socket.pipe(upstream);
|
|
76
|
+
});
|
|
77
|
+
const close = () => { upstream.destroy(); socket.destroy(); };
|
|
78
|
+
upstream.on('error', close); socket.on('error', close);
|
|
79
|
+
upstream.on('close', () => socket.destroy()); socket.on('close', () => upstream.destroy());
|
|
80
|
+
});
|
|
81
|
+
// Absolute-form ("GET http://host/...") and CONNECT never reach the daemon.
|
|
82
|
+
server.on('connect', (req, socket) => socket.end('HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'));
|
|
83
|
+
return server;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Run the daemon's own locked, recovering entrypoint as the single child and
|
|
88
|
+
* serve the prefix beside it. Either one ending stops the other, so the
|
|
89
|
+
* container exits and its restart policy recovers both together.
|
|
90
|
+
*/
|
|
91
|
+
async function main() {
|
|
92
|
+
const port = Number(process.env.OURS_DAEMON_CLIENT_PREFIX_PORT);
|
|
93
|
+
const instanceId = process.env.OURS_DAEMON_ID ?? '';
|
|
94
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535 || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(instanceId)) {
|
|
95
|
+
console.error('OURS daemon client prefix requires a port and the daemon UUID'); process.exit(2);
|
|
96
|
+
}
|
|
97
|
+
const runtime = process.env.OURS_RUNTIME_ROOT ?? '/opt/ours';
|
|
98
|
+
const child = spawn('/bin/sh', ['-e', `${runtime}/docker/entrypoint.sh`], { stdio: 'inherit' });
|
|
99
|
+
const server = createPrefixServer({ instanceId });
|
|
100
|
+
let stopping = false;
|
|
101
|
+
const stop = (signal, code) => {
|
|
102
|
+
if (stopping) return;
|
|
103
|
+
stopping = true;
|
|
104
|
+
server.close();
|
|
105
|
+
if (child.exitCode === null && child.signalCode === null) child.kill(signal);
|
|
106
|
+
else process.exit(code);
|
|
107
|
+
};
|
|
108
|
+
child.on('exit', (code, signal) => { server.close(); process.exit(code ?? (signal ? 1 : 0)); });
|
|
109
|
+
child.on('error', () => { server.close(); process.exit(1); });
|
|
110
|
+
server.on('error', error => { console.error(`OURS daemon client prefix stopped: ${error.code ?? error.message}`); stop('SIGTERM', 1); });
|
|
111
|
+
for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => stop(signal, 0));
|
|
112
|
+
server.listen(port, '0.0.0.0', () => console.error(`OURS daemon client prefix ready on ${PREFIX}`));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
|
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.44",
|
|
6
6
|
"packages": {
|
|
7
7
|
"@ours.network/sdk": {
|
|
8
8
|
"version": "3.8.1-nightly.17",
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"integrity": "sha512-03qDDGRgN9pBGsBsCcvArhPuYfMblqsaXS9HinxAzOSiI3PaoNOh+4MxIAOoHXoE/12xxCDf7kbMdEAR0CPIfg=="
|
|
26
26
|
},
|
|
27
27
|
"@ours.network/notifications": {
|
|
28
|
-
"version": "0.3.1-nightly.
|
|
29
|
-
"integrity": "sha512-
|
|
28
|
+
"version": "0.3.1-nightly.3",
|
|
29
|
+
"integrity": "sha512-mt69E2LLkfwBMOJm7IP49wLIO9KtugBqh6nQpfrfbRuTlenjyrmZqcAuhW1CyI2R1H518B0Evxagnx9/V9fIig=="
|
|
30
30
|
},
|
|
31
31
|
"@ours.network/fleet": {
|
|
32
|
-
"version": "1.2.0-nightly.
|
|
33
|
-
"integrity": "sha512-
|
|
32
|
+
"version": "1.2.0-nightly.57",
|
|
33
|
+
"integrity": "sha512-FAgN3L5yOlWQgGFJ4g0x1jpCqpAtYvRnol+ZBrgR8vCl+Ex1Ebct6GiSTIdkPtvYth4lidUgSkBpJqwiSTkx3Q=="
|
|
34
34
|
},
|
|
35
35
|
"@ours.network/mcp": {
|
|
36
36
|
"version": "1.2.0-nightly.13",
|
|
@@ -83,11 +83,11 @@
|
|
|
83
83
|
},
|
|
84
84
|
"@ours.network/notifications": {
|
|
85
85
|
"type": "npm",
|
|
86
|
-
"version": "0.3.1-nightly.
|
|
86
|
+
"version": "0.3.1-nightly.3"
|
|
87
87
|
},
|
|
88
88
|
"@ours.network/fleet": {
|
|
89
89
|
"type": "npm",
|
|
90
|
-
"version": "1.2.0-nightly.
|
|
90
|
+
"version": "1.2.0-nightly.57"
|
|
91
91
|
},
|
|
92
92
|
"@ours.network/mcp": {
|
|
93
93
|
"type": "npm",
|
package/lib/boot-readiness.mjs
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
import { warn } from './ui.mjs';
|
|
2
|
+
|
|
3
|
+
const NO_AUTOSTART = 'ours components that depend on Docker will not start automatically after a reboot until Docker is started';
|
|
4
|
+
const OPTIONAL = 'The installer does not change this for you.';
|
|
5
|
+
|
|
6
|
+
/** Persistent unit-file state only: `is-active`/a reachable Engine says nothing about the next boot. */
|
|
7
|
+
async function unitFileState(effects, scope) {
|
|
8
|
+
// `is-enabled` reads unit files offline, so it answers even where systemd is not the init
|
|
9
|
+
// system and its answer decides nothing. /run/systemd/system is sd_booted(3)'s own test.
|
|
10
|
+
if (!effects.exists('/run/systemd/system')) return 'unavailable';
|
|
11
|
+
try {
|
|
12
|
+
if (scope.length) await effects.run('systemctl', [...scope, 'show-environment']); // a user manager must be running
|
|
13
|
+
// 1 = disabled/masked, 4 = no such unit (older systemd reports that as 1 with empty output).
|
|
14
|
+
const state = (await effects.run('systemctl', [...scope, 'is-enabled', 'docker.service'], { allowCodes: [1, 4] })).stdout.trim().split('\n')[0];
|
|
15
|
+
if (state === 'enabled') return 'enabled';
|
|
16
|
+
// enabled-runtime lives in /run and is gone after the reboot it is supposed to cover.
|
|
17
|
+
if (state === 'disabled' || state === 'enabled-runtime') return 'disabled';
|
|
18
|
+
if (state === 'masked' || state === 'masked-runtime') return 'masked';
|
|
19
|
+
if (state === '' || state === 'not-found') return 'unavailable';
|
|
20
|
+
return 'unknown';
|
|
21
|
+
} catch { return 'unavailable'; } // no systemctl, or the host/session is not running systemd
|
|
22
|
+
}
|
|
23
|
+
|
|
1
24
|
/** Diagnose availability separately from boot enablement. Never alter host services. */
|
|
2
25
|
export async function reportBootReadiness(effects, record) {
|
|
3
26
|
const platform = effects.platform.platform;
|
|
@@ -16,28 +39,58 @@ export async function reportBootReadiness(effects, record) {
|
|
|
16
39
|
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
40
|
return;
|
|
18
41
|
}
|
|
42
|
+
let endpoint, rootless;
|
|
19
43
|
try {
|
|
20
44
|
const context = JSON.parse((await effects.run('docker', ['context', 'inspect'])).stdout);
|
|
21
|
-
|
|
22
|
-
|
|
45
|
+
// The CLI runs with this same environment and reports the endpoint it resolved from
|
|
46
|
+
// DOCKER_HOST/DOCKER_CONTEXT; their precedence differs between its documentation and releases.
|
|
47
|
+
endpoint = context[0]?.Endpoints?.docker?.Host;
|
|
48
|
+
if (!endpoint?.startsWith('unix://') || endpoint.includes('/.docker/desktop/')) {
|
|
23
49
|
effects.out('Container recovery requires the selected remote/Desktop Docker Engine to start independently; local systemd boot cannot be verified for this context.');
|
|
24
50
|
return;
|
|
25
51
|
}
|
|
26
52
|
const info = JSON.parse((await effects.run('docker', ['info', '--format', '{{json .SecurityOptions}}'])).stdout);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
53
|
+
rootless = info.some(option => option.startsWith('name=rootless'));
|
|
54
|
+
} catch {
|
|
55
|
+
effects.out(warn(`Docker's start-at-boot setting could not be checked because the selected Docker Engine did not answer. If Docker is not set to start at boot, ${NO_AUTOSTART}.`));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const sockets = rootless ? [`unix:///run/user/${process.getuid()}/docker.sock`] : ['unix:///var/run/docker.sock', 'unix:///run/docker.sock'];
|
|
59
|
+
if (!sockets.includes(endpoint)) {
|
|
60
|
+
effects.out('The selected custom Docker socket is reachable, but its boot service binding is unverified; enable and verify the service owning that Engine.');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const scope = rootless ? ['--user'] : [];
|
|
64
|
+
const enable = `${rootless ? '' : 'sudo '}systemctl ${rootless ? '--user ' : ''}enable docker.service`;
|
|
65
|
+
const state = await unitFileState(effects, scope);
|
|
66
|
+
if (state === 'unavailable') {
|
|
67
|
+
// WSL with Docker Desktop integration, snap, OpenRC/runit and similar: a systemctl command would be wrong here.
|
|
68
|
+
effects.out(warn(`Docker is running now, but no running systemd${rootless ? ' user' : ''} manager with a docker.service was found, so its start-at-boot setting cannot be checked. If Docker is not set to start at boot, ${NO_AUTOSTART}. To start them automatically, enable start-at-boot in whatever manages this Docker Engine (Docker Desktop, snap, or your init system). ${OPTIONAL}`));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (state === 'unknown') {
|
|
72
|
+
effects.out(warn(`Docker is running now, but its systemd${rootless ? ' user' : ''} docker.service is neither plainly enabled nor disabled, so start at boot is not verified. If Docker does not start at boot, ${NO_AUTOSTART}. Check it with: systemctl ${rootless ? '--user ' : ''}status docker.service`));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (state === 'masked') {
|
|
76
|
+
effects.out(warn(`Docker is running now, but its systemd${rootless ? ' user' : ''} docker.service is masked, so it cannot start at boot: ${NO_AUTOSTART}. An administrator masked it deliberately; it must be unmasked before it can be enabled. ${OPTIONAL}`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (state === 'disabled') {
|
|
80
|
+
effects.out(warn(`Docker is running now, but it is not enabled to start at boot: ${NO_AUTOSTART}. If you want them to start automatically, run: ${enable}${rootless ? ` (and, for startup before you log in, sudo loginctl enable-linger ${effects.username()})` : ''}. ${OPTIONAL}`));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (rootless) {
|
|
84
|
+
let linger = '';
|
|
85
|
+
try { linger = (await effects.run('loginctl', ['show-user', String(process.getuid()), '--property=Linger', '--value'])).stdout.trim(); } catch {}
|
|
86
|
+
if (linger === 'no') {
|
|
87
|
+
effects.out(warn(`Rootless Docker's user service is enabled, but user linger is off, so Docker starts only when you log in: ours components that depend on Docker will not start automatically after a reboot until then. If you want them to start at boot, run: sudo loginctl enable-linger ${effects.username()}. ${OPTIONAL}`));
|
|
31
88
|
return;
|
|
32
89
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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');
|
|
90
|
+
if (linger !== 'yes') {
|
|
91
|
+
effects.out(warn(`Rootless Docker's user service is enabled, but user linger could not be checked. If linger is off, Docker starts only when you log in and ours components that depend on Docker will not start automatically after a reboot until then. Check it with: loginctl show-user ${effects.username()} --property=Linger`));
|
|
92
|
+
return;
|
|
38
93
|
}
|
|
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
94
|
}
|
|
95
|
+
effects.out(`Docker boot service is enabled${rootless ? ' with user linger' : ''}; managed containers recover when that Engine starts.`);
|
|
43
96
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runContainer } from './container-engine.mjs';
|
|
2
|
+
import { isPluginOnly } from './product.mjs';
|
|
2
3
|
import * as fs from 'node:fs';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { randomUUID } from 'node:crypto';
|
|
@@ -64,7 +65,8 @@ export async function runDockerConversion(record, selected, operation, label, ef
|
|
|
64
65
|
}
|
|
65
66
|
} else {
|
|
66
67
|
addMount('storage', '/var/lib/ours', 'state/daemon', true);
|
|
67
|
-
|
|
68
|
+
// A collaboration-tools installation has no Cowork state to mount.
|
|
69
|
+
if (!isPluginOnly(record)) addMount('storage', '/var/lib/ours-cowork', 'state/cowork', false);
|
|
68
70
|
}
|
|
69
71
|
const definition = {
|
|
70
72
|
services: {
|
|
@@ -74,7 +76,7 @@ export async function runDockerConversion(record, selected, operation, label, ef
|
|
|
74
76
|
network_mode: 'none', read_only: true, cap_drop: ['ALL'],
|
|
75
77
|
security_opt: ['no-new-privileges:true'], tmpfs: ['/tmp:rw,nosuid,nodev,noexec,size=64m,mode=1777'],
|
|
76
78
|
entrypoint: ['node', '/opt/ours/docker/docker-layout-conversion.mjs'],
|
|
77
|
-
environment: { HOME: '/tmp', OURS_DAEMON_ID: record.instanceId },
|
|
79
|
+
environment: { HOME: '/tmp', OURS_DAEMON_ID: record.instanceId, OURS_SERVER_APPLICATIONS: isPluginOnly(record) ? 'daemon' : '' },
|
|
78
80
|
volumes: mounts,
|
|
79
81
|
},
|
|
80
82
|
},
|
package/lib/docker-recovery.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, lstatSync, realpathSync, existsSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { atomicWriteConfig } from './config.mjs';
|
|
4
4
|
import { engineName, runContainer } from './container-engine.mjs';
|
|
5
|
+
import { isPluginOnly } from './product.mjs';
|
|
5
6
|
|
|
6
7
|
const assets = new URL('../assets/', import.meta.url);
|
|
7
8
|
const files = ['scripts/runtime/recover.mjs', 'scripts/runtime/dependency-ready.mjs',
|
|
@@ -23,11 +24,15 @@ export function refreshDockerRecovery(record) {
|
|
|
23
24
|
atomicWriteConfig(marker, 'required\n'); changed = true;
|
|
24
25
|
atomicWriteConfig(path, bytes.toString());
|
|
25
26
|
};
|
|
26
|
-
|
|
27
|
+
// Plugin-only daemons also serve the client prefix from the image.
|
|
28
|
+
const selected = isPluginOnly(record) ? [...files, 'scripts/runtime/daemon-client-prefix.mjs'] : files;
|
|
29
|
+
for (const name of selected) replace(name, readFileSync(new URL(name, assets)));
|
|
27
30
|
const dockerfile = join(record.workDir, 'Dockerfile'); owned(dockerfile);
|
|
28
31
|
const text = readFileSync(dockerfile, 'utf8');
|
|
29
32
|
const line = 'COPY --chmod=644 scripts/runtime/recover.mjs scripts/runtime/dependency-ready.mjs /opt/ours/docker/';
|
|
30
33
|
if (!text.includes('scripts/runtime/recover.mjs')) replace('Dockerfile', Buffer.from(text.replace('FROM node:24 AS maintenance', `${line}\nFROM node:24 AS maintenance`)));
|
|
34
|
+
const prefixLine = 'COPY --chmod=644 scripts/runtime/daemon-client-prefix.mjs /opt/ours/docker/daemon-client-prefix.mjs';
|
|
35
|
+
if (isPluginOnly(record) && !readFileSync(dockerfile, 'utf8').includes(prefixLine)) replace('Dockerfile', Buffer.from(readFileSync(dockerfile, 'utf8').replace('FROM node:24 AS maintenance', `${prefixLine}\nFROM node:24 AS maintenance`)));
|
|
31
36
|
return changed || existsSync(marker);
|
|
32
37
|
}
|
|
33
38
|
|