tiendu 0.4.0 → 0.6.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Official CLI for [Tiendu](https://tiendu.uy) — develop and publish storefront themes from your local machine.
4
4
 
5
- Download your store's theme, edit files locally, preview changes live with a shareable URL, and publish when you're ready — all from the terminal.
5
+ Download your store's theme, edit files locally, preview changes live with a local auto-reloading URL plus a sharable preview URL, and publish when you're ready — all from the terminal.
6
6
 
7
7
  ---
8
8
 
@@ -44,7 +44,7 @@ tiendu init
44
44
  tiendu dev
45
45
  ```
46
46
 
47
- `tiendu dev` creates a remote preview, builds your source files, uploads the output, and watches for changes. It prints a shareable URL like:
47
+ `tiendu dev` creates a remote preview, builds your source files, runs an initial push from the prepared output, and then watches for changes. It prints a local live-preview URL first, plus a sharable preview URL like:
48
48
 
49
49
  ```
50
50
  http://preview-xxxxxxxxxxxx.tiendu.uy/
@@ -52,6 +52,9 @@ http://preview-xxxxxxxxxxxx.tiendu.uy/
52
52
 
53
53
  The preview renders with the real Tiendu engine — same output as production.
54
54
 
55
+ When `tiendu dev` starts, it always re-syncs your current local files to the active preview before watching for changes.
56
+ It also starts a local live-preview URL that proxies the preview and auto-reloads after successful syncs.
57
+
55
58
  ---
56
59
 
57
60
  ## Commands
@@ -91,9 +94,13 @@ tiendu build
91
94
 
92
95
  The build:
93
96
 
94
- 1. Copies theme files (`layout/`, `templates/`, `snippets/`, `assets/`) to `dist/`
95
- 2. Discovers entry points in `src/layout/` and `src/templates/`
96
- 3. Bundles JS/TS and CSS via esbuild into `dist/assets/`
97
+ 1. Copies theme files from `src/layout/`, `src/templates/`, and `src/snippets/` to `dist/`
98
+ 2. Flattens static files from `src/assets/` into `dist/assets/`
99
+ 3. Discovers entry points in `src/layout/` and `src/templates/`
100
+ 4. Bundles JS/TS and CSS into `dist/assets/`
101
+ 5. Runs project PostCSS plugins for CSS entries when available (for example Tailwind v4)
102
+
103
+ For TypeScript source, extensionless relative imports such as `import { initHeaderCart } from '../lib/scripts/cart'` are supported and recommended.
97
104
 
98
105
  Entry naming convention:
99
106
 
@@ -115,7 +122,10 @@ tiendu dev
115
122
  ```
116
123
 
117
124
  - Prints the preview URL on start
125
+ - Re-syncs the full local theme to the preview on startup
118
126
  - Syncs file creates, edits and deletes
127
+ - Retries failed file sync operations up to 3 times before giving up
128
+ - Starts a local live-preview URL on `localhost` that refreshes after successful uploads
119
129
  - Handles both text and binary files (images, fonts, etc.)
120
130
  - Press `Ctrl+C` to stop
121
131
 
@@ -272,26 +282,59 @@ my-theme/
272
282
  ├── .gitignore
273
283
  ├── src/
274
284
  │ ├── layout/
285
+ │ │ ├── theme.liquid # copied to dist/layout/theme.liquid
275
286
  │ │ ├── theme.ts # layout TS entry → layout-theme.bundle.js
276
287
  │ │ └── theme.css # layout CSS entry → layout-theme.bundle.css
277
288
  │ ├── templates/
289
+ │ │ ├── product.liquid # copied to dist/templates/product.liquid
278
290
  │ │ ├── product.ts # template TS entry → template-product.bundle.js
279
291
  │ │ └── product.css # template CSS entry → template-product.bundle.css
292
+ │ ├── snippets/ # Liquid snippets copied to dist/snippets/
293
+ │ ├── assets/ # source assets → flattened into dist/assets/
280
294
  │ ├── lib/ # shared modules (bundled into entries, not served)
281
295
  │ └── css/ # shared CSS (imported by entry CSS)
282
- ├── layout/ # Liquid layouts
283
- ├── templates/ # Liquid templates
284
- ├── snippets/ # Liquid snippets
285
- ├── assets/ # static assets (SVGs, images, fonts)
286
296
  └── dist/ # build output (gitignored, uploaded to Tiendu)
287
297
  ```
288
298
 
289
299
  ### How it works
290
300
 
291
- 1. Source JS/TS/CSS in `src/` is bundled by esbuild into `dist/assets/`
292
- 2. Liquid files and static assets are copied from root to `dist/`
293
- 3. `dist/` is what gets uploaded it looks like a normal Tiendu theme
294
- 4. Liquid templates reference bundles via `asset_url` (e.g. `{{ 'layout-theme.bundle.js' | asset_url | script_tag }}`)
301
+ 1. Source assets in `src/assets/` are flattened into `dist/assets/` (`payment-methods/visa.svg` becomes `payment-methods___visa.svg`)
302
+ 2. Source JS/TS/CSS in `src/` is bundled by esbuild into `dist/assets/`
303
+ 3. CSS entries also run through your local PostCSS pipeline when configured
304
+ 4. Liquid files are copied from `src/` to `dist/`
305
+ 5. `dist/` is what gets uploaded — it looks like a normal Tiendu theme
306
+ 6. Liquid templates reference bundles and assets via `asset_url` (e.g. `{{ 'layout-theme.bundle.js' | asset_url | script_tag }}` or `{{ 'payment-methods/visa.svg' | asset_url }}`)
307
+
308
+ ### Tailwind v4
309
+
310
+ Built themes can use Tailwind v4 in CSS entry files.
311
+
312
+ Install it in your theme project:
313
+
314
+ ```bash
315
+ npm install -D tailwindcss @tailwindcss/postcss postcss
316
+ ```
317
+
318
+ Then import Tailwind from a CSS entry such as `src/layout/theme.css`:
319
+
320
+ ```css
321
+ @import "tailwindcss";
322
+ ```
323
+
324
+ You can either:
325
+
326
+ - rely on Tiendu CLI's automatic Tailwind detection when `@tailwindcss/postcss` is installed, or
327
+ - add a local `postcss.config.mjs` / `postcss.config.js` / `postcss.config.cjs` / `postcss.config.json`
328
+
329
+ Example `postcss.config.mjs`:
330
+
331
+ ```js
332
+ export default {
333
+ plugins: {
334
+ "@tailwindcss/postcss": {},
335
+ },
336
+ };
337
+ ```
295
338
 
296
339
  ### tiendu.config.json
297
340
 
package/bin/tiendu.js CHANGED
@@ -12,6 +12,8 @@ import {
12
12
  previewList,
13
13
  previewDelete,
14
14
  previewOpen,
15
+ previewAttach,
16
+ previewDetach,
15
17
  } from "../lib/preview.mjs";
16
18
  import {
17
19
  checkForUpdates,
@@ -24,23 +26,29 @@ tiendu — Tiendu theme development CLI
24
26
 
25
27
  Usage:
26
28
  tiendu init [dir] Set up a theme project (optionally in a new directory)
27
- tiendu pull Download the live theme from your store
29
+ tiendu pull [previewKey] Download the live theme, or a specific preview's files
28
30
  tiendu build Build a theme (requires tiendu.config.json)
29
- tiendu push [--skip-build] Upload local files to the active preview (full replace)
31
+ tiendu push [previewKey] [--skip-build]
32
+ Upload files to the attached or specified preview
30
33
  tiendu dev Start dev mode: auto-sync changes to a live preview URL
31
- tiendu publish [--skip-build]
32
- Publish the active preview to the live storefront
33
-
34
- tiendu preview Show the active preview details
35
- tiendu preview create Create a new remote preview
36
- tiendu preview list List previews for your store
37
- tiendu preview delete Delete the active preview
38
- tiendu preview open Open the active preview URL in your browser
34
+ tiendu publish [previewKey] [--skip-build]
35
+ Publish the attached or specified preview to the live storefront
36
+
37
+ tiendu preview Show the attached preview details
38
+ tiendu preview create [name]
39
+ Create a new preview (and attach to it)
40
+ tiendu preview list List all previews for your store
41
+ tiendu preview attach [key]
42
+ Attach to an existing preview by its key
43
+ tiendu preview detach Detach from the current preview (without deleting it)
44
+ tiendu preview delete [key]
45
+ Delete a preview (defaults to the attached one)
46
+ tiendu preview open Open the attached preview URL in your browser
39
47
 
40
48
  tiendu check-updates Check npm for a newer CLI version
41
49
  tiendu version Show the current CLI version
42
50
 
43
- tiendu help Show this help message
51
+ tiendu --help, -h Show this help message
44
52
  tiendu --version, -v Show the current CLI version
45
53
 
46
54
  Typical workflow:
@@ -52,10 +60,19 @@ Typical workflow:
52
60
  tiendu publish Ship to the live storefront when ready
53
61
  `;
54
62
 
63
+ /**
64
+ * Extract the first positional argument that is not a flag (--skip-build, etc.).
65
+ * @param {string[]} args - CLI args after the command name
66
+ * @returns {string | undefined}
67
+ */
68
+ const extractPositionalArg = (args) =>
69
+ args.find((arg) => !arg.startsWith("--"));
70
+
55
71
  const main = async () => {
56
72
  const args = process.argv.slice(2);
57
73
  const command = args[0];
58
74
  const subcommand = args[1];
75
+ const restArgs = args.slice(1);
59
76
  const skipBuild = args.includes("--skip-build");
60
77
 
61
78
  if (
@@ -69,7 +86,6 @@ const main = async () => {
69
86
 
70
87
  if (
71
88
  !command ||
72
- command === "help" ||
73
89
  command === "--help" ||
74
90
  command === "-h"
75
91
  ) {
@@ -91,7 +107,8 @@ const main = async () => {
91
107
  }
92
108
 
93
109
  if (command === "pull") {
94
- await pull();
110
+ const previewKey = extractPositionalArg(restArgs);
111
+ await pull({ previewKey });
95
112
  return;
96
113
  }
97
114
 
@@ -102,7 +119,8 @@ const main = async () => {
102
119
  }
103
120
 
104
121
  if (command === "push") {
105
- await push({ skipBuild });
122
+ const previewKey = extractPositionalArg(restArgs);
123
+ await push({ skipBuild, previewKey });
106
124
  return;
107
125
  }
108
126
 
@@ -112,7 +130,8 @@ const main = async () => {
112
130
  }
113
131
 
114
132
  if (command === "publish") {
115
- await publish({ skipBuild });
133
+ const previewKey = extractPositionalArg(restArgs);
134
+ await publish({ skipBuild, previewKey });
116
135
  return;
117
136
  }
118
137
 
@@ -129,8 +148,16 @@ const main = async () => {
129
148
  await previewList();
130
149
  return;
131
150
  }
151
+ if (subcommand === "attach") {
152
+ await previewAttach(args[2]);
153
+ return;
154
+ }
155
+ if (subcommand === "detach") {
156
+ await previewDetach();
157
+ return;
158
+ }
132
159
  if (subcommand === "delete") {
133
- await previewDelete();
160
+ await previewDelete(args[2]);
134
161
  return;
135
162
  }
136
163
  if (subcommand === "open") {
package/bin/tiendu.mjs CHANGED
@@ -1,138 +1,3 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { init } from "../lib/init.mjs";
4
- import { pull } from "../lib/pull.mjs";
5
- import { push } from "../lib/push.mjs";
6
- import { dev } from "../lib/dev.mjs";
7
- import { publish } from "../lib/publish.mjs";
8
- import {
9
- previewCreate,
10
- previewList,
11
- previewDelete,
12
- previewOpen,
13
- } from "../lib/preview.mjs";
14
- import {
15
- checkForUpdates,
16
- checkForUpdatesNow,
17
- getCurrentVersion,
18
- } from "../lib/update-check.mjs";
19
-
20
- const HELP = `
21
- tiendu — CLI para desarrollar temas de Tiendu
22
-
23
- Uso:
24
- tiendu init Inicializar un tema en el directorio actual
25
- tiendu pull Descargar el tema live desde Tiendu
26
- tiendu push [--skip-build] Subir archivos locales al preview activo (ZIP)
27
- tiendu dev Modo desarrollo: watch + sync automático
28
- tiendu publish [--skip-build]
29
- Publicar el preview activo al storefront live
30
-
31
- tiendu preview create Crear un preview remoto
32
- tiendu preview list Listar previews de la tienda
33
- tiendu preview delete Eliminar el preview activo
34
- tiendu preview open Abrir la URL del preview en el navegador
35
-
36
- tiendu check-updates Buscar una nueva version del CLI
37
- tiendu version Mostrar la version actual del CLI
38
-
39
- tiendu help Mostrar esta ayuda
40
-
41
- Opciones:
42
- --help, -h Mostrar esta ayuda
43
- --version, -v Mostrar la version actual del CLI
44
- `;
45
-
46
- const main = async () => {
47
- const args = process.argv.slice(2);
48
- const command = args[0];
49
- const subcommand = args[1];
50
- const skipBuild = args.includes("--skip-build");
51
-
52
- if (
53
- command === "version" ||
54
- command === "--version" ||
55
- command === "-v"
56
- ) {
57
- console.log(getCurrentVersion());
58
- process.exit(0);
59
- }
60
-
61
- if (
62
- !command ||
63
- command === "help" ||
64
- command === "--help" ||
65
- command === "-h"
66
- ) {
67
- console.log(HELP.trim());
68
- process.exit(0);
69
- }
70
-
71
- if (command === "check-updates") {
72
- await checkForUpdatesNow();
73
- return;
74
- }
75
-
76
- await checkForUpdates();
77
-
78
- if (command === "init") {
79
- await init();
80
- return;
81
- }
82
-
83
- if (command === "pull") {
84
- await pull();
85
- return;
86
- }
87
-
88
- if (command === "push") {
89
- await push({ skipBuild });
90
- return;
91
- }
92
-
93
- if (command === "dev") {
94
- await dev();
95
- return;
96
- }
97
-
98
- if (command === "publish") {
99
- await publish({ skipBuild });
100
- return;
101
- }
102
-
103
- if (command === "preview") {
104
- if (subcommand === "create") {
105
- const name = args[2];
106
- await previewCreate(name);
107
- return;
108
- }
109
-
110
- if (subcommand === "list") {
111
- await previewList();
112
- return;
113
- }
114
-
115
- if (subcommand === "delete") {
116
- await previewDelete();
117
- return;
118
- }
119
-
120
- if (subcommand === "open") {
121
- await previewOpen();
122
- return;
123
- }
124
-
125
- console.error(`Subcomando desconocido: preview ${subcommand ?? "(vacío)"}`);
126
- console.log(HELP.trim());
127
- process.exit(1);
128
- }
129
-
130
- console.error(`Comando desconocido: ${command}`);
131
- console.log(HELP.trim());
132
- process.exit(1);
133
- };
134
-
135
- main().catch((error) => {
136
- console.error(error.message || error);
137
- process.exit(1);
138
- });
3
+ import "./tiendu.js";
package/lib/api.mjs CHANGED
@@ -2,9 +2,14 @@
2
2
  * @param {string} apiBaseUrl
3
3
  * @param {string} apiKey
4
4
  * @param {string} path
5
- * @param {{ method?: string, body?: string | Buffer | Uint8Array, contentType?: string }} [options]
5
+ * @param {{ method?: string, body?: string | Buffer | Uint8Array, contentType?: string, signal?: AbortSignal }} [options]
6
6
  * @returns {Promise<Response>}
7
7
  */
8
+ const REQUEST_TIMEOUT_MS = 30_000;
9
+
10
+ const isRetriableStatus = (status) =>
11
+ status === 408 || status === 425 || status === 429 || status >= 500;
12
+
8
13
  export const apiFetch = (apiBaseUrl, apiKey, path, options = {}) => {
9
14
  const url = `${apiBaseUrl}${path}`;
10
15
  /** @type {Record<string, string>} */
@@ -22,6 +27,7 @@ export const apiFetch = (apiBaseUrl, apiKey, path, options = {}) => {
22
27
  method: options.method ?? "GET",
23
28
  headers,
24
29
  body: options.body,
30
+ signal: options.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
25
31
  });
26
32
  };
27
33
 
@@ -77,6 +83,47 @@ export const fetchUserStores = async (apiBaseUrl, apiKey) => {
77
83
  }
78
84
  };
79
85
 
86
+ /**
87
+ * Fetch a single preview by key.
88
+ *
89
+ * @param {string} apiBaseUrl
90
+ * @param {string} apiKey
91
+ * @param {number} storeId
92
+ * @param {string} previewKey
93
+ * @returns {Promise<{ ok: true, data: any } | { ok: false, error: string }>}
94
+ */
95
+ export const fetchPreview = async (apiBaseUrl, apiKey, storeId, previewKey) => {
96
+ try {
97
+ const response = await apiFetch(
98
+ apiBaseUrl,
99
+ apiKey,
100
+ `/api/v2/stores/${storeId}/theme-previews/${previewKey}`,
101
+ );
102
+
103
+ const authError = checkAuthErrors(response);
104
+ if (authError) return authError;
105
+
106
+ if (response.status === 404) {
107
+ return { ok: false, error: "Preview not found." };
108
+ }
109
+
110
+ if (!response.ok) {
111
+ return {
112
+ ok: false,
113
+ error: `Server error: ${response.status} ${response.statusText}`,
114
+ };
115
+ }
116
+
117
+ const preview = await response.json();
118
+ return { ok: true, data: preview };
119
+ } catch (error) {
120
+ return {
121
+ ok: false,
122
+ error: `Could not fetch preview: ${error.message}`,
123
+ };
124
+ }
125
+ };
126
+
80
127
  /**
81
128
  * Download the storefront archive (zip) as a buffer.
82
129
  *
@@ -119,76 +166,74 @@ export const downloadStorefrontArchive = async (
119
166
  };
120
167
 
121
168
  /**
122
- * Upload a zip buffer to a preview, replacing its content.
169
+ * Download a preview's archive (zip) as a buffer.
123
170
  *
124
171
  * @param {string} apiBaseUrl
125
172
  * @param {string} apiKey
126
173
  * @param {number} storeId
127
174
  * @param {string} previewKey
128
- * @param {Buffer} zipBuffer
129
- * @returns {Promise<{ ok: true } | { ok: false, error: string }>}
175
+ * @returns {Promise<{ ok: true, data: Buffer } | { ok: false, error: string }>}
130
176
  */
131
- export const uploadPreviewZip = async (
177
+ export const downloadPreviewArchive = async (
132
178
  apiBaseUrl,
133
179
  apiKey,
134
180
  storeId,
135
181
  previewKey,
136
- zipBuffer,
137
182
  ) => {
138
183
  try {
139
184
  const response = await apiFetch(
140
185
  apiBaseUrl,
141
186
  apiKey,
142
- `/api/admin/stores/${storeId}/theme-previews/${previewKey}/upload`,
143
- {
144
- method: "POST",
145
- body: zipBuffer,
146
- contentType: "application/zip",
147
- },
187
+ `/api/admin/stores/${storeId}/theme-previews/${previewKey}/download`,
148
188
  );
149
189
 
190
+ const authError = checkAuthErrors(response);
191
+ if (authError) return authError;
192
+
150
193
  if (!response.ok) {
151
194
  const body = await response.text().catch(() => "");
152
195
  return {
153
196
  ok: false,
154
- error: `Error del servidor: ${response.status}${body ? ` — ${body}` : ""}`,
197
+ error: `Server error: ${response.status} ${response.statusText}${body ? ` — ${body}` : ""}`,
155
198
  };
156
199
  }
157
200
 
158
- return { ok: true };
201
+ const arrayBuffer = await response.arrayBuffer();
202
+ return { ok: true, data: Buffer.from(arrayBuffer) };
159
203
  } catch (error) {
160
- return { ok: false, error: `No se pudo subir: ${error.message}` };
204
+ return {
205
+ ok: false,
206
+ error: `Could not download preview: ${error.message}`,
207
+ };
161
208
  }
162
209
  };
163
210
 
164
211
  /**
165
- * Upload a single file to a preview.
212
+ * Upload a zip buffer to a preview, replacing its content.
166
213
  *
167
214
  * @param {string} apiBaseUrl
168
215
  * @param {string} apiKey
169
216
  * @param {number} storeId
170
217
  * @param {string} previewKey
171
- * @param {string} filePath - relative path within the preview
172
- * @param {string} content - file content
173
- * @returns {Promise<{ ok: true } | { ok: false, error: string }>}
218
+ * @param {Buffer} zipBuffer
219
+ * @returns {Promise<{ ok: true } | { ok: false, error: string, retriable?: boolean }>}
174
220
  */
175
- export const uploadPreviewFile = async (
221
+ export const uploadPreviewZip = async (
176
222
  apiBaseUrl,
177
223
  apiKey,
178
224
  storeId,
179
225
  previewKey,
180
- filePath,
181
- content,
226
+ zipBuffer,
182
227
  ) => {
183
228
  try {
184
- const query = new URLSearchParams({ path: filePath }).toString();
185
229
  const response = await apiFetch(
186
230
  apiBaseUrl,
187
231
  apiKey,
188
- `/api/v2/stores/${storeId}/theme-previews/${previewKey}/file?${query}`,
232
+ `/api/admin/stores/${storeId}/theme-previews/${previewKey}/upload`,
189
233
  {
190
- method: "PUT",
191
- body: JSON.stringify({ content }),
234
+ method: "POST",
235
+ body: zipBuffer,
236
+ contentType: "application/zip",
192
237
  },
193
238
  );
194
239
 
@@ -196,7 +241,8 @@ export const uploadPreviewFile = async (
196
241
  const body = await response.text().catch(() => "");
197
242
  return {
198
243
  ok: false,
199
- error: `Error subiendo ${filePath}: ${response.status}${body ? ` — ${body}` : ""}`,
244
+ error: `Error del servidor: ${response.status}${body ? ` — ${body}` : ""}`,
245
+ retriable: isRetriableStatus(response.status),
200
246
  };
201
247
  }
202
248
 
@@ -204,7 +250,8 @@ export const uploadPreviewFile = async (
204
250
  } catch (error) {
205
251
  return {
206
252
  ok: false,
207
- error: `Error subiendo ${filePath}: ${error.message}`,
253
+ error: `No se pudo subir: ${error.message}`,
254
+ retriable: true,
208
255
  };
209
256
  }
210
257
  };
@@ -219,7 +266,7 @@ export const uploadPreviewFile = async (
219
266
  * @param {string} previewKey
220
267
  * @param {string} relativePath
221
268
  * @param {Buffer} fileBuffer
222
- * @returns {Promise<{ ok: true } | { ok: false, error: string }>}
269
+ * @returns {Promise<{ ok: true } | { ok: false, error: string, retriable?: boolean }>}
223
270
  */
224
271
  export const uploadPreviewFileMultipart = async (
225
272
  apiBaseUrl,
@@ -249,6 +296,7 @@ export const uploadPreviewFileMultipart = async (
249
296
  Authorization: `Bearer ${apiKey}`,
250
297
  },
251
298
  body: formData,
299
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
252
300
  },
253
301
  );
254
302
 
@@ -257,6 +305,7 @@ export const uploadPreviewFileMultipart = async (
257
305
  return {
258
306
  ok: false,
259
307
  error: `Error subiendo ${relativePath}: ${response.status}${body ? ` — ${body}` : ""}`,
308
+ retriable: isRetriableStatus(response.status),
260
309
  };
261
310
  }
262
311
 
@@ -265,6 +314,7 @@ export const uploadPreviewFileMultipart = async (
265
314
  return {
266
315
  ok: false,
267
316
  error: `Error subiendo ${relativePath}: ${error.message}`,
317
+ retriable: true,
268
318
  };
269
319
  }
270
320
  };
@@ -277,7 +327,7 @@ export const uploadPreviewFileMultipart = async (
277
327
  * @param {number} storeId
278
328
  * @param {string} previewKey
279
329
  * @param {string} filePath
280
- * @returns {Promise<{ ok: true } | { ok: false, error: string }>}
330
+ * @returns {Promise<{ ok: true } | { ok: false, error: string, retriable?: boolean }>}
281
331
  */
282
332
  export const deletePreviewFile = async (
283
333
  apiBaseUrl,
@@ -302,6 +352,7 @@ export const deletePreviewFile = async (
302
352
  return {
303
353
  ok: false,
304
354
  error: `Error eliminando ${filePath}: ${response.status}${body ? ` — ${body}` : ""}`,
355
+ retriable: isRetriableStatus(response.status),
305
356
  };
306
357
  }
307
358
 
@@ -310,6 +361,7 @@ export const deletePreviewFile = async (
310
361
  return {
311
362
  ok: false,
312
363
  error: `Error eliminando ${filePath}: ${error.message}`,
364
+ retriable: true,
313
365
  };
314
366
  }
315
367
  };
@@ -0,0 +1,30 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { zipSync } from "fflate";
4
+ import { listFilesRecursive } from "./fs-utils.mjs";
5
+
6
+ /**
7
+ * @param {string} rootDir
8
+ * @returns {Promise<string[]>}
9
+ */
10
+ export const listAllFiles = async (rootDir) => listFilesRecursive(rootDir);
11
+
12
+ /**
13
+ * @param {string} rootDir
14
+ * @returns {Promise<Buffer>}
15
+ */
16
+ export const createZipFromDirectory = async (rootDir) => {
17
+ const absoluteFiles = await listAllFiles(rootDir);
18
+ /** @type {Record<string, Uint8Array>} */
19
+ const entries = {};
20
+
21
+ for (const absolutePath of absoluteFiles) {
22
+ const relativePath = path
23
+ .relative(rootDir, absolutePath)
24
+ .split(path.sep)
25
+ .join("/");
26
+ entries[relativePath] = new Uint8Array(await readFile(absolutePath));
27
+ }
28
+
29
+ return Buffer.from(zipSync(entries, { level: 6 }));
30
+ };