tiendu 0.8.2 → 0.9.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 +7 -6
- package/bin/tiendu.js +30 -20
- package/lib/build.mjs +7 -7
- package/lib/config.mjs +3 -3
- package/lib/dev.mjs +9 -26
- package/lib/init.mjs +184 -15
- package/lib/preview.mjs +2 -0
- package/lib/publish.mjs +2 -2
- package/lib/pull.mjs +64 -2
- package/lib/push.mjs +7 -7
- package/lib/stores.mjs +7 -2
- package/package.json +1 -1
- package/lib/local-preview.mjs +0 -393
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Official CLI for [Tiendu](https://tiendu.uy) — develop and publish storefront themes from your local machine.
|
|
4
4
|
|
|
5
|
-
Download your store's theme, edit files locally, preview changes
|
|
5
|
+
Download your store's theme, edit files locally, preview changes with a sharable preview URL, and publish when you're ready — all from the terminal.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -58,7 +58,7 @@ tiendu stores set <store-id> --non-interactive
|
|
|
58
58
|
|
|
59
59
|
When `--non-interactive` is passed, the CLI avoids prompts and prints plain text output.
|
|
60
60
|
|
|
61
|
-
`tiendu dev` creates a remote preview, builds or stages your theme into `dist/`, runs an initial push from that prepared output, and then watches for changes. It prints a
|
|
61
|
+
`tiendu dev` creates or attaches a remote preview, builds or stages your theme into `dist/`, runs an initial push from that prepared output, and then watches for changes. It prints a sharable preview URL like:
|
|
62
62
|
|
|
63
63
|
```
|
|
64
64
|
http://preview-xxxxxxxxxxxx.tiendu.uy/
|
|
@@ -67,7 +67,6 @@ http://preview-xxxxxxxxxxxx.tiendu.uy/
|
|
|
67
67
|
The preview renders with the real Tiendu engine — same output as production.
|
|
68
68
|
|
|
69
69
|
When `tiendu dev` starts, it always re-syncs your current local files to the active preview before watching for changes.
|
|
70
|
-
It also starts a local live-preview URL that proxies the preview and auto-reloads after successful syncs.
|
|
71
70
|
|
|
72
71
|
By default, the CLI preserves editor-managed theme state so local development does not overwrite changes made in the theme editor. State files are `templates/*.json`, section group files like `sections/header-group.json`, and `config/settings_data.json`. Use `--override-state` when your local state JSON files should override the editor state.
|
|
73
72
|
|
|
@@ -118,14 +117,17 @@ tiendu stores set 123 --non-interactive
|
|
|
118
117
|
|
|
119
118
|
### `tiendu pull`
|
|
120
119
|
|
|
121
|
-
Downloads the
|
|
120
|
+
Downloads the attached preview theme, or the live theme with `--live`, into `dist/` and syncs theme directories to `src/`.
|
|
122
121
|
|
|
123
122
|
- `pull` clears `dist/` first.
|
|
124
123
|
- The downloaded archive is then extracted into `dist/`.
|
|
125
|
-
-
|
|
124
|
+
- Theme directories from the download are synced into `src/`, overwriting local theme files.
|
|
125
|
+
- In interactive mode, the CLI asks before overwriting `src/`.
|
|
126
|
+
- In non-interactive mode, `src/` is overwritten without prompting.
|
|
126
127
|
|
|
127
128
|
```bash
|
|
128
129
|
tiendu pull
|
|
130
|
+
tiendu pull --live
|
|
129
131
|
```
|
|
130
132
|
|
|
131
133
|
---
|
|
@@ -184,7 +186,6 @@ tiendu dev --override-state
|
|
|
184
186
|
- Re-syncs the full local theme to the preview on startup
|
|
185
187
|
- Syncs file creates, edits and deletes
|
|
186
188
|
- Retries failed file sync operations up to 3 times before giving up
|
|
187
|
-
- Starts a local live-preview URL on `localhost` that refreshes after successful uploads
|
|
188
189
|
- Handles both text and binary files (images, fonts, etc.)
|
|
189
190
|
- Press `Ctrl+C` to stop
|
|
190
191
|
|
package/bin/tiendu.js
CHANGED
|
@@ -21,18 +21,19 @@ import {
|
|
|
21
21
|
checkForUpdatesNow,
|
|
22
22
|
getCurrentVersion,
|
|
23
23
|
} from "../lib/update-check.mjs";
|
|
24
|
-
import {
|
|
24
|
+
import { resolveOverrideState } from "../lib/config.mjs";
|
|
25
25
|
import { configureUi } from "../lib/ui.mjs";
|
|
26
26
|
|
|
27
27
|
const HELP = `
|
|
28
28
|
tiendu — Tiendu theme development CLI
|
|
29
29
|
|
|
30
30
|
Usage:
|
|
31
|
-
tiendu init [apiKey] [baseUrl] [--dir <path>]
|
|
31
|
+
tiendu init [apiKey] [baseUrl] [--api-key <key>] [--base-url <url>] [--preview-key <key>] [--dir <path>]
|
|
32
32
|
Initialize interactively, or reset config with direct credentials
|
|
33
33
|
tiendu stores list List stores available for the configured API key
|
|
34
34
|
tiendu stores set <storeId> Select the active store
|
|
35
|
-
tiendu pull [previewKey]
|
|
35
|
+
tiendu pull [previewKey] [--live]
|
|
36
|
+
Download the attached preview or a specific preview into dist/ and src/
|
|
36
37
|
tiendu build [--override-state]
|
|
37
38
|
Build or stage the current theme into dist/
|
|
38
39
|
tiendu push [previewKey] [--skip-build] [--override-state]
|
|
@@ -57,6 +58,10 @@ Usage:
|
|
|
57
58
|
Global options:
|
|
58
59
|
--non-interactive Disable prompts, print plain text output, and skip confirmations
|
|
59
60
|
--dir <path> Create the project inside a new directory during init
|
|
61
|
+
--api-key <key> Provide an API key to tiendu init (alternative to positional arg)
|
|
62
|
+
--base-url <url> Provide a base URL to tiendu init (alternative to positional arg)
|
|
63
|
+
--preview-key <key> Attach a preview during tiendu init
|
|
64
|
+
--live Force tiendu pull to download the live theme
|
|
60
65
|
--skip-build Reuse the existing dist/ output for push or publish
|
|
61
66
|
--override-state Sync local theme state JSON and override editor state
|
|
62
67
|
--preserve-state Preserve editor-managed state JSON (default)
|
|
@@ -69,12 +74,16 @@ Init behavior:
|
|
|
69
74
|
tiendu init Interactive setup wizard
|
|
70
75
|
tiendu init <apiKey> Reset saved config and connect using the default base URL
|
|
71
76
|
tiendu init <apiKey> <url> Reset saved config and connect using a custom base URL
|
|
77
|
+
tiendu init --api-key <key> --base-url <url> Using flags instead of positional args
|
|
78
|
+
tiendu init --preview-key <key> Attach a preview directly
|
|
72
79
|
The default base URL points to the Tiendu platform and rarely needs to change.
|
|
73
80
|
If exactly one store is available, it is selected automatically.
|
|
74
|
-
If multiple stores are available,
|
|
81
|
+
If multiple stores are available, the interactive init will let you choose one.
|
|
82
|
+
After selecting a store, you can also create or attach a preview.
|
|
75
83
|
|
|
76
84
|
Agent-friendly setup:
|
|
77
85
|
tiendu init <apiKey> [baseUrl] --non-interactive
|
|
86
|
+
tiendu init --api-key <key> --base-url <url> --non-interactive
|
|
78
87
|
tiendu stores list --non-interactive
|
|
79
88
|
tiendu stores set <id> --non-interactive
|
|
80
89
|
tiendu pull --non-interactive
|
|
@@ -84,8 +93,8 @@ Agent-friendly setup:
|
|
|
84
93
|
Push and pull behavior:
|
|
85
94
|
build always prepares dist/ as the local deploy artifact.
|
|
86
95
|
push sends a zip of dist/ to the target preview.
|
|
87
|
-
pull
|
|
88
|
-
pull
|
|
96
|
+
pull downloads from the attached preview by default, or the live theme with --live.
|
|
97
|
+
pull also syncs downloaded theme directories to src/.
|
|
89
98
|
|
|
90
99
|
Pipeline behavior:
|
|
91
100
|
tiendu.config.json can enable optional pipeline steps.
|
|
@@ -123,13 +132,13 @@ const parseArgv = (argv) => {
|
|
|
123
132
|
continue;
|
|
124
133
|
}
|
|
125
134
|
|
|
126
|
-
if (arg === "--dir") {
|
|
135
|
+
if (arg === "--dir" || arg === "--api-key" || arg === "--base-url" || arg === "--preview-key") {
|
|
127
136
|
const value = argv[index + 1];
|
|
128
137
|
if (!value || value.startsWith("--")) {
|
|
129
|
-
console.error(
|
|
138
|
+
console.error(`Missing value for ${arg}.`);
|
|
130
139
|
process.exit(1);
|
|
131
140
|
}
|
|
132
|
-
values.set(
|
|
141
|
+
values.set(arg.slice(2), value);
|
|
133
142
|
index += 1;
|
|
134
143
|
continue;
|
|
135
144
|
}
|
|
@@ -182,8 +191,9 @@ const main = async () => {
|
|
|
182
191
|
const initArgs = positionals.slice(1);
|
|
183
192
|
await init({
|
|
184
193
|
dirArg: values.get("dir"),
|
|
185
|
-
apiKeyArg: initArgs[0],
|
|
186
|
-
baseUrlArg: initArgs[1],
|
|
194
|
+
apiKeyArg: values.get("api-key") ?? initArgs[0],
|
|
195
|
+
baseUrlArg: values.get("base-url") ?? initArgs[1],
|
|
196
|
+
previewKeyArg: values.get("preview-key"),
|
|
187
197
|
});
|
|
188
198
|
return;
|
|
189
199
|
}
|
|
@@ -205,44 +215,44 @@ const main = async () => {
|
|
|
205
215
|
}
|
|
206
216
|
|
|
207
217
|
if (command === "pull") {
|
|
208
|
-
await pull({ previewKey: positionals[1] });
|
|
218
|
+
await pull({ previewKey: positionals[1], forceLive: flags.has("--live") });
|
|
209
219
|
return;
|
|
210
220
|
}
|
|
211
221
|
|
|
212
222
|
if (command === "build") {
|
|
213
|
-
const
|
|
223
|
+
const overrideState = await resolveOverrideState({
|
|
214
224
|
overrideStateFlag,
|
|
215
225
|
preserveStateFlag,
|
|
216
226
|
});
|
|
217
|
-
const result = await build({
|
|
227
|
+
const result = await build({ overrideState });
|
|
218
228
|
if (!result.ok) process.exit(1);
|
|
219
229
|
return;
|
|
220
230
|
}
|
|
221
231
|
|
|
222
232
|
if (command === "push") {
|
|
223
|
-
const
|
|
233
|
+
const overrideState = await resolveOverrideState({
|
|
224
234
|
overrideStateFlag,
|
|
225
235
|
preserveStateFlag,
|
|
226
236
|
});
|
|
227
|
-
await push({ skipBuild, previewKey: positionals[1],
|
|
237
|
+
await push({ skipBuild, previewKey: positionals[1], overrideState });
|
|
228
238
|
return;
|
|
229
239
|
}
|
|
230
240
|
|
|
231
241
|
if (command === "dev") {
|
|
232
|
-
const
|
|
242
|
+
const overrideState = await resolveOverrideState({
|
|
233
243
|
overrideStateFlag,
|
|
234
244
|
preserveStateFlag,
|
|
235
245
|
});
|
|
236
|
-
await dev({
|
|
246
|
+
await dev({ overrideState });
|
|
237
247
|
return;
|
|
238
248
|
}
|
|
239
249
|
|
|
240
250
|
if (command === "publish") {
|
|
241
|
-
const
|
|
251
|
+
const overrideState = await resolveOverrideState({
|
|
242
252
|
overrideStateFlag,
|
|
243
253
|
preserveStateFlag,
|
|
244
254
|
});
|
|
245
|
-
await publish({ skipBuild, previewKey: positionals[1],
|
|
255
|
+
await publish({ skipBuild, previewKey: positionals[1], overrideState });
|
|
246
256
|
return;
|
|
247
257
|
}
|
|
248
258
|
|
package/lib/build.mjs
CHANGED
|
@@ -129,10 +129,10 @@ const rewriteDirectAssetPaths = (source, knownAssetLogicalPaths) =>
|
|
|
129
129
|
return flattened ? `/assets/${flattened}${suffix}` : match;
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
const shouldCopyThemeSourceFile = (sourceRelativePath, outputRelativePath,
|
|
132
|
+
const shouldCopyThemeSourceFile = (sourceRelativePath, outputRelativePath, overrideState = false) => {
|
|
133
133
|
const extension = path.extname(sourceRelativePath).toLowerCase();
|
|
134
134
|
if (ENTRY_SOURCE_EXTENSIONS.has(extension)) return false;
|
|
135
|
-
if (!
|
|
135
|
+
if (!overrideState && isInstanceFile(outputRelativePath)) return false;
|
|
136
136
|
return true;
|
|
137
137
|
};
|
|
138
138
|
|
|
@@ -165,7 +165,7 @@ const copyThemeFiles = async (
|
|
|
165
165
|
distDir,
|
|
166
166
|
themeSourceDirs,
|
|
167
167
|
knownAssetLogicalPaths,
|
|
168
|
-
|
|
168
|
+
overrideState = false,
|
|
169
169
|
) => {
|
|
170
170
|
for (const sourceDir of themeSourceDirs) {
|
|
171
171
|
const absoluteSourceDir = path.join(rootDir, sourceDir.sourceRelativeDir);
|
|
@@ -181,7 +181,7 @@ const copyThemeFiles = async (
|
|
|
181
181
|
sourceDir.outputRelativeDir,
|
|
182
182
|
nestedRelativePath,
|
|
183
183
|
);
|
|
184
|
-
if (!shouldCopyThemeSourceFile(sourceRelativePath, outputRelativePath,
|
|
184
|
+
if (!shouldCopyThemeSourceFile(sourceRelativePath, outputRelativePath, overrideState)) continue;
|
|
185
185
|
await copyThemeSourceFile(
|
|
186
186
|
rootDir,
|
|
187
187
|
distDir,
|
|
@@ -261,7 +261,7 @@ const runEntryBuilds = async (jsBuildOptions, cssBuildOptions) => {
|
|
|
261
261
|
* @param {{ watch?: boolean }} options
|
|
262
262
|
* @returns {Promise<{ ok: boolean, cleanup?: () => Promise<void> }>}
|
|
263
263
|
*/
|
|
264
|
-
export const build = async ({ watch: watchMode = false,
|
|
264
|
+
export const build = async ({ watch: watchMode = false, overrideState = false } = {}) => {
|
|
265
265
|
const rootDir = process.cwd();
|
|
266
266
|
const distDir = path.join(rootDir, "dist");
|
|
267
267
|
|
|
@@ -324,7 +324,7 @@ export const build = async ({ watch: watchMode = false, includeInstances = false
|
|
|
324
324
|
distDir,
|
|
325
325
|
themeSourceDirs,
|
|
326
326
|
knownAssetLogicalPaths,
|
|
327
|
-
|
|
327
|
+
overrideState,
|
|
328
328
|
);
|
|
329
329
|
|
|
330
330
|
if (cssCount > 0 && pipeline.postcss) {
|
|
@@ -512,7 +512,7 @@ export const build = async ({ watch: watchMode = false, includeInstances = false
|
|
|
512
512
|
const timer = setTimeout(async () => {
|
|
513
513
|
debounceMap.delete(sourceRelativePath);
|
|
514
514
|
try {
|
|
515
|
-
if (!shouldCopyThemeSourceFile(sourceRelativePath, outputRelativePath,
|
|
515
|
+
if (!shouldCopyThemeSourceFile(sourceRelativePath, outputRelativePath, overrideState)) {
|
|
516
516
|
return;
|
|
517
517
|
}
|
|
518
518
|
|
package/lib/config.mjs
CHANGED
|
@@ -153,7 +153,7 @@ export const readThemePipelineConfig = async () =>
|
|
|
153
153
|
* @param {TienduThemeConfig | null} themeConfig
|
|
154
154
|
* @returns {boolean}
|
|
155
155
|
*/
|
|
156
|
-
export const
|
|
156
|
+
export const getThemeOverrideStateConfig = (themeConfig) => {
|
|
157
157
|
if (themeConfig?.sync?.state !== undefined) return themeConfig.sync.state;
|
|
158
158
|
if (themeConfig?.sync?.instances === "include") return true;
|
|
159
159
|
if (themeConfig?.sync?.instances === "preserve") return false;
|
|
@@ -165,7 +165,7 @@ export const getThemeIncludeInstancesConfig = (themeConfig) => {
|
|
|
165
165
|
* @param {{ overrideStateFlag?: boolean, preserveStateFlag?: boolean }} [options]
|
|
166
166
|
* @returns {Promise<boolean>}
|
|
167
167
|
*/
|
|
168
|
-
export const
|
|
168
|
+
export const resolveOverrideState = async ({
|
|
169
169
|
overrideStateFlag = false,
|
|
170
170
|
preserveStateFlag = false,
|
|
171
171
|
} = {}) => {
|
|
@@ -178,7 +178,7 @@ export const resolveIncludeInstances = async ({
|
|
|
178
178
|
if (overrideStateFlag) return true;
|
|
179
179
|
if (preserveStateFlag) return false;
|
|
180
180
|
|
|
181
|
-
return
|
|
181
|
+
return getThemeOverrideStateConfig(await readThemeConfig());
|
|
182
182
|
};
|
|
183
183
|
|
|
184
184
|
/** @returns {string} */
|
package/lib/dev.mjs
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
} from "./api.mjs";
|
|
13
13
|
import { build, isInstanceFile } from "./build.mjs";
|
|
14
14
|
import { isDotfile } from "./fs-utils.mjs";
|
|
15
|
-
import { startLocalPreviewServer } from "./local-preview.mjs";
|
|
16
15
|
import { pushPreparedDirectoryToPreview } from "./push.mjs";
|
|
17
16
|
import { retryAsync } from "./retry.mjs";
|
|
18
17
|
import * as ui from "./ui.mjs";
|
|
@@ -115,15 +114,14 @@ const deleteFileWithRetries = (
|
|
|
115
114
|
},
|
|
116
115
|
);
|
|
117
116
|
|
|
118
|
-
export const dev = async ({
|
|
117
|
+
export const dev = async ({ overrideState = false } = {}) => {
|
|
119
118
|
const { config, credentials } = await loadConfigOrFail();
|
|
120
119
|
const { apiBaseUrl, storeId } = config;
|
|
121
120
|
const { apiKey } = credentials;
|
|
122
121
|
const rootDir = getDistDir();
|
|
123
122
|
let buildCleanup = null;
|
|
124
|
-
let localPreviewServer = null;
|
|
125
123
|
|
|
126
|
-
const buildResult = await build({ watch: true,
|
|
124
|
+
const buildResult = await build({ watch: true, overrideState });
|
|
127
125
|
if (!buildResult.ok) {
|
|
128
126
|
ui.log.error("Initial build failed. Fix errors and try again.");
|
|
129
127
|
process.exit(1);
|
|
@@ -133,7 +131,7 @@ export const dev = async ({ includeInstances = false } = {}) => {
|
|
|
133
131
|
// Resolve preview via shared interactive picker
|
|
134
132
|
const previewKey = await resolvePreviewKeyInteractively({ config, credentials });
|
|
135
133
|
|
|
136
|
-
// Fetch preview
|
|
134
|
+
// Fetch preview details for user-facing URLs.
|
|
137
135
|
const previewResult = await fetchPreviewDetails(
|
|
138
136
|
apiBaseUrl,
|
|
139
137
|
apiKey,
|
|
@@ -145,7 +143,6 @@ export const dev = async ({ includeInstances = false } = {}) => {
|
|
|
145
143
|
process.exit(1);
|
|
146
144
|
}
|
|
147
145
|
|
|
148
|
-
const previewHostname = previewResult.data.preview.previewHostname;
|
|
149
146
|
const previewUrl = previewResult.data.url;
|
|
150
147
|
|
|
151
148
|
const spinner = ui.spinner();
|
|
@@ -161,7 +158,7 @@ export const dev = async ({ includeInstances = false } = {}) => {
|
|
|
161
158
|
compressMessage: "Compressing files...",
|
|
162
159
|
retryMessage: (result, nextAttempt) =>
|
|
163
160
|
`Initial push failed. Retrying ${nextAttempt}/${RETRY_ATTEMPTS}... ${result.error}`,
|
|
164
|
-
|
|
161
|
+
overrideState,
|
|
165
162
|
});
|
|
166
163
|
|
|
167
164
|
if (!uploadResult.ok) {
|
|
@@ -170,22 +167,13 @@ export const dev = async ({ includeInstances = false } = {}) => {
|
|
|
170
167
|
process.exit(1);
|
|
171
168
|
}
|
|
172
169
|
|
|
173
|
-
try {
|
|
174
|
-
localPreviewServer = await startLocalPreviewServer({
|
|
175
|
-
apiBaseUrl,
|
|
176
|
-
previewHostname,
|
|
177
|
-
});
|
|
178
|
-
} catch (error) {
|
|
179
|
-
ui.log.warn(`Could not start local live preview: ${error.message}`);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
170
|
spinner.stop(`Preview ready (${previewKey}).`);
|
|
183
|
-
if (localPreviewServer) {
|
|
184
|
-
ui.log.message(`Local live preview: ${localPreviewServer.url}`);
|
|
185
|
-
}
|
|
186
171
|
ui.log.message(`Sharable preview: ${previewUrl}`);
|
|
187
172
|
ui.log.message(
|
|
188
|
-
`Theme
|
|
173
|
+
`Theme editor: ${apiBaseUrl}/admin/tiendas/${storeId}/tema/personalizar?preview=${previewKey}`,
|
|
174
|
+
);
|
|
175
|
+
ui.log.message(
|
|
176
|
+
`Theme state: ${overrideState ? "overridden from local files" : "preserved from the theme editor"}`,
|
|
189
177
|
);
|
|
190
178
|
|
|
191
179
|
ui.log.message("Watching for changes - press Ctrl+C to stop.");
|
|
@@ -239,8 +227,6 @@ export const dev = async ({ includeInstances = false } = {}) => {
|
|
|
239
227
|
|
|
240
228
|
if (!result.ok) {
|
|
241
229
|
ui.log.warn(` Failed to delete after ${RETRY_ATTEMPTS} attempts: ${result.error}`);
|
|
242
|
-
} else {
|
|
243
|
-
localPreviewServer?.notifyReload();
|
|
244
230
|
}
|
|
245
231
|
}
|
|
246
232
|
|
|
@@ -272,8 +258,6 @@ export const dev = async ({ includeInstances = false } = {}) => {
|
|
|
272
258
|
|
|
273
259
|
if (!result.ok) {
|
|
274
260
|
ui.log.warn(` Failed to upload after ${RETRY_ATTEMPTS} attempts: ${result.error}`);
|
|
275
|
-
} else {
|
|
276
|
-
localPreviewServer?.notifyReload();
|
|
277
261
|
}
|
|
278
262
|
} catch (error) {
|
|
279
263
|
ui.log.warn(` Error processing ${relativePath}: ${error.message}`);
|
|
@@ -292,7 +276,7 @@ export const dev = async ({ includeInstances = false } = {}) => {
|
|
|
292
276
|
if (shouldIgnoreWatchedPath(filename, true)) return;
|
|
293
277
|
|
|
294
278
|
const relativePath = filename.split(path.sep).join("/");
|
|
295
|
-
if (!
|
|
279
|
+
if (!overrideState && isInstanceFile(relativePath)) return;
|
|
296
280
|
queueSync(relativePath);
|
|
297
281
|
});
|
|
298
282
|
|
|
@@ -304,7 +288,6 @@ export const dev = async ({ includeInstances = false } = {}) => {
|
|
|
304
288
|
watcher.close();
|
|
305
289
|
for (const timer of debounceMap.values()) clearTimeout(timer);
|
|
306
290
|
|
|
307
|
-
await runCleanupStep("Local preview shutdown", () => localPreviewServer?.close());
|
|
308
291
|
await runCleanupStep("Build watcher shutdown", buildCleanup);
|
|
309
292
|
|
|
310
293
|
ui.outro("Dev mode stopped.");
|
package/lib/init.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { readConfig, readCredentials, writeConfig, writeCredentials } from "./config.mjs";
|
|
4
|
-
import { fetchUserStores } from "./api.mjs";
|
|
4
|
+
import { fetchUserStores, fetchPreview } from "./api.mjs";
|
|
5
5
|
import { formatInitSummary } from "./stores.mjs";
|
|
6
|
+
import { listPreviews, createPreview, getPreviewDisplayName, getPreviewUrl } from "./preview.mjs";
|
|
7
|
+
import { pull } from "./pull.mjs";
|
|
6
8
|
import * as ui from "./ui.mjs";
|
|
7
9
|
|
|
8
10
|
const DEFAULT_API_BASE_URL = "https://tiendu.uy";
|
|
@@ -21,19 +23,52 @@ const resolveBaseUrlOrFail = (baseUrlArg) => {
|
|
|
21
23
|
return candidate;
|
|
22
24
|
};
|
|
23
25
|
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
+
const checkTargetDir = async (dirArg) => {
|
|
27
|
+
if (dirArg) {
|
|
28
|
+
const targetDir = path.resolve(process.cwd(), dirArg);
|
|
29
|
+
try {
|
|
30
|
+
const entries = await readdir(targetDir);
|
|
31
|
+
const hasContent = entries.filter((n) => !n.startsWith(".")).length > 0;
|
|
32
|
+
if (hasContent && !ui.isInteractive()) {
|
|
33
|
+
ui.log.error(`Directory "${dirArg}" already exists and is not empty.`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
if (hasContent) {
|
|
38
|
+
const confirmed = await ui.confirm({
|
|
39
|
+
message: `Directory "${dirArg}" already exists. Overwrite its contents?`,
|
|
40
|
+
});
|
|
41
|
+
if (ui.isCancel(confirmed) || !confirmed) {
|
|
42
|
+
ui.cancel("Setup cancelled.");
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Directory doesn't exist — fine
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
28
51
|
|
|
29
52
|
try {
|
|
30
|
-
await
|
|
31
|
-
|
|
32
|
-
|
|
53
|
+
const entries = await readdir(process.cwd());
|
|
54
|
+
const hasContent = entries.filter((n) => !n.startsWith(".") && n !== ".cli").length > 0;
|
|
55
|
+
if (hasContent && ui.isInteractive()) {
|
|
56
|
+
const confirmed = await ui.confirm({
|
|
57
|
+
message: "Current directory is not empty. Overwrite its contents?",
|
|
58
|
+
});
|
|
59
|
+
if (ui.isCancel(confirmed) || !confirmed) {
|
|
60
|
+
ui.cancel("Setup cancelled.");
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
33
64
|
} catch {
|
|
34
|
-
//
|
|
65
|
+
// Can't happen for cwd
|
|
35
66
|
}
|
|
67
|
+
};
|
|
36
68
|
|
|
69
|
+
const enterTargetDir = async (dirArg) => {
|
|
70
|
+
if (!dirArg) return;
|
|
71
|
+
const targetDir = path.resolve(process.cwd(), dirArg);
|
|
37
72
|
await mkdir(targetDir, { recursive: true });
|
|
38
73
|
process.chdir(targetDir);
|
|
39
74
|
};
|
|
@@ -108,9 +143,7 @@ const collectDirectInputs = (apiKeyArg, baseUrlArg) => {
|
|
|
108
143
|
};
|
|
109
144
|
};
|
|
110
145
|
|
|
111
|
-
export const init = async ({ dirArg, apiKeyArg, baseUrlArg } = {}) => {
|
|
112
|
-
await prepareWorkDir(dirArg);
|
|
113
|
-
|
|
146
|
+
export const init = async ({ dirArg, apiKeyArg, baseUrlArg, previewKeyArg } = {}) => {
|
|
114
147
|
const existingConfig = await readConfig();
|
|
115
148
|
const existingCredentials = await readCredentials();
|
|
116
149
|
const hasExistingSetup = Boolean(existingConfig || existingCredentials);
|
|
@@ -121,7 +154,7 @@ export const init = async ({ dirArg, apiKeyArg, baseUrlArg } = {}) => {
|
|
|
121
154
|
process.exit(1);
|
|
122
155
|
}
|
|
123
156
|
|
|
124
|
-
if (!directMode) {
|
|
157
|
+
if (!directMode && !dirArg) {
|
|
125
158
|
await ensureResetAllowed(hasExistingSetup);
|
|
126
159
|
}
|
|
127
160
|
|
|
@@ -146,20 +179,156 @@ export const init = async ({ dirArg, apiKeyArg, baseUrlArg } = {}) => {
|
|
|
146
179
|
process.exit(1);
|
|
147
180
|
}
|
|
148
181
|
|
|
149
|
-
|
|
150
|
-
|
|
182
|
+
let selectedStore = stores.length === 1 ? stores[0] : null;
|
|
183
|
+
|
|
184
|
+
if (!selectedStore && ui.isInteractive()) {
|
|
185
|
+
spinner.stop(`Connected to Tiendu. ${stores.length} stores available.`);
|
|
186
|
+
|
|
187
|
+
const storeOptions = stores.map((store) => ({
|
|
188
|
+
value: store.id,
|
|
189
|
+
label: store.name,
|
|
190
|
+
hint: `ID: ${store.id}`,
|
|
191
|
+
}));
|
|
192
|
+
|
|
193
|
+
const chosen = await ui.select({
|
|
194
|
+
message: "Select a store",
|
|
195
|
+
options: storeOptions,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (ui.isCancel(chosen)) {
|
|
199
|
+
ui.cancel("Setup cancelled.");
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
selectedStore = stores.find((s) => s.id === chosen) ?? null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (selectedStore || stores.length === 1) {
|
|
207
|
+
spinner.stop(
|
|
208
|
+
`Connected to Tiendu. ${stores.length} store${stores.length === 1 ? "" : "s"} available.`,
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
spinner.stop(
|
|
212
|
+
`Connected to Tiendu. ${stores.length} stores available.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let previewKey = null;
|
|
217
|
+
|
|
218
|
+
if (previewKeyArg && selectedStore) {
|
|
219
|
+
const result = await fetchPreview(apiBaseUrl, apiKey, selectedStore.id, previewKeyArg);
|
|
220
|
+
if (result.ok) {
|
|
221
|
+
previewKey = previewKeyArg;
|
|
222
|
+
const url = getPreviewUrl(apiBaseUrl, result.data);
|
|
223
|
+
const displayName = getPreviewDisplayName(result.data);
|
|
224
|
+
ui.log.message(`Preview "${displayName}" (${previewKey})`);
|
|
225
|
+
ui.log.message(` ${url}`);
|
|
226
|
+
} else {
|
|
227
|
+
ui.log.error(`Preview ${previewKeyArg} not found.`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
} else if (selectedStore && ui.isInteractive()) {
|
|
231
|
+
const listResult = await listPreviews(apiBaseUrl, apiKey, selectedStore.id);
|
|
232
|
+
let previews = [];
|
|
233
|
+
if (listResult.ok) {
|
|
234
|
+
previews = listResult.data;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const LIVE_VALUE = "__live__";
|
|
238
|
+
const CREATE_NEW_VALUE = "__create_new__";
|
|
239
|
+
|
|
240
|
+
const options = [
|
|
241
|
+
{
|
|
242
|
+
value: LIVE_VALUE,
|
|
243
|
+
label: "Live theme",
|
|
244
|
+
hint: "No preview — work directly with the live storefront",
|
|
245
|
+
},
|
|
246
|
+
...previews.map((p) => ({
|
|
247
|
+
value: p.previewKey,
|
|
248
|
+
label: `${getPreviewDisplayName(p)} (${p.previewKey})`,
|
|
249
|
+
})),
|
|
250
|
+
{ value: CREATE_NEW_VALUE, label: "Create a new preview" },
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
const chosen = await ui.select({
|
|
254
|
+
message: "Select a preview",
|
|
255
|
+
options,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (ui.isCancel(chosen)) {
|
|
259
|
+
ui.cancel("Setup cancelled.");
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (chosen === LIVE_VALUE) {
|
|
264
|
+
// Live theme — no preview key
|
|
265
|
+
} else if (chosen === CREATE_NEW_VALUE) {
|
|
266
|
+
const nameInput = await ui.text({
|
|
267
|
+
message: "Preview name (optional)",
|
|
268
|
+
placeholder: "Press Enter to skip",
|
|
269
|
+
defaultValue: "",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (ui.isCancel(nameInput)) {
|
|
273
|
+
ui.cancel("Setup cancelled.");
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const name = (nameInput ?? "").trim();
|
|
278
|
+
const createSpinner = ui.spinner();
|
|
279
|
+
createSpinner.start("Creating preview...");
|
|
280
|
+
|
|
281
|
+
const createResult = await createPreview(
|
|
282
|
+
apiBaseUrl,
|
|
283
|
+
apiKey,
|
|
284
|
+
selectedStore.id,
|
|
285
|
+
name,
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
if (!createResult.ok) {
|
|
289
|
+
createSpinner.stop("Failed to create preview.", 1);
|
|
290
|
+
ui.log.error(createResult.error);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const preview = createResult.data;
|
|
295
|
+
previewKey = preview.previewKey;
|
|
296
|
+
const url = getPreviewUrl(apiBaseUrl, preview);
|
|
297
|
+
const displayName = getPreviewDisplayName(preview);
|
|
298
|
+
createSpinner.stop(`Preview "${displayName}" created (${previewKey})`);
|
|
299
|
+
ui.log.message(` ${url}`);
|
|
300
|
+
} else {
|
|
301
|
+
const selectedPreview = previews.find((p) => p.previewKey === chosen);
|
|
302
|
+
previewKey = chosen;
|
|
303
|
+
if (selectedPreview) {
|
|
304
|
+
const displayName = getPreviewDisplayName(selectedPreview);
|
|
305
|
+
const url = getPreviewUrl(apiBaseUrl, selectedPreview);
|
|
306
|
+
ui.log.message(`Preview "${displayName}" (${previewKey})`);
|
|
307
|
+
ui.log.message(` ${url}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await checkTargetDir(dirArg);
|
|
313
|
+
await enterTargetDir(dirArg);
|
|
151
314
|
|
|
152
315
|
await writeCredentials({ apiKey });
|
|
153
316
|
await writeConfig({
|
|
154
317
|
apiBaseUrl,
|
|
155
318
|
...(selectedStore ? { storeId: selectedStore.id } : {}),
|
|
319
|
+
...(previewKey ? { previewKey } : {}),
|
|
156
320
|
});
|
|
157
321
|
|
|
322
|
+
if (selectedStore) {
|
|
323
|
+
await pull({ previewKey: previewKey || undefined, confirmSourceSync: false });
|
|
324
|
+
}
|
|
325
|
+
|
|
158
326
|
const summary = formatInitSummary({
|
|
159
327
|
apiBaseUrl,
|
|
160
328
|
usedDefaultBaseUrl,
|
|
161
329
|
stores,
|
|
162
330
|
selectedStore,
|
|
331
|
+
previewKey,
|
|
163
332
|
});
|
|
164
333
|
|
|
165
334
|
if (ui.isInteractive()) {
|
package/lib/preview.mjs
CHANGED
|
@@ -429,12 +429,14 @@ export const previewShow = async () => {
|
|
|
429
429
|
const preview = result.data;
|
|
430
430
|
const url = getPreviewUrl(config.apiBaseUrl, preview);
|
|
431
431
|
const displayName = getPreviewDisplayName(preview);
|
|
432
|
+
const editorUrl = `${config.apiBaseUrl}/admin/tiendas/${config.storeId}/tema/personalizar?preview=${preview.previewKey}`;
|
|
432
433
|
|
|
433
434
|
ui.note(
|
|
434
435
|
[
|
|
435
436
|
`Name: ${displayName}`,
|
|
436
437
|
`Key: ${preview.previewKey}`,
|
|
437
438
|
`URL: ${url}`,
|
|
439
|
+
`Editor: ${editorUrl}`,
|
|
438
440
|
`Created: ${formatRelativeDate(preview.createdAt)}`,
|
|
439
441
|
].join("\n"),
|
|
440
442
|
"Attached preview",
|
package/lib/publish.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import { push } from "./push.mjs";
|
|
8
8
|
import * as ui from "./ui.mjs";
|
|
9
9
|
|
|
10
|
-
export const publish = async ({ skipBuild = false, previewKey: previewKeyArg,
|
|
10
|
+
export const publish = async ({ skipBuild = false, previewKey: previewKeyArg, overrideState = false } = {}) => {
|
|
11
11
|
const { config, credentials } = await loadConfigOrFail();
|
|
12
12
|
|
|
13
13
|
// Resolve preview key: explicit arg > interactive picker
|
|
@@ -42,7 +42,7 @@ export const publish = async ({ skipBuild = false, previewKey: previewKeyArg, in
|
|
|
42
42
|
? "Syncing existing dist/ output to the preview before publishing..."
|
|
43
43
|
: "Building and syncing the latest dist/ output before publishing...",
|
|
44
44
|
);
|
|
45
|
-
await push({ skipBuild, previewKey,
|
|
45
|
+
await push({ skipBuild, previewKey, overrideState });
|
|
46
46
|
|
|
47
47
|
const spinner = ui.spinner();
|
|
48
48
|
spinner.start("Publishing preview to live storefront...");
|
package/lib/pull.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdir, rm } from "node:fs/promises";
|
|
1
|
+
import { cp, mkdir, readdir, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { getDistDir, loadConfigOrFail } from "./config.mjs";
|
|
3
4
|
import { downloadStorefrontArchive, downloadPreviewArchive } from "./api.mjs";
|
|
4
5
|
import { fetchPreviewDetails } from "./preview.mjs";
|
|
@@ -12,8 +13,52 @@ const formatBytes = (bytes) => {
|
|
|
12
13
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
13
14
|
};
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
const syncDistToSrc = async (distDir) => {
|
|
17
|
+
const rootDir = path.resolve(distDir, "..");
|
|
18
|
+
const srcDir = path.join(rootDir, "src");
|
|
19
|
+
let entries;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
entries = await readdir(distDir, { withFileTypes: true });
|
|
23
|
+
} catch {
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const distDirs = entries.filter(
|
|
28
|
+
(entry) => entry.isDirectory() && !entry.name.startsWith("."),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
let synced = 0;
|
|
32
|
+
|
|
33
|
+
for (const dir of distDirs) {
|
|
34
|
+
const distSubDir = path.join(distDir, dir.name);
|
|
35
|
+
const srcDest = path.join(srcDir, dir.name);
|
|
36
|
+
const rootDest = path.join(rootDir, dir.name);
|
|
37
|
+
|
|
38
|
+
await rm(srcDest, { recursive: true, force: true });
|
|
39
|
+
|
|
40
|
+
if (srcDest !== rootDest) {
|
|
41
|
+
await rm(rootDest, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await cp(distSubDir, srcDest, { recursive: true });
|
|
45
|
+
synced++;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return synced;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const pull = async ({ previewKey, forceLive = false, confirmSourceSync = true } = {}) => {
|
|
16
52
|
const { config, credentials } = await loadConfigOrFail();
|
|
53
|
+
|
|
54
|
+
if (!previewKey && !forceLive && config.previewKey) {
|
|
55
|
+
previewKey = config.previewKey;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (forceLive) {
|
|
59
|
+
previewKey = undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
17
62
|
const previewDetails = previewKey
|
|
18
63
|
? await fetchPreviewDetails(
|
|
19
64
|
config.apiBaseUrl,
|
|
@@ -63,6 +108,23 @@ export const pull = async ({ previewKey } = {}) => {
|
|
|
63
108
|
`${extractedFiles.length} file${extractedFiles.length === 1 ? "" : "s"} extracted${suffix}.`,
|
|
64
109
|
);
|
|
65
110
|
|
|
111
|
+
if (confirmSourceSync && ui.isInteractive()) {
|
|
112
|
+
const confirmed = await ui.confirm({
|
|
113
|
+
message: "Sync downloaded theme directories to src/? This overwrites local theme files.",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (ui.isCancel(confirmed) || !confirmed) {
|
|
117
|
+
ui.cancel("Source sync cancelled. dist/ was updated.");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
spinner.start("Syncing dist to src...");
|
|
123
|
+
const syncedDirs = await syncDistToSrc(outputDir);
|
|
124
|
+
spinner.stop(
|
|
125
|
+
`${syncedDirs} director${syncedDirs === 1 ? "y" : "ies"} synced to src/.`,
|
|
126
|
+
);
|
|
127
|
+
|
|
66
128
|
if (previewDetails?.ok) {
|
|
67
129
|
ui.log.message(` ${previewDetails.data.url}`);
|
|
68
130
|
}
|
package/lib/push.mjs
CHANGED
|
@@ -26,11 +26,11 @@ export const pushPreparedDirectoryToPreview = async ({
|
|
|
26
26
|
compressMessage = "Compressing files...",
|
|
27
27
|
uploadMessage,
|
|
28
28
|
retryMessage,
|
|
29
|
-
|
|
29
|
+
overrideState = false,
|
|
30
30
|
}) => {
|
|
31
31
|
spinner.message(compressMessage);
|
|
32
32
|
|
|
33
|
-
const shouldInclude = !
|
|
33
|
+
const shouldInclude = !overrideState
|
|
34
34
|
? (relativePath) => !isInstanceFile(relativePath)
|
|
35
35
|
: undefined;
|
|
36
36
|
|
|
@@ -40,7 +40,7 @@ export const pushPreparedDirectoryToPreview = async ({
|
|
|
40
40
|
);
|
|
41
41
|
|
|
42
42
|
return retryAsync(
|
|
43
|
-
() => uploadPreviewZip(apiBaseUrl, apiKey, storeId, previewKey, zipBuffer, !
|
|
43
|
+
() => uploadPreviewZip(apiBaseUrl, apiKey, storeId, previewKey, zipBuffer, !overrideState),
|
|
44
44
|
{
|
|
45
45
|
attempts: 3,
|
|
46
46
|
shouldRetry: (uploadResult) => !uploadResult.ok && Boolean(uploadResult.retriable),
|
|
@@ -54,11 +54,11 @@ export const pushPreparedDirectoryToPreview = async ({
|
|
|
54
54
|
);
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
-
export const push = async ({ skipBuild = false, previewKey: previewKeyArg,
|
|
57
|
+
export const push = async ({ skipBuild = false, previewKey: previewKeyArg, overrideState = false } = {}) => {
|
|
58
58
|
const { config, credentials } = await loadConfigOrFail();
|
|
59
59
|
|
|
60
60
|
if (!skipBuild) {
|
|
61
|
-
const result = await build({
|
|
61
|
+
const result = await build({ overrideState });
|
|
62
62
|
if (!result.ok) {
|
|
63
63
|
process.exit(1);
|
|
64
64
|
}
|
|
@@ -89,7 +89,7 @@ export const push = async ({ skipBuild = false, previewKey: previewKeyArg, inclu
|
|
|
89
89
|
previewKey,
|
|
90
90
|
rootDir,
|
|
91
91
|
spinner,
|
|
92
|
-
|
|
92
|
+
overrideState,
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
if (!result.ok) {
|
|
@@ -100,7 +100,7 @@ export const push = async ({ skipBuild = false, previewKey: previewKeyArg, inclu
|
|
|
100
100
|
|
|
101
101
|
spinner.stop(`Files uploaded to preview ${previewKey}.`);
|
|
102
102
|
ui.log.message(
|
|
103
|
-
`Theme state: ${
|
|
103
|
+
`Theme state: ${overrideState ? "overridden from local files" : "preserved from the theme editor"}`,
|
|
104
104
|
);
|
|
105
105
|
ui.log.message(` ${previewDetails.data.url}`);
|
|
106
106
|
};
|
package/lib/stores.mjs
CHANGED
|
@@ -66,7 +66,7 @@ export const storesSet = async (storeIdArg) => {
|
|
|
66
66
|
spinner.stop(`Active store set to ${selectedStore.name} (ID: ${selectedStore.id}).`);
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
export const formatInitSummary = ({ apiBaseUrl, usedDefaultBaseUrl, stores, selectedStore }) => {
|
|
69
|
+
export const formatInitSummary = ({ apiBaseUrl, usedDefaultBaseUrl, stores, selectedStore, previewKey }) => {
|
|
70
70
|
const lines = ["Status: Connected."];
|
|
71
71
|
|
|
72
72
|
if (usedDefaultBaseUrl) {
|
|
@@ -76,7 +76,12 @@ export const formatInitSummary = ({ apiBaseUrl, usedDefaultBaseUrl, stores, sele
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
if (selectedStore) {
|
|
79
|
-
lines.push(`Store: ${selectedStore.name} (ID: ${selectedStore.id}) [auto-selected]`);
|
|
79
|
+
lines.push(`Store: ${selectedStore.name} (ID: ${selectedStore.id})${stores.length === 1 ? " [auto-selected]" : ""}`);
|
|
80
|
+
if (previewKey) {
|
|
81
|
+
lines.push(`Preview: ${previewKey} [attached]`);
|
|
82
|
+
} else {
|
|
83
|
+
lines.push("Preview: live theme (no preview attached)");
|
|
84
|
+
}
|
|
80
85
|
return lines.join("\n");
|
|
81
86
|
}
|
|
82
87
|
|
package/package.json
CHANGED
package/lib/local-preview.mjs
DELETED
|
@@ -1,393 +0,0 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
2
|
-
import { Readable } from "node:stream";
|
|
3
|
-
|
|
4
|
-
const DEFAULT_PORT = 9292;
|
|
5
|
-
const MAX_PORT_ATTEMPTS = 20;
|
|
6
|
-
const MAX_SSE_CLIENTS = 20;
|
|
7
|
-
const RELOAD_DEBOUNCE_MS = 150;
|
|
8
|
-
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
9
|
-
const PROXY_TIMEOUT_MS = 30_000;
|
|
10
|
-
const MAX_PROXY_REQUEST_BODY_BYTES = 2 * 1024 * 1024;
|
|
11
|
-
|
|
12
|
-
const LIVE_RELOAD_PATH = "/__tiendu__/livereload.js";
|
|
13
|
-
const EVENTS_PATH = "/__tiendu__/events";
|
|
14
|
-
|
|
15
|
-
const LIVE_RELOAD_SCRIPT = `const source = new EventSource(${JSON.stringify(EVENTS_PATH)});
|
|
16
|
-
let reloadTimer = null;
|
|
17
|
-
|
|
18
|
-
source.addEventListener("reload", () => {
|
|
19
|
-
if (reloadTimer) clearTimeout(reloadTimer);
|
|
20
|
-
reloadTimer = setTimeout(() => window.location.reload(), 60);
|
|
21
|
-
});
|
|
22
|
-
`;
|
|
23
|
-
|
|
24
|
-
const HOP_BY_HOP_HEADERS = new Set([
|
|
25
|
-
"connection",
|
|
26
|
-
"keep-alive",
|
|
27
|
-
"proxy-authenticate",
|
|
28
|
-
"proxy-authorization",
|
|
29
|
-
"te",
|
|
30
|
-
"trailer",
|
|
31
|
-
"transfer-encoding",
|
|
32
|
-
"upgrade",
|
|
33
|
-
]);
|
|
34
|
-
|
|
35
|
-
const readRequestBody = async (request) => {
|
|
36
|
-
const chunks = [];
|
|
37
|
-
let totalBytes = 0;
|
|
38
|
-
|
|
39
|
-
for await (const chunk of request) {
|
|
40
|
-
totalBytes += chunk.length;
|
|
41
|
-
if (totalBytes > MAX_PROXY_REQUEST_BODY_BYTES) {
|
|
42
|
-
const error = new Error("Local preview request body is too large.");
|
|
43
|
-
error.statusCode = 413;
|
|
44
|
-
throw error;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
chunks.push(chunk);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (chunks.length === 0) return undefined;
|
|
51
|
-
return Buffer.concat(chunks);
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const createForwardHeaders = (request, previewHostname) => {
|
|
55
|
-
const headers = new Headers();
|
|
56
|
-
|
|
57
|
-
for (const [name, value] of Object.entries(request.headers)) {
|
|
58
|
-
if (value == null) continue;
|
|
59
|
-
|
|
60
|
-
const normalizedName = name.toLowerCase();
|
|
61
|
-
if (
|
|
62
|
-
HOP_BY_HOP_HEADERS.has(normalizedName) ||
|
|
63
|
-
normalizedName === "host" ||
|
|
64
|
-
normalizedName === "origin" ||
|
|
65
|
-
normalizedName === "referer" ||
|
|
66
|
-
normalizedName === "content-length"
|
|
67
|
-
) {
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (Array.isArray(value)) {
|
|
72
|
-
for (const entry of value) {
|
|
73
|
-
headers.append(name, entry);
|
|
74
|
-
}
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
headers.set(name, value);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
headers.set("host", previewHostname);
|
|
82
|
-
headers.set("x-forwarded-host", previewHostname);
|
|
83
|
-
|
|
84
|
-
return headers;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const isHtmlResponse = (headers) =>
|
|
88
|
-
(headers.get("content-type") ?? "").toLowerCase().includes("text/html");
|
|
89
|
-
|
|
90
|
-
const isHtmlDocument = (html) => /<html\b|<!doctype\s+html/i.test(html);
|
|
91
|
-
|
|
92
|
-
const injectLiveReloadScript = (html) => {
|
|
93
|
-
if (html.includes(LIVE_RELOAD_PATH)) return html;
|
|
94
|
-
if (!isHtmlDocument(html)) return html;
|
|
95
|
-
|
|
96
|
-
const scriptTag = `<script type="module" src="${LIVE_RELOAD_PATH}"></script>`;
|
|
97
|
-
|
|
98
|
-
if (html.includes("</head>")) {
|
|
99
|
-
return html.replace("</head>", `${scriptTag}</head>`);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (html.includes("</body>")) {
|
|
103
|
-
return html.replace("</body>", `${scriptTag}</body>`);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return `${html}${scriptTag}`;
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
const rewriteSetCookie = (cookieValue) =>
|
|
110
|
-
cookieValue
|
|
111
|
-
.replace(/;\s*Secure/gi, "")
|
|
112
|
-
.replace(/;\s*Domain=[^;]+/gi, "");
|
|
113
|
-
|
|
114
|
-
const rewriteLocationHeader = (locationValue, localOrigin, previewOrigin, upstreamOrigin) => {
|
|
115
|
-
if (!locationValue) return null;
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
const locationUrl = new URL(locationValue, previewOrigin);
|
|
119
|
-
if (
|
|
120
|
-
locationUrl.origin === previewOrigin.origin ||
|
|
121
|
-
locationUrl.origin === upstreamOrigin.origin
|
|
122
|
-
) {
|
|
123
|
-
return `${localOrigin.origin}${locationUrl.pathname}${locationUrl.search}${locationUrl.hash}`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return locationValue;
|
|
127
|
-
} catch {
|
|
128
|
-
return locationValue;
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const writeResponseHeaders = (response, serverResponse, context) => {
|
|
133
|
-
const { localOrigin, previewOrigin, upstreamOrigin } = context;
|
|
134
|
-
|
|
135
|
-
for (const [name, value] of response.headers) {
|
|
136
|
-
const normalizedName = name.toLowerCase();
|
|
137
|
-
if (
|
|
138
|
-
HOP_BY_HOP_HEADERS.has(normalizedName) ||
|
|
139
|
-
normalizedName === "content-length" ||
|
|
140
|
-
normalizedName === "content-encoding" ||
|
|
141
|
-
normalizedName === "set-cookie"
|
|
142
|
-
) {
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (normalizedName === "location") {
|
|
147
|
-
const rewritten = rewriteLocationHeader(
|
|
148
|
-
value,
|
|
149
|
-
localOrigin,
|
|
150
|
-
previewOrigin,
|
|
151
|
-
upstreamOrigin,
|
|
152
|
-
);
|
|
153
|
-
if (rewritten) serverResponse.setHeader(name, rewritten);
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
serverResponse.setHeader(name, value);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const setCookies = response.headers.getSetCookie?.() ?? [];
|
|
161
|
-
if (setCookies.length > 0) {
|
|
162
|
-
serverResponse.setHeader(
|
|
163
|
-
"set-cookie",
|
|
164
|
-
setCookies.map(rewriteSetCookie),
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
const listenOnAvailablePort = (server, preferredPort) =>
|
|
170
|
-
new Promise((resolve, reject) => {
|
|
171
|
-
let currentPort = preferredPort;
|
|
172
|
-
|
|
173
|
-
const tryListen = () => {
|
|
174
|
-
const onError = (error) => {
|
|
175
|
-
server.off("listening", onListening);
|
|
176
|
-
|
|
177
|
-
if (error?.code === "EADDRINUSE" && currentPort < preferredPort + MAX_PORT_ATTEMPTS) {
|
|
178
|
-
currentPort += 1;
|
|
179
|
-
tryListen();
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
reject(error);
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
const onListening = () => {
|
|
187
|
-
server.off("error", onError);
|
|
188
|
-
const address = server.address();
|
|
189
|
-
if (!address || typeof address === "string") {
|
|
190
|
-
reject(new Error("Could not determine local preview port."));
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
resolve(address.port);
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
server.once("error", onError);
|
|
198
|
-
server.once("listening", onListening);
|
|
199
|
-
server.listen(currentPort, "localhost");
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
tryListen();
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
export const startLocalPreviewServer = async ({
|
|
206
|
-
apiBaseUrl,
|
|
207
|
-
previewHostname,
|
|
208
|
-
port = DEFAULT_PORT,
|
|
209
|
-
}) => {
|
|
210
|
-
const upstreamOrigin = new URL(apiBaseUrl);
|
|
211
|
-
const previewOrigin = new URL(`${upstreamOrigin.protocol}//${previewHostname}`);
|
|
212
|
-
const sseClients = new Set();
|
|
213
|
-
const sockets = new Set();
|
|
214
|
-
const upstreamRequests = new Set();
|
|
215
|
-
let reloadTimer = null;
|
|
216
|
-
let closed = false;
|
|
217
|
-
let closePromise = null;
|
|
218
|
-
|
|
219
|
-
const server = createServer(async (request, response) => {
|
|
220
|
-
if (!request.url) {
|
|
221
|
-
response.writeHead(400);
|
|
222
|
-
response.end("Missing request URL");
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const localOrigin = new URL(`http://${request.headers.host ?? `127.0.0.1:${port}`}`);
|
|
227
|
-
const requestUrl = new URL(request.url, localOrigin);
|
|
228
|
-
|
|
229
|
-
if (requestUrl.pathname === LIVE_RELOAD_PATH) {
|
|
230
|
-
response.writeHead(200, {
|
|
231
|
-
"content-type": "application/javascript; charset=utf-8",
|
|
232
|
-
"cache-control": "no-store",
|
|
233
|
-
});
|
|
234
|
-
response.end(LIVE_RELOAD_SCRIPT);
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (requestUrl.pathname === EVENTS_PATH) {
|
|
239
|
-
response.writeHead(200, {
|
|
240
|
-
"content-type": "text/event-stream; charset=utf-8",
|
|
241
|
-
"cache-control": "no-store",
|
|
242
|
-
connection: "keep-alive",
|
|
243
|
-
});
|
|
244
|
-
response.write("event: connected\ndata: ok\n\n");
|
|
245
|
-
|
|
246
|
-
if (sseClients.size >= MAX_SSE_CLIENTS) {
|
|
247
|
-
const oldestClient = sseClients.values().next().value;
|
|
248
|
-
oldestClient?.end();
|
|
249
|
-
if (oldestClient) {
|
|
250
|
-
sseClients.delete(oldestClient);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
sseClients.add(response);
|
|
255
|
-
|
|
256
|
-
request.on("close", () => {
|
|
257
|
-
sseClients.delete(response);
|
|
258
|
-
});
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const targetUrl = new URL(requestUrl.pathname + requestUrl.search, upstreamOrigin);
|
|
263
|
-
const upstreamRequest = new AbortController();
|
|
264
|
-
upstreamRequests.add(upstreamRequest);
|
|
265
|
-
|
|
266
|
-
try {
|
|
267
|
-
const body = await readRequestBody(request);
|
|
268
|
-
const upstreamResponse = await fetch(targetUrl, {
|
|
269
|
-
method: request.method,
|
|
270
|
-
headers: createForwardHeaders(request, previewHostname),
|
|
271
|
-
body,
|
|
272
|
-
redirect: "manual",
|
|
273
|
-
signal: AbortSignal.any([
|
|
274
|
-
AbortSignal.timeout(PROXY_TIMEOUT_MS),
|
|
275
|
-
upstreamRequest.signal,
|
|
276
|
-
]),
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
if (isHtmlResponse(upstreamResponse.headers)) {
|
|
280
|
-
if (closed || response.destroyed) return;
|
|
281
|
-
const html = injectLiveReloadScript(await upstreamResponse.text());
|
|
282
|
-
writeResponseHeaders(upstreamResponse, response, {
|
|
283
|
-
localOrigin,
|
|
284
|
-
previewOrigin,
|
|
285
|
-
upstreamOrigin,
|
|
286
|
-
});
|
|
287
|
-
response.statusCode = upstreamResponse.status;
|
|
288
|
-
response.setHeader("cache-control", "no-store");
|
|
289
|
-
response.setHeader("content-length", Buffer.byteLength(html, "utf-8"));
|
|
290
|
-
response.end(html);
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
writeResponseHeaders(upstreamResponse, response, {
|
|
295
|
-
localOrigin,
|
|
296
|
-
previewOrigin,
|
|
297
|
-
upstreamOrigin,
|
|
298
|
-
});
|
|
299
|
-
if (closed || response.destroyed) return;
|
|
300
|
-
response.statusCode = upstreamResponse.status;
|
|
301
|
-
|
|
302
|
-
if (!upstreamResponse.body) {
|
|
303
|
-
response.end();
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const proxyStream = Readable.fromWeb(upstreamResponse.body);
|
|
308
|
-
proxyStream.on("error", (error) => {
|
|
309
|
-
console.warn(`Local preview proxy stream error: ${error.message}`);
|
|
310
|
-
response.destroy(error);
|
|
311
|
-
});
|
|
312
|
-
proxyStream.pipe(response);
|
|
313
|
-
} catch (error) {
|
|
314
|
-
if (response.destroyed || response.writableEnded) return;
|
|
315
|
-
|
|
316
|
-
const wasAbort = error?.name === "AbortError" || error?.name === "TimeoutError";
|
|
317
|
-
if (closed && wasAbort) {
|
|
318
|
-
response.destroy();
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const statusCode = error.statusCode ?? (error?.name === "TimeoutError" ? 504 : 502);
|
|
323
|
-
response.writeHead(statusCode, { "content-type": "text/plain; charset=utf-8" });
|
|
324
|
-
response.end(`Local preview proxy error: ${error.message}`);
|
|
325
|
-
} finally {
|
|
326
|
-
upstreamRequests.delete(upstreamRequest);
|
|
327
|
-
}
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
server.on("connection", (socket) => {
|
|
331
|
-
sockets.add(socket);
|
|
332
|
-
socket.on("close", () => {
|
|
333
|
-
sockets.delete(socket);
|
|
334
|
-
});
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
const heartbeat = setInterval(() => {
|
|
338
|
-
for (const client of sseClients) {
|
|
339
|
-
client.write(": ping\n\n");
|
|
340
|
-
}
|
|
341
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
342
|
-
|
|
343
|
-
const boundPort = await listenOnAvailablePort(server, port);
|
|
344
|
-
|
|
345
|
-
return {
|
|
346
|
-
url: `http://localhost:${boundPort}/`,
|
|
347
|
-
notifyReload() {
|
|
348
|
-
if (reloadTimer) clearTimeout(reloadTimer);
|
|
349
|
-
|
|
350
|
-
reloadTimer = setTimeout(() => {
|
|
351
|
-
reloadTimer = null;
|
|
352
|
-
for (const client of sseClients) {
|
|
353
|
-
client.write("event: reload\ndata: now\n\n");
|
|
354
|
-
}
|
|
355
|
-
}, RELOAD_DEBOUNCE_MS);
|
|
356
|
-
},
|
|
357
|
-
async close() {
|
|
358
|
-
if (closePromise) return closePromise;
|
|
359
|
-
closed = true;
|
|
360
|
-
|
|
361
|
-
closePromise = new Promise((resolve, reject) => {
|
|
362
|
-
if (reloadTimer) clearTimeout(reloadTimer);
|
|
363
|
-
clearInterval(heartbeat);
|
|
364
|
-
|
|
365
|
-
for (const client of sseClients) {
|
|
366
|
-
client.end();
|
|
367
|
-
}
|
|
368
|
-
sseClients.clear();
|
|
369
|
-
|
|
370
|
-
for (const upstreamRequest of upstreamRequests) {
|
|
371
|
-
upstreamRequest.abort();
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
server.close((error) => {
|
|
375
|
-
if (error && error.code !== "ERR_SERVER_NOT_RUNNING") {
|
|
376
|
-
reject(error);
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
resolve();
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
server.closeIdleConnections?.();
|
|
384
|
-
server.closeAllConnections?.();
|
|
385
|
-
for (const socket of sockets) {
|
|
386
|
-
socket.destroy();
|
|
387
|
-
}
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
return closePromise;
|
|
391
|
-
},
|
|
392
|
-
};
|
|
393
|
-
};
|