tiendu 0.1.3 → 0.2.1

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/bin/tiendu.js CHANGED
@@ -11,26 +11,31 @@ import {
11
11
  previewDelete,
12
12
  previewOpen,
13
13
  } from "../lib/preview.mjs";
14
+ import { checkForUpdates } from "../lib/update-check.mjs";
14
15
 
15
16
  const HELP = `
16
- tiendu — CLI para desarrollar temas de Tiendu
17
-
18
- Uso:
19
- tiendu init Inicializar un tema en el directorio actual
20
- tiendu pull Descargar el tema live desde Tiendu
21
- tiendu push Subir archivos locales al preview activo (ZIP)
22
- tiendu dev Modo desarrollo: watch + sync automático
23
- tiendu publish Publicar el preview activo al storefront live
24
-
25
- tiendu preview create Crear un preview remoto
26
- tiendu preview list Listar previews de la tienda
27
- tiendu preview delete Eliminar el preview activo
28
- tiendu preview open Abrir la URL del preview en el navegador
29
-
30
- tiendu help Mostrar esta ayuda
31
-
32
- Opciones:
33
- --help, -h Mostrar esta ayuda
17
+ tiendu — Tiendu theme development CLI
18
+
19
+ Usage:
20
+ tiendu init [dir] Set up a theme project (optionally in a new directory)
21
+ tiendu pull Download the live theme from your store
22
+ tiendu push Upload local files to the active preview (full replace)
23
+ tiendu dev Start dev mode: auto-sync changes to a live preview URL
24
+ tiendu publish Publish the active preview to the live storefront
25
+
26
+ tiendu preview create Create a new remote preview
27
+ tiendu preview list List previews for your store
28
+ tiendu preview delete Delete the active preview
29
+ tiendu preview open Open the preview URL in your browser
30
+
31
+ tiendu help Show this help message
32
+
33
+ Typical workflow:
34
+ tiendu init my-store Set up a new project in ./my-store
35
+ cd my-store
36
+ tiendu pull Download the current live theme
37
+ tiendu dev Edit locally — preview updates in real time
38
+ tiendu publish Ship to the live storefront when ready
34
39
  `;
35
40
 
36
41
  const main = async () => {
@@ -38,6 +43,9 @@ const main = async () => {
38
43
  const command = args[0];
39
44
  const subcommand = args[1];
40
45
 
46
+ // Check for updates at most once per day (non-blocking)
47
+ await checkForUpdates();
48
+
41
49
  if (
42
50
  !command ||
43
51
  command === "help" ||
@@ -49,7 +57,7 @@ const main = async () => {
49
57
  }
50
58
 
51
59
  if (command === "init") {
52
- await init();
60
+ await init(args[1]); // optional directory name
53
61
  return;
54
62
  }
55
63
 
@@ -75,32 +83,28 @@ const main = async () => {
75
83
 
76
84
  if (command === "preview") {
77
85
  if (subcommand === "create") {
78
- const name = args[2];
79
- await previewCreate(name);
86
+ await previewCreate(args[2]);
80
87
  return;
81
88
  }
82
-
83
89
  if (subcommand === "list") {
84
90
  await previewList();
85
91
  return;
86
92
  }
87
-
88
93
  if (subcommand === "delete") {
89
94
  await previewDelete();
90
95
  return;
91
96
  }
92
-
93
97
  if (subcommand === "open") {
94
98
  await previewOpen();
95
99
  return;
96
100
  }
97
101
 
98
- console.error(`Subcomando desconocido: preview ${subcommand ?? "(vacío)"}`);
102
+ console.error(`Unknown subcommand: preview ${subcommand ?? "(none)"}`);
99
103
  console.log(HELP.trim());
100
104
  process.exit(1);
101
105
  }
102
106
 
103
- console.error(`Comando desconocido: ${command}`);
107
+ console.error(`Unknown command: ${command}`);
104
108
  console.log(HELP.trim());
105
109
  process.exit(1);
106
110
  };
package/lib/config.mjs CHANGED
@@ -60,15 +60,15 @@ export const writeCredentials = async (credentials) => {
60
60
  export const loadConfigOrFail = async () => {
61
61
  const config = await readConfig();
62
62
  if (!config) {
63
- console.error("No se encontró .cli/config.json");
64
- console.error('Ejecutá "tiendu init" primero.');
63
+ console.error("Error: no .cli/config.json found. Run tiendu init first.");
65
64
  process.exit(1);
66
65
  }
67
66
 
68
67
  const credentials = await readCredentials();
69
68
  if (!credentials) {
70
- console.error("No se encontró .cli/credentials.json");
71
- console.error('Ejecutá "tiendu init" primero.');
69
+ console.error(
70
+ "Error: no .cli/credentials.json found. Run tiendu init first.",
71
+ );
72
72
  process.exit(1);
73
73
  }
74
74
 
package/lib/dev.mjs CHANGED
@@ -1,39 +1,31 @@
1
1
  import { watch } from "node:fs";
2
- import { readFile, stat } from "node:fs/promises";
2
+ import { readFile, readdir, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import * as p from "@clack/prompts";
5
+ import { zipSync } from "fflate";
4
6
  import { loadConfigOrFail, writeConfig } from "./config.mjs";
5
- import { createPreview, listPreviews } from "./preview.mjs";
6
7
  import {
7
- uploadPreviewFileMultipart,
8
+ createPreview,
9
+ listPreviews,
10
+ resolveActivePreview,
11
+ } from "./preview.mjs";
12
+ import {
8
13
  deletePreviewFile,
14
+ uploadPreviewFileMultipart,
9
15
  uploadPreviewZip,
10
16
  } from "./api.mjs";
11
- import { readdir } from "node:fs/promises";
12
- import { zipSync } from "fflate";
13
17
 
14
18
  const isDotfile = (name) => name.startsWith(".");
19
+
15
20
  const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
16
21
  const base = new URL(apiBaseUrl);
17
22
  const hasExplicitPort = previewHostname.includes(":");
18
23
  return `${base.protocol}//${previewHostname}${!hasExplicitPort && base.port ? `:${base.port}` : ""}/`;
19
24
  };
20
25
 
21
- /**
22
- * Check if a relative path contains any dotfile segments.
23
- * @param {string} relativePath
24
- * @returns {boolean}
25
- */
26
- const hasDotfileSegment = (relativePath) => {
27
- const segments = relativePath.split(path.sep);
28
- return segments.some((s) => isDotfile(s));
29
- };
26
+ const hasDotfileSegment = (relativePath) =>
27
+ relativePath.split(path.sep).some(isDotfile);
30
28
 
31
- /**
32
- * Recursively list all files, skipping dotfiles.
33
- * @param {string} rootDir
34
- * @param {string} currentDir
35
- * @returns {Promise<string[]>}
36
- */
37
29
  const listAllFiles = async (rootDir, currentDir) => {
38
30
  const entries = await readdir(currentDir, { withFileTypes: true });
39
31
  const files = [];
@@ -41,8 +33,7 @@ const listAllFiles = async (rootDir, currentDir) => {
41
33
  if (isDotfile(entry.name)) continue;
42
34
  const abs = path.join(currentDir, entry.name);
43
35
  if (entry.isDirectory()) {
44
- const nested = await listAllFiles(rootDir, abs);
45
- files.push(...nested);
36
+ files.push(...(await listAllFiles(rootDir, abs)));
46
37
  } else if (entry.isFile()) {
47
38
  files.push(abs);
48
39
  }
@@ -50,19 +41,13 @@ const listAllFiles = async (rootDir, currentDir) => {
50
41
  return files;
51
42
  };
52
43
 
53
- /**
54
- * Create a zip buffer from the current directory, skipping dotfiles.
55
- * @param {string} rootDir
56
- * @returns {Promise<Buffer>}
57
- */
58
44
  const createZipFromDirectory = async (rootDir) => {
59
45
  const absoluteFiles = await listAllFiles(rootDir, rootDir);
60
46
  /** @type {Record<string, Uint8Array>} */
61
47
  const entries = {};
62
48
  for (const abs of absoluteFiles) {
63
49
  const rel = path.relative(rootDir, abs).split(path.sep).join("/");
64
- const buf = await readFile(abs);
65
- entries[rel] = new Uint8Array(buf);
50
+ entries[rel] = new Uint8Array(await readFile(abs));
66
51
  }
67
52
  return Buffer.from(zipSync(entries, { level: 6 }));
68
53
  };
@@ -74,28 +59,25 @@ export const dev = async () => {
74
59
  const rootDir = process.cwd();
75
60
 
76
61
  let previewKey = config.previewKey;
62
+ let previewUrl;
77
63
 
78
- // Ensure a preview exists
79
64
  if (!previewKey) {
80
- console.log("");
81
- console.log("No hay preview activo. Creando uno...");
65
+ // ── Create preview and do initial upload ─────────────────────────────────
66
+ const spinner = p.spinner();
67
+ spinner.start("No active preview found. Creating one...");
82
68
 
83
69
  const result = await createPreview(apiBaseUrl, apiKey, storeId, "Dev");
84
70
  if (!result.ok) {
85
- console.error(`Error: ${result.error}`);
71
+ spinner.stop("Failed to create preview.", 1);
72
+ p.log.error(result.error);
86
73
  process.exit(1);
87
74
  }
88
75
 
89
76
  previewKey = result.data.previewKey;
77
+ previewUrl = buildPreviewUrl(apiBaseUrl, result.data.previewHostname);
90
78
  await writeConfig({ ...config, previewKey });
91
79
 
92
- console.log(`Preview creado: ${previewKey}`);
93
- console.log(
94
- `URL: ${buildPreviewUrl(apiBaseUrl, result.data.previewHostname)}`,
95
- );
96
-
97
- // Initial push of all files
98
- console.log("Subiendo archivos iniciales...");
80
+ spinner.message("Uploading initial files...");
99
81
  const zipBuffer = await createZipFromDirectory(rootDir);
100
82
  const uploadResult = await uploadPreviewZip(
101
83
  apiBaseUrl,
@@ -104,67 +86,71 @@ export const dev = async () => {
104
86
  previewKey,
105
87
  zipBuffer,
106
88
  );
89
+
107
90
  if (!uploadResult.ok) {
108
- console.error(`Error subiendo archivos: ${uploadResult.error}`);
91
+ spinner.stop("Failed to upload files.", 1);
92
+ p.log.error(uploadResult.error);
109
93
  process.exit(1);
110
94
  }
111
- console.log("Archivos subidos.");
95
+
96
+ spinner.stop(`Preview ready: ${previewUrl}`);
112
97
  } else {
113
- // Verify the preview still exists
98
+ // ── Verify existing preview still exists ─────────────────────────────────
99
+ const spinner = p.spinner();
100
+ spinner.start("Connecting to preview...");
101
+
114
102
  const listResult = await listPreviews(apiBaseUrl, apiKey, storeId);
115
103
  if (!listResult.ok) {
116
- console.error(`Error: ${listResult.error}`);
104
+ spinner.stop("Failed to connect.", 1);
105
+ p.log.error(listResult.error);
117
106
  process.exit(1);
118
107
  }
119
- const existing = listResult.data.find((p) => p.previewKey === previewKey);
108
+
109
+ const existing = resolveActivePreview(listResult.data, previewKey);
120
110
  if (!existing) {
121
- console.error(
122
- `El preview ${previewKey} ya no existe. Eliminá la config con: tiendu preview delete`,
111
+ spinner.stop("Could not determine the active preview.", 1);
112
+ p.log.error(
113
+ listResult.data.length === 0
114
+ ? "No previews found for this store. A new preview will be created if you clear the local config and run tiendu dev again."
115
+ : "Run tiendu preview list and then set or recreate the preview.",
123
116
  );
124
117
  process.exit(1);
125
118
  }
126
- console.log("");
127
- console.log(`Preview activo: ${previewKey}`);
128
- console.log(
129
- `URL: ${buildPreviewUrl(apiBaseUrl, existing.previewHostname)}`,
130
- );
119
+
120
+ previewKey = existing.previewKey;
121
+ if (config.previewKey !== previewKey) {
122
+ await writeConfig({ ...config, previewKey });
123
+ }
124
+
125
+ previewUrl = buildPreviewUrl(apiBaseUrl, existing.previewHostname);
126
+ spinner.stop(`Preview: ${previewUrl}`);
131
127
  }
132
128
 
133
- console.log("");
134
- console.log("Observando cambios... (Ctrl+C para salir)");
135
- console.log("");
129
+ p.log.message("Watching for changes — press Ctrl+C to stop.\n");
136
130
 
137
- // Debounce map: relativePath -> timeout
131
+ // ── File watcher ──────────────────────────────────────────────────────────
138
132
  /** @type {Map<string, NodeJS.Timeout>} */
139
133
  const debounceMap = new Map();
140
134
  const DEBOUNCE_MS = 300;
141
135
 
142
136
  const watcher = watch(rootDir, { recursive: true }, (eventType, filename) => {
143
137
  if (!filename) return;
144
-
145
- // Skip dotfiles
146
138
  if (hasDotfileSegment(filename)) return;
147
139
 
148
- // Normalize to posix path
149
140
  const relativePath = filename.split(path.sep).join("/");
150
-
151
- // Clear existing debounce timer
152
141
  const existing = debounceMap.get(relativePath);
153
142
  if (existing) clearTimeout(existing);
154
143
 
155
- // Set new debounce timer
156
144
  const timer = setTimeout(async () => {
157
145
  debounceMap.delete(relativePath);
158
-
159
146
  const absolutePath = path.join(rootDir, filename);
160
147
 
161
148
  try {
162
149
  const fileStat = await stat(absolutePath).catch(() => null);
163
150
 
164
151
  if (!fileStat || !fileStat.isFile()) {
165
- // File was deleted or is a directory
166
152
  if (!fileStat) {
167
- console.log(` ✕ ${relativePath}`);
153
+ p.log.message(` ✕ ${relativePath}`);
168
154
  const result = await deletePreviewFile(
169
155
  apiBaseUrl,
170
156
  apiKey,
@@ -173,16 +159,14 @@ export const dev = async () => {
173
159
  relativePath,
174
160
  );
175
161
  if (!result.ok) {
176
- console.error(` Error: ${result.error}`);
162
+ p.log.warn(` Failed to delete: ${result.error}`);
177
163
  }
178
164
  }
179
165
  return;
180
166
  }
181
167
 
182
- // File was created or modified
168
+ p.log.message(` ↑ ${relativePath}`);
183
169
  const content = await readFile(absolutePath);
184
- console.log(` ↑ ${relativePath}`);
185
-
186
170
  const result = await uploadPreviewFileMultipart(
187
171
  apiBaseUrl,
188
172
  apiKey,
@@ -193,30 +177,25 @@ export const dev = async () => {
193
177
  );
194
178
 
195
179
  if (!result.ok) {
196
- console.error(` Error: ${result.error}`);
180
+ p.log.warn(` Failed to upload: ${result.error}`);
197
181
  }
198
182
  } catch (error) {
199
- console.error(` Error procesando ${relativePath}: ${error.message}`);
183
+ p.log.warn(` Error processing ${relativePath}: ${error.message}`);
200
184
  }
201
185
  }, DEBOUNCE_MS);
202
186
 
203
187
  debounceMap.set(relativePath, timer);
204
188
  });
205
189
 
206
- // Handle graceful shutdown
207
190
  const cleanup = () => {
208
191
  watcher.close();
209
- for (const timer of debounceMap.values()) {
210
- clearTimeout(timer);
211
- }
212
- console.log("");
213
- console.log("Dev mode finalizado.");
192
+ for (const timer of debounceMap.values()) clearTimeout(timer);
193
+ p.outro("Dev mode stopped.");
214
194
  process.exit(0);
215
195
  };
216
196
 
217
197
  process.on("SIGINT", cleanup);
218
198
  process.on("SIGTERM", cleanup);
219
199
 
220
- // Keep process alive
221
200
  await new Promise(() => {});
222
201
  };
package/lib/init.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { mkdir, access } from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import * as p from "@clack/prompts";
2
4
  import {
3
5
  readConfig,
@@ -7,22 +9,42 @@ import {
7
9
  } from "./config.mjs";
8
10
  import { fetchUserStores } from "./api.mjs";
9
11
 
10
- /** @param {string} key */
11
- const maskApiKey = (key) => {
12
- if (key.length <= 8) return "****";
13
- return key.slice(0, 4) + "..." + key.slice(-4);
14
- };
15
-
16
12
  /** @param {string} url */
17
- const normalizeBaseUrl = (url) => {
18
- return url.endsWith("/") ? url.slice(0, -1) : url;
19
- };
13
+ const normalizeBaseUrl = (url) => (url.endsWith("/") ? url.slice(0, -1) : url);
14
+
15
+ /**
16
+ * @param {string | undefined} dirArg optional directory name passed as CLI arg
17
+ */
18
+ export const init = async (dirArg) => {
19
+ // ─── Resolve working directory ────────────────────────────────────────────
20
+ let workDir = process.cwd();
21
+
22
+ if (dirArg) {
23
+ const targetDir = path.resolve(process.cwd(), dirArg);
24
+
25
+ // Fail clearly if the directory already exists
26
+ try {
27
+ await access(targetDir);
28
+ // access succeeded → it exists
29
+ p.intro("Tiendu CLI — Setup");
30
+ p.cancel(`Directory "${dirArg}" already exists.`);
31
+ process.exit(1);
32
+ } catch {
33
+ // access failed → doesn't exist, safe to create
34
+ }
35
+
36
+ await mkdir(targetDir, { recursive: true });
37
+ workDir = targetDir;
20
38
 
21
- export const init = async () => {
39
+ // Change cwd so config is written inside the new directory
40
+ process.chdir(workDir);
41
+ }
42
+
43
+ // Re-read config after potential chdir
22
44
  const existingConfig = await readConfig();
23
45
  const existingCredentials = await readCredentials();
24
46
 
25
- p.intro("Tiendu CLI — Inicialización");
47
+ p.intro("Tiendu CLI — Setup");
26
48
 
27
49
  // ─── API Key ──────────────────────────────────────────────────────────────
28
50
  const apiKeyDefault = existingCredentials?.apiKey ?? "";
@@ -31,50 +53,52 @@ export const init = async () => {
31
53
  message: "API Key",
32
54
  mask: "*",
33
55
  validate: (value) => {
34
- const resolved = value.trim() || apiKeyDefault;
35
- if (!resolved) return "La API Key es requerida.";
56
+ const resolved = (value ?? "").trim() || apiKeyDefault;
57
+ if (!resolved) return "API Key is required.";
36
58
  },
37
59
  });
38
60
 
39
61
  if (p.isCancel(apiKeyInput)) {
40
- p.cancel("Inicialización cancelada.");
62
+ p.cancel("Setup cancelled.");
41
63
  process.exit(0);
42
64
  }
43
65
 
44
- const apiKey = apiKeyInput.trim() || apiKeyDefault;
66
+ const apiKey = (apiKeyInput ?? "").trim() || apiKeyDefault;
45
67
 
46
68
  // ─── API Base URL ─────────────────────────────────────────────────────────
47
69
  const baseUrlDefault = existingConfig?.apiBaseUrl ?? "https://tiendu.uy";
48
70
 
49
71
  const baseUrlInput = await p.text({
50
- message: "URL base de la API",
72
+ message: "API base URL",
51
73
  placeholder: baseUrlDefault,
52
74
  defaultValue: baseUrlDefault,
53
75
  validate: (value) => {
54
- const resolved = value.trim() || baseUrlDefault;
76
+ const resolved = (value ?? "").trim() || baseUrlDefault;
55
77
  try {
56
78
  new URL(resolved);
57
79
  } catch {
58
- return "URL inválida.";
80
+ return "Invalid URL.";
59
81
  }
60
82
  },
61
83
  });
62
84
 
63
85
  if (p.isCancel(baseUrlInput)) {
64
- p.cancel("Inicialización cancelada.");
86
+ p.cancel("Setup cancelled.");
65
87
  process.exit(0);
66
88
  }
67
89
 
68
- const apiBaseUrl = normalizeBaseUrl(baseUrlInput.trim() || baseUrlDefault);
90
+ const apiBaseUrl = normalizeBaseUrl(
91
+ (baseUrlInput ?? "").trim() || baseUrlDefault,
92
+ );
69
93
 
70
94
  // ─── Fetch stores (validates API key implicitly) ───────────────────────────
71
95
  const spinner = p.spinner();
72
- spinner.start("Verificando credenciales y obteniendo tiendas...");
96
+ spinner.start("Verifying credentials...");
73
97
 
74
98
  const storesResult = await fetchUserStores(apiBaseUrl, apiKey);
75
99
 
76
100
  if (!storesResult.ok) {
77
- spinner.stop("Error al verificar credenciales.", 1);
101
+ spinner.stop("Failed to verify credentials.", 1);
78
102
  p.cancel(storesResult.error);
79
103
  process.exit(1);
80
104
  }
@@ -82,25 +106,24 @@ export const init = async () => {
82
106
  const stores = storesResult.data;
83
107
 
84
108
  if (stores.length === 0) {
85
- spinner.stop("No se encontraron tiendas.", 1);
86
- p.cancel("Tu API Key no tiene acceso a ninguna tienda.");
109
+ spinner.stop("No stores found.", 1);
110
+ p.cancel("Your API Key does not have access to any store.");
87
111
  process.exit(1);
88
112
  }
89
113
 
90
114
  spinner.stop(
91
- `${stores.length} tienda${stores.length === 1 ? "" : "s"} encontrada${stores.length === 1 ? "" : "s"}.`,
115
+ `${stores.length} store${stores.length === 1 ? "" : "s"} found.`,
92
116
  );
93
117
 
94
118
  // ─── Select store ─────────────────────────────────────────────────────────
95
119
  let storeId;
96
120
 
97
121
  if (stores.length === 1) {
98
- // Auto-select if only one store
99
122
  storeId = stores[0].id;
100
- p.note(`${stores[0].name} (ID: ${storeId})`, "Tienda seleccionada");
123
+ p.log.info(`Store: ${stores[0].name} (ID: ${storeId})`);
101
124
  } else {
102
125
  const selectedId = await p.select({
103
- message: "Seleccioná una tienda",
126
+ message: "Select a store",
104
127
  options: stores.map((store) => ({
105
128
  value: store.id,
106
129
  label: store.name,
@@ -110,7 +133,7 @@ export const init = async () => {
110
133
  });
111
134
 
112
135
  if (p.isCancel(selectedId)) {
113
- p.cancel("Inicialización cancelada.");
136
+ p.cancel("Setup cancelled.");
114
137
  process.exit(0);
115
138
  }
116
139
 
@@ -121,16 +144,19 @@ export const init = async () => {
121
144
  await writeConfig({ storeId, apiBaseUrl });
122
145
  await writeCredentials({ apiKey });
123
146
 
147
+ const nextSteps = dirArg
148
+ ? [`cd ${dirArg}`, `tiendu pull # download the current live theme`]
149
+ : [`tiendu pull # download the current live theme`];
150
+
124
151
  p.note(
125
152
  [
126
- 'Ejecutá "tiendu pull" para descargar el tema.',
153
+ ...nextSteps,
127
154
  "",
128
- "Nota: habilitá el modo dev en la plataforma Tiendu",
129
- "(Ajustes > General) para que los datos del preview",
130
- "se muestren correctamente.",
155
+ "Tip: enable Dev Mode in the Tiendu platform",
156
+ "(Settings General) for preview data to load correctly.",
131
157
  ].join("\n"),
132
- "Próximos pasos",
158
+ "Next steps",
133
159
  );
134
160
 
135
- p.outro("Configuración guardada en .cli/");
161
+ p.outro("Configuration saved to .cli/");
136
162
  };
package/lib/preview.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import * as p from "@clack/prompts";
1
2
  import { loadConfigOrFail, writeConfig } from "./config.mjs";
2
3
  import { apiFetch } from "./api.mjs";
3
4
 
@@ -7,6 +8,25 @@ const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
7
8
  return `${base.protocol}//${previewHostname}${!hasExplicitPort && base.port ? `:${base.port}` : ""}/`;
8
9
  };
9
10
 
11
+ /**
12
+ * @param {Array<any>} previews
13
+ * @param {string | undefined} previewKey
14
+ * @returns {any | null}
15
+ */
16
+ export const resolveActivePreview = (previews, previewKey) => {
17
+ if (previewKey) {
18
+ return (
19
+ previews.find((preview) => preview.previewKey === previewKey) ?? null
20
+ );
21
+ }
22
+
23
+ if (previews.length === 1) {
24
+ return previews[0];
25
+ }
26
+
27
+ return null;
28
+ };
29
+
10
30
  /**
11
31
  * @param {string} apiBaseUrl
12
32
  * @param {string} apiKey
@@ -30,7 +50,7 @@ export const createPreview = async (apiBaseUrl, apiKey, storeId, name) => {
30
50
  const body = await response.json().catch(() => ({}));
31
51
  const message =
32
52
  body?.error?.message ??
33
- "Ya existe un preview para esta tienda. Eliminalo antes de crear uno nuevo.";
53
+ "A preview already exists for this store. Delete it first with: tiendu preview delete";
34
54
  return { ok: false, error: message };
35
55
  }
36
56
 
@@ -38,17 +58,14 @@ export const createPreview = async (apiBaseUrl, apiKey, storeId, name) => {
38
58
  const body = await response.text().catch(() => "");
39
59
  return {
40
60
  ok: false,
41
- error: `Error del servidor: ${response.status}${body ? ` — ${body}` : ""}`,
61
+ error: `Server error: ${response.status}${body ? ` — ${body}` : ""}`,
42
62
  };
43
63
  }
44
64
 
45
65
  const preview = await response.json();
46
66
  return { ok: true, data: preview };
47
67
  } catch (error) {
48
- return {
49
- ok: false,
50
- error: `No se pudo crear el preview: ${error.message}`,
51
- };
68
+ return { ok: false, error: `Could not create preview: ${error.message}` };
52
69
  }
53
70
  };
54
71
 
@@ -65,16 +82,13 @@ export const listPreviews = async (apiBaseUrl, apiKey, storeId) => {
65
82
  apiKey,
66
83
  `/api/v2/stores/${storeId}/theme-previews`,
67
84
  );
68
-
69
85
  if (!response.ok) {
70
- return { ok: false, error: `Error del servidor: ${response.status}` };
86
+ return { ok: false, error: `Server error: ${response.status}` };
71
87
  }
72
-
73
88
  const body = await response.json();
74
- const previews = body?.previews ?? [];
75
- return { ok: true, data: previews };
89
+ return { ok: true, data: body?.previews ?? [] };
76
90
  } catch (error) {
77
- return { ok: false, error: `No se pudo listar previews: ${error.message}` };
91
+ return { ok: false, error: `Could not list previews: ${error.message}` };
78
92
  }
79
93
  };
80
94
 
@@ -96,25 +110,18 @@ export const deletePreview = async (
96
110
  apiBaseUrl,
97
111
  apiKey,
98
112
  `/api/v2/stores/${storeId}/theme-previews/${previewKey}`,
99
- {
100
- method: "DELETE",
101
- },
113
+ { method: "DELETE" },
102
114
  );
103
-
104
115
  if (!response.ok) {
105
116
  const body = await response.text().catch(() => "");
106
117
  return {
107
118
  ok: false,
108
- error: `Error del servidor: ${response.status}${body ? ` — ${body}` : ""}`,
119
+ error: `Server error: ${response.status}${body ? ` — ${body}` : ""}`,
109
120
  };
110
121
  }
111
-
112
122
  return { ok: true };
113
123
  } catch (error) {
114
- return {
115
- ok: false,
116
- error: `No se pudo eliminar el preview: ${error.message}`,
117
- };
124
+ return { ok: false, error: `Could not delete preview: ${error.message}` };
118
125
  }
119
126
  };
120
127
 
@@ -136,25 +143,18 @@ export const publishPreview = async (
136
143
  apiBaseUrl,
137
144
  apiKey,
138
145
  `/api/v2/stores/${storeId}/theme-previews/${previewKey}/publish`,
139
- {
140
- method: "POST",
141
- },
146
+ { method: "POST" },
142
147
  );
143
-
144
148
  if (!response.ok) {
145
149
  const body = await response.text().catch(() => "");
146
150
  return {
147
151
  ok: false,
148
- error: `Error del servidor: ${response.status}${body ? ` — ${body}` : ""}`,
152
+ error: `Server error: ${response.status}${body ? ` — ${body}` : ""}`,
149
153
  };
150
154
  }
151
-
152
155
  return { ok: true };
153
156
  } catch (error) {
154
- return {
155
- ok: false,
156
- error: `No se pudo publicar el preview: ${error.message}`,
157
- };
157
+ return { ok: false, error: `Could not publish preview: ${error.message}` };
158
158
  }
159
159
  };
160
160
 
@@ -165,8 +165,8 @@ export const publishPreview = async (
165
165
  export const previewCreate = async (name) => {
166
166
  const { config, credentials } = await loadConfigOrFail();
167
167
 
168
- console.log("");
169
- console.log("Creando preview...");
168
+ const spinner = p.spinner();
169
+ spinner.start("Creating preview...");
170
170
 
171
171
  const result = await createPreview(
172
172
  config.apiBaseUrl,
@@ -176,24 +176,24 @@ export const previewCreate = async (name) => {
176
176
  );
177
177
 
178
178
  if (!result.ok) {
179
- console.error(`Error: ${result.error}`);
179
+ spinner.stop("Failed to create preview.", 1);
180
+ p.log.error(result.error);
180
181
  process.exit(1);
181
182
  }
182
183
 
183
184
  const preview = result.data;
184
- console.log(`Preview creado: ${preview.previewKey}`);
185
- console.log(
186
- `URL: ${buildPreviewUrl(config.apiBaseUrl, preview.previewHostname)}`,
187
- );
188
- console.log("");
185
+ const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
186
+ spinner.stop(`Preview created: ${url}`);
189
187
 
190
- // Save preview key to config
191
188
  await writeConfig({ ...config, previewKey: preview.previewKey });
192
189
  };
193
190
 
194
191
  export const previewList = async () => {
195
192
  const { config, credentials } = await loadConfigOrFail();
196
193
 
194
+ const spinner = p.spinner();
195
+ spinner.start("Fetching previews...");
196
+
197
197
  const result = await listPreviews(
198
198
  config.apiBaseUrl,
199
199
  credentials.apiKey,
@@ -201,67 +201,93 @@ export const previewList = async () => {
201
201
  );
202
202
 
203
203
  if (!result.ok) {
204
- console.error(`Error: ${result.error}`);
204
+ spinner.stop("Failed to fetch previews.", 1);
205
+ p.log.error(result.error);
205
206
  process.exit(1);
206
207
  }
207
208
 
208
- console.log("");
209
209
  if (result.data.length === 0) {
210
- console.log("No hay previews para esta tienda.");
211
- } else {
212
- console.log("Previews:");
213
- for (const preview of result.data) {
214
- const active =
215
- config.previewKey === preview.previewKey ? " (activo)" : "";
216
- console.log(
217
- ` ${preview.previewKey} ${preview.name} ${buildPreviewUrl(config.apiBaseUrl, preview.previewHostname)}${active}`,
218
- );
219
- }
210
+ spinner.stop("No previews for this store.");
211
+ return;
212
+ }
213
+
214
+ spinner.stop(
215
+ `${result.data.length} preview${result.data.length === 1 ? "" : "s"}:`,
216
+ );
217
+
218
+ const activePreview = resolveActivePreview(result.data, config.previewKey);
219
+
220
+ for (const preview of result.data) {
221
+ const active =
222
+ activePreview?.previewKey === preview.previewKey ? " ← active" : "";
223
+ const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
224
+ p.log.message(` ${preview.name} ${url}${active}`);
220
225
  }
221
- console.log("");
222
226
  };
223
227
 
224
228
  export const previewDelete = async () => {
225
229
  const { config, credentials } = await loadConfigOrFail();
226
230
 
227
- if (!config.previewKey) {
228
- console.error("No hay preview activo. Creá uno con: tiendu preview create");
231
+ const listResult = await listPreviews(
232
+ config.apiBaseUrl,
233
+ credentials.apiKey,
234
+ config.storeId,
235
+ );
236
+ if (!listResult.ok) {
237
+ p.log.error(listResult.error);
229
238
  process.exit(1);
230
239
  }
231
240
 
232
- console.log("");
233
- console.log(`Eliminando preview ${config.previewKey}...`);
241
+ const activePreview = resolveActivePreview(
242
+ listResult.data,
243
+ config.previewKey,
244
+ );
245
+ if (!activePreview) {
246
+ p.log.error(
247
+ listResult.data.length === 0
248
+ ? "No previews found for this store."
249
+ : "Could not determine the active preview. Run tiendu preview list first.",
250
+ );
251
+ process.exit(1);
252
+ }
253
+
254
+ const confirmed = await p.confirm({
255
+ message: `Delete preview ${activePreview.previewKey}?`,
256
+ });
257
+
258
+ if (p.isCancel(confirmed) || !confirmed) {
259
+ p.cancel("Cancelled.");
260
+ process.exit(0);
261
+ }
262
+
263
+ const spinner = p.spinner();
264
+ spinner.start("Deleting preview...");
234
265
 
235
266
  const result = await deletePreview(
236
267
  config.apiBaseUrl,
237
268
  credentials.apiKey,
238
269
  config.storeId,
239
- config.previewKey,
270
+ activePreview.previewKey,
240
271
  );
241
272
 
242
273
  if (!result.ok) {
243
- console.error(`Error: ${result.error}`);
274
+ spinner.stop("Failed to delete preview.", 1);
275
+ p.log.error(result.error);
244
276
  process.exit(1);
245
277
  }
246
278
 
247
- console.log("Preview eliminado.");
248
- console.log("");
279
+ spinner.stop("Preview deleted.");
249
280
 
250
- // Remove preview key from config
251
281
  const { previewKey, ...rest } = config;
252
282
  await writeConfig(rest);
253
283
  };
254
284
 
255
285
  export const previewOpen = async () => {
256
- const { config } = await loadConfigOrFail();
286
+ const { config, credentials } = await loadConfigOrFail();
257
287
 
258
- if (!config.previewKey) {
259
- console.error("No hay preview activo. Creá uno con: tiendu preview create");
260
- process.exit(1);
261
- }
288
+ const spinner = p.spinner();
289
+ spinner.start("Fetching preview URL...");
262
290
 
263
- // Find the preview to get its hostname
264
- const { credentials } = await loadConfigOrFail();
265
291
  const result = await listPreviews(
266
292
  config.apiBaseUrl,
267
293
  credentials.apiKey,
@@ -269,26 +295,30 @@ export const previewOpen = async () => {
269
295
  );
270
296
 
271
297
  if (!result.ok) {
272
- console.error(`Error: ${result.error}`);
298
+ spinner.stop("Failed to fetch previews.", 1);
299
+ p.log.error(result.error);
273
300
  process.exit(1);
274
301
  }
275
302
 
276
- const preview = result.data.find((p) => p.previewKey === config.previewKey);
303
+ const preview = resolveActivePreview(result.data, config.previewKey);
277
304
  if (!preview) {
278
- console.error("El preview activo ya no existe en el servidor.");
305
+ spinner.stop("Could not determine the active preview.", 1);
306
+ p.log.error(
307
+ result.data.length === 0
308
+ ? "No previews found for this store."
309
+ : "Run tiendu preview list and then set or recreate the preview.",
310
+ );
279
311
  process.exit(1);
280
312
  }
281
313
 
282
314
  const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
283
- console.log(`Abriendo ${url}...`);
315
+ spinner.stop(`Opening ${url}`);
284
316
 
285
- // Open URL in browser
286
317
  const { exec } = await import("node:child_process");
287
- const platform = process.platform;
288
318
  const cmd =
289
- platform === "darwin"
319
+ process.platform === "darwin"
290
320
  ? "open"
291
- : platform === "win32"
321
+ : process.platform === "win32"
292
322
  ? "start"
293
323
  : "xdg-open";
294
324
  exec(`${cmd} ${url}`);
package/lib/publish.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import * as p from "@clack/prompts";
1
2
  import { loadConfigOrFail, writeConfig } from "./config.mjs";
2
3
  import { publishPreview } from "./preview.mjs";
3
4
 
@@ -5,12 +6,21 @@ export const publish = async () => {
5
6
  const { config, credentials } = await loadConfigOrFail();
6
7
 
7
8
  if (!config.previewKey) {
8
- console.error("No hay preview activo. Creá uno con: tiendu preview create");
9
+ p.log.error("No active preview. Create one with: tiendu preview create");
9
10
  process.exit(1);
10
11
  }
11
12
 
12
- console.log("");
13
- console.log(`Publicando preview ${config.previewKey} al storefront live...`);
13
+ const confirmed = await p.confirm({
14
+ message: `Publish preview ${config.previewKey} to the live storefront?`,
15
+ });
16
+
17
+ if (p.isCancel(confirmed) || !confirmed) {
18
+ p.cancel("Publish cancelled.");
19
+ process.exit(0);
20
+ }
21
+
22
+ const spinner = p.spinner();
23
+ spinner.start("Publishing preview...");
14
24
 
15
25
  const result = await publishPreview(
16
26
  config.apiBaseUrl,
@@ -20,13 +30,13 @@ export const publish = async () => {
20
30
  );
21
31
 
22
32
  if (!result.ok) {
23
- console.error(`Error: ${result.error}`);
33
+ spinner.stop("Publish failed.", 1);
34
+ p.log.error(result.error);
24
35
  process.exit(1);
25
36
  }
26
37
 
27
- console.log("Preview publicado. El storefront live fue actualizado.");
28
- console.log("Todos los previews de esta tienda fueron eliminados.");
29
- console.log("");
38
+ spinner.stop("Preview published. Your live storefront has been updated.");
39
+ p.log.info("All previews for this store have been removed.");
30
40
 
31
41
  // Remove preview key from config
32
42
  const { previewKey, ...rest } = config;
package/lib/pull.mjs CHANGED
@@ -1,12 +1,20 @@
1
+ import * as p from "@clack/prompts";
1
2
  import { loadConfigOrFail } from "./config.mjs";
2
3
  import { downloadStorefrontArchive } from "./api.mjs";
3
4
  import { extractZip } from "./zip.mjs";
4
5
 
6
+ /** @param {number} bytes */
7
+ const formatBytes = (bytes) => {
8
+ if (bytes < 1024) return `${bytes} B`;
9
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
10
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
11
+ };
12
+
5
13
  export const pull = async () => {
6
14
  const { config, credentials } = await loadConfigOrFail();
7
15
 
8
- console.log("");
9
- console.log(`Descargando tema de tienda #${config.storeId}...`);
16
+ const spinner = p.spinner();
17
+ spinner.start(`Downloading theme from store #${config.storeId}...`);
10
18
 
11
19
  const result = await downloadStorefrontArchive(
12
20
  config.apiBaseUrl,
@@ -15,27 +23,19 @@ export const pull = async () => {
15
23
  );
16
24
 
17
25
  if (!result.ok) {
18
- console.error(`Error: ${result.error}`);
26
+ spinner.stop("Download failed.", 1);
27
+ p.log.error(result.error);
19
28
  process.exit(1);
20
29
  }
21
30
 
22
- console.log(`Archivo ZIP recibido (${formatBytes(result.data.length)})`);
23
- console.log("Extrayendo archivos...");
31
+ spinner.stop(
32
+ `Archive received (${formatBytes(result.data.length)}). Extracting...`,
33
+ );
24
34
 
25
35
  const outputDir = process.cwd();
26
36
  const extractedFiles = await extractZip(result.data, outputDir);
27
37
 
28
- console.log("");
29
- console.log(`${extractedFiles.length} archivos extraídos:`);
30
- for (const file of extractedFiles) {
31
- console.log(` ${file}`);
32
- }
33
- console.log("");
34
- };
35
-
36
- /** @param {number} bytes */
37
- const formatBytes = (bytes) => {
38
- if (bytes < 1024) return `${bytes} B`;
39
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
40
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
38
+ p.log.success(
39
+ `${extractedFiles.length} file${extractedFiles.length === 1 ? "" : "s"} extracted.`,
40
+ );
41
41
  };
package/lib/push.mjs CHANGED
@@ -1,9 +1,17 @@
1
- import { readdir, readFile, stat } from "node:fs/promises";
1
+ import { readdir, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import * as p from "@clack/prompts";
3
4
  import { zipSync } from "fflate";
4
5
  import { loadConfigOrFail } from "./config.mjs";
5
6
  import { uploadPreviewZip } from "./api.mjs";
6
7
 
8
+ /** @param {number} bytes */
9
+ const formatBytes = (bytes) => {
10
+ if (bytes < 1024) return `${bytes} B`;
11
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
12
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
13
+ };
14
+
7
15
  const isDotfile = (name) => name.startsWith(".");
8
16
 
9
17
  /**
@@ -15,20 +23,15 @@ const isDotfile = (name) => name.startsWith(".");
15
23
  const listAllFiles = async (rootDir, currentDir) => {
16
24
  const entries = await readdir(currentDir, { withFileTypes: true });
17
25
  const files = [];
18
-
19
26
  for (const entry of entries) {
20
27
  if (isDotfile(entry.name)) continue;
21
-
22
- const absolutePath = path.join(currentDir, entry.name);
23
-
28
+ const abs = path.join(currentDir, entry.name);
24
29
  if (entry.isDirectory()) {
25
- const nested = await listAllFiles(rootDir, absolutePath);
26
- files.push(...nested);
30
+ files.push(...(await listAllFiles(rootDir, abs)));
27
31
  } else if (entry.isFile()) {
28
- files.push(absolutePath);
32
+ files.push(abs);
29
33
  }
30
34
  }
31
-
32
35
  return files;
33
36
  };
34
37
 
@@ -41,16 +44,10 @@ const createZipFromDirectory = async (rootDir) => {
41
44
  const absoluteFiles = await listAllFiles(rootDir, rootDir);
42
45
  /** @type {Record<string, Uint8Array>} */
43
46
  const entries = {};
44
-
45
- for (const absoluteFilePath of absoluteFiles) {
46
- const relativePath = path
47
- .relative(rootDir, absoluteFilePath)
48
- .split(path.sep)
49
- .join("/");
50
- const fileBuffer = await readFile(absoluteFilePath);
51
- entries[relativePath] = new Uint8Array(fileBuffer);
47
+ for (const abs of absoluteFiles) {
48
+ const rel = path.relative(rootDir, abs).split(path.sep).join("/");
49
+ entries[rel] = new Uint8Array(await readFile(abs));
52
50
  }
53
-
54
51
  return Buffer.from(zipSync(entries, { level: 6 }));
55
52
  };
56
53
 
@@ -58,17 +55,18 @@ export const push = async () => {
58
55
  const { config, credentials } = await loadConfigOrFail();
59
56
 
60
57
  if (!config.previewKey) {
61
- console.error("No hay preview activo. Creá uno con: tiendu preview create");
58
+ p.log.error("No active preview. Create one with: tiendu preview create");
62
59
  process.exit(1);
63
60
  }
64
61
 
65
62
  const rootDir = process.cwd();
66
-
67
- console.log("");
68
- console.log(`Subiendo archivos al preview ${config.previewKey}...`);
63
+ const spinner = p.spinner();
64
+ spinner.start("Packing files...");
69
65
 
70
66
  const zipBuffer = await createZipFromDirectory(rootDir);
71
- console.log(`ZIP creado (${formatBytes(zipBuffer.length)})`);
67
+ spinner.message(
68
+ `Uploading to preview ${config.previewKey} (${formatBytes(zipBuffer.length)})...`,
69
+ );
72
70
 
73
71
  const result = await uploadPreviewZip(
74
72
  config.apiBaseUrl,
@@ -79,17 +77,10 @@ export const push = async () => {
79
77
  );
80
78
 
81
79
  if (!result.ok) {
82
- console.error(`Error: ${result.error}`);
80
+ spinner.stop("Upload failed.", 1);
81
+ p.log.error(result.error);
83
82
  process.exit(1);
84
83
  }
85
84
 
86
- console.log("Archivos subidos al preview.");
87
- console.log("");
88
- };
89
-
90
- /** @param {number} bytes */
91
- const formatBytes = (bytes) => {
92
- if (bytes < 1024) return `${bytes} B`;
93
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
94
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
85
+ spinner.stop("Files uploaded to preview.");
95
86
  };
@@ -0,0 +1,144 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import * as p from "@clack/prompts";
4
+
5
+ const CONFIG_DIR = ".cli";
6
+ const UPDATE_CHECK_FILE = "update-check.json";
7
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
8
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org/tiendu/latest";
9
+
10
+ const getUpdateCheckPath = () =>
11
+ path.resolve(process.cwd(), CONFIG_DIR, UPDATE_CHECK_FILE);
12
+
13
+ /**
14
+ * @returns {Promise<{ lastChecked: number, latestVersion: string | null } | null>}
15
+ */
16
+ const readUpdateCheckState = async () => {
17
+ try {
18
+ const raw = await readFile(getUpdateCheckPath(), "utf-8");
19
+ return JSON.parse(raw);
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+
25
+ /**
26
+ * @param {{ lastChecked: number, latestVersion: string | null }} state
27
+ */
28
+ const writeUpdateCheckState = async (state) => {
29
+ try {
30
+ await mkdir(path.resolve(process.cwd(), CONFIG_DIR), { recursive: true });
31
+ await writeFile(
32
+ getUpdateCheckPath(),
33
+ JSON.stringify(state, null, "\t") + "\n",
34
+ "utf-8",
35
+ );
36
+ } catch {
37
+ // silently ignore write errors
38
+ }
39
+ };
40
+
41
+ /**
42
+ * @returns {Promise<string | null>} latest version from npm, or null on error
43
+ */
44
+ const fetchLatestVersion = async () => {
45
+ try {
46
+ const res = await fetch(NPM_REGISTRY_URL, {
47
+ signal: AbortSignal.timeout(5000),
48
+ });
49
+ if (!res.ok) return null;
50
+ const data = await res.json();
51
+ return typeof data.version === "string" ? data.version : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
56
+
57
+ /**
58
+ * @param {string} a
59
+ * @param {string} b
60
+ * @returns {boolean} true if a is strictly older than b
61
+ */
62
+ const isOlderVersion = (a, b) => {
63
+ const parse = (v) => v.split(".").map(Number);
64
+ const [aMajor, aMinor, aPatch] = parse(a);
65
+ const [bMajor, bMinor, bPatch] = parse(b);
66
+ if (aMajor !== bMajor) return aMajor < bMajor;
67
+ if (aMinor !== bMinor) return aMinor < bMinor;
68
+ return aPatch < bPatch;
69
+ };
70
+
71
+ /**
72
+ * Reads local package.json version.
73
+ * @returns {string}
74
+ */
75
+ const getCurrentVersion = () => {
76
+ // Resolved at import time via static path relative to this file
77
+ return TIENDU_CLI_VERSION;
78
+ };
79
+
80
+ // This constant is replaced at build time via package.json version
81
+ // We read it dynamically to avoid hardcoding.
82
+ let TIENDU_CLI_VERSION = "0.0.0";
83
+ try {
84
+ const pkgPath = new URL("../package.json", import.meta.url);
85
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
86
+ TIENDU_CLI_VERSION = pkg.version ?? "0.0.0";
87
+ } catch {
88
+ // ignore
89
+ }
90
+
91
+ /**
92
+ * Check npm registry for a newer version at most once per day.
93
+ * Shows a clack note if an update is available.
94
+ * Does nothing if check fails — never blocks the user.
95
+ */
96
+ export const checkForUpdates = async () => {
97
+ const now = Date.now();
98
+ const state = await readUpdateCheckState();
99
+
100
+ // If checked within the last 24h, use cached result
101
+ if (state && now - state.lastChecked < ONE_DAY_MS) {
102
+ const currentVersion = TIENDU_CLI_VERSION;
103
+ if (
104
+ state.latestVersion &&
105
+ isOlderVersion(currentVersion, state.latestVersion)
106
+ ) {
107
+ showUpdateNote(currentVersion, state.latestVersion);
108
+ }
109
+ return;
110
+ }
111
+
112
+ // Fetch latest version (non-blocking — failures are silent)
113
+ const latestVersion = await fetchLatestVersion();
114
+
115
+ await writeUpdateCheckState({ lastChecked: now, latestVersion });
116
+
117
+ if (!latestVersion) {
118
+ // Failed to check — don't show an error, just continue silently
119
+ return;
120
+ }
121
+
122
+ const currentVersion = TIENDU_CLI_VERSION;
123
+ if (isOlderVersion(currentVersion, latestVersion)) {
124
+ showUpdateNote(currentVersion, latestVersion);
125
+ }
126
+ };
127
+
128
+ /**
129
+ * @param {string} current
130
+ * @param {string} latest
131
+ */
132
+ const showUpdateNote = (current, latest) => {
133
+ p.note(
134
+ [
135
+ `A new version of Tiendu CLI is available! 🎉`,
136
+ ``,
137
+ ` ${current} → ${latest}`,
138
+ ``,
139
+ `Update by running:`,
140
+ ` npm install -g tiendu@latest`,
141
+ ].join("\n"),
142
+ "Update available",
143
+ );
144
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tiendu",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "CLI para desarrollar y publicar temas en Tiendu",
5
5
  "type": "module",
6
6
  "bin": {