@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 +17 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/bin/promo-kit.mjs +150 -0
- package/index.mjs +9 -0
- package/kit.config.example.json +19 -0
- package/package.json +45 -0
- package/scripts/apply-control-patch.mjs +205 -0
- package/scripts/apply-submission-status.mjs +225 -0
- package/scripts/gen-baseline.mjs +402 -0
- package/scripts/gen-decision-drift.mjs +253 -0
- package/scripts/gen-experiment-decisions.mjs +282 -0
- package/scripts/gen-feedback-summary.mjs +278 -0
- package/scripts/gen-promo-decisions.mjs +507 -0
- package/scripts/gen-queue-health.mjs +223 -0
- package/scripts/gen-recommendation-patch.mjs +352 -0
- package/scripts/gen-recommendations.mjs +409 -0
- package/scripts/gen-telemetry-aggregate.mjs +266 -0
- package/scripts/gen-trust-receipt.mjs +184 -0
- package/scripts/kit-bootstrap.mjs +246 -0
- package/scripts/kit-migrate.mjs +111 -0
- package/scripts/kit-selftest.mjs +207 -0
- package/scripts/lib/config.mjs +124 -0
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,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
|
+
}
|