@runwell/shopify-toolkit 0.8.0 → 0.9.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.
@@ -8,6 +8,8 @@ import { remove } from '../lib/remove.js';
8
8
  import { doctor } from '../lib/doctor.js';
9
9
  import { validate } from '../lib/validate.js';
10
10
  import { qa } from '../lib/qa.js';
11
+ import { init } from '../lib/init.js';
12
+ import { upgradeBaseline } from '../lib/upgrade-baseline.js';
11
13
 
12
14
  const __filename = fileURLToPath(import.meta.url);
13
15
  const __dirname = path.dirname(__filename);
@@ -49,6 +51,11 @@ Commands:
49
51
  qa Code-first QA pipeline (validate-config, doctor,
50
52
  template-integrity, orphan-assets, theme-check).
51
53
  Run before any visual QA or "done" declaration.
54
+ init <client> Scaffold a new tenant theme directory pinned to
55
+ the dawn-runwell baseline + toolkit. Use --baseline
56
+ to override the default pin.
57
+ upgrade-baseline <pkg@version> Bump the baseline pin in runwell.config.json.
58
+ Run "runwell-shopify sync" after to apply.
52
59
  help Show this help
53
60
 
54
61
  Common options:
@@ -86,6 +93,12 @@ flags.toolkitRoot = TOOLKIT_ROOT;
86
93
  case 'qa':
87
94
  await qa(flags);
88
95
  break;
96
+ case 'init':
97
+ await init(flags);
98
+ break;
99
+ case 'upgrade-baseline':
100
+ await upgradeBaseline(flags);
101
+ break;
89
102
  case undefined:
90
103
  case 'help':
91
104
  case '--help':
@@ -0,0 +1,203 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { interpolate } from './template.js';
6
+
7
+ /* baseline.js: resolve, fetch, and apply the @runwell/dawn-runwell
8
+ baseline (or another baseline package) into a tenant theme dir.
9
+
10
+ Resolution order:
11
+ 1. flags.baselinePath (CLI override)
12
+ 2. config.baseline_path (local dev override in runwell.config.json)
13
+ 3. config.baseline (e.g. "@runwell/dawn-runwell@^1.0.0") via the
14
+ cache at ~/.runwell/baseline-cache/<pkg>/<version>/
15
+ */
16
+
17
+ const CACHE_DIR = path.join(os.homedir(), '.runwell', 'baseline-cache');
18
+
19
+ export function parseBaselinePin(pin) {
20
+ if (!pin) return null;
21
+ // accept "@runwell/dawn-runwell@^1.0.0" or "@runwell/dawn-runwell@latest"
22
+ const m = pin.match(/^(@?[^@]+)@(.+)$/);
23
+ if (!m) return { name: pin, version: 'latest' };
24
+ return { name: m[1], version: m[2] };
25
+ }
26
+
27
+ export function resolveBaseline(flags, config) {
28
+ if (flags && flags.baselinePath) return flags.baselinePath;
29
+ if (config && config.baseline_path) return path.resolve(config.baseline_path);
30
+ if (!config || !config.baseline) return null;
31
+
32
+ const pin = parseBaselinePin(config.baseline);
33
+ if (!pin) return null;
34
+ const safeName = pin.name.replace('/', '__');
35
+ const versionDir = path.join(CACHE_DIR, safeName, pin.version);
36
+
37
+ if (fs.existsSync(path.join(versionDir, 'baseline-manifest.json'))) {
38
+ return versionDir;
39
+ }
40
+
41
+ // Cache miss: fetch via npm pack
42
+ console.log(`Fetching baseline: ${pin.name}@${pin.version} ...`);
43
+ fs.mkdirSync(versionDir, { recursive: true });
44
+ const packResult = spawnSync('npm', ['pack', `${pin.name}@${pin.version}`, '--pack-destination', versionDir], {
45
+ stdio: 'pipe',
46
+ encoding: 'utf8'
47
+ });
48
+ if (packResult.status !== 0) {
49
+ throw new Error(`npm pack failed: ${packResult.stderr || packResult.stdout}`);
50
+ }
51
+ const tarball = packResult.stdout.trim().split('\n').pop();
52
+ const tarPath = path.join(versionDir, tarball);
53
+ // npm pack delivers a tarball that extracts to ./package/
54
+ const tarResult = spawnSync('tar', ['-xzf', tarPath, '-C', versionDir, '--strip-components=1'], { stdio: 'inherit' });
55
+ if (tarResult.status !== 0) throw new Error('tar extract failed');
56
+ fs.unlinkSync(tarPath);
57
+ return versionDir;
58
+ }
59
+
60
+ export function loadBaselineManifest(baselineRoot) {
61
+ const p = path.join(baselineRoot, 'baseline-manifest.json');
62
+ if (!fs.existsSync(p)) {
63
+ throw new Error(`baseline-manifest.json not found at ${p}`);
64
+ }
65
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
66
+ }
67
+
68
+ export async function syncBaseline({ baselineRoot, targetDir, config, dryRun, written }) {
69
+ const manifest = loadBaselineManifest(baselineRoot);
70
+ console.log(`\n[baseline: ${manifest.name}@${manifest.version}]`);
71
+
72
+ const interpolationVars = {
73
+ config: {},
74
+ brand: config.brand || {},
75
+ client: { name: config.client, store: config.store }
76
+ };
77
+
78
+ // 1. Owned files (verbatim or interpolated)
79
+ for (const [bucket, files] of Object.entries(manifest.files || {})) {
80
+ if (!Array.isArray(files)) continue;
81
+ const targetSubdir = bucketToTargetDir(bucket, targetDir);
82
+ if (!targetSubdir) continue;
83
+ for (const rel of files) {
84
+ const sourcePath = path.join(baselineRoot, bucket, rel);
85
+ if (!fs.existsSync(sourcePath)) {
86
+ console.warn(` [warn] missing baseline source: ${bucket}/${rel}`);
87
+ continue;
88
+ }
89
+ const filename = path.basename(rel);
90
+ const targetPath = path.join(targetSubdir, filename);
91
+ const isText = isTextFile(filename);
92
+ let content = fs.readFileSync(sourcePath, isText ? 'utf8' : null);
93
+ if (isText) content = interpolate(content, interpolationVars);
94
+ if (!dryRun) {
95
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
96
+ fs.writeFileSync(targetPath, content);
97
+ }
98
+ console.log(` + ${path.relative(targetDir, targetPath)}`);
99
+ written.push({ source: 'baseline', target: targetPath, bucket, baseline_version: manifest.version });
100
+ }
101
+ }
102
+
103
+ // 2. Patches (deep-merge into existing tenant files)
104
+ for (const [tenantRel, patchRel] of Object.entries(manifest.patches || {})) {
105
+ const patchPath = path.join(baselineRoot, patchRel);
106
+ const tenantPath = path.join(targetDir, tenantRel);
107
+ if (!fs.existsSync(patchPath)) {
108
+ console.warn(` [warn] missing baseline patch: ${patchRel}`);
109
+ continue;
110
+ }
111
+ if (!fs.existsSync(tenantPath)) {
112
+ console.warn(` [warn] tenant file ${tenantRel} not present; skipping patch (run shopify theme pull first)`);
113
+ continue;
114
+ }
115
+ const patchData = JSON.parse(fs.readFileSync(patchPath, 'utf8'));
116
+ const tenantData = JSON.parse(fs.readFileSync(tenantPath, 'utf8'));
117
+ if (tenantRel === 'config/settings_schema.json') {
118
+ applySettingsPatches(tenantData, patchData);
119
+ } else if (tenantRel.startsWith('locales/')) {
120
+ deepMerge(tenantData, patchData.patches || {});
121
+ } else {
122
+ deepMerge(tenantData, patchData);
123
+ }
124
+ if (!dryRun) {
125
+ fs.writeFileSync(tenantPath, JSON.stringify(tenantData, null, 2) + '\n');
126
+ }
127
+ console.log(` ~ patched ${tenantRel}`);
128
+ written.push({ source: 'baseline-patch', target: tenantPath, bucket: 'patch', baseline_version: manifest.version });
129
+ }
130
+ }
131
+
132
+ function applySettingsPatches(tenantSchema, patchDoc) {
133
+ // tenantSchema is an array of setting groups. patchDoc.patches is an array.
134
+ const patches = patchDoc.patches || [];
135
+ for (const patchGroup of patches) {
136
+ const existing = tenantSchema.find(g => g.name === patchGroup.name);
137
+ if (!existing) {
138
+ tenantSchema.push(patchGroup);
139
+ } else {
140
+ // Merge settings, skipping duplicates by id (or by content for header/paragraph)
141
+ const existingIds = new Set((existing.settings || []).map(s => s.id).filter(Boolean));
142
+ for (const s of patchGroup.settings || []) {
143
+ if (s.id && existingIds.has(s.id)) continue;
144
+ existing.settings = existing.settings || [];
145
+ existing.settings.push(s);
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ function deepMerge(target, source) {
152
+ for (const key of Object.keys(source)) {
153
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
154
+ target[key] = target[key] || {};
155
+ deepMerge(target[key], source[key]);
156
+ } else {
157
+ target[key] = source[key];
158
+ }
159
+ }
160
+ }
161
+
162
+ function bucketToTargetDir(bucket, targetDir) {
163
+ switch (bucket) {
164
+ case 'assets': return path.join(targetDir, 'assets');
165
+ case 'snippets': return path.join(targetDir, 'snippets');
166
+ case 'sections': return path.join(targetDir, 'sections');
167
+ case 'templates': return path.join(targetDir, 'templates');
168
+ case 'layout': return path.join(targetDir, 'layout');
169
+ case 'locales': return path.join(targetDir, 'locales');
170
+ case 'config': return path.join(targetDir, 'config');
171
+ default: return null;
172
+ }
173
+ }
174
+
175
+ function isTextFile(filename) {
176
+ const ext = path.extname(filename).toLowerCase();
177
+ return ['.liquid', '.js', '.css', '.json', '.html', '.svg', '.md', '.txt'].includes(ext);
178
+ }
179
+
180
+ export async function applyTenantOverrides({ targetDir, dryRun, written }) {
181
+ const overridesDir = path.join(targetDir, 'tenant-overrides');
182
+ if (!fs.existsSync(overridesDir)) return;
183
+ console.log(`\n[tenant-overrides]`);
184
+ walkAndCopy(overridesDir, targetDir, overridesDir, dryRun, written);
185
+ }
186
+
187
+ function walkAndCopy(srcRoot, dstRoot, currentDir, dryRun, written) {
188
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
189
+ const srcPath = path.join(currentDir, entry.name);
190
+ const rel = path.relative(srcRoot, srcPath);
191
+ const dstPath = path.join(dstRoot, rel);
192
+ if (entry.isDirectory()) {
193
+ walkAndCopy(srcRoot, dstRoot, srcPath, dryRun, written);
194
+ } else {
195
+ if (!dryRun) {
196
+ fs.mkdirSync(path.dirname(dstPath), { recursive: true });
197
+ fs.copyFileSync(srcPath, dstPath);
198
+ }
199
+ console.log(` + ${rel}`);
200
+ written.push({ source: 'tenant-override', target: dstPath, bucket: 'override' });
201
+ }
202
+ }
203
+ }
package/lib/init.js ADDED
@@ -0,0 +1,122 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /* runwell-shopify init <client> [--baseline <pin>]: scaffold a new
5
+ tenant theme directory with the minimum required files.
6
+ */
7
+
8
+ export async function init(flags) {
9
+ const client = flags.positional && flags.positional[0];
10
+ if (!client) {
11
+ console.error('Usage: runwell-shopify init <client-name> [--baseline @runwell/dawn-runwell@^1.0.0] [--target ./<dir>]');
12
+ process.exit(1);
13
+ }
14
+
15
+ const targetDir = path.resolve(flags.target || `./${client}-shopify`);
16
+ if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
17
+ console.error(`Target directory ${targetDir} is not empty. Choose another path or remove existing contents.`);
18
+ process.exit(1);
19
+ }
20
+
21
+ fs.mkdirSync(targetDir, { recursive: true });
22
+ for (const d of ['layout', 'sections', 'snippets', 'assets', 'config', 'locales', 'templates', 'tenant-overrides']) {
23
+ fs.mkdirSync(path.join(targetDir, d), { recursive: true });
24
+ }
25
+
26
+ const baseline = flags.baseline || '@runwell/dawn-runwell@^1.0.0';
27
+
28
+ const config = {
29
+ client,
30
+ store: 'TODO.myshopify.com',
31
+ theme_path: '.',
32
+ baseline,
33
+ toolkit_version: '^0.9.0',
34
+ brand: {
35
+ name: client.charAt(0).toUpperCase() + client.slice(1),
36
+ primary: '#1A1A1A',
37
+ accent: '#D4AF37',
38
+ cream: '#F5EFE6',
39
+ currency: 'USD',
40
+ support_email: `support@${client}.com`,
41
+ tagline: ''
42
+ },
43
+ modules: {
44
+ '_shared/css-tokens': { enabled: true, config: {} }
45
+ }
46
+ };
47
+
48
+ fs.writeFileSync(path.join(targetDir, 'runwell.config.json'), JSON.stringify(config, null, 2) + '\n');
49
+
50
+ // Scaffold templates
51
+ fs.writeFileSync(
52
+ path.join(targetDir, 'templates', 'index.json'),
53
+ JSON.stringify({ sections: {}, order: [] }, null, 2) + '\n'
54
+ );
55
+ fs.writeFileSync(
56
+ path.join(targetDir, 'templates', 'product.json'),
57
+ JSON.stringify({
58
+ sections: {
59
+ main: {
60
+ type: 'main-product',
61
+ blocks: {
62
+ title: { type: 'title' },
63
+ price: { type: 'price' },
64
+ buy_buttons: { type: 'buy_buttons', settings: { show_dynamic_checkout: true } }
65
+ },
66
+ block_order: ['title', 'price', 'buy_buttons'],
67
+ settings: {}
68
+ }
69
+ },
70
+ order: ['main']
71
+ }, null, 2) + '\n'
72
+ );
73
+
74
+ // README
75
+ fs.writeFileSync(
76
+ path.join(targetDir, 'README.md'),
77
+ `# ${client} Shopify theme
78
+
79
+ Tenant theme for ${client}, built on @runwell/dawn-runwell + @runwell/shopify-toolkit.
80
+
81
+ ## Lock context (always state these before any work)
82
+
83
+ - Client: ${client}
84
+ - Store: TODO.myshopify.com
85
+ - Theme ID: TODO
86
+ - Storefront password: TODO
87
+
88
+ ## Quick start
89
+
90
+ \`\`\`bash
91
+ # 1. Edit runwell.config.json with the real store handle + brand values
92
+ # 2. Sync baseline + toolkit + tenant overrides into the theme dir
93
+ runwell-shopify sync
94
+
95
+ # 3. Code QA before any push
96
+ runwell-shopify qa
97
+
98
+ # 4. Push to a NEW unpublished theme on the store
99
+ shopify theme push -u -s <store>.myshopify.com
100
+
101
+ # 5. Once happy, push to the live theme
102
+ shopify theme push -t <THEME_ID> -l -a -s <store>.myshopify.com
103
+ \`\`\`
104
+
105
+ ## Layers
106
+
107
+ 1. \`@runwell/dawn-runwell\` baseline (pinned in runwell.config.json).
108
+ 2. \`@runwell/shopify-toolkit\` modules (declared in runwell.config.json modules block).
109
+ 3. This repo: \`tenant-overrides/\` directory + \`templates/*.json\` + bespoke sections + brand assets.
110
+
111
+ \`runwell-manifest.json\` is regenerated on every sync and tracks file provenance.
112
+ `
113
+ );
114
+
115
+ console.log(`Scaffolded ${client} tenant theme at ${targetDir}`);
116
+ console.log(`\nNext steps:`);
117
+ console.log(` 1. cd ${path.relative(process.cwd(), targetDir) || '.'}`);
118
+ console.log(` 2. Edit runwell.config.json: set store handle + brand values`);
119
+ console.log(` 3. runwell-shopify sync`);
120
+ console.log(` 4. runwell-shopify qa`);
121
+ console.log(` 5. shopify theme push -u -s <store>`);
122
+ }
package/lib/sync.js CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { loadConfig, loadModuleManifest, listAvailableModules, resolveVariant } from './config-loader.js';
4
4
  import { interpolate } from './template.js';
5
+ import { resolveBaseline, syncBaseline, applyTenantOverrides } from './baseline.js';
5
6
 
6
7
  export async function sync(flags) {
7
8
  const configPath = flags.config || './runwell.config.json';
@@ -18,6 +19,14 @@ export async function sync(flags) {
18
19
  const writtenFiles = [];
19
20
  const removedFiles = [];
20
21
 
22
+ // Stage 1: baseline (if configured). Owned files + patches first;
23
+ // toolkit module files in stage 2 will overlay on top, and tenant
24
+ // overrides in stage 3 win last.
25
+ const baselineRoot = resolveBaseline(flags, config);
26
+ if (baselineRoot) {
27
+ await syncBaseline({ baselineRoot, targetDir, config, dryRun, written: writtenFiles });
28
+ }
29
+
21
30
  const moduleNames = Object.keys(config.modules);
22
31
  for (const moduleName of moduleNames) {
23
32
  const cfgEntry = config.modules[moduleName];
@@ -77,15 +86,21 @@ export async function sync(flags) {
77
86
  }
78
87
  }
79
88
 
80
- // Manifest
89
+ // Stage 3: tenant overrides (last; wins over baseline + toolkit).
90
+ await applyTenantOverrides({ targetDir, dryRun, written: writtenFiles });
91
+
92
+ // Manifest with provenance per file.
81
93
  const manifestPath = path.join(targetDir, 'runwell-manifest.json');
82
94
  const manifestData = {
83
95
  generated_at: new Date().toISOString(),
84
96
  toolkit_version: getToolkitVersion(flags.toolkitRoot),
97
+ baseline_version: writtenFiles.find(f => f.source === 'baseline')?.baseline_version || null,
85
98
  client: config.client,
86
99
  files: writtenFiles.map(f => ({
100
+ source: f.source || 'toolkit',
87
101
  module: f.module,
88
102
  bucket: f.bucket,
103
+ baseline_version: f.baseline_version,
89
104
  path: path.relative(targetDir, f.target)
90
105
  }))
91
106
  };
@@ -0,0 +1,25 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { loadConfig } from './config-loader.js';
4
+
5
+ /* runwell-shopify upgrade-baseline <pin>: bump the baseline version pin
6
+ in runwell.config.json. Subsequent runwell-shopify sync uses the new
7
+ pin to fetch the new baseline. Caller is responsible for running sync
8
+ + qa + push afterwards.
9
+ */
10
+
11
+ export async function upgradeBaseline(flags) {
12
+ const newPin = flags.positional && flags.positional[0];
13
+ if (!newPin) {
14
+ console.error('Usage: runwell-shopify upgrade-baseline <package@version>');
15
+ console.error('Example: runwell-shopify upgrade-baseline @runwell/dawn-runwell@1.1.0');
16
+ process.exit(1);
17
+ }
18
+ const configPath = path.resolve(flags.config || './runwell.config.json');
19
+ const { config } = loadConfig(configPath);
20
+ const oldPin = config.baseline || '(unset)';
21
+ config.baseline = newPin;
22
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
23
+ console.log(`baseline: ${oldPin} -> ${newPin}`);
24
+ console.log(`Run 'runwell-shopify sync' to apply, then 'runwell-shopify qa' before pushing.`);
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runwell/shopify-toolkit",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Reusable Shopify theme modules from Runwell. Replaces typically app-driven features (reviews, wishlist, urgency, FAQ, post-purchase upsell, exit popups, free-ship progress, sticky ATC, testimonials, badges, bundles) with native Liquid + JS + CSS that ship across multiple client themes via a config-driven sync CLI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",