@by-association-only/theme-backup 1.0.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.
Files changed (4) hide show
  1. package/.nvmrc +1 -0
  2. package/README.md +92 -0
  3. package/index.js +582 -0
  4. package/package.json +28 -0
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 18
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # Theme Backup
2
+
3
+ A CLI tool to backup Shopify themes across multiple stores.
4
+
5
+ ## What it does
6
+
7
+ This tool helps you create backups of Shopify themes across multiple stores in your organization:
8
+
9
+ - Reads store URLs from a config file (`.bao-stores` or `.bao-translation-checker`)
10
+ - Shows interactive theme selection UI
11
+ - Checks theme limits and prompts for deletion if needed:
12
+ - At 100 themes: Forces deletion (must delete at least 1)
13
+ - Above 20 themes: Optional deletion prompt
14
+ - Backs up selected theme with naming: `[Original Name] (Backup YYYY-MM-DD)`
15
+ - Uses Shopify CLI `pull` and `push` commands for duplication
16
+
17
+ ## Setup
18
+
19
+ 1) Install the package:
20
+
21
+ ```bash
22
+ npm i @by-association-only/theme-backup --save-dev
23
+ ```
24
+
25
+ or
26
+
27
+ ```bash
28
+ yarn add @by-association-only/theme-backup --save-dev
29
+ ```
30
+
31
+ 2) Add a config file in the root: `.bao-stores` (or use existing `.bao-translation-checker`)
32
+
33
+ List each store name on a separate line (the part before .myshopify.com).
34
+
35
+ Example:
36
+ If your stores are:
37
+ - acmestore.myshopify.com
38
+ - acmestore-us.myshopify.com
39
+
40
+ You would enter:
41
+ ```
42
+ acmestore
43
+ acmestore-us
44
+ ```
45
+
46
+ 3) Run the backup tool:
47
+
48
+ ```bash
49
+ bao-theme-backup
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ The tool will:
55
+ 1. Process each store in your config file
56
+ 2. Check theme count and prompt for deletion if needed
57
+ 3. Show interactive theme selector
58
+ 4. Pull the selected theme to a temp folder
59
+ 5. Push it as a new unpublished theme with backup naming using the DD/MM/YYYY format
60
+ 6. Clean up temp files
61
+
62
+ ## Theme Limit Handling
63
+
64
+ The tool automatically checks your theme count and handles it as follows:
65
+
66
+ - **100+ themes**: You've hit the maximum limit. The tool will force you to delete at least 1 theme before proceeding with the backup.
67
+ - **21-99 themes**: You'll see a prompt: "You have X themes currently. Would you like to delete any?" You can choose to delete some themes or skip and proceed directly to backup.
68
+ - **20 or fewer themes**: The tool proceeds directly to theme selection and backup.
69
+
70
+ **Note**: Shopify's theme limits are:
71
+ - Non-Plus stores: 20 themes maximum
72
+ - Shopify Plus stores: 100 themes maximum
73
+
74
+ ## Requirements
75
+
76
+ - Node v18.20+
77
+ - Shopify CLI v3.71+
78
+ - Valid Shopify store access
79
+
80
+ ## Issues
81
+
82
+ ### Authentication Required
83
+
84
+ If you see authentication errors, make sure you're logged into Shopify CLI:
85
+
86
+ ```bash
87
+ shopify auth login
88
+ ```
89
+
90
+ ### Theme Limit Errors
91
+
92
+ If you're at the theme limit, the tool will prompt you to delete themes before proceeding.
package/index.js ADDED
@@ -0,0 +1,582 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import chalk from 'chalk'
5
+ import { execSync } from 'node:child_process'
6
+
7
+ // Function to read stores from config file
8
+ function readStoresFromConfig() {
9
+ try {
10
+ // Check for .bao-stores first, fallback to .bao-translation-checker
11
+ const baoStoresPath = path.join(process.cwd(), '.bao-stores')
12
+ const legacyPath = path.join(process.cwd(), '.bao-translation-checker')
13
+
14
+ let configPath
15
+ if (fs.existsSync(baoStoresPath)) {
16
+ configPath = baoStoresPath
17
+ } else if (fs.existsSync(legacyPath)) {
18
+ configPath = legacyPath
19
+ } else {
20
+ console.error(chalk.red('Error: Config file not found.'))
21
+ console.error(chalk.gray('Create a .bao-stores or .bao-translation-checker file in your project root with one store URL per line.'))
22
+ process.exit(1)
23
+ }
24
+
25
+ const content = fs.readFileSync(configPath, 'utf8')
26
+ const stores = content.split('\n').filter(line => line.trim())
27
+ if (stores.length === 0) {
28
+ throw new Error('No stores found in config file')
29
+ }
30
+ return stores
31
+ } catch (error) {
32
+ console.error(chalk.red('Error reading config file:'))
33
+ console.error(chalk.red(error.message))
34
+ process.exit(1)
35
+ }
36
+ }
37
+
38
+ // Function to get list of themes from a store
39
+ async function getStoreThemes(store) {
40
+ try {
41
+ console.log(chalk.gray(` Fetching themes from ${store}...`))
42
+ const output = execSync(`shopify theme list -s=${store} --json`, {
43
+ encoding: 'utf8',
44
+ stdio: ['pipe', 'pipe', 'pipe'] // Silent mode since auth is already handled
45
+ })
46
+ return JSON.parse(output)
47
+ } catch (error) {
48
+ console.error(chalk.red(`Failed to fetch themes for ${store}:`))
49
+ console.error(chalk.red(error.message))
50
+ return null
51
+ }
52
+ }
53
+
54
+ // Function to filter themes based on search input
55
+ function filterThemes(themes, searchTerm) {
56
+ if (!searchTerm) return themes
57
+ const term = searchTerm.toLowerCase()
58
+ return themes.filter(theme =>
59
+ theme.name.toLowerCase().includes(term) ||
60
+ theme.id.toString().includes(term)
61
+ )
62
+ }
63
+
64
+ // Function to handle theme selection
65
+ async function selectTheme(store, actionText = 'Please select theme to backup') {
66
+ const themes = await getStoreThemes(store)
67
+ if (!themes || themes.length === 0) {
68
+ console.error(chalk.red('No themes found for this store'))
69
+ return null
70
+ }
71
+
72
+ let filteredThemes = themes
73
+ let currentPage = 1
74
+ const pageSize = 10
75
+ let selectedIndex = 0
76
+ let searchTerm = ''
77
+
78
+ // Enable raw mode for real-time input
79
+ process.stdin.setRawMode(true)
80
+ process.stdin.resume()
81
+
82
+ const clearScreen = () => {
83
+ process.stdout.write('\x1B[2J\x1B[H')
84
+ }
85
+
86
+ // Helper to get sorted themes
87
+ const getSortedThemes = () => {
88
+ return [...filteredThemes].sort((a, b) => {
89
+ if (a.role === 'live') return -1
90
+ if (b.role === 'live') return 1
91
+ return 0
92
+ })
93
+ }
94
+
95
+ const updateDisplay = () => {
96
+ clearScreen()
97
+ console.log(chalk.bold(`\n${store} - ${actionText}:`))
98
+ console.log(chalk.bold('----------------------------------------'))
99
+
100
+ // Sort themes to put published theme first
101
+ const sortedThemes = getSortedThemes()
102
+
103
+ const start = (currentPage - 1) * pageSize
104
+ const end = start + pageSize
105
+ const pageThemes = sortedThemes.slice(start, end)
106
+
107
+ let hasShownLiveTheme = false
108
+
109
+ pageThemes.forEach((theme, index) => {
110
+ const isLive = theme.role === 'live'
111
+ const isSelected = start + index === selectedIndex
112
+ const line = `${theme.name} (ID: ${theme.id})`
113
+
114
+ // Add blank line after live theme if we haven't shown it yet
115
+ if (!hasShownLiveTheme && !isLive && sortedThemes.some(t => t.role === 'live')) {
116
+ console.log('')
117
+ hasShownLiveTheme = true
118
+ }
119
+
120
+ if (isLive) {
121
+ console.log(isSelected ?
122
+ chalk.bgCyan.black(`[LIVE THEME] ${line}`) :
123
+ chalk.green(`[LIVE THEME] ${line}`)
124
+ )
125
+ } else {
126
+ console.log(isSelected ?
127
+ chalk.bgCyan.black(line) :
128
+ chalk.cyan(line)
129
+ )
130
+ }
131
+ })
132
+
133
+ if (filteredThemes.length > pageSize) {
134
+ const totalPages = Math.ceil(filteredThemes.length / pageSize)
135
+ console.log(chalk.gray(`\nPage ${currentPage} of ${totalPages}`))
136
+ }
137
+
138
+ if (searchTerm) {
139
+ console.log(chalk.gray(`\nSearch: ${searchTerm}`))
140
+ }
141
+
142
+ console.log(chalk.yellow('\nNavigation:'))
143
+ console.log(chalk.yellow(' ↑/↓ Move between themes'))
144
+ console.log(chalk.yellow(' ←/→ Move between pages'))
145
+ console.log(chalk.yellow(' Type Filter themes by name'))
146
+ console.log(chalk.yellow(' Enter Select highlighted theme'))
147
+ console.log(chalk.yellow(' s Skip current store'))
148
+ console.log(chalk.yellow(' q Quit'))
149
+ }
150
+
151
+ updateDisplay()
152
+
153
+ return new Promise((resolve) => {
154
+ const handleKey = (key) => {
155
+ if (key === '\x03' || key === 'q') { // Ctrl+C or 'q'
156
+ process.stdin.setRawMode(false)
157
+ process.stdin.pause()
158
+ console.log(chalk.yellow('\nQuitting theme backup...'))
159
+ process.exit(0)
160
+ return
161
+ }
162
+
163
+ if (key === 's') { // Skip store
164
+ process.stdin.setRawMode(false)
165
+ process.stdin.pause()
166
+ console.log(chalk.yellow(`\nSkipping ${store}...`))
167
+ resolve(null)
168
+ return
169
+ }
170
+
171
+ if (key === '\r') { // Enter
172
+ process.stdin.setRawMode(false)
173
+ process.stdin.pause()
174
+ const sortedThemes = getSortedThemes()
175
+ resolve(sortedThemes[selectedIndex])
176
+ return
177
+ }
178
+
179
+ if (key === '\x1B[A') { // Up arrow
180
+ selectedIndex = Math.max(0, selectedIndex - 1)
181
+ if (selectedIndex < (currentPage - 1) * pageSize) {
182
+ currentPage = Math.max(1, currentPage - 1)
183
+ }
184
+ } else if (key === '\x1B[B') { // Down arrow
185
+ selectedIndex = Math.min(filteredThemes.length - 1, selectedIndex + 1)
186
+ if (selectedIndex >= currentPage * pageSize) {
187
+ currentPage = Math.min(Math.ceil(filteredThemes.length / pageSize), currentPage + 1)
188
+ }
189
+ } else if (key === '\x1B[C') { // Right arrow
190
+ if (currentPage < Math.ceil(filteredThemes.length / pageSize)) {
191
+ currentPage++
192
+ selectedIndex = (currentPage - 1) * pageSize
193
+ }
194
+ } else if (key === '\x1B[D') { // Left arrow
195
+ if (currentPage > 1) {
196
+ currentPage--
197
+ selectedIndex = (currentPage - 1) * pageSize
198
+ }
199
+ } else if (key === '\x7F') { // Backspace
200
+ searchTerm = searchTerm.slice(0, -1)
201
+ filteredThemes = filterThemes(themes, searchTerm)
202
+ currentPage = 1
203
+ selectedIndex = 0
204
+ } else if (key.length === 1 && key >= ' ' && key <= '~') { // Printable characters
205
+ searchTerm += key
206
+ filteredThemes = filterThemes(themes, searchTerm)
207
+ currentPage = 1
208
+ selectedIndex = 0
209
+ }
210
+
211
+ updateDisplay()
212
+ }
213
+
214
+ process.stdin.on('data', (data) => {
215
+ const key = data.toString()
216
+ handleKey(key)
217
+ })
218
+ })
219
+ }
220
+
221
+ // Function to handle theme deletion when at limit
222
+ async function selectThemesToDelete(store, themes, requiredCount = 1) {
223
+ let selectedIndices = new Set()
224
+ let currentPage = 1
225
+ const pageSize = 10
226
+ let highlightedIndex = 0
227
+ let searchTerm = ''
228
+ let filteredThemes = themes.filter(t => t.role !== 'live') // Don't allow deleting live theme
229
+ let errorMessage = ''
230
+
231
+ // Enable raw mode for real-time input
232
+ process.stdin.setRawMode(true)
233
+ process.stdin.resume()
234
+
235
+ const clearScreen = () => {
236
+ process.stdout.write('\x1B[2J\x1B[H')
237
+ }
238
+
239
+ const updateDisplay = () => {
240
+ clearScreen()
241
+ if (requiredCount > 0) {
242
+ console.log(chalk.bold.red(`\n${store} - Theme limit reached (${themes.length} themes)`))
243
+ console.log(chalk.bold.yellow(`Select ${requiredCount} theme(s) to delete:`))
244
+ } else {
245
+ console.log(chalk.bold(`\n${store} - Select themes to delete:`))
246
+ }
247
+ console.log(chalk.bold('----------------------------------------'))
248
+
249
+ const start = (currentPage - 1) * pageSize
250
+ const end = start + pageSize
251
+ const pageThemes = filteredThemes.slice(start, end)
252
+
253
+ pageThemes.forEach((theme, index) => {
254
+ const globalIndex = start + index
255
+ const isHighlighted = globalIndex === highlightedIndex
256
+ const isSelected = selectedIndices.has(globalIndex)
257
+ const line = `${theme.name} (ID: ${theme.id})`
258
+
259
+ let prefix = isSelected ? '[✓] ' : '[ ] '
260
+
261
+ if (isHighlighted && isSelected) {
262
+ console.log(chalk.bgRed.white(prefix + line))
263
+ } else if (isHighlighted) {
264
+ console.log(chalk.bgCyan.black(prefix + line))
265
+ } else if (isSelected) {
266
+ console.log(chalk.red(prefix + line))
267
+ } else {
268
+ console.log(chalk.cyan(prefix + line))
269
+ }
270
+ })
271
+
272
+ if (filteredThemes.length > pageSize) {
273
+ const totalPages = Math.ceil(filteredThemes.length / pageSize)
274
+ console.log(chalk.gray(`\nPage ${currentPage} of ${totalPages}`))
275
+ }
276
+
277
+ if (searchTerm) {
278
+ console.log(chalk.gray(`\nSearch: ${searchTerm}`))
279
+ }
280
+
281
+ if (requiredCount > 0) {
282
+ const isEnoughSelected = selectedIndices.size >= requiredCount
283
+ if (isEnoughSelected) {
284
+ console.log(chalk.green(`\nSelected: ${selectedIndices.size} / ${requiredCount} required`))
285
+ } else {
286
+ console.log(chalk.yellow(`\nSelected: ${selectedIndices.size} / ${requiredCount} required`))
287
+ }
288
+ } else {
289
+ console.log(chalk.gray(`\nSelected: ${selectedIndices.size}`))
290
+ }
291
+
292
+ if (errorMessage) {
293
+ console.log(chalk.red.bold(`\n⚠ ${errorMessage}`))
294
+ }
295
+
296
+ console.log(chalk.yellow('\nNavigation:'))
297
+ console.log(chalk.yellow(' ↑/↓ Move between themes'))
298
+ console.log(chalk.yellow(' ←/→ Move between pages'))
299
+ console.log(chalk.yellow(' Space Toggle selection'))
300
+ console.log(chalk.yellow(' Type Filter themes by name'))
301
+ console.log(chalk.yellow(' Enter Confirm deletion'))
302
+ console.log(chalk.yellow(' q Quit'))
303
+ }
304
+
305
+ updateDisplay()
306
+
307
+ return new Promise((resolve) => {
308
+ const handleKey = (key) => {
309
+ if (key === '\x03' || key === 'q') { // Ctrl+C or 'q'
310
+ process.stdin.setRawMode(false)
311
+ process.stdin.pause()
312
+ console.log(chalk.yellow('\nQuitting theme backup...'))
313
+ process.exit(0)
314
+ return
315
+ }
316
+
317
+ if (key === '\r') { // Enter
318
+ if (requiredCount === 0 || selectedIndices.size >= requiredCount) {
319
+ process.stdin.setRawMode(false)
320
+ process.stdin.pause()
321
+ const themesToDelete = Array.from(selectedIndices).map(i => filteredThemes[i])
322
+ resolve(themesToDelete)
323
+ return
324
+ }
325
+ // Not enough selected - show error message
326
+ errorMessage = `Please select at least ${requiredCount} theme(s) to delete (use Spacebar to select)`
327
+ updateDisplay()
328
+ return
329
+ }
330
+
331
+ if (key === ' ') { // Space - toggle selection
332
+ errorMessage = '' // Clear error on any action
333
+ if (selectedIndices.has(highlightedIndex)) {
334
+ selectedIndices.delete(highlightedIndex)
335
+ } else {
336
+ selectedIndices.add(highlightedIndex)
337
+ }
338
+ } else if (key === '\x1B[A') { // Up arrow
339
+ errorMessage = '' // Clear error on any action
340
+ highlightedIndex = Math.max(0, highlightedIndex - 1)
341
+ if (highlightedIndex < (currentPage - 1) * pageSize) {
342
+ currentPage = Math.max(1, currentPage - 1)
343
+ }
344
+ } else if (key === '\x1B[B') { // Down arrow
345
+ errorMessage = '' // Clear error on any action
346
+ highlightedIndex = Math.min(filteredThemes.length - 1, highlightedIndex + 1)
347
+ if (highlightedIndex >= currentPage * pageSize) {
348
+ currentPage = Math.min(Math.ceil(filteredThemes.length / pageSize), currentPage + 1)
349
+ }
350
+ } else if (key === '\x1B[C') { // Right arrow
351
+ errorMessage = '' // Clear error on any action
352
+ if (currentPage < Math.ceil(filteredThemes.length / pageSize)) {
353
+ currentPage++
354
+ highlightedIndex = (currentPage - 1) * pageSize
355
+ }
356
+ } else if (key === '\x1B[D') { // Left arrow
357
+ errorMessage = '' // Clear error on any action
358
+ if (currentPage > 1) {
359
+ currentPage--
360
+ highlightedIndex = (currentPage - 1) * pageSize
361
+ }
362
+ } else if (key === '\x7F') { // Backspace
363
+ errorMessage = '' // Clear error on any action
364
+ searchTerm = searchTerm.slice(0, -1)
365
+ filteredThemes = themes.filter(t => t.role !== 'live')
366
+ filteredThemes = filterThemes(filteredThemes, searchTerm)
367
+ selectedIndices.clear()
368
+ currentPage = 1
369
+ highlightedIndex = 0
370
+ } else if (key.length === 1 && key >= ' ' && key <= '~') { // Printable characters
371
+ errorMessage = '' // Clear error on any action
372
+ searchTerm += key
373
+ filteredThemes = themes.filter(t => t.role !== 'live')
374
+ filteredThemes = filterThemes(filteredThemes, searchTerm)
375
+ selectedIndices.clear()
376
+ currentPage = 1
377
+ highlightedIndex = 0
378
+ }
379
+
380
+ updateDisplay()
381
+ }
382
+
383
+ process.stdin.on('data', (data) => {
384
+ const key = data.toString()
385
+ handleKey(key)
386
+ })
387
+ })
388
+ }
389
+
390
+ // Function to delete themes
391
+ async function deleteThemes(store, themes) {
392
+ console.log(chalk.yellow(`\nDeleting ${themes.length} theme(s)...`))
393
+
394
+ for (const theme of themes) {
395
+ try {
396
+ console.log(chalk.gray(` Deleting: ${theme.name} (ID: ${theme.id})`))
397
+ execSync(`shopify theme delete -t=${theme.id} -s=${store} --force`, { stdio: 'inherit' })
398
+ console.log(chalk.green(` ✓ Deleted: ${theme.name}`))
399
+ } catch (error) {
400
+ console.error(chalk.red(` ✗ Failed to delete ${theme.name}:`))
401
+ console.error(chalk.red(` ${error.message}`))
402
+ return false
403
+ }
404
+ }
405
+
406
+ return true
407
+ }
408
+
409
+ // Function to backup a theme
410
+ async function backupTheme(store, theme) {
411
+ const date = new Date()
412
+ const today = `${String(date.getDate()).padStart(2, '0')}/${String(date.getMonth() + 1).padStart(2, '0')}/${date.getFullYear()}` // DD/MM/YYYY
413
+ const backupName = `${theme.name} (Backup ${today})`
414
+ const tempPath = path.join(process.cwd(), '.temp-backup')
415
+
416
+ try {
417
+ // Clean up any existing temp folder and recreate it
418
+ if (fs.existsSync(tempPath)) {
419
+ fs.rmSync(tempPath, { recursive: true, force: true })
420
+ }
421
+ fs.mkdirSync(tempPath, { recursive: true })
422
+
423
+ console.log(chalk.gray(`\nPulling theme: ${theme.name}...`))
424
+ execSync(`shopify theme pull -t=${theme.id} -s=${store} --path ${tempPath}`, { stdio: 'inherit' })
425
+
426
+ console.log(chalk.gray(`\nPushing as new theme: ${backupName}...`))
427
+ execSync(`shopify theme push -s=${store} --unpublished --path ${tempPath} --theme="${backupName}"`, { stdio: 'inherit' })
428
+
429
+ console.log(chalk.green(`\n✓ Successfully backed up theme: ${theme.name}`))
430
+ console.log(chalk.gray(` New theme: ${backupName}`))
431
+
432
+ // Clean up temp folder
433
+ fs.rmSync(tempPath, { recursive: true, force: true })
434
+
435
+ return true
436
+ } catch (error) {
437
+ console.error(chalk.red(`\n✗ Failed to backup theme ${theme.name}:`))
438
+ console.error(chalk.red(error.message))
439
+
440
+ // Clean up temp folder even on failure
441
+ if (fs.existsSync(tempPath)) {
442
+ fs.rmSync(tempPath, { recursive: true, force: true })
443
+ }
444
+
445
+ return false
446
+ }
447
+ }
448
+
449
+ // Function to prompt user for yes/no
450
+ async function promptYesNo(message) {
451
+ process.stdin.setRawMode(true)
452
+ process.stdin.resume()
453
+
454
+ console.log(chalk.yellow(`\n${message}`))
455
+ console.log(chalk.gray('Press Y for Yes, N for No'))
456
+
457
+ return new Promise((resolve) => {
458
+ const handleKey = (key) => {
459
+ const keyStr = key.toString().toLowerCase()
460
+
461
+ if (keyStr === 'y') {
462
+ process.stdin.setRawMode(false)
463
+ process.stdin.pause()
464
+ resolve(true)
465
+ return
466
+ }
467
+
468
+ if (keyStr === 'n') {
469
+ process.stdin.setRawMode(false)
470
+ process.stdin.pause()
471
+ resolve(false)
472
+ return
473
+ }
474
+
475
+ if (key === '\x03') { // Ctrl+C
476
+ process.stdin.setRawMode(false)
477
+ process.stdin.pause()
478
+ console.log(chalk.yellow('\nQuitting...'))
479
+ process.exit(0)
480
+ }
481
+ }
482
+
483
+ process.stdin.on('data', handleKey)
484
+ })
485
+ }
486
+
487
+ // Pre-flight check to ensure Shopify CLI authentication
488
+ async function checkAuthentication(store) {
489
+ try {
490
+ console.log(chalk.gray(`Checking authentication for ${store}...`))
491
+ execSync(`shopify theme info -s=${store}`, {
492
+ encoding: 'utf8',
493
+ stdio: 'inherit' // Allow interactive authentication
494
+ })
495
+ return true
496
+ } catch (error) {
497
+ console.error(chalk.red(`\nAuthentication failed for ${store}`))
498
+ console.error(chalk.yellow('Please run: shopify auth login'))
499
+ return false
500
+ }
501
+ }
502
+
503
+ async function runBackup() {
504
+ const stores = readStoresFromConfig()
505
+
506
+ console.log(chalk.bold('\n---------------------------------'))
507
+ console.log(chalk.bold('SHOPIFY THEME BACKUP'))
508
+ console.log(chalk.bold('---------------------------------\n'))
509
+
510
+ // Pre-flight authentication check for first store
511
+ if (stores.length > 0) {
512
+ console.log(chalk.yellow('Checking Shopify CLI authentication...\n'))
513
+ const isAuthenticated = await checkAuthentication(stores[0])
514
+ if (!isAuthenticated) {
515
+ process.exit(1)
516
+ }
517
+ console.log(chalk.green('✓ Authentication verified\n'))
518
+ }
519
+
520
+ for (const store of stores) {
521
+ console.log(chalk.bold(`\nProcessing ${store}:`))
522
+
523
+ // Get all themes
524
+ const themes = await getStoreThemes(store)
525
+ if (!themes) {
526
+ console.error(chalk.red('Failed to fetch themes, skipping store'))
527
+ continue
528
+ }
529
+
530
+ // Check if at theme limit (100 themes) - MUST delete
531
+ if (themes.length >= 100) {
532
+ console.log(chalk.red(`\n⚠ Theme limit reached: ${themes.length} themes`))
533
+ console.log(chalk.yellow('You must delete at least 1 theme before creating a backup.\n'))
534
+
535
+ const themesToDelete = await selectThemesToDelete(store, themes, 1)
536
+ if (!themesToDelete || themesToDelete.length === 0) {
537
+ console.log(chalk.yellow('No themes selected for deletion, skipping store'))
538
+ continue
539
+ }
540
+
541
+ const deleteSuccess = await deleteThemes(store, themesToDelete)
542
+ if (!deleteSuccess) {
543
+ console.log(chalk.red('Failed to delete themes, skipping store'))
544
+ continue
545
+ }
546
+
547
+ console.log(chalk.green('\n✓ Theme deletion complete'))
548
+ }
549
+ // Check if >20 themes - OPTIONAL delete
550
+ else if (themes.length > 20) {
551
+ const wantToDelete = await promptYesNo(`You have ${themes.length} themes currently. Would you like to delete any?`)
552
+
553
+ if (wantToDelete) {
554
+ const themesToDelete = await selectThemesToDelete(store, themes, 0) // 0 = optional, any number
555
+ if (themesToDelete && themesToDelete.length > 0) {
556
+ const deleteSuccess = await deleteThemes(store, themesToDelete)
557
+ if (!deleteSuccess) {
558
+ console.log(chalk.red('Failed to delete some themes, but continuing...'))
559
+ } else {
560
+ console.log(chalk.green('\n✓ Theme deletion complete'))
561
+ }
562
+ }
563
+ }
564
+ }
565
+
566
+ // Select theme to backup
567
+ const selectedTheme = await selectTheme(store, 'Please select theme to backup')
568
+ if (!selectedTheme) {
569
+ console.log(chalk.yellow('No theme selected, skipping store'))
570
+ continue
571
+ }
572
+
573
+ // Backup the theme
574
+ await backupTheme(store, selectedTheme)
575
+ }
576
+
577
+ console.log(chalk.bold('\n---------------------------------'))
578
+ console.log(chalk.bold.green('✓ BACKUP COMPLETE'))
579
+ console.log(chalk.bold('---------------------------------\n'))
580
+ }
581
+
582
+ runBackup().catch(console.error)
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@by-association-only/theme-backup",
3
+ "version": "1.0.0",
4
+ "description": "Backup Shopify themes across multiple stores.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "test": "echo \"Error: no test specified\" && exit 1"
9
+ },
10
+ "bin": {
11
+ "bao-theme-backup": "index.js"
12
+ },
13
+ "dependencies": {
14
+ "chalk": "^5.3.0"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/baoagency/theme-backup.git"
19
+ },
20
+ "author": "By Association Only",
21
+ "license": "ISC",
22
+ "bugs": {
23
+ "url": "https://github.com/baoagency/theme-backup/issues"
24
+ },
25
+ "homepage": "https://github.com/baoagency/theme-backup#readme",
26
+ "devDependencies": {}
27
+ }
28
+