gufi-cli 0.1.35 → 0.1.36

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.
@@ -42,6 +42,13 @@ export declare function setCurrentEnv(env: "prod" | "local" | "custom"): void;
42
42
  export declare function loadConfig(): GufiConfig;
43
43
  export declare function saveConfig(config: GufiConfig): void;
44
44
  export declare function getToken(): string | undefined;
45
+ export declare function getTokenForEnv(env: "prod" | "dev" | "local"): string | undefined;
46
+ export declare function getRefreshTokenForEnv(env: "prod" | "dev" | "local"): string | undefined;
47
+ export declare function setTokenForEnv(env: "prod" | "dev" | "local", token: string, email: string, refreshToken?: string): void;
48
+ export declare function getCredentialsForEnv(env: "prod" | "dev" | "local"): {
49
+ email?: string;
50
+ password?: string;
51
+ } | undefined;
45
52
  export declare function setToken(token: string, email: string, refreshToken?: string): void;
46
53
  export declare function getRefreshToken(): string | undefined;
47
54
  export declare function setRefreshToken(refreshToken: string): void;
@@ -117,6 +117,43 @@ export function saveConfig(config) {
117
117
  export function getToken() {
118
118
  return loadConfig().token;
119
119
  }
120
+ // Get token for a specific environment
121
+ // Note: "dev" maps to "local" internally for backwards compatibility
122
+ export function getTokenForEnv(env) {
123
+ const file = loadConfigFile();
124
+ const key = env === "dev" ? "local" : env;
125
+ return file.environments[key]?.token;
126
+ }
127
+ // Get refresh token for a specific environment
128
+ export function getRefreshTokenForEnv(env) {
129
+ const file = loadConfigFile();
130
+ const key = env === "dev" ? "local" : env;
131
+ return file.environments[key]?.refreshToken;
132
+ }
133
+ // Set token for a specific environment
134
+ export function setTokenForEnv(env, token, email, refreshToken) {
135
+ const file = loadConfigFile();
136
+ const key = env === "dev" ? "local" : env;
137
+ if (!file.environments[key]) {
138
+ file.environments[key] = { apiUrl: key === "prod" ? "https://gogufi.com" : "http://localhost:3000" };
139
+ }
140
+ file.environments[key].token = token;
141
+ file.environments[key].email = email;
142
+ if (refreshToken) {
143
+ file.environments[key].refreshToken = refreshToken;
144
+ }
145
+ saveConfigFile(file);
146
+ }
147
+ // Get credentials for a specific environment (for auto-login)
148
+ export function getCredentialsForEnv(env) {
149
+ const file = loadConfigFile();
150
+ const key = env === "dev" ? "local" : env;
151
+ const envConfig = file.environments[key];
152
+ if (envConfig?.email && envConfig?.password) {
153
+ return { email: envConfig.email, password: envConfig.password };
154
+ }
155
+ return undefined;
156
+ }
120
157
  export function setToken(token, email, refreshToken) {
121
158
  const config = loadConfig();
122
159
  config.token = token;
package/dist/mcp.js CHANGED
@@ -14,13 +14,47 @@ import * as readline from "readline";
14
14
  import * as fs from "fs";
15
15
  import * as path from "path";
16
16
  import { fileURLToPath } from "url";
17
- import { getToken, getApiUrl, getRefreshToken, setToken, loadConfig, isLoggedIn, getCurrentEnv } from "./lib/config.js";
17
+ import { getToken, getRefreshToken, loadConfig, isLoggedIn, getTokenForEnv, getRefreshTokenForEnv, setTokenForEnv, getCredentialsForEnv } from "./lib/config.js";
18
18
  import { pullView, pushView, getViewDir, loadViewMeta } from "./lib/sync.js";
19
19
  // For ES modules __dirname equivalent
20
20
  const __filename = fileURLToPath(import.meta.url);
21
21
  const __dirname = path.dirname(__filename);
22
22
  // docs/mcp path relative to CLI
23
23
  const DOCS_MCP_PATH = path.resolve(__dirname, "../../../docs/mcp");
24
+ // ════════════════════════════════════════════════════════════════════════════
25
+ // Environment Configuration (stateless - passed per request)
26
+ // ════════════════════════════════════════════════════════════════════════════
27
+ // IMPORTANT: No more global sessionEnv variable!
28
+ // Each tool call can specify env='local' or env='prod' (default: prod)
29
+ // This prevents cross-session conflicts when multiple Claude instances share the MCP process.
30
+ const ENV_URLS = {
31
+ prod: "https://gogufi.com",
32
+ dev: "http://localhost:3000",
33
+ local: "http://localhost:3000", // Alias for backwards compatibility
34
+ };
35
+ // Default environment - always prod for safety
36
+ const DEFAULT_ENV = "prod";
37
+ /**
38
+ * Get API URL for the specified environment.
39
+ * @param env - 'prod' (default) or 'dev' (localhost:3000 → Cloud SQL Dev)
40
+ */
41
+ function getApiUrl(env) {
42
+ const resolvedEnv = (env === "dev" || env === "local" || env === "prod") ? env : DEFAULT_ENV;
43
+ return ENV_URLS[resolvedEnv];
44
+ }
45
+ /**
46
+ * Resolve env parameter to canonical form ('prod' or 'dev')
47
+ * 'local' is treated as alias for 'dev'
48
+ */
49
+ function resolveEnv(env) {
50
+ if (env === "dev" || env === "local")
51
+ return "dev";
52
+ return "prod";
53
+ }
54
+ // Keep for backwards compatibility (some internal functions may use this)
55
+ function getSessionApiUrl() {
56
+ return ENV_URLS[DEFAULT_ENV];
57
+ }
24
58
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
25
59
  const viewFilesCache = new Map();
26
60
  const viewContextCache = new Map();
@@ -49,6 +83,56 @@ function setCachedViewContext(viewId, context) {
49
83
  viewContextCache.set(viewId, { data: context, timestamp: Date.now() });
50
84
  }
51
85
  // ════════════════════════════════════════════════════════════════════════════
86
+ // Automation Code Validation
87
+ // ════════════════════════════════════════════════════════════════════════════
88
+ /**
89
+ * 💜 Detecta patrones de IDs hardcodeados que rompen entre entornos
90
+ *
91
+ * Busca:
92
+ * - Tablas físicas: m310_t4146 (deben usar nombres lógicos: stock.productos)
93
+ * - entity_id/tableId hardcodeados: entity_id === 1234
94
+ *
95
+ * @param code - Código a validar
96
+ * @returns Array de warnings (vacío si no hay problemas)
97
+ */
98
+ function checkForHardcodedIds(code) {
99
+ const warnings = [];
100
+ // Pattern 1: Physical table names (m123_t456)
101
+ const physicalTablePattern = /m\d+_t\d+/g;
102
+ const physicalMatches = code.match(physicalTablePattern);
103
+ if (physicalMatches) {
104
+ const uniqueTables = [...new Set(physicalMatches)];
105
+ warnings.push(`⚠️ HARDCODED TABLE IDS: Found physical table names: ${uniqueTables.join(', ')}. ` +
106
+ `Use logical names instead (e.g., 'stock.productos' not 'm310_t4146'). ` +
107
+ `Physical IDs change between DEV/PROD environments.`);
108
+ }
109
+ // Pattern 2: Hardcoded entity_id comparisons (entity_id === 1234 or entity_id == 1234)
110
+ const entityIdPattern = /entity_id\s*(?:===?|!==?)\s*(\d{3,})/g;
111
+ const entityMatches = [...code.matchAll(entityIdPattern)];
112
+ if (entityMatches.length > 0) {
113
+ const ids = entityMatches.map(m => m[1]);
114
+ warnings.push(`⚠️ HARDCODED ENTITY_ID: Found comparisons with literal IDs: ${ids.join(', ')}. ` +
115
+ `Use context.table instead (e.g., table.includes('supplier_orders') not entity_id === 7140).`);
116
+ }
117
+ // Pattern 3: Hardcoded tableId comparisons
118
+ const tableIdPattern = /tableId\s*(?:===?|!==?)\s*(\d{3,})/g;
119
+ const tableIdMatches = [...code.matchAll(tableIdPattern)];
120
+ if (tableIdMatches.length > 0) {
121
+ const ids = tableIdMatches.map(m => m[1]);
122
+ warnings.push(`⚠️ HARDCODED TABLE_ID: Found comparisons with literal IDs: ${ids.join(', ')}. ` +
123
+ `Use logical table names instead.`);
124
+ }
125
+ // Pattern 4: Hardcoded table_id comparisons (underscore version)
126
+ const table_idPattern = /table_id\s*(?:===?|!==?)\s*(\d{3,})/g;
127
+ const table_idMatches = [...code.matchAll(table_idPattern)];
128
+ if (table_idMatches.length > 0) {
129
+ const ids = table_idMatches.map(m => m[1]);
130
+ warnings.push(`⚠️ HARDCODED TABLE_ID: Found comparisons with literal IDs: ${ids.join(', ')}. ` +
131
+ `Use logical table names instead.`);
132
+ }
133
+ return warnings;
134
+ }
135
+ // ════════════════════════════════════════════════════════════════════════════
52
136
  // Analytics - Track MCP tool calls
53
137
  // ════════════════════════════════════════════════════════════════════════════
54
138
  async function trackAnalytics(data) {
@@ -58,7 +142,8 @@ async function trackAnalytics(data) {
58
142
  console.error("[MCP Analytics] No token, skipping");
59
143
  return;
60
144
  }
61
- const url = `${getApiUrl()}/api/cli/mcp-analytics`;
145
+ // Analytics always go to prod
146
+ const url = `${getApiUrl("prod")}/api/cli/mcp-analytics`;
62
147
  console.error(`[MCP Analytics] Tracking ${data.tool_name} to ${url}`);
63
148
  const res = await fetch(url, {
64
149
  method: "POST",
@@ -81,19 +166,26 @@ async function trackAnalytics(data) {
81
166
  // API Client (reused from CLI)
82
167
  // ════════════════════════════════════════════════════════════════════════════
83
168
  async function autoLogin() {
84
- const config = loadConfig();
85
- if (!config.email || !config.password)
86
- return undefined;
169
+ return autoLoginWithEnv(DEFAULT_ENV);
170
+ }
171
+ async function autoLoginWithEnv(env) {
172
+ const resolvedEnv = resolveEnv(env);
173
+ // ONLY use credentials for the specified env - NO fallback to other env
174
+ const creds = getCredentialsForEnv(resolvedEnv);
175
+ if (!creds?.email || !creds?.password) {
176
+ throw new Error(`No credentials for env '${resolvedEnv}'. Run: gufi config:${resolvedEnv === 'dev' ? 'local' : 'prod'} && gufi login`);
177
+ }
87
178
  try {
88
- const response = await fetch(`${getApiUrl()}/api/auth/login`, {
179
+ const response = await fetch(`${getApiUrl(env)}/api/auth/login`, {
89
180
  method: "POST",
90
181
  headers: { "Content-Type": "application/json", "X-Client": "mcp" },
91
- body: JSON.stringify({ email: config.email, password: config.password }),
182
+ body: JSON.stringify({ email: creds.email, password: creds.password }),
92
183
  });
93
184
  if (!response.ok)
94
185
  return undefined;
95
186
  const data = await response.json();
96
- setToken(data.accessToken, config.email, data.refreshToken);
187
+ // Save token to the correct environment
188
+ setTokenForEnv(resolvedEnv, data.accessToken, creds.email, data.refreshToken);
97
189
  return data.accessToken;
98
190
  }
99
191
  catch {
@@ -101,32 +193,43 @@ async function autoLogin() {
101
193
  }
102
194
  }
103
195
  async function refreshOrLogin() {
104
- const refreshToken = getRefreshToken();
196
+ return refreshOrLoginWithEnv(DEFAULT_ENV);
197
+ }
198
+ async function refreshOrLoginWithEnv(env) {
199
+ const resolvedEnv = resolveEnv(env);
200
+ const refreshToken = getRefreshTokenForEnv(resolvedEnv) || getRefreshToken();
105
201
  if (refreshToken) {
106
202
  try {
107
- const response = await fetch(`${getApiUrl()}/api/auth/refresh`, {
203
+ const response = await fetch(`${getApiUrl(env)}/api/auth/refresh`, {
108
204
  method: "POST",
109
205
  headers: { "Content-Type": "application/json", "X-Client": "mcp", "X-Refresh-Token": refreshToken },
110
206
  });
111
207
  if (response.ok) {
112
208
  const data = await response.json();
113
- const config = loadConfig();
114
- setToken(data.accessToken, config.email || "", data.refreshToken);
209
+ const creds = getCredentialsForEnv(resolvedEnv) || loadConfig();
210
+ setTokenForEnv(resolvedEnv, data.accessToken, creds.email || "", data.refreshToken);
115
211
  return data.accessToken;
116
212
  }
117
213
  }
118
214
  catch { }
119
215
  }
120
- return autoLogin();
216
+ return autoLoginWithEnv(env);
121
217
  }
122
- async function apiRequest(endpoint, options = {}, companyId, retry = true) {
123
- let token = getToken();
218
+ // Simple wrapper - just passes env to apiRequestWithEnv
219
+ async function apiRequest(endpoint, options = {}, companyId, retry = true, env) {
220
+ return apiRequestWithEnv(endpoint, options, companyId, env, retry);
221
+ }
222
+ async function apiRequestWithEnv(endpoint, options = {}, companyId, env, retry = true) {
223
+ const resolvedEnv = resolveEnv(env);
224
+ // ONLY use token for the specified env - NO fallback to prod token
225
+ let token = getTokenForEnv(resolvedEnv);
226
+ const apiUrl = getApiUrl(env);
124
227
  if (!token) {
125
- token = await autoLogin();
228
+ token = await autoLoginWithEnv(env);
126
229
  if (!token)
127
230
  throw new Error("Not logged in. Run: gufi login");
128
231
  }
129
- const url = `${getApiUrl()}${endpoint}`;
232
+ const url = `${apiUrl}${endpoint}`;
130
233
  const headers = {
131
234
  "Content-Type": "application/json",
132
235
  Authorization: `Bearer ${token}`,
@@ -140,9 +243,9 @@ async function apiRequest(endpoint, options = {}, companyId, retry = true) {
140
243
  headers: { ...headers, ...options.headers },
141
244
  });
142
245
  if ((response.status === 401 || response.status === 403) && retry) {
143
- const newToken = await refreshOrLogin();
246
+ const newToken = await refreshOrLoginWithEnv(env);
144
247
  if (newToken)
145
- return apiRequest(endpoint, options, companyId, false);
248
+ return apiRequestWithEnv(endpoint, options, companyId, env, false);
146
249
  }
147
250
  if (!response.ok) {
148
251
  const text = await response.text();
@@ -150,14 +253,17 @@ async function apiRequest(endpoint, options = {}, companyId, retry = true) {
150
253
  }
151
254
  return response.json();
152
255
  }
153
- async function developerRequest(path, options = {}, retry = true) {
154
- let token = getToken();
256
+ async function developerRequest(path, options = {}, retry = true, env) {
257
+ const resolvedEnv = resolveEnv(env);
258
+ // ONLY use token for the specified env - NO fallback to prod token
259
+ let token = getTokenForEnv(resolvedEnv);
260
+ const apiUrl = getApiUrl(env);
155
261
  if (!token) {
156
- token = await autoLogin();
262
+ token = await autoLoginWithEnv(env);
157
263
  if (!token)
158
264
  throw new Error("Not logged in. Run: gufi login");
159
265
  }
160
- const res = await fetch(`${getApiUrl()}/api/developer${path}`, {
266
+ const res = await fetch(`${apiUrl}/api/developer${path}`, {
161
267
  ...options,
162
268
  headers: {
163
269
  Authorization: `Bearer ${token}`,
@@ -167,9 +273,9 @@ async function developerRequest(path, options = {}, retry = true) {
167
273
  },
168
274
  });
169
275
  if ((res.status === 401 || res.status === 403) && retry) {
170
- const newToken = await refreshOrLogin();
276
+ const newToken = await refreshOrLoginWithEnv(env);
171
277
  if (newToken)
172
- return developerRequest(path, options, false);
278
+ return developerRequest(path, options, false, env);
173
279
  }
174
280
  if (!res.ok) {
175
281
  const error = await res.text();
@@ -223,12 +329,12 @@ function generateFileId() {
223
329
  * Output: { id, url, name, mime, uploaded_at }
224
330
  */
225
331
  function normalizeFileObject(file) {
226
- // Validate required fields
227
- if (!file.url || typeof file.url !== "string") {
228
- throw new Error("File field error: 'url' is required and must be a string");
332
+ // Validate required fields - url cannot be empty
333
+ if (!file.url || typeof file.url !== "string" || file.url.trim() === "") {
334
+ throw new Error("File field error: 'url' is required and cannot be empty");
229
335
  }
230
- if (!file.name || typeof file.name !== "string") {
231
- throw new Error("File field error: 'name' is required and must be a string");
336
+ if (!file.name || typeof file.name !== "string" || file.name.trim() === "") {
337
+ throw new Error("File field error: 'name' is required and cannot be empty");
232
338
  }
233
339
  // Validate url format (must be company_X/... path or full URL)
234
340
  const isValidPath = file.url.startsWith("company_") || file.url.startsWith("http");
@@ -276,14 +382,16 @@ const PHYSICAL_TABLE_RE = /^m\d+_t\d+$/;
276
382
  /**
277
383
  * Get company schema (cached)
278
384
  */
279
- async function getCompanySchema(companyId) {
280
- const cached = schemaCache.get(companyId);
385
+ async function getCompanySchema(companyId, env) {
386
+ // Include env in cache key to avoid mixing schemas from different environments
387
+ const cacheKey = `${companyId}:${env || 'prod'}`;
388
+ const cached = schemaCache.get(cacheKey);
281
389
  if (cached && Date.now() - cached.timestamp < SCHEMA_CACHE_TTL) {
282
390
  return cached.schema;
283
391
  }
284
392
  try {
285
- const data = await apiRequest("/api/cli/schema", {}, companyId);
286
- schemaCache.set(companyId, { schema: data, timestamp: Date.now() });
393
+ const data = await apiRequest("/api/cli/schema", {}, companyId, true, env);
394
+ schemaCache.set(cacheKey, { schema: data, timestamp: Date.now() });
287
395
  return data;
288
396
  }
289
397
  catch (err) {
@@ -298,7 +406,7 @@ async function getCompanySchema(companyId) {
298
406
  * @param companyId - company ID for schema lookup
299
407
  * @returns physical table name or throws error
300
408
  */
301
- async function resolveTableName(tableName, companyId) {
409
+ async function resolveTableName(tableName, companyId, env) {
302
410
  // If already physical, return as-is
303
411
  if (PHYSICAL_TABLE_RE.test(tableName)) {
304
412
  return tableName;
@@ -309,7 +417,7 @@ async function resolveTableName(tableName, companyId) {
309
417
  throw new Error(`Invalid table name: "${tableName}". Use physical (m123_t456) or logical (module.entity) format`);
310
418
  }
311
419
  const [moduleName, entityName] = parts;
312
- const schema = await getCompanySchema(companyId);
420
+ const schema = await getCompanySchema(companyId, env);
313
421
  if (!schema?.modules) {
314
422
  throw new Error(`Cannot resolve logical table name "${tableName}": schema not available. Use physical name (m123_t456) instead`);
315
423
  }
@@ -353,6 +461,8 @@ catch (err) {
353
461
  function getDesc(toolName, fallback = "") {
354
462
  return toolDescriptions[toolName]?.description || fallback;
355
463
  }
464
+ // Environment parameter description (reused across tools)
465
+ const ENV_PARAM = { type: "string", description: "Environment: 'prod' (default) or 'dev'. Use 'dev' for development against localhost:3000" };
356
466
  const TOOLS = [
357
467
  // ─────────────────────────────────────────────────────────────────────────
358
468
  // Context & Info
@@ -368,14 +478,22 @@ const TOOLS = [
368
478
  company_id: { type: "string", description: "Company ID to get context for" },
369
479
  module_id: { type: "string", description: "Module ID to get detailed context for (requires company_id)" },
370
480
  entity_id: { type: "string", description: "Entity ID to get detailed context for (requires company_id)" },
481
+ env: ENV_PARAM,
371
482
  },
372
483
  },
373
484
  },
374
485
  {
375
486
  name: "gufi_whoami",
376
487
  description: getDesc("gufi_whoami"),
377
- inputSchema: { type: "object", properties: {} },
488
+ inputSchema: {
489
+ type: "object",
490
+ properties: {
491
+ env: { type: "string", description: "Environment: 'prod' (default) or 'local'" },
492
+ }
493
+ },
378
494
  },
495
+ // gufi_set_env REMOVED - environment is now passed per-request via 'env' parameter
496
+ // This prevents cross-session conflicts when multiple Claude instances share the MCP process
379
497
  // gufi_schema REMOVED - functionality merged into gufi_context (always full)
380
498
  {
381
499
  name: "gufi_schema_modify",
@@ -385,6 +503,7 @@ const TOOLS = [
385
503
  properties: {
386
504
  company_id: { type: "string", description: "Company ID (optional if user has only one company)" },
387
505
  preview: { type: "boolean", description: "If true, show what would be done without executing (dry run)" },
506
+ skip_existing: { type: "boolean", description: "If true, skip fields/entities that already exist instead of failing" },
388
507
  operations: {
389
508
  type: "array",
390
509
  description: "Array of operations to execute",
@@ -425,16 +544,34 @@ const TOOLS = [
425
544
  inputSchema: {
426
545
  type: "object",
427
546
  properties: {
428
- action: { type: "string", description: "Action: list, get, create" },
547
+ action: { type: "string", description: "Action: list, get, create, update, delete" },
429
548
  company_id: { type: "string", description: "Company ID" },
430
549
  id: { type: "string", description: "Script ID (for get)" },
431
- name: { type: "string", description: "Script name (for create, snake_case)" },
432
- code: { type: "string", description: "JavaScript code (for create)" },
550
+ name: { type: "string", description: "Script name (for create/update/delete, snake_case)" },
551
+ code: { type: "string", description: "JavaScript code (for create/update)" },
433
552
  description: { type: "string", description: "Description (for create)" },
553
+ env: ENV_PARAM,
434
554
  },
435
555
  required: ["action", "company_id"],
436
556
  },
437
557
  },
558
+ {
559
+ name: "gufi_automation_test",
560
+ description: "Ejecutar/testear un automation script manualmente.\n\nEjemplo:\ngufi_automation_test({\n company_id: '116',\n script_name: 'send_email',\n entity: 'ventas.facturas',\n trigger_event: 'on_click',\n row: { id: 123, total: 150 },\n input: { email_to: 'test@example.com' } // Para on_click\n})",
561
+ inputSchema: {
562
+ type: "object",
563
+ properties: {
564
+ company_id: { type: "string", description: "Company ID" },
565
+ script_name: { type: "string", description: "Script name to execute" },
566
+ entity: { type: "string", description: "Entity name (module.entity format like 'ventas.facturas')" },
567
+ trigger_event: { type: "string", description: "Trigger event: on_create, on_update, on_delete, on_click" },
568
+ row: { type: "object", description: "Row data to pass to the script (must include id)" },
569
+ input: { type: "object", description: "Input data for on_click triggers (goes to context.input)" },
570
+ env: ENV_PARAM,
571
+ },
572
+ required: ["company_id", "script_name", "entity", "trigger_event", "row"],
573
+ },
574
+ },
438
575
  {
439
576
  name: "gufi_triggers",
440
577
  description: getDesc("gufi_triggers"),
@@ -447,6 +584,7 @@ const TOOLS = [
447
584
  type: "object",
448
585
  description: "Trigger config to SET (omit to GET current config). Format: { on_create: [...], on_update: [...], on_delete: [...], on_click: [...] }",
449
586
  },
587
+ env: ENV_PARAM,
450
588
  },
451
589
  required: ["entity_id", "company_id"],
452
590
  },
@@ -460,6 +598,7 @@ const TOOLS = [
460
598
  company_id: { type: "string", description: "Company ID" },
461
599
  limit: { type: "number", description: "Number of records (default 20)" },
462
600
  script_name: { type: "string", description: "Filter by script name" },
601
+ env: ENV_PARAM,
463
602
  },
464
603
  required: ["company_id"],
465
604
  },
@@ -473,7 +612,7 @@ const TOOLS = [
473
612
  inputSchema: {
474
613
  type: "object",
475
614
  properties: {
476
- action: { type: "string", description: "Action: list, get, create, update, delete" },
615
+ action: { type: "string", description: "Action: list, get, create, update, delete, aggregate" },
477
616
  table: { type: "string", description: "Table name (logical name like 'ventas.productos' or physical ID)" },
478
617
  company_id: { type: "string", description: "Company ID (required)" },
479
618
  id: { type: "number", description: "Row ID (for get, update, delete)" },
@@ -483,6 +622,13 @@ const TOOLS = [
483
622
  sort: { type: "string", description: "Field to sort by (default: id)" },
484
623
  order: { type: "string", description: "ASC or DESC (default: DESC)" },
485
624
  filter: { type: "string", description: "Filter expression (field=value)" },
625
+ agg: { type: "string", description: "Aggregation function for 'aggregate' action: count, sum, avg, min, max" },
626
+ field: { type: "string", description: "Field to aggregate (for 'aggregate' action)" },
627
+ groupBy: { type: "string", description: "Field to group by (for 'aggregate' action)" },
628
+ dateField: { type: "string", description: "Date field name for date range filtering (for 'aggregate' action)" },
629
+ dateFrom: { type: "string", description: "Start date ISO string for date range (for 'aggregate' action)" },
630
+ dateTo: { type: "string", description: "End date ISO string for date range (for 'aggregate' action)" },
631
+ env: ENV_PARAM,
486
632
  },
487
633
  required: ["action", "table", "company_id"],
488
634
  },
@@ -500,6 +646,7 @@ const TOOLS = [
500
646
  company_id: { type: "string", description: "Company ID" },
501
647
  key: { type: "string", description: "Variable name (for set/delete)" },
502
648
  value: { type: "string", description: "Variable value (for set)" },
649
+ env: ENV_PARAM,
503
650
  },
504
651
  required: ["action", "company_id"],
505
652
  },
@@ -523,6 +670,7 @@ Example: gufi_view_pull({ view_id: 13 })`,
523
670
  properties: {
524
671
  view_id: { type: "number", description: "View ID to pull" },
525
672
  company_id: { type: "string", description: "Company ID (required for company-specific views)" },
673
+ env: ENV_PARAM,
526
674
  },
527
675
  required: ["view_id"],
528
676
  },
@@ -543,6 +691,7 @@ Example: gufi_view_push({ view_id: 13, message: "Fixed bug in chart" })`,
543
691
  properties: {
544
692
  view_id: { type: "number", description: "View ID to push (optional if in view directory)" },
545
693
  message: { type: "string", description: "Optional commit message for version history" },
694
+ env: ENV_PARAM,
546
695
  },
547
696
  required: [],
548
697
  },
@@ -562,6 +711,7 @@ Example: gufi_view_push({ view_id: 13, message: "Fixed bug in chart" })`,
562
711
  description: { type: "string", description: "Package description (for create)" },
563
712
  module_id: { type: "string", description: "Module ID (for add_module, remove_module)" },
564
713
  company_id: { type: "string", description: "Source company ID (for add_module)" },
714
+ env: ENV_PARAM,
565
715
  },
566
716
  required: ["action"],
567
717
  },
@@ -692,13 +842,14 @@ const toolHandlers = {
692
842
  // ─────────────────────────────────────────────────────────────────────────
693
843
  // Context & Info
694
844
  // ─────────────────────────────────────────────────────────────────────────
695
- async gufi_whoami() {
845
+ async gufi_whoami(params) {
846
+ const env = params.env || DEFAULT_ENV;
696
847
  const config = loadConfig();
697
848
  const loggedIn = isLoggedIn();
698
849
  let companies = [];
699
850
  if (loggedIn) {
700
851
  try {
701
- const data = await apiRequest("/api/companies");
852
+ const data = await apiRequestWithEnv("/api/companies", {}, undefined, env);
702
853
  const list = data.items || data.data || data || [];
703
854
  companies = list.map((c) => ({ id: c.id, name: c.name }));
704
855
  }
@@ -707,14 +858,16 @@ const toolHandlers = {
707
858
  return {
708
859
  logged_in: loggedIn,
709
860
  email: config.email || null,
710
- environment: getCurrentEnv(),
711
- api_url: getApiUrl(),
861
+ env: env,
862
+ api_url: getApiUrl(env),
712
863
  companies,
713
864
  hint: companies.length > 0
714
865
  ? `Use gufi_context({ company_id: '${companies[0].id}' }) to get schema`
715
866
  : "Login with 'gufi login' first",
867
+ tip: "Pass env: 'dev' to any tool to use localhost:3000 (Cloud SQL Dev) instead of gogufi.com (prod)",
716
868
  };
717
869
  },
870
+ // gufi_set_env REMOVED - use env parameter in each tool instead
718
871
  async gufi_context(params) {
719
872
  // Generate intelligent context based on what's requested
720
873
  // Always returns FULL context (no more "detail" parameter)
@@ -796,12 +949,39 @@ const toolHandlers = {
796
949
  // gufi_context({ company_id: "X", module_id: "Y" })
797
950
  // gufi_context({ company_id: "X", entity_id: "Z" })
798
951
  async gufi_schema_modify(params) {
952
+ // 💜 Map common type aliases to valid Gufi types
953
+ const TYPE_ALIASES = {
954
+ textarea: "text",
955
+ varchar: "text",
956
+ string: "text",
957
+ int: "number_int",
958
+ integer: "number_int",
959
+ float: "number_float",
960
+ decimal: "number_float",
961
+ double: "number_float",
962
+ bool: "boolean",
963
+ timestamp: "datetime",
964
+ money: "currency",
965
+ tel: "phone",
966
+ address: "location",
967
+ };
968
+ // Process operations: normalize field types
969
+ const normalizedOps = params.operations.map((op) => {
970
+ if (op.field?.type && TYPE_ALIASES[op.field.type]) {
971
+ return {
972
+ ...op,
973
+ field: { ...op.field, type: TYPE_ALIASES[op.field.type] },
974
+ };
975
+ }
976
+ return op;
977
+ });
799
978
  // Use the new /api/schema/operations endpoint for semantic operations
800
979
  const result = await apiRequest("/api/schema/operations", {
801
980
  method: "POST",
802
981
  body: JSON.stringify({
803
- operations: params.operations,
982
+ operations: normalizedOps,
804
983
  preview: params.preview || false,
984
+ skip_existing: params.skip_existing || false,
805
985
  }),
806
986
  }, params.company_id);
807
987
  if (params.preview) {
@@ -910,12 +1090,12 @@ const toolHandlers = {
910
1090
  // Automations
911
1091
  // ─────────────────────────────────────────────────────────────────────────
912
1092
  async gufi_automation(params) {
913
- const { action, company_id, id, name, code, description } = params;
1093
+ const { action, company_id, id, name, code, description, env } = params;
914
1094
  switch (action) {
915
1095
  case "list": {
916
- const data = await apiRequest("/api/automation-scripts", {
1096
+ const data = await apiRequestWithEnv("/api/automation-scripts", {
917
1097
  headers: { "X-Company-ID": company_id },
918
- });
1098
+ }, company_id, env);
919
1099
  const automations = Array.isArray(data) ? data : data.data || [];
920
1100
  return {
921
1101
  scripts: automations.map((a) => ({
@@ -928,9 +1108,9 @@ const toolHandlers = {
928
1108
  case "get": {
929
1109
  if (!id)
930
1110
  throw new Error("id required for action 'get'");
931
- const data = await apiRequest("/api/automation-scripts", {
1111
+ const data = await apiRequestWithEnv("/api/automation-scripts", {
932
1112
  headers: { "X-Company-ID": company_id },
933
- });
1113
+ }, company_id, env);
934
1114
  const automations = Array.isArray(data) ? data : data.data || [];
935
1115
  const found = automations.find((a) => String(a.id) === id);
936
1116
  if (!found)
@@ -945,33 +1125,127 @@ const toolHandlers = {
945
1125
  case "create": {
946
1126
  if (!name || !code)
947
1127
  throw new Error("name and code required for action 'create'");
948
- const response = await apiRequest("/api/automation-scripts", {
1128
+ // 💜 Check for hardcoded IDs that break between environments
1129
+ const createWarnings = checkForHardcodedIds(code);
1130
+ const response = await apiRequestWithEnv("/api/automation-scripts", {
949
1131
  method: "POST",
950
1132
  body: JSON.stringify({ name, code, description: description || "" }),
951
1133
  headers: { "X-Company-ID": company_id },
952
- });
953
- return { success: true, script: response.data || response };
1134
+ }, company_id, env);
1135
+ const createResult = { success: true, script: response.data || response };
1136
+ if (createWarnings.length > 0) {
1137
+ createResult.warnings = createWarnings;
1138
+ createResult.hint = "Use logical table names (e.g., 'stock.productos') and context.table for entity detection";
1139
+ }
1140
+ return createResult;
1141
+ }
1142
+ case "update": {
1143
+ if (!name || !code)
1144
+ throw new Error("name and code required for action 'update'");
1145
+ // 💜 Check for hardcoded IDs that break between environments
1146
+ const updateWarnings = checkForHardcodedIds(code);
1147
+ const response = await apiRequestWithEnv(`/api/automation-scripts/${encodeURIComponent(name)}`, {
1148
+ method: "PUT",
1149
+ body: JSON.stringify({ code }),
1150
+ headers: { "X-Company-ID": company_id },
1151
+ }, company_id, env);
1152
+ const updateResult = { success: true, script: response.data || response };
1153
+ if (updateWarnings.length > 0) {
1154
+ updateResult.warnings = updateWarnings;
1155
+ updateResult.hint = "Use logical table names (e.g., 'stock.productos') and context.table for entity detection";
1156
+ }
1157
+ return updateResult;
1158
+ }
1159
+ case "delete": {
1160
+ if (!name)
1161
+ throw new Error("name required for action 'delete'");
1162
+ await apiRequestWithEnv(`/api/automation-scripts/${encodeURIComponent(name)}`, {
1163
+ method: "DELETE",
1164
+ headers: { "X-Company-ID": company_id },
1165
+ }, company_id, env);
1166
+ return { success: true, deleted: name };
954
1167
  }
955
1168
  default:
956
- throw new Error(`Unknown action: ${action}. Use: list, get, create`);
1169
+ throw new Error(`Unknown action: ${action}. Use: list, get, create, update, delete`);
957
1170
  }
958
1171
  },
1172
+ async gufi_automation_test(params) {
1173
+ const { company_id, script_name, entity, trigger_event, row, input, env } = params;
1174
+ // Resolve entity to get module_id and table_id
1175
+ const schema = await getCompanySchema(company_id, env);
1176
+ if (!schema?.modules) {
1177
+ throw new Error("Cannot get schema. Make sure company_id is correct.");
1178
+ }
1179
+ // Parse entity (module.entity format)
1180
+ const parts = entity.split(".");
1181
+ if (parts.length !== 2) {
1182
+ throw new Error(`Invalid entity format: "${entity}". Use module.entity (e.g., 'ventas.facturas')`);
1183
+ }
1184
+ const [moduleName, entityName] = parts;
1185
+ // Find module and entity IDs
1186
+ let moduleId = null;
1187
+ let tableId = null;
1188
+ let tableName = null;
1189
+ for (const mod of schema.modules) {
1190
+ const modMatch = mod.name?.toLowerCase() === moduleName.toLowerCase() ||
1191
+ mod.label?.toLowerCase() === moduleName.toLowerCase();
1192
+ if (!modMatch)
1193
+ continue;
1194
+ moduleId = mod.id;
1195
+ for (const ent of mod.entities || []) {
1196
+ const entMatch = ent.name?.toLowerCase() === entityName.toLowerCase() ||
1197
+ ent.label?.toLowerCase() === entityName.toLowerCase();
1198
+ if (entMatch) {
1199
+ tableId = ent.id;
1200
+ tableName = `m${moduleId}_t${tableId}`;
1201
+ break;
1202
+ }
1203
+ }
1204
+ if (tableId)
1205
+ break;
1206
+ }
1207
+ if (!moduleId || !tableId) {
1208
+ throw new Error(`Entity "${entity}" not found in schema`);
1209
+ }
1210
+ // Call test endpoint (same as backend: accepts row and/or input)
1211
+ const response = await apiRequest("/api/automation-scripts/test", {
1212
+ method: "POST",
1213
+ body: JSON.stringify({
1214
+ company_id,
1215
+ module_id: moduleId,
1216
+ function_name: script_name,
1217
+ trigger_event,
1218
+ table_id: tableId,
1219
+ table_name: tableName,
1220
+ row,
1221
+ input: input || null,
1222
+ }),
1223
+ headers: { "X-Company-ID": company_id },
1224
+ }, company_id, true, env);
1225
+ return {
1226
+ success: true,
1227
+ script_name,
1228
+ entity,
1229
+ trigger_event,
1230
+ result: response.data || response,
1231
+ };
1232
+ },
959
1233
  async gufi_triggers(params) {
960
- const { entity_id, company_id, config } = params;
1234
+ const { entity_id, company_id, config, env } = params;
961
1235
  if (config) {
962
1236
  // SET triggers
963
1237
  await apiRequest(`/api/entities/${entity_id}/automations`, {
964
1238
  method: "PUT",
965
1239
  body: JSON.stringify(config),
966
1240
  headers: { "X-Company-ID": company_id },
967
- }, company_id);
1241
+ }, company_id, true, env);
968
1242
  return { success: true, entity_id, config };
969
1243
  }
970
1244
  else {
971
1245
  // GET triggers
972
1246
  const response = await apiRequest(`/api/entities/${entity_id}/automations`, {
973
1247
  headers: { "X-Company-ID": company_id },
974
- }, company_id);
1248
+ }, company_id, true, env);
975
1249
  const data = response.data || response;
976
1250
  return {
977
1251
  entity_id,
@@ -995,27 +1269,29 @@ const toolHandlers = {
995
1269
  // ─────────────────────────────────────────────────────────────────────────
996
1270
  async gufi_data(params) {
997
1271
  // 💜 Unified data tool - all CRUD operations in one
998
- const { action, company_id } = params;
1272
+ const { action, company_id, env } = params;
999
1273
  // 💜 Resolve logical table names (module.entity) to physical (m123_t456)
1000
- const table = await resolveTableName(params.table, company_id);
1274
+ const table = await resolveTableName(params.table, company_id, env);
1001
1275
  switch (action) {
1002
1276
  case "list": {
1003
1277
  const limit = params.limit || 20;
1004
1278
  const offset = params.offset || 0;
1279
+ // 💜 IMPORTANTE: Backend espera page/perPage, NO pagination.current/pageSize
1005
1280
  const body = {
1006
- pagination: { current: Math.floor(offset / limit) + 1, pageSize: limit },
1281
+ page: Math.floor(offset / limit) + 1,
1282
+ perPage: limit,
1007
1283
  sorters: [{ field: params.sort || "id", order: params.order || "desc" }],
1008
- filters: [],
1284
+ filter: [], // Backend usa 'filter', no 'filters'
1009
1285
  };
1010
1286
  if (params.filter) {
1011
1287
  const [field, value] = params.filter.split("=");
1012
1288
  if (field && value)
1013
- body.filters.push({ field, operator: "eq", value });
1289
+ body.filter.push({ field, operator: "eq", value });
1014
1290
  }
1015
1291
  const result = await apiRequest(`/api/tables/${table}/list`, {
1016
1292
  method: "POST",
1017
1293
  body: JSON.stringify(body),
1018
- }, company_id);
1294
+ }, company_id, true, env);
1019
1295
  return { action: "list", table, total: result.total, rows: result.data || [] };
1020
1296
  }
1021
1297
  case "get": {
@@ -1024,7 +1300,7 @@ const toolHandlers = {
1024
1300
  const result = await apiRequest(`/api/tables/${table}/getOne`, {
1025
1301
  method: "POST",
1026
1302
  body: JSON.stringify({ id: params.id }),
1027
- }, company_id);
1303
+ }, company_id, true, env);
1028
1304
  return { action: "get", table, id: params.id, row: result.data || result };
1029
1305
  }
1030
1306
  case "create": {
@@ -1033,7 +1309,7 @@ const toolHandlers = {
1033
1309
  const result = await apiRequest(`/api/tables/${table}`, {
1034
1310
  method: "POST",
1035
1311
  body: JSON.stringify(params.data),
1036
- }, company_id);
1312
+ }, company_id, true, env);
1037
1313
  return { action: "create", table, success: true, id: result.id || result.data?.id, row: result.data || result };
1038
1314
  }
1039
1315
  case "update": {
@@ -1044,7 +1320,7 @@ const toolHandlers = {
1044
1320
  const result = await apiRequest(`/api/tables/${table}/${params.id}`, {
1045
1321
  method: "PUT",
1046
1322
  body: JSON.stringify(params.data),
1047
- }, company_id);
1323
+ }, company_id, true, env);
1048
1324
  return { action: "update", table, success: true, id: params.id, row: result.data || result };
1049
1325
  }
1050
1326
  case "delete": {
@@ -1052,11 +1328,59 @@ const toolHandlers = {
1052
1328
  throw new Error("id is required for 'delete' action");
1053
1329
  await apiRequest(`/api/tables/${table}/${params.id}`, {
1054
1330
  method: "DELETE",
1055
- }, company_id);
1331
+ }, company_id, true, env);
1056
1332
  return { action: "delete", table, success: true, id: params.id };
1057
1333
  }
1334
+ case "aggregate": {
1335
+ // 💜 Analytics query using runAnalyticsSpec engine
1336
+ const agg = params.agg || "count";
1337
+ const field = params.field || "id";
1338
+ const groupBy = params.groupBy;
1339
+ const dateField = params.dateField;
1340
+ const dateFrom = params.dateFrom;
1341
+ const dateTo = params.dateTo;
1342
+ // Build analytics spec
1343
+ const spec = {
1344
+ source: table,
1345
+ measures: [
1346
+ { alias: "value", fn: agg, from: "t", field },
1347
+ ],
1348
+ filters: [],
1349
+ };
1350
+ // Add groupBy as dimension if provided
1351
+ if (groupBy) {
1352
+ spec.dimensions = [{ alias: groupBy, from: "t", field: groupBy }];
1353
+ }
1354
+ // Add date range filters if provided
1355
+ if (dateField && dateFrom) {
1356
+ spec.filters.push({ from: "t", field: dateField, op: ">=", value: dateFrom });
1357
+ }
1358
+ if (dateField && dateTo) {
1359
+ spec.filters.push({ from: "t", field: dateField, op: "<=", value: dateTo });
1360
+ }
1361
+ // Add simple filter if provided (field=value)
1362
+ if (params.filter) {
1363
+ const [filterField, filterValue] = params.filter.split("=");
1364
+ if (filterField && filterValue) {
1365
+ spec.filters.push({ from: "t", field: filterField, op: "=", value: filterValue });
1366
+ }
1367
+ }
1368
+ const result = await apiRequest(`/api/analytics/query`, {
1369
+ method: "POST",
1370
+ body: JSON.stringify({ spec, ttlSeconds: 60 }),
1371
+ }, company_id, true, env);
1372
+ return {
1373
+ action: "aggregate",
1374
+ table,
1375
+ agg,
1376
+ field,
1377
+ groupBy: groupBy || null,
1378
+ dateRange: dateFrom || dateTo ? { from: dateFrom, to: dateTo } : null,
1379
+ result: Array.isArray(result) ? result : [result],
1380
+ };
1381
+ }
1058
1382
  default:
1059
- throw new Error(`Unknown action: ${action}. Use: list, get, create, update, delete`);
1383
+ throw new Error(`Unknown action: ${action}. Use: list, get, create, update, delete, aggregate`);
1060
1384
  }
1061
1385
  },
1062
1386
  // ─────────────────────────────────────────────────────────────────────────
@@ -1281,7 +1605,7 @@ async function getClaudeOptimizedContext(companyId, moduleId, entityId, fullText
1281
1605
  if (entityId)
1282
1606
  params.set("entity_id", entityId);
1283
1607
  const queryString = params.toString();
1284
- const url = `${getApiUrl()}/api/schema/export-claude${queryString ? "?" + queryString : ""}`;
1608
+ const url = `${getSessionApiUrl()}/api/schema/export-claude${queryString ? "?" + queryString : ""}`;
1285
1609
  let token = getToken();
1286
1610
  if (!token) {
1287
1611
  token = await autoLogin();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gufi-cli",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "CLI for developing Gufi Marketplace views locally with Claude Code",
5
5
  "bin": {
6
6
  "gufi": "./bin/gufi.js"