openmates 0.12.0-alpha.23 → 0.12.0-alpha.25

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.
@@ -921,8 +921,15 @@ function removeServerConfig() {
921
921
  }
922
922
  var SOURCE_COMPOSE_MARKER = join2("backend", "core", "docker-compose.yml");
923
923
  var IMAGE_COMPOSE_MARKER = join2("backend", "core", "docker-compose.selfhost.yml");
924
+ var UPLOAD_COMPOSE_MARKER = join2("backend", "upload", "docker-compose.yml");
925
+ var PREVIEW_COMPOSE_MARKER = join2("backend", "preview", "docker-compose.preview.yml");
924
926
  function isOpenMatesDir(dir) {
925
- return existsSync3(join2(dir, SOURCE_COMPOSE_MARKER)) || existsSync3(join2(dir, IMAGE_COMPOSE_MARKER));
927
+ return [
928
+ SOURCE_COMPOSE_MARKER,
929
+ IMAGE_COMPOSE_MARKER,
930
+ UPLOAD_COMPOSE_MARKER,
931
+ PREVIEW_COMPOSE_MARKER
932
+ ].some((marker) => existsSync3(join2(dir, marker)));
926
933
  }
927
934
  function resolveServerPath(flags) {
928
935
  if (typeof flags.path === "string" && flags.path) {
@@ -2052,6 +2059,55 @@ function getClientMessagesVersionForSync(cached) {
2052
2059
  if (cached.messages.length === 0) return 0;
2053
2060
  return typeof cached.details.messages_v === "number" ? cached.details.messages_v : 0;
2054
2061
  }
2062
+ var INTEREST_TAG_IDS = [
2063
+ "software_development",
2064
+ "use_the_cli",
2065
+ "open_source",
2066
+ "read_developer_docs",
2067
+ "run_code",
2068
+ "protect_my_privacy",
2069
+ "summarize_documents",
2070
+ "find_apartments",
2071
+ "local_life",
2072
+ "learn_anything"
2073
+ ];
2074
+ var TOPIC_PREFERENCES_SETTINGS_KEY = "topic_preferences";
2075
+ function normalizeInterestTagIds(values) {
2076
+ const validIds = new Set(INTEREST_TAG_IDS);
2077
+ const normalized = [];
2078
+ for (const value of values) {
2079
+ if (!validIds.has(value)) {
2080
+ throw new Error(
2081
+ `Unknown interest tag '${value}'. Use one of: ${INTEREST_TAG_IDS.join(", ")}`
2082
+ );
2083
+ }
2084
+ if (!normalized.includes(value)) {
2085
+ normalized.push(value);
2086
+ }
2087
+ }
2088
+ return normalized;
2089
+ }
2090
+ function normalizeTopicPreferencesPayload(value) {
2091
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2092
+ return null;
2093
+ }
2094
+ const candidate = value;
2095
+ if (candidate.version !== 1 || !Array.isArray(candidate.selectedTagIds)) {
2096
+ return null;
2097
+ }
2098
+ const validIds = new Set(INTEREST_TAG_IDS);
2099
+ const selectedTagIds = [];
2100
+ for (const value2 of candidate.selectedTagIds) {
2101
+ if (validIds.has(value2) && !selectedTagIds.includes(value2)) {
2102
+ selectedTagIds.push(value2);
2103
+ }
2104
+ }
2105
+ return {
2106
+ version: 1,
2107
+ selectedTagIds,
2108
+ updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : (/* @__PURE__ */ new Date(0)).toISOString()
2109
+ };
2110
+ }
2055
2111
  function buildSubChatConfirmationPayload(params) {
2056
2112
  return {
2057
2113
  chat_id: params.chatId,
@@ -2987,6 +3043,31 @@ var OpenMatesClient = class _OpenMatesClient {
2987
3043
  saveSession(session);
2988
3044
  return response.data.user ?? {};
2989
3045
  }
3046
+ async getTopicPreferences() {
3047
+ const user = await this.whoAmI();
3048
+ return await this.decryptTopicPreferences(user.encrypted_settings);
3049
+ }
3050
+ async setTopicPreferences(selectedTagIds) {
3051
+ const user = await this.whoAmI();
3052
+ const settings = await this.decryptSettingsRecord(user.encrypted_settings);
3053
+ const payload = {
3054
+ version: 1,
3055
+ selectedTagIds: normalizeInterestTagIds(selectedTagIds),
3056
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3057
+ };
3058
+ settings[TOPIC_PREFERENCES_SETTINGS_KEY] = payload;
3059
+ const encryptedSettings = await encryptWithAesGcmCombined(
3060
+ JSON.stringify(settings),
3061
+ this.getMasterKeyBytes()
3062
+ );
3063
+ await this.settingsPost("topic-preferences", {
3064
+ encrypted_settings: encryptedSettings
3065
+ });
3066
+ return payload;
3067
+ }
3068
+ async clearTopicPreferences() {
3069
+ return await this.setTopicPreferences([]);
3070
+ }
2990
3071
  async getLearningModeStatus() {
2991
3072
  this.requireSession();
2992
3073
  const response = await this.http.get(
@@ -3335,8 +3416,8 @@ var OpenMatesClient = class _OpenMatesClient {
3335
3416
  );
3336
3417
  let msgEmbedIds = [];
3337
3418
  if (clientMsgId && cache.embeds.length > 0) {
3338
- const { createHash: createHash5 } = await import("crypto");
3339
- const hashed = createHash5("sha256").update(clientMsgId).digest("hex");
3419
+ const { createHash: createHash6 } = await import("crypto");
3420
+ const hashed = createHash6("sha256").update(clientMsgId).digest("hex");
3340
3421
  msgEmbedIds = cache.embeds.filter(
3341
3422
  (e) => e.hashed_message_id === hashed && // Only include parent embeds (no parent_embed_id).
3342
3423
  // Child embeds inherit the parent's key and are loaded
@@ -3383,8 +3464,8 @@ var OpenMatesClient = class _OpenMatesClient {
3383
3464
  );
3384
3465
  }
3385
3466
  const embedId = String(embed.embed_id ?? embed.id ?? "");
3386
- const { createHash: createHash5 } = await import("crypto");
3387
- const hashedEmbedId = createHash5("sha256").update(embedId).digest("hex");
3467
+ const { createHash: createHash6 } = await import("crypto");
3468
+ const hashedEmbedId = createHash6("sha256").update(embedId).digest("hex");
3388
3469
  const embedKeyBytes = await this.resolveEmbedKey(
3389
3470
  cache,
3390
3471
  masterKey,
@@ -3492,7 +3573,7 @@ var OpenMatesClient = class _OpenMatesClient {
3492
3573
  async resolveEmbedKey(cache, masterKey, embed, embedId, hashedEmbedId, visited = /* @__PURE__ */ new Set()) {
3493
3574
  if (visited.has(embedId)) return null;
3494
3575
  visited.add(embedId);
3495
- const { createHash: createHash5 } = await import("crypto");
3576
+ const { createHash: createHash6 } = await import("crypto");
3496
3577
  const masterKeyEntry = cache.embedKeys.find(
3497
3578
  (ek) => ek.hashed_embed_id === hashedEmbedId && String(ek.key_type) === "master"
3498
3579
  );
@@ -3509,7 +3590,7 @@ var OpenMatesClient = class _OpenMatesClient {
3509
3590
  if (chatKeyEntry && typeof chatKeyEntry.encrypted_embed_key === "string") {
3510
3591
  const hashedChatId = String(chatKeyEntry.hashed_chat_id ?? "");
3511
3592
  const owningChat = cache.chats.find((c) => {
3512
- const chatHash = createHash5("sha256").update(String(c.details.id ?? "")).digest("hex");
3593
+ const chatHash = createHash6("sha256").update(String(c.details.id ?? "")).digest("hex");
3513
3594
  return chatHash === hashedChatId;
3514
3595
  });
3515
3596
  if (owningChat) {
@@ -3538,7 +3619,7 @@ var OpenMatesClient = class _OpenMatesClient {
3538
3619
  const parentFullId = String(
3539
3620
  parentEmbed2.embed_id ?? parentEmbed2.id ?? ""
3540
3621
  );
3541
- const parentHashedId = createHash5("sha256").update(parentFullId).digest("hex");
3622
+ const parentHashedId = createHash6("sha256").update(parentFullId).digest("hex");
3542
3623
  const parentKey = await this.resolveEmbedKey(
3543
3624
  cache,
3544
3625
  masterKey,
@@ -5347,7 +5428,7 @@ Required: ${schema.required.join(", ")}`
5347
5428
  const session = this.requireSession();
5348
5429
  const masterKey = base64ToBytes(session.masterKeyExportedB64);
5349
5430
  const cache = await this.ensureSynced();
5350
- const { createHash: createHash5 } = await import("crypto");
5431
+ const { createHash: createHash6 } = await import("crypto");
5351
5432
  const embed = cache.embeds.find(
5352
5433
  (e) => String(e.embed_id ?? "").startsWith(embedIdOrShort) || String(e.id ?? "").startsWith(embedIdOrShort)
5353
5434
  );
@@ -5355,7 +5436,7 @@ Required: ${schema.required.join(", ")}`
5355
5436
  throw new Error(`Embed '${embedIdOrShort}' not found in local cache.`);
5356
5437
  }
5357
5438
  const embedId = String(embed.embed_id ?? embed.id ?? "");
5358
- const hashedEmbedId = createHash5("sha256").update(embedId).digest("hex");
5439
+ const hashedEmbedId = createHash6("sha256").update(embedId).digest("hex");
5359
5440
  const embedKeyBytes = await this.resolveEmbedKey(
5360
5441
  cache,
5361
5442
  masterKey,
@@ -5415,8 +5496,8 @@ Required: ${schema.required.join(", ")}`
5415
5496
  if (!embed) {
5416
5497
  throw new Error(`Embed '${embedId}' not found in local cache. Run 'openmates chats list' to sync first.`);
5417
5498
  }
5418
- const { createHash: createHash5 } = await import("crypto");
5419
- const hashedEmbedId = createHash5("sha256").update(embedId).digest("hex");
5499
+ const { createHash: createHash6 } = await import("crypto");
5500
+ const hashedEmbedId = createHash6("sha256").update(embedId).digest("hex");
5420
5501
  const embedKey = await this.resolveEmbedKey(
5421
5502
  cache,
5422
5503
  masterKey,
@@ -5510,8 +5591,8 @@ Required: ${schema.required.join(", ")}`
5510
5591
  if (!embed) {
5511
5592
  throw new Error(`Embed '${embedId}' not found in local cache. Run 'openmates chats list' to sync first.`);
5512
5593
  }
5513
- const { createHash: createHash5 } = await import("crypto");
5514
- const hashedEmbedId = createHash5("sha256").update(embedId).digest("hex");
5594
+ const { createHash: createHash6 } = await import("crypto");
5595
+ const hashedEmbedId = createHash6("sha256").update(embedId).digest("hex");
5515
5596
  const embedKey = await this.resolveEmbedKey(
5516
5597
  cache,
5517
5598
  masterKey,
@@ -5649,6 +5730,27 @@ Required: ${schema.required.join(", ")}`
5649
5730
  const session = this.requireSession();
5650
5731
  return base64ToBytes(session.masterKeyExportedB64);
5651
5732
  }
5733
+ async decryptTopicPreferences(encryptedSettings) {
5734
+ const settings = await this.decryptSettingsRecord(encryptedSettings);
5735
+ return normalizeTopicPreferencesPayload(settings[TOPIC_PREFERENCES_SETTINGS_KEY]);
5736
+ }
5737
+ async decryptSettingsRecord(encryptedSettings) {
5738
+ if (typeof encryptedSettings !== "string" || encryptedSettings.length === 0) {
5739
+ return {};
5740
+ }
5741
+ const decrypted = await decryptWithAesGcmCombined(
5742
+ encryptedSettings,
5743
+ this.getMasterKeyBytes()
5744
+ );
5745
+ if (!decrypted) {
5746
+ throw new Error("Failed to decrypt encrypted account settings.");
5747
+ }
5748
+ const parsed = JSON.parse(decrypted);
5749
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
5750
+ return {};
5751
+ }
5752
+ return parsed;
5753
+ }
5652
5754
  getValidSessionFromDisk() {
5653
5755
  const session = loadSession();
5654
5756
  if (!session) return null;
@@ -7372,6 +7474,8 @@ var DIRECT_TYPES = /* @__PURE__ */ new Set([
7372
7474
  "recording",
7373
7475
  "mail-email",
7374
7476
  "math-plot",
7477
+ "mindmap",
7478
+ "mindmaps-mindmap",
7375
7479
  "events-event",
7376
7480
  "health-appointment",
7377
7481
  "shopping-product",
@@ -7399,6 +7503,8 @@ var DIRECT_TYPE_LABELS = {
7399
7503
  "recording": "recording",
7400
7504
  "mail-email": "email",
7401
7505
  "math-plot": "plot",
7506
+ "mindmap": "Mind Map",
7507
+ "mindmaps-mindmap": "Mind Map",
7402
7508
  "events-event": "event",
7403
7509
  "health-appointment": "appointment",
7404
7510
  "shopping-product": "product",
@@ -8355,6 +8461,20 @@ function renderByDirectType(embed, c, ln) {
8355
8461
  ln("\x1B[2m[mathematical plot]\x1B[0m");
8356
8462
  break;
8357
8463
  }
8464
+ case "mindmap":
8465
+ case "mindmaps-mindmap": {
8466
+ const document = mindMapDocumentFromContent(c);
8467
+ const title = str(c.title) ?? str(document?.title) ?? "Mind Map";
8468
+ const nodeCount = typeof c.node_count === "number" ? c.node_count : document?.nodes.length;
8469
+ const edgeCount = typeof c.edge_count === "number" ? c.edge_count : document?.edges?.length ?? 0;
8470
+ ln(title);
8471
+ if (nodeCount !== void 0) ln(`\x1B[2m${nodeCount} nodes \xB7 ${edgeCount} edges\x1B[0m`);
8472
+ const outline = document ? mindMapOutline(document, 8) : "";
8473
+ if (outline) {
8474
+ for (const line of outline.split("\n")) ln(line);
8475
+ }
8476
+ break;
8477
+ }
8358
8478
  case "images-image-result": {
8359
8479
  const title = str(c.title) ?? "";
8360
8480
  const source = str(c.source) ?? str(c.url) ?? "";
@@ -8520,6 +8640,24 @@ function renderDirectTypeFullscreen(embed, c) {
8520
8640
  }
8521
8641
  break;
8522
8642
  }
8643
+ case "mindmap":
8644
+ case "mindmaps-mindmap": {
8645
+ const document = mindMapDocumentFromContent(c);
8646
+ const title = str(c.title) ?? str(document?.title) ?? "Mind Map";
8647
+ process.stdout.write(`\x1B[1m${title}\x1B[0m
8648
+ `);
8649
+ if (!document) {
8650
+ console.log("Invalid mind map JSON");
8651
+ break;
8652
+ }
8653
+ console.log(`${document.nodes.length} nodes \xB7 ${document.edges?.length ?? 0} edges
8654
+ `);
8655
+ console.log(mindMapOutline(document));
8656
+ console.log("\n```openmates_mindmap");
8657
+ console.log(JSON.stringify(document, null, 2));
8658
+ console.log("```");
8659
+ break;
8660
+ }
8523
8661
  default: {
8524
8662
  for (const [k, v] of Object.entries(c)) {
8525
8663
  if (v === null || v === void 0 || k.startsWith("_")) continue;
@@ -8546,6 +8684,49 @@ function resolveResultCount(c) {
8546
8684
  if (ids.length > 0) return ids.length;
8547
8685
  return null;
8548
8686
  }
8687
+ function mindMapDocumentFromContent(c) {
8688
+ const model = c.model;
8689
+ if (isMindMapDocument(model)) return model;
8690
+ const source = str(c.source_json);
8691
+ if (!source) return null;
8692
+ try {
8693
+ const parsed = JSON.parse(source);
8694
+ return isMindMapDocument(parsed) ? parsed : null;
8695
+ } catch {
8696
+ return null;
8697
+ }
8698
+ }
8699
+ function isMindMapDocument(value) {
8700
+ if (!isRecord(value)) return false;
8701
+ return value.openmatesType === "mindmap" && typeof value.schemaVersion === "number" && typeof value.title === "string" && typeof value.rootId === "string" && Array.isArray(value.nodes);
8702
+ }
8703
+ function mindMapOutline(document, maxNodes = 100) {
8704
+ const nodesById = /* @__PURE__ */ new Map();
8705
+ for (const node of document.nodes) {
8706
+ if (typeof node.id === "string" && typeof node.label === "string") {
8707
+ nodesById.set(node.id, node);
8708
+ }
8709
+ }
8710
+ const lines = [];
8711
+ const visited = /* @__PURE__ */ new Set();
8712
+ const visit = (nodeId, depth) => {
8713
+ if (visited.size >= maxNodes || visited.has(nodeId)) return;
8714
+ const node = nodesById.get(nodeId);
8715
+ if (!node) return;
8716
+ visited.add(nodeId);
8717
+ lines.push(`${" ".repeat(depth)}- ${node.label}`);
8718
+ for (const childId of node.children ?? []) visit(childId, depth + 1);
8719
+ };
8720
+ visit(document.rootId, 0);
8721
+ for (const node of document.nodes) {
8722
+ if (visited.size >= maxNodes) break;
8723
+ if (!visited.has(node.id)) visit(node.id, 0);
8724
+ }
8725
+ return lines.join("\n");
8726
+ }
8727
+ function isRecord(value) {
8728
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8729
+ }
8549
8730
  async function resolveChildResults(c, client) {
8550
8731
  const inline = c.results;
8551
8732
  if (Array.isArray(inline) && inline.length > 0) {
@@ -8574,14 +8755,211 @@ function formatTs(ts) {
8574
8755
 
8575
8756
  // src/server.ts
8576
8757
  import { execSync, spawn as nodeSpawn } from "child_process";
8577
- import { randomBytes as randomBytes2 } from "crypto";
8578
- import { copyFileSync, existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
8758
+ import { createHash as createHash5, randomBytes as randomBytes2 } from "crypto";
8759
+ import { chmodSync as chmodSync2, copyFileSync, cpSync, existsSync as existsSync5, mkdirSync as mkdirSync3, mkdtempSync, readFileSync as readFileSync5, readdirSync, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
8579
8760
  import { createInterface as createInterface2 } from "readline";
8580
8761
  import { createInterface as createPromptInterface } from "readline/promises";
8581
8762
  import { homedir as homedir5 } from "os";
8582
8763
  import { dirname, join as join3, resolve as resolve3 } from "path";
8764
+
8765
+ // src/serverPlanning.ts
8766
+ var CORE_WORKER_SERVICES = [
8767
+ "task-worker",
8768
+ "task-scheduler",
8769
+ "app-ai-worker",
8770
+ "app-images-worker",
8771
+ "app-music-worker",
8772
+ "app-videos-worker",
8773
+ "app-pdf-worker",
8774
+ "app-docs-worker",
8775
+ "app-code-worker",
8776
+ "app-social-media-worker"
8777
+ ];
8778
+ var CORE_OBSERVABILITY_BY_PROFILE = {
8779
+ minimal: [],
8780
+ standard: ["openobserve", "promtail"],
8781
+ production: ["openobserve", "promtail", "prometheus", "cadvisor"]
8782
+ };
8783
+ var ROLE_DEFINITIONS = {
8784
+ core: {
8785
+ dataBearing: true,
8786
+ requiredServices: ["api", "cms", "cms-database", "cache", "vault", "vault-setup", "cms-setup"],
8787
+ optionalServices: [...CORE_WORKER_SERVICES, "admin-sidecar", "webapp", "openobserve", "promtail", "prometheus", "cadvisor", "alertmanager"],
8788
+ healthChecks: ["http://localhost:8000/health"],
8789
+ templatePath: "templates/core/docker-compose.selfhost.yml",
8790
+ composeFile: "backend/core/docker-compose.selfhost.yml"
8791
+ },
8792
+ upload: {
8793
+ dataBearing: true,
8794
+ requiredServices: ["app-uploads", "clamav", "vault", "vault-setup", "admin-sidecar"],
8795
+ optionalServices: [],
8796
+ healthChecks: ["http://localhost:8000/health"],
8797
+ templatePath: "templates/upload/docker-compose.yml",
8798
+ composeFile: "backend/upload/docker-compose.yml"
8799
+ },
8800
+ preview: {
8801
+ dataBearing: false,
8802
+ requiredServices: ["preview", "admin-sidecar"],
8803
+ optionalServices: ["cache"],
8804
+ healthChecks: ["http://localhost:8080/health"],
8805
+ templatePath: "templates/preview/docker-compose.preview.yml",
8806
+ composeFile: "backend/preview/docker-compose.preview.yml"
8807
+ }
8808
+ };
8809
+ function unique(items) {
8810
+ return [...new Set(items.map((item) => item.trim()).filter(Boolean))];
8811
+ }
8812
+ function csv(value) {
8813
+ if (value === void 0) return [];
8814
+ if (Array.isArray(value)) return unique(value.flatMap((item) => item.split(",")));
8815
+ return unique(value.split(","));
8816
+ }
8817
+ function parseServerRole(value) {
8818
+ if (!value) return "core";
8819
+ if (value === "core" || value === "upload" || value === "preview") return value;
8820
+ throw new Error(`Unsupported server role '${value}'. Use core, upload, or preview.`);
8821
+ }
8822
+ function planServerRuntime(input) {
8823
+ const role = parseServerRole(input.role);
8824
+ const definition = ROLE_DEFINITIONS[role];
8825
+ const coreProfile = input.profile ?? "production";
8826
+ const profile = role === "core" ? coreProfile : null;
8827
+ const profileServices = role === "core" ? [...CORE_OBSERVABILITY_BY_PROFILE[coreProfile]] : [];
8828
+ if (role === "core" && input.withAlerts) profileServices.push("alertmanager");
8829
+ const defaultServices = role === "core" ? unique([...definition.requiredServices, ...CORE_WORKER_SERVICES, "admin-sidecar", ...profileServices, "webapp"]) : unique([...definition.requiredServices, ...definition.optionalServices]);
8830
+ return {
8831
+ role,
8832
+ profile,
8833
+ dataBearing: definition.dataBearing,
8834
+ composeFiles: [definition.composeFile],
8835
+ requiredServices: [...definition.requiredServices],
8836
+ profileServices,
8837
+ defaultServices,
8838
+ healthChecks: [...definition.healthChecks]
8839
+ };
8840
+ }
8841
+ function resolveServiceSelection(roleValue, filter = {}) {
8842
+ const role = parseServerRole(roleValue);
8843
+ const definition = ROLE_DEFINITIONS[role];
8844
+ const allowed = /* @__PURE__ */ new Set([...definition.requiredServices, ...definition.optionalServices]);
8845
+ const requested = csv(filter.services);
8846
+ const excluded = new Set(csv(filter.exclude));
8847
+ const base = requested.length ? requested : [...allowed];
8848
+ for (const service of [...base, ...excluded]) {
8849
+ if (!allowed.has(service)) {
8850
+ throw new Error(`Invalid service '${service}' for ${role} role.`);
8851
+ }
8852
+ }
8853
+ return base.filter((service) => !excluded.has(service));
8854
+ }
8855
+ function planUpdate(input) {
8856
+ const runtime = planServerRuntime({ role: input.role });
8857
+ const selectedServices = input.selectedServices?.length ? input.selectedServices : runtime.defaultServices;
8858
+ const missingRequiredSecrets = input.missingRequiredSecrets ?? [];
8859
+ const blocked = input.continuous === true && missingRequiredSecrets.length > 0;
8860
+ const steps = ["preflight"];
8861
+ const backupName = runtime.dataBearing && input.skipBackup !== true ? `latest-pre-update-${runtime.role}.tar.zst` : null;
8862
+ if (backupName) steps.push("backup:latest-pre-update");
8863
+ steps.push("pull", "up", "health-check");
8864
+ return {
8865
+ role: runtime.role,
8866
+ selectedServices,
8867
+ steps,
8868
+ commands: [
8869
+ `docker compose pull ${selectedServices.join(" ")}`,
8870
+ `docker compose up -d ${selectedServices.join(" ")}`
8871
+ ],
8872
+ backupName,
8873
+ blocked,
8874
+ blockReason: blocked ? `Blocked by missing required secrets: ${missingRequiredSecrets.join(", ")}` : null
8875
+ };
8876
+ }
8877
+ function planBackup(input) {
8878
+ const role = parseServerRole(input.role);
8879
+ const contentsByRole = {
8880
+ core: ["postgres-dump", "directus-uploads", "directus-extensions", "vault-data", "vault-setup-data", "runtime-env", "runtime-config", "manifest", "checksums"],
8881
+ upload: ["vault-data", "vault-setup-data", "runtime-env", "runtime-config", "manifest", "checksums"],
8882
+ preview: ["runtime-env", "runtime-config", "preview-cache", "manifest", "checksums"]
8883
+ };
8884
+ const contents = [...contentsByRole[role]];
8885
+ if (input.includeObservability) contents.push("openobserve-data", "prometheus-data");
8886
+ return { role, contents, fileMode: 384 };
8887
+ }
8888
+ function planRestore(input) {
8889
+ const role = parseServerRole(input.role);
8890
+ return {
8891
+ role,
8892
+ file: input.file,
8893
+ requiresConfirmation: input.yes !== true,
8894
+ steps: input.yes === true ? ["stop", "restore", "start", "health-check"] : ["confirm", "stop", "restore", "start", "health-check"]
8895
+ };
8896
+ }
8897
+ function planCaddyCommand(input) {
8898
+ const role = parseServerRole(input.role);
8899
+ const templatePath = `templates/caddy/${role}/Caddyfile`;
8900
+ const stepsByAction = {
8901
+ check: ["render-template", "validate"],
8902
+ status: ["hash-template", "hash-applied", "validate"],
8903
+ diff: ["hash-template", "hash-applied", "diff"],
8904
+ apply: ["render-template", "validate", "backup-applied", "write", "reload"]
8905
+ };
8906
+ return {
8907
+ role,
8908
+ action: input.action,
8909
+ templatePath,
8910
+ appliedPath: input.appliedPath ?? "/etc/caddy/Caddyfile",
8911
+ steps: stepsByAction[input.action]
8912
+ };
8913
+ }
8914
+ function planContinuousUpdateService(input) {
8915
+ const role = parseServerRole(input.role);
8916
+ const channel = input.channel ?? "main";
8917
+ const window = input.window ?? "02:00-04:00 UTC";
8918
+ const serviceName = `openmates-${role}-continuous-update.service`;
8919
+ const timerName = `openmates-${role}-continuous-update.timer`;
8920
+ return {
8921
+ role,
8922
+ serviceName,
8923
+ timerName,
8924
+ unit: [
8925
+ "[Unit]",
8926
+ `Description=OpenMates ${role} continuous updater`,
8927
+ "After=docker.service network-online.target",
8928
+ "Wants=network-online.target",
8929
+ "",
8930
+ "[Service]",
8931
+ "Type=oneshot",
8932
+ `ExecStart=openmates server update --role ${role} --channel ${channel} --continuous`,
8933
+ `Environment=OPENMATES_UPDATE_WINDOW=${window}`,
8934
+ ""
8935
+ ].join("\n"),
8936
+ timer: [
8937
+ "[Unit]",
8938
+ `Description=Run OpenMates ${role} continuous updater`,
8939
+ "",
8940
+ "[Timer]",
8941
+ "OnCalendar=*:0/30",
8942
+ "Persistent=true",
8943
+ "",
8944
+ "[Install]",
8945
+ "WantedBy=timers.target",
8946
+ ""
8947
+ ].join("\n")
8948
+ };
8949
+ }
8950
+
8951
+ // src/server.ts
8583
8952
  var SOURCE_COMPOSE_FILE = join3("backend", "core", "docker-compose.yml");
8584
- var IMAGE_COMPOSE_FILE = join3("backend", "core", "docker-compose.selfhost.yml");
8953
+ var ROLE_IMAGE_COMPOSE_FILES = {
8954
+ core: join3("backend", "core", "docker-compose.selfhost.yml"),
8955
+ upload: join3("backend", "upload", "docker-compose.yml"),
8956
+ preview: join3("backend", "preview", "docker-compose.preview.yml")
8957
+ };
8958
+ var ROLE_TEMPLATE_FILES = {
8959
+ core: join3("core", "docker-compose.selfhost.yml"),
8960
+ upload: join3("upload", "docker-compose.yml"),
8961
+ preview: join3("preview", "docker-compose.preview.yml")
8962
+ };
8585
8963
  var COMPOSE_OVERRIDE = join3("backend", "core", "docker-compose.override.yml");
8586
8964
  var DEFAULT_INSTALL_PATH = join3(homedir5(), "openmates");
8587
8965
  var REPO_URL = "https://github.com/glowingkitty/OpenMates.git";
@@ -8668,6 +9046,12 @@ SELF_HOST_SIGNUP_MODE=invite_only
8668
9046
  SELF_HOST_SIGNUP_ALLOWED_DOMAINS=
8669
9047
  SELF_HOST_FIRST_INVITE_CODE=
8670
9048
  APPLICATION_PREVIEW_ORIGIN=
9049
+ PROD_CORE_API_URL=
9050
+ PROD_INTERNAL_API_SHARED_TOKEN=
9051
+ DEV_CORE_API_URL=
9052
+ DEV_INTERNAL_API_SHARED_TOKEN=
9053
+ PREVIEW_CORS_ORIGINS=https://openmates.org
9054
+ PREVIEW_ALLOWED_REFERERS=https://openmates.org/*
8671
9055
  OPENMATES_IMAGE_REGISTRY=${DEFAULT_IMAGE_REGISTRY}
8672
9056
  OPENMATES_IMAGE_TAG=
8673
9057
  GIT_WORK_DIR=
@@ -8714,9 +9098,26 @@ function loadConfigForInstallPath(installPath) {
8714
9098
  }
8715
9099
  function getInstallMode(installPath, config = loadConfigForInstallPath(installPath)) {
8716
9100
  if (config?.installMode) return config.installMode;
8717
- if (existsSync5(join3(installPath, IMAGE_COMPOSE_FILE))) return "image";
9101
+ if (Object.values(ROLE_IMAGE_COMPOSE_FILES).some((composeFile) => existsSync5(join3(installPath, composeFile)))) return "image";
8718
9102
  return "source";
8719
9103
  }
9104
+ function getServerRole(flags, config) {
9105
+ return parseServerRole(typeof flags.role === "string" ? flags.role : config?.serverRole);
9106
+ }
9107
+ function getCoreProfile(flags, config) {
9108
+ const value = typeof flags.profile === "string" ? flags.profile : config?.serverProfile;
9109
+ if (value === "minimal" || value === "standard" || value === "production") return value;
9110
+ return "production";
9111
+ }
9112
+ function hasServiceFilter(flags) {
9113
+ return typeof flags.services === "string" || typeof flags.exclude === "string";
9114
+ }
9115
+ function selectedComposeServices(role, flags) {
9116
+ return resolveServiceSelection(role, {
9117
+ services: typeof flags.services === "string" ? flags.services : void 0,
9118
+ exclude: typeof flags.exclude === "string" ? flags.exclude : void 0
9119
+ });
9120
+ }
8720
9121
  function shouldPullImages() {
8721
9122
  return process.env.OPENMATES_SKIP_IMAGE_PULL !== "1";
8722
9123
  }
@@ -8779,8 +9180,8 @@ ${renderFeatureOverrides(overrides)}`;
8779
9180
  function featureKind(featureId) {
8780
9181
  return featureId.split(":", 1)[0] || "unknown";
8781
9182
  }
8782
- function composeArgs(installPath, withOverrides, installMode = getInstallMode(installPath)) {
8783
- const composeFile = installMode === "image" ? IMAGE_COMPOSE_FILE : SOURCE_COMPOSE_FILE;
9183
+ function composeArgs(installPath, withOverrides, installMode = getInstallMode(installPath), role = "core") {
9184
+ const composeFile = installMode === "image" ? ROLE_IMAGE_COMPOSE_FILES[role] : SOURCE_COMPOSE_FILE;
8784
9185
  const args = ["compose", "--env-file", ".env", "-f", composeFile];
8785
9186
  if (withOverrides && existsSync5(join3(installPath, COMPOSE_OVERRIDE))) {
8786
9187
  args.push("-f", COMPOSE_OVERRIDE);
@@ -8912,25 +9313,35 @@ async function fetchText(url) {
8912
9313
  }
8913
9314
  return response.text();
8914
9315
  }
8915
- async function loadSelfHostComposeTemplate(templateRef) {
9316
+ function packagedTemplatePath(role) {
9317
+ return join3(dirname(new URL(import.meta.url).pathname), "..", "templates", ROLE_TEMPLATE_FILES[role]);
9318
+ }
9319
+ function packagedCaddyTemplatePath(role) {
9320
+ return join3(dirname(new URL(import.meta.url).pathname), "..", "templates", "caddy", role, "Caddyfile");
9321
+ }
9322
+ function fileHash(path) {
9323
+ if (!existsSync5(path)) return null;
9324
+ return createHash5("sha256").update(readFileSync5(path)).digest("hex");
9325
+ }
9326
+ async function loadSelfHostComposeTemplate(templateRef, role) {
8916
9327
  const templateDir = process.env.OPENMATES_SELFHOST_TEMPLATE_DIR;
8917
9328
  if (templateDir) {
8918
- return readFileSync5(join3(resolve3(templateDir), IMAGE_COMPOSE_FILE), "utf-8");
9329
+ return readFileSync5(join3(resolve3(templateDir), ROLE_TEMPLATE_FILES[role]), "utf-8");
8919
9330
  }
8920
9331
  const overrideUrl = process.env.OPENMATES_SELFHOST_COMPOSE_URL;
8921
9332
  if (overrideUrl) {
8922
9333
  return fetchText(overrideUrl);
8923
9334
  }
8924
- return fetchText(
8925
- `https://raw.githubusercontent.com/glowingkitty/OpenMates/${templateRef}/backend/core/docker-compose.selfhost.yml`
8926
- );
9335
+ const packaged = packagedTemplatePath(role);
9336
+ if (existsSync5(packaged)) return readFileSync5(packaged, "utf-8");
9337
+ return fetchText(`https://raw.githubusercontent.com/glowingkitty/OpenMates/${templateRef}/${ROLE_IMAGE_COMPOSE_FILES[role]}`);
8927
9338
  }
8928
- async function writeImageModeRuntimeFiles(installPath, imageTag) {
8929
- const coreDir = join3(installPath, "backend", "core");
8930
- const vaultConfigDir = join3(coreDir, "vault", "config");
9339
+ async function writeImageModeRuntimeFiles(installPath, imageTag, role) {
9340
+ const roleDir = join3(installPath, "backend", role === "core" ? "core" : role);
9341
+ const vaultConfigDir = join3(roleDir, "vault", "config");
8931
9342
  mkdirSync3(vaultConfigDir, { recursive: true });
8932
9343
  mkdirSync3(join3(installPath, "config", "providers"), { recursive: true });
8933
- writeFileSync3(join3(coreDir, "docker-compose.selfhost.yml"), await loadSelfHostComposeTemplate(templateRefForImageTag(imageTag, getPackageVersion())));
9344
+ writeFileSync3(join3(installPath, ROLE_IMAGE_COMPOSE_FILES[role]), await loadSelfHostComposeTemplate(templateRefForImageTag(imageTag, getPackageVersion()), role));
8934
9345
  writeFileSync3(join3(vaultConfigDir, "vault.hcl"), VAULT_CONFIG_TEMPLATE);
8935
9346
  ensureImageRuntimeConfig(installPath);
8936
9347
  const envPath = join3(installPath, ".env");
@@ -8975,7 +9386,16 @@ async function checkUrl(url) {
8975
9386
  clearTimeout(timeout);
8976
9387
  }
8977
9388
  }
8978
- async function waitForServerHealth(installPath) {
9389
+ async function waitForServerHealth(installPath, role = "core") {
9390
+ if (role === "upload" || role === "preview") {
9391
+ const healthUrl = role === "upload" ? "http://localhost:8000/health" : "http://localhost:8080/health";
9392
+ const deadline2 = Date.now() + UPDATE_HEALTH_TIMEOUT_MS;
9393
+ while (Date.now() < deadline2) {
9394
+ if (await checkUrl(healthUrl)) return;
9395
+ await sleep2(UPDATE_HEALTH_INTERVAL_MS);
9396
+ }
9397
+ throw new Error(`Updated ${role} server did not pass health checks in time. Tried ${healthUrl}.`);
9398
+ }
8979
9399
  const envPath = join3(installPath, ".env");
8980
9400
  const envContent = existsSync5(envPath) ? readFileSync5(envPath, "utf-8") : "";
8981
9401
  const urls = deriveSelfHostCliUrls(envContent);
@@ -9104,6 +9524,185 @@ function boolFromFlag(value, defaultValue = false) {
9104
9524
  const normalized = value.toLowerCase();
9105
9525
  return ["1", "true", "yes", "y", "on"].includes(normalized);
9106
9526
  }
9527
+ function shellQuote(value) {
9528
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
9529
+ }
9530
+ function nowStamp() {
9531
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
9532
+ }
9533
+ function backupRoot(installPath) {
9534
+ return join3(installPath, "backups");
9535
+ }
9536
+ function roleBackupDir(installPath, role) {
9537
+ return join3(backupRoot(installPath), role);
9538
+ }
9539
+ function updateStatusFile(installPath, role) {
9540
+ return join3(installPath, ".openmates", `${role}-update-status.json`);
9541
+ }
9542
+ function writeUpdateStatus(installPath, role, status) {
9543
+ const filePath = updateStatusFile(installPath, role);
9544
+ mkdirSync3(dirname(filePath), { recursive: true, mode: 448 });
9545
+ writeFileSync3(filePath, `${JSON.stringify({ role, updated_at: (/* @__PURE__ */ new Date()).toISOString(), ...status }, null, 2)}
9546
+ `, { mode: 384 });
9547
+ }
9548
+ function copyIfExists(source, destination) {
9549
+ if (!existsSync5(source)) return;
9550
+ mkdirSync3(dirname(destination), { recursive: true });
9551
+ cpSync(source, destination, { recursive: true, force: true });
9552
+ }
9553
+ function readEnvMap(installPath) {
9554
+ const envPath = join3(installPath, ".env");
9555
+ if (!existsSync5(envPath)) return {};
9556
+ const values = {};
9557
+ for (const line of readFileSync5(envPath, "utf-8").split("\n")) {
9558
+ const trimmed = line.trim();
9559
+ if (!trimmed || trimmed.startsWith("#")) continue;
9560
+ const eqIdx = trimmed.indexOf("=");
9561
+ if (eqIdx === -1) continue;
9562
+ values[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1).replace(/^"|"$/g, "");
9563
+ }
9564
+ return values;
9565
+ }
9566
+ function requiredRuntimeEnvKeys(role) {
9567
+ if (role === "core") {
9568
+ return [
9569
+ "DATABASE_ADMIN_EMAIL",
9570
+ "DATABASE_ADMIN_PASSWORD",
9571
+ "DATABASE_NAME",
9572
+ "DATABASE_USERNAME",
9573
+ "DATABASE_PASSWORD",
9574
+ "DIRECTUS_TOKEN",
9575
+ "DIRECTUS_SECRET",
9576
+ "DRAGONFLY_PASSWORD",
9577
+ "INTERNAL_API_SHARED_TOKEN"
9578
+ ];
9579
+ }
9580
+ if (role === "upload") {
9581
+ return ["PROD_CORE_API_URL", "PROD_INTERNAL_API_SHARED_TOKEN", "DEV_CORE_API_URL", "DEV_INTERNAL_API_SHARED_TOKEN"];
9582
+ }
9583
+ return ["PREVIEW_CORS_ORIGINS", "PREVIEW_ALLOWED_REFERERS"];
9584
+ }
9585
+ function missingRequiredEnvKeys(installPath, role) {
9586
+ const env = readEnvMap(installPath);
9587
+ return requiredRuntimeEnvKeys(role).filter((key) => !env[key]);
9588
+ }
9589
+ function writeChecksums(rootDir) {
9590
+ const lines = [];
9591
+ const walk = (dir) => {
9592
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
9593
+ const path = join3(dir, entry.name);
9594
+ if (entry.isDirectory()) {
9595
+ walk(path);
9596
+ continue;
9597
+ }
9598
+ if (entry.name === "checksums.sha256") continue;
9599
+ const relative2 = path.slice(rootDir.length + 1);
9600
+ const hash = createHash5("sha256").update(readFileSync5(path)).digest("hex");
9601
+ lines.push(`${hash} ${relative2}`);
9602
+ }
9603
+ };
9604
+ walk(rootDir);
9605
+ writeFileSync3(join3(rootDir, "checksums.sha256"), `${lines.sort().join("\n")}
9606
+ `);
9607
+ }
9608
+ function verifyChecksums(rootDir) {
9609
+ const checksumsPath = join3(rootDir, "checksums.sha256");
9610
+ if (!existsSync5(checksumsPath)) throw new Error("Backup archive is missing checksums.sha256.");
9611
+ for (const line of readFileSync5(checksumsPath, "utf-8").split("\n")) {
9612
+ const trimmed = line.trim();
9613
+ if (!trimmed) continue;
9614
+ const match = trimmed.match(/^([a-f0-9]{64}) {2}(.+)$/);
9615
+ if (!match) throw new Error(`Invalid checksum entry: ${trimmed}`);
9616
+ const relative2 = match[2];
9617
+ if (relative2.startsWith("/") || relative2.split(/[\\/]/).includes("..")) {
9618
+ throw new Error(`Unsafe checksum path in backup archive: ${relative2}`);
9619
+ }
9620
+ const filePath = join3(rootDir, relative2);
9621
+ if (!existsSync5(filePath)) throw new Error(`Backup archive is missing checksummed file: ${relative2}`);
9622
+ const actual = createHash5("sha256").update(readFileSync5(filePath)).digest("hex");
9623
+ if (actual !== match[1]) throw new Error(`Backup checksum mismatch for ${relative2}.`);
9624
+ }
9625
+ }
9626
+ function createServerBackup(installPath, role, options = {}) {
9627
+ const plan = planBackup({ role, includeObservability: options.includeObservability });
9628
+ const backupDir = roleBackupDir(installPath, role);
9629
+ mkdirSync3(backupDir, { recursive: true, mode: 448 });
9630
+ const archivePath = options.output ? resolve3(options.output) : join3(backupDir, options.preUpdate ? `latest-pre-update-${role}.tar.gz` : `openmates-${role}-${nowStamp()}.tar.gz`);
9631
+ const tempDir = mkdtempSync(join3(backupDir, ".tmp-"));
9632
+ const env = readEnvMap(installPath);
9633
+ const manifest = {
9634
+ role,
9635
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
9636
+ cli_version: getPackageVersion(),
9637
+ image_tag: getEnvVar(existsSync5(join3(installPath, ".env")) ? readFileSync5(join3(installPath, ".env"), "utf-8") : "", "OPENMATES_IMAGE_TAG"),
9638
+ include_observability: options.includeObservability === true,
9639
+ contents: plan.contents
9640
+ };
9641
+ try {
9642
+ writeFileSync3(join3(tempDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
9643
+ `);
9644
+ copyIfExists(join3(installPath, ".env"), join3(tempDir, "runtime", ".env"));
9645
+ copyIfExists(join3(installPath, "config"), join3(tempDir, "runtime", "config"));
9646
+ if (role === "core") {
9647
+ const databaseUser = env.DATABASE_USERNAME || "directus";
9648
+ const databaseName = env.DATABASE_NAME || "directus";
9649
+ try {
9650
+ const dump = execSync(
9651
+ `docker exec cms-database pg_dump --clean --if-exists --no-owner --no-privileges -U ${shellQuote(databaseUser)} ${shellQuote(databaseName)}`,
9652
+ { encoding: "utf-8" }
9653
+ );
9654
+ writeFileSync3(join3(tempDir, "postgres.sql"), dump);
9655
+ } catch (error) {
9656
+ throw new Error(`Postgres backup failed. Is cms-database running? ${error instanceof Error ? error.message : String(error)}`);
9657
+ }
9658
+ }
9659
+ for (const item of [
9660
+ [join3(installPath, "backend", "core", "uploads"), join3(tempDir, "directus-uploads")],
9661
+ [join3(installPath, "backend", "core", "extensions"), join3(tempDir, "directus-extensions")],
9662
+ [join3(installPath, "backend", role, "vault"), join3(tempDir, `${role}-vault-config`)]
9663
+ ]) {
9664
+ copyIfExists(item[0], item[1]);
9665
+ }
9666
+ writeChecksums(tempDir);
9667
+ execSync(`tar -czf ${shellQuote(archivePath)} -C ${shellQuote(tempDir)} .`, { stdio: "pipe" });
9668
+ chmodSync2(archivePath, plan.fileMode);
9669
+ return archivePath;
9670
+ } finally {
9671
+ rmSync3(tempDir, { recursive: true, force: true });
9672
+ }
9673
+ }
9674
+ function restoreServerBackup(installPath, role, file) {
9675
+ const archivePath = resolve3(file);
9676
+ if (!existsSync5(archivePath)) throw new Error(`Backup file not found: ${archivePath}`);
9677
+ mkdirSync3(roleBackupDir(installPath, role), { recursive: true, mode: 448 });
9678
+ const tempDir = mkdtempSync(join3(roleBackupDir(installPath, role), ".restore-"));
9679
+ const env = readEnvMap(installPath);
9680
+ try {
9681
+ execSync(`tar -xzf ${shellQuote(archivePath)} -C ${shellQuote(tempDir)}`, { stdio: "pipe" });
9682
+ verifyChecksums(tempDir);
9683
+ const manifestPath = join3(tempDir, "manifest.json");
9684
+ if (!existsSync5(manifestPath)) throw new Error("Backup archive is missing manifest.json.");
9685
+ const manifest = JSON.parse(readFileSync5(manifestPath, "utf-8"));
9686
+ if (manifest.role !== role) throw new Error(`Backup role '${manifest.role}' does not match requested role '${role}'.`);
9687
+ copyIfExists(join3(tempDir, "runtime", ".env"), join3(installPath, ".env"));
9688
+ copyIfExists(join3(tempDir, "runtime", "config"), join3(installPath, "config"));
9689
+ const postgresDump = join3(tempDir, "postgres.sql");
9690
+ if (role === "core" && existsSync5(postgresDump)) {
9691
+ const databaseUser = env.DATABASE_USERNAME || "directus";
9692
+ const databaseName = env.DATABASE_NAME || "directus";
9693
+ execSync(`docker exec -i cms-database psql -v ON_ERROR_STOP=1 -U ${shellQuote(databaseUser)} ${shellQuote(databaseName)}`, {
9694
+ input: readFileSync5(postgresDump),
9695
+ stdio: ["pipe", "pipe", "pipe"]
9696
+ });
9697
+ }
9698
+ } finally {
9699
+ rmSync3(tempDir, { recursive: true, force: true });
9700
+ }
9701
+ }
9702
+ function restoreStopServices(role) {
9703
+ if (role !== "core") return [];
9704
+ return resolveServiceSelection("core", { exclude: "cms-database" });
9705
+ }
9107
9706
  async function promptText(question, defaultValue = "") {
9108
9707
  const rl = createPromptInterface({ input: process.stdin, output: process.stderr });
9109
9708
  try {
@@ -9200,11 +9799,13 @@ async function serverStatus(flags) {
9200
9799
  const installPath = resolveServerPath(flags);
9201
9800
  ensureGitWorkDirEnv(installPath);
9202
9801
  const config = loadConfigForInstallPath(installPath);
9802
+ const role = getServerRole(flags, config);
9203
9803
  const withOverrides = config?.composeProfile === "full";
9204
- const args = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config)), "ps"];
9804
+ const args = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config), role), "ps"];
9205
9805
  if (flags.json === true) {
9206
9806
  args.push("--format", "json");
9207
9807
  }
9808
+ if (hasServiceFilter(flags)) args.push(...selectedComposeServices(role, flags));
9208
9809
  const code = await runInteractive("docker", args, installPath);
9209
9810
  if (code !== 0) process.exit(code);
9210
9811
  }
@@ -9215,9 +9816,15 @@ async function serverStart(flags) {
9215
9816
  warnIfMissingLlmCredentials(installPath);
9216
9817
  const withOverrides = flags["with-overrides"] === true;
9217
9818
  const config = loadConfigForInstallPath(installPath);
9819
+ const role = getServerRole(flags, config);
9218
9820
  const installMode = getInstallMode(installPath, config);
9219
- const pullArgs = [...composeArgs(installPath, withOverrides, installMode), "pull"];
9220
- const args = [...composeArgs(installPath, withOverrides, installMode), "up", "-d"];
9821
+ const pullArgs = [...composeArgs(installPath, withOverrides, installMode, role), "pull"];
9822
+ const args = [...composeArgs(installPath, withOverrides, installMode, role), "up", "-d"];
9823
+ if (hasServiceFilter(flags)) {
9824
+ const services = selectedComposeServices(role, flags);
9825
+ pullArgs.push(...services);
9826
+ args.push(...services);
9827
+ }
9221
9828
  if (config && withOverrides && config.composeProfile !== "full") {
9222
9829
  saveServerConfig({ ...config, composeProfile: "full" });
9223
9830
  }
@@ -9246,8 +9853,9 @@ async function serverStop(flags) {
9246
9853
  const installPath = resolveServerPath(flags);
9247
9854
  ensureGitWorkDirEnv(installPath);
9248
9855
  const config = loadConfigForInstallPath(installPath);
9856
+ const role = getServerRole(flags, config);
9249
9857
  const withOverrides = config?.composeProfile === "full";
9250
- const args = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config)), "down"];
9858
+ const args = hasServiceFilter(flags) ? [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config), role), "stop", ...selectedComposeServices(role, flags)] : [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config), role), "down"];
9251
9859
  console.error("Stopping OpenMates server...");
9252
9860
  const code = await runInteractive("docker", args, installPath);
9253
9861
  if (code !== 0) process.exit(code);
@@ -9262,31 +9870,36 @@ async function serverRestart(flags) {
9262
9870
  const installPath = resolveServerPath(flags);
9263
9871
  ensureGitWorkDirEnv(installPath);
9264
9872
  const config = loadConfigForInstallPath(installPath);
9873
+ const role = getServerRole(flags, config);
9265
9874
  const withOverrides = config?.composeProfile === "full";
9266
9875
  const installMode = getInstallMode(installPath, config);
9267
9876
  if (flags.rebuild === true) {
9877
+ if (hasServiceFilter(flags)) {
9878
+ throw new Error("--services/--exclude cannot be combined with --rebuild. Use graceful restart for service-scoped restarts.");
9879
+ }
9268
9880
  if (installMode === "image") {
9269
9881
  throw new Error(
9270
9882
  "Image-mode installs use prebuilt images and cannot rebuild locally. Run 'openmates server update' to pull newer images, or reinstall with --from-source to build from source."
9271
9883
  );
9272
9884
  }
9273
9885
  console.error("Rebuilding OpenMates server (this may take a few minutes)...");
9274
- const downArgs = [...composeArgs(installPath, withOverrides, installMode), "down"];
9886
+ const downArgs = [...composeArgs(installPath, withOverrides, installMode, role), "down"];
9275
9887
  let code = await runInteractive("docker", downArgs, installPath);
9276
9888
  if (code !== 0) process.exit(code);
9277
9889
  try {
9278
9890
  exec("docker volume rm openmates-cache-data", installPath);
9279
9891
  } catch {
9280
9892
  }
9281
- const buildArgs = [...composeArgs(installPath, withOverrides, installMode), "build"];
9893
+ const buildArgs = [...composeArgs(installPath, withOverrides, installMode, role), "build"];
9282
9894
  code = await runInteractive("docker", buildArgs, installPath);
9283
9895
  if (code !== 0) process.exit(code);
9284
- const upArgs = [...composeArgs(installPath, withOverrides, installMode), "up", "-d"];
9896
+ const upArgs = [...composeArgs(installPath, withOverrides, installMode, role), "up", "-d"];
9285
9897
  code = await runInteractive("docker", upArgs, installPath);
9286
9898
  if (code !== 0) process.exit(code);
9287
9899
  } else {
9288
9900
  console.error("Restarting OpenMates server...");
9289
- const args = [...composeArgs(installPath, withOverrides, installMode), "restart"];
9901
+ const args = [...composeArgs(installPath, withOverrides, installMode, role), "restart"];
9902
+ if (hasServiceFilter(flags)) args.push(...selectedComposeServices(role, flags));
9290
9903
  const code = await runInteractive("docker", args, installPath);
9291
9904
  if (code !== 0) process.exit(code);
9292
9905
  }
@@ -9301,8 +9914,9 @@ async function serverLogs(flags) {
9301
9914
  const installPath = resolveServerPath(flags);
9302
9915
  ensureGitWorkDirEnv(installPath);
9303
9916
  const config = loadConfigForInstallPath(installPath);
9917
+ const role = getServerRole(flags, config);
9304
9918
  const withOverrides = config?.composeProfile === "full";
9305
- const args = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config)), "logs"];
9919
+ const args = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config), role), "logs"];
9306
9920
  if (flags.follow === true || flags.f === true) {
9307
9921
  args.push("--follow");
9308
9922
  }
@@ -9312,9 +9926,13 @@ async function serverLogs(flags) {
9312
9926
  } else {
9313
9927
  args.push("--tail", "100");
9314
9928
  }
9315
- const container = flags.container;
9316
- if (typeof container === "string") {
9317
- args.push(container);
9929
+ if (hasServiceFilter(flags)) {
9930
+ args.push(...selectedComposeServices(role, flags));
9931
+ } else {
9932
+ const container = flags.container;
9933
+ if (typeof container === "string") {
9934
+ args.push(container);
9935
+ }
9318
9936
  }
9319
9937
  const code = await runInteractive("docker", args, installPath);
9320
9938
  if (code !== 0) process.exit(code);
@@ -9323,7 +9941,10 @@ async function serverInstall(flags) {
9323
9941
  const installPath = typeof flags.path === "string" ? resolve3(flags.path) : DEFAULT_INSTALL_PATH;
9324
9942
  const sourcePath = typeof flags["source-path"] === "string" ? resolve3(flags["source-path"]) : null;
9325
9943
  const fromSource = flags["from-source"] === true || sourcePath !== null;
9326
- if (existsSync5(join3(installPath, SOURCE_COMPOSE_FILE)) || existsSync5(join3(installPath, IMAGE_COMPOSE_FILE))) {
9944
+ const role = getServerRole(flags, null);
9945
+ const profile = getCoreProfile(flags, null);
9946
+ const runtimePlan = planServerRuntime({ role, profile, withAlerts: flags["with-alerts"] === true });
9947
+ if (existsSync5(join3(installPath, SOURCE_COMPOSE_FILE)) || Object.values(ROLE_IMAGE_COMPOSE_FILES).some((composeFile) => existsSync5(join3(installPath, composeFile)))) {
9327
9948
  console.error(`OpenMates already exists at ${installPath}.`);
9328
9949
  console.error("Use 'openmates server update' to update, or choose a different --path.");
9329
9950
  process.exit(1);
@@ -9341,7 +9962,7 @@ async function serverInstall(flags) {
9341
9962
  }
9342
9963
  const imageTag = typeof flags["image-tag"] === "string" ? flags["image-tag"] : getDefaultImageTag();
9343
9964
  console.error(`Preparing OpenMates image-mode install at ${installPath}...`);
9344
- await writeImageModeRuntimeFiles(installPath, imageTag);
9965
+ await writeImageModeRuntimeFiles(installPath, imageTag, role);
9345
9966
  const cliUrls2 = deriveSelfHostCliUrls(readFileSync5(join3(installPath, ".env"), "utf-8"));
9346
9967
  try {
9347
9968
  exec("docker network create openmates", installPath);
@@ -9350,7 +9971,11 @@ async function serverInstall(flags) {
9350
9971
  saveServerConfig({
9351
9972
  installPath,
9352
9973
  installedAt: Date.now(),
9353
- composeProfile: "core",
9974
+ composeProfile: role === "core" ? "core" : "core",
9975
+ serverRole: role,
9976
+ serverProfile: profile,
9977
+ defaultServices: runtimePlan.defaultServices,
9978
+ composeFiles: runtimePlan.composeFiles,
9354
9979
  installMode: "image",
9355
9980
  imageTag,
9356
9981
  ...cliUrls2
@@ -9417,6 +10042,10 @@ CLI default API: ${cliUrls2.apiUrl}`);
9417
10042
  installPath,
9418
10043
  installedAt: Date.now(),
9419
10044
  composeProfile: "core",
10045
+ serverRole: role,
10046
+ serverProfile: profile,
10047
+ defaultServices: runtimePlan.defaultServices,
10048
+ composeFiles: runtimePlan.composeFiles,
9420
10049
  installMode: "source",
9421
10050
  ...cliUrls
9422
10051
  });
@@ -9434,13 +10063,71 @@ CLI default API: ${cliUrls.apiUrl}`);
9434
10063
  console.log("\nOptional: edit .env first to add LLM provider API keys. Without keys, the web app and backend still start, but AI model processing is unavailable.");
9435
10064
  }
9436
10065
  }
9437
- async function serverUpdate(flags) {
10066
+ async function installContinuousUpdateService(flags) {
10067
+ const config = loadServerConfig();
10068
+ const role = getServerRole(flags, config);
10069
+ const channel = typeof flags.channel === "string" ? flags.channel : config?.imageChannel ?? "main";
10070
+ const window = typeof flags.window === "string" ? flags.window : "02:00-04:00 UTC";
10071
+ const plan = planContinuousUpdateService({ role, channel, window });
10072
+ const servicePath = join3("/etc", "systemd", "system", plan.serviceName);
10073
+ const timerPath = join3("/etc", "systemd", "system", plan.timerName);
10074
+ if (flags["dry-run"] === true || flags.json === true) {
10075
+ printJson({ command: "update install-service", status: "planned", role, servicePath, timerPath, unit: plan.unit, timer: plan.timer });
10076
+ return;
10077
+ }
10078
+ try {
10079
+ writeFileSync3(servicePath, plan.unit, { mode: 420 });
10080
+ writeFileSync3(timerPath, plan.timer, { mode: 420 });
10081
+ execSync("systemctl daemon-reload", { stdio: "pipe" });
10082
+ execSync(`systemctl enable --now ${shellQuote(plan.timerName)}`, { stdio: "pipe" });
10083
+ } catch (error) {
10084
+ throw new Error(
10085
+ `Could not install systemd updater. Run with sudo or use --dry-run to inspect generated units. ${error instanceof Error ? error.message : String(error)}`
10086
+ );
10087
+ }
10088
+ console.log(`Installed ${plan.timerName}.`);
10089
+ }
10090
+ async function serverUpdate(rest, flags) {
10091
+ if (rest[0] === "status") {
10092
+ const installPath2 = resolveServerPath(flags);
10093
+ const config2 = loadConfigForInstallPath(installPath2);
10094
+ const role2 = getServerRole(flags, config2);
10095
+ const filePath = updateStatusFile(installPath2, role2);
10096
+ if (flags.json === true) {
10097
+ printJson(existsSync5(filePath) ? JSON.parse(readFileSync5(filePath, "utf-8")) : { role: role2, status: "unknown" });
10098
+ return;
10099
+ }
10100
+ if (!existsSync5(filePath)) {
10101
+ console.log(`No update status recorded for ${role2}.`);
10102
+ return;
10103
+ }
10104
+ console.log(readFileSync5(filePath, "utf-8").trim());
10105
+ return;
10106
+ }
10107
+ if (rest[0] === "install-service") {
10108
+ if (flags.continuous !== true) throw new Error("Usage: openmates server update install-service --continuous [--channel main|dev|stable]");
10109
+ await installContinuousUpdateService(flags);
10110
+ return;
10111
+ }
10112
+ if (flags.continuous === true) {
10113
+ const intervalMinutes = typeof flags.interval === "string" ? Number.parseInt(flags.interval, 10) : 30;
10114
+ if (!Number.isFinite(intervalMinutes) || intervalMinutes < 5) throw new Error("--interval must be at least 5 minutes.");
10115
+ console.error(`Running continuous updater every ${intervalMinutes} minutes. Use Ctrl+C to stop.`);
10116
+ while (true) {
10117
+ await serverUpdate([], { ...flags, continuous: false });
10118
+ await sleep2(intervalMinutes * 6e4);
10119
+ }
10120
+ }
9438
10121
  const installPath = resolveServerPath(flags);
9439
10122
  const dryRun = flags["dry-run"] === true;
9440
10123
  if (!dryRun) ensureGitWorkDirEnv(installPath);
9441
10124
  const config = loadConfigForInstallPath(installPath);
10125
+ const role = getServerRole(flags, config);
9442
10126
  const withOverrides = config?.composeProfile === "full";
9443
10127
  const installMode = getInstallMode(installPath, config);
10128
+ const filterRequested = hasServiceFilter(flags);
10129
+ const selectedServices = filterRequested ? selectedComposeServices(role, flags) : [];
10130
+ const missingEnvKeys = missingRequiredEnvKeys(installPath, role);
9444
10131
  if (installMode === "source" && (flags["image-tag"] !== void 0 || flags.channel !== void 0)) {
9445
10132
  throw new Error("--image-tag and --channel only apply to image-mode installs. Source-mode installs update from Git.");
9446
10133
  }
@@ -9450,14 +10137,22 @@ async function serverUpdate(flags) {
9450
10137
  const currentTag = getImageTagFromEnv(installPath, config);
9451
10138
  const target = resolveTargetImageTag(flags, currentTag, getPackageVersion());
9452
10139
  const templateRef = templateRefForImageTag(target.tag, getPackageVersion());
10140
+ const safetyPlan = planUpdate({ role, selectedServices, dryRun, skipBackup: flags["skip-backup"] === true, continuous: false, missingRequiredSecrets: missingEnvKeys });
9453
10141
  const plan = {
9454
10142
  command: "update",
10143
+ role,
9455
10144
  path: installPath,
9456
10145
  mode: "image",
9457
10146
  currentImageTag: currentTag || null,
9458
10147
  targetImageTag: target.tag,
9459
10148
  channel: target.channel ?? null,
9460
10149
  templateRef,
10150
+ selectedServices: filterRequested ? selectedServices : "all",
10151
+ steps: safetyPlan.steps,
10152
+ backupName: safetyPlan.backupName,
10153
+ missingRequiredEnvKeys: missingEnvKeys,
10154
+ blocked: safetyPlan.blocked,
10155
+ blockReason: safetyPlan.blockReason,
9461
10156
  dryRun
9462
10157
  };
9463
10158
  if (dryRun) {
@@ -9469,37 +10164,59 @@ async function serverUpdate(flags) {
9469
10164
  console.log(` Current tag: ${currentTag || "unknown"}`);
9470
10165
  console.log(` Target tag: ${target.tag}`);
9471
10166
  console.log(` Template ref: ${templateRef}`);
10167
+ console.log(` Role: ${role}`);
10168
+ console.log(` Services: ${filterRequested ? selectedServices.join(", ") : "all"}`);
10169
+ console.log(` Backup: ${safetyPlan.backupName ?? "none"}`);
10170
+ console.log(` Steps: ${safetyPlan.steps.join(" -> ")}`);
10171
+ console.log(` Env preflight: ${missingEnvKeys.length ? `missing ${missingEnvKeys.join(", ")}` : "ok"}`);
9472
10172
  console.log(" Commands: refresh compose, docker compose pull, docker compose up -d, health checks");
9473
10173
  }
9474
10174
  return;
9475
10175
  }
10176
+ if (safetyPlan.blocked) throw new Error(safetyPlan.blockReason ?? "Update blocked by preflight.");
10177
+ if (missingEnvKeys.length && flags.yes !== true) {
10178
+ throw new Error(`Required environment keys are missing: ${missingEnvKeys.join(", ")}. Add them to .env or rerun with --yes after reviewing.`);
10179
+ }
9476
10180
  console.error(`Mode: image`);
9477
10181
  console.error(`Current image tag: ${currentTag || "unknown"}`);
9478
10182
  console.error(`Target image tag: ${target.tag}`);
10183
+ writeUpdateStatus(installPath, role, { status: "in_progress", targetImageTag: target.tag, step: "backup" });
10184
+ if (safetyPlan.backupName) {
10185
+ console.error(`Creating rotating pre-update backup: ${safetyPlan.backupName}`);
10186
+ createServerBackup(installPath, role, { preUpdate: true });
10187
+ } else {
10188
+ console.error("Skipping pre-update backup for this role or because --skip-backup was passed.");
10189
+ }
9479
10190
  console.error(`Refreshing self-host runtime files from ${templateRef}...`);
9480
- await writeImageModeRuntimeFiles(installPath, target.tag);
9481
- const pullArgs = [...composeArgs(installPath, withOverrides, installMode), "pull"];
10191
+ await writeImageModeRuntimeFiles(installPath, target.tag, role);
10192
+ const pullArgs = [...composeArgs(installPath, withOverrides, installMode, role), "pull"];
10193
+ if (filterRequested) pullArgs.push(...selectedServices);
9482
10194
  let code2 = 0;
9483
10195
  if (shouldPullImages()) {
10196
+ writeUpdateStatus(installPath, role, { status: "in_progress", targetImageTag: target.tag, step: "pull" });
9484
10197
  console.error("Pulling prebuilt images...");
9485
10198
  code2 = await runInteractive("docker", pullArgs, installPath);
9486
10199
  if (code2 !== 0) process.exit(code2);
9487
10200
  } else {
9488
10201
  console.error("Skipping image pull because OPENMATES_SKIP_IMAGE_PULL=1.");
9489
10202
  }
9490
- const upArgs2 = [...composeArgs(installPath, withOverrides, installMode), "up", "-d"];
10203
+ writeUpdateStatus(installPath, role, { status: "in_progress", targetImageTag: target.tag, step: "up" });
10204
+ const upArgs2 = [...composeArgs(installPath, withOverrides, installMode, role), "up", "-d"];
10205
+ if (filterRequested) upArgs2.push(...selectedServices);
9491
10206
  code2 = await runInteractive("docker", upArgs2, installPath);
9492
10207
  if (code2 !== 0) process.exit(code2);
9493
- console.error("Waiting for API and web health checks...");
10208
+ console.error("Waiting for role health checks...");
9494
10209
  try {
9495
- await waitForServerHealth(installPath);
10210
+ writeUpdateStatus(installPath, role, { status: "in_progress", targetImageTag: target.tag, step: "health-check" });
10211
+ await waitForServerHealth(installPath, role);
9496
10212
  } catch (error) {
9497
- await runInteractive("docker", [...composeArgs(installPath, withOverrides, installMode), "ps"], installPath);
10213
+ await runInteractive("docker", [...composeArgs(installPath, withOverrides, installMode, role), "ps"], installPath);
9498
10214
  throw error;
9499
10215
  }
9500
10216
  if (config) {
9501
10217
  saveServerConfig({ ...config, imageTag: target.tag, imageChannel: target.channel });
9502
10218
  }
10219
+ writeUpdateStatus(installPath, role, { status: "success", targetImageTag: target.tag, step: "complete" });
9503
10220
  if (flags.json === true) {
9504
10221
  printJson({ ...plan, status: "success", dryRun: false });
9505
10222
  } else {
@@ -9545,18 +10262,18 @@ async function serverUpdate(flags) {
9545
10262
  } catch {
9546
10263
  }
9547
10264
  }
9548
- const buildArgs = [...composeArgs(installPath, withOverrides, installMode), "build"];
10265
+ const buildArgs = [...composeArgs(installPath, withOverrides, installMode, role), "build"];
9549
10266
  console.error("Rebuilding containers...");
9550
10267
  let code = await runInteractive("docker", buildArgs, installPath);
9551
10268
  if (code !== 0) process.exit(code);
9552
- const upArgs = [...composeArgs(installPath, withOverrides, installMode), "up", "-d"];
10269
+ const upArgs = [...composeArgs(installPath, withOverrides, installMode, role), "up", "-d"];
9553
10270
  code = await runInteractive("docker", upArgs, installPath);
9554
10271
  if (code !== 0) process.exit(code);
9555
10272
  console.error("Waiting for API and web health checks...");
9556
10273
  try {
9557
- await waitForServerHealth(installPath);
10274
+ await waitForServerHealth(installPath, role);
9558
10275
  } catch (error) {
9559
- await runInteractive("docker", [...composeArgs(installPath, withOverrides, installMode), "ps"], installPath);
10276
+ await runInteractive("docker", [...composeArgs(installPath, withOverrides, installMode, role), "ps"], installPath);
9560
10277
  throw error;
9561
10278
  }
9562
10279
  if (flags.json === true) {
@@ -9570,6 +10287,7 @@ async function serverReset(flags) {
9570
10287
  const installPath = resolveServerPath(flags);
9571
10288
  ensureGitWorkDirEnv(installPath);
9572
10289
  const config = loadConfigForInstallPath(installPath);
10290
+ const role = getServerRole(flags, config);
9573
10291
  const withOverrides = config?.composeProfile === "full";
9574
10292
  const installMode = getInstallMode(installPath, config);
9575
10293
  const userDataOnly = flags["delete-user-data-only"] === true;
@@ -9591,7 +10309,7 @@ async function serverReset(flags) {
9591
10309
  }
9592
10310
  console.error("Resetting server...");
9593
10311
  if (userDataOnly) {
9594
- const downArgs = [...composeArgs(installPath, withOverrides, installMode), "down"];
10312
+ const downArgs = [...composeArgs(installPath, withOverrides, installMode, role), "down"];
9595
10313
  let code = await runInteractive("docker", downArgs, installPath);
9596
10314
  if (code !== 0) process.exit(code);
9597
10315
  for (const vol of ["openmates-cache-data", "openmates-postgres-data", "openmates-cms-database-data"]) {
@@ -9602,15 +10320,15 @@ async function serverReset(flags) {
9602
10320
  }
9603
10321
  }
9604
10322
  if (installMode === "source") {
9605
- const buildArgs = [...composeArgs(installPath, withOverrides, installMode), "build"];
10323
+ const buildArgs = [...composeArgs(installPath, withOverrides, installMode, role), "build"];
9606
10324
  code = await runInteractive("docker", buildArgs, installPath);
9607
10325
  if (code !== 0) process.exit(code);
9608
10326
  }
9609
- const upArgs = [...composeArgs(installPath, withOverrides, installMode), "up", "-d"];
10327
+ const upArgs = [...composeArgs(installPath, withOverrides, installMode, role), "up", "-d"];
9610
10328
  code = await runInteractive("docker", upArgs, installPath);
9611
10329
  if (code !== 0) process.exit(code);
9612
10330
  } else {
9613
- const args = [...composeArgs(installPath, withOverrides, installMode), "down", "-v"];
10331
+ const args = [...composeArgs(installPath, withOverrides, installMode, role), "down", "-v"];
9614
10332
  const code = await runInteractive("docker", args, installPath);
9615
10333
  if (code !== 0) process.exit(code);
9616
10334
  }
@@ -9888,11 +10606,76 @@ async function serverAi(rest, flags) {
9888
10606
  throw new Error(`Unknown server ai models command '${action}'. Use add, list, test, or remove.`);
9889
10607
  }
9890
10608
  }
10609
+ async function serverBackup(rest, flags) {
10610
+ const action = rest[0];
10611
+ const installPath = resolveServerPath(flags);
10612
+ const config = loadConfigForInstallPath(installPath);
10613
+ const role = getServerRole(flags, config);
10614
+ if (action === "list") {
10615
+ const dir = roleBackupDir(installPath, role);
10616
+ const files = existsSync5(dir) ? readdirSync(dir).filter((item) => item.endsWith(".tar.gz")).sort() : [];
10617
+ if (flags.json === true) {
10618
+ printJson({ role, backupDir: dir, files });
10619
+ return;
10620
+ }
10621
+ console.log(`Backups for ${role}:`);
10622
+ if (!files.length) {
10623
+ console.log(" none");
10624
+ return;
10625
+ }
10626
+ for (const file of files) console.log(` ${join3(dir, file)}`);
10627
+ return;
10628
+ }
10629
+ requireDocker();
10630
+ const output = typeof flags.output === "string" ? flags.output : void 0;
10631
+ const archivePath = createServerBackup(installPath, role, {
10632
+ output,
10633
+ includeObservability: flags["include-observability"] === true
10634
+ });
10635
+ if (flags.json === true) {
10636
+ printJson({ command: "backup", status: "success", role, file: archivePath });
10637
+ } else {
10638
+ console.log(`Backup created: ${archivePath}`);
10639
+ }
10640
+ }
10641
+ async function serverRestore(flags) {
10642
+ requireDocker();
10643
+ const installPath = resolveServerPath(flags);
10644
+ const config = loadConfigForInstallPath(installPath);
10645
+ const role = getServerRole(flags, config);
10646
+ const file = typeof flags.file === "string" ? flags.file : "";
10647
+ if (!file) throw new Error("Usage: openmates server restore --file <backup.tar.gz> [--role core|upload|preview] [--yes]");
10648
+ const restorePlan = planRestore({ role, file, yes: flags.yes === true });
10649
+ if (restorePlan.requiresConfirmation) {
10650
+ console.error(`
10651
+ WARNING: This will restore ${role} data from ${file}.`);
10652
+ const confirmed = await confirmDestructive("RESTORE OPENMATES BACKUP");
10653
+ if (!confirmed) {
10654
+ console.error("Restore cancelled.");
10655
+ return;
10656
+ }
10657
+ }
10658
+ const withOverrides = config?.composeProfile === "full";
10659
+ const installMode = getInstallMode(installPath, config);
10660
+ const stopArgs = [...composeArgs(installPath, withOverrides, installMode, role), "stop", ...restoreStopServices(role)];
10661
+ let code = await runInteractive("docker", stopArgs, installPath);
10662
+ if (code !== 0) process.exit(code);
10663
+ restoreServerBackup(installPath, role, file);
10664
+ code = await runInteractive("docker", [...composeArgs(installPath, withOverrides, installMode, role), "up", "-d"], installPath);
10665
+ if (code !== 0) process.exit(code);
10666
+ await waitForServerHealth(installPath, role);
10667
+ if (flags.json === true) {
10668
+ printJson({ command: "restore", status: "success", role, file });
10669
+ } else {
10670
+ console.log(`Restore completed for ${role}.`);
10671
+ }
10672
+ }
9891
10673
  async function serverUninstall(flags) {
9892
10674
  requireDocker();
9893
10675
  const installPath = resolveServerPath(flags);
9894
10676
  ensureGitWorkDirEnv(installPath);
9895
10677
  const config = loadConfigForInstallPath(installPath);
10678
+ const role = getServerRole(flags, config);
9896
10679
  const withOverrides = config?.composeProfile === "full";
9897
10680
  const keepData = flags["keep-data"] === true;
9898
10681
  console.error("\nWARNING: This will completely uninstall OpenMates:");
@@ -9914,7 +10697,7 @@ async function serverUninstall(flags) {
9914
10697
  }
9915
10698
  }
9916
10699
  console.error("Uninstalling OpenMates...");
9917
- const downArgs = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config)), "down", "--rmi", "local"];
10700
+ const downArgs = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config), role), "down", "--rmi", "local"];
9918
10701
  if (!keepData) {
9919
10702
  downArgs.push("-v");
9920
10703
  }
@@ -9940,6 +10723,113 @@ async function serverUninstall(flags) {
9940
10723
  }
9941
10724
  }
9942
10725
  }
10726
+ async function serverPreflight(flags) {
10727
+ const installPath = resolveServerPath(flags);
10728
+ const config = loadConfigForInstallPath(installPath);
10729
+ const role = getServerRole(flags, config);
10730
+ const services = hasServiceFilter(flags) ? selectedComposeServices(role, flags) : [];
10731
+ const updatePlan = planUpdate({ role, selectedServices: services, dryRun: true });
10732
+ const missingEnvKeys = missingRequiredEnvKeys(installPath, role);
10733
+ const runtimePlan = planServerRuntime({
10734
+ role,
10735
+ profile: getCoreProfile(flags, config),
10736
+ withAlerts: flags["with-alerts"] === true
10737
+ });
10738
+ const caddyPlan = planCaddyCommand({ role, action: "status" });
10739
+ const preflight = {
10740
+ command: "preflight",
10741
+ status: updatePlan.blocked ? "blocked" : "ok",
10742
+ path: installPath,
10743
+ role,
10744
+ profile: runtimePlan.profile,
10745
+ services: hasServiceFilter(flags) ? services : "all",
10746
+ healthChecks: runtimePlan.healthChecks,
10747
+ updateSteps: updatePlan.steps,
10748
+ backupName: updatePlan.backupName,
10749
+ missingRequiredEnvKeys: missingEnvKeys,
10750
+ caddy: caddyPlan
10751
+ };
10752
+ if (flags.json === true) {
10753
+ printJson(preflight);
10754
+ return;
10755
+ }
10756
+ console.log("Server preflight plan:");
10757
+ console.log(` Role: ${role}`);
10758
+ console.log(` Profile: ${runtimePlan.profile ?? "n/a"}`);
10759
+ console.log(` Services: ${hasServiceFilter(flags) ? services.join(", ") : "all"}`);
10760
+ console.log(` Backup: ${updatePlan.backupName ?? "none"}`);
10761
+ console.log(` Env preflight: ${missingEnvKeys.length ? `missing ${missingEnvKeys.join(", ")}` : "ok"}`);
10762
+ console.log(` Health checks: ${runtimePlan.healthChecks.join(", ")}`);
10763
+ console.log(` Caddy steps: ${caddyPlan.steps.join(" -> ")}`);
10764
+ }
10765
+ async function serverCaddy(rest, flags) {
10766
+ const action = rest[0] ?? "status";
10767
+ if (!["check", "status", "diff", "apply"].includes(action)) {
10768
+ throw new Error("Usage: openmates server caddy check|status|diff|apply [--role core|upload|preview]");
10769
+ }
10770
+ const config = loadServerConfig();
10771
+ const role = getServerRole(flags, config);
10772
+ const appliedPath = typeof flags.config === "string" ? flags.config : "/etc/caddy/Caddyfile";
10773
+ const templatePath = packagedCaddyTemplatePath(role);
10774
+ if (!existsSync5(templatePath)) throw new Error(`Packaged Caddy template not found: ${templatePath}`);
10775
+ const plan = planCaddyCommand({ role, action, appliedPath });
10776
+ const payload = {
10777
+ ...plan,
10778
+ templatePath,
10779
+ templateHash: fileHash(templatePath),
10780
+ appliedHash: fileHash(appliedPath),
10781
+ drift: fileHash(templatePath) !== fileHash(appliedPath)
10782
+ };
10783
+ if (flags.json === true) {
10784
+ printJson(payload);
10785
+ return;
10786
+ }
10787
+ if (action === "check") {
10788
+ execSync(`caddy validate --config ${shellQuote(templatePath)}`, { stdio: "inherit" });
10789
+ console.log(`Caddy template is valid for ${role}.`);
10790
+ return;
10791
+ }
10792
+ if (action === "diff") {
10793
+ if (!existsSync5(appliedPath)) throw new Error(`Applied Caddyfile not found: ${appliedPath}`);
10794
+ try {
10795
+ execSync(`diff -u ${shellQuote(appliedPath)} ${shellQuote(templatePath)}`, { stdio: "inherit" });
10796
+ } catch (error) {
10797
+ const status = typeof error === "object" && error !== null && "status" in error ? error.status : void 0;
10798
+ if (status !== 1) throw error;
10799
+ }
10800
+ return;
10801
+ }
10802
+ if (action === "apply") {
10803
+ execSync(`caddy validate --config ${shellQuote(templatePath)}`, { stdio: "inherit" });
10804
+ if (flags.yes !== true) {
10805
+ console.error(`
10806
+ WARNING: This will replace ${appliedPath} with the packaged ${role} Caddyfile.`);
10807
+ const confirmed = await confirmDestructive("APPLY CADDYFILE");
10808
+ if (!confirmed) {
10809
+ console.error("Caddy apply cancelled.");
10810
+ return;
10811
+ }
10812
+ }
10813
+ const backupPath = `${appliedPath}.openmates-backup-${nowStamp()}`;
10814
+ try {
10815
+ if (existsSync5(appliedPath)) copyFileSync(appliedPath, backupPath);
10816
+ copyFileSync(templatePath, appliedPath);
10817
+ execSync("systemctl reload caddy", { stdio: "inherit" });
10818
+ } catch (error) {
10819
+ throw new Error(`Could not apply Caddyfile. Run with sudo or use --config <writable path>. ${error instanceof Error ? error.message : String(error)}`);
10820
+ }
10821
+ console.log(`Applied Caddyfile for ${role}. Backup: ${backupPath}`);
10822
+ return;
10823
+ }
10824
+ console.log(`Caddy ${action} plan:`);
10825
+ console.log(` Role: ${payload.role}`);
10826
+ console.log(` Template: ${payload.templatePath}`);
10827
+ console.log(` Applied: ${payload.appliedPath}`);
10828
+ console.log(` Template hash: ${payload.templateHash ?? "missing"}`);
10829
+ console.log(` Applied hash: ${payload.appliedHash ?? "missing"}`);
10830
+ console.log(` Drift: ${payload.drift ? "yes" : "no"}`);
10831
+ console.log(` Steps: ${payload.steps.join(" -> ")}`);
10832
+ }
9943
10833
  function printServerHelp() {
9944
10834
  console.log(`
9945
10835
  OpenMates Server Management
@@ -9954,6 +10844,10 @@ Commands:
9954
10844
  status Show server status (container health)
9955
10845
  logs Display server logs
9956
10846
  update Update to latest version (pull images, or git pull + rebuild for source installs)
10847
+ preflight Show role/update/Caddy preflight plan
10848
+ caddy Plan host-level Caddyfile check/status/diff/apply operations
10849
+ backup Create or list backups
10850
+ restore Restore a backup archive
9957
10851
  make-admin Grant admin privileges to a user
9958
10852
  ai Manage self-hosted local AI models
9959
10853
  reset Reset server data (requires confirmation)
@@ -9962,33 +10856,57 @@ Commands:
9962
10856
  Global Options:
9963
10857
  --path <dir> Override the server installation directory
9964
10858
  --json Output machine-readable JSON
10859
+ --role <role> Server role: core, upload, or preview (default: core)
9965
10860
  --help Show this help message
9966
10861
 
9967
10862
  Command Options:
9968
10863
  install:
9969
10864
  --path <dir> Install directory (default: ~/openmates)
9970
10865
  --env-path <file> Copy a pre-existing .env file during install
10866
+ --profile <name> Core profile: minimal, standard, or production
10867
+ --with-alerts Include alertmanager in production profile planning
9971
10868
  --image-tag <tag> Prebuilt image tag (default: CLI version tag)
9972
10869
  --from-source Clone/build from source instead of using prebuilt GHCR images
9973
10870
  --source-path <dir> Clone from a local checkout instead of GitHub (implies --from-source)
9974
10871
 
9975
10872
  start:
9976
10873
  --with-overrides Include admin UIs (Directus CMS, Grafana)
10874
+ --services <csv> Start only selected role services
10875
+ --exclude <csv> Start all role services except selected services
9977
10876
 
9978
10877
  restart:
9979
10878
  --rebuild Full rebuild (down + build + up) instead of graceful restart
10879
+ --services <csv> Restart only selected role services
10880
+ --exclude <csv> Restart all role services except selected services
9980
10881
 
9981
10882
  logs:
9982
10883
  --container <name> Filter logs to a specific service (e.g. api, cms)
10884
+ --services <csv> Filter logs to selected role services
10885
+ --exclude <csv> Filter logs to all role services except selected services
9983
10886
  --follow, -f Stream logs in real time
9984
10887
  --tail <n> Number of lines to show (default: 100)
9985
10888
 
9986
10889
  update:
9987
10890
  --dry-run Show update plan without changing files or containers
10891
+ --services <csv> Update only selected role services
10892
+ --exclude <csv> Update all role services except selected services
9988
10893
  --image-tag <tag> Image mode: update to a specific prebuilt image tag
9989
10894
  --channel <name> Image mode: update using stable/main or dev channel tags
10895
+ --continuous Run continuously in foreground, or use with install-service
10896
+ --interval <min> Foreground continuous update interval (default: 30)
10897
+ install-service --continuous --channel <name> --window <window>
9990
10898
  --force Source mode: stash local changes before pulling
9991
10899
 
10900
+ backup:
10901
+ openmates server backup [--role core|upload|preview] [--output <file>] [--include-observability]
10902
+ openmates server backup list [--role core|upload|preview]
10903
+
10904
+ restore:
10905
+ openmates server restore --file <backup.tar.gz> [--role core|upload|preview] [--yes]
10906
+
10907
+ caddy:
10908
+ openmates server caddy check|status|diff|apply [--role core|upload|preview] [--config /etc/caddy/Caddyfile]
10909
+
9992
10910
  reset:
9993
10911
  --delete-user-data-only Only delete database and cache (preserve config)
9994
10912
  --yes Skip confirmation prompt
@@ -10047,7 +10965,15 @@ async function handleServer(subcommand, rest, flags) {
10047
10965
  case "install":
10048
10966
  return serverInstall(flags);
10049
10967
  case "update":
10050
- return serverUpdate(flags);
10968
+ return serverUpdate(rest, flags);
10969
+ case "preflight":
10970
+ return serverPreflight(flags);
10971
+ case "caddy":
10972
+ return serverCaddy(rest, flags);
10973
+ case "backup":
10974
+ return serverBackup(rest, flags);
10975
+ case "restore":
10976
+ return serverRestore(flags);
10051
10977
  case "reset":
10052
10978
  return serverReset(flags);
10053
10979
  case "make-admin":
@@ -26725,6 +27651,12 @@ Only output the final Markdown table. Do NOT include explanations, notes, or any
26725
27651
  text: "Upload PDFs and read or search their content."
26726
27652
  }
26727
27653
  },
27654
+ mindmaps: {
27655
+ text: "Mind Maps",
27656
+ description: {
27657
+ text: "Create structured idea maps and brainstorm topic trees."
27658
+ }
27659
+ },
26728
27660
  math: {
26729
27661
  text: "Math",
26730
27662
  description: {
@@ -26788,6 +27720,47 @@ Only output the final Markdown table. Do NOT include explanations, notes, or any
26788
27720
  generated_by_cost: {
26789
27721
  text: "Cost: {credits} credits"
26790
27722
  },
27723
+ interests: {
27724
+ eyebrow: {
27725
+ text: "Private local personalization"
27726
+ },
27727
+ title: {
27728
+ text: "What are you interested in?"
27729
+ },
27730
+ continue: {
27731
+ text: "Continue"
27732
+ },
27733
+ software_development: {
27734
+ text: "software development"
27735
+ },
27736
+ use_the_cli: {
27737
+ text: "use the CLI"
27738
+ },
27739
+ open_source: {
27740
+ text: "open source"
27741
+ },
27742
+ read_developer_docs: {
27743
+ text: "read developer docs"
27744
+ },
27745
+ run_code: {
27746
+ text: "run code"
27747
+ },
27748
+ protect_my_privacy: {
27749
+ text: "protect my privacy"
27750
+ },
27751
+ summarize_documents: {
27752
+ text: "summarize documents"
27753
+ },
27754
+ find_apartments: {
27755
+ text: "find apartments"
27756
+ },
27757
+ local_life: {
27758
+ text: "local life"
27759
+ },
27760
+ learn_anything: {
27761
+ text: "learn anything"
27762
+ }
27763
+ },
26791
27764
  new_chat: {
26792
27765
  text: "New chat"
26793
27766
  },
@@ -27213,6 +28186,9 @@ Only output the final Markdown table. Do NOT include explanations, notes, or any
27213
28186
  learn_coding: {
27214
28187
  text: "How do I start learning to code?"
27215
28188
  },
28189
+ use_openmates_cli_api: {
28190
+ text: "Show me how to use OpenMates from the CLI or API"
28191
+ },
27216
28192
  stock_market: {
27217
28193
  text: "Explain how the stock market works"
27218
28194
  },
@@ -30378,6 +31354,32 @@ Only output the final Markdown table. Do NOT include explanations, notes, or any
30378
31354
  text: "Plot"
30379
31355
  }
30380
31356
  },
31357
+ mindmaps: {
31358
+ mindmap: {
31359
+ text: "Mind Map"
31360
+ },
31361
+ counts: {
31362
+ text: "{nodes} nodes \xB7 {edges} edges"
31363
+ },
31364
+ invalid_json: {
31365
+ text: "Invalid mind map JSON"
31366
+ },
31367
+ invalid_content: {
31368
+ text: "Invalid content"
31369
+ },
31370
+ validation_warnings: {
31371
+ text: "Validation warnings"
31372
+ },
31373
+ source: {
31374
+ text: "Source"
31375
+ },
31376
+ expand: {
31377
+ text: "Expand {label}"
31378
+ },
31379
+ collapse: {
31380
+ text: "Collapse {label}"
31381
+ }
31382
+ },
30381
31383
  diagrams: {
30382
31384
  mermaid: {
30383
31385
  text: "Mermaid Diagram"
@@ -36409,6 +37411,21 @@ As of mid-2026, the severe supply shocks from the 2024\u20132025 avian flu have
36409
37411
  },
36410
37412
  import_importing: {
36411
37413
  text: "Importing\u2026"
37414
+ },
37415
+ interests: {
37416
+ text: "Interests"
37417
+ },
37418
+ interests_description: {
37419
+ text: "Choose the topics OpenMates should prioritize when it suggests chats, examples, and product tips."
37420
+ },
37421
+ interests_privacy_note: {
37422
+ text: "These preferences are encrypted on your device before syncing. The server stores only ciphertext."
37423
+ },
37424
+ interests_saved: {
37425
+ text: "Interests saved"
37426
+ },
37427
+ interests_save_error: {
37428
+ text: "Could not save interests. Please try again."
36412
37429
  }
36413
37430
  },
36414
37431
  ai: {
@@ -39929,6 +40946,9 @@ As of mid-2026, the severe supply shocks from the 2024\u20132025 avian flu have
39929
40946
  server_will_be_restarted: {
39930
40947
  text: "The server will be restarted and\ntherefore briefly offline once\nthe update has been installed."
39931
40948
  },
40949
+ cli_managed_update_notice: {
40950
+ text: "Install updates from the server host with <code>openmates server update</code>."
40951
+ },
39932
40952
  installing_update: {
39933
40953
  text: "Installing update..."
39934
40954
  },
@@ -42301,7 +43321,7 @@ function buildAssistantFeedbackDecision(rating) {
42301
43321
 
42302
43322
  // src/benchmark.ts
42303
43323
  import { randomUUID as randomUUID3 } from "crypto";
42304
- import { existsSync as existsSync6, mkdtempSync, readFileSync as readFileSync6, readdirSync, writeFileSync as writeFileSync4 } from "fs";
43324
+ import { existsSync as existsSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync6, readdirSync as readdirSync2, writeFileSync as writeFileSync4 } from "fs";
42305
43325
  import { tmpdir } from "os";
42306
43326
  import { dirname as dirname2, join as join4, resolve as resolve5 } from "path";
42307
43327
  import { fileURLToPath } from "url";
@@ -43100,7 +44120,7 @@ function loadProviderPricing() {
43100
44120
  const providersDir = findProvidersDir();
43101
44121
  const pricing = /* @__PURE__ */ new Map();
43102
44122
  if (!providersDir) return pricing;
43103
- for (const fileName of readdirSync(providersDir)) {
44123
+ for (const fileName of readdirSync2(providersDir)) {
43104
44124
  if (!fileName.endsWith(".yml")) continue;
43105
44125
  const filePath = join4(providersDir, fileName);
43106
44126
  const text = readFileSync6(filePath, "utf-8");
@@ -43285,7 +44305,7 @@ function defaultImageFixturePath() {
43285
44305
  const fixtureDir = join4(dirname2(fileURLToPath(import.meta.url)), "..", "fixtures");
43286
44306
  const fixturePath = join4(fixtureDir, "brandenburger-tor.png");
43287
44307
  if (existsSync6(fixturePath)) return fixturePath;
43288
- const tempDir = mkdtempSync(join4(tmpdir(), "openmates-benchmark-"));
44308
+ const tempDir = mkdtempSync2(join4(tmpdir(), "openmates-benchmark-"));
43289
44309
  const tempPath = join4(tempDir, "brandenburger-tor.svg");
43290
44310
  writeFileSync4(tempPath, FIXTURE_IMAGE_SVG, "utf-8");
43291
44311
  return tempPath;
@@ -44804,6 +45824,9 @@ async function handleEmbeds(client, subcommand, rest, flags) {
44804
45824
  var SETTINGS_EXECUTABLE_COMMANDS = [
44805
45825
  { path: ["account", "info"], description: "Show account info", examples: ["openmates settings account info --json"] },
44806
45826
  { path: ["account", "timezone", "set"], description: "Set account timezone", examples: ["openmates settings account timezone set Europe/Berlin"] },
45827
+ { path: ["account", "interests", "list"], description: "Show encrypted account topic interests", examples: ["openmates settings account interests list --json"] },
45828
+ { path: ["account", "interests", "set"], description: "Set encrypted account topic interests", examples: ["openmates settings account interests set software_development use_the_cli"] },
45829
+ { path: ["account", "interests", "clear"], description: "Clear encrypted account topic interests", examples: ["openmates settings account interests clear --yes"] },
44807
45830
  { path: ["account", "export", "manifest"], description: "Show account export manifest", examples: ["openmates settings account export manifest --json"] },
44808
45831
  { path: ["account", "export", "data"], description: "Fetch account export data", examples: ["openmates settings account export data --json"] },
44809
45832
  { path: ["account", "import-chat"], description: "Import a CLI chat export file", examples: ["openmates settings account import-chat ./chat.yml", "openmates settings account import-chat ./payload.json"] },
@@ -44905,6 +45928,30 @@ async function printSettingsMutationResult(resultPromise, flags) {
44905
45928
  process.stdout.write("\x1B[32m\u2713\x1B[0m Settings updated\n");
44906
45929
  if (result && typeof result === "object") printGenericObject(result);
44907
45930
  }
45931
+ function printTopicPreferences(preferences, flags, successLabel) {
45932
+ const payload = preferences ?? {
45933
+ version: 1,
45934
+ selectedTagIds: [],
45935
+ updatedAt: null
45936
+ };
45937
+ const result = {
45938
+ ...payload,
45939
+ availableTagIds: INTEREST_TAG_IDS
45940
+ };
45941
+ if (flags.json === true) {
45942
+ printJson2(result);
45943
+ return;
45944
+ }
45945
+ if (successLabel) {
45946
+ process.stdout.write(`\x1B[32m\u2713\x1B[0m ${successLabel}
45947
+ `);
45948
+ }
45949
+ const selected = result.selectedTagIds.length > 0 ? result.selectedTagIds.join(", ") : "none";
45950
+ process.stdout.write(`Selected interests: ${selected}
45951
+ `);
45952
+ process.stdout.write(`Available interests: ${INTEREST_TAG_IDS.join(", ")}
45953
+ `);
45954
+ }
44908
45955
  function printReportIssueCreateResult(result, flags) {
44909
45956
  if (flags.json === true) {
44910
45957
  printJson2(result);
@@ -45428,6 +46475,30 @@ async function handleSettings(client, subcommand, rest, flags) {
45428
46475
  );
45429
46476
  return;
45430
46477
  }
46478
+ if (matches(tokens, ["account", "interests", "list"])) {
46479
+ const preferences = await client.getTopicPreferences();
46480
+ printTopicPreferences(preferences, flags);
46481
+ return;
46482
+ }
46483
+ if (matches(tokens, ["account", "interests", "set"])) {
46484
+ const selectedTagIds = rest.slice(2);
46485
+ if (selectedTagIds.length === 0) {
46486
+ throw new Error(
46487
+ `Missing interest tag IDs. Use one or more of: ${INTEREST_TAG_IDS.join(", ")}`
46488
+ );
46489
+ }
46490
+ const preferences = await client.setTopicPreferences(selectedTagIds);
46491
+ printTopicPreferences(preferences, flags, "Interests updated");
46492
+ return;
46493
+ }
46494
+ if (matches(tokens, ["account", "interests", "clear"])) {
46495
+ if (flags.yes !== true) {
46496
+ await confirmOrExit("Clear account interests? [y/N] ");
46497
+ }
46498
+ const preferences = await client.clearTopicPreferences();
46499
+ printTopicPreferences(preferences, flags, "Interests cleared");
46500
+ return;
46501
+ }
45431
46502
  if (matches(tokens, ["account", "export", "manifest"])) {
45432
46503
  await printSettingsResult(client.settingsGet("export-account-manifest"), flags);
45433
46504
  return;
@@ -48323,6 +49394,8 @@ if (isCliEntrypoint()) {
48323
49394
  }
48324
49395
 
48325
49396
  export {
49397
+ INTEREST_TAG_IDS,
49398
+ normalizeInterestTagIds,
48326
49399
  MEMORY_TYPE_REGISTRY,
48327
49400
  MATE_NAMES,
48328
49401
  deriveAppUrl,