climaybe 2.4.1 → 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,188 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { watchTree } from './watch.js';
4
+ import { join } from 'node:path';
5
+ import pc from 'picocolors';
6
+ import { readConfig } from './config.js';
7
+ import { buildScripts } from './build-scripts.js';
8
+ import { runShopify } from './shopify-cli.js';
9
+
10
+ function getPackageDir() {
11
+ return process.env.CLIMAYBE_PACKAGE_DIR || process.cwd();
12
+ }
13
+
14
+ function binPath(binName) {
15
+ // Resolve to this package's bundled .bin (works for npx + installed package).
16
+ return join(getPackageDir(), 'node_modules', '.bin', binName);
17
+ }
18
+
19
+ function spawnLogged(command, args, { name, cwd = process.cwd(), env = process.env } = {}) {
20
+ const child = spawn(command, args, {
21
+ cwd,
22
+ env,
23
+ stdio: 'inherit',
24
+ shell: process.platform === 'win32',
25
+ });
26
+
27
+ child.on('exit', (code, signal) => {
28
+ if (signal) {
29
+ console.log(pc.yellow(`\n ${name} exited with signal ${signal}\n`));
30
+ return;
31
+ }
32
+ if (code && code !== 0) {
33
+ console.log(pc.red(`\n ${name} exited with code ${code}\n`));
34
+ }
35
+ });
36
+
37
+ return child;
38
+ }
39
+
40
+ function runTailwind(args, { cwd = process.cwd(), env = process.env, name = 'tailwind' } = {}) {
41
+ return spawnLogged(
42
+ 'npx',
43
+ ['-y', '--package', '@tailwindcss/cli@latest', '--package', 'tailwindcss@latest', 'tailwindcss', ...args],
44
+ { name, cwd, env }
45
+ );
46
+ }
47
+
48
+ function safeClose(w) {
49
+ if (!w) return;
50
+ try {
51
+ w.close?.();
52
+ } catch {
53
+ // ignore
54
+ }
55
+ }
56
+
57
+ function safeKill(child) {
58
+ if (!child || typeof child.kill !== 'function') return;
59
+ try {
60
+ child.kill('SIGTERM');
61
+ } catch {
62
+ // ignore
63
+ }
64
+ }
65
+
66
+ export function serveShopify({ cwd = process.cwd() } = {}) {
67
+ const config = readConfig(cwd) || {};
68
+ const store = config.default_store || config.store || '';
69
+ const args = ['theme', 'dev', '--theme-editor-sync'];
70
+ if (store) args.push(`--store=${store}`);
71
+ return runShopify(args, { cwd, name: 'shopify' });
72
+ }
73
+
74
+ export function serveAssets({ cwd = process.cwd(), includeThemeCheck = true } = {}) {
75
+ const env = { ...process.env, NODE_ENV: 'production' };
76
+ const styleEntrypoint = join(cwd, '_styles', 'main.css');
77
+ const tailwind = existsSync(styleEntrypoint)
78
+ ? runTailwind(['-i', '_styles/main.css', '-o', 'assets/style.css', '--watch'], { cwd, env, name: 'tailwind' })
79
+ : null;
80
+
81
+ // Optional dev MCP (non-blocking if missing)
82
+ const devMcp = spawnLogged('npx', ['-y', '@shopify/dev-mcp@latest'], { name: 'dev-mcp', cwd });
83
+
84
+ const scriptsDir = join(cwd, '_scripts');
85
+ if (existsSync(scriptsDir)) {
86
+ try {
87
+ buildScripts({ cwd });
88
+ console.log(pc.green(' scripts built (initial)'));
89
+ } catch (err) {
90
+ console.log(pc.red(` initial scripts build failed: ${err.message}`));
91
+ }
92
+ }
93
+ const scriptsWatch = existsSync(scriptsDir)
94
+ ? watchTree({
95
+ rootDir: scriptsDir,
96
+ ignore: (p) => p.includes('node_modules') || p.includes('/assets/') || p.includes('/.git/'),
97
+ debounceMs: 300,
98
+ onChange: () => {
99
+ try {
100
+ buildScripts({ cwd });
101
+ console.log(pc.green(' scripts rebuilt'));
102
+ } catch (err) {
103
+ console.log(pc.red(` scripts build failed: ${err.message}`));
104
+ }
105
+ },
106
+ })
107
+ : null;
108
+
109
+ const themeCheckWatch =
110
+ includeThemeCheck
111
+ ? watchTree({
112
+ rootDir: cwd,
113
+ ignore: (p) =>
114
+ p.includes('/node_modules/') ||
115
+ p.includes('/assets/') ||
116
+ p.includes('/.git/') ||
117
+ p.includes('/_scripts/') ||
118
+ p.includes('/_styles/'),
119
+ debounceMs: 800,
120
+ onChange: () => {
121
+ runShopify(['theme', 'check'], { cwd, name: 'theme-check' });
122
+ },
123
+ })
124
+ : null;
125
+
126
+ const cleanup = () => {
127
+ safeClose(scriptsWatch);
128
+ safeClose(themeCheckWatch);
129
+ safeKill(tailwind);
130
+ safeKill(devMcp);
131
+ };
132
+
133
+ return { tailwind, devMcp, scriptsWatch, themeCheckWatch, cleanup };
134
+ }
135
+
136
+ export function serveAll({ cwd = process.cwd(), includeThemeCheck = true } = {}) {
137
+ // Keep Shopify CLI in the foreground (real TTY), and run watchers in background.
138
+ const assets = serveAssets({ cwd, includeThemeCheck });
139
+ const shopify = serveShopify({ cwd });
140
+
141
+ const cleanup = () => {
142
+ assets.cleanup?.();
143
+ safeKill(shopify);
144
+ };
145
+
146
+ const handleSignal = () => cleanup();
147
+ process.once('SIGINT', handleSignal);
148
+ process.once('SIGTERM', handleSignal);
149
+
150
+ shopify.on('exit', () => {
151
+ cleanup();
152
+ });
153
+
154
+ return { shopify, ...assets, cleanup };
155
+ }
156
+
157
+ export function lintAll({ cwd = process.cwd() } = {}) {
158
+ // Keep these intentionally simple wrappers; users can run the underlying tools directly if desired.
159
+ const eslint = spawnLogged(binPath('eslint'), ['./assets/*.js', '--config', '.config/eslint.config.mjs'], {
160
+ name: 'eslint',
161
+ cwd,
162
+ });
163
+ const stylelint = spawnLogged(
164
+ binPath('stylelint'),
165
+ ['./assets/*.css', '--config', '.config/.stylelintrc.json'],
166
+ { name: 'stylelint', cwd }
167
+ );
168
+ const themeCheck = runShopify(['theme', 'check'], { cwd, name: 'theme-check' });
169
+ return { eslint, stylelint, themeCheck };
170
+ }
171
+
172
+ export function buildAll({ cwd = process.cwd() } = {}) {
173
+ const env = { ...process.env, NODE_ENV: 'production' };
174
+ let scriptsOk = true;
175
+ try {
176
+ buildScripts({ cwd });
177
+ } catch (err) {
178
+ console.log(pc.red(`\n build-scripts failed: ${err.message}\n`));
179
+ scriptsOk = false;
180
+ }
181
+ const tailwind = runTailwind(['-i', '_styles/main.css', '-o', 'assets/style.css', '--minify'], {
182
+ cwd,
183
+ env,
184
+ name: 'tailwind',
185
+ });
186
+ return { scriptsOk, tailwind };
187
+ }
188
+
@@ -1,5 +1,6 @@
1
1
  import prompts from 'prompts';
2
2
  import pc from 'picocolors';
3
+ import { basename } from 'node:path';
3
4
 
4
5
  /**
5
6
  * Extract the subdomain (storeKey) from a Shopify domain.
@@ -42,13 +43,13 @@ export async function promptStore(defaultDomain = '') {
42
43
  const { domain } = await prompts({
43
44
  type: 'text',
44
45
  name: 'domain',
45
- message: 'Store URL',
46
+ message: 'Store name or domain',
46
47
  initial: defaultDomain,
47
48
  validate: (v) => {
48
- if (v.trim().length === 0) return 'Store URL is required';
49
+ if (v.trim().length === 0) return 'Store name is required';
49
50
  const normalized = normalizeDomain(v);
50
51
  if (!normalized || !isValidShopifyDomain(normalized)) {
51
- return 'Enter a valid Shopify domain (e.g. voldt-staging.myshopify.com)';
52
+ return 'Enter a valid store name or domain (e.g. voldt-staging or voldt-staging.myshopify.com)';
52
53
  }
53
54
  return true;
54
55
  },
@@ -149,14 +150,14 @@ export async function promptBuildWorkflows() {
149
150
  }
150
151
 
151
152
  /**
152
- * Ask whether to scaffold the local theme dev kit files (scripts, lint, ignores, editor tasks).
153
+ * Ask whether to scaffold local theme dev-kit files (configs, ignores, editor tasks).
153
154
  */
154
155
  export async function promptDevKit() {
155
156
  const { enableDevKit } = await prompts({
156
157
  type: 'confirm',
157
158
  name: 'enableDevKit',
158
159
  message:
159
- 'Install Electric Maybe theme dev kit? (local scripts/watch/lint configs, ignores, and optional VS Code tasks)',
160
+ 'Install Electric Maybe theme dev kit? (local dev config files, ignore defaults, and optional VS Code tasks)',
160
161
  initial: true,
161
162
  });
162
163
 
@@ -170,7 +171,7 @@ export async function promptVSCodeDevTasks() {
170
171
  const { enableVSCodeTasks } = await prompts({
171
172
  type: 'confirm',
172
173
  name: 'enableVSCodeTasks',
173
- message: 'Add VS Code tasks.json to auto-run Shopify + Tailwind local dev tasks?',
174
+ message: 'Add VS Code tasks.json to auto-run climaybe local dev commands (Shopify + assets watch)?',
174
175
  initial: true,
175
176
  });
176
177
 
@@ -206,6 +207,28 @@ export async function promptCursorSkills() {
206
207
  return !!enableCursorSkills;
207
208
  }
208
209
 
210
+ /**
211
+ * Prompt for package.json name when creating a new package.json.
212
+ */
213
+ export async function promptProjectName(cwd = process.cwd()) {
214
+ const suggested = basename(cwd).trim().toLowerCase().replace(/\s+/g, '-');
215
+ const { projectName } = await prompts({
216
+ type: 'text',
217
+ name: 'projectName',
218
+ message: 'Project name for package.json',
219
+ initial: suggested || 'shopify-theme',
220
+ validate: (v) => {
221
+ const name = String(v || '').trim();
222
+ if (!name) return 'Project name is required';
223
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) {
224
+ return 'Use lowercase letters, numbers, dot, underscore, or hyphen';
225
+ }
226
+ return true;
227
+ },
228
+ });
229
+ return String(projectName || '').trim();
230
+ }
231
+
209
232
  /**
210
233
  * Prompt for a single new store (used by add-store command).
211
234
  * Takes existing aliases to prevent duplicates.
@@ -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
@@ -133,8 +134,14 @@ jobs:
133
134
 
134
135
  git fetch origin "$BRANCH"
135
136
  git checkout "$BRANCH"
136
- git merge origin/main --no-ff -m "Sync main $BRANCH" || true
137
- if [ $? -ne 0 ]; then
137
+ BRANCH_TREE=$(git rev-parse HEAD^{tree} 2>/dev/null || echo "")
138
+ MAIN_TREE=$(git rev-parse origin/main^{tree} 2>/dev/null || echo "")
139
+ if [ -n "$BRANCH_TREE" ] && [ "$BRANCH_TREE" = "$MAIN_TREE" ]; then
140
+ echo "$BRANCH is already in sync with main (no-op), skipping."
141
+ exit 0
142
+ fi
143
+
144
+ if ! git merge origin/main --no-ff -m "Sync main → $BRANCH"; then
138
145
  echo "Merge had conflicts; aborting. Manual resolution may be needed."
139
146
  git merge --abort 2>/dev/null || true
140
147
  exit 1