@runwell/shopify-toolkit 0.17.4 → 0.21.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/bin/runwell-shopify +14 -2
- package/lib/list.js +133 -0
- package/lib/qa-bundles.js +117 -0
- package/lib/qa.js +147 -13
- package/modules/INDEX.md +65 -23
- package/modules/_shared/css-tokens/assets/runwell-tokens.css +24 -4
- package/modules/_shared/css-tokens/module.json +2 -2
- package/modules/_shared/css-typography/assets/runwell-typography.css +14 -6
- package/modules/_shared/css-typography/module.json +2 -2
- package/modules/bundle-builder/README.md +6 -1
- package/modules/bundle-builder/module.json +5 -1
- package/modules/care-coaching-medvi/README.md +46 -0
- package/modules/care-coaching-medvi/assets/runwell-care-coaching-medvi.css +241 -0
- package/modules/care-coaching-medvi/module.json +80 -0
- package/modules/care-coaching-medvi/sections/runwell-care-coaching-medvi.liquid +274 -0
- package/modules/care-coaching-medvi/snippets/runwell-care-coaching-medvi-circular-text.liquid +25 -0
- package/modules/cart-cross-sell/snippets/runwell-cart-xsell.liquid +16 -0
- package/modules/collection-block-medvi/README.md +50 -0
- package/modules/collection-block-medvi/assets/runwell-collection-block-medvi.css +242 -0
- package/modules/collection-block-medvi/module.json +83 -0
- package/modules/collection-block-medvi/sections/runwell-collection-block-medvi.liquid +355 -0
- package/modules/product-trio-medvi/README.md +35 -0
- package/modules/product-trio-medvi/assets/runwell-product-trio-medvi.css +119 -0
- package/modules/product-trio-medvi/module.json +48 -0
- package/modules/product-trio-medvi/sections/runwell-product-trio-medvi.liquid +188 -0
- package/modules/runwell-bundle-system/README.md +35 -0
- package/modules/runwell-bundle-system/admin-metafields.json +46 -0
- package/modules/runwell-bundle-system/assets/runwell-bundle-system.css +861 -0
- package/modules/runwell-bundle-system/assets/runwell-bundle-system.js +287 -0
- package/modules/runwell-bundle-system/module.json +126 -0
- package/modules/runwell-bundle-system/qa/mobile-checklist.md +105 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-cart-xsell.liquid +59 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-collection.liquid +121 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-home-stacks.liquid +77 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-banner.liquid +50 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-pdp-pairs-with.liquid +72 -0
- package/modules/runwell-bundle-system/sections/runwell-bundle-pdp.liquid +105 -0
- package/modules/runwell-bundle-system/settings.json +25 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-card.liquid +70 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-cross-supplier.liquid +18 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-data.liquid +67 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-fomo.liquid +32 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-free-gift.liquid +34 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-multi-product.liquid +86 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-pricing.liquid +30 -0
- package/modules/runwell-bundle-system/snippets/runwell-bundle-quantity-tiers.liquid +73 -0
- package/modules/testimonials-medvi/README.md +44 -0
- package/modules/testimonials-medvi/assets/runwell-testimonials-medvi.css +239 -0
- package/modules/testimonials-medvi/module.json +68 -0
- package/modules/testimonials-medvi/sections/runwell-testimonials-medvi.liquid +355 -0
- package/package.json +2 -2
package/bin/runwell-shopify
CHANGED
|
@@ -8,6 +8,7 @@ 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 { qaBundles } from '../lib/qa-bundles.js';
|
|
11
12
|
import { init } from '../lib/init.js';
|
|
12
13
|
import { upgradeBaseline } from '../lib/upgrade-baseline.js';
|
|
13
14
|
import { diffBaseline } from '../lib/diff-baseline.js';
|
|
@@ -44,7 +45,10 @@ function help() {
|
|
|
44
45
|
|
|
45
46
|
Commands:
|
|
46
47
|
sync Apply runwell.config.json to the target theme
|
|
47
|
-
list List available + installed modules
|
|
48
|
+
list List available + installed modules.
|
|
49
|
+
--tenants prints a cross-tenant matrix
|
|
50
|
+
(tenants x baseline pin x toolkit pin x
|
|
51
|
+
drift) read from shopify-toolkit-deployments.json.
|
|
48
52
|
add <module> Enable a module in runwell.config.json
|
|
49
53
|
remove <module> Disable + clean a module's files
|
|
50
54
|
doctor Detect drift between manifest and theme
|
|
@@ -52,6 +56,9 @@ Commands:
|
|
|
52
56
|
qa Code-first QA pipeline (validate-config, doctor,
|
|
53
57
|
template-integrity, orphan-assets, theme-check).
|
|
54
58
|
Run before any visual QA or "done" declaration.
|
|
59
|
+
Add --bundles to run the bundle-system check
|
|
60
|
+
(admin metafields, cross-supplier disclosure,
|
|
61
|
+
customizer contract) instead of the full pipeline.
|
|
55
62
|
init <client> Scaffold a new tenant theme directory pinned to
|
|
56
63
|
the dawn-runwell baseline + toolkit. Use --baseline
|
|
57
64
|
to override the default pin.
|
|
@@ -95,7 +102,12 @@ flags.toolkitRoot = TOOLKIT_ROOT;
|
|
|
95
102
|
await validate(flags);
|
|
96
103
|
break;
|
|
97
104
|
case 'qa':
|
|
98
|
-
|
|
105
|
+
if (flags.bundles) {
|
|
106
|
+
flags.toolkitRoot = flags.toolkitRoot || TOOLKIT_ROOT;
|
|
107
|
+
await qaBundles(flags);
|
|
108
|
+
} else {
|
|
109
|
+
await qa(flags);
|
|
110
|
+
}
|
|
99
111
|
break;
|
|
100
112
|
case 'init':
|
|
101
113
|
await init(flags);
|
package/lib/list.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import { listAvailableModules, loadModuleManifest, loadConfig } from './config-loader.js';
|
|
4
5
|
|
|
5
6
|
export async function list(flags) {
|
|
7
|
+
if (flags.tenants) {
|
|
8
|
+
return listTenants(flags);
|
|
9
|
+
}
|
|
10
|
+
return listModules(flags);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function listModules(flags) {
|
|
6
14
|
const available = listAvailableModules(flags.toolkitRoot);
|
|
7
15
|
|
|
8
16
|
let installed = new Set();
|
|
@@ -29,3 +37,128 @@ export async function list(flags) {
|
|
|
29
37
|
|
|
30
38
|
console.log(`\nTotal: ${available.length} modules. Installed in current config: ${installed.size}.`);
|
|
31
39
|
}
|
|
40
|
+
|
|
41
|
+
function listTenants(flags) {
|
|
42
|
+
const registryPath = resolveRegistryPath(flags);
|
|
43
|
+
if (!registryPath) {
|
|
44
|
+
console.error('Could not locate shopify-toolkit-deployments.json.');
|
|
45
|
+
console.error('Pass --registry <path>, set RUNWELL_DEPLOYMENT_REGISTRY env var, or place the file at ~/Documents/Code/claude-PM/infrastructure/config/shopify-toolkit-deployments.json');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
|
|
50
|
+
const deployments = registry.deployments || {};
|
|
51
|
+
const tenants = Object.entries(deployments);
|
|
52
|
+
|
|
53
|
+
if (tenants.length === 0) {
|
|
54
|
+
console.log('No tenants in registry.');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const toolkitCurrent = readToolkitVersion(flags.toolkitRoot);
|
|
59
|
+
|
|
60
|
+
console.log(`Runwell Shopify tenants (registry: ${registryPath})\n`);
|
|
61
|
+
console.log(`Toolkit current: ${toolkitCurrent}\n`);
|
|
62
|
+
console.log(' Tenant Store Config pin Synced ver Modules Brand CSS Drift Status');
|
|
63
|
+
console.log(' --------- ------------------------------------------- ------------ ----------- ------- --------- ----- ----------');
|
|
64
|
+
|
|
65
|
+
let drifters = 0;
|
|
66
|
+
let exposed = 0;
|
|
67
|
+
|
|
68
|
+
for (const [slug, t] of tenants.sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
69
|
+
const store = (t.store || '?').padEnd(43);
|
|
70
|
+
const tenantConfig = readTenantConfig(t.theme_repo_local);
|
|
71
|
+
const configPin = (tenantConfig?.toolkit_version || '?').padEnd(12);
|
|
72
|
+
const syncedVer = (readManifestToolkit(t.theme_repo_local) || '?').padEnd(11);
|
|
73
|
+
const moduleCount = (t.modules_enabled || []).length.toString().padEnd(7);
|
|
74
|
+
const brandCss = hasTenantBrandCss(slug, t.theme_repo_local) ? 'present ' : 'MISSING ';
|
|
75
|
+
if (brandCss.trim() === 'MISSING') exposed++;
|
|
76
|
+
const drift = computeDrift(tenantConfig?.toolkit_version, toolkitCurrent);
|
|
77
|
+
if (drift !== 'ok') drifters++;
|
|
78
|
+
const driftStr = drift.padEnd(5);
|
|
79
|
+
const status = (t.status || '?');
|
|
80
|
+
console.log(` ${slug.padEnd(9)} ${store} ${configPin} ${syncedVer} ${moduleCount} ${brandCss} ${driftStr} ${status}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log(`\nTotal: ${tenants.length} tenants.`);
|
|
84
|
+
console.log(` Drift: ${drifters} of ${tenants.length} pinned below toolkit current (${toolkitCurrent}).`);
|
|
85
|
+
console.log(` Exposed: ${exposed} of ${tenants.length} missing tenant brand CSS (will inherit toolkit token changes raw).`);
|
|
86
|
+
|
|
87
|
+
if (flags.verbose) {
|
|
88
|
+
console.log('\nLegend:');
|
|
89
|
+
console.log(' Config pin: from runwell.config.json toolkit_version (truth source). ^X.Y.Z accepts forward minor bumps.');
|
|
90
|
+
console.log(' Synced ver: actual toolkit version recorded by the last `runwell-shopify sync` (from runwell-manifest.json).');
|
|
91
|
+
console.log(' Brand CSS: "present" if assets/<tenant>-brand.css exists, "MISSING" if not. Per docs/CUSTOMIZATION-LEVELS.md, every tenant should ship one to insulate the storefront from upstream token tuning.');
|
|
92
|
+
console.log(' Drift: "ok" if config-pin minor >= toolkit-current minor; "stale" if behind. "stale" with synced=current means the files are current but the pin lies.');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveRegistryPath(flags) {
|
|
97
|
+
if (flags.registry) {
|
|
98
|
+
return path.resolve(flags.registry);
|
|
99
|
+
}
|
|
100
|
+
if (process.env.RUNWELL_DEPLOYMENT_REGISTRY) {
|
|
101
|
+
return path.resolve(process.env.RUNWELL_DEPLOYMENT_REGISTRY);
|
|
102
|
+
}
|
|
103
|
+
const guesses = [
|
|
104
|
+
path.join(os.homedir(), 'Documents/Code/claude-PM/infrastructure/config/shopify-toolkit-deployments.json'),
|
|
105
|
+
path.resolve(flags.toolkitRoot, '../claude-PM/infrastructure/config/shopify-toolkit-deployments.json'),
|
|
106
|
+
];
|
|
107
|
+
return guesses.find(p => fs.existsSync(p));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readToolkitVersion(toolkitRoot) {
|
|
111
|
+
try {
|
|
112
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(toolkitRoot, 'package.json'), 'utf-8'));
|
|
113
|
+
return pkg.version;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readManifestToolkit(themeRepoLocal) {
|
|
120
|
+
if (!themeRepoLocal) return null;
|
|
121
|
+
const manifestPath = path.join(themeRepoLocal, 'runwell-manifest.json');
|
|
122
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
123
|
+
try {
|
|
124
|
+
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
125
|
+
return m.toolkit_version || null;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readTenantConfig(themeRepoLocal) {
|
|
132
|
+
if (!themeRepoLocal) return null;
|
|
133
|
+
const configPath = path.join(themeRepoLocal, 'runwell.config.json');
|
|
134
|
+
if (!fs.existsSync(configPath)) return null;
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hasTenantBrandCss(slug, themeRepoLocal) {
|
|
143
|
+
if (!themeRepoLocal) return false;
|
|
144
|
+
const candidates = [
|
|
145
|
+
path.join(themeRepoLocal, 'assets', `${slug}-brand.css`),
|
|
146
|
+
path.join(themeRepoLocal, 'assets', 'tenant-brand.css'),
|
|
147
|
+
];
|
|
148
|
+
return candidates.some(p => fs.existsSync(p));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function computeDrift(pin, current) {
|
|
152
|
+
if (!pin || !current) return '?';
|
|
153
|
+
const pinMinor = extractMinor(pin);
|
|
154
|
+
const curMinor = extractMinor(current);
|
|
155
|
+
if (pinMinor == null || curMinor == null) return '?';
|
|
156
|
+
if (pinMinor >= curMinor) return 'ok';
|
|
157
|
+
return 'stale';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function extractMinor(version) {
|
|
161
|
+
const m = String(version).match(/(\d+)\.(\d+)/);
|
|
162
|
+
if (!m) return null;
|
|
163
|
+
return parseInt(m[1], 10) * 1000 + parseInt(m[2], 10);
|
|
164
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/* qa-bundles: cross-supplier and bundle metafield consistency check.
|
|
5
|
+
|
|
6
|
+
Invoked via `runwell-shopify qa --bundles`. Two layers:
|
|
7
|
+
|
|
8
|
+
A. Local checks (this file): manifest + contract integrity.
|
|
9
|
+
B. Store checks (deferred): per-bundle Shopify Admin GraphQL reads to
|
|
10
|
+
verify bundle_cross_supplier matches actual Carro supplier count.
|
|
11
|
+
Store checks land in BS-15 (Lushi integration) once the bundle
|
|
12
|
+
module is enabled on staging and a real bundle_index exists.
|
|
13
|
+
|
|
14
|
+
Local stages:
|
|
15
|
+
1. admin-metafields-defined the 19 product metafields are present
|
|
16
|
+
in modules/runwell-bundle-system/admin-metafields.json
|
|
17
|
+
2. disclosure-snippet-exists the cross-supplier disclosure renders
|
|
18
|
+
guard `bundle_cross_supplier && bundle_supplier_count > 1`
|
|
19
|
+
3. settings-contract the cross_supplier_disclosure customizer
|
|
20
|
+
key is wired in settings.json with the
|
|
21
|
+
{n} placeholder
|
|
22
|
+
|
|
23
|
+
The Shopify Admin API stage (store-bundle-audit) is stubbed and reports
|
|
24
|
+
"deferred" until BS-15 wires the live check. */
|
|
25
|
+
|
|
26
|
+
export async function qaBundles(flags) {
|
|
27
|
+
const toolkitRoot = path.resolve(flags.toolkitRoot || process.cwd());
|
|
28
|
+
const moduleDir = path.join(toolkitRoot, 'modules', 'runwell-bundle-system');
|
|
29
|
+
|
|
30
|
+
const findings = { errors: [], warnings: [], info: [] };
|
|
31
|
+
const log = (level, stage, msg) => {
|
|
32
|
+
findings[level].push({ stage, msg });
|
|
33
|
+
const tag = level === 'errors' ? 'FAIL' : level === 'warnings' ? 'WARN' : 'OK ';
|
|
34
|
+
console.log(` [${tag}] ${stage}: ${msg}`);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
console.log(`runwell-shopify qa --bundles @ ${toolkitRoot}`);
|
|
38
|
+
console.log('');
|
|
39
|
+
|
|
40
|
+
// 1. admin-metafields-defined
|
|
41
|
+
console.log('1/4 admin-metafields-defined');
|
|
42
|
+
const adminPath = path.join(moduleDir, 'admin-metafields.json');
|
|
43
|
+
if (!fs.existsSync(adminPath)) {
|
|
44
|
+
log('errors', 'admin-metafields-defined', 'admin-metafields.json missing');
|
|
45
|
+
} else {
|
|
46
|
+
try {
|
|
47
|
+
const admin = JSON.parse(fs.readFileSync(adminPath, 'utf8'));
|
|
48
|
+
const defs = admin.product_metafields?.definitions || [];
|
|
49
|
+
const required = [
|
|
50
|
+
'bundle_mode', 'bundle_pricing_model', 'bundle_pricing_value',
|
|
51
|
+
'bundle_components', 'bundle_quantity_tiers', 'bundle_cross_supplier',
|
|
52
|
+
'bundle_supplier_count'
|
|
53
|
+
];
|
|
54
|
+
const present = new Set(defs.map(d => d.key));
|
|
55
|
+
const missing = required.filter(k => !present.has(k));
|
|
56
|
+
if (missing.length > 0) {
|
|
57
|
+
log('errors', 'admin-metafields-defined', `missing required keys: ${missing.join(', ')}`);
|
|
58
|
+
} else {
|
|
59
|
+
log('info', 'admin-metafields-defined', `${defs.length} definitions present, all required keys covered`);
|
|
60
|
+
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
log('errors', 'admin-metafields-defined', `admin-metafields.json invalid JSON: ${e.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
console.log('');
|
|
66
|
+
|
|
67
|
+
// 2. disclosure-snippet-exists
|
|
68
|
+
console.log('2/4 disclosure-snippet-exists');
|
|
69
|
+
const snippetPath = path.join(moduleDir, 'snippets', 'runwell-bundle-cross-supplier.liquid');
|
|
70
|
+
if (!fs.existsSync(snippetPath)) {
|
|
71
|
+
log('errors', 'disclosure-snippet-exists', 'runwell-bundle-cross-supplier.liquid missing');
|
|
72
|
+
} else {
|
|
73
|
+
const snippet = fs.readFileSync(snippetPath, 'utf8');
|
|
74
|
+
if (!snippet.includes('bundle_supplier_count')) {
|
|
75
|
+
log('warnings', 'disclosure-snippet-exists', 'snippet does not reference bundle_supplier_count');
|
|
76
|
+
} else {
|
|
77
|
+
log('info', 'disclosure-snippet-exists', 'snippet present and references supplier_count');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
console.log('');
|
|
81
|
+
|
|
82
|
+
// 3. settings-contract
|
|
83
|
+
console.log('3/4 settings-contract');
|
|
84
|
+
const settingsPath = path.join(moduleDir, 'settings.json');
|
|
85
|
+
if (!fs.existsSync(settingsPath)) {
|
|
86
|
+
log('errors', 'settings-contract', 'settings.json missing');
|
|
87
|
+
} else {
|
|
88
|
+
try {
|
|
89
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
90
|
+
const ids = (settings.settings || []).map(s => s.id);
|
|
91
|
+
if (!ids.includes('bundle_system__cross_supplier_disclosure')) {
|
|
92
|
+
log('errors', 'settings-contract', 'bundle_system__cross_supplier_disclosure missing from settings.json');
|
|
93
|
+
} else {
|
|
94
|
+
const setting = settings.settings.find(s => s.id === 'bundle_system__cross_supplier_disclosure');
|
|
95
|
+
if (!(setting.default || '').includes('{n}')) {
|
|
96
|
+
log('warnings', 'settings-contract', 'cross_supplier_disclosure default lacks {n} placeholder');
|
|
97
|
+
} else {
|
|
98
|
+
log('info', 'settings-contract', 'cross_supplier_disclosure wired with {n} placeholder');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
log('errors', 'settings-contract', `settings.json invalid JSON: ${e.message}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
console.log('');
|
|
106
|
+
|
|
107
|
+
// 4. store-bundle-audit (deferred until BS-15)
|
|
108
|
+
console.log('4/4 store-bundle-audit');
|
|
109
|
+
log('info', 'store-bundle-audit',
|
|
110
|
+
'deferred until BS-15. Will read each bundle product via Shopify Admin ' +
|
|
111
|
+
'GraphQL, count distinct Carro suppliers via product.metafields, and ' +
|
|
112
|
+
'flag mismatches with bundle_cross_supplier / bundle_supplier_count.');
|
|
113
|
+
console.log('');
|
|
114
|
+
|
|
115
|
+
console.log(`summary: ${findings.errors.length} errors, ${findings.warnings.length} warnings, ${findings.info.length} info`);
|
|
116
|
+
return findings;
|
|
117
|
+
}
|
package/lib/qa.js
CHANGED
|
@@ -10,16 +10,19 @@ import { loadConfig, loadModuleManifest, resolveVariant } from './config-loader.
|
|
|
10
10
|
if the theme cannot be inspected at all (no manifest, no config).
|
|
11
11
|
|
|
12
12
|
Stages:
|
|
13
|
-
1. validate-config
|
|
14
|
-
2. doctor
|
|
15
|
-
3. template-integrity
|
|
16
|
-
4. orphan-assets
|
|
17
|
-
5.
|
|
13
|
+
1. validate-config runwell.config.json shape + module references
|
|
14
|
+
2. doctor drift between runwell-manifest and synced files
|
|
15
|
+
3. template-integrity templates/*.json sections referenced in order
|
|
16
|
+
4. orphan-assets runwell-* files with no matching enabled module
|
|
17
|
+
5. brand-fields {{brand.X}} references defined in tenant config
|
|
18
|
+
6. shared-token-impact shared-token VALUE diffs between synced + toolkit-current
|
|
19
|
+
cross-checked against tenant brand CSS coverage
|
|
20
|
+
7. theme-check shopify CLI theme-check (lint), if available
|
|
18
21
|
|
|
19
22
|
Caller can skip stages with --skip <name,name>.
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
|
-
const STAGES = ['validate-config', 'doctor', 'template-integrity', 'orphan-assets', 'brand-fields', 'theme-check'];
|
|
25
|
+
const STAGES = ['validate-config', 'doctor', 'template-integrity', 'orphan-assets', 'brand-fields', 'shared-token-impact', 'theme-check'];
|
|
23
26
|
|
|
24
27
|
/* Walk the toolkit modules tree and collect every {{brand.X}} reference.
|
|
25
28
|
Returns the set of unique X identifiers (e.g. 'primary', 'rain-forrest',
|
|
@@ -43,6 +46,129 @@ function collectBrandFieldReferences(modulesDir) {
|
|
|
43
46
|
return refs;
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/* Parse a CSS file for top-level :root --runwell-X declarations.
|
|
50
|
+
Returns Map<token-name, value-string>. Whitespace-collapsed values so
|
|
51
|
+
identical sizes match regardless of formatting.
|
|
52
|
+
|
|
53
|
+
Brace-balanced block extraction. Naive regex (`/:root\s*\{(.*?)\}/`)
|
|
54
|
+
breaks on `{{brand.X}}` Liquid placeholders inside the root block:
|
|
55
|
+
the `}}` terminates the lazy match prematurely. This walker counts
|
|
56
|
+
braces and stops at the actual matching close. */
|
|
57
|
+
function parseRunwellTokens(filePath) {
|
|
58
|
+
const out = new Map();
|
|
59
|
+
if (!fs.existsSync(filePath)) return out;
|
|
60
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
61
|
+
const rootStart = content.search(/:root\s*\{/);
|
|
62
|
+
if (rootStart < 0) return out;
|
|
63
|
+
const openIdx = content.indexOf('{', rootStart);
|
|
64
|
+
let depth = 0;
|
|
65
|
+
let blockEnd = -1;
|
|
66
|
+
for (let i = openIdx; i < content.length; i++) {
|
|
67
|
+
const c = content[i];
|
|
68
|
+
if (c === '{') depth++;
|
|
69
|
+
else if (c === '}') {
|
|
70
|
+
depth--;
|
|
71
|
+
if (depth === 0) { blockEnd = i; break; }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (blockEnd < 0) return out;
|
|
75
|
+
const block = content.slice(openIdx + 1, blockEnd);
|
|
76
|
+
// Strip Liquid placeholders so they don't break the value matcher
|
|
77
|
+
// (we treat the placeholder as the literal value; comparison logic
|
|
78
|
+
// upstream skips entries whose toolkit-current value starts with '{{').
|
|
79
|
+
const decl = /(--runwell-[a-z0-9-]+)\s*:\s*([^;]+);/gi;
|
|
80
|
+
let m;
|
|
81
|
+
while ((m = decl.exec(block)) !== null) {
|
|
82
|
+
out.set(m[1], m[2].replace(/\s+/g, ' ').trim());
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Read tenant brand CSS files (if any) and collect every --runwell-X token
|
|
88
|
+
the tenant overrides at any cascade scope. Used to determine whether a
|
|
89
|
+
shared-token value change will reach the storefront or be masked. */
|
|
90
|
+
function collectTenantTokenOverrides(targetDir, clientSlug) {
|
|
91
|
+
const overrides = new Set();
|
|
92
|
+
const candidates = [
|
|
93
|
+
path.join(targetDir, 'assets', `${clientSlug}-brand.css`),
|
|
94
|
+
path.join(targetDir, 'assets', 'tenant-brand.css'),
|
|
95
|
+
];
|
|
96
|
+
for (const file of candidates) {
|
|
97
|
+
if (!fs.existsSync(file)) continue;
|
|
98
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
99
|
+
const decl = /(--runwell-[a-z0-9-]+)\s*:/gi;
|
|
100
|
+
let m;
|
|
101
|
+
while ((m = decl.exec(content)) !== null) overrides.add(m[1]);
|
|
102
|
+
}
|
|
103
|
+
return overrides;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function runSharedTokenImpactProbe(targetDir, toolkitRoot, log) {
|
|
107
|
+
const sharedFiles = [
|
|
108
|
+
{
|
|
109
|
+
synced: path.join(targetDir, 'assets', 'runwell-tokens.css'),
|
|
110
|
+
current: path.join(toolkitRoot, 'modules', '_shared', 'css-tokens', 'assets', 'runwell-tokens.css'),
|
|
111
|
+
label: 'tokens',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
synced: path.join(targetDir, 'assets', 'runwell-typography.css'),
|
|
115
|
+
current: path.join(toolkitRoot, 'modules', '_shared', 'css-typography', 'assets', 'runwell-typography.css'),
|
|
116
|
+
label: 'typography',
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
let clientSlug = '';
|
|
121
|
+
try {
|
|
122
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(targetDir, 'runwell.config.json'), 'utf8'));
|
|
123
|
+
clientSlug = cfg.client || '';
|
|
124
|
+
} catch { /* no config; probe still runs against synced vs current */ }
|
|
125
|
+
|
|
126
|
+
const tenantOverrides = clientSlug ? collectTenantTokenOverrides(targetDir, clientSlug) : new Set();
|
|
127
|
+
let totalDiffs = 0;
|
|
128
|
+
let exposedDiffs = 0;
|
|
129
|
+
|
|
130
|
+
for (const { synced, current, label } of sharedFiles) {
|
|
131
|
+
if (!fs.existsSync(synced)) {
|
|
132
|
+
log('warnings', 'shared-token-impact', `${label}: ${path.basename(synced)} missing in synced theme; cannot compare`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!fs.existsSync(current)) {
|
|
136
|
+
log('warnings', 'shared-token-impact', `${label}: toolkit-current source not found at ${current}; cannot compare`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const oldTokens = parseRunwellTokens(synced);
|
|
140
|
+
const newTokens = parseRunwellTokens(current);
|
|
141
|
+
const allKeys = new Set([...oldTokens.keys(), ...newTokens.keys()]);
|
|
142
|
+
for (const key of allKeys) {
|
|
143
|
+
const a = oldTokens.get(key);
|
|
144
|
+
const b = newTokens.get(key);
|
|
145
|
+
// Skip Liquid-interpolated tokens: brand colors get resolved from
|
|
146
|
+
// runwell.config.json during sync, so the toolkit-current placeholder
|
|
147
|
+
// is not directly comparable to the synced hex value. Only flag
|
|
148
|
+
// design-system tokens that ship with literal values.
|
|
149
|
+
if (b && b.includes('{{')) continue;
|
|
150
|
+
if (a && a.includes('{{')) continue;
|
|
151
|
+
if (a === b) continue;
|
|
152
|
+
totalDiffs++;
|
|
153
|
+
const insulated = tenantOverrides.has(key);
|
|
154
|
+
const verdict = insulated ? 'insulated' : 'EXPOSED';
|
|
155
|
+
const detail = a == null
|
|
156
|
+
? `new token (will be added: ${b})`
|
|
157
|
+
: b == null
|
|
158
|
+
? `token removed (was: ${a})`
|
|
159
|
+
: `${a} -> ${b}`;
|
|
160
|
+
const level = insulated ? 'info' : 'warnings';
|
|
161
|
+
if (!insulated) exposedDiffs++;
|
|
162
|
+
log(level, 'shared-token-impact', `${key}: ${detail} [${verdict}${insulated ? ' by tenant brand CSS' : '; tenant brand CSS does not override this token'}]`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (totalDiffs === 0) {
|
|
166
|
+
log('info', 'shared-token-impact', 'no shared-token value drift between synced theme and toolkit-current');
|
|
167
|
+
} else {
|
|
168
|
+
log('info', 'shared-token-impact', `${totalDiffs} shared tokens differ between synced and toolkit-current; ${exposedDiffs} would surface visually on next sync (no tenant override).`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
46
172
|
export async function qa(flags) {
|
|
47
173
|
const targetDir = path.resolve(flags.target || process.cwd());
|
|
48
174
|
const skip = new Set((flags.skip || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
@@ -59,7 +185,7 @@ export async function qa(flags) {
|
|
|
59
185
|
|
|
60
186
|
// Stage 1: validate-config
|
|
61
187
|
if (!skip.has('validate-config')) {
|
|
62
|
-
console.log('1/
|
|
188
|
+
console.log('1/7 validate-config');
|
|
63
189
|
try {
|
|
64
190
|
const { config } = loadConfig(flags.config || './runwell.config.json');
|
|
65
191
|
if (!config.client) log('errors', 'validate-config', 'missing required field "client"');
|
|
@@ -82,7 +208,7 @@ export async function qa(flags) {
|
|
|
82
208
|
|
|
83
209
|
// Stage 2: doctor (manifest drift)
|
|
84
210
|
if (!skip.has('doctor')) {
|
|
85
|
-
console.log('2/
|
|
211
|
+
console.log('2/7 doctor (manifest drift)');
|
|
86
212
|
const manifestPath = path.join(targetDir, 'runwell-manifest.json');
|
|
87
213
|
if (!fs.existsSync(manifestPath)) {
|
|
88
214
|
log('errors', 'doctor', 'no runwell-manifest.json (run "runwell-shopify sync" first)');
|
|
@@ -103,7 +229,7 @@ export async function qa(flags) {
|
|
|
103
229
|
|
|
104
230
|
// Stage 3: template-integrity (every section in templates/*.json must exist)
|
|
105
231
|
if (!skip.has('template-integrity')) {
|
|
106
|
-
console.log('3/
|
|
232
|
+
console.log('3/7 template-integrity');
|
|
107
233
|
const tmplDir = path.join(targetDir, 'templates');
|
|
108
234
|
if (fs.existsSync(tmplDir)) {
|
|
109
235
|
const jsons = fs.readdirSync(tmplDir).filter(f => f.endsWith('.json'));
|
|
@@ -146,7 +272,7 @@ export async function qa(flags) {
|
|
|
146
272
|
|
|
147
273
|
// Stage 4: orphan-assets (runwell-* files in theme that aren't synced from any enabled module)
|
|
148
274
|
if (!skip.has('orphan-assets')) {
|
|
149
|
-
console.log('4/
|
|
275
|
+
console.log('4/7 orphan-assets');
|
|
150
276
|
const manifestPath = path.join(targetDir, 'runwell-manifest.json');
|
|
151
277
|
if (fs.existsSync(manifestPath)) {
|
|
152
278
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
@@ -172,7 +298,7 @@ export async function qa(flags) {
|
|
|
172
298
|
|
|
173
299
|
// Stage 5: brand-fields (every {{brand.X}} reference in toolkit modules must be defined in tenant config.brand)
|
|
174
300
|
if (!skip.has('brand-fields')) {
|
|
175
|
-
console.log('5/
|
|
301
|
+
console.log('5/7 brand-fields');
|
|
176
302
|
try {
|
|
177
303
|
const { config } = loadConfig(flags.config || './runwell.config.json');
|
|
178
304
|
const brand = config.brand || {};
|
|
@@ -199,9 +325,17 @@ export async function qa(flags) {
|
|
|
199
325
|
console.log('');
|
|
200
326
|
}
|
|
201
327
|
|
|
202
|
-
// Stage 6:
|
|
328
|
+
// Stage 6: shared-token-impact (compare synced shared tokens vs toolkit-current;
|
|
329
|
+
// surface any value diffs and whether the tenant brand CSS overrides them)
|
|
330
|
+
if (!skip.has('shared-token-impact')) {
|
|
331
|
+
console.log('6/7 shared-token-impact');
|
|
332
|
+
runSharedTokenImpactProbe(targetDir, flags.toolkitRoot, log);
|
|
333
|
+
console.log('');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Stage 7: theme-check via Shopify CLI (parse --output json for accuracy)
|
|
203
337
|
if (!skip.has('theme-check')) {
|
|
204
|
-
console.log('
|
|
338
|
+
console.log('7/7 theme-check');
|
|
205
339
|
await new Promise(resolve => {
|
|
206
340
|
const proc = spawn('shopify', ['theme', 'check', '--path', targetDir, '--output', 'json'], {
|
|
207
341
|
stdio: ['ignore', 'pipe', 'pipe']
|