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 +30 -26
- package/lib/api.mjs +10 -11
- package/lib/config.mjs +4 -4
- package/lib/dev.mjs +45 -77
- package/lib/init.mjs +140 -75
- package/lib/preview.mjs +64 -73
- package/lib/publish.mjs +17 -7
- package/lib/pull.mjs +20 -16
- package/lib/push.mjs +24 -33
- package/lib/update-check.mjs +144 -0
- package/package.json +2 -1
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 —
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
tiendu init
|
|
20
|
-
tiendu pull
|
|
21
|
-
tiendu push
|
|
22
|
-
tiendu dev
|
|
23
|
-
tiendu publish
|
|
24
|
-
|
|
25
|
-
tiendu preview create
|
|
26
|
-
tiendu preview list
|
|
27
|
-
tiendu preview delete
|
|
28
|
-
tiendu preview open
|
|
29
|
-
|
|
30
|
-
tiendu help
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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(`
|
|
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(`
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
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
|
-
|
|
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("
|
|
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(
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
+
spinner.stop("Failed to upload files.", 1);
|
|
88
|
+
p.log.error(uploadResult.error);
|
|
109
89
|
process.exit(1);
|
|
110
90
|
}
|
|
111
|
-
|
|
91
|
+
|
|
92
|
+
spinner.stop(`Preview ready: ${previewUrl}`);
|
|
112
93
|
} else {
|
|
113
|
-
// Verify
|
|
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
|
-
|
|
100
|
+
spinner.stop("Failed to connect.", 1);
|
|
101
|
+
p.log.error(listResult.error);
|
|
117
102
|
process.exit(1);
|
|
118
103
|
}
|
|
119
|
-
|
|
104
|
+
|
|
105
|
+
const existing = listResult.data.find((pr) => pr.previewKey === previewKey);
|
|
120
106
|
if (!existing) {
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
`URL: ${buildPreviewUrl(apiBaseUrl, existing.previewHostname)}`,
|
|
130
|
-
);
|
|
113
|
+
|
|
114
|
+
previewUrl = buildPreviewUrl(apiBaseUrl, existing.previewHostname);
|
|
115
|
+
spinner.stop(`Preview: ${previewUrl}`);
|
|
131
116
|
}
|
|
132
117
|
|
|
133
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
151
|
+
p.log.warn(` Failed to delete: ${result.error}`);
|
|
177
152
|
}
|
|
178
153
|
}
|
|
179
154
|
return;
|
|
180
155
|
}
|
|
181
156
|
|
|
182
|
-
|
|
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
|
-
|
|
169
|
+
p.log.warn(` Failed to upload: ${result.error}`);
|
|
197
170
|
}
|
|
198
171
|
} catch (error) {
|
|
199
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
import
|
|
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 {
|
|
10
|
+
import { fetchUserStores } from "./api.mjs";
|
|
10
11
|
|
|
11
|
-
|
|
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
|
-
|
|
47
|
+
p.intro("Tiendu CLI — Setup");
|
|
16
48
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
console.log("Tiendu CLI — Inicialización");
|
|
20
|
-
console.log("===========================");
|
|
21
|
-
console.log("");
|
|
49
|
+
// ─── API Key ──────────────────────────────────────────────────────────────
|
|
50
|
+
const apiKeyDefault = existingCredentials?.apiKey ?? "";
|
|
22
51
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
61
|
+
if (p.isCancel(apiKeyInput)) {
|
|
62
|
+
p.cancel("Setup cancelled.");
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
35
65
|
|
|
36
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
console.log("Verificando credenciales...");
|
|
68
|
+
// ─── API Base URL ─────────────────────────────────────────────────────────
|
|
69
|
+
const baseUrlDefault = existingConfig?.apiBaseUrl ?? "https://tiendu.uy";
|
|
60
70
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return key.slice(0, 4) + "..." + key.slice(-4);
|
|
92
|
-
};
|
|
143
|
+
// ─── Save ─────────────────────────────────────────────────────────────────
|
|
144
|
+
await writeConfig({ storeId, apiBaseUrl });
|
|
145
|
+
await writeCredentials({ apiKey });
|
|
93
146
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
"
|
|
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: `
|
|
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: `
|
|
67
|
+
return { ok: false, error: `Server error: ${response.status}` };
|
|
71
68
|
}
|
|
72
|
-
|
|
73
69
|
const body = await response.json();
|
|
74
|
-
|
|
75
|
-
return { ok: true, data: previews };
|
|
70
|
+
return { ok: true, data: body?.previews ?? [] };
|
|
76
71
|
} catch (error) {
|
|
77
|
-
return { ok: false, error: `
|
|
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: `
|
|
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: `
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
210
|
+
p.log.error("No active preview. Create one with: tiendu preview create");
|
|
229
211
|
process.exit(1);
|
|
230
212
|
}
|
|
231
213
|
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
234
|
+
spinner.stop("Failed to delete preview.", 1);
|
|
235
|
+
p.log.error(result.error);
|
|
244
236
|
process.exit(1);
|
|
245
237
|
}
|
|
246
238
|
|
|
247
|
-
|
|
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
|
-
|
|
249
|
+
p.log.error("No active preview. Create one with: tiendu preview create");
|
|
260
250
|
process.exit(1);
|
|
261
251
|
}
|
|
262
252
|
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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((
|
|
268
|
+
const preview = result.data.find((pr) => pr.previewKey === config.previewKey);
|
|
277
269
|
if (!preview) {
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
+
p.log.error("No active preview. Create one with: tiendu preview create");
|
|
9
10
|
process.exit(1);
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
33
|
+
spinner.stop("Publish failed.", 1);
|
|
34
|
+
p.log.error(result.error);
|
|
24
35
|
process.exit(1);
|
|
25
36
|
}
|
|
26
37
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
26
|
+
spinner.stop("Download failed.", 1);
|
|
27
|
+
p.log.error(result.error);
|
|
19
28
|
process.exit(1);
|
|
20
29
|
}
|
|
21
30
|
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
29
|
-
|
|
38
|
+
p.log.success(
|
|
39
|
+
`${extractedFiles.length} file${extractedFiles.length === 1 ? "" : "s"} extracted.`,
|
|
40
|
+
);
|
|
41
|
+
|
|
30
42
|
for (const file of extractedFiles) {
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
26
|
-
files.push(...nested);
|
|
30
|
+
files.push(...(await listAllFiles(rootDir, abs)));
|
|
27
31
|
} else if (entry.isFile()) {
|
|
28
|
-
files.push(
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
+
spinner.stop("Upload failed.", 1);
|
|
81
|
+
p.log.error(result.error);
|
|
83
82
|
process.exit(1);
|
|
84
83
|
}
|
|
85
84
|
|
|
86
|
-
|
|
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.
|
|
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
|
}
|