@mgsoftwarebv/mg-dashboard-mcp 2.0.0 → 2.0.4

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.
package/dist/index.js CHANGED
@@ -24,6 +24,133 @@ if (!supabaseUrl || !supabaseKey) {
24
24
  process.exit(1);
25
25
  }
26
26
  var supabase = createClient(supabaseUrl, supabaseKey);
27
+ var RateLimiter = class {
28
+ buckets = /* @__PURE__ */ new Map();
29
+ maxAttempts;
30
+ windowMs;
31
+ constructor(maxAttempts, windowMs) {
32
+ this.maxAttempts = maxAttempts;
33
+ this.windowMs = windowMs;
34
+ }
35
+ /**
36
+ * Check if an action is allowed for the given key.
37
+ * Increments the counter and returns whether the action should proceed.
38
+ */
39
+ check(key) {
40
+ const now = Date.now();
41
+ const entry = this.buckets.get(key);
42
+ if (!entry || now >= entry.resetAt) {
43
+ this.buckets.set(key, { count: 1, resetAt: now + this.windowMs });
44
+ return { allowed: true, remaining: this.maxAttempts - 1, retryAfterMs: 0 };
45
+ }
46
+ entry.count++;
47
+ if (entry.count > this.maxAttempts) {
48
+ return {
49
+ allowed: false,
50
+ remaining: 0,
51
+ retryAfterMs: entry.resetAt - now
52
+ };
53
+ }
54
+ return {
55
+ allowed: true,
56
+ remaining: this.maxAttempts - entry.count,
57
+ retryAfterMs: 0
58
+ };
59
+ }
60
+ /** Periodically remove expired entries to prevent unbounded growth. */
61
+ cleanup() {
62
+ const now = Date.now();
63
+ for (const [key, entry] of this.buckets) {
64
+ if (now >= entry.resetAt) this.buckets.delete(key);
65
+ }
66
+ }
67
+ };
68
+ var authRateLimiter = new RateLimiter(5, 15 * 60 * 1e3);
69
+ setInterval(() => {
70
+ authRateLimiter.cleanup();
71
+ }, 5 * 60 * 1e3).unref();
72
+ var MODULE_KEYS = [
73
+ "users",
74
+ "emails",
75
+ "logs",
76
+ "ssh_servers",
77
+ "supabase",
78
+ "wiki",
79
+ "ci_cd",
80
+ "source_control",
81
+ "domains",
82
+ "google_search_console",
83
+ "settings"
84
+ ];
85
+ var FULL_PERMISSIONS = {
86
+ modules: Object.fromEntries(MODULE_KEYS.map((k) => [k, true])),
87
+ resources: { ssh_servers: ["*"], supabase_instances: ["*"] }
88
+ };
89
+ function parsePermissions(raw) {
90
+ if (!raw || typeof raw !== "object") return null;
91
+ return raw;
92
+ }
93
+ function resolvePermissions(roleName, roleDefaults, userOverrides) {
94
+ if (roleName === "superadmin") return FULL_PERMISSIONS;
95
+ const base = parsePermissions(roleDefaults);
96
+ const overrides = parsePermissions(userOverrides);
97
+ const modules = {};
98
+ for (const key of MODULE_KEYS) {
99
+ const userVal = overrides?.modules?.[key];
100
+ const roleVal = base?.modules?.[key];
101
+ modules[key] = userVal !== void 0 ? userVal : roleVal !== void 0 ? roleVal : false;
102
+ }
103
+ const resources = {
104
+ ssh_servers: overrides?.resources?.ssh_servers ?? base?.resources?.ssh_servers ?? [],
105
+ supabase_instances: overrides?.resources?.supabase_instances ?? base?.resources?.supabase_instances ?? []
106
+ };
107
+ return { modules, resources };
108
+ }
109
+ function intersectServerAccess(keyServerIds, permissionServerIds) {
110
+ const keyHasRestriction = keyServerIds !== null;
111
+ const permWildcard = permissionServerIds.includes("*");
112
+ const permEmpty = permissionServerIds.length === 0;
113
+ if (!keyHasRestriction && permWildcard) return null;
114
+ if (!keyHasRestriction && permEmpty) return [];
115
+ if (!keyHasRestriction) return permissionServerIds;
116
+ if (permWildcard) return keyServerIds;
117
+ if (permEmpty) return [];
118
+ return keyServerIds.filter((id) => permissionServerIds.includes(id));
119
+ }
120
+ var TOOL_MODULE_MAP = {
121
+ "list-servers": "ssh_servers",
122
+ "server-status": "ssh_servers",
123
+ "ssh-execute": "ssh_servers",
124
+ "server-reboot": "ssh_servers",
125
+ "server-restart-service": "ssh_servers",
126
+ "sftp-list": "ssh_servers",
127
+ "sftp-read": "ssh_servers",
128
+ "sftp-write": "ssh_servers",
129
+ "sftp-delete": "ssh_servers",
130
+ "docker-list": "ssh_servers",
131
+ "docker-action": "ssh_servers",
132
+ "docker-logs": "ssh_servers",
133
+ "db-discover": "ssh_servers",
134
+ "db-tables": "ssh_servers",
135
+ "db-describe": "ssh_servers",
136
+ "db-query": "ssh_servers",
137
+ "cache-purge": "ssh_servers",
138
+ "log-list": "ssh_servers",
139
+ "log-read": "ssh_servers",
140
+ "cron-list": "ssh_servers",
141
+ "cron-add": "ssh_servers",
142
+ "cron-remove": "ssh_servers",
143
+ "cron-toggle": "ssh_servers",
144
+ "env-list": "ci_cd",
145
+ "env-get": "ci_cd",
146
+ "env-store": "ci_cd",
147
+ "domain-list": "domains",
148
+ "domain-get": "domains",
149
+ "dns-list": "domains",
150
+ "dns-create": "domains",
151
+ "dns-update": "domains",
152
+ "dns-delete": "domains"
153
+ };
27
154
  var authContext = null;
28
155
  async function validateApiKey(key) {
29
156
  if (!key.startsWith("dk_") || key.length !== 67) {
@@ -31,20 +158,42 @@ async function validateApiKey(key) {
31
158
  return null;
32
159
  }
33
160
  const keyHash = createHash("sha256").update(key).digest("hex");
161
+ const rateCheck = authRateLimiter.check(keyHash);
162
+ if (!rateCheck.allowed) {
163
+ const retryMin = Math.ceil(rateCheck.retryAfterMs / 6e4);
164
+ console.error(`Rate limited: too many failed auth attempts. Retry in ${retryMin} minute(s).`);
165
+ return null;
166
+ }
34
167
  const { data, error } = await supabase.from("dashboard_mcp_api_key").select("id, created_by, allowed_server_ids, is_active, expires_at").eq("api_key_hash", keyHash).eq("is_active", true).single();
35
168
  if (error || !data) {
36
- console.error("API key not found or inactive");
169
+ console.error(`API key not found or inactive (${rateCheck.remaining} attempts remaining)`);
37
170
  return null;
38
171
  }
39
172
  if (data.expires_at && new Date(data.expires_at) < /* @__PURE__ */ new Date()) {
40
- console.error("API key has expired");
173
+ console.error(`API key has expired (${rateCheck.remaining} attempts remaining)`);
174
+ return null;
175
+ }
176
+ const { data: userData, error: userError } = await supabase.from("user").select("permissions, role:role!role_id(name, default_permissions)").eq("id", data.created_by).single();
177
+ if (userError || !userData) {
178
+ console.error(`User not found for API key creator: ${data.created_by}`);
41
179
  return null;
42
180
  }
181
+ const roleName = userData.role?.name || "user";
182
+ const roleDefaults = userData.role?.default_permissions ?? {};
183
+ const userOverrides = userData.permissions ?? null;
184
+ const permissions = resolvePermissions(roleName, roleDefaults, userOverrides);
185
+ const allowedServerIds = intersectServerAccess(
186
+ data.allowed_server_ids,
187
+ permissions.resources.ssh_servers
188
+ );
43
189
  await supabase.from("dashboard_mcp_api_key").update({ last_used_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", data.id);
44
- console.error(`Authenticated as user ${data.created_by}`);
190
+ const moduleCount = MODULE_KEYS.filter((k) => permissions.modules[k]).length;
191
+ console.error(`Authenticated as user ${data.created_by} (role: ${roleName}, modules: ${moduleCount}/${MODULE_KEYS.length})`);
45
192
  return {
46
193
  userId: data.created_by,
47
- allowedServerIds: data.allowed_server_ids
194
+ allowedServerIds,
195
+ permissions,
196
+ roleName
48
197
  };
49
198
  }
50
199
  function assertServerAccess(serverId) {
@@ -54,6 +203,38 @@ function assertServerAccess(serverId) {
54
203
  throw new Error(`Access denied: you do not have permission for server ${serverId}`);
55
204
  }
56
205
  }
206
+ async function resolveReleaseProfileStageIds(profileName) {
207
+ const { data: profile, error } = await supabase.from("release_profile").select("id, name").ilike("name", profileName).maybeSingle();
208
+ if (error) throw new Error(`Failed to look up release profile: ${error.message}`);
209
+ if (!profile) {
210
+ const { data: all } = await supabase.from("release_profile").select("name").order("name");
211
+ const names = (all || []).map((p) => p.name).join(", ");
212
+ throw new Error(
213
+ `Release profile "${profileName}" not found. Available profiles: ${names || "(none)"}`
214
+ );
215
+ }
216
+ const { data: stages, error: stageErr } = await supabase.from("release_profile_stage").select("id").eq("release_profile_id", profile.id);
217
+ if (stageErr) throw new Error(`Failed to look up stages: ${stageErr.message}`);
218
+ if (!stages || stages.length === 0) {
219
+ throw new Error(`Release profile "${profile.name}" has no stages configured`);
220
+ }
221
+ return { stageIds: stages.map((s) => s.id), profileId: profile.id };
222
+ }
223
+ async function getProfileNamesForStageIds(stageIds) {
224
+ if (stageIds.length === 0) return {};
225
+ const { data: stages } = await supabase.from("release_profile_stage").select("id, release_profile_id").in("id", stageIds);
226
+ if (!stages || stages.length === 0) return {};
227
+ const profileIds = [...new Set(stages.map((s) => s.release_profile_id))];
228
+ const { data: profiles } = await supabase.from("release_profile").select("id, name").in("id", profileIds);
229
+ if (!profiles) return {};
230
+ const profileMap = {};
231
+ for (const p of profiles) profileMap[p.id] = p.name;
232
+ const result = {};
233
+ for (const s of stages) {
234
+ result[s.id] = profileMap[s.release_profile_id] || "unknown";
235
+ }
236
+ return result;
237
+ }
57
238
  var ENC_ALGORITHM = "aes-256-gcm";
58
239
  var ENC_IV_LENGTH = 16;
59
240
  var ENC_TAG_LENGTH = 16;
@@ -88,6 +269,120 @@ function decrypt(payload) {
88
269
  decrypted += decipher.final("utf8");
89
270
  return decrypted;
90
271
  }
272
+ var VERCEL_API = "https://api.vercel.com";
273
+ function parseEnvContent(content) {
274
+ const result = {};
275
+ for (const line of content.split("\n")) {
276
+ const trimmed = line.trim();
277
+ if (!trimmed || trimmed.startsWith("#")) continue;
278
+ const eqIdx = trimmed.indexOf("=");
279
+ if (eqIdx === -1) continue;
280
+ const key = trimmed.slice(0, eqIdx).trim();
281
+ let value = trimmed.slice(eqIdx + 1).trim();
282
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
283
+ value = value.slice(1, -1);
284
+ }
285
+ if (key) result[key] = value;
286
+ }
287
+ return result;
288
+ }
289
+ function stageToVercelTargets(stageType, customEnvId) {
290
+ if (stageType === "prod") return { target: ["production"] };
291
+ if (customEnvId) return { customEnvironmentIds: [customEnvId] };
292
+ return { target: ["preview"] };
293
+ }
294
+ async function syncEnvVarsToVercel(token, projectId, envVars) {
295
+ if (envVars.length === 0) return { created: 0, error: null };
296
+ const res = await fetch(
297
+ `${VERCEL_API}/v10/projects/${encodeURIComponent(projectId)}/env?upsert=true`,
298
+ {
299
+ method: "POST",
300
+ headers: {
301
+ Authorization: `Bearer ${token}`,
302
+ "Content-Type": "application/json"
303
+ },
304
+ body: JSON.stringify(envVars)
305
+ }
306
+ );
307
+ if (!res.ok) {
308
+ const body = await res.text().catch(() => "");
309
+ return { created: 0, error: `Vercel API ${res.status}: ${body}` };
310
+ }
311
+ const data = await res.json().catch(() => ({}));
312
+ return { created: data?.created?.length ?? envVars.length, error: null };
313
+ }
314
+ async function attemptVercelSync(appName, environment) {
315
+ try {
316
+ const { data: direct } = await supabase.from("env_config").select("release_profile_stage_id").eq("app_name", appName).not("release_profile_stage_id", "is", null).limit(1).single();
317
+ const stageId = direct?.release_profile_stage_id;
318
+ if (!stageId) return "Vercel sync skipped: no stage link found";
319
+ const { data: settings } = await supabase.from("app_setting").select("vercel_token_encrypted").maybeSingle();
320
+ if (!settings?.vercel_token_encrypted) return "Vercel sync skipped: no Vercel token configured";
321
+ let token;
322
+ try {
323
+ token = decrypt(settings.vercel_token_encrypted);
324
+ } catch {
325
+ return "Vercel sync failed: could not decrypt Vercel token";
326
+ }
327
+ const { data: stage } = await supabase.from("release_profile_stage").select("id, stage, stage_apps").eq("id", stageId).single();
328
+ if (!stage) return "Vercel sync skipped: stage not found";
329
+ const stageType = stage.stage;
330
+ const stageApps = stage.stage_apps || [];
331
+ const vercelApps = stageApps.filter(
332
+ (a) => a.deployMethod === "vercel" && a.enabled && a.vercelProjectId
333
+ );
334
+ if (vercelApps.length === 0) return "Vercel sync skipped: no Vercel apps in stage";
335
+ const { data: envConfigs } = await supabase.from("env_config").select("*").eq("release_profile_stage_id", stageId);
336
+ if (!envConfigs || envConfigs.length === 0) return "Vercel sync skipped: no env configs for stage";
337
+ const variantMap = {
338
+ dev: "development",
339
+ staging: "staging",
340
+ prod: "production"
341
+ };
342
+ const deployedVariant = variantMap[stageType] ?? stageType;
343
+ const syncResults = [];
344
+ for (const app of vercelApps) {
345
+ const name = app.path.replace("apps/", "");
346
+ const config = envConfigs.find(
347
+ (c) => c.app_name === name && c.variant === deployedVariant
348
+ );
349
+ if (!config) {
350
+ syncResults.push(`${app.label}: skipped (no config for variant "${deployedVariant}")`);
351
+ continue;
352
+ }
353
+ let envContent;
354
+ try {
355
+ envContent = decrypt(config.env_data_encrypted);
356
+ } catch {
357
+ syncResults.push(`${app.label}: decrypt failed`);
358
+ continue;
359
+ }
360
+ const pairs = parseEnvContent(envContent);
361
+ const keys = Object.keys(pairs);
362
+ if (keys.length === 0) {
363
+ syncResults.push(`${app.label}: empty config`);
364
+ continue;
365
+ }
366
+ const targeting = stageToVercelTargets(stageType, app.vercelCustomEnvId);
367
+ const envVars = keys.map((key) => ({
368
+ key,
369
+ value: pairs[key],
370
+ type: "encrypted",
371
+ ...targeting
372
+ }));
373
+ const { created, error } = await syncEnvVarsToVercel(token, app.vercelProjectId, envVars);
374
+ if (error) {
375
+ syncResults.push(`${app.label}: FAILED - ${error}`);
376
+ } else {
377
+ syncResults.push(`${app.label}: ${created} vars synced`);
378
+ }
379
+ }
380
+ return `Vercel sync: ${syncResults.join("; ")}`;
381
+ } catch (err) {
382
+ const msg = err instanceof Error ? err.message : String(err);
383
+ return `Vercel sync error: ${msg}`;
384
+ }
385
+ }
91
386
  async function getServerConnection(serverId) {
92
387
  assertServerAccess(serverId);
93
388
  const { data, error } = await supabase.from("ssh_server").select("hostname, port, username, password_encrypted, ssh_key_encrypted, ssh_key_passphrase_encrypted").eq("id", serverId).single();
@@ -794,8 +1089,13 @@ var TOOLS = [
794
1089
  },
795
1090
  {
796
1091
  name: "env-list",
797
- description: "List all stored environment configurations (app name, environment, description).",
798
- inputSchema: { type: "object", properties: {}, required: [] }
1092
+ description: "List all stored environment configurations with their release profile names.",
1093
+ inputSchema: {
1094
+ type: "object",
1095
+ properties: {
1096
+ releaseProfile: { type: "string", description: "Release profile name to filter by (usually matches the project folder name or git repo name, e.g. prefabaanbouw). Omit to list all." }
1097
+ }
1098
+ }
799
1099
  },
800
1100
  {
801
1101
  name: "env-get",
@@ -804,7 +1104,8 @@ var TOOLS = [
804
1104
  type: "object",
805
1105
  properties: {
806
1106
  appName: { type: "string", description: "Application name (e.g. backoffice, api, web)" },
807
- environment: { type: "string", description: "Environment name (e.g. production, staging, development)" }
1107
+ environment: { type: "string", description: "Environment name (e.g. production, staging, development, local)" },
1108
+ releaseProfile: { type: "string", description: "Release profile name (usually matches the project folder name or git repo name, e.g. prefabaanbouw). Required when multiple profiles exist. Use env-list to discover available profiles." }
808
1109
  },
809
1110
  required: ["appName", "environment"]
810
1111
  }
@@ -816,9 +1117,10 @@ var TOOLS = [
816
1117
  type: "object",
817
1118
  properties: {
818
1119
  appName: { type: "string", description: "Application name (e.g. backoffice, api, web)" },
819
- environment: { type: "string", description: "Environment name (e.g. production, staging, development)" },
1120
+ environment: { type: "string", description: "Environment name (e.g. production, staging, development, local)" },
820
1121
  content: { type: "string", description: "The .env file content to store" },
821
- description: { type: "string", description: "Optional description" }
1122
+ description: { type: "string", description: "Optional description" },
1123
+ releaseProfile: { type: "string", description: "Release profile name (usually matches the project folder name or git repo name, e.g. prefabaanbouw). Required when multiple profiles exist. Use env-list to discover available profiles." }
822
1124
  },
823
1125
  required: ["appName", "environment", "content"]
824
1126
  }
@@ -992,13 +1294,30 @@ var server = new Server(
992
1294
  { name: "mg-dashboard-mcp", version: "1.7.0" },
993
1295
  { capabilities: { tools: {} } }
994
1296
  );
995
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
1297
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
1298
+ if (!authContext) return { tools: TOOLS };
1299
+ const accessible = TOOLS.filter((tool) => {
1300
+ const requiredModule = TOOL_MODULE_MAP[tool.name];
1301
+ if (!requiredModule) return true;
1302
+ return authContext.permissions.modules[requiredModule] === true;
1303
+ });
1304
+ return { tools: accessible };
1305
+ });
996
1306
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
997
1307
  if (!authContext) {
998
1308
  return { content: [{ type: "text", text: "Error: not authenticated" }] };
999
1309
  }
1000
1310
  const { name, arguments: toolArgs } = request.params;
1001
1311
  const a = toolArgs || {};
1312
+ const requiredModule = TOOL_MODULE_MAP[name];
1313
+ if (requiredModule && authContext.permissions.modules[requiredModule] !== true) {
1314
+ return {
1315
+ content: [{
1316
+ type: "text",
1317
+ text: `Access denied: you do not have permission for the "${requiredModule}" module (tool: ${name})`
1318
+ }]
1319
+ };
1320
+ }
1002
1321
  try {
1003
1322
  switch (name) {
1004
1323
  // ----- Servers -----
@@ -1135,44 +1454,95 @@ ${result.stderr}`);
1135
1454
  }
1136
1455
  // ----- Env Config -----
1137
1456
  case "env-list": {
1138
- const { data, error } = await supabase.from("env_config").select("id, app_name, environment, description, updated_at").order("app_name").order("environment");
1457
+ let query = supabase.from("env_config").select("id, app_name, environment, description, updated_at, release_profile_stage_id").order("app_name").order("environment");
1458
+ if (a.releaseProfile) {
1459
+ const { stageIds: stageIds2 } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
1460
+ query = query.in("release_profile_stage_id", stageIds2);
1461
+ }
1462
+ const { data, error } = await query;
1139
1463
  if (error) throw new Error(error.message);
1140
- const lines = (data || []).map(
1141
- (e) => `${e.app_name}/${e.environment} ${e.description || ""} (updated: ${e.updated_at})`
1142
- );
1464
+ const stageIds = (data || []).map((e) => e.release_profile_stage_id).filter(Boolean);
1465
+ const profileNames = await getProfileNamesForStageIds(stageIds);
1466
+ const lines = (data || []).map((e) => {
1467
+ const profile = e.release_profile_stage_id ? profileNames[e.release_profile_stage_id] || "unknown" : "unlinked";
1468
+ return `${e.app_name}/${e.environment} [${profile}] (updated: ${e.updated_at})`;
1469
+ });
1143
1470
  return { content: [{ type: "text", text: lines.length ? lines.join("\n") : "No environment configs stored" }] };
1144
1471
  }
1145
1472
  case "env-get": {
1146
- const { data, error } = await supabase.from("env_config").select("env_data_encrypted").eq("app_name", String(a.appName)).eq("environment", String(a.environment)).single();
1147
- if (error || !data) throw new Error(`Env config not found: ${a.appName}/${a.environment}`);
1148
- const decrypted = decrypt(data.env_data_encrypted);
1473
+ let query = supabase.from("env_config").select("env_data_encrypted, release_profile_stage_id").eq("app_name", String(a.appName)).eq("environment", String(a.environment));
1474
+ if (a.releaseProfile) {
1475
+ const { stageIds } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
1476
+ query = query.in("release_profile_stage_id", stageIds);
1477
+ }
1478
+ const { data, error } = await query;
1479
+ if (error) throw new Error(`Env config query failed: ${error.message}`);
1480
+ if (!data || data.length === 0) {
1481
+ throw new Error(`Env config not found: ${a.appName}/${a.environment}${a.releaseProfile ? ` (profile: ${a.releaseProfile})` : ""}`);
1482
+ }
1483
+ if (data.length > 1) {
1484
+ const stageIds = data.map((r) => r.release_profile_stage_id).filter(Boolean);
1485
+ const profileNames = await getProfileNamesForStageIds(stageIds);
1486
+ const names = [...new Set(Object.values(profileNames))].join(", ");
1487
+ throw new Error(
1488
+ `Multiple env configs found for ${a.appName}/${a.environment} across profiles: ${names}. Pass releaseProfile parameter to select one (e.g. releaseProfile: "${Object.values(profileNames)[0] || "..."}")`
1489
+ );
1490
+ }
1491
+ const decrypted = decrypt(data[0].env_data_encrypted);
1149
1492
  return { content: [{ type: "text", text: decrypted }] };
1150
1493
  }
1151
1494
  case "env-store": {
1152
1495
  const appName = String(a.appName);
1153
1496
  const environment = String(a.environment);
1154
1497
  const encrypted = encrypt(String(a.content));
1155
- const { data: existing } = await supabase.from("env_config").select("id").eq("app_name", appName).eq("environment", environment).single();
1498
+ let resolvedStageIds = null;
1499
+ if (a.releaseProfile) {
1500
+ const { stageIds } = await resolveReleaseProfileStageIds(String(a.releaseProfile));
1501
+ resolvedStageIds = stageIds;
1502
+ }
1503
+ let existQuery = supabase.from("env_config").select("id, release_profile_stage_id").eq("app_name", appName).eq("environment", environment);
1504
+ if (resolvedStageIds) {
1505
+ existQuery = existQuery.in("release_profile_stage_id", resolvedStageIds);
1506
+ }
1507
+ const { data: existingRows, error: existErr } = await existQuery;
1508
+ if (existErr) throw new Error(`Lookup failed: ${existErr.message}`);
1509
+ if (existingRows && existingRows.length > 1 && !resolvedStageIds) {
1510
+ const stageIds = existingRows.map((r) => r.release_profile_stage_id).filter(Boolean);
1511
+ const profileNames = await getProfileNamesForStageIds(stageIds);
1512
+ const names = [...new Set(Object.values(profileNames))].join(", ");
1513
+ throw new Error(
1514
+ `Multiple env configs found for ${appName}/${environment} across profiles: ${names}. Pass releaseProfile parameter to select one.`
1515
+ );
1516
+ }
1517
+ const existing = existingRows?.[0] ?? null;
1518
+ let saveMsg;
1156
1519
  if (existing) {
1157
- const { error: error2 } = await supabase.from("env_config").update({
1520
+ const { error } = await supabase.from("env_config").update({
1158
1521
  env_data_encrypted: encrypted,
1159
1522
  description: a.description ? String(a.description) : void 0,
1160
1523
  updated_by: authContext.userId,
1161
1524
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1162
1525
  }).eq("id", existing.id);
1163
- if (error2) throw new Error(error2.message);
1164
- return { content: [{ type: "text", text: `Updated env config: ${appName}/${environment}` }] };
1526
+ if (error) throw new Error(error.message);
1527
+ saveMsg = `Updated env config: ${appName}/${environment}`;
1528
+ } else {
1529
+ const insertData = {
1530
+ app_name: appName,
1531
+ environment,
1532
+ env_data_encrypted: encrypted,
1533
+ description: a.description ? String(a.description) : null,
1534
+ created_by: authContext.userId,
1535
+ updated_by: authContext.userId
1536
+ };
1537
+ if (resolvedStageIds?.[0]) {
1538
+ insertData.release_profile_stage_id = resolvedStageIds[0];
1539
+ }
1540
+ const { error } = await supabase.from("env_config").insert(insertData);
1541
+ if (error) throw new Error(error.message);
1542
+ saveMsg = `Stored env config: ${appName}/${environment}`;
1165
1543
  }
1166
- const { error } = await supabase.from("env_config").insert({
1167
- app_name: appName,
1168
- environment,
1169
- env_data_encrypted: encrypted,
1170
- description: a.description ? String(a.description) : null,
1171
- created_by: authContext.userId,
1172
- updated_by: authContext.userId
1173
- });
1174
- if (error) throw new Error(error.message);
1175
- return { content: [{ type: "text", text: `Stored env config: ${appName}/${environment}` }] };
1544
+ const vercelStatus = await attemptVercelSync(appName, environment);
1545
+ return { content: [{ type: "text", text: `${saveMsg}. ${vercelStatus}` }] };
1176
1546
  }
1177
1547
  // ----- Cache Purge -----
1178
1548
  case "cache-purge": {