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/LICENSE +21 -0
- package/README.md +194 -0
- package/bin/tiendu.js +111 -0
- package/bin/tiendu.mjs +111 -0
- package/lib/api.mjs +316 -0
- package/lib/config.mjs +76 -0
- package/lib/dev.mjs +222 -0
- package/lib/init.mjs +92 -0
- package/lib/preview.mjs +295 -0
- package/lib/publish.mjs +34 -0
- package/lib/pull.mjs +41 -0
- package/lib/push.mjs +95 -0
- package/lib/zip.mjs +36 -0
- package/package.json +41 -0
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
|
+
};
|