climaybe 2.4.2 → 3.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.
@@ -0,0 +1,30 @@
1
+ import { spawn } from 'node:child_process';
2
+ import pc from 'picocolors';
3
+
4
+ function spawnInherit(cmd, args, { cwd = process.cwd(), name = cmd } = {}) {
5
+ const child = spawn(cmd, args, {
6
+ cwd,
7
+ stdio: 'inherit',
8
+ shell: process.platform === 'win32',
9
+ });
10
+ child.on('exit', (code) => {
11
+ if (code && code !== 0) {
12
+ console.log(pc.red(`\n ${name} exited with code ${code}\n`));
13
+ }
14
+ });
15
+ return child;
16
+ }
17
+
18
+ /**
19
+ * Run Shopify CLI, falling back to npx when `shopify` isn't available.
20
+ * @param {string[]} args e.g. ['theme','check']
21
+ */
22
+ export function runShopify(args, { cwd = process.cwd(), name = 'shopify' } = {}) {
23
+ const child = spawnInherit('shopify', args, { cwd, name });
24
+ child.on('error', (err) => {
25
+ if (err?.code !== 'ENOENT') return;
26
+ spawnInherit('npx', ['-y', '@shopify/cli@latest', ...args], { cwd, name: 'shopify(npx)' });
27
+ });
28
+ return child;
29
+ }
30
+
@@ -1,21 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
- import { readPkg, writePkg } from './config.js';
3
+ import { readPkg, writePkg, writeClimaybeConfig } from './config.js';
4
4
 
5
5
  const DEV_KIT_FILES = {
6
- 'nodemon.json': `{
7
- "watch": ["_scripts"],
8
- "ext": "js",
9
- "exec": "npm run scripts:build --silent",
10
- "quiet": true,
11
- "no-colours": true,
12
- "ignore": ["node_modules/**/*", "assets/**/*", "**/*.min.js"],
13
- "delay": "500",
14
- "polling": false,
15
- "legacyWatch": false,
16
- "restartable": "rs"
17
- }
18
- `,
19
6
  '.theme-check.yml': `root: .
20
7
 
21
8
  extends: :nothing
@@ -80,7 +67,7 @@ const VSCODE_TASKS_CONTENT = `{
80
67
  {
81
68
  "label": "Shopify",
82
69
  "type": "shell",
83
- "command": "yarn shopify:serve",
70
+ "command": "climaybe serve:shopify",
84
71
  "isBackground": true,
85
72
  "presentation": {
86
73
  "echo": true,
@@ -96,7 +83,7 @@ const VSCODE_TASKS_CONTENT = `{
96
83
  {
97
84
  "label": "Tailwind",
98
85
  "type": "shell",
99
- "command": "yarn tailwind:watch",
86
+ "command": "climaybe serve:assets",
100
87
  "isBackground": true,
101
88
  "presentation": {
102
89
  "echo": true,
@@ -128,33 +115,6 @@ assets/index.js
128
115
  .vercel
129
116
  `;
130
117
 
131
- const PACKAGE_MERGES = {
132
- scripts: {
133
- 'shopify:serve': 'shopify theme dev --theme-editor-sync --store=$npm_package_config_store',
134
- 'shopify:populate': 'shopify populate --store=$npm_package_config_store',
135
- 'scripts:build': 'node build-scripts.js',
136
- 'scripts:watch': 'nodemon',
137
- 'tailwind:watch':
138
- `concurrently --kill-others --max-restarts 3 "NODE_ENV=production NODE_OPTIONS='--max-old-space-size=512' ` +
139
- `npx @tailwindcss/cli -i _styles/main.css -o assets/style.css --watch" "NODE_OPTIONS='--max-old-space-size=256' ` +
140
- `npm run scripts:watch" "npx -y @shopify/dev-mcp@latest"`,
141
- 'tailwind:build': 'NODE_ENV=production npx @tailwindcss/cli -i _styles/main.css -o assets/style.css --minify && npm run scripts:build',
142
- 'lint:liquid': 'shopify theme check',
143
- 'lint:js': 'eslint ./assets/*.js --config .config/eslint.config.mjs',
144
- 'lint:css': 'node_modules/.bin/stylelint ./assets/*.css --config .config/.stylelintrc.json',
145
- release: 'node .sys/scripts/release.js',
146
- },
147
- devDependencies: {
148
- '@shopify/prettier-plugin-liquid': '^1.6.3',
149
- '@tailwindcss/cli': '^4.1.17',
150
- concurrently: '^8.2.2',
151
- nodemon: '^3.0.2',
152
- prettier: '^3.4.2',
153
- stylelint: '^16.9.0',
154
- eslint: '^9.11.0',
155
- },
156
- };
157
-
158
118
  function ensureParent(path) {
159
119
  mkdirSync(dirname(path), { recursive: true });
160
120
  }
@@ -171,12 +131,16 @@ function mergeGitignore(cwd = process.cwd()) {
171
131
  writeFileSync(path, `${next}\n`, 'utf-8');
172
132
  }
173
133
 
174
- function mergePackageJson(defaultStoreDomain = '', cwd = process.cwd()) {
175
- const pkg = readPkg(cwd) || { name: 'shopify-theme', version: '1.0.0', private: true };
176
- pkg.config = { ...(pkg.config || {}) };
177
- if (!pkg.config.store && defaultStoreDomain) pkg.config.store = defaultStoreDomain;
178
- pkg.scripts = { ...(pkg.scripts || {}), ...PACKAGE_MERGES.scripts };
179
- pkg.devDependencies = { ...(pkg.devDependencies || {}), ...PACKAGE_MERGES.devDependencies };
134
+ function mergePackageJson({ packageName = 'shopify-theme', cwd = process.cwd() } = {}) {
135
+ const pkg = readPkg(cwd) || { name: packageName, version: '1.0.0', private: true };
136
+ pkg.devDependencies = { ...(pkg.devDependencies || {}) };
137
+
138
+ // Ensure teammates can run climaybe + Tailwind after plain npm install.
139
+ const cliVersion = process.env.CLIMAYBE_PACKAGE_VERSION;
140
+ const climaybeRange = /^\d+\.\d+\.\d+/.test(String(cliVersion || '')) ? `^${cliVersion}` : 'latest';
141
+ if (!pkg.devDependencies.climaybe) pkg.devDependencies.climaybe = climaybeRange;
142
+ if (!pkg.devDependencies.tailwindcss) pkg.devDependencies.tailwindcss = 'latest';
143
+
180
144
  writePkg(pkg, cwd);
181
145
  }
182
146
 
@@ -186,7 +150,12 @@ export function getDevKitExistingFiles({ includeVSCodeTasks = true, cwd = proces
186
150
  return paths.filter((p) => existsSync(join(cwd, p)));
187
151
  }
188
152
 
189
- export function scaffoldThemeDevKit({ includeVSCodeTasks = true, defaultStoreDomain = '', cwd = process.cwd() } = {}) {
153
+ export function scaffoldThemeDevKit({
154
+ includeVSCodeTasks = true,
155
+ defaultStoreDomain = '',
156
+ packageName = 'shopify-theme',
157
+ cwd = process.cwd(),
158
+ } = {}) {
190
159
  for (const [rel, content] of Object.entries(DEV_KIT_FILES)) {
191
160
  const dest = join(cwd, rel);
192
161
  ensureParent(dest);
@@ -198,5 +167,18 @@ export function scaffoldThemeDevKit({ includeVSCodeTasks = true, defaultStoreDom
198
167
  writeFileSync(dest, VSCODE_TASKS_CONTENT, 'utf-8');
199
168
  }
200
169
  mergeGitignore(cwd);
201
- mergePackageJson(defaultStoreDomain, cwd);
170
+ mergePackageJson({ packageName, cwd });
171
+
172
+ // New source-of-truth config file for climaybe (local dev + CI).
173
+ // We intentionally keep package.json changes minimal (no script injection).
174
+ writeClimaybeConfig(
175
+ {
176
+ port: 9295,
177
+ default_store: defaultStoreDomain || undefined,
178
+ dev_kit: true,
179
+ vscode_tasks: includeVSCodeTasks,
180
+ project_type: 'theme',
181
+ },
182
+ cwd
183
+ );
202
184
  }
@@ -0,0 +1,80 @@
1
+ import { existsSync, readdirSync, watch } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ function debounce(fn, ms) {
5
+ let t = null;
6
+ return (...args) => {
7
+ if (t) clearTimeout(t);
8
+ t = setTimeout(() => fn(...args), ms);
9
+ };
10
+ }
11
+
12
+ function listDirsRecursively(rootDir) {
13
+ const dirs = [];
14
+ if (!existsSync(rootDir)) return dirs;
15
+
16
+ const stack = [rootDir];
17
+ while (stack.length) {
18
+ const dir = stack.pop();
19
+ dirs.push(dir);
20
+ let entries = [];
21
+ try {
22
+ entries = readdirSync(dir, { withFileTypes: true });
23
+ } catch {
24
+ continue;
25
+ }
26
+ for (const e of entries) {
27
+ if (!e.isDirectory()) continue;
28
+ const full = join(dir, e.name);
29
+ stack.push(full);
30
+ }
31
+ }
32
+ return dirs;
33
+ }
34
+
35
+ export function watchTree({ rootDir, ignore = () => false, onChange, debounceMs = 300 } = {}) {
36
+ if (!rootDir) throw new Error('watchTree: rootDir is required');
37
+ if (typeof onChange !== 'function') throw new Error('watchTree: onChange is required');
38
+ if (!existsSync(rootDir)) return { close: () => {} };
39
+
40
+ const watchers = new Map();
41
+ const debounced = debounce(onChange, debounceMs);
42
+
43
+ function ensureWatched(dir) {
44
+ if (watchers.has(dir)) return;
45
+ try {
46
+ const w = watch(dir, { persistent: true }, (eventType, filename) => {
47
+ const name = typeof filename === 'string' ? filename : '';
48
+ const full = name ? join(dir, name) : dir;
49
+ if (ignore(full)) return;
50
+ debounced(full, eventType);
51
+ // Best-effort: new dirs can appear; rescan on any event.
52
+ rescan();
53
+ });
54
+ watchers.set(dir, w);
55
+ } catch {
56
+ // ignore unwatcheable dirs
57
+ }
58
+ }
59
+
60
+ function rescan() {
61
+ const dirs = listDirsRecursively(rootDir);
62
+ for (const d of dirs) ensureWatched(d);
63
+ }
64
+
65
+ rescan();
66
+
67
+ return {
68
+ close() {
69
+ for (const w of watchers.values()) {
70
+ try {
71
+ w.close();
72
+ } catch {
73
+ // ignore
74
+ }
75
+ }
76
+ watchers.clear();
77
+ },
78
+ };
79
+ }
80
+
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, copyFileSync, rmSync } from 'node:f
2
2
  import { join, dirname } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import pc from 'picocolors';
5
- import { installBuildScript, removeBuildScript } from './build-workflows.js';
5
+ import { ensureBuildWorkflowDefaults } from './build-workflows.js';
6
6
 
7
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
8
  const TEMPLATES_DIR = join(__dirname, '..', 'workflows');
@@ -110,9 +110,7 @@ export function scaffoldWorkflows(mode = 'single', options = {}, cwd = process.c
110
110
  for (const f of listYmls(buildDir)) {
111
111
  copyWorkflow(buildDir, f, dest);
112
112
  }
113
- installBuildScript(cwd);
114
- } else {
115
- removeBuildScript(cwd);
113
+ ensureBuildWorkflowDefaults(cwd);
116
114
  }
117
115
 
118
116
  const total = readdirSync(dest).filter((f) => f.endsWith('.yml')).length;
@@ -119,9 +119,18 @@ jobs:
119
119
  COMMITS=$(git log --pretty=format:"%s" -n 30 "$TAG_NAME" 2>/dev/null || true)
120
120
  fi
121
121
 
122
+ # Filter out noisy system-generated sync/merge commits so release notes stay readable.
123
+ FILTERED_COMMITS=$(printf '%s\n' "$COMMITS" | sed '/^[[:space:]]*$/d' | grep -Eiv \
124
+ '^Sync main → staging-|^Sync main -> staging-|^Merge pull request #|^Merge branch |^Merge live-.* into main \[hotfix-backport\]$|^chore: sync root to stores/.+ \[root-to-stores\]$|^chore: keep store root JSONs from stores/.+ \[stores-to-root\]$|^Update from Shopify for theme ')
125
+ if [ -z "$FILTERED_COMMITS" ]; then
126
+ NOISY_ONLY_COMMITS=true
127
+ else
128
+ NOISY_ONLY_COMMITS=false
129
+ fi
130
+
122
131
  # Score how many commit subjects follow conventional-commit style.
123
- TOTAL_COUNT=$(printf '%s\n' "$COMMITS" | sed '/^[[:space:]]*$/d' | wc -l | tr -d ' ')
124
- CONVENTIONAL_COUNT=$(printf '%s\n' "$COMMITS" | sed '/^[[:space:]]*$/d' | grep -Eic '^(feat|fix|refactor|perf|docs|style|test|build|ci|chore|revert)(\([^)]+\))?!?: ')
132
+ TOTAL_COUNT=$(printf '%s\n' "$FILTERED_COMMITS" | sed '/^[[:space:]]*$/d' | wc -l | tr -d ' ')
133
+ CONVENTIONAL_COUNT=$(printf '%s\n' "$FILTERED_COMMITS" | sed '/^[[:space:]]*$/d' | grep -Eic '^(feat|fix|refactor|perf|docs|style|test|build|ci|chore|revert)(\([^)]+\))?!?: ')
125
134
  if [ -z "$TOTAL_COUNT" ] || [ "$TOTAL_COUNT" -eq 0 ]; then
126
135
  TOTAL_COUNT=1
127
136
  fi
@@ -139,8 +148,8 @@ jobs:
139
148
  } > "$NOTES_FILE"
140
149
 
141
150
  # If commit quality is low and Gemini is available, ask AI to summarize merchant-facing notes.
142
- if [ "$QUALITY" -lt 50 ] && [ -n "$GEMINI_API_KEY" ]; then
143
- printf '%s\n' "$COMMITS" > .sys/release-notes/commits-for-ai.txt
151
+ if [ "$QUALITY" -lt 50 ] && [ -n "$GEMINI_API_KEY" ] && [ "$NOISY_ONLY_COMMITS" != "true" ]; then
152
+ printf '%s\n' "$FILTERED_COMMITS" > .sys/release-notes/commits-for-ai.txt
144
153
 
145
154
  PROMPT=$(cat <<'PROMPT_EOF'
146
155
  You generate release notes for a Shopify theme.
@@ -182,12 +191,20 @@ jobs:
182
191
  else
183
192
  echo "## Changes" >> "$NOTES_FILE"
184
193
  echo >> "$NOTES_FILE"
185
- printf '%s\n' "$COMMITS" | sed '/^[[:space:]]*$/d' | sed 's/^/- /' >> "$NOTES_FILE"
194
+ if [ "$NOISY_ONLY_COMMITS" = "true" ]; then
195
+ echo "- No notable merchant-facing changes in this release." >> "$NOTES_FILE"
196
+ else
197
+ printf '%s\n' "$FILTERED_COMMITS" | sed '/^[[:space:]]*$/d' | sed 's/^/- /' >> "$NOTES_FILE"
198
+ fi
186
199
  fi
187
200
  else
188
201
  echo "## Changes" >> "$NOTES_FILE"
189
202
  echo >> "$NOTES_FILE"
190
- printf '%s\n' "$COMMITS" | sed '/^[[:space:]]*$/d' | sed 's/^/- /' >> "$NOTES_FILE"
203
+ if [ "$NOISY_ONLY_COMMITS" = "true" ]; then
204
+ echo "- No notable merchant-facing changes in this release." >> "$NOTES_FILE"
205
+ else
206
+ printf '%s\n' "$FILTERED_COMMITS" | sed '/^[[:space:]]*$/d' | sed 's/^/- /' >> "$NOTES_FILE"
207
+ fi
191
208
  fi
192
209
 
193
210
  echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT
@@ -24,16 +24,32 @@ jobs:
24
24
  node-version: "24"
25
25
  cache: "npm"
26
26
 
27
- - name: Install dependencies
28
- run: npm ci
27
+ - name: Detect build entrypoints
28
+ id: detect
29
+ run: |
30
+ HAS_SCRIPTS=false
31
+ if ls _scripts/*.js >/dev/null 2>&1; then
32
+ HAS_SCRIPTS=true
33
+ fi
34
+ HAS_STYLES=false
35
+ if [ -f "_styles/main.css" ]; then
36
+ HAS_STYLES=true
37
+ fi
38
+ echo "has_scripts=$HAS_SCRIPTS" >> $GITHUB_OUTPUT
39
+ echo "has_styles=$HAS_STYLES" >> $GITHUB_OUTPUT
29
40
 
30
41
  - name: Build scripts
31
- run: node .climaybe/build-scripts.js
42
+ if: steps.detect.outputs.has_scripts == 'true'
43
+ run: npx -y climaybe@latest build-scripts
32
44
 
33
45
  - name: Run Tailwind build
34
46
  id: build
35
47
  run: |
36
- NODE_ENV=production npx @tailwindcss/cli -i _styles/main.css -o assets/style.css --minify
48
+ if [ "${{ steps.detect.outputs.has_styles }}" = "true" ]; then
49
+ NODE_ENV=production npx -y climaybe@latest build
50
+ else
51
+ echo "No _styles/main.css found; skipping Tailwind build."
52
+ fi
37
53
  echo "success=true" >> $GITHUB_OUTPUT
38
54
 
39
55
  - name: Commit and push changes
@@ -43,7 +59,12 @@ jobs:
43
59
  BRANCH_NAME=${{ github.head_ref || github.ref_name }}
44
60
  git fetch origin "$BRANCH_NAME" || echo "Branch does not exist yet"
45
61
  git checkout -B "$BRANCH_NAME" "origin/$BRANCH_NAME" 2>/dev/null || git checkout -B "$BRANCH_NAME"
46
- git add -f assets/index.js assets/style.css
62
+ if ls assets/*.js >/dev/null 2>&1; then
63
+ git add -f assets/*.js
64
+ fi
65
+ if [ -f "assets/style.css" ]; then
66
+ git add -f assets/style.css
67
+ fi
47
68
  if git diff --staged --quiet; then
48
69
  echo "No changes to commit"
49
70
  else
@@ -68,7 +68,7 @@ jobs:
68
68
  fi
69
69
  echo "hotfix_skip_alias=$HOTFIX_SKIP_ALIAS" >> $GITHUB_OUTPUT
70
70
 
71
- # Read store list from package.json
71
+ # Read store list from climaybe.config.json
72
72
  config:
73
73
  needs: gate
74
74
  if: needs.gate.outputs.should_run == 'true'
@@ -82,8 +82,9 @@ jobs:
82
82
  id: read
83
83
  run: |
84
84
  STORES=$(node -e "
85
- const pkg = require('./package.json');
86
- const stores = Object.keys(pkg.config?.stores || {});
85
+ const fs = require('fs');
86
+ const cfg = JSON.parse(fs.readFileSync('./climaybe.config.json', 'utf8'));
87
+ const stores = Object.keys(cfg?.stores || {});
87
88
  console.log(JSON.stringify(stores));
88
89
  ")
89
90
  echo "stores=$STORES" >> $GITHUB_OUTPUT
@@ -48,9 +48,9 @@ jobs:
48
48
  run: |
49
49
  DEFAULT_ALIAS=$(node -e "
50
50
  const fs = require('fs');
51
- const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
52
- const stores = pkg?.config?.stores || {};
53
- const defaultStoreRaw = pkg?.config?.default_store;
51
+ const cfg = JSON.parse(fs.readFileSync('./climaybe.config.json', 'utf8'));
52
+ const stores = cfg?.stores || {};
53
+ const defaultStoreRaw = cfg?.default_store;
54
54
  const normalize = (v) => String(v || '').toLowerCase().replace(/^https?:\\/\\//, '').replace(/\/.*\$/, '');
55
55
  const defaultStore = normalize(defaultStoreRaw);
56
56
  let alias = '';
@@ -47,7 +47,7 @@ jobs:
47
47
  echo "staging_branch=$BRANCH" >> $GITHUB_OUTPUT
48
48
  echo "live_branch=live-${ALIAS}" >> $GITHUB_OUTPUT
49
49
 
50
- - name: Resolve store domain from package.json
50
+ - name: Resolve store domain from climaybe.config.json
51
51
  id: store
52
52
  env:
53
53
  SHOPIFY_STORE_URL_SCOPED: ${{ secrets[format('SHOPIFY_STORE_URL_{0}', steps.alias.outputs.alias_secret)] }}
@@ -56,8 +56,8 @@ jobs:
56
56
  ALIAS="${{ steps.alias.outputs.alias }}"
57
57
  DOMAIN_CONFIG=$(node -e "
58
58
  const fs = require('fs');
59
- const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
60
- const domain = pkg?.config?.stores?.['${ALIAS}'] || '';
59
+ const cfg = JSON.parse(fs.readFileSync('./climaybe.config.json', 'utf8'));
60
+ const domain = cfg?.stores?.['${ALIAS}'] || '';
61
61
  process.stdout.write(domain);
62
62
  ")
63
63
  DOMAIN="${SHOPIFY_STORE_URL_SCOPED:-}"
@@ -31,9 +31,9 @@ jobs:
31
31
  else
32
32
  ALIAS=$(node -e "
33
33
  const fs = require('fs');
34
- const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
35
- const stores = pkg?.config?.stores || {};
36
- const defaultStoreRaw = pkg?.config?.default_store;
34
+ const cfg = JSON.parse(fs.readFileSync('./climaybe.config.json', 'utf8'));
35
+ const stores = cfg?.stores || {};
36
+ const defaultStoreRaw = cfg?.default_store;
37
37
  const normalize = (v) => String(v || '')
38
38
  .toLowerCase()
39
39
  .replace(/^https?:\\/\\//, '')
@@ -63,7 +63,7 @@ jobs:
63
63
  fi
64
64
 
65
65
  if [ -z "$ALIAS" ]; then
66
- echo "Could not resolve store alias from package.json config or base branch."
66
+ echo "Could not resolve store alias from climaybe.config.json config or base branch."
67
67
  exit 1
68
68
  fi
69
69
 
@@ -34,9 +34,9 @@ jobs:
34
34
  else
35
35
  ALIAS=$(node -e "
36
36
  const fs = require('fs');
37
- const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
38
- const stores = pkg?.config?.stores || {};
39
- const defaultStoreRaw = pkg?.config?.default_store;
37
+ const cfg = JSON.parse(fs.readFileSync('./climaybe.config.json', 'utf8'));
38
+ const stores = cfg?.stores || {};
39
+ const defaultStoreRaw = cfg?.default_store;
40
40
  const normalize = (v) => String(v || '')
41
41
  .toLowerCase()
42
42
  .replace(/^https?:\\/\\//, '')
@@ -66,7 +66,7 @@ jobs:
66
66
  fi
67
67
 
68
68
  if [ -z "$ALIAS" ]; then
69
- echo "Could not resolve default store alias from package.json config."
69
+ echo "Could not resolve default store alias from climaybe.config.json config."
70
70
  exit 1
71
71
  fi
72
72