tiendu 0.1.3 → 0.2.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/bin/tiendu.js +30 -26
- package/lib/config.mjs +4 -4
- package/lib/dev.mjs +57 -78
- package/lib/init.mjs +61 -35
- package/lib/preview.mjs +108 -78
- package/lib/publish.mjs +17 -7
- package/lib/pull.mjs +18 -18
- package/lib/push.mjs +24 -33
- package/lib/update-check.mjs +144 -0
- package/package.json +1 -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/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,31 @@
|
|
|
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
|
-
import { createPreview, listPreviews } from "./preview.mjs";
|
|
6
7
|
import {
|
|
7
|
-
|
|
8
|
+
createPreview,
|
|
9
|
+
listPreviews,
|
|
10
|
+
resolveActivePreview,
|
|
11
|
+
} from "./preview.mjs";
|
|
12
|
+
import {
|
|
8
13
|
deletePreviewFile,
|
|
14
|
+
uploadPreviewFileMultipart,
|
|
9
15
|
uploadPreviewZip,
|
|
10
16
|
} from "./api.mjs";
|
|
11
|
-
import { readdir } from "node:fs/promises";
|
|
12
|
-
import { zipSync } from "fflate";
|
|
13
17
|
|
|
14
18
|
const isDotfile = (name) => name.startsWith(".");
|
|
19
|
+
|
|
15
20
|
const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
|
|
16
21
|
const base = new URL(apiBaseUrl);
|
|
17
22
|
const hasExplicitPort = previewHostname.includes(":");
|
|
18
23
|
return `${base.protocol}//${previewHostname}${!hasExplicitPort && base.port ? `:${base.port}` : ""}/`;
|
|
19
24
|
};
|
|
20
25
|
|
|
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
|
-
};
|
|
26
|
+
const hasDotfileSegment = (relativePath) =>
|
|
27
|
+
relativePath.split(path.sep).some(isDotfile);
|
|
30
28
|
|
|
31
|
-
/**
|
|
32
|
-
* Recursively list all files, skipping dotfiles.
|
|
33
|
-
* @param {string} rootDir
|
|
34
|
-
* @param {string} currentDir
|
|
35
|
-
* @returns {Promise<string[]>}
|
|
36
|
-
*/
|
|
37
29
|
const listAllFiles = async (rootDir, currentDir) => {
|
|
38
30
|
const entries = await readdir(currentDir, { withFileTypes: true });
|
|
39
31
|
const files = [];
|
|
@@ -41,8 +33,7 @@ const listAllFiles = async (rootDir, currentDir) => {
|
|
|
41
33
|
if (isDotfile(entry.name)) continue;
|
|
42
34
|
const abs = path.join(currentDir, entry.name);
|
|
43
35
|
if (entry.isDirectory()) {
|
|
44
|
-
|
|
45
|
-
files.push(...nested);
|
|
36
|
+
files.push(...(await listAllFiles(rootDir, abs)));
|
|
46
37
|
} else if (entry.isFile()) {
|
|
47
38
|
files.push(abs);
|
|
48
39
|
}
|
|
@@ -50,19 +41,13 @@ const listAllFiles = async (rootDir, currentDir) => {
|
|
|
50
41
|
return files;
|
|
51
42
|
};
|
|
52
43
|
|
|
53
|
-
/**
|
|
54
|
-
* Create a zip buffer from the current directory, skipping dotfiles.
|
|
55
|
-
* @param {string} rootDir
|
|
56
|
-
* @returns {Promise<Buffer>}
|
|
57
|
-
*/
|
|
58
44
|
const createZipFromDirectory = async (rootDir) => {
|
|
59
45
|
const absoluteFiles = await listAllFiles(rootDir, rootDir);
|
|
60
46
|
/** @type {Record<string, Uint8Array>} */
|
|
61
47
|
const entries = {};
|
|
62
48
|
for (const abs of absoluteFiles) {
|
|
63
49
|
const rel = path.relative(rootDir, abs).split(path.sep).join("/");
|
|
64
|
-
|
|
65
|
-
entries[rel] = new Uint8Array(buf);
|
|
50
|
+
entries[rel] = new Uint8Array(await readFile(abs));
|
|
66
51
|
}
|
|
67
52
|
return Buffer.from(zipSync(entries, { level: 6 }));
|
|
68
53
|
};
|
|
@@ -74,28 +59,25 @@ export const dev = async () => {
|
|
|
74
59
|
const rootDir = process.cwd();
|
|
75
60
|
|
|
76
61
|
let previewKey = config.previewKey;
|
|
62
|
+
let previewUrl;
|
|
77
63
|
|
|
78
|
-
// Ensure a preview exists
|
|
79
64
|
if (!previewKey) {
|
|
80
|
-
|
|
81
|
-
|
|
65
|
+
// ── Create preview and do initial upload ─────────────────────────────────
|
|
66
|
+
const spinner = p.spinner();
|
|
67
|
+
spinner.start("No active preview found. Creating one...");
|
|
82
68
|
|
|
83
69
|
const result = await createPreview(apiBaseUrl, apiKey, storeId, "Dev");
|
|
84
70
|
if (!result.ok) {
|
|
85
|
-
|
|
71
|
+
spinner.stop("Failed to create preview.", 1);
|
|
72
|
+
p.log.error(result.error);
|
|
86
73
|
process.exit(1);
|
|
87
74
|
}
|
|
88
75
|
|
|
89
76
|
previewKey = result.data.previewKey;
|
|
77
|
+
previewUrl = buildPreviewUrl(apiBaseUrl, result.data.previewHostname);
|
|
90
78
|
await writeConfig({ ...config, previewKey });
|
|
91
79
|
|
|
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...");
|
|
80
|
+
spinner.message("Uploading initial files...");
|
|
99
81
|
const zipBuffer = await createZipFromDirectory(rootDir);
|
|
100
82
|
const uploadResult = await uploadPreviewZip(
|
|
101
83
|
apiBaseUrl,
|
|
@@ -104,67 +86,71 @@ export const dev = async () => {
|
|
|
104
86
|
previewKey,
|
|
105
87
|
zipBuffer,
|
|
106
88
|
);
|
|
89
|
+
|
|
107
90
|
if (!uploadResult.ok) {
|
|
108
|
-
|
|
91
|
+
spinner.stop("Failed to upload files.", 1);
|
|
92
|
+
p.log.error(uploadResult.error);
|
|
109
93
|
process.exit(1);
|
|
110
94
|
}
|
|
111
|
-
|
|
95
|
+
|
|
96
|
+
spinner.stop(`Preview ready: ${previewUrl}`);
|
|
112
97
|
} else {
|
|
113
|
-
// Verify
|
|
98
|
+
// ── Verify existing preview still exists ─────────────────────────────────
|
|
99
|
+
const spinner = p.spinner();
|
|
100
|
+
spinner.start("Connecting to preview...");
|
|
101
|
+
|
|
114
102
|
const listResult = await listPreviews(apiBaseUrl, apiKey, storeId);
|
|
115
103
|
if (!listResult.ok) {
|
|
116
|
-
|
|
104
|
+
spinner.stop("Failed to connect.", 1);
|
|
105
|
+
p.log.error(listResult.error);
|
|
117
106
|
process.exit(1);
|
|
118
107
|
}
|
|
119
|
-
|
|
108
|
+
|
|
109
|
+
const existing = resolveActivePreview(listResult.data, previewKey);
|
|
120
110
|
if (!existing) {
|
|
121
|
-
|
|
122
|
-
|
|
111
|
+
spinner.stop("Could not determine the active preview.", 1);
|
|
112
|
+
p.log.error(
|
|
113
|
+
listResult.data.length === 0
|
|
114
|
+
? "No previews found for this store. A new preview will be created if you clear the local config and run tiendu dev again."
|
|
115
|
+
: "Run tiendu preview list and then set or recreate the preview.",
|
|
123
116
|
);
|
|
124
117
|
process.exit(1);
|
|
125
118
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
119
|
+
|
|
120
|
+
previewKey = existing.previewKey;
|
|
121
|
+
if (config.previewKey !== previewKey) {
|
|
122
|
+
await writeConfig({ ...config, previewKey });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
previewUrl = buildPreviewUrl(apiBaseUrl, existing.previewHostname);
|
|
126
|
+
spinner.stop(`Preview: ${previewUrl}`);
|
|
131
127
|
}
|
|
132
128
|
|
|
133
|
-
|
|
134
|
-
console.log("Observando cambios... (Ctrl+C para salir)");
|
|
135
|
-
console.log("");
|
|
129
|
+
p.log.message("Watching for changes — press Ctrl+C to stop.\n");
|
|
136
130
|
|
|
137
|
-
//
|
|
131
|
+
// ── File watcher ──────────────────────────────────────────────────────────
|
|
138
132
|
/** @type {Map<string, NodeJS.Timeout>} */
|
|
139
133
|
const debounceMap = new Map();
|
|
140
134
|
const DEBOUNCE_MS = 300;
|
|
141
135
|
|
|
142
136
|
const watcher = watch(rootDir, { recursive: true }, (eventType, filename) => {
|
|
143
137
|
if (!filename) return;
|
|
144
|
-
|
|
145
|
-
// Skip dotfiles
|
|
146
138
|
if (hasDotfileSegment(filename)) return;
|
|
147
139
|
|
|
148
|
-
// Normalize to posix path
|
|
149
140
|
const relativePath = filename.split(path.sep).join("/");
|
|
150
|
-
|
|
151
|
-
// Clear existing debounce timer
|
|
152
141
|
const existing = debounceMap.get(relativePath);
|
|
153
142
|
if (existing) clearTimeout(existing);
|
|
154
143
|
|
|
155
|
-
// Set new debounce timer
|
|
156
144
|
const timer = setTimeout(async () => {
|
|
157
145
|
debounceMap.delete(relativePath);
|
|
158
|
-
|
|
159
146
|
const absolutePath = path.join(rootDir, filename);
|
|
160
147
|
|
|
161
148
|
try {
|
|
162
149
|
const fileStat = await stat(absolutePath).catch(() => null);
|
|
163
150
|
|
|
164
151
|
if (!fileStat || !fileStat.isFile()) {
|
|
165
|
-
// File was deleted or is a directory
|
|
166
152
|
if (!fileStat) {
|
|
167
|
-
|
|
153
|
+
p.log.message(` ✕ ${relativePath}`);
|
|
168
154
|
const result = await deletePreviewFile(
|
|
169
155
|
apiBaseUrl,
|
|
170
156
|
apiKey,
|
|
@@ -173,16 +159,14 @@ export const dev = async () => {
|
|
|
173
159
|
relativePath,
|
|
174
160
|
);
|
|
175
161
|
if (!result.ok) {
|
|
176
|
-
|
|
162
|
+
p.log.warn(` Failed to delete: ${result.error}`);
|
|
177
163
|
}
|
|
178
164
|
}
|
|
179
165
|
return;
|
|
180
166
|
}
|
|
181
167
|
|
|
182
|
-
|
|
168
|
+
p.log.message(` ↑ ${relativePath}`);
|
|
183
169
|
const content = await readFile(absolutePath);
|
|
184
|
-
console.log(` ↑ ${relativePath}`);
|
|
185
|
-
|
|
186
170
|
const result = await uploadPreviewFileMultipart(
|
|
187
171
|
apiBaseUrl,
|
|
188
172
|
apiKey,
|
|
@@ -193,30 +177,25 @@ export const dev = async () => {
|
|
|
193
177
|
);
|
|
194
178
|
|
|
195
179
|
if (!result.ok) {
|
|
196
|
-
|
|
180
|
+
p.log.warn(` Failed to upload: ${result.error}`);
|
|
197
181
|
}
|
|
198
182
|
} catch (error) {
|
|
199
|
-
|
|
183
|
+
p.log.warn(` Error processing ${relativePath}: ${error.message}`);
|
|
200
184
|
}
|
|
201
185
|
}, DEBOUNCE_MS);
|
|
202
186
|
|
|
203
187
|
debounceMap.set(relativePath, timer);
|
|
204
188
|
});
|
|
205
189
|
|
|
206
|
-
// Handle graceful shutdown
|
|
207
190
|
const cleanup = () => {
|
|
208
191
|
watcher.close();
|
|
209
|
-
for (const timer of debounceMap.values())
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
console.log("");
|
|
213
|
-
console.log("Dev mode finalizado.");
|
|
192
|
+
for (const timer of debounceMap.values()) clearTimeout(timer);
|
|
193
|
+
p.outro("Dev mode stopped.");
|
|
214
194
|
process.exit(0);
|
|
215
195
|
};
|
|
216
196
|
|
|
217
197
|
process.on("SIGINT", cleanup);
|
|
218
198
|
process.on("SIGTERM", cleanup);
|
|
219
199
|
|
|
220
|
-
// Keep process alive
|
|
221
200
|
await new Promise(() => {});
|
|
222
201
|
};
|
package/lib/init.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { mkdir, access } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import * as p from "@clack/prompts";
|
|
2
4
|
import {
|
|
3
5
|
readConfig,
|
|
@@ -7,22 +9,42 @@ import {
|
|
|
7
9
|
} from "./config.mjs";
|
|
8
10
|
import { fetchUserStores } from "./api.mjs";
|
|
9
11
|
|
|
10
|
-
/** @param {string} key */
|
|
11
|
-
const maskApiKey = (key) => {
|
|
12
|
-
if (key.length <= 8) return "****";
|
|
13
|
-
return key.slice(0, 4) + "..." + key.slice(-4);
|
|
14
|
-
};
|
|
15
|
-
|
|
16
12
|
/** @param {string} url */
|
|
17
|
-
const normalizeBaseUrl = (url) =>
|
|
18
|
-
|
|
19
|
-
|
|
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;
|
|
20
38
|
|
|
21
|
-
|
|
39
|
+
// Change cwd so config is written inside the new directory
|
|
40
|
+
process.chdir(workDir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Re-read config after potential chdir
|
|
22
44
|
const existingConfig = await readConfig();
|
|
23
45
|
const existingCredentials = await readCredentials();
|
|
24
46
|
|
|
25
|
-
p.intro("Tiendu CLI —
|
|
47
|
+
p.intro("Tiendu CLI — Setup");
|
|
26
48
|
|
|
27
49
|
// ─── API Key ──────────────────────────────────────────────────────────────
|
|
28
50
|
const apiKeyDefault = existingCredentials?.apiKey ?? "";
|
|
@@ -31,50 +53,52 @@ export const init = async () => {
|
|
|
31
53
|
message: "API Key",
|
|
32
54
|
mask: "*",
|
|
33
55
|
validate: (value) => {
|
|
34
|
-
const resolved = value.trim() || apiKeyDefault;
|
|
35
|
-
if (!resolved) return "
|
|
56
|
+
const resolved = (value ?? "").trim() || apiKeyDefault;
|
|
57
|
+
if (!resolved) return "API Key is required.";
|
|
36
58
|
},
|
|
37
59
|
});
|
|
38
60
|
|
|
39
61
|
if (p.isCancel(apiKeyInput)) {
|
|
40
|
-
p.cancel("
|
|
62
|
+
p.cancel("Setup cancelled.");
|
|
41
63
|
process.exit(0);
|
|
42
64
|
}
|
|
43
65
|
|
|
44
|
-
const apiKey = apiKeyInput.trim() || apiKeyDefault;
|
|
66
|
+
const apiKey = (apiKeyInput ?? "").trim() || apiKeyDefault;
|
|
45
67
|
|
|
46
68
|
// ─── API Base URL ─────────────────────────────────────────────────────────
|
|
47
69
|
const baseUrlDefault = existingConfig?.apiBaseUrl ?? "https://tiendu.uy";
|
|
48
70
|
|
|
49
71
|
const baseUrlInput = await p.text({
|
|
50
|
-
message: "
|
|
72
|
+
message: "API base URL",
|
|
51
73
|
placeholder: baseUrlDefault,
|
|
52
74
|
defaultValue: baseUrlDefault,
|
|
53
75
|
validate: (value) => {
|
|
54
|
-
const resolved = value.trim() || baseUrlDefault;
|
|
76
|
+
const resolved = (value ?? "").trim() || baseUrlDefault;
|
|
55
77
|
try {
|
|
56
78
|
new URL(resolved);
|
|
57
79
|
} catch {
|
|
58
|
-
return "URL
|
|
80
|
+
return "Invalid URL.";
|
|
59
81
|
}
|
|
60
82
|
},
|
|
61
83
|
});
|
|
62
84
|
|
|
63
85
|
if (p.isCancel(baseUrlInput)) {
|
|
64
|
-
p.cancel("
|
|
86
|
+
p.cancel("Setup cancelled.");
|
|
65
87
|
process.exit(0);
|
|
66
88
|
}
|
|
67
89
|
|
|
68
|
-
const apiBaseUrl = normalizeBaseUrl(
|
|
90
|
+
const apiBaseUrl = normalizeBaseUrl(
|
|
91
|
+
(baseUrlInput ?? "").trim() || baseUrlDefault,
|
|
92
|
+
);
|
|
69
93
|
|
|
70
94
|
// ─── Fetch stores (validates API key implicitly) ───────────────────────────
|
|
71
95
|
const spinner = p.spinner();
|
|
72
|
-
spinner.start("
|
|
96
|
+
spinner.start("Verifying credentials...");
|
|
73
97
|
|
|
74
98
|
const storesResult = await fetchUserStores(apiBaseUrl, apiKey);
|
|
75
99
|
|
|
76
100
|
if (!storesResult.ok) {
|
|
77
|
-
spinner.stop("
|
|
101
|
+
spinner.stop("Failed to verify credentials.", 1);
|
|
78
102
|
p.cancel(storesResult.error);
|
|
79
103
|
process.exit(1);
|
|
80
104
|
}
|
|
@@ -82,25 +106,24 @@ export const init = async () => {
|
|
|
82
106
|
const stores = storesResult.data;
|
|
83
107
|
|
|
84
108
|
if (stores.length === 0) {
|
|
85
|
-
spinner.stop("No
|
|
86
|
-
p.cancel("
|
|
109
|
+
spinner.stop("No stores found.", 1);
|
|
110
|
+
p.cancel("Your API Key does not have access to any store.");
|
|
87
111
|
process.exit(1);
|
|
88
112
|
}
|
|
89
113
|
|
|
90
114
|
spinner.stop(
|
|
91
|
-
`${stores.length}
|
|
115
|
+
`${stores.length} store${stores.length === 1 ? "" : "s"} found.`,
|
|
92
116
|
);
|
|
93
117
|
|
|
94
118
|
// ─── Select store ─────────────────────────────────────────────────────────
|
|
95
119
|
let storeId;
|
|
96
120
|
|
|
97
121
|
if (stores.length === 1) {
|
|
98
|
-
// Auto-select if only one store
|
|
99
122
|
storeId = stores[0].id;
|
|
100
|
-
p.
|
|
123
|
+
p.log.info(`Store: ${stores[0].name} (ID: ${storeId})`);
|
|
101
124
|
} else {
|
|
102
125
|
const selectedId = await p.select({
|
|
103
|
-
message: "
|
|
126
|
+
message: "Select a store",
|
|
104
127
|
options: stores.map((store) => ({
|
|
105
128
|
value: store.id,
|
|
106
129
|
label: store.name,
|
|
@@ -110,7 +133,7 @@ export const init = async () => {
|
|
|
110
133
|
});
|
|
111
134
|
|
|
112
135
|
if (p.isCancel(selectedId)) {
|
|
113
|
-
p.cancel("
|
|
136
|
+
p.cancel("Setup cancelled.");
|
|
114
137
|
process.exit(0);
|
|
115
138
|
}
|
|
116
139
|
|
|
@@ -121,16 +144,19 @@ export const init = async () => {
|
|
|
121
144
|
await writeConfig({ storeId, apiBaseUrl });
|
|
122
145
|
await writeCredentials({ apiKey });
|
|
123
146
|
|
|
147
|
+
const nextSteps = dirArg
|
|
148
|
+
? [`cd ${dirArg}`, `tiendu pull # download the current live theme`]
|
|
149
|
+
: [`tiendu pull # download the current live theme`];
|
|
150
|
+
|
|
124
151
|
p.note(
|
|
125
152
|
[
|
|
126
|
-
|
|
153
|
+
...nextSteps,
|
|
127
154
|
"",
|
|
128
|
-
"
|
|
129
|
-
"(
|
|
130
|
-
"se muestren correctamente.",
|
|
155
|
+
"Tip: enable Dev Mode in the Tiendu platform",
|
|
156
|
+
"(Settings → General) for preview data to load correctly.",
|
|
131
157
|
].join("\n"),
|
|
132
|
-
"
|
|
158
|
+
"Next steps",
|
|
133
159
|
);
|
|
134
160
|
|
|
135
|
-
p.outro("
|
|
161
|
+
p.outro("Configuration saved to .cli/");
|
|
136
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
|
|
|
@@ -7,6 +8,25 @@ const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
|
|
|
7
8
|
return `${base.protocol}//${previewHostname}${!hasExplicitPort && base.port ? `:${base.port}` : ""}/`;
|
|
8
9
|
};
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* @param {Array<any>} previews
|
|
13
|
+
* @param {string | undefined} previewKey
|
|
14
|
+
* @returns {any | null}
|
|
15
|
+
*/
|
|
16
|
+
export const resolveActivePreview = (previews, previewKey) => {
|
|
17
|
+
if (previewKey) {
|
|
18
|
+
return (
|
|
19
|
+
previews.find((preview) => preview.previewKey === previewKey) ?? null
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (previews.length === 1) {
|
|
24
|
+
return previews[0];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return null;
|
|
28
|
+
};
|
|
29
|
+
|
|
10
30
|
/**
|
|
11
31
|
* @param {string} apiBaseUrl
|
|
12
32
|
* @param {string} apiKey
|
|
@@ -30,7 +50,7 @@ export const createPreview = async (apiBaseUrl, apiKey, storeId, name) => {
|
|
|
30
50
|
const body = await response.json().catch(() => ({}));
|
|
31
51
|
const message =
|
|
32
52
|
body?.error?.message ??
|
|
33
|
-
"
|
|
53
|
+
"A preview already exists for this store. Delete it first with: tiendu preview delete";
|
|
34
54
|
return { ok: false, error: message };
|
|
35
55
|
}
|
|
36
56
|
|
|
@@ -38,17 +58,14 @@ export const createPreview = async (apiBaseUrl, apiKey, storeId, name) => {
|
|
|
38
58
|
const body = await response.text().catch(() => "");
|
|
39
59
|
return {
|
|
40
60
|
ok: false,
|
|
41
|
-
error: `
|
|
61
|
+
error: `Server error: ${response.status}${body ? ` — ${body}` : ""}`,
|
|
42
62
|
};
|
|
43
63
|
}
|
|
44
64
|
|
|
45
65
|
const preview = await response.json();
|
|
46
66
|
return { ok: true, data: preview };
|
|
47
67
|
} catch (error) {
|
|
48
|
-
return {
|
|
49
|
-
ok: false,
|
|
50
|
-
error: `No se pudo crear el preview: ${error.message}`,
|
|
51
|
-
};
|
|
68
|
+
return { ok: false, error: `Could not create preview: ${error.message}` };
|
|
52
69
|
}
|
|
53
70
|
};
|
|
54
71
|
|
|
@@ -65,16 +82,13 @@ export const listPreviews = async (apiBaseUrl, apiKey, storeId) => {
|
|
|
65
82
|
apiKey,
|
|
66
83
|
`/api/v2/stores/${storeId}/theme-previews`,
|
|
67
84
|
);
|
|
68
|
-
|
|
69
85
|
if (!response.ok) {
|
|
70
|
-
return { ok: false, error: `
|
|
86
|
+
return { ok: false, error: `Server error: ${response.status}` };
|
|
71
87
|
}
|
|
72
|
-
|
|
73
88
|
const body = await response.json();
|
|
74
|
-
|
|
75
|
-
return { ok: true, data: previews };
|
|
89
|
+
return { ok: true, data: body?.previews ?? [] };
|
|
76
90
|
} catch (error) {
|
|
77
|
-
return { ok: false, error: `
|
|
91
|
+
return { ok: false, error: `Could not list previews: ${error.message}` };
|
|
78
92
|
}
|
|
79
93
|
};
|
|
80
94
|
|
|
@@ -96,25 +110,18 @@ export const deletePreview = async (
|
|
|
96
110
|
apiBaseUrl,
|
|
97
111
|
apiKey,
|
|
98
112
|
`/api/v2/stores/${storeId}/theme-previews/${previewKey}`,
|
|
99
|
-
{
|
|
100
|
-
method: "DELETE",
|
|
101
|
-
},
|
|
113
|
+
{ method: "DELETE" },
|
|
102
114
|
);
|
|
103
|
-
|
|
104
115
|
if (!response.ok) {
|
|
105
116
|
const body = await response.text().catch(() => "");
|
|
106
117
|
return {
|
|
107
118
|
ok: false,
|
|
108
|
-
error: `
|
|
119
|
+
error: `Server error: ${response.status}${body ? ` — ${body}` : ""}`,
|
|
109
120
|
};
|
|
110
121
|
}
|
|
111
|
-
|
|
112
122
|
return { ok: true };
|
|
113
123
|
} catch (error) {
|
|
114
|
-
return {
|
|
115
|
-
ok: false,
|
|
116
|
-
error: `No se pudo eliminar el preview: ${error.message}`,
|
|
117
|
-
};
|
|
124
|
+
return { ok: false, error: `Could not delete preview: ${error.message}` };
|
|
118
125
|
}
|
|
119
126
|
};
|
|
120
127
|
|
|
@@ -136,25 +143,18 @@ export const publishPreview = async (
|
|
|
136
143
|
apiBaseUrl,
|
|
137
144
|
apiKey,
|
|
138
145
|
`/api/v2/stores/${storeId}/theme-previews/${previewKey}/publish`,
|
|
139
|
-
{
|
|
140
|
-
method: "POST",
|
|
141
|
-
},
|
|
146
|
+
{ method: "POST" },
|
|
142
147
|
);
|
|
143
|
-
|
|
144
148
|
if (!response.ok) {
|
|
145
149
|
const body = await response.text().catch(() => "");
|
|
146
150
|
return {
|
|
147
151
|
ok: false,
|
|
148
|
-
error: `
|
|
152
|
+
error: `Server error: ${response.status}${body ? ` — ${body}` : ""}`,
|
|
149
153
|
};
|
|
150
154
|
}
|
|
151
|
-
|
|
152
155
|
return { ok: true };
|
|
153
156
|
} catch (error) {
|
|
154
|
-
return {
|
|
155
|
-
ok: false,
|
|
156
|
-
error: `No se pudo publicar el preview: ${error.message}`,
|
|
157
|
-
};
|
|
157
|
+
return { ok: false, error: `Could not publish preview: ${error.message}` };
|
|
158
158
|
}
|
|
159
159
|
};
|
|
160
160
|
|
|
@@ -165,8 +165,8 @@ export const publishPreview = async (
|
|
|
165
165
|
export const previewCreate = async (name) => {
|
|
166
166
|
const { config, credentials } = await loadConfigOrFail();
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
const spinner = p.spinner();
|
|
169
|
+
spinner.start("Creating preview...");
|
|
170
170
|
|
|
171
171
|
const result = await createPreview(
|
|
172
172
|
config.apiBaseUrl,
|
|
@@ -176,24 +176,24 @@ export const previewCreate = async (name) => {
|
|
|
176
176
|
);
|
|
177
177
|
|
|
178
178
|
if (!result.ok) {
|
|
179
|
-
|
|
179
|
+
spinner.stop("Failed to create preview.", 1);
|
|
180
|
+
p.log.error(result.error);
|
|
180
181
|
process.exit(1);
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
const preview = result.data;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
`URL: ${buildPreviewUrl(config.apiBaseUrl, preview.previewHostname)}`,
|
|
187
|
-
);
|
|
188
|
-
console.log("");
|
|
185
|
+
const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
|
|
186
|
+
spinner.stop(`Preview created: ${url}`);
|
|
189
187
|
|
|
190
|
-
// Save preview key to config
|
|
191
188
|
await writeConfig({ ...config, previewKey: preview.previewKey });
|
|
192
189
|
};
|
|
193
190
|
|
|
194
191
|
export const previewList = async () => {
|
|
195
192
|
const { config, credentials } = await loadConfigOrFail();
|
|
196
193
|
|
|
194
|
+
const spinner = p.spinner();
|
|
195
|
+
spinner.start("Fetching previews...");
|
|
196
|
+
|
|
197
197
|
const result = await listPreviews(
|
|
198
198
|
config.apiBaseUrl,
|
|
199
199
|
credentials.apiKey,
|
|
@@ -201,67 +201,93 @@ export const previewList = async () => {
|
|
|
201
201
|
);
|
|
202
202
|
|
|
203
203
|
if (!result.ok) {
|
|
204
|
-
|
|
204
|
+
spinner.stop("Failed to fetch previews.", 1);
|
|
205
|
+
p.log.error(result.error);
|
|
205
206
|
process.exit(1);
|
|
206
207
|
}
|
|
207
208
|
|
|
208
|
-
console.log("");
|
|
209
209
|
if (result.data.length === 0) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
210
|
+
spinner.stop("No previews for this store.");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
spinner.stop(
|
|
215
|
+
`${result.data.length} preview${result.data.length === 1 ? "" : "s"}:`,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const activePreview = resolveActivePreview(result.data, config.previewKey);
|
|
219
|
+
|
|
220
|
+
for (const preview of result.data) {
|
|
221
|
+
const active =
|
|
222
|
+
activePreview?.previewKey === preview.previewKey ? " ← active" : "";
|
|
223
|
+
const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
|
|
224
|
+
p.log.message(` ${preview.name} ${url}${active}`);
|
|
220
225
|
}
|
|
221
|
-
console.log("");
|
|
222
226
|
};
|
|
223
227
|
|
|
224
228
|
export const previewDelete = async () => {
|
|
225
229
|
const { config, credentials } = await loadConfigOrFail();
|
|
226
230
|
|
|
227
|
-
|
|
228
|
-
|
|
231
|
+
const listResult = await listPreviews(
|
|
232
|
+
config.apiBaseUrl,
|
|
233
|
+
credentials.apiKey,
|
|
234
|
+
config.storeId,
|
|
235
|
+
);
|
|
236
|
+
if (!listResult.ok) {
|
|
237
|
+
p.log.error(listResult.error);
|
|
229
238
|
process.exit(1);
|
|
230
239
|
}
|
|
231
240
|
|
|
232
|
-
|
|
233
|
-
|
|
241
|
+
const activePreview = resolveActivePreview(
|
|
242
|
+
listResult.data,
|
|
243
|
+
config.previewKey,
|
|
244
|
+
);
|
|
245
|
+
if (!activePreview) {
|
|
246
|
+
p.log.error(
|
|
247
|
+
listResult.data.length === 0
|
|
248
|
+
? "No previews found for this store."
|
|
249
|
+
: "Could not determine the active preview. Run tiendu preview list first.",
|
|
250
|
+
);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const confirmed = await p.confirm({
|
|
255
|
+
message: `Delete preview ${activePreview.previewKey}?`,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
259
|
+
p.cancel("Cancelled.");
|
|
260
|
+
process.exit(0);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const spinner = p.spinner();
|
|
264
|
+
spinner.start("Deleting preview...");
|
|
234
265
|
|
|
235
266
|
const result = await deletePreview(
|
|
236
267
|
config.apiBaseUrl,
|
|
237
268
|
credentials.apiKey,
|
|
238
269
|
config.storeId,
|
|
239
|
-
|
|
270
|
+
activePreview.previewKey,
|
|
240
271
|
);
|
|
241
272
|
|
|
242
273
|
if (!result.ok) {
|
|
243
|
-
|
|
274
|
+
spinner.stop("Failed to delete preview.", 1);
|
|
275
|
+
p.log.error(result.error);
|
|
244
276
|
process.exit(1);
|
|
245
277
|
}
|
|
246
278
|
|
|
247
|
-
|
|
248
|
-
console.log("");
|
|
279
|
+
spinner.stop("Preview deleted.");
|
|
249
280
|
|
|
250
|
-
// Remove preview key from config
|
|
251
281
|
const { previewKey, ...rest } = config;
|
|
252
282
|
await writeConfig(rest);
|
|
253
283
|
};
|
|
254
284
|
|
|
255
285
|
export const previewOpen = async () => {
|
|
256
|
-
const { config } = await loadConfigOrFail();
|
|
286
|
+
const { config, credentials } = await loadConfigOrFail();
|
|
257
287
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
process.exit(1);
|
|
261
|
-
}
|
|
288
|
+
const spinner = p.spinner();
|
|
289
|
+
spinner.start("Fetching preview URL...");
|
|
262
290
|
|
|
263
|
-
// Find the preview to get its hostname
|
|
264
|
-
const { credentials } = await loadConfigOrFail();
|
|
265
291
|
const result = await listPreviews(
|
|
266
292
|
config.apiBaseUrl,
|
|
267
293
|
credentials.apiKey,
|
|
@@ -269,26 +295,30 @@ export const previewOpen = async () => {
|
|
|
269
295
|
);
|
|
270
296
|
|
|
271
297
|
if (!result.ok) {
|
|
272
|
-
|
|
298
|
+
spinner.stop("Failed to fetch previews.", 1);
|
|
299
|
+
p.log.error(result.error);
|
|
273
300
|
process.exit(1);
|
|
274
301
|
}
|
|
275
302
|
|
|
276
|
-
const preview = result.data
|
|
303
|
+
const preview = resolveActivePreview(result.data, config.previewKey);
|
|
277
304
|
if (!preview) {
|
|
278
|
-
|
|
305
|
+
spinner.stop("Could not determine the active preview.", 1);
|
|
306
|
+
p.log.error(
|
|
307
|
+
result.data.length === 0
|
|
308
|
+
? "No previews found for this store."
|
|
309
|
+
: "Run tiendu preview list and then set or recreate the preview.",
|
|
310
|
+
);
|
|
279
311
|
process.exit(1);
|
|
280
312
|
}
|
|
281
313
|
|
|
282
314
|
const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
|
|
283
|
-
|
|
315
|
+
spinner.stop(`Opening ${url}`);
|
|
284
316
|
|
|
285
|
-
// Open URL in browser
|
|
286
317
|
const { exec } = await import("node:child_process");
|
|
287
|
-
const platform = process.platform;
|
|
288
318
|
const cmd =
|
|
289
|
-
platform === "darwin"
|
|
319
|
+
process.platform === "darwin"
|
|
290
320
|
? "open"
|
|
291
|
-
: platform === "win32"
|
|
321
|
+
: process.platform === "win32"
|
|
292
322
|
? "start"
|
|
293
323
|
: "xdg-open";
|
|
294
324
|
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,19 @@ 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
|
-
|
|
30
|
-
|
|
31
|
-
console.log(` ${file}`);
|
|
32
|
-
}
|
|
33
|
-
console.log("");
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
/** @param {number} bytes */
|
|
37
|
-
const formatBytes = (bytes) => {
|
|
38
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
39
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
40
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
38
|
+
p.log.success(
|
|
39
|
+
`${extractedFiles.length} file${extractedFiles.length === 1 ? "" : "s"} extracted.`,
|
|
40
|
+
);
|
|
41
41
|
};
|
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
|
+
};
|