gufi-cli 0.1.7 → 0.1.8

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/CLAUDE.md CHANGED
@@ -612,7 +612,8 @@ Cuando descargas una view con `gufi pull`, obtienes:
612
612
  ├── types.ts # Interfaces TypeScript
613
613
 
614
614
  ├── core/
615
- └── dataProvider.ts # 💜 dataSources + featureConfig (MUY IMPORTANTE)
615
+ ├── dataProvider.ts # 💜 dataSources + featureConfig (MUY IMPORTANTE)
616
+ │ └── permissions.ts # 💜 Sistema de permisos dinámico
616
617
 
617
618
  ├── metadata/
618
619
  │ ├── inputs.ts # 💜 Inputs configurables por usuario
@@ -620,7 +621,8 @@ Cuando descargas una view con `gufi pull`, obtienes:
620
621
  │ └── help.en.ts # Documentación en inglés
621
622
 
622
623
  ├── components/
623
- └── MiComponente.tsx # Componentes React
624
+ ├── MiComponente.tsx # Componentes React
625
+ │ └── DevPermissionSwitcher.tsx # 💜 Testing de permisos en dev
624
626
 
625
627
  ├── views/
626
628
  │ └── page.tsx # Página principal
@@ -882,6 +884,158 @@ Para crear tarea, pulsa el botón **+**
882
884
 
883
885
  ---
884
886
 
887
+ ### 💜 permissions - Sistema de Permisos Explícitos (Sin Wildcards)
888
+
889
+ Las vistas usan un sistema de **permisos explícitos** - sin wildcards, sin magia. Cada permiso es un string específico que existe o no en el array de permisos del usuario.
890
+
891
+ > ⚠️ **Importante**: Eliminamos el soporte de wildcards (`*`) porque causaba problemas de integridad entre el estado de la UI y los permisos reales. Siempre usa strings de permisos explícitos.
892
+
893
+ **1. Declarar permisos en `metadata/permissions.ts`:**
894
+ ```typescript
895
+ // metadata/permissions.ts
896
+ export const permissions = [
897
+ // Permisos estáticos - toggles simples
898
+ {
899
+ key: 'send_orders',
900
+ label: { es: 'Enviar pedidos', en: 'Send orders' },
901
+ description: { es: 'Puede enviar pedidos a proveedores', en: 'Can send orders' },
902
+ },
903
+ {
904
+ key: 'view_costs',
905
+ label: { es: 'Ver costos', en: 'View costs' },
906
+ description: { es: 'Puede ver precios de compra', en: 'Can see purchase prices' },
907
+ },
908
+
909
+ // Permisos dinámicos - se resuelven en runtime
910
+ {
911
+ key: 'warehouse:*', // Placeholder, se resuelve a warehouse:madrid, warehouse:barcelona, etc.
912
+ label: { es: 'Acceso a almacén', en: 'Warehouse access' },
913
+ description: { es: 'Almacenes a los que tiene acceso', en: 'Warehouses user can access' },
914
+ dynamic: true, // Marca este como dinámico
915
+ },
916
+ ] as const;
917
+ ```
918
+
919
+ **2. Crear `core/permissions.ts` (sin wildcards):**
920
+ ```typescript
921
+ // core/permissions.ts
922
+ // SIN WILDCARDS - solo permisos explícitos
923
+
924
+ export interface PermissionsConfig {
925
+ userPermissions: string[]; // Permisos del usuario (resueltos desde su rol)
926
+ isDevMode: boolean;
927
+ devPermissions: string[]; // Override en modo dev para testing
928
+ }
929
+
930
+ // Obtener permisos efectivos (dev mode override)
931
+ function getEffectivePermissions(config: PermissionsConfig): string[] {
932
+ const { userPermissions, isDevMode, devPermissions } = config;
933
+ return (isDevMode && devPermissions.length > 0) ? devPermissions : userPermissions;
934
+ }
935
+
936
+ // Check simple - ¿tiene exactamente este permiso?
937
+ export function hasPermission(config: PermissionsConfig, permission: string): boolean {
938
+ const perms = getEffectivePermissions(config);
939
+ return perms.includes(permission);
940
+ }
941
+
942
+ // Helpers específicos de la vista
943
+ export function canSendOrders(config: PermissionsConfig): boolean {
944
+ return hasPermission(config, 'send_orders');
945
+ }
946
+
947
+ export function canViewCosts(config: PermissionsConfig): boolean {
948
+ return hasPermission(config, 'view_costs');
949
+ }
950
+
951
+ // Obtener warehouses permitidos (null si no hay restricciones)
952
+ export function getAllowedWarehouses(config: PermissionsConfig): string[] | null {
953
+ const perms = getEffectivePermissions(config);
954
+
955
+ const warehouses: string[] = [];
956
+ for (const perm of perms) {
957
+ if (perm.startsWith('warehouse:')) {
958
+ warehouses.push(perm.slice('warehouse:'.length));
959
+ }
960
+ }
961
+
962
+ return warehouses.length > 0 ? warehouses : null;
963
+ }
964
+ ```
965
+
966
+ **3. Usar en el componente con inicialización correcta:**
967
+ ```typescript
968
+ import { useState, useEffect, useRef } from 'react';
969
+ import { DevPermissionSwitcher } from '../components/DevPermissionSwitcher';
970
+ import { canSendOrders, getAllowedWarehouses } from '../core/permissions';
971
+
972
+ export default function MiVista({ gufi }) {
973
+ const lang = gufi?.context?.lang || 'es';
974
+ const isDevMode = gufi?.context?.isPreview || window.location.hostname === 'localhost';
975
+
976
+ // 💜 IMPORTANTE: Inicializar vacío, poblar cuando los datos carguen
977
+ const [devPermissions, setDevPermissions] = useState<string[]>([]);
978
+ const devPermissionsInitialized = useRef(false);
979
+
980
+ // Cargar warehouses de tu data source
981
+ const [warehouses, setWarehouses] = useState([]);
982
+
983
+ // Inicializar permisos cuando warehouses carguen (una sola vez)
984
+ useEffect(() => {
985
+ if (isDevMode && !devPermissionsInitialized.current && warehouses.length > 0) {
986
+ devPermissionsInitialized.current = true;
987
+
988
+ // Otorgar todos los permisos explícitos
989
+ const allPerms = [
990
+ 'send_orders',
991
+ 'view_costs',
992
+ ...warehouses.map(w => `warehouse:${w.name.toLowerCase()}`),
993
+ ];
994
+ setDevPermissions(allPerms);
995
+ }
996
+ }, [isDevMode, warehouses]);
997
+
998
+ // Construir config de permisos
999
+ const permissionsConfig = {
1000
+ userPermissions: gufi?.context?.user?.permissions || [],
1001
+ isDevMode,
1002
+ devPermissions,
1003
+ };
1004
+
1005
+ // Usar helpers de permisos
1006
+ const showCosts = canViewCosts(permissionsConfig);
1007
+ const allowedWarehouses = getAllowedWarehouses(permissionsConfig);
1008
+
1009
+ return (
1010
+ <div>
1011
+ {showCosts && <CostsColumn />}
1012
+
1013
+ {/* DevPermissionSwitcher - solo en dev mode */}
1014
+ {isDevMode && (
1015
+ <DevPermissionSwitcher
1016
+ lang={lang}
1017
+ activePermissions={devPermissions}
1018
+ onPermissionsChange={setDevPermissions}
1019
+ dynamicValues={{
1020
+ warehouse: warehouses.map(w => w.name.toLowerCase())
1021
+ }}
1022
+ />
1023
+ )}
1024
+ </div>
1025
+ );
1026
+ }
1027
+ ```
1028
+
1029
+ **DevPermissionSwitcher**: Botón flotante **purple** (💜 Gufi style) que permite:
1030
+ - Toggle individual de permisos on/off
1031
+ - Botón "Todos" en el header para acceso completo
1032
+ - Permisos dinámicos expandibles (ej: warehouses individuales)
1033
+ - Integridad total: UI siempre refleja el estado real
1034
+
1035
+ **Principio clave**: Inicializar con `[]` vacío, luego usar `useEffect` para otorgar todos los permisos cuando los valores dinámicos (como warehouses) terminen de cargar. Esto garantiza integridad UI.
1036
+
1037
+ ---
1038
+
885
1039
  ### 💜 automations en Vistas
886
1040
 
887
1041
  Las vistas pueden incluir automations que se ejecutan al hacer click.
@@ -1,53 +1,11 @@
1
1
  /**
2
2
  * gufi login - Authenticate with Gufi
3
3
  */
4
- import readline from "readline";
4
+ import prompts from "prompts";
5
5
  import chalk from "chalk";
6
6
  import ora from "ora";
7
7
  import { login, validateToken } from "../lib/api.js";
8
8
  import { setToken, isLoggedIn, clearToken, loadConfig, setApiUrl } from "../lib/config.js";
9
- function prompt(question, hidden = false) {
10
- const rl = readline.createInterface({
11
- input: process.stdin,
12
- output: process.stdout,
13
- });
14
- return new Promise((resolve) => {
15
- if (hidden) {
16
- process.stdout.write(question);
17
- let input = "";
18
- process.stdin.setRawMode?.(true);
19
- process.stdin.resume();
20
- process.stdin.on("data", (char) => {
21
- const c = char.toString();
22
- if (c === "\n" || c === "\r") {
23
- process.stdin.setRawMode?.(false);
24
- process.stdout.write("\n");
25
- rl.close();
26
- resolve(input);
27
- }
28
- else if (c === "\u0003") {
29
- process.exit();
30
- }
31
- else if (c === "\u007F") {
32
- if (input.length > 0) {
33
- input = input.slice(0, -1);
34
- process.stdout.write("\b \b");
35
- }
36
- }
37
- else {
38
- input += c;
39
- process.stdout.write("*");
40
- }
41
- });
42
- }
43
- else {
44
- rl.question(question, (answer) => {
45
- rl.close();
46
- resolve(answer);
47
- });
48
- }
49
- });
50
- }
51
9
  export async function loginCommand(options) {
52
10
  console.log(chalk.magenta("\n 🟣 Gufi Developer CLI\n"));
53
11
  // Set custom API URL if provided
@@ -62,8 +20,13 @@ export async function loginCommand(options) {
62
20
  const valid = await validateToken();
63
21
  if (valid) {
64
22
  spinner.succeed(chalk.green(`Ya estás logueado como ${config.email}`));
65
- const relogin = await prompt("\n¿Quieres iniciar sesión con otra cuenta? (s/N): ");
66
- if (relogin.toLowerCase() !== "s") {
23
+ const { relogin } = await prompts({
24
+ type: "confirm",
25
+ name: "relogin",
26
+ message: "¿Quieres iniciar sesión con otra cuenta?",
27
+ initial: false,
28
+ });
29
+ if (!relogin) {
67
30
  return;
68
31
  }
69
32
  clearToken();
@@ -73,19 +36,34 @@ export async function loginCommand(options) {
73
36
  clearToken();
74
37
  }
75
38
  }
76
- // Get credentials
77
- const email = await prompt(" Email: ");
78
- const password = await prompt(" Password: ", true);
79
- if (!email || !password) {
39
+ // Get credentials using prompts (more robust than readline)
40
+ const response = await prompts([
41
+ {
42
+ type: "text",
43
+ name: "email",
44
+ message: "Email",
45
+ validate: (v) => (v ? true : "Email es requerido"),
46
+ },
47
+ {
48
+ type: "password",
49
+ name: "password",
50
+ message: "Password",
51
+ validate: (v) => (v ? true : "Password es requerido"),
52
+ },
53
+ ]);
54
+ if (!response.email || !response.password) {
80
55
  console.log(chalk.red("\n ✗ Email y password son requeridos\n"));
81
56
  process.exit(1);
82
57
  }
83
58
  const spinner = ora("Iniciando sesión...").start();
84
59
  try {
85
- const { token, refreshToken } = await login(email, password);
86
- setToken(token, email, refreshToken);
87
- spinner.succeed(chalk.green(`Sesión iniciada como ${email}`));
88
- console.log(chalk.gray("\n Tu sesión se mantendrá activa automáticamente.\n"));
60
+ const { token, refreshToken } = await login(response.email, response.password);
61
+ if (!refreshToken) {
62
+ console.log(chalk.yellow("\n ⚠️ No se recibió refresh token - sesión durará 1 hora"));
63
+ }
64
+ setToken(token, response.email, refreshToken);
65
+ spinner.succeed(chalk.green(`Sesión iniciada como ${response.email}`));
66
+ console.log(chalk.gray("\n Tu sesión se mantendrá activa automáticamente (7 días).\n"));
89
67
  console.log(chalk.gray(" Ahora puedes usar: gufi pull <vista>\n"));
90
68
  }
91
69
  catch (error) {
@@ -21,7 +21,7 @@ export async function pullCommand(viewIdentifier) {
21
21
  const spinner = ora("Obteniendo vista...").start();
22
22
  try {
23
23
  const view = await getView(viewId);
24
- viewName = view.name;
24
+ viewName = view.name || `vista-${viewId}`;
25
25
  packageId = view.package_id;
26
26
  spinner.succeed(`Vista: ${viewName}`);
27
27
  }
@@ -57,11 +57,12 @@ export async function pullCommand(viewIdentifier) {
57
57
  }
58
58
  console.log(chalk.gray(" Vistas disponibles:\n"));
59
59
  views.forEach((view, i) => {
60
- console.log(` ${chalk.cyan(i + 1)}. ${view.name} ${chalk.gray(`(${view.view_type})`)}`);
60
+ const name = view.name || `Vista ${view.pk_id}`;
61
+ console.log(` ${chalk.cyan(i + 1)}. ${name} ${chalk.gray(`(${view.view_type})`)}`);
61
62
  });
62
63
  // If viewIdentifier matches a name
63
64
  let selectedView = viewIdentifier
64
- ? views.find(v => v.name.toLowerCase().includes(viewIdentifier.toLowerCase()))
65
+ ? views.find(v => v.name && v.name.toLowerCase().includes(viewIdentifier.toLowerCase()))
65
66
  : views[0];
66
67
  if (!selectedView) {
67
68
  console.log(chalk.yellow(`\n No se encontró vista "${viewIdentifier}"\n`));
@@ -69,7 +70,7 @@ export async function pullCommand(viewIdentifier) {
69
70
  process.exit(1);
70
71
  }
71
72
  viewId = selectedView.pk_id;
72
- viewName = selectedView.name;
73
+ viewName = selectedView.name || `vista-${selectedView.pk_id}`;
73
74
  console.log(chalk.gray(`\n Descargando: ${viewName}\n`));
74
75
  }
75
76
  catch (error) {
package/dist/lib/api.js CHANGED
@@ -14,8 +14,10 @@ class ApiError extends Error {
14
14
  // Auto-refresh the token if expired
15
15
  async function refreshAccessToken() {
16
16
  const refreshToken = getRefreshToken();
17
- if (!refreshToken)
17
+ if (!refreshToken) {
18
+ console.error("[gufi] No refresh token available");
18
19
  return null;
20
+ }
19
21
  try {
20
22
  const url = `${getApiUrl()}/api/auth/refresh`;
21
23
  const response = await fetch(url, {
@@ -26,14 +28,18 @@ async function refreshAccessToken() {
26
28
  "X-Refresh-Token": refreshToken,
27
29
  },
28
30
  });
29
- if (!response.ok)
31
+ if (!response.ok) {
32
+ console.error(`[gufi] Refresh failed: ${response.status}`);
30
33
  return null;
34
+ }
31
35
  const data = await response.json();
32
36
  const config = loadConfig();
33
37
  setToken(data.accessToken, config.email || "", data.refreshToken);
38
+ console.log("[gufi] Token refreshed successfully");
34
39
  return data.accessToken;
35
40
  }
36
- catch {
41
+ catch (err) {
42
+ console.error("[gufi] Refresh error:", err);
37
43
  return null;
38
44
  }
39
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gufi-cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "CLI for developing Gufi Marketplace views locally with Claude Code",
5
5
  "bin": {
6
6
  "gufi": "./bin/gufi.js"
@@ -22,12 +22,14 @@
22
22
  "start": "node bin/gufi.js"
23
23
  },
24
24
  "dependencies": {
25
+ "@types/prompts": "^2.4.9",
25
26
  "@types/ws": "^8.18.1",
26
27
  "chalk": "^5.3.0",
27
28
  "chokidar": "^3.5.3",
28
29
  "commander": "^12.0.0",
29
30
  "node-fetch": "^3.3.2",
30
31
  "ora": "^8.0.1",
32
+ "prompts": "^2.4.2",
31
33
  "ws": "^8.18.3"
32
34
  },
33
35
  "devDependencies": {