gufi-cli 0.1.46 β†’ 0.1.48

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.
@@ -6,7 +6,7 @@ import chalk from "chalk";
6
6
  import ora from "ora";
7
7
  import fs from "fs";
8
8
  import path from "path";
9
- import { pushView, getChangedFiles, loadViewMeta } from "../lib/sync.js";
9
+ import { pushView, getChangedFiles, loadViewMeta, checkVersionConflict } from "../lib/sync.js";
10
10
  import { isLoggedIn, getCurrentView } from "../lib/config.js";
11
11
  import { validateView } from "../lib/api.js";
12
12
  import { scanFilesForXSS, hasBlockingErrors } from "../lib/security.js";
@@ -244,6 +244,19 @@ export async function pushCommand(viewDir) {
244
244
  console.log(chalk.yellow(` β€’ ${file}`));
245
245
  });
246
246
  console.log();
247
+ // πŸ’œ Version check (BEFORE anything else)
248
+ const versionSpinner = ora("Verificando versiΓ³n...").start();
249
+ const versionCheck = await checkVersionConflict(dir);
250
+ if (versionCheck?.hasConflict) {
251
+ versionSpinner.fail(chalk.red("Conflicto de versiΓ³n"));
252
+ console.log(chalk.red(`\n βœ— ${versionCheck.message}`));
253
+ console.log(chalk.cyan(`\n Haz pull primero: gufi view:pull ${meta.viewId}`));
254
+ console.log(chalk.yellow("\n ⚠️ Si tienes cambios locales, cópialos a otra carpeta antes del pull.\n"));
255
+ process.exit(1);
256
+ }
257
+ else {
258
+ versionSpinner.succeed(chalk.green("VersiΓ³n actualizada"));
259
+ }
247
260
  // πŸ’œ Structure validation (BEFORE pushing)
248
261
  const structureSpinner = ora("Validando estructura...").start();
249
262
  const structureErrors = validateViewStructure(dir);
package/dist/lib/api.d.ts CHANGED
@@ -35,8 +35,9 @@ export declare function getViewFiles(viewId: number): Promise<ViewFile[]>;
35
35
  /**
36
36
  * πŸ’œ Full sync: saves all files and deletes any not in the list
37
37
  * This is the ONLY way to push - ensures local and server are in sync
38
+ * Backend validates lastPulledSnapshot to prevent version conflicts (409)
38
39
  */
39
- export declare function saveViewFiles(viewId: number, files: ViewFile[], message?: string): Promise<{
40
+ export declare function saveViewFiles(viewId: number, files: ViewFile[], message?: string, lastPulledSnapshot?: number): Promise<{
40
41
  snapshot?: number;
41
42
  deleted?: number;
42
43
  }>;
@@ -65,6 +66,14 @@ export declare function validateFiles(files: Array<{
65
66
  file_path: string;
66
67
  content: string;
67
68
  }>): Promise<ValidationResult>;
69
+ /**
70
+ * Get the latest snapshot number for a view
71
+ * Used to check if local version is up-to-date before pushing
72
+ */
73
+ export declare function getLatestSnapshot(viewId: number): Promise<{
74
+ view_id: number;
75
+ latest_snapshot: number;
76
+ }>;
68
77
  export interface FileHash {
69
78
  hash: string;
70
79
  updated_at: string;
package/dist/lib/api.js CHANGED
@@ -135,11 +135,12 @@ export async function getViewFiles(viewId) {
135
135
  /**
136
136
  * πŸ’œ Full sync: saves all files and deletes any not in the list
137
137
  * This is the ONLY way to push - ensures local and server are in sync
138
+ * Backend validates lastPulledSnapshot to prevent version conflicts (409)
138
139
  */
139
- export async function saveViewFiles(viewId, files, message) {
140
+ export async function saveViewFiles(viewId, files, message, lastPulledSnapshot) {
140
141
  const response = await request(`/api/marketplace/views/${viewId}/files/bulk`, {
141
142
  method: "POST",
142
- body: JSON.stringify({ files, message, sync: true }),
143
+ body: JSON.stringify({ files, message, sync: true, lastPulledSnapshot }),
143
144
  });
144
145
  return response;
145
146
  }
@@ -172,6 +173,16 @@ export async function validateFiles(files) {
172
173
  body: JSON.stringify({ files }),
173
174
  });
174
175
  }
176
+ // ════════════════════════════════════════════════════════════════════
177
+ // πŸ’œ Version Check API - For preventing overwrites
178
+ // ════════════════════════════════════════════════════════════════════
179
+ /**
180
+ * Get the latest snapshot number for a view
181
+ * Used to check if local version is up-to-date before pushing
182
+ */
183
+ export async function getLatestSnapshot(viewId) {
184
+ return request(`/api/marketplace/views/${viewId}/latest-snapshot`);
185
+ }
175
186
  /**
176
187
  * Get server-side file hashes for a view
177
188
  * Used to compare local vs server content and detect real changes
@@ -7,6 +7,7 @@ export interface ViewMeta {
7
7
  viewName: string;
8
8
  packageId: number;
9
9
  lastSync: string;
10
+ lastPulledSnapshot?: number;
10
11
  files: Record<string, {
11
12
  hash: string;
12
13
  }>;
@@ -22,6 +23,7 @@ export declare function loadViewMeta(viewDir: string): ViewMeta | null;
22
23
  export declare function pullView(viewId: number, viewKey: string, packageId: number): Promise<{
23
24
  dir: string;
24
25
  fileCount: number;
26
+ snapshot?: number;
25
27
  }>;
26
28
  /**
27
29
  * πŸ’œ Push local files to Gufi (FULL SYNC)
@@ -33,6 +35,16 @@ export declare function pushView(viewDir?: string, message?: string): Promise<{
33
35
  deleted: number;
34
36
  snapshot?: number;
35
37
  }>;
38
+ /**
39
+ * πŸ’œ Check if local version matches server exactly
40
+ * If server snapshot !== local snapshot β†’ conflict (must pull first)
41
+ */
42
+ export declare function checkVersionConflict(viewDir: string): Promise<{
43
+ hasConflict: boolean;
44
+ localSnapshot?: number;
45
+ serverSnapshot?: number;
46
+ message?: string;
47
+ } | null>;
36
48
  /**
37
49
  * Check for local changes that need pushing
38
50
  */
package/dist/lib/sync.js CHANGED
@@ -6,7 +6,7 @@ import fs from "fs";
6
6
  import path from "path";
7
7
  import os from "os";
8
8
  import crypto from "crypto";
9
- import { getViewFiles, saveViewFiles } from "./api.js";
9
+ import { getViewFiles, saveViewFiles, getLatestSnapshot } from "./api.js";
10
10
  import { setCurrentView, getCurrentView } from "./config.js";
11
11
  // πŸ’œ Views go to ~/gufi-dev/ for local development
12
12
  const GUFI_DEV_DIR = path.join(os.homedir(), "gufi-dev");
@@ -61,6 +61,15 @@ function saveViewMeta(viewDir, meta) {
61
61
  export async function pullView(viewId, viewKey, packageId) {
62
62
  const viewDir = getViewDir(viewKey);
63
63
  ensureDir(viewDir);
64
+ // πŸ’œ Get current snapshot number from server
65
+ let currentSnapshot;
66
+ try {
67
+ const snapshotInfo = await getLatestSnapshot(viewId);
68
+ currentSnapshot = snapshotInfo.latest_snapshot;
69
+ }
70
+ catch {
71
+ // If endpoint not available, continue without snapshot tracking
72
+ }
64
73
  const files = await getViewFiles(viewId);
65
74
  const fileMeta = {};
66
75
  for (const file of files) {
@@ -73,15 +82,16 @@ export async function pullView(viewId, viewKey, packageId) {
73
82
  }
74
83
  const meta = {
75
84
  viewId,
76
- viewName: viewKey, // πŸ’œ Store viewKey as viewName for backwards compat
85
+ viewName: viewKey,
77
86
  packageId,
78
87
  lastSync: new Date().toISOString(),
88
+ lastPulledSnapshot: currentSnapshot, // πŸ’œ Must match server exactly to push
79
89
  files: fileMeta,
80
90
  };
81
91
  saveViewMeta(viewDir, meta);
82
92
  // Update current view in config
83
93
  setCurrentView({ id: viewId, name: viewKey, packageId, localPath: viewDir });
84
- return { dir: viewDir, fileCount: files.length };
94
+ return { dir: viewDir, fileCount: files.length, snapshot: currentSnapshot };
85
95
  }
86
96
  /**
87
97
  * πŸ’œ Push local files to Gufi (FULL SYNC)
@@ -113,10 +123,14 @@ export async function pushView(viewDir, message) {
113
123
  newMeta[filePath] = { hash: hashContent(content) };
114
124
  }
115
125
  // Push ALL files - backend handles sync (deletes files not in request)
116
- const result = await saveViewFiles(meta.viewId, allFiles, message);
117
- // Update meta with new file list
126
+ // πŸ’œ Send lastPulledSnapshot for server-side version check (returns 409 on conflict)
127
+ const result = await saveViewFiles(meta.viewId, allFiles, message, meta.lastPulledSnapshot);
128
+ // Update meta with new file list and snapshot
118
129
  meta.files = newMeta;
119
130
  meta.lastSync = new Date().toISOString();
131
+ if (result.snapshot) {
132
+ meta.lastPulledSnapshot = result.snapshot; // πŸ’œ Update to new snapshot
133
+ }
120
134
  saveViewMeta(dir, meta);
121
135
  return { pushed: allFiles.length, deleted: result.deleted || 0, snapshot: result.snapshot };
122
136
  }
@@ -139,6 +153,43 @@ function getLocalFiles(dir, prefix = "") {
139
153
  }
140
154
  return files;
141
155
  }
156
+ /**
157
+ * πŸ’œ Check if local version matches server exactly
158
+ * If server snapshot !== local snapshot β†’ conflict (must pull first)
159
+ */
160
+ export async function checkVersionConflict(viewDir) {
161
+ const meta = loadViewMeta(viewDir);
162
+ if (!meta)
163
+ return null;
164
+ try {
165
+ const serverInfo = await getLatestSnapshot(meta.viewId);
166
+ const serverSnapshot = serverInfo.latest_snapshot;
167
+ // πŸ’œ If server has versions but we don't have local snapshot tracking,
168
+ // it means we pulled with an old CLI version - require pull first
169
+ if (serverSnapshot > 0 && meta.lastPulledSnapshot === undefined) {
170
+ return {
171
+ hasConflict: true,
172
+ localSnapshot: undefined,
173
+ serverSnapshot,
174
+ message: `Tu versiΓ³n local no tiene tracking de versiones. El servidor estΓ‘ en v${serverSnapshot}. Haz pull primero.`
175
+ };
176
+ }
177
+ // πŸ’œ If snapshots don't match, someone else pushed - require pull first
178
+ if (meta.lastPulledSnapshot !== undefined && serverSnapshot !== meta.lastPulledSnapshot) {
179
+ return {
180
+ hasConflict: true,
181
+ localSnapshot: meta.lastPulledSnapshot,
182
+ serverSnapshot,
183
+ message: `Tu versiΓ³n local (v${meta.lastPulledSnapshot}) no coincide con el servidor (v${serverSnapshot}). Haz pull primero.`
184
+ };
185
+ }
186
+ return { hasConflict: false, localSnapshot: meta.lastPulledSnapshot, serverSnapshot };
187
+ }
188
+ catch {
189
+ // If can't check, allow push (backwards compat)
190
+ return { hasConflict: false };
191
+ }
192
+ }
142
193
  /**
143
194
  * Check for local changes that need pushing
144
195
  */
package/dist/mcp.js CHANGED
@@ -37,6 +37,14 @@ function canWriteLocal() {
37
37
  }
38
38
  }
39
39
  import { getToken, getRefreshToken, loadConfig, isLoggedIn, getTokenForEnv, getRefreshTokenForEnv, setTokenForEnv, getCredentialsForEnv, getCurrentEnv } from "./lib/config.js";
40
+ // πŸ’œ Valid column types (hardcoded to avoid npm dependency on @gufi/column-types)
41
+ const COLUMN_TYPE_NAMES = [
42
+ "text", "email", "url", "barcode", "signature", "number", "number_int",
43
+ "number_float", "percentage", "date", "time", "created_at", "select",
44
+ "multiselect", "relation", "reversed_relation", "users", "boolean",
45
+ "currency", "phone", "location", "file", "json", "formula", "mirror",
46
+ "custom", "password"
47
+ ];
40
48
  // For ES modules __dirname equivalent
41
49
  const __filename = fileURLToPath(import.meta.url);
42
50
  const __dirname = path.dirname(__filename);
@@ -870,7 +878,11 @@ Example: gufi_view_pull({ view_id: 13 })`,
870
878
  description: `πŸ“€ Upload local view changes to draft.
871
879
 
872
880
  Pushes changed files from ~/gufi-dev/view_<id>/ to Gufi draft.
873
- Creates a snapshot for version history (last 10 kept).
881
+ Creates a snapshot for version history (last 1000 kept).
882
+
883
+ ⚠️ IMPORTANT: Checks if local version is up-to-date before pushing.
884
+ If server has newer version, returns VERSION_CONFLICT error.
885
+ You must pull the latest version first before pushing.
874
886
 
875
887
  After pushing, use: gufi package:publish <package_id> to publish.
876
888
 
@@ -1182,37 +1194,18 @@ const toolHandlers = {
1182
1194
  // gufi_context({ company_id: "X", module_id: "Y" })
1183
1195
  // gufi_context({ company_id: "X", entity_id: "Z" })
1184
1196
  async gufi_schema_modify(params) {
1185
- // πŸ’œ Map common type aliases to valid Gufi types
1186
- const TYPE_ALIASES = {
1187
- textarea: "text",
1188
- varchar: "text",
1189
- string: "text",
1190
- int: "number_int",
1191
- integer: "number_int",
1192
- float: "number_float",
1193
- decimal: "number_float",
1194
- double: "number_float",
1195
- bool: "boolean",
1196
- timestamp: "datetime",
1197
- money: "currency",
1198
- tel: "phone",
1199
- address: "location",
1200
- };
1201
- // Process operations: normalize field types
1202
- const normalizedOps = params.operations.map((op) => {
1203
- if (op.field?.type && TYPE_ALIASES[op.field.type]) {
1204
- return {
1205
- ...op,
1206
- field: { ...op.field, type: TYPE_ALIASES[op.field.type] },
1207
- };
1197
+ // πŸ’œ Validate field types BEFORE sending to API
1198
+ // Uses COLUMN_TYPE_NAMES from @gufi/column-types (single source of truth)
1199
+ for (const op of params.operations) {
1200
+ if (op.field?.type && !COLUMN_TYPE_NAMES.includes(op.field.type)) {
1201
+ throw new Error(`Invalid field type: "${op.field.type}". Valid types: ${COLUMN_TYPE_NAMES.join(", ")}`);
1208
1202
  }
1209
- return op;
1210
- });
1203
+ }
1211
1204
  // Use the new /api/schema/operations endpoint for semantic operations
1212
1205
  const result = await apiRequest("/api/schema/operations", {
1213
1206
  method: "POST",
1214
1207
  body: JSON.stringify({
1215
- operations: normalizedOps,
1208
+ operations: params.operations,
1216
1209
  preview: params.preview || false,
1217
1210
  skip_existing: params.skip_existing || false,
1218
1211
  }),
@@ -1824,6 +1817,15 @@ const toolHandlers = {
1824
1817
  // πŸ’œ CLI: Save to ~/gufi-dev/view_<id>/
1825
1818
  const viewDir = path.join(LOCAL_VIEWS_DIR, `view_${viewId}`);
1826
1819
  fs.mkdirSync(viewDir, { recursive: true });
1820
+ // πŸ’œ Get latest snapshot for version tracking
1821
+ let latestSnapshot;
1822
+ try {
1823
+ const snapshotResp = await apiRequest(`/api/marketplace/views/${viewId}/latest-snapshot`, {}, companyId, true, env);
1824
+ latestSnapshot = snapshotResp.latest_snapshot;
1825
+ }
1826
+ catch {
1827
+ // If endpoint not available, continue without snapshot tracking
1828
+ }
1827
1829
  const fileMeta = {};
1828
1830
  for (const file of files) {
1829
1831
  const filePath = path.join(viewDir, file.file_path.replace(/^\//, ""));
@@ -1833,12 +1835,13 @@ const toolHandlers = {
1833
1835
  hash: crypto.createHash("sha256").update(file.content, "utf-8").digest("hex")
1834
1836
  };
1835
1837
  }
1836
- // Save metadata
1838
+ // Save metadata (with snapshot for version tracking)
1837
1839
  const meta = {
1838
1840
  viewId,
1839
1841
  viewName: `view_${viewId}`,
1840
1842
  packageId: view.package_id || 0,
1841
1843
  lastSync: new Date().toISOString(),
1844
+ lastPulledSnapshot: latestSnapshot, // πŸ’œ For version conflict detection
1842
1845
  files: fileMeta,
1843
1846
  };
1844
1847
  fs.writeFileSync(path.join(viewDir, ".gufi-view.json"), JSON.stringify(meta, null, 2));
@@ -1881,12 +1884,24 @@ const toolHandlers = {
1881
1884
  throw new Error("view_id is required");
1882
1885
  }
1883
1886
  let files = [];
1887
+ let lastPulledSnapshot; // πŸ’œ For version conflict detection
1884
1888
  if (useLocal) {
1885
1889
  // πŸ’œ CLI: Read from ~/gufi-dev/view_<id>/
1886
1890
  const viewDir = path.join(LOCAL_VIEWS_DIR, `view_${viewId}`);
1887
1891
  if (!fs.existsSync(viewDir)) {
1888
1892
  throw new Error(`View directory not found: ${viewDir}. Run gufi_view_pull first.`);
1889
1893
  }
1894
+ // πŸ’œ Read metadata for version tracking (backend handles the check)
1895
+ const metaPath = path.join(viewDir, ".gufi-view.json");
1896
+ if (fs.existsSync(metaPath)) {
1897
+ try {
1898
+ const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
1899
+ lastPulledSnapshot = meta.lastPulledSnapshot;
1900
+ }
1901
+ catch {
1902
+ // Ignore metadata read errors
1903
+ }
1904
+ }
1890
1905
  // Get all files recursively
1891
1906
  const getFiles = (dir, prefix = "") => {
1892
1907
  const result = [];
@@ -1921,16 +1936,83 @@ const toolHandlers = {
1921
1936
  const workspaceResponse = await apiRequest(`/api/claude/workspace/view/${viewId}/files`, {}, undefined, true, env);
1922
1937
  files = workspaceResponse.data || workspaceResponse || [];
1923
1938
  }
1939
+ // πŸ’œ VERSION CHECK: Always verify before push (don't rely on backend alone)
1940
+ // This prevents overwriting changes from other developers
1941
+ try {
1942
+ const snapshotResp = await apiRequest(`/api/marketplace/views/${viewId}/latest-snapshot`, {}, undefined, true, env);
1943
+ const serverSnapshot = snapshotResp.latest_snapshot;
1944
+ // If server has versions but we don't have a local snapshot β†’ conflict
1945
+ // (means we pulled with an old CLI that didn't track versions)
1946
+ if (serverSnapshot > 0 && lastPulledSnapshot === undefined) {
1947
+ return {
1948
+ success: false,
1949
+ error: "VERSION_CONFLICT",
1950
+ message: `Tu versiΓ³n local no tiene tracking de versiones. El servidor estΓ‘ en v${serverSnapshot}.`,
1951
+ local_snapshot: null,
1952
+ server_snapshot: serverSnapshot,
1953
+ _hint: `Haz pull primero para sincronizar: gufi_view_pull({ view_id: ${viewId} }). Si tienes cambios locales, cΓ³pialos a otra carpeta antes del pull.`,
1954
+ };
1955
+ }
1956
+ // If snapshots don't match β†’ someone else pushed
1957
+ if (lastPulledSnapshot !== undefined && serverSnapshot !== lastPulledSnapshot) {
1958
+ return {
1959
+ success: false,
1960
+ error: "VERSION_CONFLICT",
1961
+ message: `Tu versiΓ³n local (v${lastPulledSnapshot}) no coincide con el servidor (v${serverSnapshot}). Alguien mΓ‘s hizo push.`,
1962
+ local_snapshot: lastPulledSnapshot,
1963
+ server_snapshot: serverSnapshot,
1964
+ _hint: `Haz pull primero: gufi_view_pull({ view_id: ${viewId} }). Si tienes cambios locales, cΓ³pialos a otra carpeta antes del pull.`,
1965
+ };
1966
+ }
1967
+ }
1968
+ catch (err) {
1969
+ // If we can't check version, log warning but continue (backwards compat)
1970
+ console.error("Warning: Could not verify version before push:", err.message);
1971
+ }
1924
1972
  // πŸ’œ Check for large files (>1000 lines) and suggest splitting
1925
1973
  const LINE_LIMIT = 1000;
1926
1974
  const largeFiles = files
1927
1975
  .map(f => ({ path: f.file_path, lines: f.content.split('\n').length }))
1928
1976
  .filter(f => f.lines > LINE_LIMIT);
1929
1977
  // Push to server using apiRequest (supports env)
1930
- const result = await apiRequest(`/api/marketplace/views/${viewId}/files/bulk`, {
1931
- method: "POST",
1932
- body: JSON.stringify({ files, message: params.message, sync: true }),
1933
- }, undefined, true, env);
1978
+ // πŸ’œ Backend will check lastPulledSnapshot and return 409 if conflict
1979
+ let result;
1980
+ try {
1981
+ result = await apiRequest(`/api/marketplace/views/${viewId}/files/bulk`, {
1982
+ method: "POST",
1983
+ body: JSON.stringify({ files, message: params.message, sync: true, lastPulledSnapshot }),
1984
+ }, undefined, true, env);
1985
+ }
1986
+ catch (err) {
1987
+ // πŸ’œ Handle version conflict from backend
1988
+ if (err.status === 409 || err.error === "VERSION_CONFLICT") {
1989
+ return {
1990
+ success: false,
1991
+ error: "VERSION_CONFLICT",
1992
+ message: err.message || `Conflicto de versiΓ³n. Haz pull primero.`,
1993
+ local_snapshot: err.localSnapshot,
1994
+ server_snapshot: err.serverSnapshot,
1995
+ _hint: `Haz pull primero: gufi_view_pull({ view_id: ${viewId} }). Si tienes cambios locales, cΓ³pialos a otra carpeta antes del pull.`,
1996
+ };
1997
+ }
1998
+ throw err;
1999
+ }
2000
+ // πŸ’œ Update local metadata with new snapshot number (for version tracking)
2001
+ if (useLocal && result.snapshot) {
2002
+ const viewDir = path.join(LOCAL_VIEWS_DIR, `view_${viewId}`);
2003
+ const metaPath = path.join(viewDir, ".gufi-view.json");
2004
+ if (fs.existsSync(metaPath)) {
2005
+ try {
2006
+ const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
2007
+ meta.lastPulledSnapshot = result.snapshot;
2008
+ meta.lastSync = new Date().toISOString();
2009
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
2010
+ }
2011
+ catch {
2012
+ // Ignore metadata update errors
2013
+ }
2014
+ }
2015
+ }
1934
2016
  // Get view info for package
1935
2017
  let packageInfo = null;
1936
2018
  const viewResponse = await apiRequest(`/api/marketplace/views/${viewId}`, {}, undefined, true, env);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gufi-cli",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "description": "CLI for developing Gufi Marketplace views locally with Claude Code",
5
5
  "bin": {
6
6
  "gufi": "./bin/gufi.js"