@by-association-only/theme-backup 1.0.2 → 1.0.4
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/.github/workflows/npm-publish.yml +89 -10
- package/README.md +9 -0
- package/index.js +68 -40
- package/package.json +1 -2
|
@@ -1,20 +1,99 @@
|
|
|
1
|
+
# Releases a new version whenever a PR is merged into main:
|
|
2
|
+
# bumps the version, publishes to npm, then pushes the version commit and tag and creates a GitHub release.
|
|
3
|
+
#
|
|
4
|
+
# Version bump defaults to patch. Add one of these labels to the PR to change it:
|
|
5
|
+
# release:minor, release:major - bump minor / major instead
|
|
6
|
+
# skip-release - don't release this merge
|
|
7
|
+
# Merges that only change *.md files or .github/ don't release.
|
|
8
|
+
#
|
|
9
|
+
# Can also be run by hand from the Actions tab (Run workflow) to release the current main.
|
|
10
|
+
#
|
|
11
|
+
# npm authenticates via trusted publishing (OIDC), configured on npmjs.com for this workflow filename.
|
|
1
12
|
name: Publish package to npm
|
|
2
13
|
on:
|
|
3
|
-
|
|
4
|
-
|
|
14
|
+
push:
|
|
15
|
+
branches: [main]
|
|
16
|
+
paths-ignore:
|
|
17
|
+
- '**.md'
|
|
18
|
+
- '.github/**'
|
|
19
|
+
workflow_dispatch:
|
|
20
|
+
inputs:
|
|
21
|
+
bump:
|
|
22
|
+
description: Version bump
|
|
23
|
+
type: choice
|
|
24
|
+
options: [patch, minor, major]
|
|
25
|
+
default: patch
|
|
26
|
+
# Queue releases so two quick merges don't race to push the same version
|
|
27
|
+
concurrency:
|
|
28
|
+
group: release
|
|
29
|
+
cancel-in-progress: false
|
|
5
30
|
jobs:
|
|
6
|
-
|
|
31
|
+
release:
|
|
32
|
+
if: github.ref_name == github.event.repository.default_branch
|
|
7
33
|
runs-on: ubuntu-latest
|
|
8
34
|
permissions:
|
|
9
|
-
contents:
|
|
35
|
+
contents: write
|
|
10
36
|
id-token: write
|
|
37
|
+
pull-requests: read
|
|
11
38
|
steps:
|
|
12
|
-
- uses: actions/checkout@
|
|
13
|
-
|
|
39
|
+
- uses: actions/checkout@v7
|
|
40
|
+
with:
|
|
41
|
+
# Latest branch tip rather than the triggering commit, so a queued run includes earlier release commits
|
|
42
|
+
ref: ${{ github.ref_name }}
|
|
43
|
+
- uses: actions/setup-node@v7
|
|
14
44
|
with:
|
|
15
|
-
node-version: '
|
|
45
|
+
node-version: '24'
|
|
16
46
|
registry-url: 'https://registry.npmjs.org'
|
|
17
|
-
|
|
18
|
-
-
|
|
47
|
+
package-manager-cache: false
|
|
48
|
+
- name: Choose version bump
|
|
49
|
+
id: bump
|
|
50
|
+
env:
|
|
51
|
+
GH_TOKEN: ${{ github.token }}
|
|
52
|
+
INPUT_BUMP: ${{ inputs.bump }}
|
|
53
|
+
run: |
|
|
54
|
+
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
|
55
|
+
bump="$INPUT_BUMP"
|
|
56
|
+
else
|
|
57
|
+
labels=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" -q '.[0].labels[].name')
|
|
58
|
+
bump=patch
|
|
59
|
+
if grep -qx 'release:minor' <<< "$labels"; then bump=minor; fi
|
|
60
|
+
if grep -qx 'release:major' <<< "$labels"; then bump=major; fi
|
|
61
|
+
if grep -qx 'skip-release' <<< "$labels"; then bump=skip; fi
|
|
62
|
+
fi
|
|
63
|
+
echo "Version bump: $bump"
|
|
64
|
+
echo "bump=$bump" >> "$GITHUB_OUTPUT"
|
|
65
|
+
- if: steps.bump.outputs.bump != 'skip'
|
|
66
|
+
run: npm ci
|
|
67
|
+
- name: Bump version
|
|
68
|
+
id: version
|
|
69
|
+
if: steps.bump.outputs.bump != 'skip'
|
|
70
|
+
env:
|
|
71
|
+
BUMP: ${{ steps.bump.outputs.bump }}
|
|
72
|
+
run: |
|
|
73
|
+
git config user.name 'github-actions[bot]'
|
|
74
|
+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
|
75
|
+
name=$(node -p "require('./package.json').name")
|
|
76
|
+
current=$(node -p "require('./package.json').version")
|
|
77
|
+
if npm view "$name@$current" version > /dev/null 2>&1; then
|
|
78
|
+
# Commits the bumped package.json / package-lock.json and tags it v<version>
|
|
79
|
+
npm version "$BUMP" -m 'chore: Release v%s'
|
|
80
|
+
else
|
|
81
|
+
# The version was already bumped by hand and isn't on npm yet, so release it as-is
|
|
82
|
+
echo "$name@$current isn't on npm yet, releasing it without a bump"
|
|
83
|
+
git tag "v$current"
|
|
84
|
+
fi
|
|
85
|
+
echo "tag=v$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
|
86
|
+
# Publish before pushing, so if npm rejects it nothing reaches git and the run can be retried
|
|
87
|
+
- if: steps.bump.outputs.bump != 'skip'
|
|
88
|
+
run: npm publish --access public
|
|
89
|
+
- name: Push version commit and tag
|
|
90
|
+
if: steps.bump.outputs.bump != 'skip'
|
|
91
|
+
env:
|
|
92
|
+
TAG: ${{ steps.version.outputs.tag }}
|
|
93
|
+
run: git push --atomic origin "HEAD:$GITHUB_REF_NAME" "$TAG"
|
|
94
|
+
- name: Create GitHub release
|
|
95
|
+
if: steps.bump.outputs.bump != 'skip'
|
|
19
96
|
env:
|
|
20
|
-
|
|
97
|
+
GH_TOKEN: ${{ github.token }}
|
|
98
|
+
TAG: ${{ steps.version.outputs.tag }}
|
|
99
|
+
run: gh release create "$TAG" --title "$TAG" --generate-notes --verify-tag
|
package/README.md
CHANGED
|
@@ -78,3 +78,12 @@ shopify auth login
|
|
|
78
78
|
### Theme Limit Errors
|
|
79
79
|
|
|
80
80
|
If you're at the theme limit, the tool will prompt you to delete themes before proceeding.
|
|
81
|
+
|
|
82
|
+
## Releasing
|
|
83
|
+
|
|
84
|
+
Merging a PR into `main` publishes a new version automatically. The GitHub Action bumps the version, publishes to npm, and creates the version tag and a GitHub release with notes.
|
|
85
|
+
|
|
86
|
+
- The version bump is **patch** by default. Add a `release:minor` or `release:major` label to the PR to change it, or `skip-release` to not release that merge.
|
|
87
|
+
- Merges that only change `*.md` files or `.github/` don't release.
|
|
88
|
+
- If the PR already bumps `version` in `package.json` to a version that isn't on npm yet, that version is released as-is.
|
|
89
|
+
- To release by hand, run the **Publish package to npm** workflow from the Actions tab and choose the bump.
|
package/index.js
CHANGED
|
@@ -2,7 +2,31 @@
|
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import chalk from 'chalk'
|
|
5
|
-
import { execSync } from 'node:child_process'
|
|
5
|
+
import { execFileSync, execSync } from 'node:child_process'
|
|
6
|
+
|
|
7
|
+
// Function to run a Shopify CLI command
|
|
8
|
+
// Arguments are passed without a shell so theme names and paths containing quotes, $ or spaces aren't mangled.
|
|
9
|
+
// Windows can't run shopify.cmd without a shell, so each argument is quoted there instead.
|
|
10
|
+
function shopify(args, options) {
|
|
11
|
+
if (process.platform === 'win32') {
|
|
12
|
+
return execSync(`shopify ${args.map(arg => `"${arg}"`).join(' ')}`, options)
|
|
13
|
+
}
|
|
14
|
+
return execFileSync('shopify', args, options)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Enable raw mode for real-time input, removing any previous prompt's key listener first.
|
|
18
|
+
// Listeners left attached keep reacting to key presses, e.g. the Y/N prompt pausing input when typing a search.
|
|
19
|
+
function startInput() {
|
|
20
|
+
process.stdin.removeAllListeners('data')
|
|
21
|
+
process.stdin.setRawMode(true)
|
|
22
|
+
process.stdin.resume()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stopInput() {
|
|
26
|
+
process.stdin.removeAllListeners('data')
|
|
27
|
+
process.stdin.setRawMode(false)
|
|
28
|
+
process.stdin.pause()
|
|
29
|
+
}
|
|
6
30
|
|
|
7
31
|
// Function to read stores from config file
|
|
8
32
|
function readStoresFromConfig() {
|
|
@@ -39,7 +63,7 @@ function readStoresFromConfig() {
|
|
|
39
63
|
async function getStoreThemes(store) {
|
|
40
64
|
try {
|
|
41
65
|
console.log(chalk.gray(` Fetching themes from ${store}...`))
|
|
42
|
-
const output =
|
|
66
|
+
const output = shopify(['theme', 'list', `-s=${store}`, '--json'], {
|
|
43
67
|
encoding: 'utf8',
|
|
44
68
|
stdio: ['pipe', 'pipe', 'pipe'] // Silent mode since auth is already handled
|
|
45
69
|
})
|
|
@@ -76,8 +100,7 @@ async function selectTheme(store, actionText = 'Please select theme to backup')
|
|
|
76
100
|
let searchTerm = ''
|
|
77
101
|
|
|
78
102
|
// Enable raw mode for real-time input
|
|
79
|
-
|
|
80
|
-
process.stdin.resume()
|
|
103
|
+
startInput()
|
|
81
104
|
|
|
82
105
|
const clearScreen = () => {
|
|
83
106
|
process.stdout.write('\x1B[2J\x1B[H')
|
|
@@ -153,24 +176,21 @@ async function selectTheme(store, actionText = 'Please select theme to backup')
|
|
|
153
176
|
return new Promise((resolve) => {
|
|
154
177
|
const handleKey = (key) => {
|
|
155
178
|
if (key === '\x03' || key === 'q') { // Ctrl+C or 'q'
|
|
156
|
-
|
|
157
|
-
process.stdin.pause()
|
|
179
|
+
stopInput()
|
|
158
180
|
console.log(chalk.yellow('\nQuitting theme backup...'))
|
|
159
181
|
process.exit(0)
|
|
160
182
|
return
|
|
161
183
|
}
|
|
162
184
|
|
|
163
185
|
if (key === 's') { // Skip store
|
|
164
|
-
|
|
165
|
-
process.stdin.pause()
|
|
186
|
+
stopInput()
|
|
166
187
|
console.log(chalk.yellow(`\nSkipping ${store}...`))
|
|
167
188
|
resolve(null)
|
|
168
189
|
return
|
|
169
190
|
}
|
|
170
191
|
|
|
171
192
|
if (key === '\r') { // Enter
|
|
172
|
-
|
|
173
|
-
process.stdin.pause()
|
|
193
|
+
stopInput()
|
|
174
194
|
const sortedThemes = getSortedThemes()
|
|
175
195
|
resolve(sortedThemes[selectedIndex])
|
|
176
196
|
return
|
|
@@ -229,8 +249,7 @@ async function selectThemesToDelete(store, themes, requiredCount = 1) {
|
|
|
229
249
|
let errorMessage = ''
|
|
230
250
|
|
|
231
251
|
// Enable raw mode for real-time input
|
|
232
|
-
|
|
233
|
-
process.stdin.resume()
|
|
252
|
+
startInput()
|
|
234
253
|
|
|
235
254
|
const clearScreen = () => {
|
|
236
255
|
process.stdout.write('\x1B[2J\x1B[H')
|
|
@@ -307,8 +326,7 @@ async function selectThemesToDelete(store, themes, requiredCount = 1) {
|
|
|
307
326
|
return new Promise((resolve) => {
|
|
308
327
|
const handleKey = (key) => {
|
|
309
328
|
if (key === '\x03' || key === 'q') { // Ctrl+C or 'q'
|
|
310
|
-
|
|
311
|
-
process.stdin.pause()
|
|
329
|
+
stopInput()
|
|
312
330
|
console.log(chalk.yellow('\nQuitting theme backup...'))
|
|
313
331
|
process.exit(0)
|
|
314
332
|
return
|
|
@@ -316,8 +334,7 @@ async function selectThemesToDelete(store, themes, requiredCount = 1) {
|
|
|
316
334
|
|
|
317
335
|
if (key === '\r') { // Enter
|
|
318
336
|
if (requiredCount === 0 || selectedIndices.size >= requiredCount) {
|
|
319
|
-
|
|
320
|
-
process.stdin.pause()
|
|
337
|
+
stopInput()
|
|
321
338
|
const themesToDelete = Array.from(selectedIndices).map(i => filteredThemes[i])
|
|
322
339
|
resolve(themesToDelete)
|
|
323
340
|
return
|
|
@@ -394,7 +411,7 @@ async function deleteThemes(store, themes) {
|
|
|
394
411
|
for (const theme of themes) {
|
|
395
412
|
try {
|
|
396
413
|
console.log(chalk.gray(` Deleting: ${theme.name} (ID: ${theme.id})`))
|
|
397
|
-
|
|
414
|
+
shopify(['theme', 'delete', `-t=${theme.id}`, `-s=${store}`, '--force'], { stdio: 'inherit' })
|
|
398
415
|
console.log(chalk.green(` ✓ Deleted: ${theme.name}`))
|
|
399
416
|
} catch (error) {
|
|
400
417
|
console.error(chalk.red(` ✗ Failed to delete ${theme.name}:`))
|
|
@@ -430,10 +447,10 @@ async function backupTheme(store, theme) {
|
|
|
430
447
|
fs.mkdirSync(tempPath, { recursive: true })
|
|
431
448
|
|
|
432
449
|
console.log(chalk.gray(`\nPulling theme: ${theme.name}...`))
|
|
433
|
-
|
|
450
|
+
shopify(['theme', 'pull', `-t=${theme.id}`, `-s=${store}`, '--path', tempPath], { stdio: 'inherit' })
|
|
434
451
|
|
|
435
452
|
console.log(chalk.gray(`\nPushing as new theme: ${backupName}...`))
|
|
436
|
-
|
|
453
|
+
shopify(['theme', 'push', `-s=${store}`, '--unpublished', '--path', tempPath, `--theme=${backupName}`], { stdio: 'inherit' })
|
|
437
454
|
|
|
438
455
|
console.log(chalk.green(`\n✓ Successfully backed up theme: ${theme.name}`))
|
|
439
456
|
console.log(chalk.gray(` New theme: ${backupName}`))
|
|
@@ -457,10 +474,7 @@ async function backupTheme(store, theme) {
|
|
|
457
474
|
|
|
458
475
|
// Function to prompt user for text input
|
|
459
476
|
async function promptTextInput(message, maxLength = 50) {
|
|
460
|
-
|
|
461
|
-
process.stdin.removeAllListeners('data')
|
|
462
|
-
process.stdin.setRawMode(true)
|
|
463
|
-
process.stdin.resume()
|
|
477
|
+
startInput()
|
|
464
478
|
|
|
465
479
|
let input = ''
|
|
466
480
|
|
|
@@ -482,9 +496,7 @@ async function promptTextInput(message, maxLength = 50) {
|
|
|
482
496
|
const key = data.toString()
|
|
483
497
|
|
|
484
498
|
if (key === '\x03') { // Ctrl+C
|
|
485
|
-
|
|
486
|
-
process.stdin.setRawMode(false)
|
|
487
|
-
process.stdin.pause()
|
|
499
|
+
stopInput()
|
|
488
500
|
console.log(chalk.yellow('\nCancelled...'))
|
|
489
501
|
process.exit(0)
|
|
490
502
|
return
|
|
@@ -499,9 +511,7 @@ async function promptTextInput(message, maxLength = 50) {
|
|
|
499
511
|
updateDisplay()
|
|
500
512
|
return
|
|
501
513
|
}
|
|
502
|
-
|
|
503
|
-
process.stdin.setRawMode(false)
|
|
504
|
-
process.stdin.pause()
|
|
514
|
+
stopInput()
|
|
505
515
|
resolve(input.trim())
|
|
506
516
|
return
|
|
507
517
|
}
|
|
@@ -521,8 +531,7 @@ async function promptTextInput(message, maxLength = 50) {
|
|
|
521
531
|
|
|
522
532
|
// Function to prompt user for yes/no
|
|
523
533
|
async function promptYesNo(message) {
|
|
524
|
-
|
|
525
|
-
process.stdin.resume()
|
|
534
|
+
startInput()
|
|
526
535
|
|
|
527
536
|
console.log(chalk.yellow(`\n${message}`))
|
|
528
537
|
console.log(chalk.gray('Press Y for Yes, N for No'))
|
|
@@ -532,22 +541,19 @@ async function promptYesNo(message) {
|
|
|
532
541
|
const keyStr = key.toString().toLowerCase()
|
|
533
542
|
|
|
534
543
|
if (keyStr === 'y') {
|
|
535
|
-
|
|
536
|
-
process.stdin.pause()
|
|
544
|
+
stopInput()
|
|
537
545
|
resolve(true)
|
|
538
546
|
return
|
|
539
547
|
}
|
|
540
548
|
|
|
541
549
|
if (keyStr === 'n') {
|
|
542
|
-
|
|
543
|
-
process.stdin.pause()
|
|
550
|
+
stopInput()
|
|
544
551
|
resolve(false)
|
|
545
552
|
return
|
|
546
553
|
}
|
|
547
554
|
|
|
548
555
|
if (key === '\x03') { // Ctrl+C
|
|
549
|
-
|
|
550
|
-
process.stdin.pause()
|
|
556
|
+
stopInput()
|
|
551
557
|
console.log(chalk.yellow('\nQuitting...'))
|
|
552
558
|
process.exit(0)
|
|
553
559
|
}
|
|
@@ -561,7 +567,7 @@ async function promptYesNo(message) {
|
|
|
561
567
|
async function checkAuthentication(store) {
|
|
562
568
|
try {
|
|
563
569
|
console.log(chalk.gray(`Checking authentication for ${store}...`))
|
|
564
|
-
|
|
570
|
+
shopify(['theme', 'info', `-s=${store}`], {
|
|
565
571
|
encoding: 'utf8',
|
|
566
572
|
stdio: 'inherit' // Allow interactive authentication
|
|
567
573
|
})
|
|
@@ -590,6 +596,9 @@ async function runBackup() {
|
|
|
590
596
|
console.log(chalk.green('✓ Authentication verified\n'))
|
|
591
597
|
}
|
|
592
598
|
|
|
599
|
+
// Stores whose backup failed, listed at the end as the screen is cleared between stores
|
|
600
|
+
const failedStores = []
|
|
601
|
+
|
|
593
602
|
for (let i = 0; i < stores.length; i++) {
|
|
594
603
|
const store = stores[i]
|
|
595
604
|
|
|
@@ -606,6 +615,7 @@ async function runBackup() {
|
|
|
606
615
|
const themes = await getStoreThemes(store)
|
|
607
616
|
if (!themes) {
|
|
608
617
|
console.error(chalk.red('Failed to fetch themes, skipping store'))
|
|
618
|
+
failedStores.push(store)
|
|
609
619
|
continue
|
|
610
620
|
}
|
|
611
621
|
|
|
@@ -623,6 +633,7 @@ async function runBackup() {
|
|
|
623
633
|
const deleteSuccess = await deleteThemes(store, themesToDelete)
|
|
624
634
|
if (!deleteSuccess) {
|
|
625
635
|
console.log(chalk.red('Failed to delete themes, skipping store'))
|
|
636
|
+
failedStores.push(store)
|
|
626
637
|
continue
|
|
627
638
|
}
|
|
628
639
|
|
|
@@ -656,7 +667,21 @@ async function runBackup() {
|
|
|
656
667
|
}
|
|
657
668
|
|
|
658
669
|
// Backup the theme
|
|
659
|
-
await backupTheme(store, selectedTheme)
|
|
670
|
+
const backupSuccess = await backupTheme(store, selectedTheme)
|
|
671
|
+
if (!backupSuccess) {
|
|
672
|
+
failedStores.push(store)
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (failedStores.length > 0) {
|
|
677
|
+
console.log(chalk.bold.red('\n---------------------------------'))
|
|
678
|
+
console.log(chalk.bold.red(`✗ BACKUP FAILED FOR ${failedStores.length} STORE(S)`))
|
|
679
|
+
console.log(chalk.bold.red('---------------------------------'))
|
|
680
|
+
for (const store of failedStores) {
|
|
681
|
+
console.log(chalk.red(` ${store}`))
|
|
682
|
+
}
|
|
683
|
+
console.log('')
|
|
684
|
+
process.exit(1)
|
|
660
685
|
}
|
|
661
686
|
|
|
662
687
|
console.log(chalk.bold('\n---------------------------------'))
|
|
@@ -664,4 +689,7 @@ async function runBackup() {
|
|
|
664
689
|
console.log(chalk.bold('---------------------------------\n'))
|
|
665
690
|
}
|
|
666
691
|
|
|
667
|
-
runBackup().catch(
|
|
692
|
+
runBackup().catch((error) => {
|
|
693
|
+
console.error(error)
|
|
694
|
+
process.exit(1)
|
|
695
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@by-association-only/theme-backup",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Backup Shopify themes across multiple stores.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -25,4 +25,3 @@
|
|
|
25
25
|
"homepage": "https://github.com/baoagency/theme-backup#readme",
|
|
26
26
|
"devDependencies": {}
|
|
27
27
|
}
|
|
28
|
-
|