tiendu 0.1.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.
@@ -0,0 +1,295 @@
1
+ import { loadConfigOrFail, writeConfig } from "./config.mjs";
2
+ import { apiFetch } from "./api.mjs";
3
+
4
+ const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
5
+ const base = new URL(apiBaseUrl);
6
+ const hasExplicitPort = previewHostname.includes(":");
7
+ return `${base.protocol}//${previewHostname}${!hasExplicitPort && base.port ? `:${base.port}` : ""}/`;
8
+ };
9
+
10
+ /**
11
+ * @param {string} apiBaseUrl
12
+ * @param {string} apiKey
13
+ * @param {number} storeId
14
+ * @param {string} [name]
15
+ * @returns {Promise<{ ok: true, data: any } | { ok: false, error: string }>}
16
+ */
17
+ export const createPreview = async (apiBaseUrl, apiKey, storeId, name) => {
18
+ try {
19
+ const response = await apiFetch(
20
+ apiBaseUrl,
21
+ apiKey,
22
+ `/api/v2/stores/${storeId}/theme-previews`,
23
+ {
24
+ method: "POST",
25
+ body: JSON.stringify({ name: name ?? "Dev" }),
26
+ },
27
+ );
28
+
29
+ if (response.status === 409) {
30
+ const body = await response.json().catch(() => ({}));
31
+ const message =
32
+ body?.error?.message ??
33
+ "Ya existe un preview para esta tienda. Eliminalo antes de crear uno nuevo.";
34
+ return { ok: false, error: message };
35
+ }
36
+
37
+ if (!response.ok) {
38
+ const body = await response.text().catch(() => "");
39
+ return {
40
+ ok: false,
41
+ error: `Error del servidor: ${response.status}${body ? ` — ${body}` : ""}`,
42
+ };
43
+ }
44
+
45
+ const preview = await response.json();
46
+ return { ok: true, data: preview };
47
+ } catch (error) {
48
+ return {
49
+ ok: false,
50
+ error: `No se pudo crear el preview: ${error.message}`,
51
+ };
52
+ }
53
+ };
54
+
55
+ /**
56
+ * @param {string} apiBaseUrl
57
+ * @param {string} apiKey
58
+ * @param {number} storeId
59
+ * @returns {Promise<{ ok: true, data: any[] } | { ok: false, error: string }>}
60
+ */
61
+ export const listPreviews = async (apiBaseUrl, apiKey, storeId) => {
62
+ try {
63
+ const response = await apiFetch(
64
+ apiBaseUrl,
65
+ apiKey,
66
+ `/api/v2/stores/${storeId}/theme-previews`,
67
+ );
68
+
69
+ if (!response.ok) {
70
+ return { ok: false, error: `Error del servidor: ${response.status}` };
71
+ }
72
+
73
+ const body = await response.json();
74
+ const previews = body?.previews ?? [];
75
+ return { ok: true, data: previews };
76
+ } catch (error) {
77
+ return { ok: false, error: `No se pudo listar previews: ${error.message}` };
78
+ }
79
+ };
80
+
81
+ /**
82
+ * @param {string} apiBaseUrl
83
+ * @param {string} apiKey
84
+ * @param {number} storeId
85
+ * @param {string} previewKey
86
+ * @returns {Promise<{ ok: true } | { ok: false, error: string }>}
87
+ */
88
+ export const deletePreview = async (
89
+ apiBaseUrl,
90
+ apiKey,
91
+ storeId,
92
+ previewKey,
93
+ ) => {
94
+ try {
95
+ const response = await apiFetch(
96
+ apiBaseUrl,
97
+ apiKey,
98
+ `/api/v2/stores/${storeId}/theme-previews/${previewKey}`,
99
+ {
100
+ method: "DELETE",
101
+ },
102
+ );
103
+
104
+ if (!response.ok) {
105
+ const body = await response.text().catch(() => "");
106
+ return {
107
+ ok: false,
108
+ error: `Error del servidor: ${response.status}${body ? ` — ${body}` : ""}`,
109
+ };
110
+ }
111
+
112
+ return { ok: true };
113
+ } catch (error) {
114
+ return {
115
+ ok: false,
116
+ error: `No se pudo eliminar el preview: ${error.message}`,
117
+ };
118
+ }
119
+ };
120
+
121
+ /**
122
+ * @param {string} apiBaseUrl
123
+ * @param {string} apiKey
124
+ * @param {number} storeId
125
+ * @param {string} previewKey
126
+ * @returns {Promise<{ ok: true } | { ok: false, error: string }>}
127
+ */
128
+ export const publishPreview = async (
129
+ apiBaseUrl,
130
+ apiKey,
131
+ storeId,
132
+ previewKey,
133
+ ) => {
134
+ try {
135
+ const response = await apiFetch(
136
+ apiBaseUrl,
137
+ apiKey,
138
+ `/api/v2/stores/${storeId}/theme-previews/${previewKey}/publish`,
139
+ {
140
+ method: "POST",
141
+ },
142
+ );
143
+
144
+ if (!response.ok) {
145
+ const body = await response.text().catch(() => "");
146
+ return {
147
+ ok: false,
148
+ error: `Error del servidor: ${response.status}${body ? ` — ${body}` : ""}`,
149
+ };
150
+ }
151
+
152
+ return { ok: true };
153
+ } catch (error) {
154
+ return {
155
+ ok: false,
156
+ error: `No se pudo publicar el preview: ${error.message}`,
157
+ };
158
+ }
159
+ };
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // CLI commands
163
+ // ---------------------------------------------------------------------------
164
+
165
+ export const previewCreate = async (name) => {
166
+ const { config, credentials } = await loadConfigOrFail();
167
+
168
+ console.log("");
169
+ console.log("Creando preview...");
170
+
171
+ const result = await createPreview(
172
+ config.apiBaseUrl,
173
+ credentials.apiKey,
174
+ config.storeId,
175
+ name,
176
+ );
177
+
178
+ if (!result.ok) {
179
+ console.error(`Error: ${result.error}`);
180
+ process.exit(1);
181
+ }
182
+
183
+ 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("");
189
+
190
+ // Save preview key to config
191
+ await writeConfig({ ...config, previewKey: preview.previewKey });
192
+ };
193
+
194
+ export const previewList = async () => {
195
+ const { config, credentials } = await loadConfigOrFail();
196
+
197
+ const result = await listPreviews(
198
+ config.apiBaseUrl,
199
+ credentials.apiKey,
200
+ config.storeId,
201
+ );
202
+
203
+ if (!result.ok) {
204
+ console.error(`Error: ${result.error}`);
205
+ process.exit(1);
206
+ }
207
+
208
+ console.log("");
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
+ }
220
+ }
221
+ console.log("");
222
+ };
223
+
224
+ export const previewDelete = async () => {
225
+ const { config, credentials } = await loadConfigOrFail();
226
+
227
+ if (!config.previewKey) {
228
+ console.error("No hay preview activo. Creá uno con: tiendu preview create");
229
+ process.exit(1);
230
+ }
231
+
232
+ console.log("");
233
+ console.log(`Eliminando preview ${config.previewKey}...`);
234
+
235
+ const result = await deletePreview(
236
+ config.apiBaseUrl,
237
+ credentials.apiKey,
238
+ config.storeId,
239
+ config.previewKey,
240
+ );
241
+
242
+ if (!result.ok) {
243
+ console.error(`Error: ${result.error}`);
244
+ process.exit(1);
245
+ }
246
+
247
+ console.log("Preview eliminado.");
248
+ console.log("");
249
+
250
+ // Remove preview key from config
251
+ const { previewKey, ...rest } = config;
252
+ await writeConfig(rest);
253
+ };
254
+
255
+ export const previewOpen = async () => {
256
+ const { config } = await loadConfigOrFail();
257
+
258
+ if (!config.previewKey) {
259
+ console.error("No hay preview activo. Creá uno con: tiendu preview create");
260
+ process.exit(1);
261
+ }
262
+
263
+ // Find the preview to get its hostname
264
+ const { credentials } = await loadConfigOrFail();
265
+ const result = await listPreviews(
266
+ config.apiBaseUrl,
267
+ credentials.apiKey,
268
+ config.storeId,
269
+ );
270
+
271
+ if (!result.ok) {
272
+ console.error(`Error: ${result.error}`);
273
+ process.exit(1);
274
+ }
275
+
276
+ const preview = result.data.find((p) => p.previewKey === config.previewKey);
277
+ if (!preview) {
278
+ console.error("El preview activo ya no existe en el servidor.");
279
+ process.exit(1);
280
+ }
281
+
282
+ const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
283
+ console.log(`Abriendo ${url}...`);
284
+
285
+ // Open URL in browser
286
+ const { exec } = await import("node:child_process");
287
+ const platform = process.platform;
288
+ const cmd =
289
+ platform === "darwin"
290
+ ? "open"
291
+ : platform === "win32"
292
+ ? "start"
293
+ : "xdg-open";
294
+ exec(`${cmd} ${url}`);
295
+ };
@@ -0,0 +1,34 @@
1
+ import { loadConfigOrFail, writeConfig } from "./config.mjs";
2
+ import { publishPreview } from "./preview.mjs";
3
+
4
+ export const publish = async () => {
5
+ const { config, credentials } = await loadConfigOrFail();
6
+
7
+ if (!config.previewKey) {
8
+ console.error("No hay preview activo. Creá uno con: tiendu preview create");
9
+ process.exit(1);
10
+ }
11
+
12
+ console.log("");
13
+ console.log(`Publicando preview ${config.previewKey} al storefront live...`);
14
+
15
+ const result = await publishPreview(
16
+ config.apiBaseUrl,
17
+ credentials.apiKey,
18
+ config.storeId,
19
+ config.previewKey,
20
+ );
21
+
22
+ if (!result.ok) {
23
+ console.error(`Error: ${result.error}`);
24
+ process.exit(1);
25
+ }
26
+
27
+ console.log("Preview publicado. El storefront live fue actualizado.");
28
+ console.log("Todos los previews de esta tienda fueron eliminados.");
29
+ console.log("");
30
+
31
+ // Remove preview key from config
32
+ const { previewKey, ...rest } = config;
33
+ await writeConfig(rest);
34
+ };
package/lib/pull.mjs ADDED
@@ -0,0 +1,41 @@
1
+ import { loadConfigOrFail } from "./config.mjs";
2
+ import { downloadStorefrontArchive } from "./api.mjs";
3
+ import { extractZip } from "./zip.mjs";
4
+
5
+ export const pull = async () => {
6
+ const { config, credentials } = await loadConfigOrFail();
7
+
8
+ console.log("");
9
+ console.log(`Descargando tema de tienda #${config.storeId}...`);
10
+
11
+ const result = await downloadStorefrontArchive(
12
+ config.apiBaseUrl,
13
+ credentials.apiKey,
14
+ config.storeId,
15
+ );
16
+
17
+ if (!result.ok) {
18
+ console.error(`Error: ${result.error}`);
19
+ process.exit(1);
20
+ }
21
+
22
+ console.log(`Archivo ZIP recibido (${formatBytes(result.data.length)})`);
23
+ console.log("Extrayendo archivos...");
24
+
25
+ const outputDir = process.cwd();
26
+ const extractedFiles = await extractZip(result.data, outputDir);
27
+
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`;
41
+ };
package/lib/push.mjs ADDED
@@ -0,0 +1,95 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { zipSync } from "fflate";
4
+ import { loadConfigOrFail } from "./config.mjs";
5
+ import { uploadPreviewZip } from "./api.mjs";
6
+
7
+ const isDotfile = (name) => name.startsWith(".");
8
+
9
+ /**
10
+ * Recursively list all files, skipping dotfiles/dotdirs.
11
+ * @param {string} rootDir
12
+ * @param {string} currentDir
13
+ * @returns {Promise<string[]>}
14
+ */
15
+ const listAllFiles = async (rootDir, currentDir) => {
16
+ const entries = await readdir(currentDir, { withFileTypes: true });
17
+ const files = [];
18
+
19
+ for (const entry of entries) {
20
+ if (isDotfile(entry.name)) continue;
21
+
22
+ const absolutePath = path.join(currentDir, entry.name);
23
+
24
+ if (entry.isDirectory()) {
25
+ const nested = await listAllFiles(rootDir, absolutePath);
26
+ files.push(...nested);
27
+ } else if (entry.isFile()) {
28
+ files.push(absolutePath);
29
+ }
30
+ }
31
+
32
+ return files;
33
+ };
34
+
35
+ /**
36
+ * Create a zip buffer from the current directory, skipping dotfiles.
37
+ * @param {string} rootDir
38
+ * @returns {Promise<Buffer>}
39
+ */
40
+ const createZipFromDirectory = async (rootDir) => {
41
+ const absoluteFiles = await listAllFiles(rootDir, rootDir);
42
+ /** @type {Record<string, Uint8Array>} */
43
+ 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);
52
+ }
53
+
54
+ return Buffer.from(zipSync(entries, { level: 6 }));
55
+ };
56
+
57
+ export const push = async () => {
58
+ const { config, credentials } = await loadConfigOrFail();
59
+
60
+ if (!config.previewKey) {
61
+ console.error("No hay preview activo. Creá uno con: tiendu preview create");
62
+ process.exit(1);
63
+ }
64
+
65
+ const rootDir = process.cwd();
66
+
67
+ console.log("");
68
+ console.log(`Subiendo archivos al preview ${config.previewKey}...`);
69
+
70
+ const zipBuffer = await createZipFromDirectory(rootDir);
71
+ console.log(`ZIP creado (${formatBytes(zipBuffer.length)})`);
72
+
73
+ const result = await uploadPreviewZip(
74
+ config.apiBaseUrl,
75
+ credentials.apiKey,
76
+ config.storeId,
77
+ config.previewKey,
78
+ zipBuffer,
79
+ );
80
+
81
+ if (!result.ok) {
82
+ console.error(`Error: ${result.error}`);
83
+ process.exit(1);
84
+ }
85
+
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`;
95
+ };
package/lib/zip.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { unzipSync } from "fflate";
4
+
5
+ /**
6
+ * Extract a zip buffer into the given output directory.
7
+ * Returns an array of extracted file paths (relative).
8
+ *
9
+ * @param {Buffer} zipBuffer
10
+ * @param {string} outputDir
11
+ * @returns {Promise<string[]>}
12
+ */
13
+ export const extractZip = async (zipBuffer, outputDir) => {
14
+ const archiveEntries = unzipSync(new Uint8Array(zipBuffer));
15
+ const extractedFiles = [];
16
+ const resolvedOutputDir = path.resolve(outputDir);
17
+
18
+ for (const [relativePath, fileContent] of Object.entries(archiveEntries)) {
19
+ if (!relativePath || relativePath.endsWith("/")) continue;
20
+
21
+ const outputPath = path.join(outputDir, relativePath);
22
+ const resolvedPath = path.resolve(outputPath);
23
+ if (
24
+ !resolvedPath.startsWith(`${resolvedOutputDir}${path.sep}`) &&
25
+ resolvedPath !== resolvedOutputDir
26
+ ) {
27
+ continue;
28
+ }
29
+
30
+ await mkdir(path.dirname(outputPath), { recursive: true });
31
+ await writeFile(outputPath, fileContent);
32
+ extractedFiles.push(relativePath);
33
+ }
34
+
35
+ return extractedFiles.sort((left, right) => left.localeCompare(right));
36
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "tiendu",
3
+ "version": "0.1.1",
4
+ "description": "CLI para desarrollar y publicar temas en Tiendu",
5
+ "type": "module",
6
+ "bin": {
7
+ "tiendu": "./bin/tiendu.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "LICENSE",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "dev": "node bin/tiendu.mjs"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "keywords": [
22
+ "tiendu",
23
+ "ecommerce",
24
+ "theme",
25
+ "storefront",
26
+ "cli"
27
+ ],
28
+ "author": "Tiendu",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/squiel91/tiendu-cli"
33
+ },
34
+ "homepage": "https://tiendu.uy",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "fflate": "^0.8.2"
40
+ }
41
+ }