@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.
@@ -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 COMPONENTS) {
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', ...COMPONENTS.filter(name => name !== 'daemon').map(name => `credentials/${name}/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' ? APPLICATIONS.map(name => join(tree, name)) : [tree];
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 ['telegram', 'cowork', 'messenger']) if (!privateStat(join(credentials, name, 'daemon-token'), false, 0o600).size) fail('current managed credential is empty');
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 = APPLICATIONS.map(name => readRecords(join(live, name, PROVENANCE)));
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
- ...['telegram', 'cowork', 'messenger'].map(name => `/storage/state/credentials/${name}`),
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 ['daemon', 'telegram', 'cowork', 'messenger']) {
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
- const coworkPath = '/storage/state/cowork/config.json';
177
- const cowork = composeConfig('cowork');
178
- if (!existingPrivate(coworkPath, false)) atomicJson(coworkPath, cowork);
179
- else {
180
- const config = jsonObject(coworkPath);
181
- 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');
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') { prepare(); await telegramInput(); }
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();
@@ -2,7 +2,7 @@
2
2
  "release": {
3
3
  "schema": 1,
4
4
  "channel": "nightly",
5
- "installerVersion": "1.2.1-nightly.42",
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.2",
29
- "integrity": "sha512-OjnrJ8ZuOfKYT4KAmiRLU7czfiuacs6rShwOvvqxqBaUKcs1UwjsYwHARNb6erMxG4am3xxcE0ItahL+d2vt4A=="
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.54",
33
- "integrity": "sha512-Sh3t8iRGBoYzbyslS+IKuqQ573Zn+M0GVhR0cGepy/hlyNMAwXD2TBM+j7d8QiA1+ALugkMhKnanivE97JBmhQ=="
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.2"
86
+ "version": "0.3.1-nightly.3"
87
87
  },
88
88
  "@ours.network/fleet": {
89
89
  "type": "npm",
90
- "version": "1.2.0-nightly.54"
90
+ "version": "1.2.0-nightly.57"
91
91
  },
92
92
  "@ours.network/mcp": {
93
93
  "type": "npm",
@@ -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
- const endpoint = effects.env.DOCKER_HOST ?? context[0]?.Endpoints?.docker?.Host;
22
- if (effects.env.DOCKER_CONTEXT || !endpoint?.startsWith('unix://') || endpoint.includes('/.docker/desktop/')) {
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
- 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.');
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
- 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');
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
- addMount('storage', '/var/lib/ours-cowork', 'state/cowork', false);
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
  },
@@ -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
- for (const name of files) replace(name, readFileSync(new URL(name, assets)));
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