tiendu 0.1.2 → 0.2.0

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/api.mjs CHANGED
@@ -43,21 +43,16 @@ const checkAuthErrors = (response) => {
43
43
  };
44
44
 
45
45
  /**
46
- * Validate API key and store access with a HEAD request to the download endpoint.
46
+ * Fetch all stores accessible by the current API key.
47
+ * Also serves as API key validation — 401/403 means invalid key.
47
48
  *
48
49
  * @param {string} apiBaseUrl
49
50
  * @param {string} apiKey
50
- * @param {number} storeId
51
- * @returns {Promise<{ ok: true, data: { name: string } } | { ok: false, error: string }>}
51
+ * @returns {Promise<{ ok: true, data: Array<{ id: number, name: string, hostname: string }> } | { ok: false, error: string }>}
52
52
  */
53
- export const fetchStoreInfo = async (apiBaseUrl, apiKey, storeId) => {
53
+ export const fetchUserStores = async (apiBaseUrl, apiKey) => {
54
54
  try {
55
- const response = await apiFetch(
56
- apiBaseUrl,
57
- apiKey,
58
- `/api/admin/stores/${storeId}/code/download`,
59
- { method: "HEAD" },
60
- );
55
+ const response = await apiFetch(apiBaseUrl, apiKey, `/api/v2/stores`);
61
56
 
62
57
  const authError = checkAuthErrors(response);
63
58
  if (authError) return authError;
@@ -69,7 +64,11 @@ export const fetchStoreInfo = async (apiBaseUrl, apiKey, storeId) => {
69
64
  };
70
65
  }
71
66
 
72
- return { ok: true, data: { name: `Tienda #${storeId}` } };
67
+ const stores = await response.json();
68
+ return {
69
+ ok: true,
70
+ data: Array.isArray(stores) ? stores : [],
71
+ };
73
72
  } catch (error) {
74
73
  return {
75
74
  ok: false,
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,27 @@
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
7
  import { createPreview, listPreviews } from "./preview.mjs";
6
8
  import {
7
- uploadPreviewFileMultipart,
8
9
  deletePreviewFile,
10
+ uploadPreviewFileMultipart,
9
11
  uploadPreviewZip,
10
12
  } from "./api.mjs";
11
- import { readdir } from "node:fs/promises";
12
- import { zipSync } from "fflate";
13
13
 
14
14
  const isDotfile = (name) => name.startsWith(".");
15
+
15
16
  const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
16
17
  const base = new URL(apiBaseUrl);
17
18
  const hasExplicitPort = previewHostname.includes(":");
18
19
  return `${base.protocol}//${previewHostname}${!hasExplicitPort && base.port ? `:${base.port}` : ""}/`;
19
20
  };
20
21
 
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
- };
22
+ const hasDotfileSegment = (relativePath) =>
23
+ relativePath.split(path.sep).some(isDotfile);
30
24
 
31
- /**
32
- * Recursively list all files, skipping dotfiles.
33
- * @param {string} rootDir
34
- * @param {string} currentDir
35
- * @returns {Promise<string[]>}
36
- */
37
25
  const listAllFiles = async (rootDir, currentDir) => {
38
26
  const entries = await readdir(currentDir, { withFileTypes: true });
39
27
  const files = [];
@@ -41,8 +29,7 @@ const listAllFiles = async (rootDir, currentDir) => {
41
29
  if (isDotfile(entry.name)) continue;
42
30
  const abs = path.join(currentDir, entry.name);
43
31
  if (entry.isDirectory()) {
44
- const nested = await listAllFiles(rootDir, abs);
45
- files.push(...nested);
32
+ files.push(...(await listAllFiles(rootDir, abs)));
46
33
  } else if (entry.isFile()) {
47
34
  files.push(abs);
48
35
  }
@@ -50,19 +37,13 @@ const listAllFiles = async (rootDir, currentDir) => {
50
37
  return files;
51
38
  };
52
39
 
53
- /**
54
- * Create a zip buffer from the current directory, skipping dotfiles.
55
- * @param {string} rootDir
56
- * @returns {Promise<Buffer>}
57
- */
58
40
  const createZipFromDirectory = async (rootDir) => {
59
41
  const absoluteFiles = await listAllFiles(rootDir, rootDir);
60
42
  /** @type {Record<string, Uint8Array>} */
61
43
  const entries = {};
62
44
  for (const abs of absoluteFiles) {
63
45
  const rel = path.relative(rootDir, abs).split(path.sep).join("/");
64
- const buf = await readFile(abs);
65
- entries[rel] = new Uint8Array(buf);
46
+ entries[rel] = new Uint8Array(await readFile(abs));
66
47
  }
67
48
  return Buffer.from(zipSync(entries, { level: 6 }));
68
49
  };
@@ -74,28 +55,25 @@ export const dev = async () => {
74
55
  const rootDir = process.cwd();
75
56
 
76
57
  let previewKey = config.previewKey;
58
+ let previewUrl;
77
59
 
78
- // Ensure a preview exists
79
60
  if (!previewKey) {
80
- console.log("");
81
- console.log("No hay preview activo. Creando uno...");
61
+ // ── Create preview and do initial upload ─────────────────────────────────
62
+ const spinner = p.spinner();
63
+ spinner.start("No active preview found. Creating one...");
82
64
 
83
65
  const result = await createPreview(apiBaseUrl, apiKey, storeId, "Dev");
84
66
  if (!result.ok) {
85
- console.error(`Error: ${result.error}`);
67
+ spinner.stop("Failed to create preview.", 1);
68
+ p.log.error(result.error);
86
69
  process.exit(1);
87
70
  }
88
71
 
89
72
  previewKey = result.data.previewKey;
73
+ previewUrl = buildPreviewUrl(apiBaseUrl, result.data.previewHostname);
90
74
  await writeConfig({ ...config, previewKey });
91
75
 
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...");
76
+ spinner.message("Uploading initial files...");
99
77
  const zipBuffer = await createZipFromDirectory(rootDir);
100
78
  const uploadResult = await uploadPreviewZip(
101
79
  apiBaseUrl,
@@ -104,67 +82,64 @@ export const dev = async () => {
104
82
  previewKey,
105
83
  zipBuffer,
106
84
  );
85
+
107
86
  if (!uploadResult.ok) {
108
- console.error(`Error subiendo archivos: ${uploadResult.error}`);
87
+ spinner.stop("Failed to upload files.", 1);
88
+ p.log.error(uploadResult.error);
109
89
  process.exit(1);
110
90
  }
111
- console.log("Archivos subidos.");
91
+
92
+ spinner.stop(`Preview ready: ${previewUrl}`);
112
93
  } else {
113
- // Verify the preview still exists
94
+ // ── Verify existing preview still exists ─────────────────────────────────
95
+ const spinner = p.spinner();
96
+ spinner.start("Connecting to preview...");
97
+
114
98
  const listResult = await listPreviews(apiBaseUrl, apiKey, storeId);
115
99
  if (!listResult.ok) {
116
- console.error(`Error: ${listResult.error}`);
100
+ spinner.stop("Failed to connect.", 1);
101
+ p.log.error(listResult.error);
117
102
  process.exit(1);
118
103
  }
119
- const existing = listResult.data.find((p) => p.previewKey === previewKey);
104
+
105
+ const existing = listResult.data.find((pr) => pr.previewKey === previewKey);
120
106
  if (!existing) {
121
- console.error(
122
- `El preview ${previewKey} ya no existe. Eliminá la config con: tiendu preview delete`,
107
+ spinner.stop("Preview no longer exists on the server.", 1);
108
+ p.log.error(
109
+ "Run tiendu preview delete to clean up, then tiendu dev again.",
123
110
  );
124
111
  process.exit(1);
125
112
  }
126
- console.log("");
127
- console.log(`Preview activo: ${previewKey}`);
128
- console.log(
129
- `URL: ${buildPreviewUrl(apiBaseUrl, existing.previewHostname)}`,
130
- );
113
+
114
+ previewUrl = buildPreviewUrl(apiBaseUrl, existing.previewHostname);
115
+ spinner.stop(`Preview: ${previewUrl}`);
131
116
  }
132
117
 
133
- console.log("");
134
- console.log("Observando cambios... (Ctrl+C para salir)");
135
- console.log("");
118
+ p.log.message("Watching for changes — press Ctrl+C to stop.\n");
136
119
 
137
- // Debounce map: relativePath -> timeout
120
+ // ── File watcher ──────────────────────────────────────────────────────────
138
121
  /** @type {Map<string, NodeJS.Timeout>} */
139
122
  const debounceMap = new Map();
140
123
  const DEBOUNCE_MS = 300;
141
124
 
142
125
  const watcher = watch(rootDir, { recursive: true }, (eventType, filename) => {
143
126
  if (!filename) return;
144
-
145
- // Skip dotfiles
146
127
  if (hasDotfileSegment(filename)) return;
147
128
 
148
- // Normalize to posix path
149
129
  const relativePath = filename.split(path.sep).join("/");
150
-
151
- // Clear existing debounce timer
152
130
  const existing = debounceMap.get(relativePath);
153
131
  if (existing) clearTimeout(existing);
154
132
 
155
- // Set new debounce timer
156
133
  const timer = setTimeout(async () => {
157
134
  debounceMap.delete(relativePath);
158
-
159
135
  const absolutePath = path.join(rootDir, filename);
160
136
 
161
137
  try {
162
138
  const fileStat = await stat(absolutePath).catch(() => null);
163
139
 
164
140
  if (!fileStat || !fileStat.isFile()) {
165
- // File was deleted or is a directory
166
141
  if (!fileStat) {
167
- console.log(` ✕ ${relativePath}`);
142
+ p.log.message(` ✕ ${relativePath}`);
168
143
  const result = await deletePreviewFile(
169
144
  apiBaseUrl,
170
145
  apiKey,
@@ -173,16 +148,14 @@ export const dev = async () => {
173
148
  relativePath,
174
149
  );
175
150
  if (!result.ok) {
176
- console.error(` Error: ${result.error}`);
151
+ p.log.warn(` Failed to delete: ${result.error}`);
177
152
  }
178
153
  }
179
154
  return;
180
155
  }
181
156
 
182
- // File was created or modified
157
+ p.log.message(` ↑ ${relativePath}`);
183
158
  const content = await readFile(absolutePath);
184
- console.log(` ↑ ${relativePath}`);
185
-
186
159
  const result = await uploadPreviewFileMultipart(
187
160
  apiBaseUrl,
188
161
  apiKey,
@@ -193,30 +166,25 @@ export const dev = async () => {
193
166
  );
194
167
 
195
168
  if (!result.ok) {
196
- console.error(` Error: ${result.error}`);
169
+ p.log.warn(` Failed to upload: ${result.error}`);
197
170
  }
198
171
  } catch (error) {
199
- console.error(` Error procesando ${relativePath}: ${error.message}`);
172
+ p.log.warn(` Error processing ${relativePath}: ${error.message}`);
200
173
  }
201
174
  }, DEBOUNCE_MS);
202
175
 
203
176
  debounceMap.set(relativePath, timer);
204
177
  });
205
178
 
206
- // Handle graceful shutdown
207
179
  const cleanup = () => {
208
180
  watcher.close();
209
- for (const timer of debounceMap.values()) {
210
- clearTimeout(timer);
211
- }
212
- console.log("");
213
- console.log("Dev mode finalizado.");
181
+ for (const timer of debounceMap.values()) clearTimeout(timer);
182
+ p.outro("Dev mode stopped.");
214
183
  process.exit(0);
215
184
  };
216
185
 
217
186
  process.on("SIGINT", cleanup);
218
187
  process.on("SIGTERM", cleanup);
219
188
 
220
- // Keep process alive
221
189
  await new Promise(() => {});
222
190
  };
package/lib/init.mjs CHANGED
@@ -1,97 +1,162 @@
1
- import * as readline from "node:readline/promises";
2
- import { stdin, stdout } from "node:process";
1
+ import { mkdir, access } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import * as p from "@clack/prompts";
3
4
  import {
4
5
  readConfig,
5
6
  readCredentials,
6
7
  writeConfig,
7
8
  writeCredentials,
8
9
  } from "./config.mjs";
9
- import { fetchStoreInfo } from "./api.mjs";
10
+ import { fetchUserStores } from "./api.mjs";
10
11
 
11
- export const init = async () => {
12
+ /** @param {string} url */
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;
38
+
39
+ // Change cwd so config is written inside the new directory
40
+ process.chdir(workDir);
41
+ }
42
+
43
+ // Re-read config after potential chdir
12
44
  const existingConfig = await readConfig();
13
45
  const existingCredentials = await readCredentials();
14
46
 
15
- const rl = readline.createInterface({ input: stdin, output: stdout });
47
+ p.intro("Tiendu CLI Setup");
16
48
 
17
- try {
18
- console.log("");
19
- console.log("Tiendu CLI — Inicialización");
20
- console.log("===========================");
21
- console.log("");
49
+ // ─── API Key ──────────────────────────────────────────────────────────────
50
+ const apiKeyDefault = existingCredentials?.apiKey ?? "";
22
51
 
23
- // API key
24
- const defaultApiKey = existingCredentials?.apiKey ?? "";
25
- const apiKeyPrompt = defaultApiKey
26
- ? `API Key [${maskApiKey(defaultApiKey)}]: `
27
- : "API Key: ";
28
- const apiKeyInput = (await rl.question(apiKeyPrompt)).trim();
29
- const apiKey = apiKeyInput || defaultApiKey;
52
+ const apiKeyInput = await p.password({
53
+ message: "API Key",
54
+ mask: "*",
55
+ validate: (value) => {
56
+ const resolved = (value ?? "").trim() || apiKeyDefault;
57
+ if (!resolved) return "API Key is required.";
58
+ },
59
+ });
30
60
 
31
- if (!apiKey) {
32
- console.error("La API Key es requerida.");
33
- process.exit(1);
34
- }
61
+ if (p.isCancel(apiKeyInput)) {
62
+ p.cancel("Setup cancelled.");
63
+ process.exit(0);
64
+ }
35
65
 
36
- // API base URL
37
- const defaultBaseUrl = existingConfig?.apiBaseUrl ?? "https://tiendu.uy";
38
- const baseUrlInput = (
39
- await rl.question(`URL base de la API [${defaultBaseUrl}]: `)
40
- ).trim();
41
- const apiBaseUrl = normalizeBaseUrl(baseUrlInput || defaultBaseUrl);
42
-
43
- // Store ID
44
- const defaultStoreId = existingConfig?.storeId ?? "";
45
- const storeIdPrompt = defaultStoreId
46
- ? `Store ID [${defaultStoreId}]: `
47
- : "Store ID: ";
48
- const storeIdInput = (await rl.question(storeIdPrompt)).trim();
49
- const storeIdRaw = storeIdInput || String(defaultStoreId);
50
- const storeId = Number(storeIdRaw);
51
-
52
- if (!Number.isInteger(storeId) || storeId <= 0) {
53
- console.error("El Store ID debe ser un número entero positivo.");
54
- process.exit(1);
55
- }
66
+ const apiKey = (apiKeyInput ?? "").trim() || apiKeyDefault;
56
67
 
57
- // Validate credentials against the server
58
- console.log("");
59
- console.log("Verificando credenciales...");
68
+ // ─── API Base URL ─────────────────────────────────────────────────────────
69
+ const baseUrlDefault = existingConfig?.apiBaseUrl ?? "https://tiendu.uy";
60
70
 
61
- const storeInfo = await fetchStoreInfo(apiBaseUrl, apiKey, storeId);
62
- if (!storeInfo.ok) {
63
- console.error(`Error: ${storeInfo.error}`);
64
- process.exit(1);
71
+ const baseUrlInput = await p.text({
72
+ message: "API base URL",
73
+ placeholder: baseUrlDefault,
74
+ defaultValue: baseUrlDefault,
75
+ validate: (value) => {
76
+ const resolved = (value ?? "").trim() || baseUrlDefault;
77
+ try {
78
+ new URL(resolved);
79
+ } catch {
80
+ return "Invalid URL.";
81
+ }
82
+ },
83
+ });
84
+
85
+ if (p.isCancel(baseUrlInput)) {
86
+ p.cancel("Setup cancelled.");
87
+ process.exit(0);
88
+ }
89
+
90
+ const apiBaseUrl = normalizeBaseUrl(
91
+ (baseUrlInput ?? "").trim() || baseUrlDefault,
92
+ );
93
+
94
+ // ─── Fetch stores (validates API key implicitly) ───────────────────────────
95
+ const spinner = p.spinner();
96
+ spinner.start("Verifying credentials...");
97
+
98
+ const storesResult = await fetchUserStores(apiBaseUrl, apiKey);
99
+
100
+ if (!storesResult.ok) {
101
+ spinner.stop("Failed to verify credentials.", 1);
102
+ p.cancel(storesResult.error);
103
+ process.exit(1);
104
+ }
105
+
106
+ const stores = storesResult.data;
107
+
108
+ if (stores.length === 0) {
109
+ spinner.stop("No stores found.", 1);
110
+ p.cancel("Your API Key does not have access to any store.");
111
+ process.exit(1);
112
+ }
113
+
114
+ spinner.stop(
115
+ `${stores.length} store${stores.length === 1 ? "" : "s"} found.`,
116
+ );
117
+
118
+ // ─── Select store ─────────────────────────────────────────────────────────
119
+ let storeId;
120
+
121
+ if (stores.length === 1) {
122
+ storeId = stores[0].id;
123
+ p.log.info(`Store: ${stores[0].name} (ID: ${storeId})`);
124
+ } else {
125
+ const selectedId = await p.select({
126
+ message: "Select a store",
127
+ options: stores.map((store) => ({
128
+ value: store.id,
129
+ label: store.name,
130
+ hint: `ID: ${store.id}`,
131
+ })),
132
+ initialValue: existingConfig?.storeId ?? stores[0].id,
133
+ });
134
+
135
+ if (p.isCancel(selectedId)) {
136
+ p.cancel("Setup cancelled.");
137
+ process.exit(0);
65
138
  }
66
139
 
67
- console.log(`Tienda: ${storeInfo.data.name} (ID: ${storeId})`);
68
- console.log("");
69
-
70
- // Save
71
- await writeConfig({ storeId, apiBaseUrl });
72
- await writeCredentials({ apiKey });
73
-
74
- console.log("Configuración guardada en .cli/");
75
- console.log("");
76
- console.log('Próximo paso: ejecutá "tiendu pull" para descargar el tema.');
77
- console.log("");
78
- console.log("Nota: habilitá el modo dev en la plataforma Tiendu");
79
- console.log(
80
- "(Ajustes > General) para que los datos del preview se muestren correctamente.",
81
- );
82
- console.log("");
83
- } finally {
84
- rl.close();
140
+ storeId = selectedId;
85
141
  }
86
- };
87
142
 
88
- /** @param {string} key */
89
- const maskApiKey = (key) => {
90
- if (key.length <= 8) return "****";
91
- return key.slice(0, 4) + "..." + key.slice(-4);
92
- };
143
+ // ─── Save ─────────────────────────────────────────────────────────────────
144
+ await writeConfig({ storeId, apiBaseUrl });
145
+ await writeCredentials({ apiKey });
93
146
 
94
- /** @param {string} url */
95
- const normalizeBaseUrl = (url) => {
96
- return url.endsWith("/") ? url.slice(0, -1) : url;
147
+ const nextSteps = dirArg
148
+ ? [`cd ${dirArg}`, `tiendu pull # download the current live theme`]
149
+ : [`tiendu pull # download the current live theme`];
150
+
151
+ p.note(
152
+ [
153
+ ...nextSteps,
154
+ "",
155
+ "Tip: enable Dev Mode in the Tiendu platform",
156
+ "(Settings → General) for preview data to load correctly.",
157
+ ].join("\n"),
158
+ "Next steps",
159
+ );
160
+
161
+ p.outro("Configuration saved to .cli/");
97
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
 
@@ -30,7 +31,7 @@ export const createPreview = async (apiBaseUrl, apiKey, storeId, name) => {
30
31
  const body = await response.json().catch(() => ({}));
31
32
  const message =
32
33
  body?.error?.message ??
33
- "Ya existe un preview para esta tienda. Eliminalo antes de crear uno nuevo.";
34
+ "A preview already exists for this store. Delete it first with: tiendu preview delete";
34
35
  return { ok: false, error: message };
35
36
  }
36
37
 
@@ -38,17 +39,14 @@ export const createPreview = async (apiBaseUrl, apiKey, storeId, name) => {
38
39
  const body = await response.text().catch(() => "");
39
40
  return {
40
41
  ok: false,
41
- error: `Error del servidor: ${response.status}${body ? ` — ${body}` : ""}`,
42
+ error: `Server error: ${response.status}${body ? ` — ${body}` : ""}`,
42
43
  };
43
44
  }
44
45
 
45
46
  const preview = await response.json();
46
47
  return { ok: true, data: preview };
47
48
  } catch (error) {
48
- return {
49
- ok: false,
50
- error: `No se pudo crear el preview: ${error.message}`,
51
- };
49
+ return { ok: false, error: `Could not create preview: ${error.message}` };
52
50
  }
53
51
  };
54
52
 
@@ -65,16 +63,13 @@ export const listPreviews = async (apiBaseUrl, apiKey, storeId) => {
65
63
  apiKey,
66
64
  `/api/v2/stores/${storeId}/theme-previews`,
67
65
  );
68
-
69
66
  if (!response.ok) {
70
- return { ok: false, error: `Error del servidor: ${response.status}` };
67
+ return { ok: false, error: `Server error: ${response.status}` };
71
68
  }
72
-
73
69
  const body = await response.json();
74
- const previews = body?.previews ?? [];
75
- return { ok: true, data: previews };
70
+ return { ok: true, data: body?.previews ?? [] };
76
71
  } catch (error) {
77
- return { ok: false, error: `No se pudo listar previews: ${error.message}` };
72
+ return { ok: false, error: `Could not list previews: ${error.message}` };
78
73
  }
79
74
  };
80
75
 
@@ -96,25 +91,18 @@ export const deletePreview = async (
96
91
  apiBaseUrl,
97
92
  apiKey,
98
93
  `/api/v2/stores/${storeId}/theme-previews/${previewKey}`,
99
- {
100
- method: "DELETE",
101
- },
94
+ { method: "DELETE" },
102
95
  );
103
-
104
96
  if (!response.ok) {
105
97
  const body = await response.text().catch(() => "");
106
98
  return {
107
99
  ok: false,
108
- error: `Error del servidor: ${response.status}${body ? ` — ${body}` : ""}`,
100
+ error: `Server error: ${response.status}${body ? ` — ${body}` : ""}`,
109
101
  };
110
102
  }
111
-
112
103
  return { ok: true };
113
104
  } catch (error) {
114
- return {
115
- ok: false,
116
- error: `No se pudo eliminar el preview: ${error.message}`,
117
- };
105
+ return { ok: false, error: `Could not delete preview: ${error.message}` };
118
106
  }
119
107
  };
120
108
 
@@ -136,25 +124,18 @@ export const publishPreview = async (
136
124
  apiBaseUrl,
137
125
  apiKey,
138
126
  `/api/v2/stores/${storeId}/theme-previews/${previewKey}/publish`,
139
- {
140
- method: "POST",
141
- },
127
+ { method: "POST" },
142
128
  );
143
-
144
129
  if (!response.ok) {
145
130
  const body = await response.text().catch(() => "");
146
131
  return {
147
132
  ok: false,
148
- error: `Error del servidor: ${response.status}${body ? ` — ${body}` : ""}`,
133
+ error: `Server error: ${response.status}${body ? ` — ${body}` : ""}`,
149
134
  };
150
135
  }
151
-
152
136
  return { ok: true };
153
137
  } catch (error) {
154
- return {
155
- ok: false,
156
- error: `No se pudo publicar el preview: ${error.message}`,
157
- };
138
+ return { ok: false, error: `Could not publish preview: ${error.message}` };
158
139
  }
159
140
  };
160
141
 
@@ -165,8 +146,8 @@ export const publishPreview = async (
165
146
  export const previewCreate = async (name) => {
166
147
  const { config, credentials } = await loadConfigOrFail();
167
148
 
168
- console.log("");
169
- console.log("Creando preview...");
149
+ const spinner = p.spinner();
150
+ spinner.start("Creating preview...");
170
151
 
171
152
  const result = await createPreview(
172
153
  config.apiBaseUrl,
@@ -176,24 +157,24 @@ export const previewCreate = async (name) => {
176
157
  );
177
158
 
178
159
  if (!result.ok) {
179
- console.error(`Error: ${result.error}`);
160
+ spinner.stop("Failed to create preview.", 1);
161
+ p.log.error(result.error);
180
162
  process.exit(1);
181
163
  }
182
164
 
183
165
  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("");
166
+ const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
167
+ spinner.stop(`Preview created: ${url}`);
189
168
 
190
- // Save preview key to config
191
169
  await writeConfig({ ...config, previewKey: preview.previewKey });
192
170
  };
193
171
 
194
172
  export const previewList = async () => {
195
173
  const { config, credentials } = await loadConfigOrFail();
196
174
 
175
+ const spinner = p.spinner();
176
+ spinner.start("Fetching previews...");
177
+
197
178
  const result = await listPreviews(
198
179
  config.apiBaseUrl,
199
180
  credentials.apiKey,
@@ -201,36 +182,46 @@ export const previewList = async () => {
201
182
  );
202
183
 
203
184
  if (!result.ok) {
204
- console.error(`Error: ${result.error}`);
185
+ spinner.stop("Failed to fetch previews.", 1);
186
+ p.log.error(result.error);
205
187
  process.exit(1);
206
188
  }
207
189
 
208
- console.log("");
209
190
  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
- }
191
+ spinner.stop("No previews for this store.");
192
+ return;
193
+ }
194
+
195
+ spinner.stop(
196
+ `${result.data.length} preview${result.data.length === 1 ? "" : "s"}:`,
197
+ );
198
+
199
+ for (const preview of result.data) {
200
+ const active = config.previewKey === preview.previewKey ? " ← active" : "";
201
+ const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
202
+ p.log.message(` ${preview.name} ${url}${active}`);
220
203
  }
221
- console.log("");
222
204
  };
223
205
 
224
206
  export const previewDelete = async () => {
225
207
  const { config, credentials } = await loadConfigOrFail();
226
208
 
227
209
  if (!config.previewKey) {
228
- console.error("No hay preview activo. Creá uno con: tiendu preview create");
210
+ p.log.error("No active preview. Create one with: tiendu preview create");
229
211
  process.exit(1);
230
212
  }
231
213
 
232
- console.log("");
233
- console.log(`Eliminando preview ${config.previewKey}...`);
214
+ const confirmed = await p.confirm({
215
+ message: `Delete preview ${config.previewKey}?`,
216
+ });
217
+
218
+ if (p.isCancel(confirmed) || !confirmed) {
219
+ p.cancel("Cancelled.");
220
+ process.exit(0);
221
+ }
222
+
223
+ const spinner = p.spinner();
224
+ spinner.start("Deleting preview...");
234
225
 
235
226
  const result = await deletePreview(
236
227
  config.apiBaseUrl,
@@ -240,28 +231,28 @@ export const previewDelete = async () => {
240
231
  );
241
232
 
242
233
  if (!result.ok) {
243
- console.error(`Error: ${result.error}`);
234
+ spinner.stop("Failed to delete preview.", 1);
235
+ p.log.error(result.error);
244
236
  process.exit(1);
245
237
  }
246
238
 
247
- console.log("Preview eliminado.");
248
- console.log("");
239
+ spinner.stop("Preview deleted.");
249
240
 
250
- // Remove preview key from config
251
241
  const { previewKey, ...rest } = config;
252
242
  await writeConfig(rest);
253
243
  };
254
244
 
255
245
  export const previewOpen = async () => {
256
- const { config } = await loadConfigOrFail();
246
+ const { config, credentials } = await loadConfigOrFail();
257
247
 
258
248
  if (!config.previewKey) {
259
- console.error("No hay preview activo. Creá uno con: tiendu preview create");
249
+ p.log.error("No active preview. Create one with: tiendu preview create");
260
250
  process.exit(1);
261
251
  }
262
252
 
263
- // Find the preview to get its hostname
264
- const { credentials } = await loadConfigOrFail();
253
+ const spinner = p.spinner();
254
+ spinner.start("Fetching preview URL...");
255
+
265
256
  const result = await listPreviews(
266
257
  config.apiBaseUrl,
267
258
  credentials.apiKey,
@@ -269,26 +260,26 @@ export const previewOpen = async () => {
269
260
  );
270
261
 
271
262
  if (!result.ok) {
272
- console.error(`Error: ${result.error}`);
263
+ spinner.stop("Failed to fetch previews.", 1);
264
+ p.log.error(result.error);
273
265
  process.exit(1);
274
266
  }
275
267
 
276
- const preview = result.data.find((p) => p.previewKey === config.previewKey);
268
+ const preview = result.data.find((pr) => pr.previewKey === config.previewKey);
277
269
  if (!preview) {
278
- console.error("El preview activo ya no existe en el servidor.");
270
+ spinner.stop("Active preview no longer exists on the server.", 1);
271
+ p.log.error("Run tiendu preview delete to clean up the local config.");
279
272
  process.exit(1);
280
273
  }
281
274
 
282
275
  const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
283
- console.log(`Abriendo ${url}...`);
276
+ spinner.stop(`Opening ${url}`);
284
277
 
285
- // Open URL in browser
286
278
  const { exec } = await import("node:child_process");
287
- const platform = process.platform;
288
279
  const cmd =
289
- platform === "darwin"
280
+ process.platform === "darwin"
290
281
  ? "open"
291
- : platform === "win32"
282
+ : process.platform === "win32"
292
283
  ? "start"
293
284
  : "xdg-open";
294
285
  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,23 @@ 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:`);
38
+ p.log.success(
39
+ `${extractedFiles.length} file${extractedFiles.length === 1 ? "" : "s"} extracted.`,
40
+ );
41
+
30
42
  for (const file of extractedFiles) {
31
- console.log(` ${file}`);
43
+ p.log.message(` ${file}`);
32
44
  }
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`;
41
45
  };
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.2",
3
+ "version": "0.2.0",
4
4
  "description": "CLI para desarrollar y publicar temas en Tiendu",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,6 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
+ "@clack/prompts": "^1.1.0",
39
40
  "fflate": "^0.8.2"
40
41
  }
41
42
  }