climaybe 3.2.0 → 3.4.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/README.md CHANGED
@@ -94,7 +94,11 @@ Switch your local dev environment to a specific store (multi-store only).
94
94
  npx climaybe switch voldt-norway
95
95
  ```
96
96
 
97
- Copies `stores/<alias>/` JSON files to the repo root so you can preview that store locally, and sets `default_store` in `climaybe.config.json` so `climaybe serve` / `shopify theme dev` targets that store. If your current git branch is `staging-<alias>` or `live-<alias>`, `climaybe serve` uses that store’s domain from config (even if `default_store` differs), matching CI preview behavior.
97
+ Copies `stores/<alias>/` JSON files to the repo root so you can preview that store locally, and sets `default_store` in `climaybe.config.json` so `climaybe serve` / `shopify theme dev` targets that store. If your current git branch is `staging-<alias>` or `live-<alias>`, `climaybe serve` still passes that branch’s store to Shopify for `--store` (even if `default_store` differs), matching CI preview behavior.
98
+
99
+ ### `climaybe serve` / `climaybe theme serve`
100
+
101
+ Runs Tailwind/scripts/schema watchers and `shopify theme dev` (see the theme dev kit section for flags). In **multi-store** mode, the first step asks **which store to serve** (interactive terminal); the default choice is the store matching `default_store` (same as after `climaybe switch`). If you pick a **different** store, the CLI saves the current root `config/`, `templates/`, and `sections/` JSON files into `stores/<previous-alias>/` (same paths as `climaybe sync`), then copies the selected store’s JSONs to the root and updates `default_store`, so work in progress is not lost. In **CI** or when **stdin is not a TTY**, there is no prompt: it uses `default_store` unless you set **`CLIMAYBE_SERVE_STORE=<alias>`** to a configured alias.
98
102
 
99
103
  ### `climaybe sync [alias]` / `climaybe theme sync`
100
104
 
@@ -227,12 +231,14 @@ Enabled via `climaybe init` prompt (`Enable preview + cleanup workflows?`; defau
227
231
 
228
232
  | Workflow | Trigger | What it does |
229
233
  |----------|---------|-------------|
230
- | `pr-update.yml` | PR opened/synchronize/reopened (base: main, staging, develop, staging-*, live-*) | Shares draft theme, renames with `-PR<number>`, comments preview + customize URLs; uses default store for main/staging/develop, or the store for staging-&lt;alias&gt;/live-&lt;alias&gt;. **Path filter:** runs only when the PR changes theme paths (`assets/`, `blocks/`, `config/`, `layout/`, `locales/`, `sections/`, `snippets/`, `templates/`, `_scripts/`, `_styles/`, `shopify.theme.toml`, `stores/**`); docs-only or tooling-only PRs skip preview work. |
231
- | `pr-close.yml` | PR closed (same branch set) | Deletes matching preview themes and comments deleted count + names |
232
- | `reusable-share-theme.yml` | workflow_call | Shares Shopify draft theme and returns `theme_id` |
233
- | `reusable-rename-theme.yml` | workflow_call | Renames shared theme to include `PR<number>` (fails job on rename failure) |
234
- | `reusable-comment-on-pr.yml` | workflow_call | Posts preview comment including Customize URL |
235
- | `reusable-cleanup-themes.yml` | workflow_call | Deletes preview themes by PR number and exposes cleanup outputs |
234
+ | `pr-update.yml` | PR opened/synchronize/reopened (base: main, staging, develop, staging-*, live-*) | Shares draft theme, renames with `-PR<number>`, comments preview + customize URLs. **Multi-store + base not** `staging-<alias>` **or** `live-<alias>`**:** publishes to **every** configured store (matrix) and posts **all** links in one PR comment. For `staging-<alias>` / `live-<alias>` bases, only that store is used. **Path filter:** theme paths only (`assets/`, `blocks/`, `config/`, `layout/`, `locales/`, `sections/`, `snippets/`, `templates/`, `_scripts/`, `_styles/`, `shopify.theme.toml`, `stores/**`). |
235
+ | `pr-close.yml` | PR closed (same branch set) | Deletes this PR’s preview themes using the **same store matrix rule** as `pr-update`; PR comment shows **total** deleted count across stores. |
236
+ | `cleanup-orphan-preview-themes.yml` | Weekly (Mon 06:00 UTC) + `workflow_dispatch` | Per store: deletes themes ending with `-PR<n>` when PR `#n` is **not** open (merged/closed without cleanup). Uses `gh pr list` (limit 1000 open PRs). |
237
+ | `reusable-publish-pr-preview-store.yml` | workflow_call | Share + rename + upload comment fragment for **one** store (matrix leg in `pr-update`). |
238
+ | `reusable-share-theme.yml` | workflow_call | Shares Shopify draft theme and returns `theme_id` (still available if you call it elsewhere) |
239
+ | `reusable-rename-theme.yml` | workflow_call | Renames shared theme to include `PR<number>` (still available for custom flows) |
240
+ | `reusable-comment-on-pr.yml` | workflow_call | Posts preview comment; aggregates `preview-fragment-*` artifacts when `use_preview_fragments` is true |
241
+ | `reusable-cleanup-themes.yml` | workflow_call | `cleanup_mode: by_pr` deletes names ending with `-PR{padded}`; `orphan_pr` deletes `-PR<n>` when PR `n` is not open. Optional `result_artifact_prefix` for matrix fan-in on `pr-close` |
236
242
  | `reusable-extract-pr-number.yml` | workflow_call | Extracts padded/unpadded PR number outputs for naming and API-safe usage |
237
243
 
238
244
  ### Optional build + Lighthouse package
@@ -244,6 +250,8 @@ When enabled, builds are **resilient**:
244
250
  - `init` may offer to create entrypoints; **default answer is No**.
245
251
  - Script bundling preserves comments/spacing and emits bundles only for root entry files (files imported by other top-level `_scripts/*.js` are inlined, not emitted separately).
246
252
  - Script bundles are written to `assets/*.js` (readable by default; use `climaybe build-scripts --minify` if you want minified output).
253
+ - `assets/*.js`, `assets/style.css`, and the injected `{% schema %}` blocks are **generated outputs** — edit the source in `_scripts/`, `_styles/`, and `_schemas/` instead, since the watcher/build regenerate (and overwrite) the outputs on save and in CI.
254
+ - **Orphan cleanup:** when `_scripts/` is in use, a full build (`climaybe build`, `climaybe build-scripts`, and the `serve` watcher) deletes any `assets/*.js` that no longer has a matching `_scripts/` source. Add new JS via a top-level `_scripts/<name>.js` entry, not by hand-editing `assets/`. Liquid-processed `*.js.liquid` assets and non-JS files are left untouched; targeted single-entry builds (`build-scripts <entry>`) skip pruning.
247
255
  - Live minified `assets/*` changes are intentionally excluded from hotfix backports to `main` (no branch-specific `.gitignore` split required).
248
256
 
249
257
  Build workflows install deps with `npm ci` and run `npx --no-install climaybe build-scripts` plus `npx --no-install climaybe build`, so CI uses lockfile-pinned versions (no `@latest` drift).
@@ -374,7 +382,7 @@ Add the following secrets to your GitHub repository (or use **GitLab CI/CD varia
374
382
 
375
383
  **Store URL:** During `climaybe init` (or `add-store`), store URL secret(s) are set from your configured store domain(s); theme tokens are optional prompts.
376
384
 
377
- **Multi-store:** Per-store secrets `SHOPIFY_STORE_URL_<ALIAS>` and `SHOPIFY_THEME_ACCESS_TOKEN_<ALIAS>` — the URL is set from config; you must provide the theme token per store. `<ALIAS>` is uppercase with hyphens as underscores (e.g. `voldt-norway` → `SHOPIFY_STORE_URL_VOLDT_NORWAY`). Preview and cleanup: for PRs targeting **main**, **staging**, or **develop** the workflows use the **default store** (from `config.default_store` or first in `config.stores`); for PRs targeting **staging-&lt;alias&gt;** or **live-&lt;alias&gt;** they use that store’s credentials. Set either the plain `SHOPIFY_*` secrets or the `_<ALIAS>` pair for each store you use in preview.
385
+ **Multi-store:** Per-store secrets `SHOPIFY_STORE_URL_<ALIAS>` and `SHOPIFY_THEME_ACCESS_TOKEN_<ALIAS>` — the URL is set from config; you must provide the theme token per store. `<ALIAS>` is uppercase with hyphens as underscores (e.g. `voldt-norway` → `SHOPIFY_STORE_URL_VOLDT_NORWAY`). **Preview:** for PRs targeting **staging-&lt;alias&gt;** or **live-&lt;alias&gt;** only that store is used. For **main**, **staging**, **develop**, or other bases with **multiple** stores in `climaybe.config.json`, **pr-update** publishes the preview theme to **every** store (each must have secrets); **pr-close** cleans that PR’s preview themes on **all** stores. For a **single** store in config, behavior matches the default-store case. Optional **cleanup-orphan-preview-themes** (weekly + manual) removes stale `-PR<n>` themes when PR `n` is no longer open.
378
386
 
379
387
  ## Directory Structure (Multi-store)
380
388
 
package/bin/version.txt CHANGED
@@ -1 +1 @@
1
- 3.2.0
1
+ 3.4.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "climaybe",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "Shopify CLI by Electric Maybe for theme CI/CD workflows, branch orchestration, app setup, and dev tooling",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,7 @@ export async function buildScriptsCommand(opts = {}) {
9
9
  try {
10
10
  const minify = opts.minify === true;
11
11
  if (global.gc) global.gc();
12
- const { bundles } = buildScripts({ cwd: process.cwd(), minify });
12
+ const { bundles, removed } = buildScripts({ cwd: process.cwd(), minify });
13
13
  if (!bundles || bundles.length === 0) {
14
14
  console.log(pc.yellow(' No _scripts/*.js entrypoints found; nothing to build.'));
15
15
  return;
@@ -20,6 +20,12 @@ export async function buildScriptsCommand(opts = {}) {
20
20
  for (const b of bundles) {
21
21
  console.log(pc.dim(` - ${b.entryFile} → ${b.outputPath}`));
22
22
  }
23
+ if (removed && removed.length > 0) {
24
+ console.log(pc.yellow(` Removed ${removed.length} orphan asset(s) with no _scripts source:`));
25
+ for (const name of removed) {
26
+ console.log(pc.dim(` - assets/${name}`));
27
+ }
28
+ }
23
29
  if (global.gc) global.gc();
24
30
  } catch (err) {
25
31
  console.log(pc.red(` Build error: ${err.message}`));
@@ -8,6 +8,7 @@ alwaysApply: true
8
8
 
9
9
  When you perform any of the following, **read and apply** the corresponding rule file from `.cursor/rules/`:
10
10
 
11
+ - **Editing `assets/*.js` or `assets/style.css`, build outputs vs source, where to edit JS/CSS** → `project-overview.mdc` (Build Outputs vs Source — edit `_scripts/`/`_styles/`, never the generated `assets/` files)
11
12
  - **Git commits, commit messages** → `commit-rules.mdc`
12
13
  - **Accessibility, a11y, focus, WCAG, UI behavior** → `accessibility-rules.mdc`
13
14
  - **JavaScript, web components, _scripts/, *.js** → `javascript-standards.mdc`
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Professional JavaScript standards for Electric Maybe Shopify Theme. All JavaScript files must follow these patterns for consistency, performance, and maintainability.
3
3
  globs:
4
- - "**/*.js"
4
+ - "_scripts/**/*.js"
5
5
  - "sections/*.liquid"
6
6
  - "snippets/*.liquid"
7
7
  alwaysApply: false
@@ -9,6 +9,8 @@ alwaysApply: false
9
9
 
10
10
  # JavaScript Development Standards
11
11
 
12
+ > **Source vs build output:** Edit JavaScript in `_scripts/`. The CLI bundles `_scripts/*.js` into `assets/*.js` (`main.js` → `assets/index.js`) on every save and in CI, so `assets/*.js` is a **generated artifact** — never edit it directly, your changes will be overwritten. See `project-overview.mdc` (Build Outputs vs Source).
13
+
12
14
  ## Core Principles
13
15
 
14
16
  - **Zero external dependencies** - Use native browser APIs
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: JavaScript component refactor tasks and improvement tracking
3
3
  globs:
4
- - "**/*.js"
4
+ - "_scripts/**/*.js"
5
5
  alwaysApply: false
6
6
  ---
7
7
  # JavaScript Component Refactor Tasks
@@ -55,8 +55,10 @@ Use `_scripts/electric-modal.js` as the gold standard for all web components.
55
55
 
56
56
  ## File Organization
57
57
  ```
58
- _scripts/ # Web components and utilities
59
- assets/ # Compiled CSS/JS and static assets
58
+ _scripts/ # Source: web components and JS utilities (edit here)
59
+ _styles/ # Source: Tailwind/CSS entrypoint (_styles/main.css)
60
+ _schemas/ # Source: section/block schema definitions
61
+ assets/ # GENERATED build outputs + static assets (do not hand-edit generated files)
60
62
  sections/ # Liquid section files
61
63
  snippets/ # Reusable Liquid snippets
62
64
  templates/ # Page templates
@@ -64,6 +66,22 @@ locales/ # Translation files
64
66
  config/ # Theme settings
65
67
  ```
66
68
 
69
+ ## Build Outputs vs Source
70
+
71
+ `assets/` contains both static assets and **generated** build outputs. The CLI (`climaybe serve` / `climaybe build`) compiles sources into `assets/` and regenerates them on every save and in CI. Always edit the **source**, never the generated output:
72
+
73
+ | Source (edit this) | Generated output (do NOT edit) |
74
+ | --- | --- |
75
+ | `_scripts/main.js` | `assets/index.js` |
76
+ | `_scripts/<name>.js` (top-level entry) | `assets/<name>.js` |
77
+ | `_styles/main.css` | `assets/style.css` |
78
+ | `_schemas/<name>.js` / `.json` | injected `{% schema %}` block in `sections/*.liquid` & `blocks/*.liquid` |
79
+
80
+ Notes:
81
+ - JS bundling follows `import` statements: files imported by a top-level `_scripts/*.js` entry are **inlined** into that bundle, not emitted separately.
82
+ - Any manual edit to `assets/index.js`, `assets/*.js`, or `assets/style.css` is **overwritten** on the next save (watcher) or build. Make JS changes in `_scripts/`, CSS in `_styles/`, and schema changes in `_schemas/`.
83
+ - **Orphan cleanup:** on a full build (`climaybe build` / `climaybe build-scripts`, and the `serve` watcher), any `assets/*.js` that the current `_scripts/` build does not produce is **deleted** as a stale artifact. To add a new JS asset, create a top-level `_scripts/<name>.js` entry — do not drop a hand-written `.js` into `assets/` (it will be removed). Liquid-processed assets (`*.js.liquid`) and non-JS files are never touched.
84
+
67
85
  ## Code Review Requirements
68
86
  All code must pass the checklist in `javascript-standards.mdc` before merge.
69
87
 
@@ -3,13 +3,14 @@ description: Tailwind CSS and theme token standards for Liquid and theme styles.
3
3
  globs:
4
4
  - "**/*.liquid"
5
5
  - "_styles/**/*.css"
6
- - "assets/*.css"
7
6
  alwaysApply: false
8
7
  ---
9
8
  # Tailwind CSS Development Rules
10
9
 
11
10
  Apply when editing Liquid templates or theme CSS that use Tailwind classes or theme tokens. Use semantic tokens (surface, subtle, emphasis, accent, text-primary, border-secondary) from this project's `@theme`; see `_styles/02_base/02.02_colors.css`.
12
11
 
12
+ > **Source vs build output:** Edit CSS in `_styles/` (entrypoint `_styles/main.css`). The CLI compiles it into `assets/style.css` on every save and in CI, so `assets/style.css` is a **generated artifact** — never edit it directly, your changes will be overwritten. See `project-overview.mdc` (Build Outputs vs Source).
13
+
13
14
  ## Core Principles
14
15
 
15
16
  ### 1. Static Class Generation
package/src/index.js CHANGED
@@ -64,8 +64,12 @@ function registerThemeCommands(cmd) {
64
64
  .command('serve')
65
65
  .description('Run local theme dev (Shopify + assets; Theme Check off by default)')
66
66
  .option('--theme-check', 'Enable Theme Check watcher')
67
- .action((opts) => serveAll({ includeThemeCheck: opts.themeCheck === true }));
68
- cmd.command('serve:shopify').description('Run Shopify theme dev server').action(() => serveShopify());
67
+ .action(async (opts) => {
68
+ await serveAll({ includeThemeCheck: opts.themeCheck === true });
69
+ });
70
+ cmd.command('serve:shopify').description('Run Shopify theme dev server').action(async () => {
71
+ await serveShopify();
72
+ });
69
73
  cmd
70
74
  .command('serve:assets')
71
75
  .description('Run assets watch (Tailwind + scripts; Theme Check off by default)')
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync } from 'node:fs';
2
2
  import { join, basename, dirname, normalize } from 'node:path';
3
3
 
4
4
  function extractImportRecords(content) {
@@ -161,6 +161,30 @@ function collectFilesToIsolate({ scriptsDir, entryFile }) {
161
161
  return isolateFiles;
162
162
  }
163
163
 
164
+ function removeOrphanScriptAssets({ cwd, keepNames }) {
165
+ // Delete assets/*.js that this build did not produce. The Electric Maybe build
166
+ // model treats assets/*.js as generated output of _scripts/, so any *.js without
167
+ // a matching bundle output is stale and should be removed. Only plain *.js files
168
+ // are considered — Liquid-processed assets (e.g. *.js.liquid) are left untouched.
169
+ const assetsDir = join(cwd, 'assets');
170
+ if (!existsSync(assetsDir)) return [];
171
+
172
+ const removed = [];
173
+ for (const dirent of readdirSync(assetsDir, { withFileTypes: true })) {
174
+ if (!dirent.isFile()) continue;
175
+ const name = dirent.name;
176
+ if (!name.endsWith('.js')) continue;
177
+ if (keepNames.has(name)) continue;
178
+ try {
179
+ unlinkSync(join(assetsDir, name));
180
+ removed.push(name);
181
+ } catch {
182
+ // Best effort: a failed unlink shouldn't break the build.
183
+ }
184
+ }
185
+ return removed;
186
+ }
187
+
164
188
  function listTopLevelEntrypoints(scriptsDir) {
165
189
  if (!existsSync(scriptsDir)) return [];
166
190
  return readdirSync(scriptsDir, { withFileTypes: true })
@@ -234,9 +258,18 @@ export function buildScripts({ cwd = process.cwd(), entry = null, minify = false
234
258
  }
235
259
  }
236
260
  if (entrypoints.length === 0) {
237
- return { bundles: [] };
261
+ return { bundles: [], removed: [] };
238
262
  }
239
263
  const bundles = entrypoints.map((entryFile) => buildSingleEntrypoint({ cwd, entryFile, minify }));
240
- return { bundles };
264
+
265
+ // Only prune orphans on a full build (no explicit entry). A targeted single-entry
266
+ // build must not delete the other bundles' outputs.
267
+ let removed = [];
268
+ if (!entry) {
269
+ const keepNames = new Set(bundles.map((b) => basename(b.outputPath)));
270
+ removed = removeOrphanScriptAssets({ cwd, keepNames });
271
+ }
272
+
273
+ return { bundles, removed };
241
274
  }
242
275
 
package/src/lib/config.js CHANGED
@@ -180,6 +180,23 @@ export function getStoreAliases(cwd = process.cwd()) {
180
180
  return Object.keys(config.stores);
181
181
  }
182
182
 
183
+ /**
184
+ * Resolve the store alias for `default_store` (the domain last set by `climaybe switch` or serve).
185
+ * @param {string} [cwd]
186
+ * @returns {string | null}
187
+ */
188
+ export function getAliasForDefaultStore(cwd = process.cwd()) {
189
+ const config = readConfig(cwd);
190
+ const domain = config?.default_store;
191
+ const stores = config?.stores;
192
+ if (!domain || !stores || typeof stores !== 'object') return null;
193
+ const normalized = String(domain).trim();
194
+ for (const [alias, storeDomain] of Object.entries(stores)) {
195
+ if (String(storeDomain).trim() === normalized) return alias;
196
+ }
197
+ return null;
198
+ }
199
+
183
200
  /**
184
201
  * Determine the current mode: 'single' or 'multi'.
185
202
  */
@@ -4,6 +4,7 @@ import { watchTree } from './watch.js';
4
4
  import { isAbsolute, join, relative } from 'node:path';
5
5
  import pc from 'picocolors';
6
6
  import { readConfig, getStoreDomainFromBranch } from './config.js';
7
+ import { prepareMultiStoreForServe } from './serve-multi-store.js';
7
8
  import { buildScripts } from './build-scripts.js';
8
9
  import { buildSchemas } from './schema-builder.js';
9
10
  import { runShopify } from './shopify-cli.js';
@@ -209,7 +210,15 @@ function runThemeCheckFiltered({ cwd = process.cwd() } = {}) {
209
210
  return child;
210
211
  }
211
212
 
212
- export function serveShopify({ cwd = process.cwd() } = {}) {
213
+ /**
214
+ * @param {{ cwd?: string; skipMultiStorePrep?: boolean }} [opts]
215
+ * @returns {Promise<import('node:child_process').ChildProcess | null>} Null when multi-store prep failed or was cancelled.
216
+ */
217
+ export async function serveShopify({ cwd = process.cwd(), skipMultiStorePrep = false } = {}) {
218
+ if (!skipMultiStorePrep) {
219
+ const ok = await prepareMultiStoreForServe(cwd);
220
+ if (!ok) return null;
221
+ }
213
222
  const config = readConfig(cwd) || {};
214
223
  const branchStore = getStoreDomainFromBranch(cwd);
215
224
  const store = branchStore || config.default_store || config.store || '';
@@ -244,8 +253,11 @@ export function serveAssets({ cwd = process.cwd(), includeThemeCheck = false } =
244
253
  const scriptsDir = join(cwd, '_scripts');
245
254
  if (existsSync(scriptsDir)) {
246
255
  try {
247
- buildScripts({ cwd });
256
+ const { removed } = buildScripts({ cwd });
248
257
  writeTaggedLine('scripts', pc.yellow, 'built (initial)');
258
+ if (removed?.length) {
259
+ writeTaggedLine('scripts', pc.yellow, `removed ${removed.length} orphan asset(s): ${removed.join(', ')}`);
260
+ }
249
261
  } catch (err) {
250
262
  writeTaggedLine('scripts', pc.yellow, `initial build failed: ${err.message}`, process.stderr);
251
263
  }
@@ -259,8 +271,11 @@ export function serveAssets({ cwd = process.cwd(), includeThemeCheck = false } =
259
271
  debounceMs: 300,
260
272
  onChange: () => {
261
273
  try {
262
- buildScripts({ cwd });
274
+ const { removed } = buildScripts({ cwd });
263
275
  writeTaggedLine('scripts', pc.yellow, 'rebuilt');
276
+ if (removed?.length) {
277
+ writeTaggedLine('scripts', pc.yellow, `removed ${removed.length} orphan asset(s): ${removed.join(', ')}`);
278
+ }
264
279
  } catch (err) {
265
280
  writeTaggedLine('scripts', pc.yellow, `build failed: ${err.message}`, process.stderr);
266
281
  }
@@ -365,16 +380,28 @@ export function serveAssets({ cwd = process.cwd(), includeThemeCheck = false } =
365
380
  return { tailwind, devMcp, scriptsWatch, schemasWatch, themeCheckWatch, cleanup };
366
381
  }
367
382
 
368
- export function serveAll({ cwd = process.cwd(), includeThemeCheck = false } = {}) {
383
+ export async function serveAll({ cwd = process.cwd(), includeThemeCheck = false } = {}) {
384
+ const prepOk = await prepareMultiStoreForServe(cwd);
385
+ if (!prepOk) {
386
+ const noop = () => {};
387
+ return { cleanup: noop };
388
+ }
389
+
369
390
  // Start assets first, then bring up Shopify after a short delay.
370
391
  const assets = serveAssets({ cwd, includeThemeCheck });
371
392
  let shopify = null;
372
393
  const shopifyStartDelayMs = 2500;
373
394
  const shopifyTimer = setTimeout(() => {
374
- shopify = serveShopify({ cwd });
375
- shopify.on('exit', () => {
376
- cleanup();
377
- });
395
+ void (async () => {
396
+ shopify = await serveShopify({ cwd, skipMultiStorePrep: true });
397
+ if (shopify) {
398
+ shopify.on('exit', () => {
399
+ cleanup();
400
+ });
401
+ } else {
402
+ cleanup();
403
+ }
404
+ })();
378
405
  }, shopifyStartDelayMs);
379
406
  console.log(pc.dim(` Waiting ${shopifyStartDelayMs}ms before starting Shopify...`));
380
407
 
@@ -428,7 +455,10 @@ export function buildAll({ cwd = process.cwd() } = {}) {
428
455
 
429
456
  let scriptsOk = true;
430
457
  try {
431
- buildScripts({ cwd });
458
+ const { removed } = buildScripts({ cwd });
459
+ if (removed?.length) {
460
+ writeTaggedLine('scripts', pc.yellow, `removed ${removed.length} orphan asset(s): ${removed.join(', ')}`);
461
+ }
432
462
  } catch (err) {
433
463
  console.log(pc.red(`\n build-scripts failed: ${err.message}\n`));
434
464
  scriptsOk = false;
@@ -229,6 +229,28 @@ export async function promptProjectName(cwd = process.cwd()) {
229
229
  return String(projectName || '').trim();
230
230
  }
231
231
 
232
+ /**
233
+ * Ask which store to use for local theme dev (multi-store `climaybe serve`).
234
+ * @param {{ aliases: string[]; stores: Record<string, string>; suggestedAlias: string }} opts
235
+ * @returns {Promise<string | null>} Selected alias, or null if cancelled.
236
+ */
237
+ export async function promptServeStore({ aliases, stores, suggestedAlias }) {
238
+ const sorted = [...aliases].sort();
239
+ const choices = sorted.map((alias) => ({
240
+ title: `${alias} (${stores[alias]})`,
241
+ value: alias,
242
+ }));
243
+ const initialIndex = sorted.indexOf(suggestedAlias);
244
+ const { alias } = await prompts({
245
+ type: 'select',
246
+ name: 'alias',
247
+ message: 'Which store should we serve?',
248
+ choices,
249
+ initial: initialIndex >= 0 ? initialIndex : 0,
250
+ });
251
+ return alias ?? null;
252
+ }
253
+
232
254
  /**
233
255
  * Prompt for a single new store (used by add-store command).
234
256
  * Takes existing aliases to prevent duplicates.
@@ -0,0 +1,81 @@
1
+ import pc from 'picocolors';
2
+ import { readConfig, getMode, getProjectType, getStoreAliases, writeConfig, getAliasForDefaultStore } from './config.js';
3
+ import { rootToStores, storesToRoot } from './store-sync.js';
4
+ import { promptServeStore } from './prompts.js';
5
+
6
+ function readExplicitServeAlias(cwd) {
7
+ const raw = process.env.CLIMAYBE_SERVE_STORE?.trim();
8
+ if (!raw) return { ok: true, alias: null };
9
+ const aliases = getStoreAliases(cwd);
10
+ if (aliases.includes(raw)) return { ok: true, alias: raw };
11
+ console.log(pc.red(` CLIMAYBE_SERVE_STORE="${raw}" is not a known store alias.`));
12
+ console.log(pc.dim(` Available: ${aliases.join(', ')}\n`));
13
+ return { ok: false, alias: null };
14
+ }
15
+
16
+ function isNonInteractiveServe() {
17
+ if (process.env.CLIMAYBE_SERVE_STORE?.trim()) return true;
18
+ if (process.env.CI === 'true') return true;
19
+ return process.stdin.isTTY !== true;
20
+ }
21
+
22
+ /**
23
+ * In multi-store theme repos, ensure root JSONs and `default_store` match the store
24
+ * the user wants to serve. If the choice differs from the previous switch target,
25
+ * saves current root → `stores/<previous>/` then loads `stores/<selected>/` → root.
26
+ *
27
+ * @param {string} [cwd]
28
+ * @returns {Promise<boolean>} False if the user cancelled or `CLIMAYBE_SERVE_STORE` is invalid.
29
+ */
30
+ export async function prepareMultiStoreForServe(cwd = process.cwd()) {
31
+ if (getProjectType(cwd) === 'app') return true;
32
+ if (getMode(cwd) !== 'multi') return true;
33
+
34
+ const config = readConfig(cwd);
35
+ if (!config?.stores || typeof config.stores !== 'object') return true;
36
+
37
+ const aliases = getStoreAliases(cwd);
38
+ if (aliases.length < 2) return true;
39
+
40
+ const stores = config.stores;
41
+ const suggestedAlias = getAliasForDefaultStore(cwd) ?? aliases[0];
42
+
43
+ const explicit = readExplicitServeAlias(cwd);
44
+ if (!explicit.ok) return false;
45
+
46
+ let selected = explicit.alias;
47
+ if (!selected) {
48
+ if (isNonInteractiveServe()) {
49
+ selected = suggestedAlias;
50
+ } else {
51
+ console.log(pc.bold('\n climaybe — serve (store)\n'));
52
+ selected = await promptServeStore({ aliases, stores, suggestedAlias });
53
+ if (!selected) {
54
+ console.log(pc.dim(' Cancelled.\n'));
55
+ return false;
56
+ }
57
+ }
58
+ }
59
+
60
+ const previousAlias = getAliasForDefaultStore(cwd);
61
+
62
+ if (selected === previousAlias) return true;
63
+
64
+ if (previousAlias) {
65
+ rootToStores(previousAlias, cwd);
66
+ } else {
67
+ console.log(
68
+ pc.yellow(
69
+ ' Warning: default_store did not match any store alias; current root JSONs are not auto-saved to a store folder.'
70
+ )
71
+ );
72
+ }
73
+
74
+ const ok = storesToRoot(selected, cwd);
75
+ if (!ok) return false;
76
+
77
+ const domain = stores[selected];
78
+ if (domain) writeConfig({ default_store: domain }, cwd);
79
+
80
+ return true;
81
+ }
@@ -0,0 +1,59 @@
1
+ # climaybe — Cleanup orphan preview themes (optional)
2
+ # Weekly (and manual): on each configured store, delete themes whose name ends with -PR<n>
3
+ # when PR #n is not open in this repo (merged/closed without theme cleanup).
4
+
5
+ name: Cleanup orphan preview themes
6
+
7
+ on:
8
+ schedule:
9
+ - cron: '0 6 * * 1'
10
+ workflow_dispatch:
11
+
12
+ permissions:
13
+ contents: read
14
+ pull-requests: read
15
+
16
+ jobs:
17
+ configure:
18
+ runs-on: ubuntu-latest
19
+ outputs:
20
+ preview_targets_json: ${{ steps.cfg.outputs.preview_targets_json }}
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - name: Build store matrix from climaybe.config.json
25
+ id: cfg
26
+ run: |
27
+ if [ ! -f climaybe.config.json ]; then
28
+ {
29
+ echo 'preview_targets_json<<PT_JSON'
30
+ echo '[]'
31
+ echo 'PT_JSON'
32
+ } >> "$GITHUB_OUTPUT"
33
+ exit 0
34
+ fi
35
+ node <<'NODE' >>"$GITHUB_OUTPUT"
36
+ const fs = require('fs');
37
+ const cfg = JSON.parse(fs.readFileSync('climaybe.config.json', 'utf8'));
38
+ const stores = cfg?.stores || {};
39
+ const keys = Object.keys(stores);
40
+ const aliasToSecret = (a) => String(a).toUpperCase().replace(/-/g, '_');
41
+ const targets = keys.map((alias) => ({ alias, alias_secret: aliasToSecret(alias) }));
42
+ console.log('preview_targets_json<<PT_JSON');
43
+ console.log(JSON.stringify(targets));
44
+ console.log('PT_JSON');
45
+ NODE
46
+
47
+ cleanup-orphans:
48
+ needs: [configure]
49
+ if: needs.configure.outputs.preview_targets_json != '[]'
50
+ strategy:
51
+ fail-fast: false
52
+ matrix:
53
+ include: ${{ fromJson(needs.configure.outputs.preview_targets_json) }}
54
+ uses: ./.github/workflows/reusable-cleanup-themes.yml
55
+ with:
56
+ cleanup_mode: orphan_pr
57
+ pr_number: ''
58
+ store_alias_secret: ${{ matrix.alias_secret }}
59
+ secrets: inherit