@by-association-only/theme-backup 1.0.3 → 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.
@@ -1,27 +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
- release:
4
- types: [published]
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
- build:
31
+ release:
32
+ if: github.ref_name == github.event.repository.default_branch
7
33
  runs-on: ubuntu-latest
8
34
  permissions:
9
- contents: read
35
+ contents: write
10
36
  id-token: write
37
+ pull-requests: read
11
38
  steps:
12
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 }}
13
43
  - uses: actions/setup-node@v7
14
44
  with:
15
45
  node-version: '24'
16
46
  registry-url: 'https://registry.npmjs.org'
17
47
  package-manager-cache: false
18
- - name: Check release tag matches package.json version
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 }}
19
72
  run: |
20
- PACKAGE_VERSION="v$(node -p "require('./package.json').version")"
21
- if [ "$PACKAGE_VERSION" != "$GITHUB_REF_NAME" ]; then
22
- echo "::error::Release tag $GITHUB_REF_NAME does not match package.json version $PACKAGE_VERSION. Bump the version in package.json before releasing."
23
- exit 1
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"
24
84
  fi
25
- - run: npm ci
26
- # Authenticates via npm trusted publishing (OIDC), no token needed
27
- - run: npm publish --access public
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'
96
+ env:
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 = execSync(`shopify theme list -s=${store} --json`, {
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
- process.stdin.setRawMode(true)
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
- process.stdin.setRawMode(false)
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
- process.stdin.setRawMode(false)
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
- process.stdin.setRawMode(false)
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
- process.stdin.setRawMode(true)
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
- process.stdin.setRawMode(false)
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
- process.stdin.setRawMode(false)
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
- execSync(`shopify theme delete -t=${theme.id} -s=${store} --force`, { stdio: 'inherit' })
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
- execSync(`shopify theme pull -t=${theme.id} -s=${store} --path ${tempPath}`, { stdio: 'inherit' })
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
- execSync(`shopify theme push -s=${store} --unpublished --path ${tempPath} --theme="${backupName}"`, { stdio: 'inherit' })
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
- // Remove all existing listeners to prevent conflicts
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
- process.stdin.removeAllListeners('data')
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
- process.stdin.removeAllListeners('data')
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
- process.stdin.setRawMode(true)
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
- process.stdin.setRawMode(false)
536
- process.stdin.pause()
544
+ stopInput()
537
545
  resolve(true)
538
546
  return
539
547
  }
540
548
 
541
549
  if (keyStr === 'n') {
542
- process.stdin.setRawMode(false)
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
- process.stdin.setRawMode(false)
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
- execSync(`shopify theme info -s=${store}`, {
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(console.error)
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",
3
+ "version": "1.0.4",
4
4
  "description": "Backup Shopify themes across multiple stores.",
5
5
  "type": "module",
6
6
  "main": "index.js",