tiendu 0.8.1 → 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/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, skipInstances = 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, sk
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, skipInstances });
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
- skipInstances = false,
29
+ overrideState = false,
30
30
  }) => {
31
31
  spinner.message(compressMessage);
32
32
 
33
- const shouldInclude = skipInstances
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, skipInstances),
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, skipInstances = 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({ skipInstances });
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, skipI
89
89
  previewKey,
90
90
  rootDir,
91
91
  spinner,
92
- skipInstances,
92
+ overrideState,
93
93
  });
94
94
 
95
95
  if (!result.ok) {
@@ -99,5 +99,8 @@ export const push = async ({ skipBuild = false, previewKey: previewKeyArg, skipI
99
99
  }
100
100
 
101
101
  spinner.stop(`Files uploaded to preview ${previewKey}.`);
102
+ ui.log.message(
103
+ `Theme state: ${overrideState ? "overridden from local files" : "preserved from the theme editor"}`,
104
+ );
102
105
  ui.log.message(` ${previewDetails.data.url}`);
103
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.1",
3
+ "version": "0.9.0",
4
4
  "description": "CLI para desarrollar y publicar temas en Tiendu",
5
5
  "type": "module",
6
6
  "bin": {