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.
package/src/index.js CHANGED
@@ -9,6 +9,10 @@ import { setupCommitlintCommand } from './commands/setup-commitlint.js';
9
9
  import { addCursorSkillCommand } from './commands/add-cursor-skill.js';
10
10
  import { addDevKitCommand } from './commands/add-dev-kit.js';
11
11
  import { appInitCommand } from './commands/app-init.js';
12
+ import { migrateLegacyConfigCommand } from './commands/migrate-legacy-config.js';
13
+ import { buildScriptsCommand } from './commands/build-scripts.js';
14
+ import { createEntrypointsCommand } from './commands/create-entrypoints.js';
15
+ import { serveAll, serveAssets, serveShopify, lintAll, buildAll } from './lib/dev-runtime.js';
12
16
 
13
17
  /**
14
18
  * Register theme CI/CD commands on a Commander instance (root or `theme` subgroup).
@@ -44,9 +48,38 @@ function registerThemeCommands(cmd) {
44
48
 
45
49
  cmd
46
50
  .command('add-dev-kit')
47
- .description('Install/update local theme dev kit files (scripts, lint, ignores, optional VS Code tasks)')
51
+ .description('Install/update local theme dev kit files (configs, ignores, optional VS Code tasks)')
48
52
  .action(addDevKitCommand);
49
53
 
54
+ cmd
55
+ .command('migrate-legacy-config')
56
+ .description('Migrate legacy package.json config to climaybe.config.json (optional workflow refresh)')
57
+ .option('--overwrite', 'Overwrite existing climaybe.config.json')
58
+ .option('-y, --yes', 'Non-interactive; assume "yes" for prompts')
59
+ .option('--no-update-workflows', 'Do not refresh workflows after migrating')
60
+ .action(migrateLegacyConfigCommand);
61
+
62
+ cmd
63
+ .command('serve')
64
+ .description('Run local theme dev (Shopify + assets + Theme Check)')
65
+ .option('--no-theme-check', 'Disable Theme Check watcher')
66
+ .action((opts) => serveAll({ includeThemeCheck: opts.themeCheck !== false }));
67
+ cmd.command('serve:shopify').description('Run Shopify theme dev server').action(() => serveShopify());
68
+ cmd
69
+ .command('serve:assets')
70
+ .description('Run assets watch (Tailwind + scripts + Theme Check)')
71
+ .option('--no-theme-check', 'Disable Theme Check watcher')
72
+ .action((opts) => serveAssets({ includeThemeCheck: opts.themeCheck !== false }));
73
+
74
+ cmd.command('lint').description('Run theme linting (liquid, js, css)').action(() => lintAll());
75
+
76
+ cmd.command('build').description('Build assets (Tailwind + scripts build)').action(() => buildAll());
77
+ cmd.command('build-scripts').description('Build _scripts → assets/index.js').action(buildScriptsCommand);
78
+ cmd
79
+ .command('create-entrypoints')
80
+ .description('Create _scripts/main.js and _styles/main.css (optional)')
81
+ .action(createEntrypointsCommand);
82
+
50
83
  cmd
51
84
  .command('update-workflows')
52
85
  .description('Refresh GitHub Actions workflows from latest bundled templates')
@@ -84,7 +117,7 @@ export function createProgram(version = '0.0.0', packageDir = '') {
84
117
  const app = program.command('app').description('Shopify app repo helpers (no theme workflows)');
85
118
  app
86
119
  .command('init')
87
- .description('Set up commitlint, Cursor bundle (rules/skills/agents), and project_type: app in package.json')
120
+ .description('Set up commitlint, Cursor bundle (rules/skills/agents), and project_type: app in climaybe.config.json')
88
121
  .action(appInitCommand);
89
122
 
90
123
  program
@@ -104,5 +137,7 @@ export function createProgram(version = '0.0.0', packageDir = '') {
104
137
  }
105
138
 
106
139
  export function run(argv, version, packageDir = '') {
140
+ if (packageDir) process.env.CLIMAYBE_PACKAGE_DIR = packageDir;
141
+ if (version) process.env.CLIMAYBE_PACKAGE_VERSION = version;
107
142
  createProgram(version, packageDir).parse(argv);
108
143
  }
@@ -0,0 +1,153 @@
1
+ import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs';
2
+ import { join, basename, dirname, normalize } from 'node:path';
3
+
4
+ function extractImports(content) {
5
+ const imports = [];
6
+ // Supports compact imports (import{a}from"./x"), multiline forms,
7
+ // and import attributes (with { type: "json" }).
8
+ const fromImportRegex =
9
+ /(^|\n)\s*import(?:\s+type)?\s*[\s\S]*?\s*\bfrom\b\s*['"]([^'"]+)['"](?:\s+with\s*\{[\s\S]*?\})?\s*;?/g;
10
+ const sideEffectImportRegex = /(^|\n)\s*import\s*['"]([^'"]+)['"](?:\s+with\s*\{[\s\S]*?\})?\s*;?/g;
11
+ let match;
12
+
13
+ while ((match = fromImportRegex.exec(content)) !== null) {
14
+ imports.push(match[2]);
15
+ }
16
+ while ((match = sideEffectImportRegex.exec(content)) !== null) {
17
+ imports.push(match[2]);
18
+ }
19
+
20
+ return imports;
21
+ }
22
+
23
+ function stripModuleSyntax(content) {
24
+ // Remove import statements (including multiline/compact forms and import attributes).
25
+ let cleaned = content.replace(
26
+ /(^|\n)\s*import(?:\s+type)?\s*[\s\S]*?\s*\bfrom\b\s*['"][^'"]+['"](?:\s+with\s*\{[\s\S]*?\})?\s*;?/g,
27
+ '$1'
28
+ );
29
+ cleaned = cleaned.replace(/(^|\n)\s*import\s*['"][^'"]+['"](?:\s+with\s*\{[\s\S]*?\})?\s*;?/g, '$1');
30
+
31
+ // Fallback: ensure no standalone import declarations leak into bundle output.
32
+ cleaned = cleaned.replace(/^[ \t]*import\s*['"][^'"]+['"][ \t]*;?[ \t]*$/gm, '');
33
+ cleaned = cleaned.replace(
34
+ /^[ \t]*import(?:\s+type)?[ \t]*[^;\n\r]*\bfrom\b[ \t]*['"][^'"]+['"][ \t]*(?:with[ \t]*\{[^}\n\r]*\})?[ \t]*;?[ \t]*$/gm,
35
+ ''
36
+ );
37
+
38
+ cleaned = cleaned.replace(/^\s*export\s+default\s+/gm, '');
39
+ cleaned = cleaned.replace(/^\s*export\s+\{[^}]*\}\s*;?\s*$/gm, '');
40
+ cleaned = cleaned.replace(/^\s*export\s+(?=(const|let|var|function|class)\b)/gm, '');
41
+ return cleaned;
42
+ }
43
+
44
+ function processScriptFile({ scriptsDir, filePath, processedFiles }) {
45
+ if (processedFiles.has(filePath)) return '';
46
+ processedFiles.add(filePath);
47
+
48
+ const fullPath = join(scriptsDir, filePath);
49
+ if (!existsSync(fullPath)) {
50
+ // Keep going; missing optional imports shouldn't hard fail local dev
51
+ // (CI will still fail later if output is invalid).
52
+ // eslint-disable-next-line no-console
53
+ console.warn(`Warning: File ${filePath} not found`);
54
+ return '';
55
+ }
56
+
57
+ let content = readFileSync(fullPath, 'utf8');
58
+ const imports = extractImports(content);
59
+
60
+ let importedContent = '';
61
+ for (const importPath of imports) {
62
+ const resolvedImport = resolveImportPath(filePath, importPath);
63
+ if (!resolvedImport) continue;
64
+ importedContent += processScriptFile({ scriptsDir, filePath: resolvedImport, processedFiles });
65
+ }
66
+
67
+ content = stripModuleSyntax(content);
68
+
69
+ if (process.env.NODE_ENV === 'production') {
70
+ content = content.replace(/\/\*\*[\s\S]*?\*\//g, '');
71
+ content = content.replace(/^\s*\*.*$/gm, '');
72
+ content = content.replace(/console\.(log|warn|error)\([^)]*\);?\s*/g, '');
73
+ content = content.replace(/^\s*\n/gm, '');
74
+ }
75
+
76
+ return importedContent + '\n' + content;
77
+ }
78
+
79
+ function resolveImportPath(fromFilePath, importPath) {
80
+ // Only bundle local relative imports from _scripts.
81
+ if (!importPath || (!importPath.startsWith('./') && !importPath.startsWith('../'))) {
82
+ return null;
83
+ }
84
+ const fromDir = dirname(fromFilePath);
85
+ const resolved = normalize(join(fromDir, importPath)).replace(/\\/g, '/');
86
+ return resolved.startsWith('./') ? resolved.slice(2) : resolved;
87
+ }
88
+
89
+ function collectImportedFiles({ scriptsDir, entryFile, seen = new Set() }) {
90
+ if (seen.has(entryFile)) return seen;
91
+ seen.add(entryFile);
92
+
93
+ const fullPath = join(scriptsDir, entryFile);
94
+ if (!existsSync(fullPath)) return seen;
95
+ const content = readFileSync(fullPath, 'utf8');
96
+ const imports = extractImports(content);
97
+ for (const importPath of imports) {
98
+ const resolved = resolveImportPath(entryFile, importPath);
99
+ if (!resolved) continue;
100
+ collectImportedFiles({ scriptsDir, entryFile: resolved, seen });
101
+ }
102
+ return seen;
103
+ }
104
+
105
+ function listTopLevelEntrypoints(scriptsDir) {
106
+ if (!existsSync(scriptsDir)) return [];
107
+ return readdirSync(scriptsDir, { withFileTypes: true })
108
+ .filter((d) => d.isFile() && d.name.endsWith('.js'))
109
+ .map((d) => d.name)
110
+ .sort();
111
+ }
112
+
113
+ function outputNameForEntrypoint(entryFile) {
114
+ if (entryFile === 'main.js') return 'index.js';
115
+ return basename(entryFile);
116
+ }
117
+
118
+ function buildSingleEntrypoint({ cwd, entryFile }) {
119
+ const scriptsDir = join(cwd, '_scripts');
120
+ const entryPath = join(scriptsDir, entryFile);
121
+ if (!existsSync(entryPath)) {
122
+ throw new Error(`Missing required file: _scripts/${entryFile}`);
123
+ }
124
+
125
+ const processedFiles = new Set();
126
+ let finalContent = processScriptFile({ scriptsDir, filePath: entryFile, processedFiles });
127
+ finalContent = stripModuleSyntax(finalContent);
128
+
129
+ const assetsDir = join(cwd, 'assets');
130
+ mkdirSync(assetsDir, { recursive: true });
131
+
132
+ const outFile = outputNameForEntrypoint(entryFile);
133
+ const outputPath = join(assetsDir, outFile);
134
+ writeFileSync(outputPath, finalContent.trim() + '\n', 'utf-8');
135
+
136
+ return { entryFile, fileCount: processedFiles.size, outputPath };
137
+ }
138
+
139
+ export function buildScripts({ cwd = process.cwd(), entry = null } = {}) {
140
+ const scriptsDir = join(cwd, '_scripts');
141
+ let entrypoints = entry ? [entry.endsWith('.js') ? entry : `${entry}.js`] : listTopLevelEntrypoints(scriptsDir);
142
+ if (!entry && entrypoints.includes('main.js')) {
143
+ const importedByMain = collectImportedFiles({ scriptsDir, entryFile: 'main.js' });
144
+ importedByMain.delete('main.js');
145
+ entrypoints = entrypoints.filter((ep) => ep === 'main.js' || !importedByMain.has(ep));
146
+ }
147
+ if (entrypoints.length === 0) {
148
+ return { bundles: [] };
149
+ }
150
+ const bundles = entrypoints.map((entryFile) => buildSingleEntrypoint({ cwd, entryFile }));
151
+ return { bundles };
152
+ }
153
+
@@ -1,20 +1,5 @@
1
- import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
-
5
- const SCRIPT_SOURCE = fileURLToPath(new URL('../workflows/build/build-scripts.js', import.meta.url));
6
- const SCRIPT_TARGET = '.climaybe/build-scripts.js';
7
- const SCRIPT_SHIM_TARGET = 'build-scripts.js';
8
- const SCRIPT_SHIM_CONTENT = `// Auto-generated by climaybe. Keep for npm scripts compatibility.
9
- // Delegates to the bundled implementation in .climaybe/.
10
- const { buildScripts } = require('./.climaybe/build-scripts.js');
11
-
12
- if (require.main === module) {
13
- buildScripts();
14
- }
15
-
16
- module.exports = { buildScripts };
17
- `;
18
3
 
19
4
  const REQUIRED_PATHS = [
20
5
  { path: '_scripts/main.js', kind: 'file' },
@@ -25,32 +10,6 @@ const DEFAULTS = [
25
10
  { path: 'assets', kind: 'dir' },
26
11
  ];
27
12
 
28
- function targetScriptPath(cwd = process.cwd()) {
29
- return join(cwd, SCRIPT_TARGET);
30
- }
31
-
32
- export function installBuildScript(cwd = process.cwd()) {
33
- const target = targetScriptPath(cwd);
34
- mkdirSync(join(cwd, '.climaybe'), { recursive: true });
35
- copyFileSync(SCRIPT_SOURCE, target);
36
- const shimTarget = join(cwd, SCRIPT_SHIM_TARGET);
37
- if (!existsSync(shimTarget)) {
38
- writeFileSync(shimTarget, SCRIPT_SHIM_CONTENT, 'utf-8');
39
- }
40
- return target;
41
- }
42
-
43
- export function removeBuildScript(cwd = process.cwd()) {
44
- const target = targetScriptPath(cwd);
45
- if (existsSync(target)) rmSync(target);
46
- const shimTarget = join(cwd, SCRIPT_SHIM_TARGET);
47
- if (!existsSync(shimTarget)) return;
48
- const content = readFileSync(shimTarget, 'utf-8');
49
- if (content === SCRIPT_SHIM_CONTENT) {
50
- rmSync(shimTarget);
51
- }
52
- }
53
-
54
13
  export function getMissingBuildWorkflowRequirements(cwd = process.cwd()) {
55
14
  const missing = [];
56
15
  for (const req of REQUIRED_PATHS) {
@@ -75,5 +34,6 @@ export function ensureBuildWorkflowDefaults(cwd = process.cwd()) {
75
34
  }
76
35
 
77
36
  export function getBuildScriptRelativePath() {
78
- return SCRIPT_TARGET;
37
+ // Legacy; build-scripts are now part of climaybe runtime.
38
+ return 'n/a';
79
39
  }
package/src/lib/config.js CHANGED
@@ -1,8 +1,22 @@
1
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
3
  import { getLatestTagVersion } from './git.js';
4
4
 
5
5
  const PKG = 'package.json';
6
+ const CLIMAYBE_CONFIG = 'climaybe.config.json';
7
+ const LEGACY_CLIMAYBE_DIR = '.climaybe';
8
+ const LEGACY_CLIMAYBE_CONFIG = 'config.json';
9
+
10
+ /**
11
+ * Resolve absolute path to root climaybe config file.
12
+ */
13
+ function climaybeConfigPath(cwd = process.cwd()) {
14
+ return join(cwd, CLIMAYBE_CONFIG);
15
+ }
16
+
17
+ function legacyClimaybeConfigPath(cwd = process.cwd()) {
18
+ return join(cwd, LEGACY_CLIMAYBE_DIR, LEGACY_CLIMAYBE_CONFIG);
19
+ }
6
20
 
7
21
  /**
8
22
  * Resolve absolute path to the target repo's package.json.
@@ -12,6 +26,15 @@ function pkgPath(cwd = process.cwd()) {
12
26
  return join(cwd, PKG);
13
27
  }
14
28
 
29
+ function readJson(path) {
30
+ return JSON.parse(readFileSync(path, 'utf-8'));
31
+ }
32
+
33
+ function writeJson(path, obj) {
34
+ mkdirSync(dirname(path), { recursive: true });
35
+ writeFileSync(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
36
+ }
37
+
15
38
  /**
16
39
  * Read the full package.json from a target repo.
17
40
  * Returns null if it doesn't exist.
@@ -20,21 +43,46 @@ function pkgPath(cwd = process.cwd()) {
20
43
  export function readPkg(cwd = process.cwd()) {
21
44
  const p = pkgPath(cwd);
22
45
  if (!existsSync(p)) return null;
23
- return JSON.parse(readFileSync(p, 'utf-8'));
46
+ return readJson(p);
24
47
  }
25
48
 
26
49
  /**
27
50
  * Write the full package.json object back to disk.
28
51
  */
29
52
  export function writePkg(pkg, cwd = process.cwd()) {
30
- writeFileSync(pkgPath(cwd), JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
53
+ writeJson(pkgPath(cwd), pkg);
54
+ }
55
+
56
+ /**
57
+ * Read the climaybe config from root file (with legacy fallback).
58
+ * Returns null if neither exists.
59
+ */
60
+ export function readClimaybeConfig(cwd = process.cwd()) {
61
+ const p = climaybeConfigPath(cwd);
62
+ if (existsSync(p)) return readJson(p);
63
+ const legacy = legacyClimaybeConfigPath(cwd);
64
+ if (existsSync(legacy)) return readJson(legacy);
65
+ return null;
31
66
  }
32
67
 
33
68
  /**
34
- * Read the climaybe config section from package.json.
35
- * Returns null if package.json or config section doesn't exist.
69
+ * Write/merge climaybe config into root config file.
70
+ */
71
+ export function writeClimaybeConfig(config, cwd = process.cwd()) {
72
+ const current = readClimaybeConfig(cwd) || {};
73
+ const next = { ...current, ...config };
74
+ writeJson(climaybeConfigPath(cwd), next);
75
+ }
76
+
77
+ /**
78
+ * Read the climaybe config (source of truth).
79
+ * - Primary: climaybe.config.json
80
+ * - Back-compat: .climaybe/config.json
81
+ * - Legacy fallback: package.json → config
36
82
  */
37
83
  export function readConfig(cwd = process.cwd()) {
84
+ const cfg = readClimaybeConfig(cwd);
85
+ if (cfg) return cfg;
38
86
  const pkg = readPkg(cwd);
39
87
  return pkg?.config ?? null;
40
88
  }
@@ -42,17 +90,24 @@ export function readConfig(cwd = process.cwd()) {
42
90
  /**
43
91
  * @typedef {object} WriteConfigOptions
44
92
  * @property {string} [defaultPackageName] - When creating package.json, set `name` (default: shopify-theme).
93
+ * @property {boolean} [alsoWriteLegacyPackageConfig] - Also write package.json config (default: false).
45
94
  */
46
95
 
47
96
  /**
48
- * Write/merge the climaybe config section into package.json.
49
- * Creates package.json if it doesn't exist.
97
+ * Write/merge the climaybe config (source of truth: climaybe.config.json).
98
+ * Optionally writes legacy package.json config for transitional repos.
50
99
  * @param {object} config
51
100
  * @param {string} [cwd]
52
101
  * @param {WriteConfigOptions} [options]
53
102
  */
54
103
  export function writeConfig(config, cwd = process.cwd(), options = {}) {
55
104
  const defaultPackageName = options.defaultPackageName ?? 'shopify-theme';
105
+ const alsoWriteLegacyPackageConfig = options.alsoWriteLegacyPackageConfig ?? false;
106
+
107
+ writeClimaybeConfig(config, cwd);
108
+
109
+ if (!alsoWriteLegacyPackageConfig) return;
110
+
56
111
  let pkg = readPkg(cwd);
57
112
  if (!pkg) {
58
113
  let version = '1.0.0';
@@ -73,6 +128,21 @@ export function writeConfig(config, cwd = process.cwd(), options = {}) {
73
128
  writePkg(pkg, cwd);
74
129
  }
75
130
 
131
+ /**
132
+ * Migrate legacy package.json config into climaybe.config.json.
133
+ * Returns true if migration wrote a config file.
134
+ */
135
+ export function migrateLegacyPackageConfigToClimaybe({ cwd = process.cwd(), overwrite = false } = {}) {
136
+ const legacy = readPkg(cwd)?.config ?? null;
137
+ if (!legacy || typeof legacy !== 'object') return false;
138
+
139
+ const existing = readClimaybeConfig(cwd);
140
+ if (existing && !overwrite) return false;
141
+
142
+ writeJson(climaybeConfigPath(cwd), legacy);
143
+ return true;
144
+ }
145
+
76
146
  /**
77
147
  * Resolved project kind for guards and init flows.
78
148
  * - `app` only when config explicitly sets project_type: app.
@@ -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.