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 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 live with a local auto-reloading URL plus a sharable preview URL, and publish when you're ready — all from the terminal.
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 local live-preview URL first, plus a sharable preview URL like:
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 current live theme from your store into `dist/`.
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
- - Your source files, including `src/`, are left untouched.
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 { resolveIncludeInstances } from "../lib/config.mjs";
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] Download the live theme or a preview into dist/
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, run tiendu stores list and tiendu stores set <id>.
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 resets dist/ and extracts the downloaded theme there.
88
- pull does not delete src/ files.
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("Missing value for --dir.");
138
+ console.error(`Missing value for ${arg}.`);
130
139
  process.exit(1);
131
140
  }
132
- values.set("dir", value);
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 includeInstances = await resolveIncludeInstances({
223
+ const overrideState = await resolveOverrideState({
214
224
  overrideStateFlag,
215
225
  preserveStateFlag,
216
226
  });
217
- const result = await build({ includeInstances });
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 includeInstances = await resolveIncludeInstances({
233
+ const overrideState = await resolveOverrideState({
224
234
  overrideStateFlag,
225
235
  preserveStateFlag,
226
236
  });
227
- await push({ skipBuild, previewKey: positionals[1], includeInstances });
237
+ await push({ skipBuild, previewKey: positionals[1], overrideState });
228
238
  return;
229
239
  }
230
240
 
231
241
  if (command === "dev") {
232
- const includeInstances = await resolveIncludeInstances({
242
+ const overrideState = await resolveOverrideState({
233
243
  overrideStateFlag,
234
244
  preserveStateFlag,
235
245
  });
236
- await dev({ includeInstances });
246
+ await dev({ overrideState });
237
247
  return;
238
248
  }
239
249
 
240
250
  if (command === "publish") {
241
- const includeInstances = await resolveIncludeInstances({
251
+ const overrideState = await resolveOverrideState({
242
252
  overrideStateFlag,
243
253
  preserveStateFlag,
244
254
  });
245
- await publish({ skipBuild, previewKey: positionals[1], includeInstances });
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, includeInstances = false) => {
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 (!includeInstances && isInstanceFile(outputRelativePath)) return false;
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
- includeInstances = false,
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, includeInstances)) continue;
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, includeInstances = 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
- includeInstances,
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, includeInstances)) {
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 getThemeIncludeInstancesConfig = (themeConfig) => {
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 resolveIncludeInstances = async ({
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 getThemeIncludeInstancesConfig(await readThemeConfig());
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 ({ includeInstances = false } = {}) => {
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, includeInstances });
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 to get hostname for local proxy
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
- includeInstances,
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 state: ${includeInstances ? "overridden from local files" : "preserved from the theme editor"}`,
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 (!includeInstances && isInstanceFile(relativePath)) return;
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, access } from "node:fs/promises";
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 prepareWorkDir = async (dirArg) => {
25
- if (!dirArg) return;
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
- const targetDir = path.resolve(process.cwd(), dirArg);
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 access(targetDir);
31
- ui.cancel(`Directory "${dirArg}" already exists.`);
32
- process.exit(1);
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
- // Safe to create the target directory.
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
- const selectedStore = stores.length === 1 ? stores[0] : null;
150
- spinner.stop(`Connected to Tiendu. ${stores.length} store${stores.length === 1 ? "" : "s"} available.`);
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, includeInstances = false } = {}) => {
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, includeInstances });
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
- export const pull = async ({ previewKey } = {}) => {
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
- includeInstances = false,
29
+ overrideState = false,
30
30
  }) => {
31
31
  spinner.message(compressMessage);
32
32
 
33
- const shouldInclude = !includeInstances
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, !includeInstances),
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, includeInstances = false } = {}) => {
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({ includeInstances });
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
- includeInstances,
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: ${includeInstances ? "overridden from local files" : "preserved from the theme editor"}`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tiendu",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "CLI para desarrollar y publicar temas en Tiendu",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- };