ema-mcp-toolkit 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +338 -0
  2. package/config.example.yaml +32 -0
  3. package/dist/cli/index.js +333 -0
  4. package/dist/config.js +136 -0
  5. package/dist/emaClient.js +398 -0
  6. package/dist/index.js +109 -0
  7. package/dist/mcp/handlers-consolidated.js +851 -0
  8. package/dist/mcp/index.js +15 -0
  9. package/dist/mcp/prompts.js +1753 -0
  10. package/dist/mcp/resources.js +624 -0
  11. package/dist/mcp/server.js +4585 -0
  12. package/dist/mcp/tools-consolidated.js +590 -0
  13. package/dist/mcp/tools-legacy.js +736 -0
  14. package/dist/models.js +8 -0
  15. package/dist/scheduler.js +21 -0
  16. package/dist/sdk/client.js +788 -0
  17. package/dist/sdk/config.js +136 -0
  18. package/dist/sdk/contracts.js +429 -0
  19. package/dist/sdk/generation-schema.js +189 -0
  20. package/dist/sdk/index.js +39 -0
  21. package/dist/sdk/knowledge.js +2780 -0
  22. package/dist/sdk/models.js +8 -0
  23. package/dist/sdk/state.js +88 -0
  24. package/dist/sdk/sync-options.js +216 -0
  25. package/dist/sdk/sync.js +220 -0
  26. package/dist/sdk/validation-rules.js +355 -0
  27. package/dist/sdk/workflow-generator.js +291 -0
  28. package/dist/sdk/workflow-intent.js +1585 -0
  29. package/dist/state.js +88 -0
  30. package/dist/sync.js +416 -0
  31. package/dist/syncOptions.js +216 -0
  32. package/dist/ui.js +334 -0
  33. package/docs/advisor-comms-assistant-fixes.md +175 -0
  34. package/docs/api-contracts.md +216 -0
  35. package/docs/auto-builder-analysis.md +271 -0
  36. package/docs/data-architecture.md +166 -0
  37. package/docs/ema-auto-builder-guide.html +394 -0
  38. package/docs/ema-user-guide.md +1121 -0
  39. package/docs/mcp-tools-guide.md +149 -0
  40. package/docs/naming-conventions.md +218 -0
  41. package/docs/tool-consolidation-proposal.md +427 -0
  42. package/package.json +95 -0
  43. package/resources/templates/chat-ai/README.md +119 -0
  44. package/resources/templates/chat-ai/persona-config.json +111 -0
  45. package/resources/templates/dashboard-ai/README.md +156 -0
  46. package/resources/templates/dashboard-ai/persona-config.json +180 -0
  47. package/resources/templates/voice-ai/README.md +123 -0
  48. package/resources/templates/voice-ai/persona-config.json +74 -0
  49. package/resources/templates/voice-ai/workflow-prompt.md +120 -0
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Sync Options Configuration
3
+ *
4
+ * Hierarchical configuration for sync behavior, loaded from:
5
+ * 1. .ema.yaml in repo root (shared team defaults)
6
+ * 2. ~/.ema.yaml in user home (personal overrides)
7
+ * 3. Tool parameters (runtime overrides)
8
+ *
9
+ * Resolution order (lowest → highest priority):
10
+ * Built-in defaults → defaults → targets[env] → personas[name] → tool params
11
+ */
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import os from "node:os";
15
+ import yaml from "js-yaml";
16
+ import { z } from "zod";
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+ // Schema
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ const SyncBehaviorSchema = z.object({
21
+ sync_status: z.boolean().optional(),
22
+ dry_run: z.boolean().optional(),
23
+ }).strict();
24
+ const RoutingRuleSchema = z.object({
25
+ match: z.object({
26
+ names: z.array(z.string()).optional().default([]),
27
+ ids: z.array(z.string()).optional().default([]),
28
+ }).strict(),
29
+ targets: z.array(z.string()).min(1),
30
+ }).strict();
31
+ const SyncOptionsConfigSchema = z.object({
32
+ defaults: SyncBehaviorSchema.optional().default({}),
33
+ targets: z.record(SyncBehaviorSchema).optional().default({}),
34
+ personas: z.record(SyncBehaviorSchema).optional().default({}),
35
+ routing: z.array(RoutingRuleSchema).optional().default([]),
36
+ }).strict();
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+ // Built-in defaults
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+ const BUILTIN_DEFAULTS = {
41
+ sync_status: false,
42
+ dry_run: false,
43
+ };
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+ // Config loading
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+ let cachedSyncOptions = null;
48
+ function loadYamlFile(filePath) {
49
+ try {
50
+ const content = fs.readFileSync(filePath, "utf8");
51
+ return yaml.load(content);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ function findRepoRoot() {
58
+ // Start from current working directory and look for .git or package.json
59
+ let dir = process.cwd();
60
+ while (dir !== path.dirname(dir)) {
61
+ if (fs.existsSync(path.join(dir, ".git")) || fs.existsSync(path.join(dir, "package.json"))) {
62
+ return dir;
63
+ }
64
+ dir = path.dirname(dir);
65
+ }
66
+ return process.cwd();
67
+ }
68
+ /**
69
+ * Load and merge sync options from all config sources.
70
+ * Caches result for performance.
71
+ */
72
+ export function loadSyncOptions() {
73
+ if (cachedSyncOptions)
74
+ return cachedSyncOptions;
75
+ const repoRoot = findRepoRoot();
76
+ const homeDir = os.homedir();
77
+ // Load from repo .ema.yaml
78
+ const repoConfigPath = path.join(repoRoot, ".ema.yaml");
79
+ const repoConfig = loadYamlFile(repoConfigPath);
80
+ // Load from ~/.ema.yaml
81
+ const userConfigPath = path.join(homeDir, ".ema.yaml");
82
+ const userConfig = loadYamlFile(userConfigPath);
83
+ // Merge configs (user overrides repo)
84
+ const merged = {
85
+ defaults: {},
86
+ targets: {},
87
+ personas: {},
88
+ routing: [],
89
+ };
90
+ // Apply repo config
91
+ if (repoConfig && typeof repoConfig === "object") {
92
+ const parsed = SyncOptionsConfigSchema.safeParse(repoConfig);
93
+ if (parsed.success) {
94
+ merged.defaults = { ...merged.defaults, ...parsed.data.defaults };
95
+ merged.targets = { ...merged.targets, ...parsed.data.targets };
96
+ merged.personas = { ...merged.personas, ...parsed.data.personas };
97
+ merged.routing = [...(parsed.data.routing ?? [])];
98
+ }
99
+ }
100
+ // Apply user config (overrides repo)
101
+ if (userConfig && typeof userConfig === "object") {
102
+ const parsed = SyncOptionsConfigSchema.safeParse(userConfig);
103
+ if (parsed.success) {
104
+ merged.defaults = { ...merged.defaults, ...parsed.data.defaults };
105
+ // Deep merge targets
106
+ for (const [env, behavior] of Object.entries(parsed.data.targets ?? {})) {
107
+ merged.targets[env] = { ...merged.targets[env], ...behavior };
108
+ }
109
+ // Deep merge personas
110
+ for (const [name, behavior] of Object.entries(parsed.data.personas ?? {})) {
111
+ merged.personas[name] = { ...merged.personas[name], ...behavior };
112
+ }
113
+ // Prepend user routing rules (higher priority)
114
+ merged.routing = [...(parsed.data.routing ?? []), ...merged.routing];
115
+ }
116
+ }
117
+ cachedSyncOptions = merged;
118
+ return merged;
119
+ }
120
+ /**
121
+ * Clear the cached sync options (for testing or config reload).
122
+ */
123
+ export function clearSyncOptionsCache() {
124
+ cachedSyncOptions = null;
125
+ }
126
+ /**
127
+ * Resolve sync behavior for a specific persona and target environment.
128
+ *
129
+ * Resolution order (lowest → highest priority):
130
+ * 1. Built-in defaults
131
+ * 2. Config defaults
132
+ * 3. Config targets[targetEnv]
133
+ * 4. Config personas[personaName]
134
+ * 5. Runtime overrides (passed as parameter)
135
+ */
136
+ export function resolveSyncBehavior(opts) {
137
+ const { personaName, targetEnv, overrides } = opts;
138
+ const config = loadSyncOptions();
139
+ // Start with built-in defaults
140
+ const resolved = { ...BUILTIN_DEFAULTS };
141
+ // Apply config defaults
142
+ if (config.defaults) {
143
+ if (config.defaults.sync_status !== undefined)
144
+ resolved.sync_status = config.defaults.sync_status;
145
+ if (config.defaults.dry_run !== undefined)
146
+ resolved.dry_run = config.defaults.dry_run;
147
+ }
148
+ // Apply target env overrides
149
+ const targetConfig = config.targets?.[targetEnv];
150
+ if (targetConfig) {
151
+ if (targetConfig.sync_status !== undefined)
152
+ resolved.sync_status = targetConfig.sync_status;
153
+ if (targetConfig.dry_run !== undefined)
154
+ resolved.dry_run = targetConfig.dry_run;
155
+ }
156
+ // Apply persona overrides
157
+ if (personaName) {
158
+ const personaConfig = config.personas?.[personaName];
159
+ if (personaConfig) {
160
+ if (personaConfig.sync_status !== undefined)
161
+ resolved.sync_status = personaConfig.sync_status;
162
+ if (personaConfig.dry_run !== undefined)
163
+ resolved.dry_run = personaConfig.dry_run;
164
+ }
165
+ }
166
+ // Apply runtime overrides
167
+ if (overrides) {
168
+ if (overrides.sync_status !== undefined)
169
+ resolved.sync_status = overrides.sync_status;
170
+ if (overrides.dry_run !== undefined)
171
+ resolved.dry_run = overrides.dry_run;
172
+ }
173
+ return resolved;
174
+ }
175
+ // ─────────────────────────────────────────────────────────────────────────────
176
+ // Routing
177
+ // ─────────────────────────────────────────────────────────────────────────────
178
+ /**
179
+ * Check if a string matches a glob pattern (simple implementation).
180
+ * Supports * as wildcard.
181
+ */
182
+ function matchGlob(pattern, value) {
183
+ const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
184
+ return regex.test(value);
185
+ }
186
+ /**
187
+ * Get target environments for a persona based on routing rules.
188
+ */
189
+ export function getRoutingTargets(personaName, personaId) {
190
+ const config = loadSyncOptions();
191
+ const targets = new Set();
192
+ for (const rule of config.routing ?? []) {
193
+ // Check name patterns
194
+ for (const pattern of rule.match.names ?? []) {
195
+ if (matchGlob(pattern, personaName)) {
196
+ rule.targets.forEach((t) => targets.add(t));
197
+ break;
198
+ }
199
+ }
200
+ // Check IDs
201
+ if (rule.match.ids?.includes(personaId)) {
202
+ rule.targets.forEach((t) => targets.add(t));
203
+ }
204
+ }
205
+ return [...targets];
206
+ }
207
+ /**
208
+ * Validate sync options config.
209
+ */
210
+ export function validateSyncOptions(input) {
211
+ const result = SyncOptionsConfigSchema.safeParse(input);
212
+ if (!result.success) {
213
+ return { ok: false, errors: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) };
214
+ }
215
+ return { ok: true, config: result.data };
216
+ }
package/dist/ui.js ADDED
@@ -0,0 +1,334 @@
1
+ export function uiHtml() {
2
+ return `<!doctype html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <title>Ema Agent Sync – Config Wizard</title>
8
+ <style>
9
+ body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; margin: 24px; color: #111; }
10
+ .row { display: flex; gap: 16px; flex-wrap: wrap; }
11
+ .card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; max-width: 1100px; }
12
+ .card h2 { margin: 0 0 8px 0; font-size: 16px; }
13
+ label { display: block; font-size: 12px; color: #374151; margin-top: 10px; }
14
+ input, textarea, select { width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 8px; }
15
+ textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
16
+ button { padding: 8px 12px; border-radius: 10px; border: 1px solid #d1d5db; background: #fff; cursor: pointer; }
17
+ button.primary { background: #111827; color: #fff; border-color: #111827; }
18
+ .muted { color: #6b7280; font-size: 12px; }
19
+ .danger { color: #b91c1c; }
20
+ .ok { color: #047857; }
21
+ .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
22
+ .env { border: 1px dashed #d1d5db; border-radius: 12px; padding: 12px; margin-top: 12px; }
23
+ .envHeader { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
24
+ .pill { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid #d1d5db; }
25
+ .footer { margin-top: 16px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
26
+ .small { font-size: 12px; }
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <h1 style="margin:0 0 6px 0;">Ema Agent Sync – Config Wizard</h1>
31
+ <div class="muted">Build and validate a <code>config.yaml</code> for the sync service. Tokens are referenced by env var name (not stored).</div>
32
+
33
+ <div class="card" style="margin-top:16px;">
34
+ <h2>Global settings</h2>
35
+ <div class="grid2">
36
+ <div>
37
+ <label>stateDbPath</label>
38
+ <input id="stateDbPath" value="./ema-agent-sync.sqlite3" />
39
+ </div>
40
+ <div>
41
+ <label>dryRun</label>
42
+ <select id="dryRun">
43
+ <option value="false" selected>false</option>
44
+ <option value="true">true</option>
45
+ </select>
46
+ </div>
47
+ <div>
48
+ <label>scheduler.intervalSeconds</label>
49
+ <input id="intervalSeconds" type="number" value="300" />
50
+ <div class="muted">If set, cron is ignored.</div>
51
+ </div>
52
+ <div>
53
+ <label>scheduler.cron (optional)</label>
54
+ <input id="cron" placeholder='*/5 * * * *' />
55
+ </div>
56
+ <div>
57
+ <label>eventSharedSecretEnv (optional)</label>
58
+ <input id="eventSharedSecretEnv" placeholder="EMA_SYNC_EVENT_SECRET" />
59
+ <div class="muted">If set, <code>/events/persona-changed</code> requires header <code>x-ema-sync-secret</code>.</div>
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ <div class="card" style="margin-top:16px;">
65
+ <h2>Environments (exactly one must be master)</h2>
66
+ <div id="envList"></div>
67
+ <div class="footer">
68
+ <button id="addEnv">Add environment</button>
69
+ <div class="muted">Each env needs: name, userId, baseUrl, bearerTokenEnv. Select exactly one master.</div>
70
+ </div>
71
+ </div>
72
+
73
+ <div class="card" style="margin-top:16px;">
74
+ <h2>Routing rules</h2>
75
+ <div class="muted">Select which master persona IDs sync to which target envs. For creates in replicas, provide createTemplateId.</div>
76
+ <div id="routeList"></div>
77
+ <div class="footer">
78
+ <button id="addRoute">Add routing rule</button>
79
+ </div>
80
+ </div>
81
+
82
+ <div class="card" style="margin-top:16px;">
83
+ <h2>Generated config</h2>
84
+ <div class="row">
85
+ <button class="primary" id="validate">Validate</button>
86
+ <button id="download">Download config.yaml</button>
87
+ <button id="save">Save to server</button>
88
+ <button id="copy">Copy YAML</button>
89
+ <span id="status" class="small muted"></span>
90
+ </div>
91
+ <label>YAML</label>
92
+ <textarea id="yaml" spellcheck="false"></textarea>
93
+ <label>Env exports (optional helper)</label>
94
+ <textarea id="exports" spellcheck="false"></textarea>
95
+ </div>
96
+
97
+ <script type="module" src="/ui/app.js"></script>
98
+ </body>
99
+ </html>`;
100
+ }
101
+ export function uiJs() {
102
+ return `
103
+ const $ = (id) => document.getElementById(id);
104
+
105
+ let envs = [
106
+ { name: "prod", userId: "prod-user", baseUrl: "https://YOUR-PROD-EMA-BASE-URL", bearerTokenEnv: "EMA_PROD_BEARER_TOKEN", isMaster: true },
107
+ { name: "staging", userId: "staging-user", baseUrl: "https://YOUR-STAGING-EMA-BASE-URL", bearerTokenEnv: "EMA_STAGING_BEARER_TOKEN", isMaster: false },
108
+ ];
109
+ let routes = [
110
+ { personaIds: ["550e8400-e29b-41d4-a716-446655440000"], targetEnvs: ["staging"], createTemplateId: "TEMPLATE-UUID-HERE" }
111
+ ];
112
+
113
+ function renderEnvs() {
114
+ const root = $("envList");
115
+ root.innerHTML = "";
116
+ envs.forEach((e, idx) => {
117
+ const div = document.createElement("div");
118
+ div.className = "env";
119
+ div.innerHTML = \`
120
+ <div class="envHeader">
121
+ <div>
122
+ <span class="pill">\${e.isMaster ? "MASTER" : "REPLICA"}</span>
123
+ <span class="muted">#\${idx + 1}</span>
124
+ </div>
125
+ <div class="row" style="margin:0;">
126
+ <button data-action="setMaster">Set as master</button>
127
+ <button data-action="remove">Remove</button>
128
+ </div>
129
+ </div>
130
+ <div class="grid2">
131
+ <div><label>name</label><input data-k="name" value="\${escapeHtml(e.name)}" /></div>
132
+ <div><label>userId</label><input data-k="userId" value="\${escapeHtml(e.userId)}" /></div>
133
+ <div><label>baseUrl</label><input data-k="baseUrl" value="\${escapeHtml(e.baseUrl)}" /></div>
134
+ <div><label>bearerTokenEnv</label><input data-k="bearerTokenEnv" value="\${escapeHtml(e.bearerTokenEnv)}" /></div>
135
+ </div>
136
+ \`;
137
+ div.addEventListener("input", (ev) => {
138
+ const t = ev.target;
139
+ if (!(t instanceof HTMLInputElement)) return;
140
+ const k = t.getAttribute("data-k");
141
+ if (!k) return;
142
+ envs[idx] = { ...envs[idx], [k]: t.value };
143
+ refreshOutputs();
144
+ });
145
+ div.querySelector("[data-action='setMaster']").addEventListener("click", () => {
146
+ envs = envs.map((x, i) => ({ ...x, isMaster: i === idx }));
147
+ renderEnvs();
148
+ refreshOutputs();
149
+ });
150
+ div.querySelector("[data-action='remove']").addEventListener("click", () => {
151
+ envs.splice(idx, 1);
152
+ if (!envs.some((x) => x.isMaster) && envs.length > 0) envs[0].isMaster = true;
153
+ renderEnvs();
154
+ refreshOutputs();
155
+ });
156
+ root.appendChild(div);
157
+ });
158
+ }
159
+
160
+ function renderRoutes() {
161
+ const root = $("routeList");
162
+ root.innerHTML = "";
163
+ routes.forEach((r, idx) => {
164
+ const div = document.createElement("div");
165
+ div.className = "env";
166
+ div.innerHTML = \`
167
+ <div class="envHeader">
168
+ <div><span class="pill">RULE</span> <span class="muted">#\${idx + 1}</span></div>
169
+ <div><button data-action="remove">Remove</button></div>
170
+ </div>
171
+ <div class="grid2">
172
+ <div>
173
+ <label>personaIds (one per line)</label>
174
+ <textarea data-k="personaIds" spellcheck="false">\${(r.personaIds ?? []).join("\\n")}</textarea>
175
+ <label>nameGlobs (one per line, e.g. ema-*)</label>
176
+ <textarea data-k="nameGlobs" spellcheck="false">\${(r.nameGlobs ?? []).join("\\n")}</textarea>
177
+ <label>namePrefixes (one per line, e.g. ema-)</label>
178
+ <textarea data-k="namePrefixes" spellcheck="false">\${(r.namePrefixes ?? []).join("\\n")}</textarea>
179
+ <label>nameRegexes (one per line, JS regex, e.g. ^(ema-|x|yy|z))</label>
180
+ <textarea data-k="nameRegexes" spellcheck="false">\${(r.nameRegexes ?? []).join("\\n")}</textarea>
181
+ </div>
182
+ <div>
183
+ <label>targetEnvs (comma separated)</label>
184
+ <input data-k="targetEnvs" value="\${escapeHtml((r.targetEnvs ?? []).join(", "))}" />
185
+ <label>createTemplateId (required if target persona missing)</label>
186
+ <input data-k="createTemplateId" value="\${escapeHtml(r.createTemplateId ?? "")}" />
187
+ </div>
188
+ </div>
189
+ \`;
190
+ div.addEventListener("input", (ev) => {
191
+ const t = ev.target;
192
+ if (t instanceof HTMLTextAreaElement) {
193
+ const k = t.getAttribute("data-k");
194
+ if (!k) return;
195
+ const arr = t.value.split(/\\r?\\n/).map((s) => s.trim()).filter(Boolean);
196
+ if (k === "personaIds" || k === "nameGlobs" || k === "namePrefixes" || k === "nameRegexes") {
197
+ routes[idx] = { ...routes[idx], [k]: arr };
198
+ refreshOutputs();
199
+ return;
200
+ }
201
+ }
202
+ if (!(t instanceof HTMLInputElement)) return;
203
+ const k = t.getAttribute("data-k");
204
+ if (!k) return;
205
+ if (k === "targetEnvs") {
206
+ routes[idx] = { ...routes[idx], targetEnvs: t.value.split(",").map((s) => s.trim()).filter(Boolean) };
207
+ } else if (k === "createTemplateId") {
208
+ routes[idx] = { ...routes[idx], createTemplateId: t.value.trim() || undefined };
209
+ }
210
+ refreshOutputs();
211
+ });
212
+ div.querySelector("[data-action='remove']").addEventListener("click", () => {
213
+ routes.splice(idx, 1);
214
+ renderRoutes();
215
+ refreshOutputs();
216
+ });
217
+ root.appendChild(div);
218
+ });
219
+ }
220
+
221
+ function buildConfigJson() {
222
+ const stateDbPath = $("stateDbPath").value.trim() || "./ema-agent-sync.sqlite3";
223
+ const dryRun = $("dryRun").value === "true";
224
+ const intervalSeconds = Number($("intervalSeconds").value || "300");
225
+ const cron = $("cron").value.trim() || undefined;
226
+ const eventSharedSecretEnv = $("eventSharedSecretEnv").value.trim() || undefined;
227
+
228
+ return {
229
+ dryRun,
230
+ stateDbPath,
231
+ environments: envs.map((e) => ({
232
+ name: e.name.trim(),
233
+ userId: e.userId.trim(),
234
+ baseUrl: e.baseUrl.trim(),
235
+ bearerTokenEnv: e.bearerTokenEnv.trim(),
236
+ isMaster: !!e.isMaster,
237
+ })),
238
+ scheduler: cron ? { cron } : { intervalSeconds },
239
+ routing: routes.map((r) => ({
240
+ personaIds: (r.personaIds ?? []).filter(Boolean),
241
+ nameGlobs: (r.nameGlobs ?? []).filter(Boolean),
242
+ namePrefixes: (r.namePrefixes ?? []).filter(Boolean),
243
+ nameRegexes: (r.nameRegexes ?? []).filter(Boolean),
244
+ targetEnvs: (r.targetEnvs ?? []).filter(Boolean),
245
+ ...(r.createTemplateId ? { createTemplateId: r.createTemplateId } : {}),
246
+ })),
247
+ ...(eventSharedSecretEnv ? { eventSharedSecretEnv } : {}),
248
+ };
249
+ }
250
+
251
+ async function refreshOutputs() {
252
+ const cfg = buildConfigJson();
253
+ // YAML comes from server so it matches backend rendering exactly
254
+ const resp = await fetch("/api/config/yaml", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(cfg) });
255
+ const data = await resp.json();
256
+ $("yaml").value = data.yaml ?? "";
257
+
258
+ const exports = cfg.environments.map((e) => \`export \${e.bearerTokenEnv}="..."\`).join("\\n");
259
+ $("exports").value = exports + (cfg.eventSharedSecretEnv ? "\\n" + \`export \${cfg.eventSharedSecretEnv}="..."\` : "");
260
+ }
261
+
262
+ function escapeHtml(s) {
263
+ return String(s ?? "").replace(/[&<>\\"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\\"":"&quot;","'":"&#39;" }[c]));
264
+ }
265
+
266
+ $("addEnv").addEventListener("click", () => {
267
+ envs.push({ name: "new-env", userId: "user", baseUrl: "https://YOUR-EMA-BASE-URL", bearerTokenEnv: "EMA_BEARER_TOKEN", isMaster: envs.length === 0 });
268
+ renderEnvs();
269
+ refreshOutputs();
270
+ });
271
+
272
+ $("addRoute").addEventListener("click", () => {
273
+ routes.push({ personaIds: [], nameGlobs: [], namePrefixes: [], nameRegexes: [], targetEnvs: [] });
274
+ renderRoutes();
275
+ refreshOutputs();
276
+ });
277
+
278
+ $("validate").addEventListener("click", async () => {
279
+ const cfg = buildConfigJson();
280
+ const resp = await fetch("/api/config/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(cfg) });
281
+ const data = await resp.json();
282
+ const status = $("status");
283
+ if (data.ok) {
284
+ status.textContent = "Valid configuration.";
285
+ status.className = "small ok";
286
+ } else {
287
+ status.textContent = "Invalid: " + (data.errors ?? []).join("; ");
288
+ status.className = "small danger";
289
+ }
290
+ });
291
+
292
+ $("download").addEventListener("click", () => {
293
+ const blob = new Blob([$("yaml").value], { type: "text/yaml" });
294
+ const url = URL.createObjectURL(blob);
295
+ const a = document.createElement("a");
296
+ a.href = url;
297
+ a.download = "config.yaml";
298
+ a.click();
299
+ URL.revokeObjectURL(url);
300
+ });
301
+
302
+ $("save").addEventListener("click", async () => {
303
+ const cfg = buildConfigJson();
304
+ const resp = await fetch("/api/config/save", {
305
+ method: "POST",
306
+ headers: { "Content-Type": "application/json", "x-config-path": "./config.yaml" },
307
+ body: JSON.stringify(cfg),
308
+ });
309
+ const data = await resp.json();
310
+ const status = $("status");
311
+ if (data.ok) {
312
+ status.textContent = "Saved to " + data.savedTo + ". Restart the service to load it.";
313
+ status.className = "small ok";
314
+ } else {
315
+ status.textContent = "Save failed: " + (data.errors ?? []).join("; ");
316
+ status.className = "small danger";
317
+ }
318
+ });
319
+
320
+ $("copy").addEventListener("click", async () => {
321
+ await navigator.clipboard.writeText($("yaml").value);
322
+ const status = $("status");
323
+ status.textContent = "Copied to clipboard.";
324
+ status.className = "small ok";
325
+ });
326
+
327
+ // Global settings changes
328
+ ["stateDbPath", "dryRun", "intervalSeconds", "cron", "eventSharedSecretEnv"].forEach((id) => $(id).addEventListener("input", refreshOutputs));
329
+
330
+ renderEnvs();
331
+ renderRoutes();
332
+ refreshOutputs();
333
+ `;
334
+ }