baldart 4.40.0 → 4.42.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 +42 -0
- package/README.md +6 -2
- package/VERSION +1 -1
- package/framework/.claude/agents/REGISTRY.md +1 -1
- package/framework/.claude/agents/coder.md +4 -2
- package/framework/.claude/agents/qa-sentinel.md +10 -1
- package/framework/.claude/commands/qa.md +2 -0
- package/framework/.claude/skills/new/references/setup.md +19 -7
- package/framework/.claude/skills/toolchain-bootstrap/SKILL.md +127 -0
- package/framework/.claude/workflows/new-card-review.js +17 -2
- package/framework/.claude/workflows/new2.js +44 -3
- package/framework/agents/index.md +2 -0
- package/framework/agents/toolchain-protocol.md +80 -0
- package/framework/docs/TOOLCHAIN-LAYER.md +135 -0
- package/framework/scripts/validate-card-baseline.js +133 -3
- package/framework/templates/baldart.config.template.yml +40 -0
- package/framework/templates/ci/check-card-baseline.yml +0 -3
- package/package.json +1 -1
- package/src/commands/configure.js +81 -0
- package/src/commands/doctor.js +67 -0
- package/src/commands/update.js +12 -0
- package/src/utils/tool-currency.js +52 -0
- package/src/utils/toolchain-adapters/biome.js +92 -0
- package/src/utils/toolchain-adapters/eslint.js +39 -0
- package/src/utils/toolchain-adapters/husky.js +30 -0
- package/src/utils/toolchain-adapters/index.js +83 -0
- package/src/utils/toolchain-adapters/jest.js +34 -0
- package/src/utils/toolchain-adapters/lefthook.js +84 -0
- package/src/utils/toolchain-adapters/prettier.js +39 -0
- package/src/utils/toolchain-adapters/tsc.js +50 -0
- package/src/utils/toolchain-adapters/vitest.js +46 -0
- package/src/utils/toolchain-installer.js +233 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const UI = require('./ui');
|
|
5
|
+
const adapters = require('./toolchain-adapters');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* High-level installer/wrapper for the curated dev toolchain (Biome today;
|
|
9
|
+
* Vitest, tsc, Lefthook next), used by `baldart configure`, the
|
|
10
|
+
* `/toolchain-bootstrap` skill, and `doctor`.
|
|
11
|
+
*
|
|
12
|
+
* Design (since v4.41.0):
|
|
13
|
+
* - Curated, OPINIONATED defaults: the framework recommends the tools it
|
|
14
|
+
* considers best for a JS/TS project. "Opinionated but askable" — configure
|
|
15
|
+
* preselects, the user opts out.
|
|
16
|
+
* - NON-DESTRUCTIVE: `initConfigs` writes a config file only when absent;
|
|
17
|
+
* incumbent tools (ESLint/Prettier/…) are detected, never installed or
|
|
18
|
+
* removed. Flipping `features.has_toolchain` true→false does NOT uninstall.
|
|
19
|
+
* - NEVER silent in CI: the command layer only invokes `install` in the
|
|
20
|
+
* interactive `configure` path; `--non-interactive`/`--yes` writes the flag
|
|
21
|
+
* and `doctor` backfills.
|
|
22
|
+
* - All mutating ops return plain `{ ok|status, … }` objects and never throw,
|
|
23
|
+
* so the command layers stay thin and can render results / hints.
|
|
24
|
+
*
|
|
25
|
+
* Per-tool adapters under src/utils/toolchain-adapters/ describe HOW to install
|
|
26
|
+
* and verify each tool; this class composes them. The CONSUME side (agents
|
|
27
|
+
* running the gates) reads the resolved literal commands from
|
|
28
|
+
* `toolchain.commands.*` in baldart.config.yml — see
|
|
29
|
+
* framework/agents/toolchain-protocol.md.
|
|
30
|
+
*/
|
|
31
|
+
class ToolchainInstaller {
|
|
32
|
+
constructor(cwd = process.cwd()) { this.cwd = cwd; }
|
|
33
|
+
|
|
34
|
+
// ── Detection ───────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/** True iff this project has a package.json (required to install JS devDeps). */
|
|
37
|
+
hasPackageJson() {
|
|
38
|
+
return fs.existsSync(path.join(this.cwd, 'package.json'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Curated tools in scope for this project (e.g. ['biome'] on a JS/TS repo). */
|
|
42
|
+
inScope() { return adapters.detectAll(this.cwd); }
|
|
43
|
+
|
|
44
|
+
/** Incumbent tools already present: `[{ name, label, replacedBy }]`. */
|
|
45
|
+
detectIncumbents() { return adapters.detectIncumbents(this.cwd); }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Curated tools to install on the CLEAN path: in scope, not yet usable, and
|
|
49
|
+
* NOT blocked by an incumbent they would replace (a Jest/husky/ESLint project
|
|
50
|
+
* routes the corresponding tool to `migrations()` instead — never auto-installed
|
|
51
|
+
* over an existing setup). Never throws.
|
|
52
|
+
*/
|
|
53
|
+
recommend() {
|
|
54
|
+
const present = new Set(this.detectIncumbents().map((i) => i.name));
|
|
55
|
+
return this.inScope().filter((name) => {
|
|
56
|
+
if (this._toolUsable(name)) return false;
|
|
57
|
+
return !this._replacesOf(name).some((inc) => present.has(inc));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Curated tools in scope but BLOCKED by an incumbent they would replace.
|
|
63
|
+
* Returns `[{ tool, replaces:[incumbent names present] }]` — the migration
|
|
64
|
+
* proposal surface (manual, never automatic). Never throws.
|
|
65
|
+
*/
|
|
66
|
+
migrations() {
|
|
67
|
+
const present = new Set(this.detectIncumbents().map((i) => i.name));
|
|
68
|
+
return this.inScope()
|
|
69
|
+
.filter((name) => !this._toolUsable(name))
|
|
70
|
+
.map((name) => ({ tool: name, replaces: this._replacesOf(name).filter((inc) => present.has(inc)) }))
|
|
71
|
+
.filter((m) => m.replaces.length);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True iff at least one curated tool is already usable in this project. */
|
|
75
|
+
detect() {
|
|
76
|
+
return this.inScope().some((name) => this._toolUsable(name));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* In-scope curated tools that RESOLVE right now (devDep present). This is the
|
|
81
|
+
* set whose configs + gate commands are written into baldart.config.yml — a
|
|
82
|
+
* declined migration (incumbent kept) leaves its tool out, so the command
|
|
83
|
+
* stays empty and the consume side falls back. Never throws.
|
|
84
|
+
*/
|
|
85
|
+
activeTools() {
|
|
86
|
+
return this.inScope().filter((name) => this._toolUsable(name));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Install (devDeps) ─────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Install the given curated tools as project devDependencies. Returns
|
|
93
|
+
* `{ ok, installed:[], failed:[{tool,error}], skipped:[{tool,reason}] }`.
|
|
94
|
+
* Never throws. NOT run in CI / `--non-interactive` by the command layer.
|
|
95
|
+
*/
|
|
96
|
+
install({ tools = this.recommend(), spinner = true } = {}) {
|
|
97
|
+
if (!this.hasPackageJson()) {
|
|
98
|
+
return { ok: false, installed: [], failed: [], skipped: tools.map((t) => ({ tool: t, reason: 'no package.json' })) };
|
|
99
|
+
}
|
|
100
|
+
const installed = [];
|
|
101
|
+
const failed = [];
|
|
102
|
+
const skipped = [];
|
|
103
|
+
for (const name of tools) {
|
|
104
|
+
let adapter;
|
|
105
|
+
try { adapter = adapters.getAdapter(name, this.cwd); }
|
|
106
|
+
catch (err) { skipped.push({ tool: name, reason: (err.message || '').split('\n')[0] }); continue; }
|
|
107
|
+
if (this._toolUsable(name)) { skipped.push({ tool: name, reason: 'already present' }); continue; }
|
|
108
|
+
const sp = spinner ? UI.spinner(`Installing ${adapter.label}…`).start() : null;
|
|
109
|
+
try {
|
|
110
|
+
// Write the tool's default config (if absent) BEFORE `npm install`, so a
|
|
111
|
+
// package whose own postinstall scaffolds from its config (Lefthook's
|
|
112
|
+
// npm postinstall runs `lefthook install` and would otherwise drop a
|
|
113
|
+
// commented-out example, then register no hooks) sees OURS — a biome
|
|
114
|
+
// pre-commit — and registers it. Non-destructive (skips if present).
|
|
115
|
+
if (typeof adapter.initConfig === 'function') {
|
|
116
|
+
try { adapter.initConfig(this.cwd); } catch (_) { /* non-fatal */ }
|
|
117
|
+
}
|
|
118
|
+
execSync(adapter.installCommand(), { cwd: this.cwd, stdio: 'pipe', timeout: 300000 });
|
|
119
|
+
if (this._toolUsable(name)) {
|
|
120
|
+
// Optional post-install side effect (e.g. re-assert Lefthook's git hooks
|
|
121
|
+
// in case the package's own postinstall ran before our config existed).
|
|
122
|
+
// Only runs on this clean-install path — never over an incumbent setup.
|
|
123
|
+
let postWarn = null;
|
|
124
|
+
if (typeof adapter.postInstall === 'function') {
|
|
125
|
+
const pi = adapter.postInstall(this.cwd);
|
|
126
|
+
if (pi && !pi.ok) postWarn = pi.error;
|
|
127
|
+
}
|
|
128
|
+
if (sp) sp.succeed(`Installed ${adapter.label}`);
|
|
129
|
+
if (postWarn) UI.warning(`${adapter.label} installed but post-install step failed: ${postWarn}`);
|
|
130
|
+
installed.push(name);
|
|
131
|
+
} else {
|
|
132
|
+
if (sp) sp.fail(`${adapter.label} install did not resolve`);
|
|
133
|
+
failed.push({ tool: name, error: 'verify failed after install' });
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (sp) sp.fail(`${adapter.label} install failed`);
|
|
137
|
+
failed.push({ tool: name, error: (err.message || '').split('\n')[0] });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { ok: failed.length === 0, installed, failed, skipped };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Write each tool's default config when absent (non-destructive). Returns
|
|
145
|
+
* `[{ tool, status:'written'|'skipped'|'noop', reason?, path? }]`. Never throws.
|
|
146
|
+
*/
|
|
147
|
+
initConfigs({ tools = this.inScope() } = {}) {
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const name of tools) {
|
|
150
|
+
let adapter;
|
|
151
|
+
try { adapter = adapters.getAdapter(name, this.cwd); }
|
|
152
|
+
catch { out.push({ tool: name, status: 'noop', reason: 'unknown adapter' }); continue; }
|
|
153
|
+
if (typeof adapter.initConfig !== 'function') { out.push({ tool: name, status: 'noop' }); continue; }
|
|
154
|
+
try { out.push({ tool: name, ...adapter.initConfig(this.cwd) }); }
|
|
155
|
+
catch (err) { out.push({ tool: name, status: 'noop', reason: (err.message || '').split('\n')[0] }); }
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Merge the literal commands contributed by the given tools into a flat
|
|
162
|
+
* `{ lint, format, typecheck, test, test_related, build, audit }` map (empty
|
|
163
|
+
* string for unprovided keys). Written verbatim into `toolchain.commands.*`.
|
|
164
|
+
*/
|
|
165
|
+
commandsFor(tools = this.inScope()) {
|
|
166
|
+
const merged = {
|
|
167
|
+
lint: '', format: '', typecheck: '', test: '', test_related: '', build: '', audit: '',
|
|
168
|
+
};
|
|
169
|
+
for (const name of tools) {
|
|
170
|
+
let adapter;
|
|
171
|
+
try { adapter = adapters.getAdapter(name, this.cwd); } catch { continue; }
|
|
172
|
+
if (typeof adapter.commands !== 'function') continue;
|
|
173
|
+
const c = adapter.commands() || {};
|
|
174
|
+
for (const k of Object.keys(c)) {
|
|
175
|
+
if (k in merged && c[k]) merged[k] = c[k];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return merged;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Version currency (stub in Phase 1; real impl lands with tool-currency) ──
|
|
182
|
+
|
|
183
|
+
/** Placeholder — devDep currency arrives with `_toolchainRecords` (Phase 3). */
|
|
184
|
+
async checkUpgrade() { return { tool: 'toolchain', installed: null, latest: null, outdated: false, source: 'npm' }; }
|
|
185
|
+
|
|
186
|
+
/** Placeholder — devDep upgrades touch package.json; user runs them. */
|
|
187
|
+
upgrade() { return { ok: false, error: 'devDep upgrades are user-driven (npm install -D -E <pkg>@latest)' }; }
|
|
188
|
+
|
|
189
|
+
// ── Certification ─────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* One-shot certification of the selected tools, used by `configure`, the
|
|
193
|
+
* bootstrap skill, and `doctor`. Per tool, attests: the devDep RESOLVES
|
|
194
|
+
* (`npx --no-install <bin> --version`) and its config file EXISTS. Returns a
|
|
195
|
+
* plain object; never throws. `ok` = every selected tool resolves (a missing
|
|
196
|
+
* config is reported but does not fail `ok` — it is restorable).
|
|
197
|
+
*/
|
|
198
|
+
certify({ tools = this.inScope() } = {}) {
|
|
199
|
+
const perTool = tools.map((name) => {
|
|
200
|
+
let adapter;
|
|
201
|
+
try { adapter = adapters.getAdapter(name, this.cwd); }
|
|
202
|
+
catch { return { tool: name, devDep: false, config: false }; }
|
|
203
|
+
const devDep = this._toolUsable(name);
|
|
204
|
+
const config = adapter.configFile
|
|
205
|
+
? fs.existsSync(path.join(this.cwd, adapter.configFile))
|
|
206
|
+
: true;
|
|
207
|
+
return { tool: name, devDep, config };
|
|
208
|
+
});
|
|
209
|
+
const ok = perTool.length > 0 && perTool.every((t) => t.devDep);
|
|
210
|
+
return { ok, perTool };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── internals ─────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/** Static `replaces` (incumbents superseded) declared by a curated adapter. */
|
|
216
|
+
_replacesOf(name) {
|
|
217
|
+
const Cls = adapters.REGISTRY[name];
|
|
218
|
+
return (Cls && Array.isArray(Cls.replaces)) ? Cls.replaces : [];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** True iff the tool's binary resolves via its verifyCommand in this cwd. */
|
|
222
|
+
_toolUsable(name) {
|
|
223
|
+
let adapter;
|
|
224
|
+
try { adapter = adapters.getAdapter(name, this.cwd); } catch { return false; }
|
|
225
|
+
if (typeof adapter.verifyCommand !== 'function') return false;
|
|
226
|
+
try {
|
|
227
|
+
execSync(adapter.verifyCommand(), { cwd: this.cwd, stdio: 'ignore', timeout: 15000 });
|
|
228
|
+
return true;
|
|
229
|
+
} catch { return false; }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = ToolchainInstaller;
|