@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.
- package/.nvmrc +1 -0
- package/README.md +92 -0
- package/index.js +582 -0
- 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
|
+
|