tiendu 0.4.0 → 0.6.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/README.md +56 -13
- package/bin/tiendu.js +43 -16
- package/bin/tiendu.mjs +1 -136
- package/lib/api.mjs +82 -30
- package/lib/archive.mjs +30 -0
- package/lib/assets.mjs +245 -0
- package/lib/build.mjs +299 -41
- package/lib/dev.mjs +234 -144
- package/lib/fs-utils.mjs +35 -0
- package/lib/local-preview.mjs +393 -0
- package/lib/postcss.mjs +166 -0
- package/lib/preview.mjs +279 -73
- package/lib/publish.mjs +32 -17
- package/lib/pull.mjs +37 -12
- package/lib/push.mjs +60 -57
- package/lib/retry.mjs +69 -0
- package/package.json +2 -2
package/lib/dev.mjs
CHANGED
|
@@ -1,58 +1,120 @@
|
|
|
1
1
|
import { watch } from "node:fs";
|
|
2
|
-
import { readFile,
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import * as p from "@clack/prompts";
|
|
5
|
-
import {
|
|
6
|
-
import { loadConfigOrFail, writeConfig, isBuiltTheme, getDistDir } from "./config.mjs";
|
|
5
|
+
import { loadConfigOrFail, isBuiltTheme, getDistDir } from "./config.mjs";
|
|
7
6
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
resolveActivePreview,
|
|
7
|
+
fetchPreviewDetails,
|
|
8
|
+
resolvePreviewKeyInteractively,
|
|
11
9
|
} from "./preview.mjs";
|
|
12
10
|
import {
|
|
13
11
|
deletePreviewFile,
|
|
14
12
|
uploadPreviewFileMultipart,
|
|
15
|
-
uploadPreviewZip,
|
|
16
13
|
} from "./api.mjs";
|
|
17
14
|
import { build } from "./build.mjs";
|
|
15
|
+
import { isDotfile } from "./fs-utils.mjs";
|
|
16
|
+
import { startLocalPreviewServer } from "./local-preview.mjs";
|
|
17
|
+
import { pushPreparedDirectoryToPreview } from "./push.mjs";
|
|
18
|
+
import { retryAsync } from "./retry.mjs";
|
|
18
19
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
const hasExplicitPort = previewHostname.includes(":");
|
|
24
|
-
return `${base.protocol}//${previewHostname}${!hasExplicitPort && base.port ? `:${base.port}` : ""}/`;
|
|
25
|
-
};
|
|
20
|
+
const RETRY_ATTEMPTS = 3;
|
|
21
|
+
const MAX_SYNC_FILE_SIZE_BYTES = 20 * 1024 * 1024;
|
|
22
|
+
const CLEANUP_TIMEOUT_MS = 5_000;
|
|
23
|
+
const IGNORED_ROOT_SEGMENTS = new Set(["node_modules", ".git"]);
|
|
26
24
|
|
|
27
25
|
const hasDotfileSegment = (relativePath) =>
|
|
28
26
|
relativePath.split(path.sep).some(isDotfile);
|
|
29
27
|
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
files.push(...(await listAllFiles(rootDir, abs)));
|
|
38
|
-
} else if (entry.isFile()) {
|
|
39
|
-
files.push(abs);
|
|
40
|
-
}
|
|
28
|
+
const shouldIgnoreWatchedPath = (relativePath, builtTheme) => {
|
|
29
|
+
const normalizedPath = relativePath.split(path.sep).join("/");
|
|
30
|
+
const segments = normalizedPath.split("/");
|
|
31
|
+
const basename = segments.at(-1) ?? "";
|
|
32
|
+
|
|
33
|
+
if (segments.some((segment) => IGNORED_ROOT_SEGMENTS.has(segment))) {
|
|
34
|
+
return true;
|
|
41
35
|
}
|
|
42
|
-
|
|
36
|
+
|
|
37
|
+
if (!builtTheme && segments[0] === "dist") {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return basename.endsWith("~") || /\.(swp|tmp|temp)$/i.test(basename);
|
|
43
42
|
};
|
|
44
43
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
const shouldRetrySyncResult = (result) =>
|
|
45
|
+
!result.ok && Boolean(result.retriable);
|
|
46
|
+
|
|
47
|
+
const runCleanupStep = async (label, cleanupFn) => {
|
|
48
|
+
if (!cleanupFn) return;
|
|
49
|
+
|
|
50
|
+
let timeoutId = null;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await Promise.race([
|
|
54
|
+
Promise.resolve().then(() => cleanupFn()),
|
|
55
|
+
new Promise((_, reject) => {
|
|
56
|
+
timeoutId = setTimeout(() => {
|
|
57
|
+
reject(new Error(`${label} did not finish within ${CLEANUP_TIMEOUT_MS}ms.`));
|
|
58
|
+
}, CLEANUP_TIMEOUT_MS);
|
|
59
|
+
}),
|
|
60
|
+
]);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
p.log.warn(error.message);
|
|
63
|
+
} finally {
|
|
64
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
52
65
|
}
|
|
53
|
-
return Buffer.from(zipSync(entries, { level: 6 }));
|
|
54
66
|
};
|
|
55
67
|
|
|
68
|
+
const uploadFileWithRetries = (
|
|
69
|
+
apiBaseUrl,
|
|
70
|
+
apiKey,
|
|
71
|
+
storeId,
|
|
72
|
+
previewKey,
|
|
73
|
+
relativePath,
|
|
74
|
+
content,
|
|
75
|
+
onRetry,
|
|
76
|
+
) =>
|
|
77
|
+
retryAsync(
|
|
78
|
+
() =>
|
|
79
|
+
uploadPreviewFileMultipart(
|
|
80
|
+
apiBaseUrl,
|
|
81
|
+
apiKey,
|
|
82
|
+
storeId,
|
|
83
|
+
previewKey,
|
|
84
|
+
relativePath,
|
|
85
|
+
content,
|
|
86
|
+
),
|
|
87
|
+
{
|
|
88
|
+
attempts: RETRY_ATTEMPTS,
|
|
89
|
+
shouldRetry: shouldRetrySyncResult,
|
|
90
|
+
onRetry,
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const deleteFileWithRetries = (
|
|
95
|
+
apiBaseUrl,
|
|
96
|
+
apiKey,
|
|
97
|
+
storeId,
|
|
98
|
+
previewKey,
|
|
99
|
+
relativePath,
|
|
100
|
+
onRetry,
|
|
101
|
+
) =>
|
|
102
|
+
retryAsync(
|
|
103
|
+
() =>
|
|
104
|
+
deletePreviewFile(
|
|
105
|
+
apiBaseUrl,
|
|
106
|
+
apiKey,
|
|
107
|
+
storeId,
|
|
108
|
+
previewKey,
|
|
109
|
+
relativePath,
|
|
110
|
+
),
|
|
111
|
+
{
|
|
112
|
+
attempts: RETRY_ATTEMPTS,
|
|
113
|
+
shouldRetry: shouldRetrySyncResult,
|
|
114
|
+
onRetry,
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
|
|
56
118
|
export const dev = async () => {
|
|
57
119
|
const { config, credentials } = await loadConfigOrFail();
|
|
58
120
|
const { apiBaseUrl, storeId } = config;
|
|
@@ -60,6 +122,7 @@ export const dev = async () => {
|
|
|
60
122
|
const builtTheme = await isBuiltTheme();
|
|
61
123
|
const rootDir = builtTheme ? getDistDir() : process.cwd();
|
|
62
124
|
let buildCleanup = null;
|
|
125
|
+
let localPreviewServer = null;
|
|
63
126
|
|
|
64
127
|
// For built themes, run the build first (with watch mode)
|
|
65
128
|
if (builtTheme) {
|
|
@@ -71,151 +134,178 @@ export const dev = async () => {
|
|
|
71
134
|
buildCleanup = buildResult.cleanup;
|
|
72
135
|
}
|
|
73
136
|
|
|
74
|
-
|
|
137
|
+
// Resolve preview via shared interactive picker
|
|
138
|
+
const previewKey = await resolvePreviewKeyInteractively({ config, credentials });
|
|
139
|
+
|
|
140
|
+
// Fetch preview to get hostname for local proxy
|
|
141
|
+
const previewResult = await fetchPreviewDetails(
|
|
75
142
|
apiBaseUrl,
|
|
76
143
|
apiKey,
|
|
77
144
|
storeId,
|
|
145
|
+
previewKey,
|
|
78
146
|
);
|
|
79
|
-
if (!
|
|
80
|
-
p.log.error(
|
|
147
|
+
if (!previewResult.ok) {
|
|
148
|
+
p.log.error(`Preview ${previewKey} not found.`);
|
|
81
149
|
process.exit(1);
|
|
82
150
|
}
|
|
83
151
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
?.previewKey ?? config.previewKey;
|
|
87
|
-
let previewUrl;
|
|
152
|
+
const previewHostname = previewResult.data.preview.previewHostname;
|
|
153
|
+
const previewUrl = previewResult.data.url;
|
|
88
154
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const spinner = p.spinner();
|
|
92
|
-
spinner.start("No active preview found. Creating one...");
|
|
155
|
+
const spinner = p.spinner();
|
|
156
|
+
spinner.start("Compressing files...");
|
|
93
157
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
158
|
+
const uploadResult = await pushPreparedDirectoryToPreview({
|
|
159
|
+
apiBaseUrl,
|
|
160
|
+
apiKey,
|
|
161
|
+
storeId,
|
|
162
|
+
previewKey,
|
|
163
|
+
rootDir,
|
|
164
|
+
spinner,
|
|
165
|
+
compressMessage: "Compressing files...",
|
|
166
|
+
retryMessage: (result, nextAttempt) =>
|
|
167
|
+
`Initial push failed. Retrying ${nextAttempt}/${RETRY_ATTEMPTS}... ${result.error}`,
|
|
168
|
+
});
|
|
100
169
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
170
|
+
if (!uploadResult.ok) {
|
|
171
|
+
spinner.stop("Initial push failed.", 1);
|
|
172
|
+
p.log.error(uploadResult.error);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
104
175
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const uploadResult = await uploadPreviewZip(
|
|
176
|
+
try {
|
|
177
|
+
localPreviewServer = await startLocalPreviewServer({
|
|
108
178
|
apiBaseUrl,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (!uploadResult.ok) {
|
|
116
|
-
spinner.stop("Failed to upload files.", 1);
|
|
117
|
-
p.log.error(uploadResult.error);
|
|
118
|
-
process.exit(1);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
spinner.stop(`Preview ready: ${previewUrl}`);
|
|
122
|
-
} else {
|
|
123
|
-
// ── Verify existing preview still exists ─────────────────────────────────
|
|
124
|
-
const spinner = p.spinner();
|
|
125
|
-
spinner.start("Connecting to preview...");
|
|
126
|
-
|
|
127
|
-
const listResult = await listPreviews(apiBaseUrl, apiKey, storeId);
|
|
128
|
-
if (!listResult.ok) {
|
|
129
|
-
spinner.stop("Failed to connect.", 1);
|
|
130
|
-
p.log.error(listResult.error);
|
|
131
|
-
process.exit(1);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const existing = resolveActivePreview(listResult.data, previewKey);
|
|
135
|
-
if (!existing) {
|
|
136
|
-
spinner.stop("Could not determine the active preview.", 1);
|
|
137
|
-
p.log.error(
|
|
138
|
-
listResult.data.length === 0
|
|
139
|
-
? "No previews found for this store. A new preview will be created if you clear the local config and run tiendu dev again."
|
|
140
|
-
: "Run tiendu preview list and then set or recreate the preview.",
|
|
141
|
-
);
|
|
142
|
-
process.exit(1);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
previewKey = existing.previewKey;
|
|
146
|
-
if (config.previewKey !== previewKey) {
|
|
147
|
-
await writeConfig({ ...config, previewKey });
|
|
148
|
-
}
|
|
179
|
+
previewHostname,
|
|
180
|
+
});
|
|
181
|
+
} catch (error) {
|
|
182
|
+
p.log.warn(`Could not start local live preview: ${error.message}`);
|
|
183
|
+
}
|
|
149
184
|
|
|
150
|
-
|
|
151
|
-
|
|
185
|
+
spinner.stop(`Preview ready (${previewKey}).`);
|
|
186
|
+
if (localPreviewServer) {
|
|
187
|
+
p.log.message(`Local live preview: ${localPreviewServer.url}`);
|
|
152
188
|
}
|
|
189
|
+
p.log.message(`Sharable preview: ${previewUrl}`);
|
|
153
190
|
|
|
154
|
-
p.log.message("Watching for changes
|
|
191
|
+
p.log.message("Watching for changes \u2014 press Ctrl+C to stop.");
|
|
155
192
|
|
|
156
193
|
// ── File watcher ──────────────────────────────────────────────────────────
|
|
157
194
|
/** @type {Map<string, NodeJS.Timeout>} */
|
|
158
195
|
const debounceMap = new Map();
|
|
196
|
+
const inFlightPaths = new Set();
|
|
197
|
+
const pendingResyncPaths = new Set();
|
|
159
198
|
const DEBOUNCE_MS = 300;
|
|
160
199
|
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
const relativePath = filename.split(path.sep).join("/");
|
|
166
|
-
const existing = debounceMap.get(relativePath);
|
|
167
|
-
if (existing) clearTimeout(existing);
|
|
200
|
+
const queueSync = (relativePath) => {
|
|
201
|
+
const existingTimer = debounceMap.get(relativePath);
|
|
202
|
+
if (existingTimer) clearTimeout(existingTimer);
|
|
168
203
|
|
|
169
|
-
const timer = setTimeout(
|
|
204
|
+
const timer = setTimeout(() => {
|
|
170
205
|
debounceMap.delete(relativePath);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
206
|
+
void syncPath(relativePath);
|
|
207
|
+
}, DEBOUNCE_MS);
|
|
208
|
+
|
|
209
|
+
debounceMap.set(relativePath, timer);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const syncPath = async (relativePath) => {
|
|
213
|
+
if (inFlightPaths.has(relativePath)) {
|
|
214
|
+
pendingResyncPaths.add(relativePath);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
inFlightPaths.add(relativePath);
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const absolutePath = path.join(rootDir, relativePath);
|
|
222
|
+
const fileStat = await stat(absolutePath).catch(() => null);
|
|
223
|
+
|
|
224
|
+
if (!fileStat || !fileStat.isFile()) {
|
|
225
|
+
if (!fileStat) {
|
|
226
|
+
console.log(`\u2715 ${relativePath}`);
|
|
227
|
+
const result = await deleteFileWithRetries(
|
|
228
|
+
apiBaseUrl,
|
|
229
|
+
apiKey,
|
|
230
|
+
storeId,
|
|
231
|
+
previewKey,
|
|
232
|
+
relativePath,
|
|
233
|
+
async (_, nextAttempt) => {
|
|
234
|
+
p.log.warn(
|
|
235
|
+
` Retry delete ${relativePath} (${nextAttempt}/${RETRY_ATTEMPTS})`,
|
|
236
|
+
);
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
if (!result.ok) {
|
|
241
|
+
p.log.warn(` Failed to delete after ${RETRY_ATTEMPTS} attempts: ${result.error}`);
|
|
242
|
+
} else {
|
|
243
|
+
localPreviewServer?.notifyReload();
|
|
189
244
|
}
|
|
190
|
-
return;
|
|
191
245
|
}
|
|
192
246
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
relativePath,
|
|
201
|
-
content,
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log(`\u2191 ${relativePath}`);
|
|
251
|
+
if (fileStat.size > MAX_SYNC_FILE_SIZE_BYTES) {
|
|
252
|
+
p.log.warn(
|
|
253
|
+
` Skipping ${relativePath}: file is ${(fileStat.size / (1024 * 1024)).toFixed(1)} MB (limit ${(MAX_SYNC_FILE_SIZE_BYTES / (1024 * 1024)).toFixed(0)} MB).`,
|
|
202
254
|
);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
203
257
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
258
|
+
const content = await readFile(absolutePath);
|
|
259
|
+
const result = await uploadFileWithRetries(
|
|
260
|
+
apiBaseUrl,
|
|
261
|
+
apiKey,
|
|
262
|
+
storeId,
|
|
263
|
+
previewKey,
|
|
264
|
+
relativePath,
|
|
265
|
+
content,
|
|
266
|
+
async (_, nextAttempt) => {
|
|
267
|
+
p.log.warn(
|
|
268
|
+
` Retry upload ${relativePath} (${nextAttempt}/${RETRY_ATTEMPTS})`,
|
|
269
|
+
);
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
if (!result.ok) {
|
|
274
|
+
p.log.warn(` Failed to upload after ${RETRY_ATTEMPTS} attempts: ${result.error}`);
|
|
275
|
+
} else {
|
|
276
|
+
localPreviewServer?.notifyReload();
|
|
209
277
|
}
|
|
210
|
-
}
|
|
278
|
+
} catch (error) {
|
|
279
|
+
p.log.warn(` Error processing ${relativePath}: ${error.message}`);
|
|
280
|
+
} finally {
|
|
281
|
+
inFlightPaths.delete(relativePath);
|
|
211
282
|
|
|
212
|
-
|
|
283
|
+
if (pendingResyncPaths.delete(relativePath)) {
|
|
284
|
+
queueSync(relativePath);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const watcher = watch(rootDir, { recursive: true }, (eventType, filename) => {
|
|
290
|
+
if (!filename) return;
|
|
291
|
+
if (hasDotfileSegment(filename)) return;
|
|
292
|
+
if (shouldIgnoreWatchedPath(filename, builtTheme)) return;
|
|
293
|
+
|
|
294
|
+
const relativePath = filename.split(path.sep).join("/");
|
|
295
|
+
queueSync(relativePath);
|
|
213
296
|
});
|
|
214
297
|
|
|
298
|
+
let cleanedUp = false;
|
|
215
299
|
const cleanup = async () => {
|
|
300
|
+
if (cleanedUp) return;
|
|
301
|
+
cleanedUp = true;
|
|
302
|
+
|
|
216
303
|
watcher.close();
|
|
217
304
|
for (const timer of debounceMap.values()) clearTimeout(timer);
|
|
218
|
-
|
|
305
|
+
|
|
306
|
+
await runCleanupStep("Local preview shutdown", () => localPreviewServer?.close());
|
|
307
|
+
await runCleanupStep("Build watcher shutdown", buildCleanup);
|
|
308
|
+
|
|
219
309
|
p.outro("Dev mode stopped.");
|
|
220
310
|
process.exit(0);
|
|
221
311
|
};
|
package/lib/fs-utils.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { access, readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const isDotfile = (name) => name.startsWith(".");
|
|
5
|
+
|
|
6
|
+
export const fileExists = async (filePath) => {
|
|
7
|
+
try {
|
|
8
|
+
await access(filePath);
|
|
9
|
+
return true;
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const listFilesRecursive = async (absoluteDir) => {
|
|
16
|
+
const entries = await readdir(absoluteDir, { withFileTypes: true });
|
|
17
|
+
const files = [];
|
|
18
|
+
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (isDotfile(entry.name)) continue;
|
|
21
|
+
|
|
22
|
+
const absolutePath = path.join(absoluteDir, entry.name);
|
|
23
|
+
|
|
24
|
+
if (entry.isDirectory()) {
|
|
25
|
+
files.push(...(await listFilesRecursive(absolutePath)));
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (entry.isFile()) {
|
|
30
|
+
files.push(absolutePath);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return files.sort((left, right) => left.localeCompare(right));
|
|
35
|
+
};
|