@ours.network/install 1.1.1 → 1.2.0-nightly.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +196 -6
  2. package/assets/Dockerfile +41 -0
  3. package/assets/docker-compose.yaml +222 -0
  4. package/assets/scripts/README.md +20 -0
  5. package/assets/scripts/build/README.md +39 -0
  6. package/assets/scripts/build/build-common.mjs +37 -0
  7. package/assets/scripts/build/build-cowork.mjs +2 -0
  8. package/assets/scripts/build/build-fleet.mjs +2 -0
  9. package/assets/scripts/build/build-mcp.mjs +9 -0
  10. package/assets/scripts/build/build-messenger.mjs +2 -0
  11. package/assets/scripts/build/build-sdk.mjs +9 -0
  12. package/assets/scripts/build/build-telegram.mjs +2 -0
  13. package/assets/scripts/build/build.mjs +49 -0
  14. package/assets/scripts/build/record-build.mjs +31 -0
  15. package/assets/scripts/maintenance/README.md +35 -0
  16. package/assets/scripts/maintenance/build-context.mjs +162 -0
  17. package/assets/scripts/maintenance/docker-layout-conversion.mjs +238 -0
  18. package/assets/scripts/maintenance/provenance-compare.mjs +160 -0
  19. package/assets/scripts/maintenance/state-archive.mjs +238 -0
  20. package/assets/scripts/maintenance/state-native.mjs +56 -0
  21. package/assets/scripts/maintenance/state-operation.mjs +249 -0
  22. package/assets/scripts/runtime/README.md +24 -0
  23. package/assets/scripts/runtime/check-client.mjs +21 -0
  24. package/assets/scripts/runtime/check-start.mjs +15 -0
  25. package/assets/scripts/runtime/client-setup.mjs +197 -0
  26. package/assets/scripts/runtime/entrypoint.sh +13 -0
  27. package/assets/scripts/runtime/health-cowork.sh +11 -0
  28. package/assets/scripts/runtime/health-messenger.mjs +6 -0
  29. package/assets/scripts/runtime/health-telegram.sh +8 -0
  30. package/assets/scripts/runtime/healthcheck.mjs +17 -0
  31. package/assets/scripts/runtime/runtime-common.mjs +47 -0
  32. package/assets/scripts/runtime/start-cowork.sh +6 -0
  33. package/assets/scripts/runtime/start-messenger.sh +6 -0
  34. package/assets/scripts/runtime/start-telegram.sh +6 -0
  35. package/assets/sources.json +21 -0
  36. package/install.sh +2 -1
  37. package/lib/build-transition.mjs +56 -0
  38. package/lib/docker-conversion-runtime.mjs +96 -0
  39. package/lib/docker-layout-installation.mjs +62 -0
  40. package/lib/effects.mjs +945 -11
  41. package/lib/extras.mjs +23 -68
  42. package/lib/layout-conversion.mjs +297 -0
  43. package/lib/orchestrate-uninstall.mjs +30 -1
  44. package/lib/orchestrate.mjs +265 -18
  45. package/lib/plan.mjs +194 -1
  46. package/lib/target.mjs +100 -0
  47. package/lib/usage.mjs +27 -2
  48. package/package.json +9 -2
  49. package/uninstall.sh +2 -0
package/lib/effects.mjs CHANGED
@@ -12,13 +12,18 @@
12
12
  // unit file or the service manager directly.
13
13
 
14
14
  import { spawnSync, execFileSync } from 'node:child_process';
15
- import { cpSync, existsSync, readFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
+ import { chmodSync, closeSync, constants, cpSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { randomUUID, createHash } from 'node:crypto';
17
19
  import { dirname, join, resolve } from 'node:path';
20
+ import { maintenanceServices, installationPaths, validateInstallation, consumerServiceState, unitNameForStateDir, launchdLabelForStateDir, messengerServicePlan, selectSourcePackages, resolveSourcePolicy, SERVER_SERVICES } from './plan.mjs';
21
+ import { validateHostProfile } from './target.mjs';
18
22
  import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
19
23
  import { askYesNo, askLine as askLineOnTty } from './prompt.mjs';
20
24
  import { classifyHarnessProbe } from './logic.mjs';
21
25
  import { classifyStateDir } from './detect.mjs';
26
+ import { BASE_RECORDS, CONTEXT, readBuildRecords, equalBuildRecords, initializeBuildMarker } from '../assets/scripts/maintenance/build-context.mjs';
22
27
 
23
28
  /** GET http://127.0.0.1:<port>/state-dir — the unauthenticated identity probe. */
24
29
  async function probePort(port, { timeoutMs = 1500 } = {}) {
@@ -69,6 +74,55 @@ function readTextFile(path) {
69
74
  }
70
75
  }
71
76
 
77
+ function assertPrivateRegularFile(path, label) {
78
+ let stat;
79
+ try { stat = lstatSync(path); } catch { throw new Error(`Cannot read ${label} ${JSON.stringify(path)}.`); }
80
+ if (!stat.isFile()) throw new Error(`Invalid external host profile: ${label} ${JSON.stringify(path)} must be a regular file.`);
81
+ const currentUid = process.getuid?.();
82
+ if (currentUid === undefined || stat.uid !== currentUid) throw new Error(`Invalid external host profile: ${label} ${JSON.stringify(path)} must be owned by the current user.`);
83
+ if ((stat.mode & 0o077) !== 0) throw new Error(`Invalid external host profile: ${label} ${JSON.stringify(path)} must have private permissions.`);
84
+ }
85
+
86
+ function readHostProfileFile(path) {
87
+ let text;
88
+ try { text = readFileSync(path, 'utf8'); } catch { throw new Error(`Cannot read host profile ${JSON.stringify(path)}.`); }
89
+ let value;
90
+ try { value = JSON.parse(text); } catch { throw new Error(`Invalid external host profile: config ${JSON.stringify(path)} is not valid JSON.`); }
91
+ const profile = validateHostProfile(value);
92
+ if (profile === null) return null;
93
+ assertPrivateRegularFile(path, 'config');
94
+ return profile;
95
+ }
96
+
97
+ async function verifyHostProfile(configPath, { timeoutMs = 5000 } = {}) {
98
+ const profile = typeof configPath === 'string' ? readHostProfileFile(configPath) : validateHostProfile(configPath);
99
+ if (profile === null) throw new Error(`Config ${JSON.stringify(configPath)} is not a host profile.`);
100
+ const request = async (path, token = null) => {
101
+ const response = await fetch(`${profile.endpoint}${path}`, {
102
+ redirect: 'error', signal: AbortSignal.timeout(timeoutMs),
103
+ headers: token === null ? {} : { 'x-ours-api-token': token },
104
+ });
105
+ if (!response.ok) throw new Error(`${path} answered HTTP ${response.status}`);
106
+ try { return await response.json(); } catch { throw new Error(`${path} returned invalid JSON`); }
107
+ };
108
+ const selection = await request('/selection');
109
+ const selectionKeys = selection && typeof selection === 'object' && !Array.isArray(selection) ? Object.keys(selection) : [];
110
+ if (selectionKeys.length !== 3
111
+ || selectionKeys.some((key) => !['schema', 'instanceId', 'capabilities'].includes(key))
112
+ || selection.schema !== 1
113
+ || selection.instanceId !== profile.expectedInstanceId
114
+ || !Array.isArray(selection.capabilities)
115
+ || selection.capabilities.some((capability) => typeof capability !== 'string')
116
+ || !selection.capabilities.includes('external-sessions-v1')) {
117
+ throw new Error('Daemon selection metadata is absent, incompatible or mismatched.');
118
+ }
119
+ assertPrivateRegularFile(profile.credentialPath, 'credential');
120
+ const token = readFileSync(profile.credentialPath, 'utf8').trim();
121
+ if (!token) throw new Error('Invalid external host profile: credential file is empty.');
122
+ const version = await request('/version', token);
123
+ return { profile, version };
124
+ }
125
+
72
126
  function installedVersionOf(pkg, npmBin = 'npm') {
73
127
  try {
74
128
  const out = execFileSync(npmBin, ['ls', '-g', '--depth', '0', '--json', pkg], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
@@ -251,10 +305,32 @@ function knownStateDirsIn(home) {
251
305
  */
252
306
  export function realEffects({ write, ttyFd, env = process.env, home = homedir(), out, version = null } = {}) {
253
307
  const npmBin = env.OURS_NPM?.trim() || 'npm';
254
- return {
308
+ let installationLockFd = null;
309
+ const effects = {
310
+ async withInstallationLock(root, operation) {
311
+ const { tryLock } = await import('../assets/scripts/maintenance/state-native.mjs');
312
+ if (installationLockFd !== null) throw new Error('Another installer operation is already active');
313
+ ensurePrivateDirectory(root);
314
+ const path = join(root, '.operation.lock');
315
+ const fd = openSync(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
316
+ try {
317
+ const stat = fstatSync(fd);
318
+ if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077) || stat.nlink !== 1) throw new Error('Unsafe installation lock');
319
+ if (!tryLock(fd)) throw new Error('Another installer operation is already active');
320
+ const current = lstatSync(path);
321
+ if (current.dev !== stat.dev || current.ino !== stat.ino) throw new Error('Installation lock changed during acquisition');
322
+ installationLockFd = fd;
323
+ return await operation();
324
+ } finally {
325
+ installationLockFd = null;
326
+ // Keep the inode. Closing the last inherited descriptor releases flock.
327
+ closeSync(fd);
328
+ }
329
+ },
255
330
  home,
256
331
  env,
257
332
  version,
333
+ interactive: ttyFd != null,
258
334
  // Preflight reads the machine rather than asking the orchestrator to.
259
335
  platform: { platform: osPlatform(), release: osRelease() },
260
336
  nodeVersion: process.versions.node,
@@ -301,6 +377,8 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
301
377
  probe: (port) => probePort(port),
302
378
  isTaken: (port) => portTakenSync(port),
303
379
  readJson: readJsonFile,
380
+ readProfile: readHostProfileFile,
381
+ verifyHostProfile: (path) => verifyHostProfile(path),
304
382
  readText: readTextFile,
305
383
  writeJson: (path, text) => {
306
384
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
@@ -319,31 +397,37 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
319
397
  // invocation only and never to the installer's own process: a state
320
398
  // directory selected by one run must not leak into anything the operator
321
399
  // starts afterwards.
322
- run: async (cmd, args, { env: extraEnv = null, stream = false } = {}) => {
400
+ run: async (cmd, args, { env: extraEnv = null, stream = false, cwd, sensitive = false, allowCodes = [] } = {}) => {
323
401
  // Always built from this layer's OWN env rather than left to spawnSync's
324
402
  // implicit inheritance, so what a child receives is a property of the
325
403
  // effects object a caller constructed and not of whatever ambient shell
326
404
  // the installer happened to start in.
327
405
  const childEnv = { ...env, ...(extraEnv ?? {}) };
406
+ delete childEnv.OURS_INSTALLER_LOCK_FD;
407
+ if (installationLockFd !== null) childEnv.OURS_INSTALLER_LOCK_FD = '3';
328
408
  const executable = cmd === 'npm' ? npmBin : cmd;
329
409
  const r = spawnSync(executable, args, {
410
+ cwd,
330
411
  encoding: 'utf8',
331
- stdio: stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
412
+ stdio: [...(stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe']), ...(installationLockFd === null ? [] : [installationLockFd])],
332
413
  env: childEnv,
333
414
  });
334
- if (r.status !== 0) {
335
- const detail = (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
415
+ if (r.error || (r.status !== 0 && !allowCodes.includes(r.status))) {
416
+ const detail = sensitive ? '' : (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
336
417
  throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
337
418
  }
338
419
  return { ok: true, code: r.status, stdout: r.stdout ?? '' };
339
420
  },
340
- // The ONE invocation that must keep the user's terminal: `ours-mcp
341
- // voice-setup` is an interactive command with its own masked prompts, and
342
- // piping its stdio would hang it forever waiting on input nobody can type.
421
+ // Commands with their own prompts, including Fleet's no-settings wizard,
422
+ // must keep the user's terminal; piping their stdio would hang them waiting
423
+ // on input nobody can type. Prepared Fleet settings use `run` above instead.
343
424
  // It is otherwise the same contract as `run` — including the environment,
344
425
  // so an interactive command reaches the same daemon a piped one would.
345
426
  runInteractive: async (cmd, args, { env: extraEnv = null } = {}) => {
346
- const r = spawnSync(cmd, args, { stdio: 'inherit', env: { ...env, ...(extraEnv ?? {}) } });
427
+ const childEnv = { ...env, ...(extraEnv ?? {}) };
428
+ delete childEnv.OURS_INSTALLER_LOCK_FD;
429
+ if (installationLockFd !== null) childEnv.OURS_INSTALLER_LOCK_FD = '3';
430
+ const r = spawnSync(cmd, args, { stdio: ['inherit', 'inherit', 'inherit', ...(installationLockFd === null ? [] : [installationLockFd])], env: childEnv });
347
431
  return { ok: !r.error && r.status === 0, code: r.status ?? -1 };
348
432
  },
349
433
  installedVersion: (pkg) => installedVersionOf(pkg, npmBin),
@@ -356,9 +440,10 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
356
440
  ask: async (prompt, def = false) => (ttyFd == null ? def : askYesNo(write, ttyFd, ` ${prompt} `, def)),
357
441
  askLine: async (prompt, def = '') => (ttyFd == null ? def : askLineOnTty(write, ttyFd, ` ${prompt} `, def)),
358
442
  };
443
+ return Object.assign(effects, networkEffects(effects));
359
444
  }
360
445
 
361
- export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, packageDependenciesOf, resolvePackageVersion, codexMarketplace, hasClaudePlugin, knownStateDirsIn };
446
+ export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, readHostProfileFile, verifyHostProfile, installedVersionOf, packageDependenciesOf, resolvePackageVersion, codexMarketplace, hasClaudePlugin, knownStateDirsIn };
362
447
 
363
448
  // -----------------------------------------------------------------------------
364
449
  // THE PAIR
@@ -409,3 +494,852 @@ export function isWholeDaemonEnv(env) {
409
494
 
410
495
  /** The state directory a default run targets, for callers that need it early. */
411
496
  export const defaultStateDir = (home = homedir()) => join(home, '.ours');
497
+
498
+ export const INSTALLER_ASSETS = fileURLToPath(new URL('../assets/', import.meta.url));
499
+ const PACKAGED_SOURCE_POLICY = join(INSTALLER_ASSETS, 'sources.json');
500
+
501
+ function privateDirectory(path) {
502
+ const stat = lstatSync(path);
503
+ if (!stat.isDirectory() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) throw new Error(`Unsafe private installation directory: ${path}`);
504
+ }
505
+
506
+ function ensurePrivateDirectory(path) {
507
+ if (!existsSync(path)) mkdirSync(path, { recursive: true, mode: 0o700 });
508
+ privateDirectory(path);
509
+ }
510
+
511
+ function writePrivateNew(path, body) {
512
+ writeFileSync(path, body, { mode: 0o600, flag: 'wx' });
513
+ assertPrivateRegularFile(path, 'installation file');
514
+ }
515
+
516
+ /** Runtime IO remains in the installer effects seam. No host runtime on clients. */
517
+ export function networkEffects(effects) {
518
+ const { env, home } = effects;
519
+ const buildRuntime = record => {
520
+ if (!record.buildTransition || existsSync(record.workDir)) return record;
521
+ const workDir = join(record.buildTransition.candidate.root, 'previous-runtime');
522
+ privateDirectory(workDir);
523
+ return { ...record, workDir };
524
+ };
525
+ const baseEnv = (record) => ({
526
+ OURS_DAEMON_ID: record.instanceId,
527
+ OURS_IMAGE: `${record.project}:runtime`,
528
+ OURS_MAINTENANCE_IMAGE: `${record.project}:maintenance`,
529
+ OURS_UID: String(record.uid ?? 1000), OURS_GID: String(record.gid ?? 1000),
530
+ OURS_HOST_PORT: String(record.port ?? 3050),
531
+ OURS_COWORK_PORT: String(record.coworkPort ?? 3052),
532
+ OURS_MESSENGER_PORT: String(record.messengerPort ?? 8420),
533
+ OURS_MESSENGER_IDENTITY: record.messengerIdentity ?? '',
534
+ });
535
+ const compose = (record, args, options = {}) => effects.run('docker', [
536
+ 'compose', '--project-directory', record.workDir, '--file', join(record.workDir,
537
+ record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
538
+ ? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
539
+ '--project-name', record.project, ...args,
540
+ ], { ...options, env: { ...baseEnv(record), ...options.env } });
541
+ const bin = (record, name) => join(record.workDir, 'node_modules', '.bin', name);
542
+ const localEnv = (record, service = 'daemon') => {
543
+ const paths = installationPaths(record);
544
+ const state = paths[service];
545
+ const credentialPath = paths.credentials[service];
546
+ const common = { OURS_DAEMON_ID: record.instanceId, OURS_DAEMON_URL: `http://127.0.0.1:${record.port}`, OURS_DAEMON_CREDENTIAL_PATH: credentialPath };
547
+ if (service === 'daemon') return { OURS_CONFIG: record.configPath, OURS_STATE_DIR: state, OURS_PORT: String(record.port), OURS_DAEMON_ID: record.instanceId };
548
+ if (service === 'telegram') return { OURS_TG_CONFIG: join(state, 'config.json'), OURS_TG_STATE_DIR: state, OURS_TG_CONTROL_PORT: '3051', OURS_TG_DAEMON_URL: common.OURS_DAEMON_URL, OURS_TG_DAEMON_ID: record.instanceId, OURS_TG_DAEMON_CREDENTIAL_PATH: credentialPath };
549
+ if (service === 'cowork') return { ...common, OURS_COWORK_CONFIG: join(state, 'config.json'), OURS_COWORK_STATE_DIR: state, OURS_COWORK_REST_PORT: String(record.coworkPort) };
550
+ return { ...common, OURS_MESSENGER_STATE_DIR: state, ...(record.messengerIdentity ? { OURS_MESSENGER_IDENTITY: record.messengerIdentity } : {}), OURS_MESSENGER_PORT: String(record.messengerPort), OURS_MESSENGER_HOST: '127.0.0.1', OURS_MESSENGER_PUBLIC_ORIGIN: `http://127.0.0.1:${record.messengerPort}` };
551
+ };
552
+ const ownerCommand = (record, service, op, options = {}) => {
553
+ const name = { daemon: 'ours', telegram: 'ours-tg-connector', cowork: 'ours-cowork' }[service];
554
+ const args = service === 'daemon' ? ['daemon', op, ...(['install-service', 'uninstall-service'].includes(op) ? ['--yes'] : []), '--config', record.configPath, '--state-dir', installationPaths(record).daemon, '--json'] : [op];
555
+ return effects.run(bin(record, name), args, { ...options, env: localEnv(record, service) });
556
+ };
557
+ const requireCleanContainerExit = async (record, selected) => {
558
+ const containers = await compose(record, ['ps', '-aq', ...selected]);
559
+ for (const id of containers.stdout.split(/\s+/).filter(Boolean)) {
560
+ const result = await effects.run('docker', ['inspect', '--format', '{{json .State}}', id]);
561
+ const state = JSON.parse(result.stdout);
562
+ if (state.Status !== 'exited' || state.ExitCode !== 0 || state.OOMKilled !== false || state.Dead !== false) {
563
+ throw new Error('Selected source container did not stop cleanly; state operation refused');
564
+ }
565
+ }
566
+ };
567
+ return {
568
+ sourcePolicyHash(path) {
569
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
570
+ },
571
+ packagedSourcePolicy() {
572
+ return JSON.parse(readFileSync(PACKAGED_SOURCE_POLICY, 'utf8'));
573
+ },
574
+ async resolveSourcePolicy(policy, role, clients = []) {
575
+ return resolveSourcePolicy(policy, role, clients, async (name, range) => {
576
+ const result = await effects.run('npm', ['view', `${name}@${range}`, 'version', '--json']);
577
+ let versions;
578
+ try { versions = JSON.parse(result.stdout); }
579
+ catch { throw new Error(`npm returned malformed version metadata for ${name}@${range}`); }
580
+ const version = Array.isArray(versions) ? versions.at(-1) : versions;
581
+ if (typeof version !== 'string') throw new Error(`npm did not resolve ${name}@${range}`);
582
+ return version;
583
+ });
584
+ },
585
+ newInstallation(root, mode) {
586
+ if (existsSync(root)) {
587
+ privateDirectory(root);
588
+ if (readdirSync(root).some(name => name !== '.operation.lock')) throw new Error('Installation root is not empty and has no selection record');
589
+ }
590
+ const instanceId = env.OURS_DAEMON_ID || randomUUID();
591
+ if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(instanceId)) throw new Error('OURS_DAEMON_ID must be a lowercase UUID');
592
+ const project = `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
593
+ return { schema: 2, root, mode, instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services: [...SERVER_SERVICES], port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
594
+ },
595
+ async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest } = {}) {
596
+ if (existing) {
597
+ privateDirectory(record.root);
598
+ assertPrivateRegularFile(join(record.root, 'installation.json'), 'selection');
599
+ assertPrivateRegularFile(record.sourcesPath, 'sources');
600
+ if (env.OURS_DAEMON_ID && env.OURS_DAEMON_ID !== record.instanceId) throw new Error('Conflicting instance ID');
601
+ }
602
+ if (record.mode === 'docker') {
603
+ await effects.run('docker', ['info', '--format', '{{.ServerVersion}}']);
604
+ const version = await effects.run('docker', ['compose', 'version', '--short']);
605
+ const match = /^v?(\d+)\.(\d+)/.exec(version.stdout.trim());
606
+ if (!match || Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 35)) throw new Error('Docker Compose 2.35 or newer is required');
607
+ if (operation !== 'status') {
608
+ // Compose clients can disappear while their Engine-owned command continues.
609
+ const active = await effects.run('docker', ['ps', '--filter', `label=com.docker.compose.project=${record.project}`, '--filter', 'label=com.docker.compose.oneoff=True', '--format', '{{.ID}}']);
610
+ if (active.stdout.trim()) throw new Error('Another server installation operation is still running in Docker');
611
+ }
612
+ } else {
613
+ if (!['linux', 'darwin'].includes(effects.platform.platform)) throw new Error('Package mode requires systemd-user or launchd');
614
+ await effects.run(effects.platform.platform === 'linux' ? 'systemctl' : 'launchctl', effects.platform.platform === 'linux' ? ['--user', 'show-environment'] : ['print', `gui/${process.getuid()}`]);
615
+ if (operation === 'install') {
616
+ for (const command of ['node', 'npm']) await effects.run(command, ['--version']);
617
+ const packages = selectSourcePackages(sourceManifest ?? effects.readJson(existing ? record.sourcesPath : sourcePath), 'server');
618
+ if (Object.values(packages).some(selection => selection.source)) {
619
+ // Native dependencies in source builds still use their own toolchains.
620
+ for (const command of ['python3', 'git', 'make', 'cc']) await effects.run(command, ['--version']);
621
+ }
622
+ }
623
+ }
624
+ },
625
+ async initializeSelection(record, manifest) {
626
+ if (typeof manifest === 'string') manifest = JSON.parse(readFileSync(manifest, 'utf8'));
627
+ selectSourcePackages(manifest, 'server');
628
+ const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
629
+ ensurePrivateDirectory(record.root);
630
+ if (record.schema === 2) {
631
+ for (const path of [join(record.root, 'storage'), installationPaths(record).state, installationPaths(record).daemon]) ensurePrivateDirectory(path);
632
+ }
633
+ writePrivateNew(record.sourcesPath, bytes);
634
+ writePrivateNew(record.configPath, JSON.stringify({ stateDir: record.mode === 'docker' ? '/var/lib/ours' : installationPaths(record).daemon, port: record.port, apiVisibility: 'owner' }, null, 2) + '\n');
635
+ },
636
+ async prepareInstallation(record, { runtimeOnly = false } = {}) {
637
+ let copied = false;
638
+ if (!existsSync(record.workDir)) {
639
+ cpSync(INSTALLER_ASSETS, record.workDir, { recursive: true, errorOnExist: true, force: false });
640
+ chmodSync(record.workDir, 0o700);
641
+ copied = true;
642
+ }
643
+ const retained = readFileSync(record.sourcesPath);
644
+ const materialized = join(record.workDir, 'sources.json');
645
+ if (copied) rmSync(materialized);
646
+ if (!existsSync(materialized)) writePrivateNew(materialized, retained);
647
+ else if (!readFileSync(materialized).equals(retained)) throw new Error('Materialized sources differ from retained selection');
648
+ if (record.mode === 'docker') {
649
+ // The installer owns these dependencies in both installation modes.
650
+ const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
651
+ writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
652
+ const image = await effects.run('docker', ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
653
+ if (image.code !== 0) await compose(record, ['build', 'daemon']);
654
+ if (runtimeOnly) return;
655
+ await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
656
+ } else {
657
+ if (!existsSync(join(record.workDir, '.packages-ready'))) {
658
+ const sourceRoot = join(record.root, `build-${randomUUID()}`);
659
+ ensurePrivateDirectory(sourceRoot);
660
+ try {
661
+ await effects.run(process.execPath, [join(record.workDir, 'scripts/build/build.mjs')], { cwd: record.workDir, env: { OURS_BUILD_ROOT: record.workDir, OURS_SOURCE_ROOT: sourceRoot } });
662
+ await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: record.workDir });
663
+ await effects.run(process.execPath, [join(record.workDir, 'scripts/build/record-build.mjs')], { cwd: record.workDir, env: { OURS_BUILD_ROOT: record.workDir } });
664
+ writePrivateNew(join(record.workDir, '.packages-ready'), 'ready\n');
665
+ } finally { rmSync(sourceRoot, { recursive: true, force: true }); }
666
+ }
667
+ await effects.recordRuntimeBuild(record);
668
+ if (runtimeOnly) return;
669
+ const paths = installationPaths(record);
670
+ ensurePrivateDirectory(join(paths.state, 'credentials'));
671
+ for (const dir of [paths.daemon, paths.telegram, paths.cowork, paths.messenger, paths.mcp,
672
+ ...Object.values(paths.credentials).map(path => dirname(path))]) ensurePrivateDirectory(dir);
673
+ const profilePath = join(installationPaths(record).mcp, 'profile.json');
674
+ if (!existsSync(profilePath)) writePrivateNew(profilePath, JSON.stringify({ endpoint: `http://127.0.0.1:${record.port}`, expectedInstanceId: record.instanceId, credentialPath: join(installationPaths(record).daemon, 'daemon-token') }));
675
+ const config = JSON.parse(readFileSync(record.configPath, 'utf8'));
676
+ config.networkMcp ??= { profile: { endpoint: `http://127.0.0.1:${record.port}`, expectedInstanceId: record.instanceId, credentialPath: join(installationPaths(record).daemon, 'daemon-token') }, applicationConfigPath: join(installationPaths(record).mcp, 'config.json') };
677
+ effects.writeJson(record.configPath, JSON.stringify(config, null, 2) + '\n');
678
+ const cowork = join(installationPaths(record).cowork, 'config.json');
679
+ if (!existsSync(cowork)) writePrivateNew(cowork, JSON.stringify({ version: 1, stateDir: installationPaths(record).cowork, rest: { enabled: true, host: '127.0.0.1', port: record.coworkPort } }));
680
+ }
681
+ },
682
+ async prepareServerBuild(record, args) {
683
+ validateInstallation(record, record.root);
684
+ if (record.schema !== 2 || record.layoutConversion || !['update', 'rebuild'].includes(args.operation)) {
685
+ throw new Error('Select a converted installation for update or rebuild');
686
+ }
687
+ const sources = args.operation === 'update'
688
+ ? (args.resolvedSources
689
+ ? Buffer.from(`${JSON.stringify(args.resolvedSources, null, 2)}\n`)
690
+ : readFileSync(args.sources))
691
+ : readFileSync(record.sourcesPath);
692
+ selectSourcePackages(JSON.parse(sources), 'server');
693
+ privateDirectory(record.root);
694
+ const root = mkdtempSync(join(record.root, '.build-'));
695
+ const candidate = { ...record, root, workDir: join(root, 'runtime'), sourcesPath: join(root, 'sources.json'),
696
+ configPath: installationPaths({ schema: 2, root }).config,
697
+ project: `ours-build${randomUUID().replaceAll('-', '')}`,
698
+ sourcePolicyHash: args.sources ? effects.sourcePolicyHash(args.sources) : undefined };
699
+ try {
700
+ writePrivateNew(candidate.sourcesPath, sources);
701
+ await effects.prepareInstallation(candidate, { runtimeOnly: true });
702
+ if (candidate.mode === 'docker') {
703
+ await compose(candidate, ['build', 'state-operation']);
704
+ await effects.copyDockerBuildRecords(candidate, candidate.workDir);
705
+ }
706
+ // npm emits readable build records; maintenance consumes private copies.
707
+ for (const file of [...BASE_RECORDS, ...(existsSync(join(candidate.workDir, CONTEXT)) ? [CONTEXT] : [])]) {
708
+ const path = join(candidate.workDir, file), stat = lstatSync(path);
709
+ if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o7002)) {
710
+ throw new Error('Unsafe candidate build record');
711
+ }
712
+ JSON.parse(readFileSync(path, 'utf8'));
713
+ chmodSync(path, 0o600);
714
+ }
715
+ readBuildRecords(candidate.workDir, { privateFiles: true });
716
+ return candidate;
717
+ } catch (error) {
718
+ rmSync(root, { recursive: true, force: true });
719
+ throw error;
720
+ }
721
+ },
722
+ async copyDockerBuildRecords(record, directory) {
723
+ // Inspect immutable image metadata; never reinterpret a failed context copy as legacy.
724
+ const label = (await effects.run('docker', ['image', 'inspect', '--format', '{{ index .Config.Labels "network.ours.build-context" }}', `${record.project}:runtime`])).stdout.trim();
725
+ if (!['', '<no value>', '1'].includes(label)) throw new Error('Unsupported image build-context schema');
726
+ const names = [...BASE_RECORDS, ...(label === '1' ? [CONTEXT] : [])];
727
+ const stage = mkdtempSync(join(directory, '.records-'));
728
+ const name = `${record.project}-records`;
729
+ let created = false;
730
+ try {
731
+ await effects.run('docker', ['create', '--name', name, '--entrypoint', '/bin/true', `${record.project}:runtime`]);
732
+ created = true;
733
+ for (const file of names) {
734
+ await effects.run('docker', ['cp', `${name}:/opt/ours/${file}`, join(stage, file)]);
735
+ const st = lstatSync(join(stage, file));
736
+ if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o7002)) throw new Error('Unsafe copied build record');
737
+ chmodSync(join(stage, file), 0o600);
738
+ }
739
+ readBuildRecords(stage, { privateFiles: true });
740
+ // Destination is unpublished candidate storage; any copy error aborts activation.
741
+ for (const file of names) renameSync(join(stage, file), join(directory, file));
742
+ if (label !== '1' && existsSync(join(directory, CONTEXT))) throw new Error('Legacy image conflicts with retained build context');
743
+ } finally {
744
+ try { if (created) await effects.run('docker', ['rm', name]); }
745
+ finally { rmSync(stage, { recursive: true, force: true }); }
746
+ }
747
+ },
748
+ async checkServerBuild(record, candidate, compatible, operation = 'update') {
749
+ const sameSources = readFileSync(record.sourcesPath).equals(readFileSync(candidate.sourcesPath));
750
+ if (operation === 'rebuild' && !sameSources) throw new Error('Rebuild must retain the selected sources');
751
+ if (!sameSources && operation !== 'rebuild' && !compatible) throw new Error('Changed sources require reviewed storage compatibility (--compatible)');
752
+ let current = record.workDir;
753
+ if (record.mode === 'docker') {
754
+ current = join(candidate.root, 'previous-build');
755
+ ensurePrivateDirectory(current);
756
+ await effects.copyDockerBuildRecords(record, current);
757
+ }
758
+ const currentRecords = readBuildRecords(current), candidateRecords = readBuildRecords(candidate.workDir);
759
+ if (compatible && operation !== 'rebuild') return;
760
+ if (!equalBuildRecords(currentRecords, candidateRecords)) {
761
+ throw new Error('Different build or missing verified context requires reviewed storage compatibility; use server update with --compatible');
762
+ }
763
+ },
764
+ async serverBuildTransition(record, args) {
765
+ const { serverBuildTransition } = await import('./build-transition.mjs');
766
+ return serverBuildTransition(record, args, effects);
767
+ },
768
+ async retireServerBuildRuntime(record) {
769
+ const selected = buildRuntime(record);
770
+ if (record.mode === 'docker') {
771
+ await effects.serverLifecycle(selected, 'stop');
772
+ await requireCleanContainerExit(selected, record.services);
773
+ await compose(selected, ['rm', '-f', ...record.services]);
774
+ } else await nativeLifecycle(selected, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
775
+ },
776
+ async updateServerBuildState(record, candidate, compatible, operation = 'update') {
777
+ const command = [operation, 'server', ...(compatible ? ['--compatible'] : [])];
778
+ if (record.mode === 'docker') {
779
+ await compose({ ...record, workDir: candidate.workDir }, ['run', '--rm', '--no-deps', '-T', 'state-operation', ...command], {
780
+ env: { OURS_MAINTENANCE_IMAGE: `${candidate.project}:maintenance`, OURS_STATE_DOMAIN: 'server', OURS_LIVE_ROOT: '/storage/state' },
781
+ });
782
+ } else {
783
+ const paths = installationPaths(record);
784
+ await effects.run(process.execPath, [join(INSTALLER_ASSETS, 'scripts/maintenance/state-operation.mjs'), ...command], {
785
+ env: { ...localEnv(record), OURS_STATE_DOMAIN: 'server', OURS_STATE_ROOT: join(record.root, 'storage'),
786
+ OURS_LIVE_ROOT: paths.state, OURS_BUILD_ROOT: candidate.workDir,
787
+ OURS_COWORK_CLI_PATH: bin(record, 'ours-cowork'), OURS_COWORK_CONFIG: join(paths.cowork, 'config.json'),
788
+ OURS_COWORK_STATE_DIR: paths.cowork },
789
+ });
790
+ }
791
+ },
792
+ async publishServerBuild(record, candidate) {
793
+ const previous = join(candidate.root, 'previous-runtime');
794
+ if (record.mode === 'docker') {
795
+ for (const target of ['runtime', 'maintenance']) {
796
+ const retained = `${candidate.project}:previous-${target}`;
797
+ const found = await effects.run('docker', ['image', 'inspect', retained], { allowCodes: [1] });
798
+ if (found.code !== 0) {
799
+ const current = await effects.run('docker', ['image', 'inspect', `${record.project}:${target}`], { allowCodes: [1] });
800
+ if (current.code === 0) await effects.run('docker', ['tag', `${record.project}:${target}`, retained]);
801
+ }
802
+ }
803
+ }
804
+ if (!existsSync(previous)) {
805
+ privateDirectory(record.workDir);
806
+ privateDirectory(candidate.workDir);
807
+ renameSync(record.workDir, previous);
808
+ }
809
+ privateDirectory(previous);
810
+ if (existsSync(candidate.workDir)) {
811
+ if (existsSync(record.workDir)) throw new Error('Both candidate and active runtimes exist after retaining the previous build');
812
+ privateDirectory(candidate.workDir);
813
+ renameSync(candidate.workDir, record.workDir);
814
+ } else privateDirectory(record.workDir);
815
+ if (record.mode === 'docker') {
816
+ for (const target of ['runtime', 'maintenance']) {
817
+ await effects.run('docker', ['tag', `${candidate.project}:${target}`, `${record.project}:${target}`]);
818
+ }
819
+ }
820
+ atomicWriteConfig(record.sourcesPath, readFileSync(candidate.sourcesPath));
821
+ },
822
+ async validateServerBuildState(record) {
823
+ if (record.mode === 'docker') {
824
+ return effects.runDockerConversion(record, { target: `${record.project}_server-storage` }, 'validate');
825
+ }
826
+ const { validateConvertedPackageState } = await import('./layout-conversion.mjs');
827
+ validateConvertedPackageState(record);
828
+ },
829
+ async discardServerBuild(candidate) {
830
+ if (candidate.mode === 'docker') {
831
+ for (const target of ['runtime', 'maintenance', 'previous-runtime', 'previous-maintenance']) {
832
+ const image = `${candidate.project}:${target}`;
833
+ const found = await effects.run('docker', ['image', 'inspect', image], { allowCodes: [1] });
834
+ if (found.code === 0) await effects.run('docker', ['image', 'rm', image]);
835
+ }
836
+ }
837
+ privateDirectory(candidate.root);
838
+ rmSync(candidate.root, { recursive: true });
839
+ },
840
+ async serverAccess(record, operation, { output, migrate = false } = {}) {
841
+ if (record.mode === 'docker') {
842
+ if (!output) return compose(record, ['run', '--rm', '--no-deps', '-T', 'access', operation], { sensitive: true, env: { OURS_ACCESS_MIGRATE: migrate ? '1' : '0' } });
843
+ privateDirectory(dirname(output));
844
+ if (existsSync(output)) throw new Error('Credential output already exists');
845
+ const name = `${record.project}-issue-${randomUUID()}`;
846
+ const issuedPath = `/var/lib/ours/.issued-${randomUUID()}`;
847
+ const staging = join(dirname(output), `.ours-issued-${randomUUID()}`);
848
+ ensurePrivateDirectory(staging);
849
+ try {
850
+ await compose(record, ['run', '--name', name, '--no-deps', '-T', 'access', 'access-issue', issuedPath], { sensitive: true });
851
+ const file = join(staging, 'credential');
852
+ await effects.run('docker', ['cp', `${name}:${issuedPath}`, file], { sensitive: true });
853
+ assertPrivateRegularFile(file, 'issued credential');
854
+ // Exclusive publication never overwrites an unrelated credential.
855
+ writePrivateNew(output, readFileSync(file));
856
+ } finally {
857
+ await compose(record, ['run', '--rm', '--no-deps', '-T', '--entrypoint', 'rm', 'access', '-f', issuedPath], { sensitive: true });
858
+ await effects.run('docker', ['rm', '-f', name], { sensitive: true });
859
+ rmSync(staging, { recursive: true, force: true });
860
+ }
861
+ return;
862
+ }
863
+ const options = { env: localEnv(record), sensitive: true };
864
+ if (operation !== 'access-issue') return effects.run(bin(record, 'ours'), ['config', operation, '--config', record.configPath, ...(operation === 'access-replace' ? ['--confirm'] : migrate ? ['--migrate'] : []), '--json'], options);
865
+ const outputs = output ? [output] : [join(installationPaths(record).daemon, 'daemon-token'), ...['telegram', 'cowork', 'messenger'].map(s => installationPaths(record).credentials[s])];
866
+ for (const path of outputs) await effects.run(bin(record, 'ours'), ['config', operation, '--config', record.configPath, '--output', path, ...(output ? [] : ['--replace']), '--json'], options);
867
+ },
868
+ async recordRuntimeBuild(record) {
869
+ if (record.mode === 'docker') return; // Image preparation records its build.
870
+ const tree = join(record.workDir, 'dependency-tree.json');
871
+ if (!existsSync(tree)) {
872
+ const result = await effects.run('npm', ['ls', '--omit=dev', '--all', '--json'], { cwd: record.workDir });
873
+ JSON.parse(result.stdout);
874
+ writePrivateNew(tree, result.stdout);
875
+ }
876
+ },
877
+ async recordInstallationBuild(record) {
878
+ if (record.mode === 'docker') return;
879
+ await effects.recordRuntimeBuild(record);
880
+ const paths = installationPaths(record);
881
+ const records = readBuildRecords(record.workDir);
882
+ for (const service of SERVER_SERVICES) {
883
+ const marker = join(paths[service], '.ours-provenance');
884
+ initializeBuildMarker(marker, records);
885
+ }
886
+ },
887
+ async serverMaintenance(record, args) {
888
+ if (record.schema !== 2 || record.layoutConversion) throw new Error('Finish managed layout conversion before maintenance');
889
+ if (!['server', 'daemon', 'telegram', 'cowork', 'messenger'].includes(args.domain) || !['backup', 'restore', 'reset'].includes(args.operation) || (args.domain === 'server' && args.operation === 'reset')) {
890
+ throw new Error('Addressed shared-state maintenance is not yet available');
891
+ }
892
+ const selected = maintenanceServices(record, args.domain);
893
+ if (!selected.length) throw new Error('Selected component is not part of this installation');
894
+ const running = await effects.serverLifecycle(record, 'status', selected);
895
+ await effects.serverLifecycle(record, 'stop', selected);
896
+ const command = [args.operation, args.domain, ...(args.operation === 'reset' ? ['--confirm'] : [args.label]), ...(args.compatible ? ['--compatible'] : [])];
897
+ if (record.mode === 'docker') {
898
+ await requireCleanContainerExit(record, selected);
899
+ // Parent replacement invalidates every retained subpath mount.
900
+ if (['restore', 'reset'].includes(args.operation)) await compose(record, ['rm', '-f', ...selected]);
901
+ await compose(record, ['run', '--rm', '--no-deps', '-T', 'state-operation', ...command], {
902
+ env: { OURS_STATE_DOMAIN: args.domain, OURS_LIVE_ROOT: ['server', 'daemon'].includes(args.domain) ? '/storage/state' : `/storage/state/${args.domain}` },
903
+ });
904
+ } else {
905
+ const paths = installationPaths(record);
906
+ await effects.run(process.execPath, [join(INSTALLER_ASSETS, 'scripts/maintenance/state-operation.mjs'), ...command], {
907
+ env: { ...localEnv(record), OURS_STATE_DOMAIN: args.domain,
908
+ OURS_STATE_ROOT: join(record.root, 'storage'), OURS_LIVE_ROOT: ['server', 'daemon'].includes(args.domain) ? paths.state : paths[args.domain],
909
+ OURS_BUILD_ROOT: record.workDir, OURS_CLI_PATH: bin(record, 'ours'),
910
+ OURS_DAEMON_CONFIG: record.configPath, OURS_COWORK_CLI_PATH: bin(record, 'ours-cowork'),
911
+ OURS_COWORK_CONFIG: join(paths.cowork, 'config.json'), OURS_COWORK_STATE_DIR: paths.cowork },
912
+ });
913
+ }
914
+ await effects.serverLifecycle(record, 'start', running);
915
+ },
916
+ async selectConversionVolumes(record, { allowMissingSources = false } = {}) {
917
+ if (record.schema !== 1 || record.mode !== 'docker') {
918
+ throw new Error('Select the recorded legacy Docker source');
919
+ }
920
+ const result = await compose(record, ['config', '--format', 'json']);
921
+ const configuration = JSON.parse(result.stdout);
922
+ const sources = {};
923
+ const inspectOwned = async (name, key, optional = false) => {
924
+ if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) {
925
+ throw new Error('Invalid conversion volume name');
926
+ }
927
+ const result = await effects.run('docker', ['volume', 'inspect', '--format', '{{json .}}', name], {
928
+ ...(optional ? { allowCodes: [1] } : {}),
929
+ });
930
+ if (result.code !== 0) {
931
+ if (optional) {
932
+ const inventory = await effects.run('docker', ['volume', 'ls', '--format', '{{.Name}}']);
933
+ if (inventory.code === 0 && !inventory.stdout.split(/\s+/).includes(name)) return false;
934
+ }
935
+ throw new Error(`Source volume is missing or unavailable: ${name}`);
936
+ }
937
+ const volume = JSON.parse(result.stdout);
938
+ if (volume.Name !== name || volume.Labels?.['com.docker.compose.project'] !== record.project
939
+ || volume.Labels?.['com.docker.compose.volume'] !== key) {
940
+ throw new Error(`Conversion volume ownership differs: ${name}`);
941
+ }
942
+ if (volume.Driver !== 'local' || Object.keys(volume.Options ?? {}).length) {
943
+ throw new Error(`External or custom volume storage requires explicit ownership resolution: ${name}`);
944
+ }
945
+ return true;
946
+ };
947
+ for (const service of SERVER_SERVICES) {
948
+ const mounts = configuration.services?.[service]?.volumes ?? [];
949
+ const selected = [{ key: `${service}-state`, alias: service,
950
+ target: service === 'daemon' ? '/var/lib/ours' : `/var/lib/ours-${service}`, subpath: 'data' }];
951
+ if (service !== 'daemon') selected.push({
952
+ key: `${service}-credential`, alias: `${service}-credential`, target: `/credentials/${service}`,
953
+ });
954
+ for (const selection of selected) {
955
+ const mount = mounts.find(mount => mount.target === selection.target);
956
+ const declared = configuration.volumes?.[selection.key];
957
+ if (mount?.type !== 'volume' || mount.source !== selection.key
958
+ || mount.volume?.subpath !== selection.subpath || !declared || declared.external) {
959
+ throw new Error(`Unexpected legacy source mount for ${selection.alias}`);
960
+ }
961
+ if ((declared.driver && declared.driver !== 'local') || Object.keys(declared.driver_opts ?? {}).length) {
962
+ throw new Error(`External or custom volume storage requires explicit ownership resolution: ${selection.key}`);
963
+ }
964
+ if (!await inspectOwned(declared.name, selection.key, allowMissingSources)) continue;
965
+ if (Object.values(sources).includes(declared.name)) throw new Error('Conversion source volumes alias each other');
966
+ sources[selection.alias] = declared.name;
967
+ }
968
+ }
969
+ const target = `${record.project}_server-storage`;
970
+ if (Object.values(sources).includes(target)) throw new Error('Conversion destination aliases a source volume');
971
+ const targetExists = await inspectOwned(target, 'server-storage', true);
972
+ if (targetExists && !record.layoutConversion) throw new Error('Unreserved conversion destination already exists');
973
+ return { sources, target, targetExists };
974
+ },
975
+ async prepareDockerConversionRuntime(record) {
976
+ const { prepareDockerConversionRuntime } = await import('./docker-conversion-runtime.mjs');
977
+ await prepareDockerConversionRuntime(record, effects, INSTALLER_ASSETS);
978
+ },
979
+ async runDockerConversion(record, volumes, operation, label) {
980
+ const { runDockerConversion } = await import('./docker-conversion-runtime.mjs');
981
+ return runDockerConversion(record, volumes, operation, label, effects);
982
+ },
983
+ async convertDockerInstallation(record, operation) {
984
+ const { convertDockerInstallation } = await import('./docker-layout-installation.mjs');
985
+ return convertDockerInstallation(record, operation, effects);
986
+ },
987
+ async confirmDockerWritersStopped(record) {
988
+ await requireCleanContainerExit(record, record.services);
989
+ },
990
+ async prepareLegacyPackageSource(record) {
991
+ await ownerCommand(record, 'cowork', 'prepare-backup');
992
+ },
993
+ async retainConvertedPackageAuthority(record, daemon) {
994
+ await effects.run(bin(record, 'ours'), [
995
+ 'config', 'access-retain', '--config', record.configPath,
996
+ '--target-state-dir', daemon, '--json',
997
+ ], { env: localEnv(record), sensitive: true });
998
+ },
999
+ async convertPackageInstallation(record, operation) {
1000
+ const { convertPackageInstallation } = await import('./layout-conversion.mjs');
1001
+ return convertPackageInstallation(record, operation, effects);
1002
+ },
1003
+ async stopPendingConversion(record) {
1004
+ validateInstallation(record, record.root);
1005
+ if (!record.layoutConversion) throw new Error('Select a pending layout conversion');
1006
+ const source = record.layoutConversion.sourceRecord;
1007
+ const selections = record.schema === 1 ? [source] : [record, source];
1008
+ for (const selection of selections) {
1009
+ if (record.mode === 'docker') {
1010
+ await effects.serverLifecycle(selection, 'stop', SERVER_SERVICES);
1011
+ } else {
1012
+ // Cleanup can already have removed former state. Never recreate it.
1013
+ const paths = installationPaths(selection);
1014
+ const retired = record.schema === 2 && selection === source;
1015
+ const selected = selection.services.filter(service => {
1016
+ if (!existsSync(paths[service])) return false;
1017
+ // Published conversion already retired old registrations. Partial
1018
+ // cleanup may leave directories without usable owner configuration.
1019
+ if (!retired || service === 'messenger') return true;
1020
+ return existsSync(service === 'daemon' ? selection.configPath : join(paths[service], 'config.json'));
1021
+ });
1022
+ await nativeLifecycle(selection, 'stop', selected, { effects, localEnv, ownerCommand, bin, stopSelections: selections });
1023
+ }
1024
+ }
1025
+ },
1026
+ async retireLegacyServices(record) {
1027
+ if (record.schema !== 1 || !['packages', 'docker'].includes(record.mode)) {
1028
+ throw new Error('Select a legacy managed installation for service retirement');
1029
+ }
1030
+ if (record.mode === 'docker') {
1031
+ await effects.serverLifecycle(record, 'stop');
1032
+ await requireCleanContainerExit(record, record.services);
1033
+ await compose(record, ['rm', '-f', ...record.services]);
1034
+ return;
1035
+ }
1036
+ await nativeLifecycle(record, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
1037
+ },
1038
+ async serverLifecycle(record, operation, selected = record.services) {
1039
+ record = buildRuntime(record);
1040
+ if (record.mode === 'docker') {
1041
+ if (operation === 'status') {
1042
+ const result = await compose(record, ['ps', '--format', 'json', ...selected]);
1043
+ const raw = result.stdout.trim();
1044
+ const rows = !raw ? [] : raw.startsWith('[') ? JSON.parse(raw) : raw.split('\n').map(line => JSON.parse(line));
1045
+ return rows.filter(row => row.State === 'running').map(row => row.Service).filter(s => selected.includes(s));
1046
+ }
1047
+ if (operation === 'stop') {
1048
+ const consumers = selected.filter(s => s !== 'daemon').reverse();
1049
+ if (consumers.length) await compose(record, ['stop', ...consumers]);
1050
+ if (selected.includes('daemon')) await compose(record, ['stop', 'daemon']);
1051
+ if ((await effects.serverLifecycle(record, 'status', selected)).length) throw new Error('Writers did not stop');
1052
+ return;
1053
+ }
1054
+ if (selected.includes('daemon')) await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', 'daemon']);
1055
+ const failures = [];
1056
+ for (const service of selected.filter(s => s !== 'daemon')) {
1057
+ try { await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', service]); }
1058
+ catch { failures.push(service); }
1059
+ }
1060
+ if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check owning prerequisites; no identities were created.`);
1061
+ return;
1062
+ }
1063
+ return nativeLifecycle(record, operation, selected, { effects, localEnv, ownerCommand, bin });
1064
+ },
1065
+ readManagedClientProfile() {
1066
+ const path = join(home, '.ours-client', 'profile.json');
1067
+ try { lstatSync(path); }
1068
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
1069
+ privateDirectory(dirname(path));
1070
+ if (!readHostProfileFile(path)) throw new Error('Managed client profile must contain a complete network profile');
1071
+ return JSON.parse(readFileSync(path, 'utf8'));
1072
+ },
1073
+ async discoverClientProfile(endpoint, credentialPath) {
1074
+ const url = new URL(endpoint);
1075
+ if (url.protocol !== 'http:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash)
1076
+ throw new Error('Client endpoint must be an HTTP origin');
1077
+ const response = await fetch(`${url.origin}/selection`, { redirect: 'error', signal: AbortSignal.timeout(5000) });
1078
+ if (!response.ok) throw new Error(`Daemon selection answered HTTP ${response.status}`);
1079
+ const selection = await response.json();
1080
+ // Full metadata and authenticated capability validation follows before publication.
1081
+ return validateHostProfile({ endpoint: url.origin, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
1082
+ },
1083
+ importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath }) {
1084
+ const root = join(home, '.ours-client');
1085
+ const configPath = join(root, 'profile.json');
1086
+ const credentialPath = join(root, 'credential');
1087
+ const current = effects.readManagedClientProfile();
1088
+ if (current && (current.endpoint !== profile.endpoint || current.expectedInstanceId !== profile.expectedInstanceId))
1089
+ throw new Error('Managed client already selects another server; existing default was not changed');
1090
+ assertPrivateRegularFile(profile.credentialPath, 'credential');
1091
+ const credential = readFileSync(profile.credentialPath, 'utf8');
1092
+ if (!credential.trim()) throw new Error('Client credential is empty');
1093
+ // Read every supplied input before any publication. Existing setup settings win on retry.
1094
+ const sources = current ? null : resolvedSources
1095
+ ? Buffer.from(`${JSON.stringify(resolvedSources, null, 2)}\n`)
1096
+ : readFileSync(sourcesPath);
1097
+ const fleetSettings = !current && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
1098
+ if (fleetSettings) JSON.parse(fleetSettings.toString());
1099
+ ensurePrivateDirectory(root);
1100
+ if (current) {
1101
+ assertPrivateRegularFile(credentialPath, 'managed credential');
1102
+ if (readFileSync(credentialPath, 'utf8') !== credential) atomicWriteConfig(credentialPath, credential);
1103
+ return { configPath, profile: validateHostProfile(current), settings: current.installer };
1104
+ }
1105
+ const settings = { sourcesPath: join(root, 'sources.json'), integrations };
1106
+ if (fleetSettings) settings.fleetSettingsPath = join(root, 'fleet-settings.json');
1107
+ // Publish the profile last: clients cannot select incomplete imported inputs.
1108
+ atomicWriteConfig(settings.sourcesPath, sources);
1109
+ if (fleetSettings) atomicWriteConfig(settings.fleetSettingsPath, fleetSettings);
1110
+ atomicWriteConfig(credentialPath, credential);
1111
+ const saved = { ...profile, credentialPath, installer: settings };
1112
+ atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
1113
+ return { configPath, profile: validateHostProfile(saved), settings };
1114
+ },
1115
+ async acquireClientPackages(configPath, sourcesPath, integrations) {
1116
+ const manifest = JSON.parse(readFileSync(sourcesPath, 'utf8'));
1117
+ // Public SDK client APIs are actual integration dependencies; Fleet also owns CLI usage.
1118
+ const selected = [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
1119
+ const packages = selectSourcePackages(manifest, 'client', selected);
1120
+ const root = join(home, '.ours-client-install', createHash('sha256').update(configPath).digest('hex').slice(0, 16));
1121
+ const hasGit = Object.values(packages).some(selection => selection.source);
1122
+ await effects.run('npm', ['--version']);
1123
+ if (hasGit) {
1124
+ for (const command of ['python3', 'git', 'make', 'cc']) await effects.run(command, ['--version']);
1125
+ }
1126
+ ensurePrivateDirectory(root);
1127
+ const retained = join(root, 'sources.json');
1128
+ const bytes = readFileSync(sourcesPath);
1129
+ if (!existsSync(retained)) writePrivateNew(retained, bytes);
1130
+ else if (!readFileSync(retained).equals(bytes)) throw new Error('Client installation has another exact source selection; select a distinct client profile');
1131
+ const selectionPath = join(root, 'integrations.json');
1132
+ const selectionBytes = JSON.stringify(integrations);
1133
+ if (existsSync(selectionPath) && readFileSync(selectionPath, 'utf8') !== selectionBytes) throw new Error('Client installation has another integration selection; retain its settings or select a distinct profile');
1134
+ if (!existsSync(selectionPath)) writePrivateNew(selectionPath, selectionBytes);
1135
+ if (!existsSync(join(root, '.packages-ready'))) {
1136
+ if (hasGit) {
1137
+ const sourceRoot = join(root, `build-${randomUUID()}`);
1138
+ ensurePrivateDirectory(sourceRoot);
1139
+ try {
1140
+ await effects.run(process.execPath, [join(INSTALLER_ASSETS, 'scripts/build/build.mjs')], { cwd: root, env: { OURS_BUILD_ROOT: root, OURS_SOURCE_ROOT: sourceRoot, OURS_BUILD_PACKAGES: selected.join(',') } });
1141
+ } finally { rmSync(sourceRoot, { recursive: true, force: true }); }
1142
+ } else {
1143
+ writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'ours-native-clients', private: true, dependencies: Object.fromEntries(Object.entries(packages).map(([name, selection]) => [name, selection.version])) }), { mode: 0o600 });
1144
+ }
1145
+ await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: root });
1146
+ writePrivateNew(join(root, '.packages-ready'), 'ready\n');
1147
+ }
1148
+ // Local acquisition alone does not publish native commands. Use the user's
1149
+ // configured npm prefix and retained dependency closure, including on retry.
1150
+ for (const name of integrations.filter(name => name === 'fleet' || name === 'codex')) {
1151
+ await effects.run('npm', ['install', '--global', '--install-links=false', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', join(root, 'node_modules', '@ours.network', name)]);
1152
+ }
1153
+ const localPackages = Object.fromEntries(integrations.filter(n => n !== 'fleet').map(name => [name, join(root, 'node_modules', '@ours.network', name)]));
1154
+ return { localPackages, packages: {}, fleetBin: integrations.includes('fleet') ? join(root, 'node_modules/.bin/ours-fleet') : null };
1155
+ },
1156
+ async prepareClientMarketplace(name, packagePath) {
1157
+ const root = join(dirname(dirname(dirname(packagePath))), 'marketplaces', name);
1158
+ const plugin = join(root, 'plugins', 'ours');
1159
+ if (!existsSync(plugin)) {
1160
+ mkdirSync(dirname(plugin), { recursive: true, mode: 0o700 });
1161
+ cpSync(packagePath, plugin, { recursive: true });
1162
+ const manifestPath = join(plugin, 'package.json');
1163
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
1164
+ for (const dependency of ['sdk', 'cli']) {
1165
+ const name = `@ours.network/${dependency}`;
1166
+ if (manifest.dependencies?.[name]) manifest.dependencies[name] = `file:${join(dirname(packagePath), dependency)}`;
1167
+ }
1168
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
1169
+ }
1170
+ // Native caches copy plugin contents; local SDK/CLI dependencies must not
1171
+ // remain links to acquisition paths. Repeating setup also repairs an interrupted install.
1172
+ await effects.run('npm', ['install', '--install-links', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: plugin });
1173
+ const value = name === 'codex'
1174
+ ? { name: 'ours-codex-marketplace', plugins: [{ name: 'ours', source: { source: 'local', path: './plugins/ours' }, policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' }, category: 'Productivity' }] }
1175
+ : { name: 'ours.network', owner: { name: 'Adapt Toolkit' }, plugins: [{ name: 'ours', source: './plugins/ours' }] };
1176
+ const manifestPath = name === 'codex' ? join(root, '.agents/plugins/marketplace.json') : join(root, '.claude-plugin/marketplace.json');
1177
+ effects.writeJson(manifestPath, JSON.stringify(value, null, 2) + '\n');
1178
+ return root;
1179
+ },
1180
+ async verifyPackagedMcp(configPath) {
1181
+ const profile = typeof configPath === 'string' ? readHostProfileFile(configPath) : validateHostProfile(configPath);
1182
+ assertPrivateRegularFile(profile.credentialPath, 'credential');
1183
+ const token = readFileSync(profile.credentialPath, 'utf8').trim();
1184
+ const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'x-ours-api-token': token, 'x-ours-session-mode': 'external', 'x-ours-lease-token': randomUUID() };
1185
+ const request = async (method, params, id) => {
1186
+ const response = await fetch(`${profile.endpoint}/mcp`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(5000), headers, body: JSON.stringify({ jsonrpc: '2.0', ...(id === undefined ? {} : { id }), method, params }) });
1187
+ const session = response.headers.get('mcp-session-id');
1188
+ if (session) headers['mcp-session-id'] = session;
1189
+ if (!response.ok) throw new Error(`Packaged MCP verification failed: HTTP ${response.status}`);
1190
+ if (id === undefined) { await response.body?.cancel(); return; }
1191
+ const text = await response.text();
1192
+ const messages = response.headers.get('content-type')?.includes('text/event-stream')
1193
+ ? text.split('\n').filter(line => line.startsWith('data:')).map(line => JSON.parse(line.slice(5)))
1194
+ : [JSON.parse(text)];
1195
+ const message = messages.find(value => value.id === id);
1196
+ if (!message || message.error || !message.result) throw new Error('Packaged MCP returned no successful protocol result');
1197
+ return message.result;
1198
+ };
1199
+ try {
1200
+ const initialized = await request('initialize', { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'ours-install', version: '1' } }, 1);
1201
+ if (initialized.serverInfo?.name !== 'ours' || !initialized.protocolVersion) throw new Error('Selected MCP is not the packaged OURS server');
1202
+ headers['mcp-protocol-version'] = initialized.protocolVersion;
1203
+ await request('notifications/initialized', {}, undefined);
1204
+ const resources = await request('resources/list', {}, 2);
1205
+ if (!resources.resources?.some(resource => resource.uri === 'ours://application-identities')) throw new Error('Packaged network MCP identity resource is absent');
1206
+ const toolList = await request('tools/list', {}, 3);
1207
+ if (!toolList.tools?.some(tool => tool.name === 'list_identities')) throw new Error('Packaged OURS identity tools are absent');
1208
+ } finally {
1209
+ if (headers['mcp-session-id']) {
1210
+ const response = await fetch(`${profile.endpoint}/mcp`, { method: 'DELETE', redirect: 'error', signal: AbortSignal.timeout(5000), headers });
1211
+ await response.body?.cancel();
1212
+ if (!response.ok) throw new Error('MCP verification session could not be closed');
1213
+ }
1214
+ }
1215
+ },
1216
+ };
1217
+ }
1218
+
1219
+ async function nativeLifecycle(record, operation, selected, { effects, localEnv, ownerCommand, bin, stopSelections }) {
1220
+ const linux = effects.platform.platform === 'linux';
1221
+ const daemonState = installationPaths(record).daemon;
1222
+ const daemonService = linux ? unitNameForStateDir(daemonState) : launchdLabelForStateDir(daemonState);
1223
+ if (!daemonService.ok) throw new Error('Cannot derive selected daemon service');
1224
+ const messenger = messengerServicePlan(record, effects.platform.platform, effects.home, bin(record, 'ours-messenger-server'), localEnv(record, 'messenger'), process.getuid());
1225
+ const plans = {
1226
+ daemon: {
1227
+ name: linux ? daemonService.unit : daemonService.label,
1228
+ path: linux
1229
+ ? join(effects.home, '.config/systemd/user', daemonService.unit)
1230
+ : join(effects.home, 'Library/LaunchAgents', `${daemonService.label}.plist`),
1231
+ },
1232
+ telegram: { name: linux ? 'ours-telegram.service' : 'solutions.adaptframework.ours-telegram', state: installationPaths(record).telegram },
1233
+ cowork: { name: linux ? 'ours-cowork.service' : 'network.ours.cowork', state: installationPaths(record).cowork },
1234
+ messenger,
1235
+ };
1236
+ for (const [service, plan] of Object.entries(plans)) {
1237
+ plan.path ??= linux ? join(effects.home, '.config/systemd/user', plan.name) : join(effects.home, 'Library/LaunchAgents', `${plan.name}.plist`);
1238
+ if (service === 'daemon') continue;
1239
+ if (existsSync(plan.path)) {
1240
+ const text = readFileSync(plan.path, 'utf8');
1241
+ const allowedStates = stopSelections?.map(selection => installationPaths(selection)[service]) ?? [plan.state];
1242
+ if (service === 'messenger' ? !text.includes(messenger.marker) : !allowedStates.includes(consumerServiceState(text, service, effects.platform.platform))) throw new Error(`Refusing unrelated existing ${service} service: ${plan.path}`);
1243
+ }
1244
+ }
1245
+ const manager = async (service, op) => {
1246
+ const plan = plans[service];
1247
+ if (!existsSync(plan.path)) return;
1248
+ if (linux) return effects.run('systemctl', ['--user', op, plan.name]);
1249
+ if (op === 'stop') {
1250
+ const found = await effects.run('launchctl', ['print', `gui/${process.getuid()}/${plan.name}`], { allowCodes: [113] });
1251
+ if (found.code === 0) await effects.run('launchctl', ['bootout', `gui/${process.getuid()}`, plan.path]);
1252
+ } else {
1253
+ const found = await effects.run('launchctl', ['print', `gui/${process.getuid()}/${plan.name}`], { allowCodes: [113] });
1254
+ if (found.code !== 0) await effects.run('launchctl', ['bootstrap', `gui/${process.getuid()}`, plan.path]);
1255
+ await effects.run('launchctl', ['kickstart', `gui/${process.getuid()}/${plan.name}`]);
1256
+ }
1257
+ };
1258
+ const health = async service => {
1259
+ if (service === 'daemon') {
1260
+ const profilePath = join(installationPaths(record).mcp, 'profile.json');
1261
+ await effects.verifyHostProfile(profilePath);
1262
+ await effects.verifyPackagedMcp(profilePath);
1263
+ return;
1264
+ }
1265
+ if (service === 'cowork') { await ownerCommand(record, service, 'status'); return; }
1266
+ const endpoint = { telegram: 'http://127.0.0.1:3051/health', messenger: `http://127.0.0.1:${record.messengerPort}/api/healthz` }[service];
1267
+ const response = await fetch(endpoint, { redirect: 'error', signal: AbortSignal.timeout(3000) });
1268
+ if (!response.ok) throw new Error(`${service} health check failed`);
1269
+ };
1270
+ if (operation === 'status') {
1271
+ const running = [];
1272
+ for (const service of selected) {
1273
+ if (service === 'messenger') {
1274
+ if (!existsSync(messenger.path)) continue;
1275
+ const result = linux ? await effects.run('systemctl', ['--user', 'is-active', messenger.name], { allowCodes: [3, 4] }) : await effects.run('launchctl', ['print', `${messenger.domain}/${messenger.name}`], { allowCodes: [113] });
1276
+ if (result.code === 0 && (linux || /pid = \d+/.test(result.stdout))) running.push(service);
1277
+ } else {
1278
+ const result = await ownerCommand(record, service, 'status', { allowCodes: service === 'daemon' ? [3] : service === 'cowork' ? [6] : [1] });
1279
+ if (result.code === 0) running.push(service);
1280
+ }
1281
+ }
1282
+ return running;
1283
+ }
1284
+ if (operation === 'stop' || operation === 'retire') {
1285
+ for (const service of [...selected].reverse()) {
1286
+ if (operation === 'retire' && linux && service !== 'daemon' && existsSync(plans[service].path)) {
1287
+ await effects.run('systemctl', ['--user', 'disable', plans[service].name]);
1288
+ }
1289
+ if (service !== 'daemon') await manager(service, 'stop');
1290
+ else await ownerCommand(record, service, 'uninstall-service');
1291
+ if (service !== 'messenger') await ownerCommand(record, service, 'stop');
1292
+ if (service === 'daemon') {
1293
+ const stateDir = installationPaths(record).daemon;
1294
+ const derived = linux ? unitNameForStateDir(stateDir) : launchdLabelForStateDir(stateDir);
1295
+ if (!derived.ok) throw new Error('Cannot verify selected daemon service shutdown');
1296
+ const observed = linux
1297
+ ? await effects.run('systemctl', ['--user', 'is-active', derived.unit], { allowCodes: [3, 4] })
1298
+ : await effects.run('launchctl', ['print', `gui/${process.getuid()}/${derived.label}`], { allowCodes: [113] });
1299
+ if (observed.code === 0) throw new Error('Selected daemon service is still loaded or active; authority operation refused');
1300
+ }
1301
+ }
1302
+ if ((await nativeLifecycle(record, 'status', selected, { effects, localEnv, ownerCommand, bin, stopSelections })).length) throw new Error('Selected writers did not stop');
1303
+ if (operation === 'retire') {
1304
+ // Daemon registration removal belongs to its existing uninstall-service command.
1305
+ // Consumer definitions were checked against this installation before any stop.
1306
+ for (const service of selected.filter(service => service !== 'daemon')) {
1307
+ rmSync(plans[service].path, { force: true });
1308
+ }
1309
+ if (linux) await effects.run('systemctl', ['--user', 'daemon-reload']);
1310
+ }
1311
+ return;
1312
+ }
1313
+ const failures = [];
1314
+ for (const service of selected) {
1315
+ try {
1316
+ if (service === 'messenger') {
1317
+ if (!existsSync(messenger.path)) {
1318
+ mkdirSync(dirname(messenger.path), { recursive: true });
1319
+ writePrivateNew(messenger.path, messenger.text);
1320
+ if (linux) {
1321
+ await effects.run('systemctl', ['--user', 'daemon-reload']);
1322
+ await effects.run('systemctl', ['--user', 'enable', messenger.name]);
1323
+ }
1324
+ }
1325
+ await manager(service, 'start');
1326
+ } else if (service === 'daemon') {
1327
+ await ownerCommand(record, service, 'install-service');
1328
+ await manager(service, 'start');
1329
+ } else if (!existsSync(plans[service].path)) {
1330
+ await ownerCommand(record, service, 'install-service');
1331
+ } else await manager(service, 'start');
1332
+ let ready = false;
1333
+ // Restoring existing identities can take longer than 30 seconds.
1334
+ const readinessDeadline = Date.now() + 120_000;
1335
+ while (Date.now() < readinessDeadline) {
1336
+ try { await health(service); ready = true; break; } catch { await new Promise(resolve => setTimeout(resolve, 1000)); }
1337
+ }
1338
+ if (!ready) throw new Error('Application is not ready');
1339
+ } catch {
1340
+ if (service === 'daemon') throw new Error('Daemon and packaged MCP are not ready; consumers were not started');
1341
+ failures.push(service);
1342
+ }
1343
+ }
1344
+ if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check owning prerequisites; no identities were created.`);
1345
+ }