@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
@@ -24,14 +24,14 @@
24
24
  // now() -> number
25
25
 
26
26
  import { basename, dirname, join, resolve } from 'node:path';
27
- import { parseInstallArgs, resolveTarget, InstallUsageError } from './target.mjs';
28
- import { planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
27
+ import { parseNetworkArgs, validateHostProfile, parseInstallArgs, resolveTarget, resolveProfileSelection, profileEnv, InstallUsageError } from './target.mjs';
28
+ import { selectSourcePackages, validateInstallation, SERVER_SERVICES, planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
29
29
  import {
30
30
  COMPONENTS,
31
31
  planComponentSelection, planMcpAttachment, planTgAttachment, planCoworkAttachment,
32
32
  tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec, componentByKey,
33
33
  } from './components.mjs';
34
- import { planHarnessPlugins, planFleet, buildHandoffPromptV3, restartHints } from './extras.mjs';
34
+ import { planHarnessPlugins, planFleet, buildHandoffPromptV3, restartHints, shellQuote } from './extras.mjs';
35
35
  import { summarizeRun } from './rerun.mjs';
36
36
  import { configJournal, reportRollback } from './journal.mjs';
37
37
  import { detectDaemons, planDaemonSelection, resolveSelection } from './detect.mjs';
@@ -212,7 +212,7 @@ async function attempt(effects, dryRun, label, thunk) {
212
212
  */
213
213
  function pairFor(plan, target) {
214
214
  return plan && plan.env && Object.keys(plan.env).length > 0
215
- ? daemonEnv(target.stateDir, target.port)
215
+ ? target.mode === 'host-profile' ? profileEnv(target) : daemonEnv(target.stateDir, target.port)
216
216
  : undefined;
217
217
  }
218
218
 
@@ -815,7 +815,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
815
815
  */
816
816
  export async function runHarnessPhase(args, effects, { target, isDefaultStateDir, exactSuite = null }) {
817
817
  effects.out(heading('Harness plugins'));
818
- const detected = await effects.detectHarnesses();
818
+ const detected = (await effects.detectHarnesses()).filter(h => !args.clientIntegrations || args.clientIntegrations.includes(h.name));
819
819
  for (const h of detected) {
820
820
  if (h.status === 'ok') effects.out(ok(`'${h.command ?? h.name}' → ${h.detail ?? 'real program'} (its plugin can be installed)`));
821
821
  else if (h.status === 'alias') effects.out(warn(`'${h.command ?? h.name}' → ${h.detail} (I won't call it — manual steps below)`));
@@ -832,6 +832,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
832
832
  harnesses: detected.map((h) => ({ name: h.name, status: h.status })),
833
833
  stateDir: target.stateDir,
834
834
  isDefaultStateDir,
835
+ configPath: target.mode === 'host-profile' ? target.configPath : null,
835
836
  channel: args.channel,
836
837
  assumeYes: true,
837
838
  answers: {},
@@ -846,6 +847,12 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
846
847
  rows.push({ ...row, state: 'skipped', note: plan.reason });
847
848
  continue;
848
849
  }
850
+ if (plan.action === 'manual' && exactSuite?.localPackages?.[plan.name]) {
851
+ const root = await effects.prepareClientMarketplace(plan.name, exactSuite.localPackages[plan.name]);
852
+ effects.out(warn(`${plan.label} requires manual registration of exact local marketplace ${root}; ${target.managed ? 'the saved client default is retained' : `keep OURS_CONFIG=${target.configPath}`}.`));
853
+ rows.push({ ...row, state: 'failed', note: 'native executable unavailable' });
854
+ continue;
855
+ }
849
856
  if (plan.action === 'manual') {
850
857
  // NEVER a dead end: the plugin is still installable, by hand, and the run
851
858
  // says so instead of pretending the harness does not exist.
@@ -864,12 +871,28 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
864
871
  }
865
872
  effects.out(warn(`${plan.label} — ${plan.reason}; install it yourself with:`));
866
873
  for (const step of manual) effects.out(info(` ${step}`));
874
+ if (plan.envLine) effects.out(info(`Before later native launches, set: ${plan.envLine}`));
867
875
  rows.push({ ...row, state: 'skipped', note: plan.reason });
868
876
  continue;
869
877
  }
870
878
 
871
879
  let steps = plan.steps;
872
880
  let manual = plan.manual;
881
+ if (exactSuite?.localPackages?.[plan.name]) {
882
+ const registration = await effects.prepareClientMarketplace(plan.name, exactSuite.localPackages[plan.name]);
883
+ const steps = plan.name === 'codex'
884
+ ? [['codex', 'plugin', 'marketplace', 'add', registration], ['codex', 'plugin', 'add', 'ours@ours-codex-marketplace']]
885
+ : [['claude', 'plugin', 'marketplace', 'add', registration], ['claude', 'plugin', await effects.hasClaudePlugin() ? 'update' : 'install', 'ours@ours.network']];
886
+ let failed = false;
887
+ for (const step of steps) {
888
+ const outcome = await attempt(effects, false, step.join(' '), () => effects.run(step[0], step.slice(1), { env: profileEnv(target) }));
889
+ if (!outcome.ok) { failed = true; break; }
890
+ }
891
+ effects.out(info(target.managed ? `Native ${plan.name} launches use the saved client default.` : `Native ${plan.name} launches must retain OURS_CONFIG=${target.configPath}.`));
892
+ rows.push({ ...row, state: failed ? 'failed' : 'installed' });
893
+ continue;
894
+ }
895
+
873
896
  if (exactSuite && (plan.name === 'claude-code' || plan.name === 'codex')) {
874
897
  const isClaude = plan.name === 'claude-code';
875
898
  const root = isClaude ? markets.claudeRoot : markets.codexRoot;
@@ -916,33 +939,43 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
916
939
  return rows;
917
940
  }
918
941
 
919
- /** Install Fleet, initialize its host support, and stage a stopped starter config. */
942
+ /** Install Fleet and let its interactive wizard publish the owned configuration. */
920
943
  export async function runFleetPhase(args, effects, { target, isDefaultStateDir }) {
921
944
  effects.out(heading('ours-fleet (your always-online agent team)'));
922
945
  effects.out(info('This makes your harnesses PERSISTENT: they stop being just a terminal session and'));
923
946
  effects.out(info('become always-online agents that survive a reboot.'));
924
947
  const plan = planFleet({
925
- home: effects.home, stateDir: target.stateDir, isDefaultStateDir, wanted: true, channel: args.channel,
948
+ home: effects.home, stateDir: target.stateDir, isDefaultStateDir,
949
+ configPath: target.mode === 'host-profile' ? target.configPath : null,
950
+ settingsPath: args.fleetSettingsPath ?? null,
951
+ wanted: true, channel: args.channel,
926
952
  });
927
953
  if (plan.action === 'skip') {
928
954
  effects.out(info('skipped cleanly — re-run ours-install any time to add it.'));
929
955
  return { key: 'fleet', label: plan.label, state: 'skipped' };
930
956
  }
931
- const install = await attempt(effects, args.dryRun, plan.install.join(' '), () => effects.run(plan.install[0], plan.install.slice(1)));
932
- // Pass the complete daemon tuple to host initialization. The generated role
933
- // also carries OURS_CONFIG when the chosen daemon is non-default.
934
- const initEnv = daemonEnv(target.stateDir, target.port);
957
+ const install = args.acquiredFleet ? { ok: true } : await attempt(effects, args.dryRun, plan.install.join(' '), () => effects.run(plan.install[0], plan.install.slice(1)));
958
+ // Fleet owns its v2 manifest, roles, models and permissions. With no prepared
959
+ // settings, its wizard keeps the user's terminal while this invocation carries
960
+ // the selected daemon; --settings makes the same call strictly noninteractive.
961
+ const initEnv = target.mode === 'host-profile' ? profileEnv(target) : daemonEnv(target.stateDir, target.port);
962
+ const initRunner = args.fleetSettingsPath ? effects.run : effects.runInteractive;
935
963
  const init = install.ok
936
- ? await attempt(effects, args.dryRun, `${plan.init.join(' ')} (one-time host setup: units, dirs, linger)`, () => effects.run(plan.init[0], plan.init.slice(1), { env: initEnv }))
964
+ ? await attempt(effects, args.dryRun, `${plan.init.join(' ')} (${args.fleetSettingsPath ? 'prepared' : 'interactive'} Fleet configuration)`, async () => {
965
+ const result = await initRunner(args.acquiredFleet || plan.init[0], plan.init.slice(1), { env: initEnv });
966
+ if (!result?.ok) throw new Error(`ours-fleet init exited ${result?.code ?? 'without a status'}`);
967
+ return result;
968
+ })
937
969
  : install;
938
970
  if (!init.ok) {
939
- effects.out(info(`retry manually: ${plan.init.join(' ')}`));
971
+ const selection = target.mode === 'host-profile'
972
+ ? `OURS_CONFIG=${shellQuote(target.configPath)} ` : '';
973
+ effects.out(info(`retry manually: ${selection}${plan.init.map(shellQuote).join(' ')}`));
940
974
  return { key: 'fleet', label: plan.label, state: 'failed', note: 'ours-fleet init failed' };
941
975
  }
942
- if (effects.readText(plan.configPath) === null) {
943
- await perform(effects, args.dryRun, `write starter fleet config ${plan.configPath}`, () => effects.writeText(plan.configPath, plan.config));
944
- } else {
945
- effects.out(ok(`${plan.configPath} already exists — not touched`));
976
+ if (!args.dryRun && effects.readText(plan.configPath) === null) {
977
+ effects.out(warn(`Fleet initialization returned without publishing ${plan.configPath}; configuration was not published.`));
978
+ return { key: 'fleet', label: plan.label, state: 'failed', note: 'Fleet configuration was not published' };
946
979
  }
947
980
  effects.out(ok('ours-fleet installed and initialized; no fleet roles were started.'));
948
981
  if (plan.instruction) effects.out(info(plan.instruction));
@@ -1052,6 +1085,23 @@ export async function endScreen(args, effects, { summary, target, isDefaultState
1052
1085
  * never a refusal.
1053
1086
  */
1054
1087
  export async function runInstall(argv, effects) {
1088
+ if (!argv.length && effects.interactive) {
1089
+ const selection = await effects.askLine('Install server (packages/docker) or connect client (client/profile path): ', effects.readManagedClientProfile() ? 'client' : 'packages');
1090
+ if (['packages', 'docker'].includes(selection)) {
1091
+ const root = await effects.askLine('Private installation root: ', join(effects.home, '.ours-install'));
1092
+ argv = ['server', 'install', '--mode', selection, '--state-dir', root];
1093
+ } else argv = ['client', 'install', ...(selection === 'client' ? [] : ['--config', selection])];
1094
+ }
1095
+ if (['server', 'client'].includes(argv[0])) {
1096
+ try {
1097
+ const command = parseNetworkArgs(argv);
1098
+ if (command.role === 'server') return await runServerCommand(command, effects);
1099
+ return await runClientCommand(command, effects);
1100
+ } catch (error) {
1101
+ effects.out(warn(`ours-install: ${reason(error)}`));
1102
+ return EXIT_REFUSED;
1103
+ }
1104
+ }
1055
1105
  let args;
1056
1106
  try {
1057
1107
  args = parseInstallArgs(argv, effects.env, { home: effects.home });
@@ -1097,6 +1147,40 @@ export async function runInstall(argv, effects) {
1097
1147
  }
1098
1148
  effects.out(ok(`Release channel: ${exactSuite.channel} → exact lockstep suite v${exactSuite.version}`));
1099
1149
 
1150
+ let profileSelection;
1151
+ try {
1152
+ profileSelection = resolveProfileSelection({
1153
+ args, env: effects.env, home: effects.home, exists: effects.exists, readProfile: effects.readProfile,
1154
+ });
1155
+ } catch (error) {
1156
+ if (error instanceof InstallUsageError) {
1157
+ effects.out(warn(`ours: ${error.message}. Nothing was changed.`));
1158
+ return EXIT_REFUSED;
1159
+ }
1160
+ throw error;
1161
+ }
1162
+ if (profileSelection.mode === 'host-profile') {
1163
+ try {
1164
+ await effects.verifyHostProfile(profileSelection.configPath);
1165
+ } catch (error) {
1166
+ effects.out(warn(`External daemon validation failed: ${reason(error)}. Nothing was changed.`));
1167
+ return EXIT_REFUSED;
1168
+ }
1169
+ const target = { ...profileSelection, endpoint: profileSelection.profile.endpoint };
1170
+ effects.out(ok(`Validated external daemon at ${target.endpoint}; installing client attachments only.`));
1171
+ const summary = [];
1172
+ const mcp = await attempt(effects, args.dryRun, `npm i -g @ours.network/mcp@${exactSuite.packages.mcp}`,
1173
+ () => effects.run('npm', ['i', '-g', `@ours.network/mcp@${exactSuite.packages.mcp}`]));
1174
+ summary.push({ key: 'mcp', label: 'MCP client', state: mcp.ok ? 'installed' : 'failed' });
1175
+ summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir: false, exactSuite }));
1176
+ summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir: false }));
1177
+ effects.out(info('Telegram, cowork, messenger, voice, daemon state, and daemon services remain Compose-owned.'));
1178
+ const failed = summary.some((row) => row.state === 'failed');
1179
+ if (failed) effects.out(warn(`Client setup for external daemon ${target.endpoint} is incomplete; shared profile and credential kept.`));
1180
+ else effects.out(ok(`Client setup for external daemon ${target.endpoint} complete; shared profile and credential kept.`));
1181
+ return failed ? EXIT_REFUSED : EXIT_OK;
1182
+ }
1183
+
1100
1184
  // Which daemon, before anything is decided about it. Only args.stateDir can
1101
1185
  // change here; every refusal downstream is unaffected.
1102
1186
  effects.out(progress(2, 8, 'Choose one daemon', 'Reuse the only detected daemon or create one coherent shared target.'));
@@ -1147,7 +1231,7 @@ export async function runInstall(argv, effects) {
1147
1231
  summary.push(await runIdentityPhase(args, effects, { target, mcpReady }));
1148
1232
  effects.out(progress(6, 8, 'Wire detected harnesses', 'Install the ours plugin into each safe Claude Code, Codex, or Hermes installation.'));
1149
1233
  summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir, exactSuite }));
1150
- effects.out(progress(7, 8, 'Stage the fleet', 'Install Fleet and write a stopped coordinator + watchdog + health-loop starter config.'));
1234
+ effects.out(progress(7, 8, 'Configure the fleet', 'Install Fleet and run its native configuration wizard; roles remain stopped.'));
1151
1235
  summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir }));
1152
1236
  summary.push(await runVoicePhase(args, effects, { target, mcpReady }));
1153
1237
 
@@ -1162,3 +1246,166 @@ export async function runInstall(argv, effects) {
1162
1246
  await endScreen(args, effects, { summary, target, isDefaultStateDir, brokerUrl: args.brokerUrl });
1163
1247
  return EXIT_OK;
1164
1248
  }
1249
+
1250
+
1251
+ export async function runServerCommand(args, effects) {
1252
+ if (args.operation === 'status') return executeServerCommand(args, effects);
1253
+ return effects.withInstallationLock(args.stateDir, () => executeServerCommand(args, effects));
1254
+ }
1255
+
1256
+ async function executeServerCommand(args, effects) {
1257
+ const recordPath = join(args.stateDir, 'installation.json');
1258
+ let record = effects.readJson(recordPath);
1259
+ const existing = record !== null;
1260
+ if (existing) {
1261
+ record = validateInstallation(record, args.stateDir);
1262
+ if (args.mode && args.mode !== record.mode) throw new Error('Conflicting runtime mode; retained installation was not changed');
1263
+ if (args.operation !== 'update' && args.sources && (record.sourcePolicyHash
1264
+ ? effects.sourcePolicyHash(args.sources) !== record.sourcePolicyHash
1265
+ : effects.readText(args.sources) !== effects.readText(record.sourcesPath))) throw new Error('Conflicting source selection; use explicit server update');
1266
+ } else {
1267
+ if (args.operation !== 'install' || !args.mode) throw new Error('First server install requires --mode');
1268
+ record = effects.newInstallation(args.stateDir, args.mode);
1269
+ }
1270
+ if (record.layoutConversion && !['install', 'start', 'stop', 'status'].includes(args.operation)) {
1271
+ throw new Error('Layout conversion is incomplete; resume with server install or server start before changing state or authority');
1272
+ }
1273
+ if (record.buildTransition && !['stop', 'status', record.buildTransition.operation].includes(args.operation)) {
1274
+ throw new Error(`Server build activation is incomplete; repeat server ${record.buildTransition.operation} before other mutations`);
1275
+ }
1276
+ if ((!existing || args.operation === 'update') && !record.buildTransition) {
1277
+ const policy = args.sources ? effects.readJson(args.sources) : effects.packagedSourcePolicy();
1278
+ args.resolvedSources = await effects.resolveSourcePolicy(policy, 'server');
1279
+ }
1280
+ if (!existing && args.sources) record.sourcePolicyHash = effects.sourcePolicyHash(args.sources);
1281
+ await effects.serverPreflight(record, args.operation, {
1282
+ existing, sourcePath: args.sources ?? record.sourcesPath, sourceManifest: args.resolvedSources,
1283
+ });
1284
+ if (existing && (record.schema === 1 || record.layoutConversion)
1285
+ && ['install', 'start', 'restart', 'update', 'rebuild'].includes(args.operation)) {
1286
+ record = record.mode === 'docker'
1287
+ ? await effects.convertDockerInstallation(record, args.operation)
1288
+ : await effects.convertPackageInstallation(record, args.operation);
1289
+ if (['install', 'start', 'restart'].includes(args.operation)) {
1290
+ effects.out(ok(`Server ${args.operation} completed after layout conversion for ${record.root}`));
1291
+ return EXIT_OK;
1292
+ }
1293
+ }
1294
+ if (args.operation === 'stop' && record.layoutConversion) {
1295
+ await effects.stopPendingConversion(record);
1296
+ } else if (args.operation === 'install') {
1297
+ if (!existing) {
1298
+ await effects.initializeSelection(record, args.resolvedSources);
1299
+ effects.writeJson(recordPath, JSON.stringify(record, null, 2) + '\n');
1300
+ }
1301
+ await effects.prepareInstallation(record);
1302
+ // A repeated setup repairs delivery with the retained master and exact packages.
1303
+ await effects.serverLifecycle(record, 'stop');
1304
+ await effects.serverAccess(record, 'access-init', { migrate: !!args.migrate });
1305
+ await effects.serverAccess(record, 'access-issue');
1306
+ await effects.recordInstallationBuild(record);
1307
+ await effects.serverLifecycle(record, 'start');
1308
+ } else if (['backup', 'restore', 'reset'].includes(args.operation)) {
1309
+ await effects.serverMaintenance(record, args);
1310
+ } else if (['update', 'rebuild'].includes(args.operation)) {
1311
+ await effects.serverBuildTransition(record, args);
1312
+ } else if (args.operation === 'access-issue') {
1313
+ await effects.serverAccess(record, 'access-issue', { output: args.output });
1314
+ } else if (args.operation === 'access-replace') {
1315
+ const running = await effects.serverLifecycle(record, 'status');
1316
+ await effects.serverLifecycle(record, 'stop');
1317
+ await effects.serverAccess(record, 'access-replace');
1318
+ try {
1319
+ await effects.serverAccess(record, 'access-issue');
1320
+ await effects.serverLifecycle(record, 'start', running);
1321
+ } catch (error) {
1322
+ effects.out(warn(`The API master has changed; setup is incomplete: ${reason(error)}. Repeat server install to repair delivery without another replacement.`));
1323
+ return EXIT_REFUSED;
1324
+ }
1325
+ effects.out(info('Separately configured clients require newly issued replacement credentials.'));
1326
+ } else if (args.operation === 'restart') {
1327
+ const running = await effects.serverLifecycle(record, 'status');
1328
+ await effects.serverLifecycle(record, 'stop');
1329
+ await effects.serverLifecycle(record, 'start', running);
1330
+ } else {
1331
+ const running = await effects.serverLifecycle(record, args.operation);
1332
+ if (args.operation === 'status') effects.out(JSON.stringify({
1333
+ mode: record.mode, instanceId: record.instanceId, schema: record.schema, running,
1334
+ ...(record.buildTransition ? { buildTransition: record.buildTransition.phase } : {}),
1335
+ ...(record.layoutConversion ? {
1336
+ layoutConversion: record.schema === 1 ? 'preparation-pending' : 'activation-pending',
1337
+ } : {}),
1338
+ }));
1339
+ }
1340
+ effects.out(ok(`Server ${args.operation} completed for ${record.root}`));
1341
+ return EXIT_OK;
1342
+ }
1343
+
1344
+ async function runClientCommand(command, effects) {
1345
+ const managedPath = join(effects.home, '.ours-client', 'profile.json');
1346
+ const saved = effects.readManagedClientProfile();
1347
+ let configPath = command.config;
1348
+ if (!configPath && saved) {
1349
+ if (effects.interactive && !await effects.ask(`Reuse saved client server ${saved.endpoint}?`, true))
1350
+ throw new InstallUsageError('Saved client default retained; use an explicit prepared profile to validate replacement input');
1351
+ configPath = managedPath;
1352
+ }
1353
+ let profile;
1354
+ if (configPath) profile = validateHostProfile(effects.readProfile(configPath));
1355
+ else if (effects.interactive) {
1356
+ const endpoint = await effects.askLine('Server HTTP endpoint: ', 'http://127.0.0.1:3050');
1357
+ const credentialPath = await effects.askLine('Private issued-token file: ', '');
1358
+ if (!credentialPath) throw new InstallUsageError('Client setup requires an issued-token file');
1359
+ profile = await effects.discoverClientProfile(endpoint, credentialPath);
1360
+ }
1361
+ if (!profile) throw new InstallUsageError('client install requires a complete network profile; supply --config or use the interactive client setup');
1362
+ if (saved && (saved.endpoint !== profile.endpoint || saved.expectedInstanceId !== profile.expectedInstanceId))
1363
+ throw new InstallUsageError('Managed client already selects another server; existing default was not changed');
1364
+ for (const name of ['OURS_API_TOKEN', 'OURS_PORT', 'OURS_STATE_DIR', 'OURS_DAEMON_ID']) {
1365
+ if (effects.env[name]?.trim()) throw new InstallUsageError(`${name} conflicts with client profile selection`);
1366
+ }
1367
+ effects.out(info(`Selected server ${profile.endpoint} (instance ${profile.expectedInstanceId}).`));
1368
+ await effects.verifyHostProfile(configPath || profile);
1369
+ await effects.verifyPackagedMcp(configPath || profile);
1370
+ const settings = saved?.installer ?? (configPath ? effects.readJson(configPath)?.installer : undefined);
1371
+ const settingsBase = saved ? dirname(managedPath) : configPath ? dirname(configPath) : process.cwd();
1372
+ let integrations = settings?.integrations;
1373
+ if (!integrations && effects.interactive) {
1374
+ integrations = [];
1375
+ for (const name of ['codex', 'claude-code', 'fleet']) if (await effects.ask(`Install ${name}?`, name !== 'fleet')) integrations.push(name);
1376
+ }
1377
+ if (!Array.isArray(integrations) || !integrations.length || integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name)) || new Set(integrations).size !== integrations.length) throw new InstallUsageError('installer.integrations must select codex, claude-code and/or fleet');
1378
+ let fleetSettingsPath = settings?.fleetSettingsPath;
1379
+ if (fleetSettingsPath !== undefined && (typeof fleetSettingsPath !== 'string' || !fleetSettingsPath))
1380
+ throw new InstallUsageError('installer.fleetSettingsPath must be a non-empty path when supplied');
1381
+ if (fleetSettingsPath) fleetSettingsPath = resolve(settingsBase, fleetSettingsPath);
1382
+ const selectedClients = [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
1383
+ let sourcesPath = settings?.sourcesPath;
1384
+ let resolvedSources;
1385
+ if (saved) sourcesPath = resolve(settingsBase, sourcesPath);
1386
+ else {
1387
+ if (sourcesPath) sourcesPath = resolve(settingsBase, sourcesPath);
1388
+ const policy = sourcesPath ? effects.readJson(sourcesPath) : effects.packagedSourcePolicy();
1389
+ resolvedSources = await effects.resolveSourcePolicy(policy, 'client', selectedClients);
1390
+ }
1391
+ const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath });
1392
+ try {
1393
+ await effects.verifyHostProfile(imported.configPath);
1394
+ const exactSuite = await effects.acquireClientPackages(imported.configPath, imported.settings.sourcesPath, integrations);
1395
+ const args = { assumeYes: true, dryRun: false, channel: 'latest', clientIntegrations: integrations,
1396
+ acquiredFleet: exactSuite.fleetBin, fleetSettingsPath: imported.settings.fleetSettingsPath };
1397
+ const target = { mode: 'host-profile', managed: true, configPath: imported.configPath, profile: imported.profile, endpoint: imported.profile.endpoint };
1398
+ const summary = await runHarnessPhase(args, effects, { target, isDefaultStateDir: false, exactSuite });
1399
+ if (integrations.includes('fleet')) summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir: false }));
1400
+ const incomplete = integrations.filter(name => !summary.some(row => row.key === name && row.state === 'installed'));
1401
+ if (effects.env.OURS_CONFIG && resolve(effects.env.OURS_CONFIG) !== imported.configPath)
1402
+ effects.out(warn('This shell has an explicit OURS_CONFIG override. Unset it for new clients to use the saved default; installer did not edit your shell.'));
1403
+ effects.out(incomplete.length
1404
+ ? warn(`Client setup incomplete (${incomplete.join(', ')}); saved profile and settings retained. Re-run ours-install client install.`)
1405
+ : ok(`Client setup complete. New clients discover ${imported.configPath}; no OURS_CONFIG export is required.`));
1406
+ return incomplete.length ? EXIT_REFUSED : EXIT_OK;
1407
+ } catch (error) {
1408
+ effects.out(warn(`Client setup incomplete: ${reason(error)}. Saved profile and settings retained; re-run ours-install client install.`));
1409
+ return EXIT_REFUSED;
1410
+ }
1411
+ }
package/lib/plan.mjs CHANGED
@@ -4,7 +4,8 @@
4
4
  // injects file reads, and every function returns a PLAN the caller renders and
5
5
  // executes. Nothing here writes, spawns, or runs systemctl.
6
6
 
7
- import { join, resolve, basename } from 'node:path';
7
+ import { join, resolve, basename, dirname } from 'node:path';
8
+ import { valid, validRange, satisfies } from 'semver';
8
9
 
9
10
  export const CLI_UNIT_MARKER = '# Managed by @ours.network/cli';
10
11
  export const SYSTEMD_USER_DIR = ['.config', 'systemd', 'user'];
@@ -275,3 +276,195 @@ export function planDaemonSteps(target, { cliVersionChanged = false, cliStartedI
275
276
  steps.push({ id: 'service', label: 'install the boot service', command: serviceInstallCommand({ stateDir: dir }) });
276
277
  return steps;
277
278
  }
279
+
280
+ export const SERVER_PACKAGES = ['sdk', 'cli', 'mcp', 'tg-connector', 'cowork', 'messenger-server'].map(n => `@ours.network/${n}`);
281
+ export const SERVER_DEPENDENCIES = {
282
+ daemon: [], telegram: ['daemon'], cowork: ['daemon'], messenger: ['daemon'],
283
+ };
284
+ export const SERVER_SERVICES = Object.keys(SERVER_DEPENDENCIES);
285
+
286
+ export function maintenanceServices(record, domain) {
287
+ const selected = new Set(domain === 'server' ? record.services : [domain]);
288
+ for (const service of SERVER_SERVICES) {
289
+ if (SERVER_DEPENDENCIES[service].some(provider => selected.has(provider))) selected.add(service);
290
+ }
291
+ return record.services.filter(service => selected.has(service));
292
+ }
293
+
294
+ /** Validate exact supplied selections, without rewriting the source authority. */
295
+ export function selectSourcePackages(manifest, role, clients = []) {
296
+ const names = role === 'server' ? SERVER_PACKAGES : clients.map(n => `@ours.network/${n}`);
297
+ const result = {};
298
+ for (const name of names) {
299
+ const selected = manifest?.packages?.[name];
300
+ if (selected?.type === 'npm' && Object.keys(selected).length === 2 && valid(selected.version) !== null) {
301
+ result[name] = selected;
302
+ } else if (selected && Object.keys(selected).length === 1 && typeof selected.source === 'string') {
303
+ const source = manifest.sources?.[selected.source];
304
+ if (source?.type !== 'git' || typeof source.url !== 'string' || !source.url || !/^[0-9a-f]{40}$/.test(source.commit)) throw new Error(`Invalid exact Git selection for ${name}`);
305
+ result[name] = selected;
306
+ } else throw new Error(`Missing or non-exact source selection for ${name}`);
307
+ }
308
+ return result;
309
+ }
310
+
311
+ /** Resolve a packaged compatibility policy into a role-filtered exact selection. */
312
+ export async function resolveSourcePolicy(manifest, role, clients = [], resolveNpm) {
313
+ const names = role === 'server' ? SERVER_PACKAGES : clients.map(name => `@ours.network/${name}`);
314
+ const packages = {};
315
+ const sourceNames = new Set();
316
+ for (const name of names) {
317
+ const selected = manifest?.packages?.[name];
318
+ if (selected?.type === 'npm' && Object.keys(selected).length === 2 && typeof selected.version === 'string') {
319
+ if (valid(selected.version) !== null) packages[name] = selected;
320
+ else {
321
+ if (validRange(selected.version) === null) throw new Error(`Invalid npm source policy for ${name}`);
322
+ if (typeof resolveNpm !== 'function') throw new Error(`Cannot resolve npm source policy for ${name}`);
323
+ const version = await resolveNpm(name, selected.version);
324
+ if (valid(version) === null || !satisfies(version, selected.version)) throw new Error(`Resolved ${name}@${version} outside allowed range ${selected.version}`);
325
+ packages[name] = { type: 'npm', version };
326
+ }
327
+ } else if (selected && Object.keys(selected).length === 1 && typeof selected.source === 'string') {
328
+ const source = manifest.sources?.[selected.source];
329
+ if (source?.type !== 'git' || typeof source.url !== 'string' || !source.url || !/^[0-9a-f]{40}$/.test(source.commit)) throw new Error(`Invalid exact Git selection for ${name}`);
330
+ packages[name] = selected;
331
+ sourceNames.add(selected.source);
332
+ } else throw new Error(`Missing source policy for ${name}`);
333
+ }
334
+ const sources = Object.fromEntries([...sourceNames].map(name => [name, manifest.sources[name]]));
335
+ const exact = { ...(sourceNames.size ? { sources } : {}), packages };
336
+ selectSourcePackages(exact, role, clients);
337
+ return exact;
338
+ }
339
+
340
+ /** Installer-owned physical layout; package defaults remain unchanged. */
341
+ export function installationPaths(record) {
342
+ const root = record.root;
343
+ const shared = record.schema === 2;
344
+ const state = shared ? join(root, 'storage', 'state') : root;
345
+ const daemon = join(state, shared ? 'daemon' : 'data');
346
+ const credentials = Object.fromEntries(['telegram', 'cowork', 'messenger'].map(service => [
347
+ service, shared ? join(state, 'credentials', service, 'daemon-token') : join(root, 'credentials', `${service}-token`),
348
+ ]));
349
+ return {
350
+ state, daemon, mcp: join(state, 'mcp'), telegram: join(state, 'telegram'),
351
+ cowork: join(state, 'cowork'), messenger: join(state, 'messenger'), credentials,
352
+ config: shared ? join(daemon, 'config.json') : join(root, 'config.json'),
353
+ };
354
+ }
355
+
356
+ export function validateInstallation(record, root) {
357
+ if (!record || ![1, 2].includes(record.schema) || !['docker', 'packages'].includes(record.mode)
358
+ || record.root !== root || record.configPath !== installationPaths(record).config
359
+ || record.sourcesPath !== join(root, 'sources.json') || record.workDir !== join(root, 'runtime')
360
+ || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(record.instanceId ?? '')
361
+ || !/^ours-[a-z0-9]+$/.test(record.project ?? '')
362
+ || (record.sourcePolicyHash !== undefined && !/^[0-9a-f]{64}$/.test(record.sourcePolicyHash))
363
+ || !Array.isArray(record.services) || record.services[0] !== 'daemon' || new Set(record.services).size !== record.services.length || record.services.some(s => !SERVER_SERVICES.includes(s))) {
364
+ throw new Error('Invalid or conflicting installation selection');
365
+ }
366
+ if (record.layoutConversion !== undefined) {
367
+ // DEPRECATED (introduced in 2.0): legacy managed-layout conversion only.
368
+ // Removal target: 3.0, after supported installs convert and upgrade inputs
369
+ // no longer need this reader. Retain backups and supported archive import.
370
+ const conversion = record.layoutConversion;
371
+ const source = conversion?.sourceRecord;
372
+ if (conversion?.version !== 1 || source?.schema !== 1 || source.layoutConversion !== undefined
373
+ || typeof conversion.backupPath !== 'string'
374
+ || dirname(conversion.backupPath) !== join(root, 'storage', 'backups')
375
+ || resolve(conversion.backupPath) !== conversion.backupPath
376
+ || !Array.isArray(conversion.runningServices)
377
+ || new Set(conversion.runningServices).size !== conversion.runningServices.length) {
378
+ throw new Error('Invalid layout conversion record');
379
+ }
380
+ validateInstallation(source, root);
381
+ if (['mode', 'instanceId', 'project', 'sourcesPath', 'workDir'].some(key => source[key] !== record[key])
382
+ || JSON.stringify(source.services) !== JSON.stringify(record.services)
383
+ || conversion.runningServices.some(service => !source.services.includes(service))) {
384
+ throw new Error('Conflicting layout conversion source');
385
+ }
386
+ }
387
+ if (record.buildTransition !== undefined) {
388
+ const transition = record.buildTransition, candidate = transition?.candidate;
389
+ if (record.schema !== 2 || record.layoutConversion || !candidate
390
+ || candidate.buildTransition !== undefined || candidate.layoutConversion !== undefined
391
+ || dirname(candidate.root ?? '') !== root || !/^\.build-[A-Za-z0-9]{6}$/.test(basename(candidate.root ?? ''))
392
+ || !/^ours-build[0-9a-f]{32}$/.test(candidate.project ?? '')
393
+ || !['update', 'rebuild'].includes(transition.operation)
394
+ || !['prepared', 'state-updated', 'runtime-activated'].includes(transition.phase)
395
+ || typeof transition.compatible !== 'boolean'
396
+ || (transition.sourcePolicyHash !== undefined && !/^[0-9a-f]{64}$/.test(transition.sourcePolicyHash))
397
+ || !Array.isArray(transition.runningServices)
398
+ || new Set(transition.runningServices).size !== transition.runningServices.length
399
+ || transition.runningServices.some(service => !record.services.includes(service))) {
400
+ throw new Error('Invalid server build transition');
401
+ }
402
+ validateInstallation(candidate, candidate.root);
403
+ for (const key of ['schema', 'mode', 'instanceId', 'services', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
404
+ if (JSON.stringify(candidate[key]) !== JSON.stringify(record[key])) throw new Error('Conflicting server build candidate');
405
+ }
406
+ }
407
+ return record;
408
+ }
409
+
410
+ /** Messenger exposes serve only; these definitions contain no credentials. */
411
+ export function messengerServicePlan(record, platform, home, executable, environment, uid) {
412
+ const marker = `Managed by @ours.network/install; state=${record.root}`;
413
+ const name = `${record.project}-messenger`;
414
+ if ([record.root, executable, ...Object.values(environment)].some(v => /[\n\r\0]/.test(String(v)))) throw new Error('Service values must not contain control characters');
415
+ if (platform === 'linux') {
416
+ const escape = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`;
417
+ return { path: join(home, '.config/systemd/user', `${name}.service`), marker: `# ${marker}`, name: `${name}.service`, text: `# ${marker}\n[Unit]\nDescription=OURS Messenger\nAfter=network-online.target\n[Service]\nType=simple\nExecStart=${escape(executable)} serve\n${Object.entries(environment).map(([k, v]) => `Environment=${escape(`${k}=${v}`)}`).join('\n')}\nRestart=on-failure\nRestartSec=2\n[Install]\nWantedBy=default.target\n` };
418
+ }
419
+ if (platform !== 'darwin') throw new Error('Messenger requires systemd-user or launchd');
420
+ const xml = value => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
421
+ const label = `network.ours.${name}`;
422
+ return { path: join(home, 'Library/LaunchAgents', `${label}.plist`), marker: `<!-- Managed by @ours.network/install; selection=${record.project} -->`, name: label, domain: `gui/${uid}`, text: `<?xml version="1.0" encoding="UTF-8"?>\n<!-- Managed by @ours.network/install; selection=${record.project} -->\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${xml(label)}</string><key>ProgramArguments</key><array><string>${xml(executable)}</string><string>serve</string></array><key>EnvironmentVariables</key><dict>${Object.entries(environment).map(([k, v]) => `<key>${xml(k)}</key><string>${xml(v)}</string>`).join('')}</dict><key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict></dict></plist>\n` };
423
+ }
424
+
425
+ /** Read the owning field emitted by the existing consumer service recipes.
426
+ * Unknown/ambiguous definitions are not evidence of ownership. In particular,
427
+ * comments, another key, or a longer state path never authorize manager calls.
428
+ */
429
+ export function consumerServiceState(text, service, platform) {
430
+ const key = { telegram: 'OURS_TG_STATE_DIR', cowork: 'OURS_COWORK_STATE_DIR' }[service];
431
+ if (!key || typeof text !== 'string') return undefined;
432
+ if (platform === 'darwin') {
433
+ const clean = text.replace(/<!--[\s\S]*?-->/g, '');
434
+ if (clean.includes('<!--')) return undefined;
435
+ const dictionaries = [...clean.matchAll(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/g)];
436
+ if (dictionaries.length !== 1) return undefined;
437
+ const fields = [...dictionaries[0][1].matchAll(new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`, 'g'))];
438
+ if (fields.length !== 1) return undefined;
439
+ const raw = fields[0][1];
440
+ if (/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/.test(raw)) return undefined;
441
+ try {
442
+ return raw.replace(/&(amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);/g, (_, entity) => {
443
+ if (entity.startsWith('#')) return String.fromCodePoint(Number.parseInt(entity.slice(entity[1] === 'x' ? 2 : 1), entity[1] === 'x' ? 16 : 10));
444
+ return { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }[entity];
445
+ });
446
+ } catch { return undefined; }
447
+ }
448
+ if (platform !== 'linux') return undefined;
449
+ let section = '', selected;
450
+ let matches = 0;
451
+ for (const source of text.split('\n')) {
452
+ const line = source.trim();
453
+ if (!line || /^[#;]/.test(line)) continue;
454
+ if (/^\[.*\]$/.test(line)) { section = line; continue; }
455
+ if (section !== '[Service]') continue;
456
+ if (/^(EnvironmentFile|UnsetEnvironment)\s*=/.test(line)) return undefined;
457
+ const assignment = /^Environment\s*=(.*)$/.exec(line);
458
+ if (!assignment) continue;
459
+ let value = assignment[1].trim();
460
+ if (!value) { selected = undefined; matches = 0; continue; }
461
+ if (value.startsWith('"')) {
462
+ if (!/^"(?:[^"\\]|\\[\\"])*"$/.test(value)) return undefined;
463
+ value = value.slice(1, -1).replace(/\\([\\"])/g, '$1');
464
+ } else if (/[\s"\\]/.test(value)) return undefined;
465
+ if (/%(?!%)/.test(value.replaceAll('%%', ''))) return undefined;
466
+ value = value.replaceAll('%%', '%');
467
+ if (value.startsWith(`${key}=`)) { selected = value.slice(key.length + 1); matches++; }
468
+ }
469
+ return matches === 1 ? selected : undefined;
470
+ }