tiendu 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -100,6 +100,8 @@ The build:
100
100
  4. Bundles JS/TS and CSS into `dist/assets/`
101
101
  5. Runs project PostCSS plugins for CSS entries when available (for example Tailwind v4)
102
102
 
103
+ For TypeScript source, extensionless relative imports such as `import { initHeaderCart } from '../lib/scripts/cart'` are supported and recommended.
104
+
103
105
  Entry naming convention:
104
106
 
105
107
  - `src/layout/theme.ts` → `dist/assets/layout-theme.bundle.js`
package/bin/tiendu.js CHANGED
@@ -12,6 +12,8 @@ import {
12
12
  previewList,
13
13
  previewDelete,
14
14
  previewOpen,
15
+ previewAttach,
16
+ previewDetach,
15
17
  } from "../lib/preview.mjs";
16
18
  import {
17
19
  checkForUpdates,
@@ -24,23 +26,29 @@ tiendu — Tiendu theme development CLI
24
26
 
25
27
  Usage:
26
28
  tiendu init [dir] Set up a theme project (optionally in a new directory)
27
- tiendu pull Download the live theme from your store
29
+ tiendu pull [previewKey] Download the live theme, or a specific preview's files
28
30
  tiendu build Build a theme (requires tiendu.config.json)
29
- tiendu push [--skip-build] Upload local files to the active preview (full replace)
31
+ tiendu push [previewKey] [--skip-build]
32
+ Upload files to the attached or specified preview
30
33
  tiendu dev Start dev mode: auto-sync changes to a live preview URL
31
- tiendu publish [--skip-build]
32
- Publish the active preview to the live storefront
33
-
34
- tiendu preview Show the active preview details
35
- tiendu preview create Create a new remote preview
36
- tiendu preview list List previews for your store
37
- tiendu preview delete Delete the active preview
38
- tiendu preview open Open the active preview URL in your browser
34
+ tiendu publish [previewKey] [--skip-build]
35
+ Publish the attached or specified preview to the live storefront
36
+
37
+ tiendu preview Show the attached preview details
38
+ tiendu preview create [name]
39
+ Create a new preview (and attach to it)
40
+ tiendu preview list List all previews for your store
41
+ tiendu preview attach [key]
42
+ Attach to an existing preview by its key
43
+ tiendu preview detach Detach from the current preview (without deleting it)
44
+ tiendu preview delete [key]
45
+ Delete a preview (defaults to the attached one)
46
+ tiendu preview open Open the attached preview URL in your browser
39
47
 
40
48
  tiendu check-updates Check npm for a newer CLI version
41
49
  tiendu version Show the current CLI version
42
50
 
43
- tiendu help Show this help message
51
+ tiendu --help, -h Show this help message
44
52
  tiendu --version, -v Show the current CLI version
45
53
 
46
54
  Typical workflow:
@@ -52,10 +60,19 @@ Typical workflow:
52
60
  tiendu publish Ship to the live storefront when ready
53
61
  `;
54
62
 
63
+ /**
64
+ * Extract the first positional argument that is not a flag (--skip-build, etc.).
65
+ * @param {string[]} args - CLI args after the command name
66
+ * @returns {string | undefined}
67
+ */
68
+ const extractPositionalArg = (args) =>
69
+ args.find((arg) => !arg.startsWith("--"));
70
+
55
71
  const main = async () => {
56
72
  const args = process.argv.slice(2);
57
73
  const command = args[0];
58
74
  const subcommand = args[1];
75
+ const restArgs = args.slice(1);
59
76
  const skipBuild = args.includes("--skip-build");
60
77
 
61
78
  if (
@@ -69,7 +86,6 @@ const main = async () => {
69
86
 
70
87
  if (
71
88
  !command ||
72
- command === "help" ||
73
89
  command === "--help" ||
74
90
  command === "-h"
75
91
  ) {
@@ -91,7 +107,8 @@ const main = async () => {
91
107
  }
92
108
 
93
109
  if (command === "pull") {
94
- await pull();
110
+ const previewKey = extractPositionalArg(restArgs);
111
+ await pull({ previewKey });
95
112
  return;
96
113
  }
97
114
 
@@ -102,7 +119,8 @@ const main = async () => {
102
119
  }
103
120
 
104
121
  if (command === "push") {
105
- await push({ skipBuild });
122
+ const previewKey = extractPositionalArg(restArgs);
123
+ await push({ skipBuild, previewKey });
106
124
  return;
107
125
  }
108
126
 
@@ -112,7 +130,8 @@ const main = async () => {
112
130
  }
113
131
 
114
132
  if (command === "publish") {
115
- await publish({ skipBuild });
133
+ const previewKey = extractPositionalArg(restArgs);
134
+ await publish({ skipBuild, previewKey });
116
135
  return;
117
136
  }
118
137
 
@@ -129,8 +148,16 @@ const main = async () => {
129
148
  await previewList();
130
149
  return;
131
150
  }
151
+ if (subcommand === "attach") {
152
+ await previewAttach(args[2]);
153
+ return;
154
+ }
155
+ if (subcommand === "detach") {
156
+ await previewDetach();
157
+ return;
158
+ }
132
159
  if (subcommand === "delete") {
133
- await previewDelete();
160
+ await previewDelete(args[2]);
134
161
  return;
135
162
  }
136
163
  if (subcommand === "open") {
package/lib/api.mjs CHANGED
@@ -83,6 +83,47 @@ export const fetchUserStores = async (apiBaseUrl, apiKey) => {
83
83
  }
84
84
  };
85
85
 
86
+ /**
87
+ * Fetch a single preview by key.
88
+ *
89
+ * @param {string} apiBaseUrl
90
+ * @param {string} apiKey
91
+ * @param {number} storeId
92
+ * @param {string} previewKey
93
+ * @returns {Promise<{ ok: true, data: any } | { ok: false, error: string }>}
94
+ */
95
+ export const fetchPreview = async (apiBaseUrl, apiKey, storeId, previewKey) => {
96
+ try {
97
+ const response = await apiFetch(
98
+ apiBaseUrl,
99
+ apiKey,
100
+ `/api/v2/stores/${storeId}/theme-previews/${previewKey}`,
101
+ );
102
+
103
+ const authError = checkAuthErrors(response);
104
+ if (authError) return authError;
105
+
106
+ if (response.status === 404) {
107
+ return { ok: false, error: "Preview not found." };
108
+ }
109
+
110
+ if (!response.ok) {
111
+ return {
112
+ ok: false,
113
+ error: `Server error: ${response.status} ${response.statusText}`,
114
+ };
115
+ }
116
+
117
+ const preview = await response.json();
118
+ return { ok: true, data: preview };
119
+ } catch (error) {
120
+ return {
121
+ ok: false,
122
+ error: `Could not fetch preview: ${error.message}`,
123
+ };
124
+ }
125
+ };
126
+
86
127
  /**
87
128
  * Download the storefront archive (zip) as a buffer.
88
129
  *
@@ -124,6 +165,49 @@ export const downloadStorefrontArchive = async (
124
165
  }
125
166
  };
126
167
 
168
+ /**
169
+ * Download a preview's archive (zip) as a buffer.
170
+ *
171
+ * @param {string} apiBaseUrl
172
+ * @param {string} apiKey
173
+ * @param {number} storeId
174
+ * @param {string} previewKey
175
+ * @returns {Promise<{ ok: true, data: Buffer } | { ok: false, error: string }>}
176
+ */
177
+ export const downloadPreviewArchive = async (
178
+ apiBaseUrl,
179
+ apiKey,
180
+ storeId,
181
+ previewKey,
182
+ ) => {
183
+ try {
184
+ const response = await apiFetch(
185
+ apiBaseUrl,
186
+ apiKey,
187
+ `/api/admin/stores/${storeId}/theme-previews/${previewKey}/download`,
188
+ );
189
+
190
+ const authError = checkAuthErrors(response);
191
+ if (authError) return authError;
192
+
193
+ if (!response.ok) {
194
+ const body = await response.text().catch(() => "");
195
+ return {
196
+ ok: false,
197
+ error: `Server error: ${response.status} ${response.statusText}${body ? ` — ${body}` : ""}`,
198
+ };
199
+ }
200
+
201
+ const arrayBuffer = await response.arrayBuffer();
202
+ return { ok: true, data: Buffer.from(arrayBuffer) };
203
+ } catch (error) {
204
+ return {
205
+ ok: false,
206
+ error: `Could not download preview: ${error.message}`,
207
+ };
208
+ }
209
+ };
210
+
127
211
  /**
128
212
  * Upload a zip buffer to a preview, replacing its content.
129
213
  *
package/lib/build.mjs CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  import { listFilesRecursive } from "./fs-utils.mjs";
24
24
  import { createCssPostCssPlugin } from "./postcss.mjs";
25
25
 
26
- const THEME_SOURCE_OUTPUT_DIRS = ["layout", "templates", "snippets"];
26
+ const THEME_SOURCE_OUTPUT_DIRS = ["layout", "templates", "sections", "snippets", "config"];
27
27
  const LIQUID_LIKE_EXTENSIONS = new Set([".liquid", ".html", ".htm"]);
28
28
  const ENTRY_SOURCE_EXTENSIONS = new Set([".js", ".ts", ".css"]);
29
29
  const NESTED_ASSET_PATH_PATTERN = /\/assets\/([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._/-]+)+)([?#][A-Za-z0-9=&._-]+)?/g;
@@ -155,7 +155,7 @@ const shouldTriggerTailwindCssRebuild = (relativePath) => {
155
155
  const normalizedPath = relativePath.split(path.sep).join("/");
156
156
  const extension = path.extname(normalizedPath).toLowerCase();
157
157
 
158
- if (!["layout/", "templates/", "snippets/"].some((prefix) => normalizedPath.startsWith(prefix))) {
158
+ if (!["layout/", "templates/", "sections/", "snippets/"].some((prefix) => normalizedPath.startsWith(prefix))) {
159
159
  return false;
160
160
  }
161
161
 
@@ -275,7 +275,7 @@ export const build = async ({ watch: watchMode = false } = {}) => {
275
275
 
276
276
  const outdir = path.join(distDir, "assets");
277
277
  const jsBuildOptions = jsCount > 0
278
- ? { entryPoints: jsEntries, bundle: true, format: "esm", target: "es2020", outdir, logLevel: "warning", write: true, plugins: jsPlugins }
278
+ ? { entryPoints: jsEntries, bundle: true, format: "esm", target: "es2020", outdir, logLevel: "warning", write: true, resolveExtensions: [".ts", ".tsx", ".js", ".jsx", ".json"], plugins: jsPlugins }
279
279
  : null;
280
280
  const cssBuildOptions = cssCount > 0
281
281
  ? { entryPoints: cssEntries, bundle: true, outdir, logLevel: "warning", write: true, plugins: cssPlugins }
package/lib/dev.mjs CHANGED
@@ -2,12 +2,10 @@ import { watch } from "node:fs";
2
2
  import { readFile, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import * as p from "@clack/prompts";
5
- import { loadConfigOrFail, writeConfig, isBuiltTheme, getDistDir } from "./config.mjs";
5
+ import { loadConfigOrFail, isBuiltTheme, getDistDir } from "./config.mjs";
6
6
  import {
7
- buildPreviewUrl,
8
- createPreview,
9
- listPreviews,
10
- resolveActivePreview,
7
+ fetchPreviewDetails,
8
+ resolvePreviewKeyInteractively,
11
9
  } from "./preview.mjs";
12
10
  import {
13
11
  deletePreviewFile,
@@ -21,6 +19,7 @@ import { retryAsync } from "./retry.mjs";
21
19
 
22
20
  const RETRY_ATTEMPTS = 3;
23
21
  const MAX_SYNC_FILE_SIZE_BYTES = 20 * 1024 * 1024;
22
+ const CLEANUP_TIMEOUT_MS = 5_000;
24
23
  const IGNORED_ROOT_SEGMENTS = new Set(["node_modules", ".git"]);
25
24
 
26
25
  const hasDotfileSegment = (relativePath) =>
@@ -45,6 +44,27 @@ const shouldIgnoreWatchedPath = (relativePath, builtTheme) => {
45
44
  const shouldRetrySyncResult = (result) =>
46
45
  !result.ok && Boolean(result.retriable);
47
46
 
47
+ const runCleanupStep = async (label, cleanupFn) => {
48
+ if (!cleanupFn) return;
49
+
50
+ let timeoutId = null;
51
+
52
+ try {
53
+ await Promise.race([
54
+ Promise.resolve().then(() => cleanupFn()),
55
+ new Promise((_, reject) => {
56
+ timeoutId = setTimeout(() => {
57
+ reject(new Error(`${label} did not finish within ${CLEANUP_TIMEOUT_MS}ms.`));
58
+ }, CLEANUP_TIMEOUT_MS);
59
+ }),
60
+ ]);
61
+ } catch (error) {
62
+ p.log.warn(error.message);
63
+ } finally {
64
+ if (timeoutId) clearTimeout(timeoutId);
65
+ }
66
+ };
67
+
48
68
  const uploadFileWithRetries = (
49
69
  apiBaseUrl,
50
70
  apiKey,
@@ -95,19 +115,6 @@ const deleteFileWithRetries = (
95
115
  },
96
116
  );
97
117
 
98
- const resolvePreviewForDev = (previews, configuredPreviewKey) => {
99
- const activePreview = resolveActivePreview(previews, configuredPreviewKey);
100
- if (activePreview) {
101
- return { preview: activePreview, fallbackUsed: false };
102
- }
103
-
104
- if (configuredPreviewKey && previews.length === 1) {
105
- return { preview: previews[0], fallbackUsed: true };
106
- }
107
-
108
- return { preview: null, fallbackUsed: false };
109
- };
110
-
111
118
  export const dev = async () => {
112
119
  const { config, credentials } = await loadConfigOrFail();
113
120
  const { apiBaseUrl, storeId } = config;
@@ -127,48 +134,26 @@ export const dev = async () => {
127
134
  buildCleanup = buildResult.cleanup;
128
135
  }
129
136
 
130
- const spinner = p.spinner();
131
- spinner.start("Connecting to preview...");
137
+ // Resolve preview via shared interactive picker
138
+ const previewKey = await resolvePreviewKeyInteractively({ config, credentials });
132
139
 
133
- const listResult = await listPreviews(apiBaseUrl, apiKey, storeId);
134
- if (!listResult.ok) {
135
- spinner.stop("Failed to connect.", 1);
136
- p.log.error(listResult.error);
140
+ // Fetch preview to get hostname for local proxy
141
+ const previewResult = await fetchPreviewDetails(
142
+ apiBaseUrl,
143
+ apiKey,
144
+ storeId,
145
+ previewKey,
146
+ );
147
+ if (!previewResult.ok) {
148
+ p.log.error(`Preview ${previewKey} not found.`);
137
149
  process.exit(1);
138
150
  }
139
151
 
140
- const previewResolution = resolvePreviewForDev(listResult.data, config.previewKey);
141
- let activePreview = previewResolution.preview;
142
- if (previewResolution.fallbackUsed && activePreview) {
143
- p.log.warn(
144
- `Stored preview ${config.previewKey} was not found. Using the only available preview ${activePreview.previewKey}.`,
145
- );
146
- }
147
-
148
- if (!activePreview) {
149
- if (config.previewKey) {
150
- p.log.warn(
151
- `Stored preview ${config.previewKey} was not found. Creating a new preview...`,
152
- );
153
- }
154
-
155
- spinner.message("Creating preview...");
156
- const previewResult = await createPreview(apiBaseUrl, apiKey, storeId, "Dev");
157
- if (!previewResult.ok) {
158
- spinner.stop("Failed to create preview.", 1);
159
- p.log.error(previewResult.error);
160
- process.exit(1);
161
- }
162
-
163
- activePreview = previewResult.data;
164
- }
165
-
166
- const previewKey = activePreview.previewKey;
167
- if (config.previewKey !== previewKey) {
168
- await writeConfig({ ...config, previewKey });
169
- }
152
+ const previewHostname = previewResult.data.preview.previewHostname;
153
+ const previewUrl = previewResult.data.url;
170
154
 
171
- const previewUrl = buildPreviewUrl(apiBaseUrl, activePreview.previewHostname);
155
+ const spinner = p.spinner();
156
+ spinner.start("Compressing files...");
172
157
 
173
158
  const uploadResult = await pushPreparedDirectoryToPreview({
174
159
  apiBaseUrl,
@@ -177,7 +162,7 @@ export const dev = async () => {
177
162
  previewKey,
178
163
  rootDir,
179
164
  spinner,
180
- packMessage: "Running initial push...",
165
+ compressMessage: "Compressing files...",
181
166
  retryMessage: (result, nextAttempt) =>
182
167
  `Initial push failed. Retrying ${nextAttempt}/${RETRY_ATTEMPTS}... ${result.error}`,
183
168
  });
@@ -191,19 +176,19 @@ export const dev = async () => {
191
176
  try {
192
177
  localPreviewServer = await startLocalPreviewServer({
193
178
  apiBaseUrl,
194
- previewHostname: activePreview.previewHostname,
179
+ previewHostname,
195
180
  });
196
181
  } catch (error) {
197
182
  p.log.warn(`Could not start local live preview: ${error.message}`);
198
183
  }
199
184
 
200
- spinner.stop("Preview ready.");
185
+ spinner.stop(`Preview ready (${previewKey}).`);
201
186
  if (localPreviewServer) {
202
187
  p.log.message(`Local live preview: ${localPreviewServer.url}`);
203
188
  }
204
189
  p.log.message(`Sharable preview: ${previewUrl}`);
205
190
 
206
- p.log.message("Watching for changes press Ctrl+C to stop.");
191
+ p.log.message("Watching for changes \u2014 press Ctrl+C to stop.");
207
192
 
208
193
  // ── File watcher ──────────────────────────────────────────────────────────
209
194
  /** @type {Map<string, NodeJS.Timeout>} */
@@ -238,7 +223,7 @@ export const dev = async () => {
238
223
 
239
224
  if (!fileStat || !fileStat.isFile()) {
240
225
  if (!fileStat) {
241
- console.log(`✕ ${relativePath}`);
226
+ console.log(`\u2715 ${relativePath}`);
242
227
  const result = await deleteFileWithRetries(
243
228
  apiBaseUrl,
244
229
  apiKey,
@@ -262,7 +247,7 @@ export const dev = async () => {
262
247
  return;
263
248
  }
264
249
 
265
- console.log(`↑ ${relativePath}`);
250
+ console.log(`\u2191 ${relativePath}`);
266
251
  if (fileStat.size > MAX_SYNC_FILE_SIZE_BYTES) {
267
252
  p.log.warn(
268
253
  ` Skipping ${relativePath}: file is ${(fileStat.size / (1024 * 1024)).toFixed(1)} MB (limit ${(MAX_SYNC_FILE_SIZE_BYTES / (1024 * 1024)).toFixed(0)} MB).`,
@@ -317,8 +302,10 @@ export const dev = async () => {
317
302
 
318
303
  watcher.close();
319
304
  for (const timer of debounceMap.values()) clearTimeout(timer);
320
- if (localPreviewServer) await localPreviewServer.close();
321
- if (buildCleanup) await buildCleanup();
305
+
306
+ await runCleanupStep("Local preview shutdown", () => localPreviewServer?.close());
307
+ await runCleanupStep("Build watcher shutdown", buildCleanup);
308
+
322
309
  p.outro("Dev mode stopped.");
323
310
  process.exit(0);
324
311
  };
@@ -210,7 +210,11 @@ export const startLocalPreviewServer = async ({
210
210
  const upstreamOrigin = new URL(apiBaseUrl);
211
211
  const previewOrigin = new URL(`${upstreamOrigin.protocol}//${previewHostname}`);
212
212
  const sseClients = new Set();
213
+ const sockets = new Set();
214
+ const upstreamRequests = new Set();
213
215
  let reloadTimer = null;
216
+ let closed = false;
217
+ let closePromise = null;
214
218
 
215
219
  const server = createServer(async (request, response) => {
216
220
  if (!request.url) {
@@ -256,6 +260,8 @@ export const startLocalPreviewServer = async ({
256
260
  }
257
261
 
258
262
  const targetUrl = new URL(requestUrl.pathname + requestUrl.search, upstreamOrigin);
263
+ const upstreamRequest = new AbortController();
264
+ upstreamRequests.add(upstreamRequest);
259
265
 
260
266
  try {
261
267
  const body = await readRequestBody(request);
@@ -264,10 +270,14 @@ export const startLocalPreviewServer = async ({
264
270
  headers: createForwardHeaders(request, previewHostname),
265
271
  body,
266
272
  redirect: "manual",
267
- signal: AbortSignal.timeout(PROXY_TIMEOUT_MS),
273
+ signal: AbortSignal.any([
274
+ AbortSignal.timeout(PROXY_TIMEOUT_MS),
275
+ upstreamRequest.signal,
276
+ ]),
268
277
  });
269
278
 
270
279
  if (isHtmlResponse(upstreamResponse.headers)) {
280
+ if (closed || response.destroyed) return;
271
281
  const html = injectLiveReloadScript(await upstreamResponse.text());
272
282
  writeResponseHeaders(upstreamResponse, response, {
273
283
  localOrigin,
@@ -286,6 +296,7 @@ export const startLocalPreviewServer = async ({
286
296
  previewOrigin,
287
297
  upstreamOrigin,
288
298
  });
299
+ if (closed || response.destroyed) return;
289
300
  response.statusCode = upstreamResponse.status;
290
301
 
291
302
  if (!upstreamResponse.body) {
@@ -300,12 +311,29 @@ export const startLocalPreviewServer = async ({
300
311
  });
301
312
  proxyStream.pipe(response);
302
313
  } catch (error) {
303
- const statusCode = error.statusCode ?? 502;
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);
304
323
  response.writeHead(statusCode, { "content-type": "text/plain; charset=utf-8" });
305
324
  response.end(`Local preview proxy error: ${error.message}`);
325
+ } finally {
326
+ upstreamRequests.delete(upstreamRequest);
306
327
  }
307
328
  });
308
329
 
330
+ server.on("connection", (socket) => {
331
+ sockets.add(socket);
332
+ socket.on("close", () => {
333
+ sockets.delete(socket);
334
+ });
335
+ });
336
+
309
337
  const heartbeat = setInterval(() => {
310
338
  for (const client of sseClients) {
311
339
  client.write(": ping\n\n");
@@ -327,24 +355,39 @@ export const startLocalPreviewServer = async ({
327
355
  }, RELOAD_DEBOUNCE_MS);
328
356
  },
329
357
  async close() {
330
- if (reloadTimer) clearTimeout(reloadTimer);
331
- clearInterval(heartbeat);
358
+ if (closePromise) return closePromise;
359
+ closed = true;
332
360
 
333
- for (const client of sseClients) {
334
- client.end();
335
- }
336
- sseClients.clear();
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
+ }
337
373
 
338
- await new Promise((resolve, reject) => {
339
374
  server.close((error) => {
340
- if (error) {
375
+ if (error && error.code !== "ERR_SERVER_NOT_RUNNING") {
341
376
  reject(error);
342
377
  return;
343
378
  }
344
379
 
345
380
  resolve();
346
381
  });
382
+
383
+ server.closeIdleConnections?.();
384
+ server.closeAllConnections?.();
385
+ for (const socket of sockets) {
386
+ socket.destroy();
387
+ }
347
388
  });
389
+
390
+ return closePromise;
348
391
  },
349
392
  };
350
393
  };
package/lib/preview.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { loadConfigOrFail, writeConfig } from "./config.mjs";
3
- import { apiFetch } from "./api.mjs";
3
+ import { apiFetch, fetchPreview } from "./api.mjs";
4
4
 
5
5
  export const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
6
6
  const base = new URL(apiBaseUrl);
@@ -8,6 +8,47 @@ export const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
8
8
  return `${base.protocol}//${previewHostname}${!hasExplicitPort && base.port ? `:${base.port}` : ""}/`;
9
9
  };
10
10
 
11
+ const formatShortDateTime = (value) => {
12
+ if (!value) return "Unknown";
13
+ const date = new Date(value);
14
+ if (Number.isNaN(date.getTime())) return "Unknown";
15
+
16
+ const months = [
17
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
18
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
19
+ ];
20
+ const month = months[date.getMonth()];
21
+ const day = date.getDate();
22
+ const hours = String(date.getHours()).padStart(2, "0");
23
+ const minutes = String(date.getMinutes()).padStart(2, "0");
24
+ return `${month} ${day} ${hours}:${minutes}`;
25
+ };
26
+
27
+ export const getPreviewDisplayName = (preview) =>
28
+ preview.name || `${formatShortDateTime(preview.createdAt)} preview (no name)`;
29
+
30
+ export const getPreviewUrl = (apiBaseUrl, preview) =>
31
+ buildPreviewUrl(apiBaseUrl, preview.previewHostname);
32
+
33
+ export const fetchPreviewDetails = async (
34
+ apiBaseUrl,
35
+ apiKey,
36
+ storeId,
37
+ previewKey,
38
+ ) => {
39
+ const result = await fetchPreview(apiBaseUrl, apiKey, storeId, previewKey);
40
+ if (!result.ok) return result;
41
+
42
+ return {
43
+ ok: true,
44
+ data: {
45
+ preview: result.data,
46
+ displayName: getPreviewDisplayName(result.data),
47
+ url: getPreviewUrl(apiBaseUrl, result.data),
48
+ },
49
+ };
50
+ };
51
+
11
52
  /**
12
53
  * @param {Array<any>} previews
13
54
  * @param {string | undefined} previewKey
@@ -42,18 +83,10 @@ export const createPreview = async (apiBaseUrl, apiKey, storeId, name) => {
42
83
  `/api/v2/stores/${storeId}/theme-previews`,
43
84
  {
44
85
  method: "POST",
45
- body: JSON.stringify({ name: name ?? "Dev" }),
86
+ body: JSON.stringify({ name: name ?? "" }),
46
87
  },
47
88
  );
48
89
 
49
- if (response.status === 409) {
50
- const body = await response.json().catch(() => ({}));
51
- const message =
52
- body?.error?.message ??
53
- "A preview already exists for this store. Delete it first with: tiendu preview delete";
54
- return { ok: false, error: message };
55
- }
56
-
57
90
  if (!response.ok) {
58
91
  const body = await response.text().catch(() => "");
59
92
  return {
@@ -158,6 +191,116 @@ export const publishPreview = async (
158
191
  }
159
192
  };
160
193
 
194
+ // ---------------------------------------------------------------------------
195
+ // Shared interactive preview picker
196
+ // ---------------------------------------------------------------------------
197
+
198
+ const CREATE_NEW_VALUE = "__create_new__";
199
+
200
+ /**
201
+ * Interactively resolve a preview key. Uses attached key if valid, otherwise
202
+ * prompts the user to pick an existing preview or create a new one.
203
+ *
204
+ * @param {{ config: import("./config.mjs").TienduConfig, credentials: import("./config.mjs").TienduCredentials }} opts
205
+ * @returns {Promise<string>} The resolved preview key
206
+ */
207
+ export const resolvePreviewKeyInteractively = async ({ config, credentials }) => {
208
+ const { apiBaseUrl, storeId } = config;
209
+ const { apiKey } = credentials;
210
+
211
+ // 1. Validate stored key
212
+ if (config.previewKey) {
213
+ const result = await fetchPreview(apiBaseUrl, apiKey, storeId, config.previewKey);
214
+ if (result.ok) {
215
+ return config.previewKey;
216
+ }
217
+
218
+ p.log.warn(`Stored preview ${config.previewKey} was not found. Please select a preview.`);
219
+ const { previewKey: _, ...rest } = config;
220
+ await writeConfig(rest);
221
+ } else {
222
+ p.log.warn("No preview attached.");
223
+ }
224
+
225
+ // 2. List previews
226
+ const listResult = await listPreviews(apiBaseUrl, apiKey, storeId);
227
+ if (!listResult.ok) {
228
+ p.log.error(listResult.error);
229
+ process.exit(1);
230
+ }
231
+
232
+ const previews = listResult.data;
233
+
234
+ if (previews.length === 0) {
235
+ p.log.info("No previews found for this store.");
236
+ }
237
+
238
+ // 3. Show picker
239
+ const options = [
240
+ ...previews.map((preview) => ({
241
+ value: preview.previewKey,
242
+ label: getPreviewDisplayName(preview),
243
+ hint: preview.previewKey,
244
+ })),
245
+ {
246
+ value: CREATE_NEW_VALUE,
247
+ label: "Create a new preview",
248
+ },
249
+ ];
250
+
251
+ const selected = await p.select({
252
+ message: "Select a preview",
253
+ options,
254
+ });
255
+
256
+ if (p.isCancel(selected)) {
257
+ p.cancel("Cancelled.");
258
+ process.exit(0);
259
+ }
260
+
261
+ // 4. Handle create new
262
+ if (selected === CREATE_NEW_VALUE) {
263
+ const nameInput = await p.text({
264
+ message: "Preview name (optional)",
265
+ placeholder: "Press Enter to skip",
266
+ defaultValue: "",
267
+ });
268
+
269
+ if (p.isCancel(nameInput)) {
270
+ p.cancel("Cancelled.");
271
+ process.exit(0);
272
+ }
273
+
274
+ const name = (nameInput ?? "").trim();
275
+ const spinner = p.spinner();
276
+ spinner.start("Creating preview...");
277
+
278
+ const createResult = await createPreview(apiBaseUrl, apiKey, storeId, name);
279
+ if (!createResult.ok) {
280
+ spinner.stop("Failed to create preview.", 1);
281
+ p.log.error(createResult.error);
282
+ process.exit(1);
283
+ }
284
+
285
+ const preview = createResult.data;
286
+ const displayName = getPreviewDisplayName(preview);
287
+ const url = getPreviewUrl(apiBaseUrl, preview);
288
+ spinner.stop(`Preview "${displayName}" created (${preview.previewKey})`);
289
+ p.log.message(` ${url}`);
290
+
291
+ await writeConfig({ ...config, previewKey: preview.previewKey });
292
+ return preview.previewKey;
293
+ }
294
+
295
+ // 5. Attach to selected preview
296
+ await writeConfig({ ...config, previewKey: selected });
297
+ const selectedPreview = previews.find((p) => p.previewKey === selected);
298
+ const displayName = selectedPreview ? getPreviewDisplayName(selectedPreview) : selected;
299
+ p.log.success(`Attached to "${displayName}" (${selected})`);
300
+
301
+ return selected;
302
+ };
303
+
161
304
  // ---------------------------------------------------------------------------
162
305
  // CLI commands
163
306
  // ---------------------------------------------------------------------------
@@ -182,8 +325,10 @@ export const previewCreate = async (name) => {
182
325
  }
183
326
 
184
327
  const preview = result.data;
185
- const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
186
- spinner.stop(`Preview created: ${url}`);
328
+ const url = getPreviewUrl(config.apiBaseUrl, preview);
329
+ const displayName = getPreviewDisplayName(preview);
330
+ spinner.stop(`Preview "${displayName}" created (${preview.previewKey})`);
331
+ p.log.message(` ${url}`);
187
332
 
188
333
  await writeConfig({ ...config, previewKey: preview.previewKey });
189
334
  };
@@ -215,14 +360,15 @@ export const previewList = async () => {
215
360
  `${result.data.length} preview${result.data.length === 1 ? "" : "s"}:`,
216
361
  );
217
362
 
218
- const activePreview = resolveActivePreview(result.data, config.previewKey);
219
-
220
363
  for (const preview of result.data) {
221
- const active =
222
- activePreview?.previewKey === preview.previewKey ? " ← active" : "";
364
+ const isAttached = config.previewKey === preview.previewKey;
365
+ const indicator = isAttached ? " \u2190 attached" : "";
223
366
  const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
224
- p.log.message(` ${preview.name} ${url}${active}`);
367
+ const displayName = getPreviewDisplayName(preview);
368
+ p.log.message(` ${displayName} ${url}${indicator}`);
225
369
  }
370
+
371
+ p.log.info("Tip: run tiendu preview attach <key> to switch previews.");
226
372
  };
227
373
 
228
374
  const formatRelativeDate = (value) => {
@@ -245,67 +391,119 @@ const formatRelativeDate = (value) => {
245
391
  export const previewShow = async () => {
246
392
  const { config, credentials } = await loadConfigOrFail();
247
393
 
248
- const result = await listPreviews(
394
+ if (!config.previewKey) {
395
+ p.log.warn("No preview attached. Run tiendu preview list or tiendu preview create.");
396
+ process.exit(0);
397
+ }
398
+
399
+ const result = await fetchPreview(
249
400
  config.apiBaseUrl,
250
401
  credentials.apiKey,
251
402
  config.storeId,
403
+ config.previewKey,
252
404
  );
253
405
 
254
406
  if (!result.ok) {
255
- p.log.error(result.error);
256
- process.exit(1);
257
- }
258
-
259
- const preview = resolveActivePreview(result.data, config.previewKey);
260
- if (!preview) {
261
- p.log.error(
262
- result.data.length === 0
263
- ? "No previews found for this store."
264
- : "Run tiendu preview list to inspect available previews.",
265
- );
407
+ p.log.warn(`Stored preview ${config.previewKey} was not found.`);
408
+ p.log.info("Run tiendu preview list to see available previews.");
266
409
  process.exit(1);
267
410
  }
268
411
 
269
- const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
412
+ const preview = result.data;
413
+ const url = getPreviewUrl(config.apiBaseUrl, preview);
414
+ const displayName = getPreviewDisplayName(preview);
270
415
 
271
416
  p.note(
272
417
  [
273
- `Name: ${preview.name || "Unnamed preview"}`,
418
+ `Name: ${displayName}`,
419
+ `Key: ${preview.previewKey}`,
274
420
  `URL: ${url}`,
275
421
  `Created: ${formatRelativeDate(preview.createdAt)}`,
276
422
  ].join("\n"),
277
- "Active preview",
423
+ "Attached preview",
278
424
  );
279
425
  };
280
426
 
281
- export const previewDelete = async () => {
427
+ export const previewAttach = async (keyArg) => {
282
428
  const { config, credentials } = await loadConfigOrFail();
283
429
 
284
- const listResult = await listPreviews(
430
+ if (!keyArg) {
431
+ await resolvePreviewKeyInteractively({ config, credentials });
432
+ return;
433
+ }
434
+
435
+ const spinner = p.spinner();
436
+ spinner.start("Validating preview...");
437
+
438
+ const result = await fetchPreview(
285
439
  config.apiBaseUrl,
286
440
  credentials.apiKey,
287
441
  config.storeId,
442
+ keyArg,
288
443
  );
289
- if (!listResult.ok) {
290
- p.log.error(listResult.error);
444
+
445
+ if (!result.ok) {
446
+ spinner.stop("Preview not found.", 1);
447
+ p.log.error("Preview not found. Run tiendu preview list to see available previews.");
291
448
  process.exit(1);
292
449
  }
293
450
 
294
- const activePreview = resolveActivePreview(
295
- listResult.data,
296
- config.previewKey,
451
+ const preview = result.data;
452
+ const url = getPreviewUrl(config.apiBaseUrl, preview);
453
+ const displayName = getPreviewDisplayName(preview);
454
+ spinner.stop(`Attached to preview "${displayName}" (${preview.previewKey})`);
455
+ p.log.message(` ${url}`);
456
+
457
+ await writeConfig({ ...config, previewKey: preview.previewKey });
458
+ };
459
+
460
+ export const previewDetach = async () => {
461
+ const { config } = await loadConfigOrFail();
462
+
463
+ if (!config.previewKey) {
464
+ p.log.warn("No preview is currently attached.");
465
+ process.exit(0);
466
+ }
467
+
468
+ const detachedKey = config.previewKey;
469
+ const { previewKey: _, ...rest } = config;
470
+ await writeConfig(rest);
471
+
472
+ p.log.success(`Detached from preview ${detachedKey}. No active preview.`);
473
+ };
474
+
475
+ export const previewDelete = async (keyArg) => {
476
+ const { config, credentials } = await loadConfigOrFail();
477
+
478
+ let previewKey = keyArg;
479
+
480
+ if (!previewKey) {
481
+ if (!config.previewKey) {
482
+ p.log.warn("No preview attached and no key provided.");
483
+ p.log.info("Run tiendu preview delete <key> or tiendu preview attach first.");
484
+ process.exit(1);
485
+ }
486
+ previewKey = config.previewKey;
487
+ }
488
+
489
+ // Fetch preview to show its name in the confirmation
490
+ const fetchResult = await fetchPreview(
491
+ config.apiBaseUrl,
492
+ credentials.apiKey,
493
+ config.storeId,
494
+ previewKey,
297
495
  );
298
- if (!activePreview) {
299
- p.log.error(
300
- listResult.data.length === 0
301
- ? "No previews found for this store."
302
- : "Could not determine the active preview. Run tiendu preview list first.",
303
- );
496
+
497
+ if (!fetchResult.ok) {
498
+ p.log.error(`Preview ${previewKey} not found.`);
304
499
  process.exit(1);
305
500
  }
306
501
 
502
+ const displayName = getPreviewDisplayName(fetchResult.data);
503
+ const url = getPreviewUrl(config.apiBaseUrl, fetchResult.data);
504
+
307
505
  const confirmed = await p.confirm({
308
- message: `Delete preview ${activePreview.previewKey}?`,
506
+ message: `Delete preview ${previewKey} "${displayName}" (${url})?`,
309
507
  });
310
508
 
311
509
  if (p.isCancel(confirmed) || !confirmed) {
@@ -320,7 +518,7 @@ export const previewDelete = async () => {
320
518
  config.apiBaseUrl,
321
519
  credentials.apiKey,
322
520
  config.storeId,
323
- activePreview.previewKey,
521
+ previewKey,
324
522
  );
325
523
 
326
524
  if (!result.ok) {
@@ -331,40 +529,38 @@ export const previewDelete = async () => {
331
529
 
332
530
  spinner.stop("Preview deleted.");
333
531
 
334
- const { previewKey, ...rest } = config;
335
- await writeConfig(rest);
532
+ if (config.previewKey === previewKey) {
533
+ const { previewKey: _, ...rest } = config;
534
+ await writeConfig(rest);
535
+ }
336
536
  };
337
537
 
338
538
  export const previewOpen = async () => {
339
539
  const { config, credentials } = await loadConfigOrFail();
340
540
 
541
+ if (!config.previewKey) {
542
+ p.log.warn("No preview attached. Run tiendu preview attach or tiendu preview create.");
543
+ process.exit(1);
544
+ }
545
+
341
546
  const spinner = p.spinner();
342
547
  spinner.start("Fetching preview URL...");
343
548
 
344
- const result = await listPreviews(
549
+ const result = await fetchPreview(
345
550
  config.apiBaseUrl,
346
551
  credentials.apiKey,
347
552
  config.storeId,
553
+ config.previewKey,
348
554
  );
349
555
 
350
556
  if (!result.ok) {
351
- spinner.stop("Failed to fetch previews.", 1);
352
- p.log.error(result.error);
557
+ spinner.stop("Preview not found.", 1);
558
+ p.log.error("Stored preview was not found. Run tiendu preview list.");
353
559
  process.exit(1);
354
560
  }
355
561
 
356
- const preview = resolveActivePreview(result.data, config.previewKey);
357
- if (!preview) {
358
- spinner.stop("Could not determine the active preview.", 1);
359
- p.log.error(
360
- result.data.length === 0
361
- ? "No previews found for this store."
362
- : "Run tiendu preview list and then set or recreate the preview.",
363
- );
364
- process.exit(1);
365
- }
366
-
367
- const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
562
+ const preview = result.data;
563
+ const url = getPreviewUrl(config.apiBaseUrl, preview);
368
564
  spinner.stop(`Opening ${url}`);
369
565
 
370
566
  const { spawn } = await import("node:child_process");
package/lib/publish.mjs CHANGED
@@ -1,18 +1,35 @@
1
1
  import * as p from "@clack/prompts";
2
- import { loadConfigOrFail, writeConfig, isBuiltTheme } from "./config.mjs";
3
- import { publishPreview } from "./preview.mjs";
2
+ import { loadConfigOrFail, isBuiltTheme } from "./config.mjs";
3
+ import {
4
+ fetchPreviewDetails,
5
+ publishPreview,
6
+ resolvePreviewKeyInteractively,
7
+ } from "./preview.mjs";
4
8
  import { push } from "./push.mjs";
5
9
 
6
- export const publish = async ({ skipBuild = false } = {}) => {
10
+ export const publish = async ({ skipBuild = false, previewKey: previewKeyArg } = {}) => {
7
11
  const { config, credentials } = await loadConfigOrFail();
8
12
 
9
- if (!config.previewKey) {
10
- p.log.error("No active preview. Create one with: tiendu preview create");
11
- process.exit(1);
12
- }
13
+ // Resolve preview key: explicit arg > interactive picker
14
+ const previewKey = previewKeyArg ?? await resolvePreviewKeyInteractively({ config, credentials });
15
+
16
+ // Fetch preview to show its name in the confirmation
17
+ const fetchResult = await fetchPreviewDetails(
18
+ config.apiBaseUrl,
19
+ credentials.apiKey,
20
+ config.storeId,
21
+ previewKey,
22
+ );
23
+
24
+ const displayName = fetchResult.ok
25
+ ? fetchResult.data.displayName
26
+ : previewKey;
27
+ const previewUrl = fetchResult.ok ? fetchResult.data.url : null;
13
28
 
14
29
  const confirmed = await p.confirm({
15
- message: `Publish preview ${config.previewKey} to the live storefront?`,
30
+ message: previewUrl
31
+ ? `Publish preview "${displayName}" (${previewKey}) at ${previewUrl} to the live storefront?`
32
+ : `Publish preview "${displayName}" (${previewKey}) to the live storefront?`,
16
33
  });
17
34
 
18
35
  if (p.isCancel(confirmed) || !confirmed) {
@@ -26,17 +43,17 @@ export const publish = async ({ skipBuild = false } = {}) => {
26
43
  ? "Syncing existing dist/ output to the preview before publishing..."
27
44
  : "Building and syncing the latest dist/ output before publishing...",
28
45
  );
29
- await push({ skipBuild });
46
+ await push({ skipBuild, previewKey });
30
47
  }
31
48
 
32
49
  const spinner = p.spinner();
33
- spinner.start("Publishing preview...");
50
+ spinner.start("Publishing preview to live storefront...");
34
51
 
35
52
  const result = await publishPreview(
36
53
  config.apiBaseUrl,
37
54
  credentials.apiKey,
38
55
  config.storeId,
39
- config.previewKey,
56
+ previewKey,
40
57
  );
41
58
 
42
59
  if (!result.ok) {
@@ -45,10 +62,8 @@ export const publish = async ({ skipBuild = false } = {}) => {
45
62
  process.exit(1);
46
63
  }
47
64
 
48
- spinner.stop("Preview published. Your live storefront has been updated.");
49
- p.log.info("All previews for this store have been removed.");
50
-
51
- // Remove preview key from config
52
- const { previewKey, ...rest } = config;
53
- await writeConfig(rest);
65
+ spinner.stop(`Preview ${previewKey} published. Your live storefront has been updated.`);
66
+ if (previewUrl) {
67
+ p.log.message(` ${previewUrl}`);
68
+ }
54
69
  };
package/lib/pull.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { loadConfigOrFail, isBuiltTheme, getDistDir } from "./config.mjs";
3
- import { downloadStorefrontArchive } from "./api.mjs";
3
+ import { downloadStorefrontArchive, downloadPreviewArchive } from "./api.mjs";
4
+ import { fetchPreviewDetails } from "./preview.mjs";
4
5
  import { extractZip } from "./zip.mjs";
5
6
 
6
7
  /** @param {number} bytes */
@@ -10,32 +11,56 @@ const formatBytes = (bytes) => {
10
11
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
11
12
  };
12
13
 
13
- export const pull = async () => {
14
+ export const pull = async ({ previewKey } = {}) => {
14
15
  const { config, credentials } = await loadConfigOrFail();
16
+ const previewDetails = previewKey
17
+ ? await fetchPreviewDetails(
18
+ config.apiBaseUrl,
19
+ credentials.apiKey,
20
+ config.storeId,
21
+ previewKey,
22
+ )
23
+ : null;
15
24
 
16
25
  const spinner = p.spinner();
17
- spinner.start(`Downloading theme from store #${config.storeId}...`);
26
+ const isPreviewPull = Boolean(previewKey);
18
27
 
19
- const result = await downloadStorefrontArchive(
20
- config.apiBaseUrl,
21
- credentials.apiKey,
22
- config.storeId,
28
+ spinner.start(
29
+ isPreviewPull
30
+ ? `Downloading preview ${previewKey} from store #${config.storeId}...`
31
+ : `Downloading live theme from store #${config.storeId}...`,
23
32
  );
24
33
 
34
+ const result = isPreviewPull
35
+ ? await downloadPreviewArchive(
36
+ config.apiBaseUrl,
37
+ credentials.apiKey,
38
+ config.storeId,
39
+ previewKey,
40
+ )
41
+ : await downloadStorefrontArchive(
42
+ config.apiBaseUrl,
43
+ credentials.apiKey,
44
+ config.storeId,
45
+ );
46
+
25
47
  if (!result.ok) {
26
48
  spinner.stop("Download failed.", 1);
27
49
  p.log.error(result.error);
28
50
  process.exit(1);
29
51
  }
30
52
 
31
- spinner.stop(
32
- `Archive received (${formatBytes(result.data.length)}). Extracting...`,
33
- );
53
+ spinner.message(`Extracting archive (${formatBytes(result.data.length)})...`);
34
54
 
35
55
  const outputDir = (await isBuiltTheme()) ? getDistDir() : process.cwd();
36
56
  const extractedFiles = await extractZip(result.data, outputDir);
37
57
 
38
- p.log.success(
39
- `${extractedFiles.length} file${extractedFiles.length === 1 ? "" : "s"} extracted.`,
58
+ const suffix = isPreviewPull ? ` from preview ${previewKey}` : "";
59
+ spinner.stop(
60
+ `${extractedFiles.length} file${extractedFiles.length === 1 ? "" : "s"} extracted${suffix}.`,
40
61
  );
62
+
63
+ if (previewDetails?.ok) {
64
+ p.log.message(` ${previewDetails.data.url}`);
65
+ }
41
66
  };
package/lib/push.mjs CHANGED
@@ -3,6 +3,10 @@ import { loadConfigOrFail, isBuiltTheme, getDistDir } from "./config.mjs";
3
3
  import { uploadPreviewZip } from "./api.mjs";
4
4
  import { createZipFromDirectory } from "./archive.mjs";
5
5
  import { build } from "./build.mjs";
6
+ import {
7
+ fetchPreviewDetails,
8
+ resolvePreviewKeyInteractively,
9
+ } from "./preview.mjs";
6
10
  import { retryAsync } from "./retry.mjs";
7
11
 
8
12
  /** @param {number} bytes */
@@ -19,11 +23,11 @@ export const pushPreparedDirectoryToPreview = async ({
19
23
  previewKey,
20
24
  rootDir,
21
25
  spinner,
22
- packMessage = "Packing files...",
26
+ compressMessage = "Compressing files...",
23
27
  uploadMessage,
24
28
  retryMessage,
25
29
  }) => {
26
- spinner.message(packMessage);
30
+ spinner.message(compressMessage);
27
31
 
28
32
  const zipBuffer = await createZipFromDirectory(rootDir);
29
33
  spinner.message(
@@ -45,14 +49,9 @@ export const pushPreparedDirectoryToPreview = async ({
45
49
  );
46
50
  };
47
51
 
48
- export const push = async ({ skipBuild = false } = {}) => {
52
+ export const push = async ({ skipBuild = false, previewKey: previewKeyArg } = {}) => {
49
53
  const { config, credentials } = await loadConfigOrFail();
50
54
 
51
- if (!config.previewKey) {
52
- p.log.error("No active preview. Create one with: tiendu preview create");
53
- process.exit(1);
54
- }
55
-
56
55
  const builtTheme = await isBuiltTheme();
57
56
 
58
57
  if (builtTheme && !skipBuild) {
@@ -62,15 +61,29 @@ export const push = async ({ skipBuild = false } = {}) => {
62
61
  }
63
62
  }
64
63
 
64
+ // Resolve preview key: explicit arg > interactive picker
65
+ const previewKey = previewKeyArg ?? await resolvePreviewKeyInteractively({ config, credentials });
66
+ const previewDetails = await fetchPreviewDetails(
67
+ config.apiBaseUrl,
68
+ credentials.apiKey,
69
+ config.storeId,
70
+ previewKey,
71
+ );
72
+
73
+ if (!previewDetails.ok) {
74
+ p.log.error(`Preview ${previewKey} not found.`);
75
+ process.exit(1);
76
+ }
77
+
65
78
  const rootDir = builtTheme ? getDistDir() : process.cwd();
66
79
  const spinner = p.spinner();
67
- spinner.start("Packing files...");
80
+ spinner.start("Compressing files...");
68
81
 
69
82
  const result = await pushPreparedDirectoryToPreview({
70
83
  apiBaseUrl: config.apiBaseUrl,
71
84
  apiKey: credentials.apiKey,
72
85
  storeId: config.storeId,
73
- previewKey: config.previewKey,
86
+ previewKey,
74
87
  rootDir,
75
88
  spinner,
76
89
  });
@@ -81,5 +94,6 @@ export const push = async ({ skipBuild = false } = {}) => {
81
94
  process.exit(1);
82
95
  }
83
96
 
84
- spinner.stop("Files uploaded to preview.");
97
+ spinner.stop(`Files uploaded to preview ${previewKey}.`);
98
+ p.log.message(` ${previewDetails.data.url}`);
85
99
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tiendu",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "CLI para desarrollar y publicar temas en Tiendu",
5
5
  "type": "module",
6
6
  "bin": {