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.
package/lib/api.mjs ADDED
@@ -0,0 +1,316 @@
1
+ /**
2
+ * @param {string} apiBaseUrl
3
+ * @param {string} apiKey
4
+ * @param {string} path
5
+ * @param {{ method?: string, body?: string | Buffer | Uint8Array, contentType?: string }} [options]
6
+ * @returns {Promise<Response>}
7
+ */
8
+ export const apiFetch = (apiBaseUrl, apiKey, path, options = {}) => {
9
+ const url = `${apiBaseUrl}${path}`;
10
+ /** @type {Record<string, string>} */
11
+ const headers = {
12
+ Authorization: `Bearer ${apiKey}`,
13
+ };
14
+
15
+ if (options.body && typeof options.body === "string") {
16
+ headers["Content-Type"] = options.contentType ?? "application/json";
17
+ } else if (options.contentType) {
18
+ headers["Content-Type"] = options.contentType;
19
+ }
20
+
21
+ return fetch(url, {
22
+ method: options.method ?? "GET",
23
+ headers,
24
+ body: options.body,
25
+ });
26
+ };
27
+
28
+ /**
29
+ * @param {Response} response
30
+ * @returns {{ ok: false, error: string } | null}
31
+ */
32
+ const checkAuthErrors = (response) => {
33
+ if (response.status === 401) {
34
+ return { ok: false, error: "API Key inválida o sin permisos." };
35
+ }
36
+ if (response.status === 403) {
37
+ return {
38
+ ok: false,
39
+ error: "No tenés acceso a esta tienda con esta API Key.",
40
+ };
41
+ }
42
+ return null;
43
+ };
44
+
45
+ /**
46
+ * Validate API key and store access with a HEAD request to the download endpoint.
47
+ *
48
+ * @param {string} apiBaseUrl
49
+ * @param {string} apiKey
50
+ * @param {number} storeId
51
+ * @returns {Promise<{ ok: true, data: { name: string } } | { ok: false, error: string }>}
52
+ */
53
+ export const fetchStoreInfo = async (apiBaseUrl, apiKey, storeId) => {
54
+ try {
55
+ const response = await apiFetch(
56
+ apiBaseUrl,
57
+ apiKey,
58
+ `/api/admin/stores/${storeId}/code/download`,
59
+ { method: "HEAD" },
60
+ );
61
+
62
+ const authError = checkAuthErrors(response);
63
+ if (authError) return authError;
64
+
65
+ if (!response.ok) {
66
+ return {
67
+ ok: false,
68
+ error: `Error del servidor: ${response.status} ${response.statusText}`,
69
+ };
70
+ }
71
+
72
+ return { ok: true, data: { name: `Tienda #${storeId}` } };
73
+ } catch (error) {
74
+ return {
75
+ ok: false,
76
+ error: `No se pudo conectar a ${apiBaseUrl}: ${error.message}`,
77
+ };
78
+ }
79
+ };
80
+
81
+ /**
82
+ * Download the storefront archive (zip) as a buffer.
83
+ *
84
+ * @param {string} apiBaseUrl
85
+ * @param {string} apiKey
86
+ * @param {number} storeId
87
+ * @returns {Promise<{ ok: true, data: Buffer } | { ok: false, error: string }>}
88
+ */
89
+ export const downloadStorefrontArchive = async (
90
+ apiBaseUrl,
91
+ apiKey,
92
+ storeId,
93
+ ) => {
94
+ try {
95
+ const response = await apiFetch(
96
+ apiBaseUrl,
97
+ apiKey,
98
+ `/api/admin/stores/${storeId}/code/download`,
99
+ );
100
+
101
+ const authError = checkAuthErrors(response);
102
+ if (authError) return authError;
103
+
104
+ if (!response.ok) {
105
+ const body = await response.text().catch(() => "");
106
+ return {
107
+ ok: false,
108
+ error: `Error del servidor: ${response.status} ${response.statusText}${body ? ` — ${body}` : ""}`,
109
+ };
110
+ }
111
+
112
+ const arrayBuffer = await response.arrayBuffer();
113
+ return { ok: true, data: Buffer.from(arrayBuffer) };
114
+ } catch (error) {
115
+ return {
116
+ ok: false,
117
+ error: `No se pudo descargar: ${error.message}`,
118
+ };
119
+ }
120
+ };
121
+
122
+ /**
123
+ * Upload a zip buffer to a preview, replacing its content.
124
+ *
125
+ * @param {string} apiBaseUrl
126
+ * @param {string} apiKey
127
+ * @param {number} storeId
128
+ * @param {string} previewKey
129
+ * @param {Buffer} zipBuffer
130
+ * @returns {Promise<{ ok: true } | { ok: false, error: string }>}
131
+ */
132
+ export const uploadPreviewZip = async (
133
+ apiBaseUrl,
134
+ apiKey,
135
+ storeId,
136
+ previewKey,
137
+ zipBuffer,
138
+ ) => {
139
+ try {
140
+ const response = await apiFetch(
141
+ apiBaseUrl,
142
+ apiKey,
143
+ `/api/admin/stores/${storeId}/theme-previews/${previewKey}/upload`,
144
+ {
145
+ method: "POST",
146
+ body: zipBuffer,
147
+ contentType: "application/zip",
148
+ },
149
+ );
150
+
151
+ if (!response.ok) {
152
+ const body = await response.text().catch(() => "");
153
+ return {
154
+ ok: false,
155
+ error: `Error del servidor: ${response.status}${body ? ` — ${body}` : ""}`,
156
+ };
157
+ }
158
+
159
+ return { ok: true };
160
+ } catch (error) {
161
+ return { ok: false, error: `No se pudo subir: ${error.message}` };
162
+ }
163
+ };
164
+
165
+ /**
166
+ * Upload a single file to a preview.
167
+ *
168
+ * @param {string} apiBaseUrl
169
+ * @param {string} apiKey
170
+ * @param {number} storeId
171
+ * @param {string} previewKey
172
+ * @param {string} filePath - relative path within the preview
173
+ * @param {string} content - file content
174
+ * @returns {Promise<{ ok: true } | { ok: false, error: string }>}
175
+ */
176
+ export const uploadPreviewFile = async (
177
+ apiBaseUrl,
178
+ apiKey,
179
+ storeId,
180
+ previewKey,
181
+ filePath,
182
+ content,
183
+ ) => {
184
+ try {
185
+ const query = new URLSearchParams({ path: filePath }).toString();
186
+ const response = await apiFetch(
187
+ apiBaseUrl,
188
+ apiKey,
189
+ `/api/v2/stores/${storeId}/theme-previews/${previewKey}/file?${query}`,
190
+ {
191
+ method: "PUT",
192
+ body: JSON.stringify({ content }),
193
+ },
194
+ );
195
+
196
+ if (!response.ok) {
197
+ const body = await response.text().catch(() => "");
198
+ return {
199
+ ok: false,
200
+ error: `Error subiendo ${filePath}: ${response.status}${body ? ` — ${body}` : ""}`,
201
+ };
202
+ }
203
+
204
+ return { ok: true };
205
+ } catch (error) {
206
+ return {
207
+ ok: false,
208
+ error: `Error subiendo ${filePath}: ${error.message}`,
209
+ };
210
+ }
211
+ };
212
+
213
+ /**
214
+ * Upload a single file to a preview using multipart form data.
215
+ * Works for both text and binary files.
216
+ *
217
+ * @param {string} apiBaseUrl
218
+ * @param {string} apiKey
219
+ * @param {number} storeId
220
+ * @param {string} previewKey
221
+ * @param {string} relativePath
222
+ * @param {Buffer} fileBuffer
223
+ * @returns {Promise<{ ok: true } | { ok: false, error: string }>}
224
+ */
225
+ export const uploadPreviewFileMultipart = async (
226
+ apiBaseUrl,
227
+ apiKey,
228
+ storeId,
229
+ previewKey,
230
+ relativePath,
231
+ fileBuffer,
232
+ ) => {
233
+ try {
234
+ const posixPath = relativePath.replaceAll("\\", "/");
235
+ const lastSlashIndex = posixPath.lastIndexOf("/");
236
+ const directory =
237
+ lastSlashIndex === -1 ? "" : posixPath.slice(0, lastSlashIndex);
238
+ const fileName =
239
+ lastSlashIndex === -1 ? posixPath : posixPath.slice(lastSlashIndex + 1);
240
+
241
+ const formData = new FormData();
242
+ formData.set("directory", directory);
243
+ formData.append("files", new File([new Uint8Array(fileBuffer)], fileName));
244
+
245
+ const response = await fetch(
246
+ `${apiBaseUrl}/api/admin/stores/${storeId}/theme-previews/${previewKey}/upload`,
247
+ {
248
+ method: "POST",
249
+ headers: {
250
+ Authorization: `Bearer ${apiKey}`,
251
+ },
252
+ body: formData,
253
+ },
254
+ );
255
+
256
+ if (!response.ok) {
257
+ const body = await response.text().catch(() => "");
258
+ return {
259
+ ok: false,
260
+ error: `Error subiendo ${relativePath}: ${response.status}${body ? ` — ${body}` : ""}`,
261
+ };
262
+ }
263
+
264
+ return { ok: true };
265
+ } catch (error) {
266
+ return {
267
+ ok: false,
268
+ error: `Error subiendo ${relativePath}: ${error.message}`,
269
+ };
270
+ }
271
+ };
272
+
273
+ /**
274
+ * Delete a file from a preview.
275
+ *
276
+ * @param {string} apiBaseUrl
277
+ * @param {string} apiKey
278
+ * @param {number} storeId
279
+ * @param {string} previewKey
280
+ * @param {string} filePath
281
+ * @returns {Promise<{ ok: true } | { ok: false, error: string }>}
282
+ */
283
+ export const deletePreviewFile = async (
284
+ apiBaseUrl,
285
+ apiKey,
286
+ storeId,
287
+ previewKey,
288
+ filePath,
289
+ ) => {
290
+ try {
291
+ const query = new URLSearchParams({ path: filePath }).toString();
292
+ const response = await apiFetch(
293
+ apiBaseUrl,
294
+ apiKey,
295
+ `/api/v2/stores/${storeId}/theme-previews/${previewKey}/file?${query}`,
296
+ {
297
+ method: "DELETE",
298
+ },
299
+ );
300
+
301
+ if (!response.ok) {
302
+ const body = await response.text().catch(() => "");
303
+ return {
304
+ ok: false,
305
+ error: `Error eliminando ${filePath}: ${response.status}${body ? ` — ${body}` : ""}`,
306
+ };
307
+ }
308
+
309
+ return { ok: true };
310
+ } catch (error) {
311
+ return {
312
+ ok: false,
313
+ error: `Error eliminando ${filePath}: ${error.message}`,
314
+ };
315
+ }
316
+ };
package/lib/config.mjs ADDED
@@ -0,0 +1,76 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const CONFIG_DIR = ".cli";
5
+ const CONFIG_FILE = "config.json";
6
+ const CREDENTIALS_FILE = "credentials.json";
7
+
8
+ /**
9
+ * @typedef {{ storeId: number, apiBaseUrl: string, previewKey?: string }} TienduConfig
10
+ * @typedef {{ apiKey: string }} TienduCredentials
11
+ */
12
+
13
+ const getConfigDir = () => path.resolve(process.cwd(), CONFIG_DIR);
14
+ const getConfigPath = () => path.join(getConfigDir(), CONFIG_FILE);
15
+ const getCredentialsPath = () => path.join(getConfigDir(), CREDENTIALS_FILE);
16
+
17
+ /** @returns {Promise<TienduConfig | null>} */
18
+ export const readConfig = async () => {
19
+ try {
20
+ const raw = await readFile(getConfigPath(), "utf-8");
21
+ return JSON.parse(raw);
22
+ } catch {
23
+ return null;
24
+ }
25
+ };
26
+
27
+ /** @param {TienduConfig} config */
28
+ export const writeConfig = async (config) => {
29
+ await mkdir(getConfigDir(), { recursive: true });
30
+ await writeFile(
31
+ getConfigPath(),
32
+ JSON.stringify(config, null, "\t") + "\n",
33
+ "utf-8",
34
+ );
35
+ };
36
+
37
+ /** @returns {Promise<TienduCredentials | null>} */
38
+ export const readCredentials = async () => {
39
+ try {
40
+ const raw = await readFile(getCredentialsPath(), "utf-8");
41
+ return JSON.parse(raw);
42
+ } catch {
43
+ return null;
44
+ }
45
+ };
46
+
47
+ /** @param {TienduCredentials} credentials */
48
+ export const writeCredentials = async (credentials) => {
49
+ await mkdir(getConfigDir(), { recursive: true });
50
+ await writeFile(
51
+ getCredentialsPath(),
52
+ JSON.stringify(credentials, null, "\t") + "\n",
53
+ "utf-8",
54
+ );
55
+ };
56
+
57
+ /**
58
+ * @returns {Promise<{ config: TienduConfig, credentials: TienduCredentials }>}
59
+ */
60
+ export const loadConfigOrFail = async () => {
61
+ const config = await readConfig();
62
+ if (!config) {
63
+ console.error("No se encontró .cli/config.json");
64
+ console.error('Ejecutá "tiendu init" primero.');
65
+ process.exit(1);
66
+ }
67
+
68
+ const credentials = await readCredentials();
69
+ if (!credentials) {
70
+ console.error("No se encontró .cli/credentials.json");
71
+ console.error('Ejecutá "tiendu init" primero.');
72
+ process.exit(1);
73
+ }
74
+
75
+ return { config, credentials };
76
+ };
package/lib/dev.mjs ADDED
@@ -0,0 +1,222 @@
1
+ import { watch } from "node:fs";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { loadConfigOrFail, writeConfig } from "./config.mjs";
5
+ import { createPreview, listPreviews } from "./preview.mjs";
6
+ import {
7
+ uploadPreviewFileMultipart,
8
+ deletePreviewFile,
9
+ uploadPreviewZip,
10
+ } from "./api.mjs";
11
+ import { readdir } from "node:fs/promises";
12
+ import { zipSync } from "fflate";
13
+
14
+ const isDotfile = (name) => name.startsWith(".");
15
+ const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
16
+ const base = new URL(apiBaseUrl);
17
+ const hasExplicitPort = previewHostname.includes(":");
18
+ return `${base.protocol}//${previewHostname}${!hasExplicitPort && base.port ? `:${base.port}` : ""}/`;
19
+ };
20
+
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
+ };
30
+
31
+ /**
32
+ * Recursively list all files, skipping dotfiles.
33
+ * @param {string} rootDir
34
+ * @param {string} currentDir
35
+ * @returns {Promise<string[]>}
36
+ */
37
+ const listAllFiles = async (rootDir, currentDir) => {
38
+ const entries = await readdir(currentDir, { withFileTypes: true });
39
+ const files = [];
40
+ for (const entry of entries) {
41
+ if (isDotfile(entry.name)) continue;
42
+ const abs = path.join(currentDir, entry.name);
43
+ if (entry.isDirectory()) {
44
+ const nested = await listAllFiles(rootDir, abs);
45
+ files.push(...nested);
46
+ } else if (entry.isFile()) {
47
+ files.push(abs);
48
+ }
49
+ }
50
+ return files;
51
+ };
52
+
53
+ /**
54
+ * Create a zip buffer from the current directory, skipping dotfiles.
55
+ * @param {string} rootDir
56
+ * @returns {Promise<Buffer>}
57
+ */
58
+ const createZipFromDirectory = async (rootDir) => {
59
+ const absoluteFiles = await listAllFiles(rootDir, rootDir);
60
+ /** @type {Record<string, Uint8Array>} */
61
+ const entries = {};
62
+ for (const abs of absoluteFiles) {
63
+ const rel = path.relative(rootDir, abs).split(path.sep).join("/");
64
+ const buf = await readFile(abs);
65
+ entries[rel] = new Uint8Array(buf);
66
+ }
67
+ return Buffer.from(zipSync(entries, { level: 6 }));
68
+ };
69
+
70
+ export const dev = async () => {
71
+ const { config, credentials } = await loadConfigOrFail();
72
+ const { apiBaseUrl, storeId } = config;
73
+ const { apiKey } = credentials;
74
+ const rootDir = process.cwd();
75
+
76
+ let previewKey = config.previewKey;
77
+
78
+ // Ensure a preview exists
79
+ if (!previewKey) {
80
+ console.log("");
81
+ console.log("No hay preview activo. Creando uno...");
82
+
83
+ const result = await createPreview(apiBaseUrl, apiKey, storeId, "Dev");
84
+ if (!result.ok) {
85
+ console.error(`Error: ${result.error}`);
86
+ process.exit(1);
87
+ }
88
+
89
+ previewKey = result.data.previewKey;
90
+ await writeConfig({ ...config, previewKey });
91
+
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...");
99
+ const zipBuffer = await createZipFromDirectory(rootDir);
100
+ const uploadResult = await uploadPreviewZip(
101
+ apiBaseUrl,
102
+ apiKey,
103
+ storeId,
104
+ previewKey,
105
+ zipBuffer,
106
+ );
107
+ if (!uploadResult.ok) {
108
+ console.error(`Error subiendo archivos: ${uploadResult.error}`);
109
+ process.exit(1);
110
+ }
111
+ console.log("Archivos subidos.");
112
+ } else {
113
+ // Verify the preview still exists
114
+ const listResult = await listPreviews(apiBaseUrl, apiKey, storeId);
115
+ if (!listResult.ok) {
116
+ console.error(`Error: ${listResult.error}`);
117
+ process.exit(1);
118
+ }
119
+ const existing = listResult.data.find((p) => p.previewKey === previewKey);
120
+ if (!existing) {
121
+ console.error(
122
+ `El preview ${previewKey} ya no existe. Eliminá la config con: tiendu preview delete`,
123
+ );
124
+ process.exit(1);
125
+ }
126
+ console.log("");
127
+ console.log(`Preview activo: ${previewKey}`);
128
+ console.log(
129
+ `URL: ${buildPreviewUrl(apiBaseUrl, existing.previewHostname)}`,
130
+ );
131
+ }
132
+
133
+ console.log("");
134
+ console.log("Observando cambios... (Ctrl+C para salir)");
135
+ console.log("");
136
+
137
+ // Debounce map: relativePath -> timeout
138
+ /** @type {Map<string, NodeJS.Timeout>} */
139
+ const debounceMap = new Map();
140
+ const DEBOUNCE_MS = 300;
141
+
142
+ const watcher = watch(rootDir, { recursive: true }, (eventType, filename) => {
143
+ if (!filename) return;
144
+
145
+ // Skip dotfiles
146
+ if (hasDotfileSegment(filename)) return;
147
+
148
+ // Normalize to posix path
149
+ const relativePath = filename.split(path.sep).join("/");
150
+
151
+ // Clear existing debounce timer
152
+ const existing = debounceMap.get(relativePath);
153
+ if (existing) clearTimeout(existing);
154
+
155
+ // Set new debounce timer
156
+ const timer = setTimeout(async () => {
157
+ debounceMap.delete(relativePath);
158
+
159
+ const absolutePath = path.join(rootDir, filename);
160
+
161
+ try {
162
+ const fileStat = await stat(absolutePath).catch(() => null);
163
+
164
+ if (!fileStat || !fileStat.isFile()) {
165
+ // File was deleted or is a directory
166
+ if (!fileStat) {
167
+ console.log(` ✕ ${relativePath}`);
168
+ const result = await deletePreviewFile(
169
+ apiBaseUrl,
170
+ apiKey,
171
+ storeId,
172
+ previewKey,
173
+ relativePath,
174
+ );
175
+ if (!result.ok) {
176
+ console.error(` Error: ${result.error}`);
177
+ }
178
+ }
179
+ return;
180
+ }
181
+
182
+ // File was created or modified
183
+ const content = await readFile(absolutePath);
184
+ console.log(` ↑ ${relativePath}`);
185
+
186
+ const result = await uploadPreviewFileMultipart(
187
+ apiBaseUrl,
188
+ apiKey,
189
+ storeId,
190
+ previewKey,
191
+ relativePath,
192
+ content,
193
+ );
194
+
195
+ if (!result.ok) {
196
+ console.error(` Error: ${result.error}`);
197
+ }
198
+ } catch (error) {
199
+ console.error(` Error procesando ${relativePath}: ${error.message}`);
200
+ }
201
+ }, DEBOUNCE_MS);
202
+
203
+ debounceMap.set(relativePath, timer);
204
+ });
205
+
206
+ // Handle graceful shutdown
207
+ const cleanup = () => {
208
+ watcher.close();
209
+ for (const timer of debounceMap.values()) {
210
+ clearTimeout(timer);
211
+ }
212
+ console.log("");
213
+ console.log("Dev mode finalizado.");
214
+ process.exit(0);
215
+ };
216
+
217
+ process.on("SIGINT", cleanup);
218
+ process.on("SIGTERM", cleanup);
219
+
220
+ // Keep process alive
221
+ await new Promise(() => {});
222
+ };
package/lib/init.mjs ADDED
@@ -0,0 +1,92 @@
1
+ import * as readline from "node:readline/promises";
2
+ import { stdin, stdout } from "node:process";
3
+ import {
4
+ readConfig,
5
+ readCredentials,
6
+ writeConfig,
7
+ writeCredentials,
8
+ } from "./config.mjs";
9
+ import { fetchStoreInfo } from "./api.mjs";
10
+
11
+ export const init = async () => {
12
+ const existingConfig = await readConfig();
13
+ const existingCredentials = await readCredentials();
14
+
15
+ const rl = readline.createInterface({ input: stdin, output: stdout });
16
+
17
+ try {
18
+ console.log("");
19
+ console.log("Tiendu CLI — Inicialización");
20
+ console.log("===========================");
21
+ console.log("");
22
+
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;
30
+
31
+ if (!apiKey) {
32
+ console.error("La API Key es requerida.");
33
+ process.exit(1);
34
+ }
35
+
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
+ }
56
+
57
+ // Validate credentials against the server
58
+ console.log("");
59
+ console.log("Verificando credenciales...");
60
+
61
+ const storeInfo = await fetchStoreInfo(apiBaseUrl, apiKey, storeId);
62
+ if (!storeInfo.ok) {
63
+ console.error(`Error: ${storeInfo.error}`);
64
+ process.exit(1);
65
+ }
66
+
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
+ } finally {
79
+ rl.close();
80
+ }
81
+ };
82
+
83
+ /** @param {string} key */
84
+ const maskApiKey = (key) => {
85
+ if (key.length <= 8) return "****";
86
+ return key.slice(0, 4) + "..." + key.slice(-4);
87
+ };
88
+
89
+ /** @param {string} url */
90
+ const normalizeBaseUrl = (url) => {
91
+ return url.endsWith("/") ? url.slice(0, -1) : url;
92
+ };