openmates 0.12.0-alpha.22 → 0.12.0-alpha.24

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,159 @@ 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 createServerBackup(installPath, role, options = {}) {
9609
+ const plan = planBackup({ role, includeObservability: options.includeObservability });
9610
+ const backupDir = roleBackupDir(installPath, role);
9611
+ mkdirSync3(backupDir, { recursive: true, mode: 448 });
9612
+ const archivePath = options.output ? resolve3(options.output) : join3(backupDir, options.preUpdate ? `latest-pre-update-${role}.tar.gz` : `openmates-${role}-${nowStamp()}.tar.gz`);
9613
+ const tempDir = mkdtempSync(join3(backupDir, ".tmp-"));
9614
+ const env = readEnvMap(installPath);
9615
+ const manifest = {
9616
+ role,
9617
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
9618
+ cli_version: getPackageVersion(),
9619
+ image_tag: getEnvVar(existsSync5(join3(installPath, ".env")) ? readFileSync5(join3(installPath, ".env"), "utf-8") : "", "OPENMATES_IMAGE_TAG"),
9620
+ include_observability: options.includeObservability === true,
9621
+ contents: plan.contents
9622
+ };
9623
+ try {
9624
+ writeFileSync3(join3(tempDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
9625
+ `);
9626
+ copyIfExists(join3(installPath, ".env"), join3(tempDir, "runtime", ".env"));
9627
+ copyIfExists(join3(installPath, "config"), join3(tempDir, "runtime", "config"));
9628
+ if (role === "core") {
9629
+ const databaseUser = env.DATABASE_USERNAME || "directus";
9630
+ const databaseName = env.DATABASE_NAME || "directus";
9631
+ try {
9632
+ const dump = execSync(`docker exec cms-database pg_dump -U ${shellQuote(databaseUser)} ${shellQuote(databaseName)}`, { encoding: "utf-8" });
9633
+ writeFileSync3(join3(tempDir, "postgres.sql"), dump);
9634
+ } catch (error) {
9635
+ throw new Error(`Postgres backup failed. Is cms-database running? ${error instanceof Error ? error.message : String(error)}`);
9636
+ }
9637
+ }
9638
+ for (const item of [
9639
+ [join3(installPath, "backend", "core", "uploads"), join3(tempDir, "directus-uploads")],
9640
+ [join3(installPath, "backend", "core", "extensions"), join3(tempDir, "directus-extensions")],
9641
+ [join3(installPath, "backend", role, "vault"), join3(tempDir, `${role}-vault-config`)]
9642
+ ]) {
9643
+ copyIfExists(item[0], item[1]);
9644
+ }
9645
+ writeChecksums(tempDir);
9646
+ execSync(`tar -czf ${shellQuote(archivePath)} -C ${shellQuote(tempDir)} .`, { stdio: "pipe" });
9647
+ chmodSync2(archivePath, plan.fileMode);
9648
+ return archivePath;
9649
+ } finally {
9650
+ rmSync3(tempDir, { recursive: true, force: true });
9651
+ }
9652
+ }
9653
+ function restoreServerBackup(installPath, role, file) {
9654
+ const archivePath = resolve3(file);
9655
+ if (!existsSync5(archivePath)) throw new Error(`Backup file not found: ${archivePath}`);
9656
+ mkdirSync3(roleBackupDir(installPath, role), { recursive: true, mode: 448 });
9657
+ const tempDir = mkdtempSync(join3(roleBackupDir(installPath, role), ".restore-"));
9658
+ const env = readEnvMap(installPath);
9659
+ try {
9660
+ execSync(`tar -xzf ${shellQuote(archivePath)} -C ${shellQuote(tempDir)}`, { stdio: "pipe" });
9661
+ const manifestPath = join3(tempDir, "manifest.json");
9662
+ if (!existsSync5(manifestPath)) throw new Error("Backup archive is missing manifest.json.");
9663
+ const manifest = JSON.parse(readFileSync5(manifestPath, "utf-8"));
9664
+ if (manifest.role !== role) throw new Error(`Backup role '${manifest.role}' does not match requested role '${role}'.`);
9665
+ copyIfExists(join3(tempDir, "runtime", ".env"), join3(installPath, ".env"));
9666
+ copyIfExists(join3(tempDir, "runtime", "config"), join3(installPath, "config"));
9667
+ const postgresDump = join3(tempDir, "postgres.sql");
9668
+ if (role === "core" && existsSync5(postgresDump)) {
9669
+ const databaseUser = env.DATABASE_USERNAME || "directus";
9670
+ const databaseName = env.DATABASE_NAME || "directus";
9671
+ execSync(`docker exec -i cms-database psql -U ${shellQuote(databaseUser)} ${shellQuote(databaseName)}`, {
9672
+ input: readFileSync5(postgresDump),
9673
+ stdio: ["pipe", "pipe", "pipe"]
9674
+ });
9675
+ }
9676
+ } finally {
9677
+ rmSync3(tempDir, { recursive: true, force: true });
9678
+ }
9679
+ }
9107
9680
  async function promptText(question, defaultValue = "") {
9108
9681
  const rl = createPromptInterface({ input: process.stdin, output: process.stderr });
9109
9682
  try {
@@ -9200,11 +9773,13 @@ async function serverStatus(flags) {
9200
9773
  const installPath = resolveServerPath(flags);
9201
9774
  ensureGitWorkDirEnv(installPath);
9202
9775
  const config = loadConfigForInstallPath(installPath);
9776
+ const role = getServerRole(flags, config);
9203
9777
  const withOverrides = config?.composeProfile === "full";
9204
- const args = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config)), "ps"];
9778
+ const args = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config), role), "ps"];
9205
9779
  if (flags.json === true) {
9206
9780
  args.push("--format", "json");
9207
9781
  }
9782
+ if (hasServiceFilter(flags)) args.push(...selectedComposeServices(role, flags));
9208
9783
  const code = await runInteractive("docker", args, installPath);
9209
9784
  if (code !== 0) process.exit(code);
9210
9785
  }
@@ -9215,9 +9790,15 @@ async function serverStart(flags) {
9215
9790
  warnIfMissingLlmCredentials(installPath);
9216
9791
  const withOverrides = flags["with-overrides"] === true;
9217
9792
  const config = loadConfigForInstallPath(installPath);
9793
+ const role = getServerRole(flags, config);
9218
9794
  const installMode = getInstallMode(installPath, config);
9219
- const pullArgs = [...composeArgs(installPath, withOverrides, installMode), "pull"];
9220
- const args = [...composeArgs(installPath, withOverrides, installMode), "up", "-d"];
9795
+ const pullArgs = [...composeArgs(installPath, withOverrides, installMode, role), "pull"];
9796
+ const args = [...composeArgs(installPath, withOverrides, installMode, role), "up", "-d"];
9797
+ if (hasServiceFilter(flags)) {
9798
+ const services = selectedComposeServices(role, flags);
9799
+ pullArgs.push(...services);
9800
+ args.push(...services);
9801
+ }
9221
9802
  if (config && withOverrides && config.composeProfile !== "full") {
9222
9803
  saveServerConfig({ ...config, composeProfile: "full" });
9223
9804
  }
@@ -9246,8 +9827,9 @@ async function serverStop(flags) {
9246
9827
  const installPath = resolveServerPath(flags);
9247
9828
  ensureGitWorkDirEnv(installPath);
9248
9829
  const config = loadConfigForInstallPath(installPath);
9830
+ const role = getServerRole(flags, config);
9249
9831
  const withOverrides = config?.composeProfile === "full";
9250
- const args = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config)), "down"];
9832
+ const args = hasServiceFilter(flags) ? [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config), role), "stop", ...selectedComposeServices(role, flags)] : [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config), role), "down"];
9251
9833
  console.error("Stopping OpenMates server...");
9252
9834
  const code = await runInteractive("docker", args, installPath);
9253
9835
  if (code !== 0) process.exit(code);
@@ -9262,31 +9844,36 @@ async function serverRestart(flags) {
9262
9844
  const installPath = resolveServerPath(flags);
9263
9845
  ensureGitWorkDirEnv(installPath);
9264
9846
  const config = loadConfigForInstallPath(installPath);
9847
+ const role = getServerRole(flags, config);
9265
9848
  const withOverrides = config?.composeProfile === "full";
9266
9849
  const installMode = getInstallMode(installPath, config);
9267
9850
  if (flags.rebuild === true) {
9851
+ if (hasServiceFilter(flags)) {
9852
+ throw new Error("--services/--exclude cannot be combined with --rebuild. Use graceful restart for service-scoped restarts.");
9853
+ }
9268
9854
  if (installMode === "image") {
9269
9855
  throw new Error(
9270
9856
  "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
9857
  );
9272
9858
  }
9273
9859
  console.error("Rebuilding OpenMates server (this may take a few minutes)...");
9274
- const downArgs = [...composeArgs(installPath, withOverrides, installMode), "down"];
9860
+ const downArgs = [...composeArgs(installPath, withOverrides, installMode, role), "down"];
9275
9861
  let code = await runInteractive("docker", downArgs, installPath);
9276
9862
  if (code !== 0) process.exit(code);
9277
9863
  try {
9278
9864
  exec("docker volume rm openmates-cache-data", installPath);
9279
9865
  } catch {
9280
9866
  }
9281
- const buildArgs = [...composeArgs(installPath, withOverrides, installMode), "build"];
9867
+ const buildArgs = [...composeArgs(installPath, withOverrides, installMode, role), "build"];
9282
9868
  code = await runInteractive("docker", buildArgs, installPath);
9283
9869
  if (code !== 0) process.exit(code);
9284
- const upArgs = [...composeArgs(installPath, withOverrides, installMode), "up", "-d"];
9870
+ const upArgs = [...composeArgs(installPath, withOverrides, installMode, role), "up", "-d"];
9285
9871
  code = await runInteractive("docker", upArgs, installPath);
9286
9872
  if (code !== 0) process.exit(code);
9287
9873
  } else {
9288
9874
  console.error("Restarting OpenMates server...");
9289
- const args = [...composeArgs(installPath, withOverrides, installMode), "restart"];
9875
+ const args = [...composeArgs(installPath, withOverrides, installMode, role), "restart"];
9876
+ if (hasServiceFilter(flags)) args.push(...selectedComposeServices(role, flags));
9290
9877
  const code = await runInteractive("docker", args, installPath);
9291
9878
  if (code !== 0) process.exit(code);
9292
9879
  }
@@ -9301,8 +9888,9 @@ async function serverLogs(flags) {
9301
9888
  const installPath = resolveServerPath(flags);
9302
9889
  ensureGitWorkDirEnv(installPath);
9303
9890
  const config = loadConfigForInstallPath(installPath);
9891
+ const role = getServerRole(flags, config);
9304
9892
  const withOverrides = config?.composeProfile === "full";
9305
- const args = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config)), "logs"];
9893
+ const args = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config), role), "logs"];
9306
9894
  if (flags.follow === true || flags.f === true) {
9307
9895
  args.push("--follow");
9308
9896
  }
@@ -9312,9 +9900,13 @@ async function serverLogs(flags) {
9312
9900
  } else {
9313
9901
  args.push("--tail", "100");
9314
9902
  }
9315
- const container = flags.container;
9316
- if (typeof container === "string") {
9317
- args.push(container);
9903
+ if (hasServiceFilter(flags)) {
9904
+ args.push(...selectedComposeServices(role, flags));
9905
+ } else {
9906
+ const container = flags.container;
9907
+ if (typeof container === "string") {
9908
+ args.push(container);
9909
+ }
9318
9910
  }
9319
9911
  const code = await runInteractive("docker", args, installPath);
9320
9912
  if (code !== 0) process.exit(code);
@@ -9323,7 +9915,10 @@ async function serverInstall(flags) {
9323
9915
  const installPath = typeof flags.path === "string" ? resolve3(flags.path) : DEFAULT_INSTALL_PATH;
9324
9916
  const sourcePath = typeof flags["source-path"] === "string" ? resolve3(flags["source-path"]) : null;
9325
9917
  const fromSource = flags["from-source"] === true || sourcePath !== null;
9326
- if (existsSync5(join3(installPath, SOURCE_COMPOSE_FILE)) || existsSync5(join3(installPath, IMAGE_COMPOSE_FILE))) {
9918
+ const role = getServerRole(flags, null);
9919
+ const profile = getCoreProfile(flags, null);
9920
+ const runtimePlan = planServerRuntime({ role, profile, withAlerts: flags["with-alerts"] === true });
9921
+ if (existsSync5(join3(installPath, SOURCE_COMPOSE_FILE)) || Object.values(ROLE_IMAGE_COMPOSE_FILES).some((composeFile) => existsSync5(join3(installPath, composeFile)))) {
9327
9922
  console.error(`OpenMates already exists at ${installPath}.`);
9328
9923
  console.error("Use 'openmates server update' to update, or choose a different --path.");
9329
9924
  process.exit(1);
@@ -9341,7 +9936,7 @@ async function serverInstall(flags) {
9341
9936
  }
9342
9937
  const imageTag = typeof flags["image-tag"] === "string" ? flags["image-tag"] : getDefaultImageTag();
9343
9938
  console.error(`Preparing OpenMates image-mode install at ${installPath}...`);
9344
- await writeImageModeRuntimeFiles(installPath, imageTag);
9939
+ await writeImageModeRuntimeFiles(installPath, imageTag, role);
9345
9940
  const cliUrls2 = deriveSelfHostCliUrls(readFileSync5(join3(installPath, ".env"), "utf-8"));
9346
9941
  try {
9347
9942
  exec("docker network create openmates", installPath);
@@ -9350,7 +9945,11 @@ async function serverInstall(flags) {
9350
9945
  saveServerConfig({
9351
9946
  installPath,
9352
9947
  installedAt: Date.now(),
9353
- composeProfile: "core",
9948
+ composeProfile: role === "core" ? "core" : "core",
9949
+ serverRole: role,
9950
+ serverProfile: profile,
9951
+ defaultServices: runtimePlan.defaultServices,
9952
+ composeFiles: runtimePlan.composeFiles,
9354
9953
  installMode: "image",
9355
9954
  imageTag,
9356
9955
  ...cliUrls2
@@ -9417,6 +10016,10 @@ CLI default API: ${cliUrls2.apiUrl}`);
9417
10016
  installPath,
9418
10017
  installedAt: Date.now(),
9419
10018
  composeProfile: "core",
10019
+ serverRole: role,
10020
+ serverProfile: profile,
10021
+ defaultServices: runtimePlan.defaultServices,
10022
+ composeFiles: runtimePlan.composeFiles,
9420
10023
  installMode: "source",
9421
10024
  ...cliUrls
9422
10025
  });
@@ -9434,13 +10037,71 @@ CLI default API: ${cliUrls.apiUrl}`);
9434
10037
  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
10038
  }
9436
10039
  }
9437
- async function serverUpdate(flags) {
10040
+ async function installContinuousUpdateService(flags) {
10041
+ const config = loadServerConfig();
10042
+ const role = getServerRole(flags, config);
10043
+ const channel = typeof flags.channel === "string" ? flags.channel : config?.imageChannel ?? "main";
10044
+ const window = typeof flags.window === "string" ? flags.window : "02:00-04:00 UTC";
10045
+ const plan = planContinuousUpdateService({ role, channel, window });
10046
+ const servicePath = join3("/etc", "systemd", "system", plan.serviceName);
10047
+ const timerPath = join3("/etc", "systemd", "system", plan.timerName);
10048
+ if (flags["dry-run"] === true || flags.json === true) {
10049
+ printJson({ command: "update install-service", status: "planned", role, servicePath, timerPath, unit: plan.unit, timer: plan.timer });
10050
+ return;
10051
+ }
10052
+ try {
10053
+ writeFileSync3(servicePath, plan.unit, { mode: 420 });
10054
+ writeFileSync3(timerPath, plan.timer, { mode: 420 });
10055
+ execSync("systemctl daemon-reload", { stdio: "pipe" });
10056
+ execSync(`systemctl enable --now ${shellQuote(plan.timerName)}`, { stdio: "pipe" });
10057
+ } catch (error) {
10058
+ throw new Error(
10059
+ `Could not install systemd updater. Run with sudo or use --dry-run to inspect generated units. ${error instanceof Error ? error.message : String(error)}`
10060
+ );
10061
+ }
10062
+ console.log(`Installed ${plan.timerName}.`);
10063
+ }
10064
+ async function serverUpdate(rest, flags) {
10065
+ if (rest[0] === "status") {
10066
+ const installPath2 = resolveServerPath(flags);
10067
+ const config2 = loadConfigForInstallPath(installPath2);
10068
+ const role2 = getServerRole(flags, config2);
10069
+ const filePath = updateStatusFile(installPath2, role2);
10070
+ if (flags.json === true) {
10071
+ printJson(existsSync5(filePath) ? JSON.parse(readFileSync5(filePath, "utf-8")) : { role: role2, status: "unknown" });
10072
+ return;
10073
+ }
10074
+ if (!existsSync5(filePath)) {
10075
+ console.log(`No update status recorded for ${role2}.`);
10076
+ return;
10077
+ }
10078
+ console.log(readFileSync5(filePath, "utf-8").trim());
10079
+ return;
10080
+ }
10081
+ if (rest[0] === "install-service") {
10082
+ if (flags.continuous !== true) throw new Error("Usage: openmates server update install-service --continuous [--channel main|dev|stable]");
10083
+ await installContinuousUpdateService(flags);
10084
+ return;
10085
+ }
10086
+ if (flags.continuous === true) {
10087
+ const intervalMinutes = typeof flags.interval === "string" ? Number.parseInt(flags.interval, 10) : 30;
10088
+ if (!Number.isFinite(intervalMinutes) || intervalMinutes < 5) throw new Error("--interval must be at least 5 minutes.");
10089
+ console.error(`Running continuous updater every ${intervalMinutes} minutes. Use Ctrl+C to stop.`);
10090
+ while (true) {
10091
+ await serverUpdate([], { ...flags, continuous: false });
10092
+ await sleep2(intervalMinutes * 6e4);
10093
+ }
10094
+ }
9438
10095
  const installPath = resolveServerPath(flags);
9439
10096
  const dryRun = flags["dry-run"] === true;
9440
10097
  if (!dryRun) ensureGitWorkDirEnv(installPath);
9441
10098
  const config = loadConfigForInstallPath(installPath);
10099
+ const role = getServerRole(flags, config);
9442
10100
  const withOverrides = config?.composeProfile === "full";
9443
10101
  const installMode = getInstallMode(installPath, config);
10102
+ const filterRequested = hasServiceFilter(flags);
10103
+ const selectedServices = filterRequested ? selectedComposeServices(role, flags) : [];
10104
+ const missingEnvKeys = missingRequiredEnvKeys(installPath, role);
9444
10105
  if (installMode === "source" && (flags["image-tag"] !== void 0 || flags.channel !== void 0)) {
9445
10106
  throw new Error("--image-tag and --channel only apply to image-mode installs. Source-mode installs update from Git.");
9446
10107
  }
@@ -9450,14 +10111,22 @@ async function serverUpdate(flags) {
9450
10111
  const currentTag = getImageTagFromEnv(installPath, config);
9451
10112
  const target = resolveTargetImageTag(flags, currentTag, getPackageVersion());
9452
10113
  const templateRef = templateRefForImageTag(target.tag, getPackageVersion());
10114
+ const safetyPlan = planUpdate({ role, selectedServices, dryRun, skipBackup: flags["skip-backup"] === true, continuous: false, missingRequiredSecrets: missingEnvKeys });
9453
10115
  const plan = {
9454
10116
  command: "update",
10117
+ role,
9455
10118
  path: installPath,
9456
10119
  mode: "image",
9457
10120
  currentImageTag: currentTag || null,
9458
10121
  targetImageTag: target.tag,
9459
10122
  channel: target.channel ?? null,
9460
10123
  templateRef,
10124
+ selectedServices: filterRequested ? selectedServices : "all",
10125
+ steps: safetyPlan.steps,
10126
+ backupName: safetyPlan.backupName,
10127
+ missingRequiredEnvKeys: missingEnvKeys,
10128
+ blocked: safetyPlan.blocked,
10129
+ blockReason: safetyPlan.blockReason,
9461
10130
  dryRun
9462
10131
  };
9463
10132
  if (dryRun) {
@@ -9469,37 +10138,59 @@ async function serverUpdate(flags) {
9469
10138
  console.log(` Current tag: ${currentTag || "unknown"}`);
9470
10139
  console.log(` Target tag: ${target.tag}`);
9471
10140
  console.log(` Template ref: ${templateRef}`);
10141
+ console.log(` Role: ${role}`);
10142
+ console.log(` Services: ${filterRequested ? selectedServices.join(", ") : "all"}`);
10143
+ console.log(` Backup: ${safetyPlan.backupName ?? "none"}`);
10144
+ console.log(` Steps: ${safetyPlan.steps.join(" -> ")}`);
10145
+ console.log(` Env preflight: ${missingEnvKeys.length ? `missing ${missingEnvKeys.join(", ")}` : "ok"}`);
9472
10146
  console.log(" Commands: refresh compose, docker compose pull, docker compose up -d, health checks");
9473
10147
  }
9474
10148
  return;
9475
10149
  }
10150
+ if (safetyPlan.blocked) throw new Error(safetyPlan.blockReason ?? "Update blocked by preflight.");
10151
+ if (missingEnvKeys.length && flags.yes !== true) {
10152
+ throw new Error(`Required environment keys are missing: ${missingEnvKeys.join(", ")}. Add them to .env or rerun with --yes after reviewing.`);
10153
+ }
9476
10154
  console.error(`Mode: image`);
9477
10155
  console.error(`Current image tag: ${currentTag || "unknown"}`);
9478
10156
  console.error(`Target image tag: ${target.tag}`);
10157
+ writeUpdateStatus(installPath, role, { status: "in_progress", targetImageTag: target.tag, step: "backup" });
10158
+ if (safetyPlan.backupName) {
10159
+ console.error(`Creating rotating pre-update backup: ${safetyPlan.backupName}`);
10160
+ createServerBackup(installPath, role, { preUpdate: true });
10161
+ } else {
10162
+ console.error("Skipping pre-update backup for this role or because --skip-backup was passed.");
10163
+ }
9479
10164
  console.error(`Refreshing self-host runtime files from ${templateRef}...`);
9480
- await writeImageModeRuntimeFiles(installPath, target.tag);
9481
- const pullArgs = [...composeArgs(installPath, withOverrides, installMode), "pull"];
10165
+ await writeImageModeRuntimeFiles(installPath, target.tag, role);
10166
+ const pullArgs = [...composeArgs(installPath, withOverrides, installMode, role), "pull"];
10167
+ if (filterRequested) pullArgs.push(...selectedServices);
9482
10168
  let code2 = 0;
9483
10169
  if (shouldPullImages()) {
10170
+ writeUpdateStatus(installPath, role, { status: "in_progress", targetImageTag: target.tag, step: "pull" });
9484
10171
  console.error("Pulling prebuilt images...");
9485
10172
  code2 = await runInteractive("docker", pullArgs, installPath);
9486
10173
  if (code2 !== 0) process.exit(code2);
9487
10174
  } else {
9488
10175
  console.error("Skipping image pull because OPENMATES_SKIP_IMAGE_PULL=1.");
9489
10176
  }
9490
- const upArgs2 = [...composeArgs(installPath, withOverrides, installMode), "up", "-d"];
10177
+ writeUpdateStatus(installPath, role, { status: "in_progress", targetImageTag: target.tag, step: "up" });
10178
+ const upArgs2 = [...composeArgs(installPath, withOverrides, installMode, role), "up", "-d"];
10179
+ if (filterRequested) upArgs2.push(...selectedServices);
9491
10180
  code2 = await runInteractive("docker", upArgs2, installPath);
9492
10181
  if (code2 !== 0) process.exit(code2);
9493
- console.error("Waiting for API and web health checks...");
10182
+ console.error("Waiting for role health checks...");
9494
10183
  try {
9495
- await waitForServerHealth(installPath);
10184
+ writeUpdateStatus(installPath, role, { status: "in_progress", targetImageTag: target.tag, step: "health-check" });
10185
+ await waitForServerHealth(installPath, role);
9496
10186
  } catch (error) {
9497
- await runInteractive("docker", [...composeArgs(installPath, withOverrides, installMode), "ps"], installPath);
10187
+ await runInteractive("docker", [...composeArgs(installPath, withOverrides, installMode, role), "ps"], installPath);
9498
10188
  throw error;
9499
10189
  }
9500
10190
  if (config) {
9501
10191
  saveServerConfig({ ...config, imageTag: target.tag, imageChannel: target.channel });
9502
10192
  }
10193
+ writeUpdateStatus(installPath, role, { status: "success", targetImageTag: target.tag, step: "complete" });
9503
10194
  if (flags.json === true) {
9504
10195
  printJson({ ...plan, status: "success", dryRun: false });
9505
10196
  } else {
@@ -9545,18 +10236,18 @@ async function serverUpdate(flags) {
9545
10236
  } catch {
9546
10237
  }
9547
10238
  }
9548
- const buildArgs = [...composeArgs(installPath, withOverrides, installMode), "build"];
10239
+ const buildArgs = [...composeArgs(installPath, withOverrides, installMode, role), "build"];
9549
10240
  console.error("Rebuilding containers...");
9550
10241
  let code = await runInteractive("docker", buildArgs, installPath);
9551
10242
  if (code !== 0) process.exit(code);
9552
- const upArgs = [...composeArgs(installPath, withOverrides, installMode), "up", "-d"];
10243
+ const upArgs = [...composeArgs(installPath, withOverrides, installMode, role), "up", "-d"];
9553
10244
  code = await runInteractive("docker", upArgs, installPath);
9554
10245
  if (code !== 0) process.exit(code);
9555
10246
  console.error("Waiting for API and web health checks...");
9556
10247
  try {
9557
- await waitForServerHealth(installPath);
10248
+ await waitForServerHealth(installPath, role);
9558
10249
  } catch (error) {
9559
- await runInteractive("docker", [...composeArgs(installPath, withOverrides, installMode), "ps"], installPath);
10250
+ await runInteractive("docker", [...composeArgs(installPath, withOverrides, installMode, role), "ps"], installPath);
9560
10251
  throw error;
9561
10252
  }
9562
10253
  if (flags.json === true) {
@@ -9570,6 +10261,7 @@ async function serverReset(flags) {
9570
10261
  const installPath = resolveServerPath(flags);
9571
10262
  ensureGitWorkDirEnv(installPath);
9572
10263
  const config = loadConfigForInstallPath(installPath);
10264
+ const role = getServerRole(flags, config);
9573
10265
  const withOverrides = config?.composeProfile === "full";
9574
10266
  const installMode = getInstallMode(installPath, config);
9575
10267
  const userDataOnly = flags["delete-user-data-only"] === true;
@@ -9591,7 +10283,7 @@ async function serverReset(flags) {
9591
10283
  }
9592
10284
  console.error("Resetting server...");
9593
10285
  if (userDataOnly) {
9594
- const downArgs = [...composeArgs(installPath, withOverrides, installMode), "down"];
10286
+ const downArgs = [...composeArgs(installPath, withOverrides, installMode, role), "down"];
9595
10287
  let code = await runInteractive("docker", downArgs, installPath);
9596
10288
  if (code !== 0) process.exit(code);
9597
10289
  for (const vol of ["openmates-cache-data", "openmates-postgres-data", "openmates-cms-database-data"]) {
@@ -9602,15 +10294,15 @@ async function serverReset(flags) {
9602
10294
  }
9603
10295
  }
9604
10296
  if (installMode === "source") {
9605
- const buildArgs = [...composeArgs(installPath, withOverrides, installMode), "build"];
10297
+ const buildArgs = [...composeArgs(installPath, withOverrides, installMode, role), "build"];
9606
10298
  code = await runInteractive("docker", buildArgs, installPath);
9607
10299
  if (code !== 0) process.exit(code);
9608
10300
  }
9609
- const upArgs = [...composeArgs(installPath, withOverrides, installMode), "up", "-d"];
10301
+ const upArgs = [...composeArgs(installPath, withOverrides, installMode, role), "up", "-d"];
9610
10302
  code = await runInteractive("docker", upArgs, installPath);
9611
10303
  if (code !== 0) process.exit(code);
9612
10304
  } else {
9613
- const args = [...composeArgs(installPath, withOverrides, installMode), "down", "-v"];
10305
+ const args = [...composeArgs(installPath, withOverrides, installMode, role), "down", "-v"];
9614
10306
  const code = await runInteractive("docker", args, installPath);
9615
10307
  if (code !== 0) process.exit(code);
9616
10308
  }
@@ -9888,11 +10580,75 @@ async function serverAi(rest, flags) {
9888
10580
  throw new Error(`Unknown server ai models command '${action}'. Use add, list, test, or remove.`);
9889
10581
  }
9890
10582
  }
10583
+ async function serverBackup(rest, flags) {
10584
+ const action = rest[0];
10585
+ const installPath = resolveServerPath(flags);
10586
+ const config = loadConfigForInstallPath(installPath);
10587
+ const role = getServerRole(flags, config);
10588
+ if (action === "list") {
10589
+ const dir = roleBackupDir(installPath, role);
10590
+ const files = existsSync5(dir) ? readdirSync(dir).filter((item) => item.endsWith(".tar.gz")).sort() : [];
10591
+ if (flags.json === true) {
10592
+ printJson({ role, backupDir: dir, files });
10593
+ return;
10594
+ }
10595
+ console.log(`Backups for ${role}:`);
10596
+ if (!files.length) {
10597
+ console.log(" none");
10598
+ return;
10599
+ }
10600
+ for (const file of files) console.log(` ${join3(dir, file)}`);
10601
+ return;
10602
+ }
10603
+ requireDocker();
10604
+ const output = typeof flags.output === "string" ? flags.output : void 0;
10605
+ const archivePath = createServerBackup(installPath, role, {
10606
+ output,
10607
+ includeObservability: flags["include-observability"] === true
10608
+ });
10609
+ if (flags.json === true) {
10610
+ printJson({ command: "backup", status: "success", role, file: archivePath });
10611
+ } else {
10612
+ console.log(`Backup created: ${archivePath}`);
10613
+ }
10614
+ }
10615
+ async function serverRestore(flags) {
10616
+ requireDocker();
10617
+ const installPath = resolveServerPath(flags);
10618
+ const config = loadConfigForInstallPath(installPath);
10619
+ const role = getServerRole(flags, config);
10620
+ const file = typeof flags.file === "string" ? flags.file : "";
10621
+ if (!file) throw new Error("Usage: openmates server restore --file <backup.tar.gz> [--role core|upload|preview] [--yes]");
10622
+ const restorePlan = planRestore({ role, file, yes: flags.yes === true });
10623
+ if (restorePlan.requiresConfirmation) {
10624
+ console.error(`
10625
+ WARNING: This will restore ${role} data from ${file}.`);
10626
+ const confirmed = await confirmDestructive("RESTORE OPENMATES BACKUP");
10627
+ if (!confirmed) {
10628
+ console.error("Restore cancelled.");
10629
+ return;
10630
+ }
10631
+ }
10632
+ const withOverrides = config?.composeProfile === "full";
10633
+ const installMode = getInstallMode(installPath, config);
10634
+ let code = await runInteractive("docker", [...composeArgs(installPath, withOverrides, installMode, role), "stop"], installPath);
10635
+ if (code !== 0) process.exit(code);
10636
+ restoreServerBackup(installPath, role, file);
10637
+ code = await runInteractive("docker", [...composeArgs(installPath, withOverrides, installMode, role), "up", "-d"], installPath);
10638
+ if (code !== 0) process.exit(code);
10639
+ await waitForServerHealth(installPath, role);
10640
+ if (flags.json === true) {
10641
+ printJson({ command: "restore", status: "success", role, file });
10642
+ } else {
10643
+ console.log(`Restore completed for ${role}.`);
10644
+ }
10645
+ }
9891
10646
  async function serverUninstall(flags) {
9892
10647
  requireDocker();
9893
10648
  const installPath = resolveServerPath(flags);
9894
10649
  ensureGitWorkDirEnv(installPath);
9895
10650
  const config = loadConfigForInstallPath(installPath);
10651
+ const role = getServerRole(flags, config);
9896
10652
  const withOverrides = config?.composeProfile === "full";
9897
10653
  const keepData = flags["keep-data"] === true;
9898
10654
  console.error("\nWARNING: This will completely uninstall OpenMates:");
@@ -9914,7 +10670,7 @@ async function serverUninstall(flags) {
9914
10670
  }
9915
10671
  }
9916
10672
  console.error("Uninstalling OpenMates...");
9917
- const downArgs = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config)), "down", "--rmi", "local"];
10673
+ const downArgs = [...composeArgs(installPath, withOverrides, getInstallMode(installPath, config), role), "down", "--rmi", "local"];
9918
10674
  if (!keepData) {
9919
10675
  downArgs.push("-v");
9920
10676
  }
@@ -9940,6 +10696,113 @@ async function serverUninstall(flags) {
9940
10696
  }
9941
10697
  }
9942
10698
  }
10699
+ async function serverPreflight(flags) {
10700
+ const installPath = resolveServerPath(flags);
10701
+ const config = loadConfigForInstallPath(installPath);
10702
+ const role = getServerRole(flags, config);
10703
+ const services = hasServiceFilter(flags) ? selectedComposeServices(role, flags) : [];
10704
+ const updatePlan = planUpdate({ role, selectedServices: services, dryRun: true });
10705
+ const missingEnvKeys = missingRequiredEnvKeys(installPath, role);
10706
+ const runtimePlan = planServerRuntime({
10707
+ role,
10708
+ profile: getCoreProfile(flags, config),
10709
+ withAlerts: flags["with-alerts"] === true
10710
+ });
10711
+ const caddyPlan = planCaddyCommand({ role, action: "status" });
10712
+ const preflight = {
10713
+ command: "preflight",
10714
+ status: updatePlan.blocked ? "blocked" : "ok",
10715
+ path: installPath,
10716
+ role,
10717
+ profile: runtimePlan.profile,
10718
+ services: hasServiceFilter(flags) ? services : "all",
10719
+ healthChecks: runtimePlan.healthChecks,
10720
+ updateSteps: updatePlan.steps,
10721
+ backupName: updatePlan.backupName,
10722
+ missingRequiredEnvKeys: missingEnvKeys,
10723
+ caddy: caddyPlan
10724
+ };
10725
+ if (flags.json === true) {
10726
+ printJson(preflight);
10727
+ return;
10728
+ }
10729
+ console.log("Server preflight plan:");
10730
+ console.log(` Role: ${role}`);
10731
+ console.log(` Profile: ${runtimePlan.profile ?? "n/a"}`);
10732
+ console.log(` Services: ${hasServiceFilter(flags) ? services.join(", ") : "all"}`);
10733
+ console.log(` Backup: ${updatePlan.backupName ?? "none"}`);
10734
+ console.log(` Env preflight: ${missingEnvKeys.length ? `missing ${missingEnvKeys.join(", ")}` : "ok"}`);
10735
+ console.log(` Health checks: ${runtimePlan.healthChecks.join(", ")}`);
10736
+ console.log(` Caddy steps: ${caddyPlan.steps.join(" -> ")}`);
10737
+ }
10738
+ async function serverCaddy(rest, flags) {
10739
+ const action = rest[0] ?? "status";
10740
+ if (!["check", "status", "diff", "apply"].includes(action)) {
10741
+ throw new Error("Usage: openmates server caddy check|status|diff|apply [--role core|upload|preview]");
10742
+ }
10743
+ const config = loadServerConfig();
10744
+ const role = getServerRole(flags, config);
10745
+ const appliedPath = typeof flags.config === "string" ? flags.config : "/etc/caddy/Caddyfile";
10746
+ const templatePath = packagedCaddyTemplatePath(role);
10747
+ if (!existsSync5(templatePath)) throw new Error(`Packaged Caddy template not found: ${templatePath}`);
10748
+ const plan = planCaddyCommand({ role, action, appliedPath });
10749
+ const payload = {
10750
+ ...plan,
10751
+ templatePath,
10752
+ templateHash: fileHash(templatePath),
10753
+ appliedHash: fileHash(appliedPath),
10754
+ drift: fileHash(templatePath) !== fileHash(appliedPath)
10755
+ };
10756
+ if (flags.json === true) {
10757
+ printJson(payload);
10758
+ return;
10759
+ }
10760
+ if (action === "check") {
10761
+ execSync(`caddy validate --config ${shellQuote(templatePath)}`, { stdio: "inherit" });
10762
+ console.log(`Caddy template is valid for ${role}.`);
10763
+ return;
10764
+ }
10765
+ if (action === "diff") {
10766
+ if (!existsSync5(appliedPath)) throw new Error(`Applied Caddyfile not found: ${appliedPath}`);
10767
+ try {
10768
+ execSync(`diff -u ${shellQuote(appliedPath)} ${shellQuote(templatePath)}`, { stdio: "inherit" });
10769
+ } catch (error) {
10770
+ const status = typeof error === "object" && error !== null && "status" in error ? error.status : void 0;
10771
+ if (status !== 1) throw error;
10772
+ }
10773
+ return;
10774
+ }
10775
+ if (action === "apply") {
10776
+ execSync(`caddy validate --config ${shellQuote(templatePath)}`, { stdio: "inherit" });
10777
+ if (flags.yes !== true) {
10778
+ console.error(`
10779
+ WARNING: This will replace ${appliedPath} with the packaged ${role} Caddyfile.`);
10780
+ const confirmed = await confirmDestructive("APPLY CADDYFILE");
10781
+ if (!confirmed) {
10782
+ console.error("Caddy apply cancelled.");
10783
+ return;
10784
+ }
10785
+ }
10786
+ const backupPath = `${appliedPath}.openmates-backup-${nowStamp()}`;
10787
+ try {
10788
+ if (existsSync5(appliedPath)) copyFileSync(appliedPath, backupPath);
10789
+ copyFileSync(templatePath, appliedPath);
10790
+ execSync("systemctl reload caddy", { stdio: "inherit" });
10791
+ } catch (error) {
10792
+ throw new Error(`Could not apply Caddyfile. Run with sudo or use --config <writable path>. ${error instanceof Error ? error.message : String(error)}`);
10793
+ }
10794
+ console.log(`Applied Caddyfile for ${role}. Backup: ${backupPath}`);
10795
+ return;
10796
+ }
10797
+ console.log(`Caddy ${action} plan:`);
10798
+ console.log(` Role: ${payload.role}`);
10799
+ console.log(` Template: ${payload.templatePath}`);
10800
+ console.log(` Applied: ${payload.appliedPath}`);
10801
+ console.log(` Template hash: ${payload.templateHash ?? "missing"}`);
10802
+ console.log(` Applied hash: ${payload.appliedHash ?? "missing"}`);
10803
+ console.log(` Drift: ${payload.drift ? "yes" : "no"}`);
10804
+ console.log(` Steps: ${payload.steps.join(" -> ")}`);
10805
+ }
9943
10806
  function printServerHelp() {
9944
10807
  console.log(`
9945
10808
  OpenMates Server Management
@@ -9954,6 +10817,10 @@ Commands:
9954
10817
  status Show server status (container health)
9955
10818
  logs Display server logs
9956
10819
  update Update to latest version (pull images, or git pull + rebuild for source installs)
10820
+ preflight Show role/update/Caddy preflight plan
10821
+ caddy Plan host-level Caddyfile check/status/diff/apply operations
10822
+ backup Create or list backups
10823
+ restore Restore a backup archive
9957
10824
  make-admin Grant admin privileges to a user
9958
10825
  ai Manage self-hosted local AI models
9959
10826
  reset Reset server data (requires confirmation)
@@ -9962,33 +10829,57 @@ Commands:
9962
10829
  Global Options:
9963
10830
  --path <dir> Override the server installation directory
9964
10831
  --json Output machine-readable JSON
10832
+ --role <role> Server role: core, upload, or preview (default: core)
9965
10833
  --help Show this help message
9966
10834
 
9967
10835
  Command Options:
9968
10836
  install:
9969
10837
  --path <dir> Install directory (default: ~/openmates)
9970
10838
  --env-path <file> Copy a pre-existing .env file during install
10839
+ --profile <name> Core profile: minimal, standard, or production
10840
+ --with-alerts Include alertmanager in production profile planning
9971
10841
  --image-tag <tag> Prebuilt image tag (default: CLI version tag)
9972
10842
  --from-source Clone/build from source instead of using prebuilt GHCR images
9973
10843
  --source-path <dir> Clone from a local checkout instead of GitHub (implies --from-source)
9974
10844
 
9975
10845
  start:
9976
10846
  --with-overrides Include admin UIs (Directus CMS, Grafana)
10847
+ --services <csv> Start only selected role services
10848
+ --exclude <csv> Start all role services except selected services
9977
10849
 
9978
10850
  restart:
9979
10851
  --rebuild Full rebuild (down + build + up) instead of graceful restart
10852
+ --services <csv> Restart only selected role services
10853
+ --exclude <csv> Restart all role services except selected services
9980
10854
 
9981
10855
  logs:
9982
10856
  --container <name> Filter logs to a specific service (e.g. api, cms)
10857
+ --services <csv> Filter logs to selected role services
10858
+ --exclude <csv> Filter logs to all role services except selected services
9983
10859
  --follow, -f Stream logs in real time
9984
10860
  --tail <n> Number of lines to show (default: 100)
9985
10861
 
9986
10862
  update:
9987
10863
  --dry-run Show update plan without changing files or containers
10864
+ --services <csv> Update only selected role services
10865
+ --exclude <csv> Update all role services except selected services
9988
10866
  --image-tag <tag> Image mode: update to a specific prebuilt image tag
9989
10867
  --channel <name> Image mode: update using stable/main or dev channel tags
10868
+ --continuous Run continuously in foreground, or use with install-service
10869
+ --interval <min> Foreground continuous update interval (default: 30)
10870
+ install-service --continuous --channel <name> --window <window>
9990
10871
  --force Source mode: stash local changes before pulling
9991
10872
 
10873
+ backup:
10874
+ openmates server backup [--role core|upload|preview] [--output <file>] [--include-observability]
10875
+ openmates server backup list [--role core|upload|preview]
10876
+
10877
+ restore:
10878
+ openmates server restore --file <backup.tar.gz> [--role core|upload|preview] [--yes]
10879
+
10880
+ caddy:
10881
+ openmates server caddy check|status|diff|apply [--role core|upload|preview] [--config /etc/caddy/Caddyfile]
10882
+
9992
10883
  reset:
9993
10884
  --delete-user-data-only Only delete database and cache (preserve config)
9994
10885
  --yes Skip confirmation prompt
@@ -10047,7 +10938,15 @@ async function handleServer(subcommand, rest, flags) {
10047
10938
  case "install":
10048
10939
  return serverInstall(flags);
10049
10940
  case "update":
10050
- return serverUpdate(flags);
10941
+ return serverUpdate(rest, flags);
10942
+ case "preflight":
10943
+ return serverPreflight(flags);
10944
+ case "caddy":
10945
+ return serverCaddy(rest, flags);
10946
+ case "backup":
10947
+ return serverBackup(rest, flags);
10948
+ case "restore":
10949
+ return serverRestore(flags);
10051
10950
  case "reset":
10052
10951
  return serverReset(flags);
10053
10952
  case "make-admin":
@@ -26725,6 +27624,12 @@ Only output the final Markdown table. Do NOT include explanations, notes, or any
26725
27624
  text: "Upload PDFs and read or search their content."
26726
27625
  }
26727
27626
  },
27627
+ mindmaps: {
27628
+ text: "Mind Maps",
27629
+ description: {
27630
+ text: "Create structured idea maps and brainstorm topic trees."
27631
+ }
27632
+ },
26728
27633
  math: {
26729
27634
  text: "Math",
26730
27635
  description: {
@@ -26788,6 +27693,47 @@ Only output the final Markdown table. Do NOT include explanations, notes, or any
26788
27693
  generated_by_cost: {
26789
27694
  text: "Cost: {credits} credits"
26790
27695
  },
27696
+ interests: {
27697
+ eyebrow: {
27698
+ text: "Private local personalization"
27699
+ },
27700
+ title: {
27701
+ text: "Choose what you care about. Your picks stay in this browser session."
27702
+ },
27703
+ continue: {
27704
+ text: "Continue"
27705
+ },
27706
+ software_development: {
27707
+ text: "software development"
27708
+ },
27709
+ use_the_cli: {
27710
+ text: "use the CLI"
27711
+ },
27712
+ open_source: {
27713
+ text: "open source"
27714
+ },
27715
+ read_developer_docs: {
27716
+ text: "read developer docs"
27717
+ },
27718
+ run_code: {
27719
+ text: "run code"
27720
+ },
27721
+ protect_my_privacy: {
27722
+ text: "protect my privacy"
27723
+ },
27724
+ summarize_documents: {
27725
+ text: "summarize documents"
27726
+ },
27727
+ find_apartments: {
27728
+ text: "find apartments"
27729
+ },
27730
+ local_life: {
27731
+ text: "local life"
27732
+ },
27733
+ learn_anything: {
27734
+ text: "learn anything"
27735
+ }
27736
+ },
26791
27737
  new_chat: {
26792
27738
  text: "New chat"
26793
27739
  },
@@ -27213,6 +28159,9 @@ Only output the final Markdown table. Do NOT include explanations, notes, or any
27213
28159
  learn_coding: {
27214
28160
  text: "How do I start learning to code?"
27215
28161
  },
28162
+ use_openmates_cli_api: {
28163
+ text: "Show me how to use OpenMates from the CLI or API"
28164
+ },
27216
28165
  stock_market: {
27217
28166
  text: "Explain how the stock market works"
27218
28167
  },
@@ -30378,6 +31327,32 @@ Only output the final Markdown table. Do NOT include explanations, notes, or any
30378
31327
  text: "Plot"
30379
31328
  }
30380
31329
  },
31330
+ mindmaps: {
31331
+ mindmap: {
31332
+ text: "Mind Map"
31333
+ },
31334
+ counts: {
31335
+ text: "{nodes} nodes \xB7 {edges} edges"
31336
+ },
31337
+ invalid_json: {
31338
+ text: "Invalid mind map JSON"
31339
+ },
31340
+ invalid_content: {
31341
+ text: "Invalid content"
31342
+ },
31343
+ validation_warnings: {
31344
+ text: "Validation warnings"
31345
+ },
31346
+ source: {
31347
+ text: "Source"
31348
+ },
31349
+ expand: {
31350
+ text: "Expand {label}"
31351
+ },
31352
+ collapse: {
31353
+ text: "Collapse {label}"
31354
+ }
31355
+ },
30381
31356
  diagrams: {
30382
31357
  mermaid: {
30383
31358
  text: "Mermaid Diagram"
@@ -36409,6 +37384,21 @@ As of mid-2026, the severe supply shocks from the 2024\u20132025 avian flu have
36409
37384
  },
36410
37385
  import_importing: {
36411
37386
  text: "Importing\u2026"
37387
+ },
37388
+ interests: {
37389
+ text: "Interests"
37390
+ },
37391
+ interests_description: {
37392
+ text: "Choose the topics OpenMates should prioritize when it suggests chats, examples, and product tips."
37393
+ },
37394
+ interests_privacy_note: {
37395
+ text: "These preferences are encrypted on your device before syncing. The server stores only ciphertext."
37396
+ },
37397
+ interests_saved: {
37398
+ text: "Interests saved"
37399
+ },
37400
+ interests_save_error: {
37401
+ text: "Could not save interests. Please try again."
36412
37402
  }
36413
37403
  },
36414
37404
  ai: {
@@ -39929,6 +40919,9 @@ As of mid-2026, the severe supply shocks from the 2024\u20132025 avian flu have
39929
40919
  server_will_be_restarted: {
39930
40920
  text: "The server will be restarted and\ntherefore briefly offline once\nthe update has been installed."
39931
40921
  },
40922
+ cli_managed_update_notice: {
40923
+ text: "Install updates from the server host with <code>openmates server update</code>."
40924
+ },
39932
40925
  installing_update: {
39933
40926
  text: "Installing update..."
39934
40927
  },
@@ -42301,7 +43294,7 @@ function buildAssistantFeedbackDecision(rating) {
42301
43294
 
42302
43295
  // src/benchmark.ts
42303
43296
  import { randomUUID as randomUUID3 } from "crypto";
42304
- import { existsSync as existsSync6, mkdtempSync, readFileSync as readFileSync6, readdirSync, writeFileSync as writeFileSync4 } from "fs";
43297
+ import { existsSync as existsSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync6, readdirSync as readdirSync2, writeFileSync as writeFileSync4 } from "fs";
42305
43298
  import { tmpdir } from "os";
42306
43299
  import { dirname as dirname2, join as join4, resolve as resolve5 } from "path";
42307
43300
  import { fileURLToPath } from "url";
@@ -43100,7 +44093,7 @@ function loadProviderPricing() {
43100
44093
  const providersDir = findProvidersDir();
43101
44094
  const pricing = /* @__PURE__ */ new Map();
43102
44095
  if (!providersDir) return pricing;
43103
- for (const fileName of readdirSync(providersDir)) {
44096
+ for (const fileName of readdirSync2(providersDir)) {
43104
44097
  if (!fileName.endsWith(".yml")) continue;
43105
44098
  const filePath = join4(providersDir, fileName);
43106
44099
  const text = readFileSync6(filePath, "utf-8");
@@ -43285,7 +44278,7 @@ function defaultImageFixturePath() {
43285
44278
  const fixtureDir = join4(dirname2(fileURLToPath(import.meta.url)), "..", "fixtures");
43286
44279
  const fixturePath = join4(fixtureDir, "brandenburger-tor.png");
43287
44280
  if (existsSync6(fixturePath)) return fixturePath;
43288
- const tempDir = mkdtempSync(join4(tmpdir(), "openmates-benchmark-"));
44281
+ const tempDir = mkdtempSync2(join4(tmpdir(), "openmates-benchmark-"));
43289
44282
  const tempPath = join4(tempDir, "brandenburger-tor.svg");
43290
44283
  writeFileSync4(tempPath, FIXTURE_IMAGE_SVG, "utf-8");
43291
44284
  return tempPath;
@@ -44804,6 +45797,9 @@ async function handleEmbeds(client, subcommand, rest, flags) {
44804
45797
  var SETTINGS_EXECUTABLE_COMMANDS = [
44805
45798
  { path: ["account", "info"], description: "Show account info", examples: ["openmates settings account info --json"] },
44806
45799
  { path: ["account", "timezone", "set"], description: "Set account timezone", examples: ["openmates settings account timezone set Europe/Berlin"] },
45800
+ { path: ["account", "interests", "list"], description: "Show encrypted account topic interests", examples: ["openmates settings account interests list --json"] },
45801
+ { path: ["account", "interests", "set"], description: "Set encrypted account topic interests", examples: ["openmates settings account interests set software_development use_the_cli"] },
45802
+ { path: ["account", "interests", "clear"], description: "Clear encrypted account topic interests", examples: ["openmates settings account interests clear --yes"] },
44807
45803
  { path: ["account", "export", "manifest"], description: "Show account export manifest", examples: ["openmates settings account export manifest --json"] },
44808
45804
  { path: ["account", "export", "data"], description: "Fetch account export data", examples: ["openmates settings account export data --json"] },
44809
45805
  { 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 +45901,30 @@ async function printSettingsMutationResult(resultPromise, flags) {
44905
45901
  process.stdout.write("\x1B[32m\u2713\x1B[0m Settings updated\n");
44906
45902
  if (result && typeof result === "object") printGenericObject(result);
44907
45903
  }
45904
+ function printTopicPreferences(preferences, flags, successLabel) {
45905
+ const payload = preferences ?? {
45906
+ version: 1,
45907
+ selectedTagIds: [],
45908
+ updatedAt: null
45909
+ };
45910
+ const result = {
45911
+ ...payload,
45912
+ availableTagIds: INTEREST_TAG_IDS
45913
+ };
45914
+ if (flags.json === true) {
45915
+ printJson2(result);
45916
+ return;
45917
+ }
45918
+ if (successLabel) {
45919
+ process.stdout.write(`\x1B[32m\u2713\x1B[0m ${successLabel}
45920
+ `);
45921
+ }
45922
+ const selected = result.selectedTagIds.length > 0 ? result.selectedTagIds.join(", ") : "none";
45923
+ process.stdout.write(`Selected interests: ${selected}
45924
+ `);
45925
+ process.stdout.write(`Available interests: ${INTEREST_TAG_IDS.join(", ")}
45926
+ `);
45927
+ }
44908
45928
  function printReportIssueCreateResult(result, flags) {
44909
45929
  if (flags.json === true) {
44910
45930
  printJson2(result);
@@ -45428,6 +46448,30 @@ async function handleSettings(client, subcommand, rest, flags) {
45428
46448
  );
45429
46449
  return;
45430
46450
  }
46451
+ if (matches(tokens, ["account", "interests", "list"])) {
46452
+ const preferences = await client.getTopicPreferences();
46453
+ printTopicPreferences(preferences, flags);
46454
+ return;
46455
+ }
46456
+ if (matches(tokens, ["account", "interests", "set"])) {
46457
+ const selectedTagIds = rest.slice(2);
46458
+ if (selectedTagIds.length === 0) {
46459
+ throw new Error(
46460
+ `Missing interest tag IDs. Use one or more of: ${INTEREST_TAG_IDS.join(", ")}`
46461
+ );
46462
+ }
46463
+ const preferences = await client.setTopicPreferences(selectedTagIds);
46464
+ printTopicPreferences(preferences, flags, "Interests updated");
46465
+ return;
46466
+ }
46467
+ if (matches(tokens, ["account", "interests", "clear"])) {
46468
+ if (flags.yes !== true) {
46469
+ await confirmOrExit("Clear account interests? [y/N] ");
46470
+ }
46471
+ const preferences = await client.clearTopicPreferences();
46472
+ printTopicPreferences(preferences, flags, "Interests cleared");
46473
+ return;
46474
+ }
45431
46475
  if (matches(tokens, ["account", "export", "manifest"])) {
45432
46476
  await printSettingsResult(client.settingsGet("export-account-manifest"), flags);
45433
46477
  return;
@@ -48323,6 +49367,8 @@ if (isCliEntrypoint()) {
48323
49367
  }
48324
49368
 
48325
49369
  export {
49370
+ INTEREST_TAG_IDS,
49371
+ normalizeInterestTagIds,
48326
49372
  MEMORY_TYPE_REGISTRY,
48327
49373
  MATE_NAMES,
48328
49374
  deriveAppUrl,