climaybe 2.4.2 → 3.0.1

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,217 @@
1
+ import prompts from 'prompts';
2
+ import pc from 'picocolors';
3
+ import { existsSync, rmSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { migrateLegacyPackageConfigToClimaybe, readClimaybeConfig, readPkg, writePkg } from '../lib/config.js';
6
+ import { updateWorkflowsCommand } from './update-workflows.js';
7
+ import { requireThemeProject } from '../lib/theme-guard.js';
8
+ import { getDevKitExistingFiles, scaffoldThemeDevKit } from '../lib/theme-dev-kit.js';
9
+
10
+ const LEGACY_SCRIPT_KEYS = [
11
+ 'shopify:serve',
12
+ 'shopify:serve:sync',
13
+ 'shopify:populate',
14
+ 'scripts:build',
15
+ 'scripts:watch',
16
+ 'tailwind:watch',
17
+ 'tailwind:build',
18
+ 'dev',
19
+ 'dev:theme',
20
+ 'dev:assets',
21
+ 'dev:no-sync',
22
+ 'lint:liquid',
23
+ 'lint:js',
24
+ 'lint:css',
25
+ 'release',
26
+ 'prepare',
27
+ ];
28
+
29
+ const LEGACY_DEP_NAMES = [
30
+ 'concurrently',
31
+ 'nodemon',
32
+ 'tailwindcss',
33
+ '@tailwindcss/cli',
34
+ '@tailwindcss/typography',
35
+ 'eslint',
36
+ '@eslint/js',
37
+ '@eslint/create-config',
38
+ 'stylelint',
39
+ 'stylelint-config-standard',
40
+ 'prettier',
41
+ '@shopify/prettier-plugin-liquid',
42
+ '@commitlint/cli',
43
+ '@commitlint/config-conventional',
44
+ 'husky',
45
+ ];
46
+
47
+ function cleanupLegacyFiles(cwd) {
48
+ const candidates = [
49
+ '.climaybe/build-scripts.js',
50
+ 'build-scripts.js',
51
+ '.climaybe/dev-with-sync.sh',
52
+ '.climaybe/dev.sh',
53
+ ];
54
+ const removed = [];
55
+ for (const rel of candidates) {
56
+ const abs = join(cwd, rel);
57
+ if (!existsSync(abs)) continue;
58
+ rmSync(abs, { force: true });
59
+ removed.push(rel);
60
+ }
61
+ return removed;
62
+ }
63
+
64
+ function cleanupLegacyPackageJson(cwd) {
65
+ const pkg = readPkg(cwd);
66
+ if (!pkg || typeof pkg !== 'object') return { removedScripts: [], removedDeps: [] };
67
+
68
+ const removedScripts = [];
69
+ if (pkg.scripts && typeof pkg.scripts === 'object') {
70
+ for (const key of LEGACY_SCRIPT_KEYS) {
71
+ if (key in pkg.scripts) {
72
+ delete pkg.scripts[key];
73
+ removedScripts.push(key);
74
+ }
75
+ }
76
+ if (Object.keys(pkg.scripts).length === 0) delete pkg.scripts;
77
+ }
78
+
79
+ const removedDeps = [];
80
+ for (const depField of ['dependencies', 'devDependencies']) {
81
+ const deps = pkg[depField];
82
+ if (!deps || typeof deps !== 'object') continue;
83
+ for (const name of LEGACY_DEP_NAMES) {
84
+ if (name in deps) {
85
+ delete deps[name];
86
+ removedDeps.push(`${depField}:${name}`);
87
+ }
88
+ }
89
+ if (Object.keys(deps).length === 0) delete pkg[depField];
90
+ }
91
+
92
+ writePkg(pkg, cwd);
93
+ return { removedScripts, removedDeps };
94
+ }
95
+
96
+ export async function migrateLegacyConfigCommand(options = {}) {
97
+ const overwrite = options.overwrite === true;
98
+ const updateWorkflows = options.updateWorkflows !== false;
99
+ const yes = options.yes === true;
100
+
101
+ console.log(pc.bold('\n climaybe — Migrate legacy config\n'));
102
+
103
+ if (!requireThemeProject()) return;
104
+
105
+ const legacy = readPkg()?.config ?? null;
106
+ if (!legacy || typeof legacy !== 'object') {
107
+ console.log(pc.yellow(' No legacy package.json config found (package.json → config).'));
108
+ console.log(pc.dim(' Nothing to migrate.\n'));
109
+ return;
110
+ }
111
+
112
+ const existing = readClimaybeConfig();
113
+ if (existing && !overwrite) {
114
+ const ok = yes
115
+ ? true
116
+ : (
117
+ await prompts({
118
+ type: 'confirm',
119
+ name: 'ok',
120
+ message: 'A climaybe.config.json already exists. Overwrite it from package.json config?',
121
+ initial: false,
122
+ })
123
+ ).ok;
124
+ if (!ok) {
125
+ console.log(pc.dim(' Cancelled.\n'));
126
+ return;
127
+ }
128
+ }
129
+
130
+ const did = migrateLegacyPackageConfigToClimaybe({ overwrite: true });
131
+ if (!did) {
132
+ console.log(pc.yellow(' Migration skipped (no legacy config, or config already present).'));
133
+ console.log(pc.dim(' Tip: run with --overwrite to force rewrite.\n'));
134
+ return;
135
+ }
136
+
137
+ console.log(pc.green(' Migrated package.json config → climaybe.config.json'));
138
+
139
+ // Clean legacy dev system artifacts (sample-style package scripts, shims, old .climaybe scripts).
140
+ const cleanup = yes
141
+ ? true
142
+ : (
143
+ await prompts({
144
+ type: 'confirm',
145
+ name: 'cleanup',
146
+ message: 'Remove legacy dev scripts/deps from package.json and delete old local shims?',
147
+ initial: true,
148
+ })
149
+ ).cleanup;
150
+ if (cleanup) {
151
+ const removedFiles = cleanupLegacyFiles(process.cwd());
152
+ const { removedScripts, removedDeps } = cleanupLegacyPackageJson(process.cwd());
153
+ if (removedFiles.length > 0) {
154
+ console.log(pc.green(` Removed legacy files: ${removedFiles.join(', ')}`));
155
+ }
156
+ if (removedScripts.length > 0) {
157
+ console.log(pc.green(` Removed legacy package.json scripts: ${removedScripts.join(', ')}`));
158
+ }
159
+ if (removedDeps.length > 0) {
160
+ console.log(pc.green(` Removed legacy package.json deps: ${removedDeps.join(', ')}`));
161
+ }
162
+ }
163
+
164
+ // Reinstall dev kit so tasks/theme-check config match the new climaybe runtime.
165
+ const reinstallDevKit = yes
166
+ ? true
167
+ : (
168
+ await prompts({
169
+ type: 'confirm',
170
+ name: 'reinstallDevKit',
171
+ message: 'Install/refresh climaybe dev kit files (.vscode/tasks.json, etc.)?',
172
+ initial: true,
173
+ })
174
+ ).reinstallDevKit;
175
+ if (reinstallDevKit) {
176
+ const existing = getDevKitExistingFiles({ includeVSCodeTasks: true });
177
+ if (existing.length > 0) {
178
+ console.log(pc.yellow(' Some dev kit files already exist and will be replaced:'));
179
+ for (const path of existing) console.log(pc.yellow(` - ${path}`));
180
+ const ok = yes
181
+ ? true
182
+ : (
183
+ await prompts({
184
+ type: 'confirm',
185
+ name: 'ok',
186
+ message: 'Replace these files?',
187
+ initial: true,
188
+ })
189
+ ).ok;
190
+ if (!ok) {
191
+ console.log(pc.dim(' Skipped dev kit refresh.\n'));
192
+ } else {
193
+ const legacy = readPkg()?.config ?? {};
194
+ scaffoldThemeDevKit({
195
+ includeVSCodeTasks: true,
196
+ defaultStoreDomain: legacy.default_store || legacy.store || '',
197
+ });
198
+ console.log(pc.green(' Dev kit refreshed.'));
199
+ }
200
+ } else {
201
+ const legacy = readPkg()?.config ?? {};
202
+ scaffoldThemeDevKit({
203
+ includeVSCodeTasks: true,
204
+ defaultStoreDomain: legacy.default_store || legacy.store || '',
205
+ });
206
+ console.log(pc.green(' Dev kit installed.'));
207
+ }
208
+ }
209
+
210
+ if (updateWorkflows) {
211
+ console.log(pc.dim('\n Updating workflows to use climaybe.config.json...\n'));
212
+ await updateWorkflowsCommand();
213
+ } else {
214
+ console.log(pc.dim(' Skipped workflow update (pass --update-workflows to refresh).\n'));
215
+ }
216
+ }
217
+
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.