@mcptoolshop/promo-kit 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-02-16
4
+
5
+ Initial release of the portable promotion engine.
6
+
7
+ ### Included
8
+
9
+ - `promo-kit init` — bootstrap 17 zero-state seed files (auto-creates kit.config.json)
10
+ - `promo-kit selftest` — validate config, seeds, and dry-runs
11
+ - `promo-kit migrate` — apply schema version upgrades
12
+ - `promo-kit --print-config` — show resolved config after defaults
13
+ - Programmatic API: `bootstrap()`, `migrate()`, `getConfig()`, `getRoot()`, `loadKitConfig()`
14
+ - 10 portable core generators (promo-decisions, experiment-decisions, baseline, feedback-summary, queue-health, telemetry-aggregate, recommendations, recommendation-patch, decision-drift, trust-receipt)
15
+ - 2 apply scripts (control-patch, submission-status)
16
+ - Example config template (`kit.config.example.json`)
17
+ - Zero runtime dependencies
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mcp-tool-shop
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # @mcptoolshop/promo-kit
2
+
3
+ Portable promotion engine for tool catalogs. Receipt-backed promotions, freeze modes, drift detection — zero dependencies.
4
+
5
+ ## What it does
6
+
7
+ `promo-kit` gives your tool catalog a complete promotion pipeline:
8
+
9
+ - **Bootstrap** 17 zero-state seed files (governance, promo queue, experiments, etc.)
10
+ - **Generate** promotion decisions, baselines, drift reports, and trust receipts
11
+ - **Verify** everything with SHA-256 hashed inputs and commit SHAs
12
+ - **Freeze** automation when you need human review
13
+
14
+ All data stays local. No cloud dependencies. No runtime npm dependencies.
15
+
16
+ ## Quickstart
17
+
18
+ ```bash
19
+ npm install @mcptoolshop/promo-kit
20
+
21
+ # Initialize (auto-creates kit.config.json from template)
22
+ npx promo-kit init
23
+
24
+ # Edit kit.config.json with your org details:
25
+ # org.name, org.account, site.title, contact.email
26
+
27
+ # Validate the installation
28
+ npx promo-kit selftest
29
+ ```
30
+
31
+ ## CLI Commands
32
+
33
+ ### `promo-kit init`
34
+
35
+ Creates `kit.config.json` (if absent) and bootstraps 17 seed files in your data directory.
36
+
37
+ ```bash
38
+ promo-kit init # create config + seeds
39
+ promo-kit init --dry-run # show what would be created
40
+ promo-kit init --force # overwrite existing kit.config.json
41
+ ```
42
+
43
+ ### `promo-kit selftest`
44
+
45
+ Validates config, seed files, and runs all portable core generators in dry-run mode.
46
+
47
+ ```bash
48
+ promo-kit selftest
49
+ ```
50
+
51
+ ### `promo-kit migrate`
52
+
53
+ Applies schema version upgrades when `kitVersion` changes.
54
+
55
+ ```bash
56
+ promo-kit migrate
57
+ ```
58
+
59
+ ### Flags
60
+
61
+ ```bash
62
+ promo-kit --version # show version
63
+ promo-kit --help # show usage
64
+ promo-kit --print-config # show resolved config after defaults
65
+ ```
66
+
67
+ ## Programmatic API
68
+
69
+ ```js
70
+ import { bootstrap, migrate, getConfig, getRoot, loadKitConfig } from "@mcptoolshop/promo-kit";
71
+
72
+ // Bootstrap seed files in a directory
73
+ const result = bootstrap("/path/to/your/project");
74
+ // => { success: true, errors: [], created: [...], skipped: [...] }
75
+
76
+ // Get resolved config (deep-merged with defaults)
77
+ const config = getConfig();
78
+
79
+ // Run migrations
80
+ const migrationResult = migrate("/path/to/your/project");
81
+ ```
82
+
83
+ Config utilities are also available as a separate export:
84
+
85
+ ```js
86
+ import { getConfig, getRoot } from "@mcptoolshop/promo-kit/config";
87
+ ```
88
+
89
+ ## What it generates
90
+
91
+ `promo-kit init` creates these seed files in your data directory:
92
+
93
+ | File | Purpose |
94
+ |------|---------|
95
+ | `governance.json` | Freeze modes, promo caps, hard rules |
96
+ | `promo-queue.json` | Weekly promotion candidates |
97
+ | `experiments.json` | Active A/B experiments |
98
+ | `submissions.json` | External tool submissions |
99
+ | `overrides.json` | Per-tool metadata overrides |
100
+ | `ops-history.json` | Workflow run history |
101
+ | `feedback.jsonl` | Append-only feedback log |
102
+ | `worthy.json` | Worthiness rubric and scores |
103
+ | `promo-decisions.json` | Generated promotion decisions |
104
+ | `experiment-decisions.json` | Generated experiment decisions |
105
+ | `baseline.json` | Computed baseline metrics |
106
+ | `feedback-summary.json` | Aggregated feedback |
107
+ | `queue-health.json` | Queue health metrics |
108
+ | `recommendations.json` | Advisory recommendations |
109
+ | `recommendation-patch.json` | Recommended data patches |
110
+ | `decision-drift.json` | Week-over-week drift report |
111
+ | `telemetry/rollup.json` | Telemetry aggregates |
112
+
113
+ ## Environment
114
+
115
+ | Variable | Purpose |
116
+ |----------|---------|
117
+ | `KIT_CONFIG` | Path to an alternate `kit.config.json` (overrides cwd discovery) |
118
+
119
+ ## Requirements
120
+
121
+ - Node.js >= 22
122
+ - Zero runtime dependencies
123
+
124
+ ## Links
125
+
126
+ - [Portable Core docs](https://github.com/mcp-tool-shop/mcp-tool-shop/blob/main/docs/portable-core.md) — full contract and field reference
127
+ - [Presskit Handbook](https://github.com/mcp-tool-shop/mcp-tool-shop/blob/main/docs/presskit-handbook.md) — brand assets and verification walkthrough
128
+ - [Trust Center](https://mcp-tool-shop.github.io/trust/) — live verification infrastructure
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * promo-kit CLI
5
+ *
6
+ * Portable promotion engine for tool catalogs.
7
+ *
8
+ * Usage:
9
+ * promo-kit init [--dry-run] [--force]
10
+ * promo-kit selftest [--skip-build] [--skip-invariants]
11
+ * promo-kit migrate
12
+ * promo-kit --print-config
13
+ * promo-kit --version
14
+ * promo-kit --help
15
+ */
16
+
17
+ import { existsSync, copyFileSync, readFileSync } from "node:fs";
18
+ import { resolve, join, dirname } from "node:path";
19
+ import { fork } from "node:child_process";
20
+ import { fileURLToPath, pathToFileURL } from "node:url";
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const PKG_ROOT = resolve(__dirname, "..");
24
+ const SCRIPTS = join(PKG_ROOT, "scripts");
25
+
26
+ const args = process.argv.slice(2);
27
+ const command = args[0];
28
+
29
+ // ── --version ───────────────────────────────────────────────
30
+
31
+ if (args.includes("--version") || args.includes("-v")) {
32
+ const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
33
+ console.log(`${pkg.name} v${pkg.version}`);
34
+ process.exit(0);
35
+ }
36
+
37
+ // ── --help ──────────────────────────────────────────────────
38
+
39
+ if (!command || args.includes("--help") || args.includes("-h")) {
40
+ console.log(`
41
+ @mcptoolshop/promo-kit — Portable promotion engine for tool catalogs
42
+
43
+ Usage:
44
+ promo-kit init [--dry-run] [--force] Bootstrap seed files (auto-creates kit.config.json)
45
+ promo-kit selftest Validate config, seeds, and dry-runs
46
+ promo-kit migrate Apply schema version upgrades
47
+ promo-kit --print-config Show resolved config after defaults
48
+ promo-kit --version Show version
49
+ promo-kit --help Show this help
50
+
51
+ Environment:
52
+ KIT_CONFIG=/path/to/kit.config.json Point at an alternate config root
53
+ `.trimEnd());
54
+ process.exit(0);
55
+ }
56
+
57
+ // ── Config resolution ───────────────────────────────────────
58
+
59
+ const cwdConfig = resolve(process.cwd(), "kit.config.json");
60
+
61
+ function resolveConfig() {
62
+ if (process.env.KIT_CONFIG) return; // already set
63
+ if (existsSync(cwdConfig)) {
64
+ process.env.KIT_CONFIG = cwdConfig;
65
+ }
66
+ }
67
+
68
+ // ── --print-config ──────────────────────────────────────────
69
+
70
+ if (args.includes("--print-config")) {
71
+ resolveConfig();
72
+ const { getConfig, getRoot } = await import(pathToFileURL(join(SCRIPTS, "lib", "config.mjs")).href);
73
+ console.log("Root:", getRoot());
74
+ console.log(JSON.stringify(getConfig(), null, 2));
75
+ process.exit(0);
76
+ }
77
+
78
+ // ── init ────────────────────────────────────────────────────
79
+
80
+ if (command === "init") {
81
+ const dryRun = args.includes("--dry-run");
82
+ const force = args.includes("--force");
83
+
84
+ // Auto-create kit.config.json if absent
85
+ if (!process.env.KIT_CONFIG && !existsSync(cwdConfig)) {
86
+ const exampleConfig = join(PKG_ROOT, "kit.config.example.json");
87
+ if (dryRun) {
88
+ console.log("[dry-run] Would create kit.config.json from template");
89
+ console.log("[dry-run] Would bootstrap seed files in current directory");
90
+ process.exit(0);
91
+ }
92
+ copyFileSync(exampleConfig, cwdConfig);
93
+ console.log("Created kit.config.json from template.");
94
+ console.log(" Edit these fields: org.name, org.account, site.title, contact.email\n");
95
+ process.env.KIT_CONFIG = cwdConfig;
96
+ } else if (process.env.KIT_CONFIG) {
97
+ // KIT_CONFIG already set, use it
98
+ } else if (existsSync(cwdConfig)) {
99
+ if (!force) {
100
+ console.log("kit.config.json already exists (use --force to overwrite).");
101
+ } else {
102
+ const exampleConfig = join(PKG_ROOT, "kit.config.example.json");
103
+ copyFileSync(exampleConfig, cwdConfig);
104
+ console.log("Overwrote kit.config.json from template.");
105
+ }
106
+ process.env.KIT_CONFIG = cwdConfig;
107
+ }
108
+
109
+ const child = fork(join(SCRIPTS, "kit-bootstrap.mjs"), [], {
110
+ env: { ...process.env },
111
+ stdio: "inherit",
112
+ });
113
+ child.on("exit", (code) => {
114
+ if (code === 0) {
115
+ console.log("\nNext: promo-kit selftest");
116
+ }
117
+ process.exit(code);
118
+ });
119
+ }
120
+
121
+ // ── selftest ────────────────────────────────────────────────
122
+
123
+ else if (command === "selftest") {
124
+ resolveConfig();
125
+ const childArgs = args.slice(1); // forward flags like --skip-build
126
+ const child = fork(join(SCRIPTS, "kit-selftest.mjs"), childArgs, {
127
+ env: { ...process.env },
128
+ stdio: "inherit",
129
+ });
130
+ child.on("exit", (code) => process.exit(code));
131
+ }
132
+
133
+ // ── migrate ─────────────────────────────────────────────────
134
+
135
+ else if (command === "migrate") {
136
+ resolveConfig();
137
+ const child = fork(join(SCRIPTS, "kit-migrate.mjs"), [], {
138
+ env: { ...process.env },
139
+ stdio: "inherit",
140
+ });
141
+ child.on("exit", (code) => process.exit(code));
142
+ }
143
+
144
+ // ── unknown command ─────────────────────────────────────────
145
+
146
+ else {
147
+ console.error(`Unknown command: ${command}`);
148
+ console.error("Run promo-kit --help for usage.");
149
+ process.exit(1);
150
+ }
package/index.mjs ADDED
@@ -0,0 +1,9 @@
1
+ export { bootstrap } from "./scripts/kit-bootstrap.mjs";
2
+ export { migrate } from "./scripts/kit-migrate.mjs";
3
+ export {
4
+ getConfig,
5
+ getRoot,
6
+ loadKitConfig,
7
+ resetConfigCache,
8
+ KIT_VERSION_SUPPORTED,
9
+ } from "./scripts/lib/config.mjs";
@@ -0,0 +1,19 @@
1
+ {
2
+ "kitVersion": 1,
3
+ "org": {
4
+ "name": "your-org",
5
+ "account": "your-github-account",
6
+ "url": "https://github.com/your-org"
7
+ },
8
+ "site": {
9
+ "title": "Your Tool Catalog",
10
+ "url": "https://your-org.github.io",
11
+ "description": "Your catalog description."
12
+ },
13
+ "repo": {
14
+ "marketing": "your-account/your-repo"
15
+ },
16
+ "contact": {
17
+ "email": "team@example.com"
18
+ }
19
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@mcptoolshop/promo-kit",
3
+ "version": "0.1.0",
4
+ "description": "Portable promotion engine for tool catalogs. Receipt-backed promotions, freeze modes, drift detection.",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs",
9
+ "./config": "./scripts/lib/config.mjs"
10
+ },
11
+ "bin": {
12
+ "promo-kit": "./bin/promo-kit.mjs"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "scripts/",
17
+ "index.mjs",
18
+ "kit.config.example.json",
19
+ "README.md",
20
+ "LICENSE",
21
+ "CHANGELOG.md"
22
+ ],
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/mcp-tool-shop/mcp-tool-shop.git",
29
+ "directory": "packages/promo-kit"
30
+ },
31
+ "homepage": "https://github.com/mcp-tool-shop/mcp-tool-shop/tree/main/packages/promo-kit",
32
+ "bugs": {
33
+ "url": "https://github.com/mcp-tool-shop/mcp-tool-shop/issues"
34
+ },
35
+ "keywords": [
36
+ "mcp",
37
+ "tools",
38
+ "promotion",
39
+ "catalog",
40
+ "receipts",
41
+ "verification"
42
+ ],
43
+ "author": "mcp-tool-shop <64996768+mcp-tool-shop@users.noreply.github.com>",
44
+ "license": "MIT"
45
+ }
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Apply Control Patch
5
+ *
6
+ * Validates and applies a JSON patch to governance and promo data files.
7
+ * Called from the apply-control-patch workflow.
8
+ *
9
+ * Usage:
10
+ * node scripts/apply-control-patch.mjs '{"governance.json":{"decisionsFrozen":true}}'
11
+ */
12
+
13
+ import { readFileSync, writeFileSync } from "node:fs";
14
+ import { resolve, join } from "node:path";
15
+ import { getConfig, getRoot } from "./lib/config.mjs";
16
+
17
+ const ROOT = getRoot();
18
+ const config = getConfig();
19
+ const DATA_DIR = join(ROOT, config.paths.dataDir);
20
+
21
+ // ── Allowed files and field validators ───────────────────────
22
+
23
+ const ALLOWED_FILES = new Set([
24
+ "governance.json",
25
+ "promo.json",
26
+ "promo-queue.json",
27
+ "experiments.json",
28
+ ]);
29
+
30
+ const PROTECTED_FIELDS = new Set(["schemaVersion", "hardRules"]);
31
+
32
+ const GOVERNANCE_VALIDATORS = {
33
+ decisionsFrozen: (v) => typeof v === "boolean",
34
+ experimentsFrozen: (v) => typeof v === "boolean",
35
+ maxPromosPerWeek: (v) => Number.isInteger(v) && v > 0 && v <= 20,
36
+ cooldownDaysPerSlug: (v) => Number.isInteger(v) && v > 0 && v <= 90,
37
+ cooldownDaysPerPartner: (v) => Number.isInteger(v) && v > 0 && v <= 90,
38
+ minCoverageScore: (v) => typeof v === "number" && v >= 0 && v <= 100,
39
+ minExperimentDataThreshold: (v) => Number.isInteger(v) && v > 0 && v <= 1000,
40
+ };
41
+
42
+ const PROMO_VALIDATORS = {
43
+ enabled: (v) => typeof v === "boolean",
44
+ learningMode: (v) => ["off", "shadow", "active"].includes(v),
45
+ };
46
+
47
+ const FILE_VALIDATORS = {
48
+ "governance.json": GOVERNANCE_VALIDATORS,
49
+ "promo.json": PROMO_VALIDATORS,
50
+ };
51
+
52
+ // ── Risk notes ───────────────────────────────────────────────
53
+
54
+ const RISK_NOTES = {
55
+ "governance.json": {
56
+ decisionsFrozen: (v) => v === true ? "Decisions will NOT update until unfrozen" : "Decisions will resume updating",
57
+ experimentsFrozen: (v) => v === true ? "Experiments will NOT update until unfrozen" : "Experiments will resume updating",
58
+ maxPromosPerWeek: (v) => `Max promos per week changed to ${v} — affects budget allocation`,
59
+ cooldownDaysPerSlug: (v) => `Slug cooldown changed to ${v} days`,
60
+ cooldownDaysPerPartner: (v) => `Partner cooldown changed to ${v} days`,
61
+ minCoverageScore: (v) => `Coverage threshold changed to ${v}`,
62
+ minExperimentDataThreshold: (v) => `Experiment data threshold changed to ${v}`,
63
+ },
64
+ "promo.json": {
65
+ enabled: (v) => v ? "Promotion ENABLED — outreach will run" : "Promotion DISABLED — no outreach",
66
+ learningMode: (v) => `Learning mode set to "${v}"`,
67
+ },
68
+ };
69
+
70
+ // ── Core functions (exported for testing) ────────────────────
71
+
72
+ /**
73
+ * Validate a patch object.
74
+ * @param {Record<string, Record<string, unknown>>} patch
75
+ * @returns {{ valid: boolean, errors: string[] }}
76
+ */
77
+ export function validatePatch(patch) {
78
+ const errors = [];
79
+
80
+ if (!patch || typeof patch !== "object") {
81
+ return { valid: false, errors: ["Patch must be a JSON object"] };
82
+ }
83
+
84
+ for (const [file, fields] of Object.entries(patch)) {
85
+ if (!ALLOWED_FILES.has(file)) {
86
+ errors.push(`File "${file}" is not in the allowed list: ${[...ALLOWED_FILES].join(", ")}`);
87
+ continue;
88
+ }
89
+
90
+ if (!fields || typeof fields !== "object") {
91
+ errors.push(`Fields for "${file}" must be an object`);
92
+ continue;
93
+ }
94
+
95
+ for (const [field, value] of Object.entries(fields)) {
96
+ if (PROTECTED_FIELDS.has(field)) {
97
+ errors.push(`Field "${field}" in "${file}" is protected and cannot be patched`);
98
+ continue;
99
+ }
100
+
101
+ const validators = FILE_VALIDATORS[file];
102
+ if (validators && validators[field]) {
103
+ if (!validators[field](value)) {
104
+ errors.push(`Invalid value for "${file}".${field}: ${JSON.stringify(value)}`);
105
+ }
106
+ }
107
+ // Files without validators (promo-queue.json, experiments.json) allow any non-protected fields
108
+ }
109
+ }
110
+
111
+ return { valid: errors.length === 0, errors };
112
+ }
113
+
114
+ /**
115
+ * Apply a validated patch to data files.
116
+ * @param {Record<string, Record<string, unknown>>} patch
117
+ * @param {{ dataDir?: string }} opts
118
+ * @returns {{ applied: string[], riskNotes: string[] }}
119
+ */
120
+ export function applyPatch(patch, opts = {}) {
121
+ const { dataDir = DATA_DIR } = opts;
122
+ const applied = [];
123
+ const riskNotes = [];
124
+
125
+ for (const [file, fields] of Object.entries(patch)) {
126
+ const filePath = join(dataDir, file);
127
+ let current = {};
128
+ try {
129
+ current = JSON.parse(readFileSync(filePath, "utf8"));
130
+ } catch { /* start fresh if missing */ }
131
+
132
+ // Merge fields
133
+ const updated = { ...current, ...fields };
134
+ writeFileSync(filePath, JSON.stringify(updated, null, 2) + "\n", "utf8");
135
+ applied.push(file);
136
+
137
+ // Generate risk notes
138
+ const noteGenerators = RISK_NOTES[file] || {};
139
+ for (const [field, value] of Object.entries(fields)) {
140
+ if (noteGenerators[field]) {
141
+ riskNotes.push(noteGenerators[field](value));
142
+ }
143
+ }
144
+ }
145
+
146
+ return { applied, riskNotes };
147
+ }
148
+
149
+ /**
150
+ * Full pipeline: parse, validate, apply.
151
+ * @param {string} patchJson - JSON string from CLI arg
152
+ * @param {{ dataDir?: string, riskNotesPath?: string }} opts
153
+ */
154
+ export function applyControlPatch(patchJson, opts = {}) {
155
+ const { dataDir = DATA_DIR, riskNotesPath = "/tmp/patch-risk-notes.txt" } = opts;
156
+
157
+ let patch;
158
+ try {
159
+ patch = JSON.parse(patchJson);
160
+ } catch (e) {
161
+ console.error(` Error: Invalid JSON — ${e.message}`);
162
+ process.exitCode = 1;
163
+ return { success: false, error: "Invalid JSON" };
164
+ }
165
+
166
+ const validation = validatePatch(patch);
167
+ if (!validation.valid) {
168
+ console.error(" Validation errors:");
169
+ for (const err of validation.errors) {
170
+ console.error(` - ${err}`);
171
+ }
172
+ process.exitCode = 1;
173
+ return { success: false, errors: validation.errors };
174
+ }
175
+
176
+ const result = applyPatch(patch, { dataDir });
177
+
178
+ console.log(` Applied patch to: ${result.applied.join(", ")}`);
179
+ if (result.riskNotes.length > 0) {
180
+ console.log(" Risk notes:");
181
+ for (const note of result.riskNotes) {
182
+ console.log(` - ${note}`);
183
+ }
184
+ // Write risk notes file for workflow
185
+ try {
186
+ writeFileSync(riskNotesPath, result.riskNotes.join("\n") + "\n", "utf8");
187
+ } catch { /* fail soft in non-CI env */ }
188
+ }
189
+
190
+ return { success: true, ...result };
191
+ }
192
+
193
+ // ── Entry point ──────────────────────────────────────────────
194
+
195
+ const isMain = process.argv[1] && resolve(process.argv[1]).endsWith("apply-control-patch.mjs");
196
+ if (isMain) {
197
+ const patchJson = process.argv[2];
198
+ if (!patchJson) {
199
+ console.error("Usage: node scripts/apply-control-patch.mjs '<patch-json>'");
200
+ process.exitCode = 1;
201
+ } else {
202
+ console.log("Applying control patch...");
203
+ applyControlPatch(patchJson);
204
+ }
205
+ }