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/README.md +25 -26
- package/bin/version.txt +1 -1
- package/package.json +9 -1
- package/src/commands/add-dev-kit.js +5 -3
- package/src/commands/add-store.js +22 -7
- package/src/commands/app-init.js +1 -1
- package/src/commands/build-scripts.js +27 -0
- package/src/commands/create-entrypoints.js +45 -0
- package/src/commands/init.js +52 -18
- package/src/commands/migrate-legacy-config.js +217 -0
- package/src/index.js +37 -2
- package/src/lib/build-scripts.js +153 -0
- package/src/lib/build-workflows.js +3 -43
- package/src/lib/config.js +78 -8
- package/src/lib/dev-runtime.js +188 -0
- package/src/lib/prompts.js +29 -6
- package/src/lib/shopify-cli.js +30 -0
- package/src/lib/theme-dev-kit.js +33 -51
- package/src/lib/watch.js +80 -0
- package/src/lib/workflows.js +2 -4
- package/src/workflows/build/create-release.yml +23 -6
- package/src/workflows/build/reusable-build.yml +26 -5
- package/src/workflows/multi/main-to-staging-stores.yml +4 -3
- package/src/workflows/multi/multistore-hotfix-to-main.yml +3 -3
- package/src/workflows/multi/pr-to-live.yml +3 -3
- package/src/workflows/preview/pr-close.yml +4 -4
- package/src/workflows/preview/pr-update.yml +4 -4
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 (
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
35
|
-
|
|
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
|
|
49
|
-
*
|
|
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
|
+
|
package/src/lib/prompts.js
CHANGED
|
@@ -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
|
|
46
|
+
message: 'Store name or domain',
|
|
46
47
|
initial: defaultDomain,
|
|
47
48
|
validate: (v) => {
|
|
48
|
-
if (v.trim().length === 0) return 'Store
|
|
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
|
|
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
|
|
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
|
|
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 +
|
|
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.
|