@polderlabs/bizar 4.4.10 → 4.4.11

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.
@@ -178,6 +178,138 @@ export const modsLoader = {
178
178
  mkdirSync(MODS_DIR, { recursive: true });
179
179
  },
180
180
 
181
+ /**
182
+ * v4.4.11 — Validate an installed mod without mounting it.
183
+ *
184
+ * Runs after a copy (in `installFromPath` + `installFromRegistry`) and
185
+ * before the install is reported as successful. Returns a structured
186
+ * `{ ok, errors, warnings }` so callers (the API + the CLI + the
187
+ * provisioner) can surface failures consistently.
188
+ *
189
+ * Checks performed:
190
+ * 1. `mod.json` is valid JSON with required fields (id, name, version).
191
+ * 2. `entry.route` (if declared) is a file that exists and is readable.
192
+ * We pre-parse it with `node --check` to catch syntax errors.
193
+ * 3. Permissions declared in mod.json are validated against the
194
+ * allowed list (delegates to mod-security.mjs).
195
+ * 4. If `entry.validate` is declared, we dynamic-import that file
196
+ * and call its `default` or named `validate` export. The hook can
197
+ * throw to fail the install.
198
+ *
199
+ * Never throws — always returns a structured result.
200
+ */
201
+ async validateModInstallation(id) {
202
+ const errors = [];
203
+ const warnings = [];
204
+ const dir = join(MODS_DIR, id);
205
+ if (!existsSync(dir)) {
206
+ return { ok: false, errors: [`mod directory not found: ${dir}`], warnings };
207
+ }
208
+
209
+ // 1. mod.json valid + required fields
210
+ const manifest = safeReadJSON(join(dir, 'mod.json'), null);
211
+ if (!manifest || typeof manifest !== 'object') {
212
+ return {
213
+ ok: false,
214
+ errors: ['mod.json is missing or invalid JSON'],
215
+ warnings,
216
+ };
217
+ }
218
+ for (const field of ['id', 'name', 'version']) {
219
+ if (!manifest[field] || typeof manifest[field] !== 'string') {
220
+ errors.push(`mod.json is missing required field "${field}"`);
221
+ }
222
+ }
223
+ if (manifest.id && manifest.id !== id) {
224
+ errors.push(`mod.json "id" field ("${manifest.id}") does not match the directory name ("${id}")`);
225
+ }
226
+
227
+ // 2. entry.route exists + parses
228
+ const entry = manifest.entry || {};
229
+ if (entry.route) {
230
+ const routePath = join(dir, entry.route);
231
+ if (!existsSync(routePath)) {
232
+ errors.push(`entry.route "${entry.route}" does not exist at ${routePath}`);
233
+ } else {
234
+ // Pre-parse the route.mjs with `node --check` to catch syntax
235
+ // errors without executing it. Skipped if node is not available.
236
+ try {
237
+ const { spawnSync } = await import('node:child_process');
238
+ const probe = spawnSync(process.execPath, ['--check', routePath], {
239
+ stdio: ['ignore', 'pipe', 'pipe'],
240
+ timeout: 5000,
241
+ });
242
+ if (probe.status !== 0) {
243
+ // Extract the first error line from the syntax checker
244
+ // output. `node --check` prints something like:
245
+ // /path/to/route.mjs:5
246
+ // syntax is wrong here
247
+ // ^^^
248
+ // SyntaxError: message
249
+ // We want the last "SyntaxError:" line.
250
+ const stderr = (probe.stderr || '').toString();
251
+ const lines = stderr.split('\n').map((l) => l.trim()).filter(Boolean);
252
+ const errLine = lines.reverse().find((l) => l.toLowerCase().includes('syntaxerror')) || lines[0] || 'unknown';
253
+ errors.push(`entry.route "${entry.route}" has a syntax error: ${errLine}`);
254
+ }
255
+ } catch {
256
+ // node --check unavailable — skip (not fatal)
257
+ }
258
+ }
259
+ }
260
+
261
+ // 3. Permissions valid
262
+ if (Array.isArray(manifest.permissions)) {
263
+ const { invalid } = parsePermissions(manifest.permissions);
264
+ for (const p of invalid) {
265
+ warnings.push(`mod declares unknown permission: ${p}`);
266
+ }
267
+ }
268
+
269
+ // 4. Optional entry.validate hook
270
+ if (entry.validate) {
271
+ const validatePath = join(dir, entry.validate);
272
+ if (!existsSync(validatePath)) {
273
+ errors.push(`entry.validate "${entry.validate}" does not exist at ${validatePath}`);
274
+ } else {
275
+ try {
276
+ // Dynamic import — the validate hook is opt-in. The mod may
277
+ // export `default` (function) or named export `validate`.
278
+ const mod = await import(/* @vite-ignore */ `file://${validatePath}`);
279
+ const fn = mod.default || mod.validate;
280
+ if (typeof fn !== 'function') {
281
+ warnings.push(
282
+ `entry.validate "${entry.validate}" does not export a function (got ${typeof fn})`,
283
+ );
284
+ } else {
285
+ // Run the validate hook with a 5s timeout. The hook is
286
+ // trusted (it's part of the mod) but we don't want a bad
287
+ // hook to hang the install.
288
+ const result = await Promise.race([
289
+ Promise.resolve()
290
+ .then(() => fn({ mod: manifest, dir }))
291
+ .catch((err) => ({ ok: false, error: err.message })),
292
+ new Promise((resolve) =>
293
+ setTimeout(() => resolve({ ok: false, error: 'validate hook timed out after 5s' }), 5000),
294
+ ),
295
+ ]);
296
+ if (result && result.ok === false) {
297
+ errors.push(`mod validate hook failed: ${result.error || 'unknown'}`);
298
+ }
299
+ }
300
+ } catch (err) {
301
+ errors.push(`failed to load entry.validate "${entry.validate}": ${err.message}`);
302
+ }
303
+ }
304
+ }
305
+
306
+ return {
307
+ ok: errors.length === 0,
308
+ errors,
309
+ warnings,
310
+ };
311
+ },
312
+
181
313
  /** List all installed mods. v3.3.1 — never throws. */
182
314
  list() {
183
315
  try {
@@ -211,7 +343,7 @@ export const modsLoader = {
211
343
  * Install a mod from a local path. Copies the folder into
212
344
  * `~/.config/bizar/mods/<id>/`. The id is the source folder's basename.
213
345
  */
214
- installFromPath(sourcePath) {
346
+ async installFromPath(sourcePath) {
215
347
  if (!existsSync(sourcePath)) {
216
348
  throw new Error(`source path does not exist: ${sourcePath}`);
217
349
  }
@@ -243,6 +375,24 @@ export const modsLoader = {
243
375
  // (agents/, commands/, skills/). This makes the mod's rules binding
244
376
  // on every agent at session start.
245
377
  installModInstructions(id, target);
378
+ // v4.4.11 — Smoke-test the mod before reporting success. We
379
+ // uninstall on failure so a broken mod doesn't leave a half-
380
+ // copied directory the user has to clean up manually.
381
+ const validation = await this.validateModInstallation(id);
382
+ if (!validation.ok) {
383
+ uninstallModInstructions(id);
384
+ rmSync(target, { recursive: true, force: true });
385
+ const err = new Error(
386
+ `mod "${id}" failed post-install validation: ${validation.errors.join('; ')}`,
387
+ );
388
+ err.validation = validation;
389
+ throw err;
390
+ }
391
+ if (validation.warnings.length > 0) {
392
+ console.warn(
393
+ `[mods-loader] mod "${id}" installed with warnings: ${validation.warnings.join('; ')}`,
394
+ );
395
+ }
246
396
  return loadMod({ id, dir: target });
247
397
  },
248
398
 
@@ -494,6 +644,24 @@ export const modsLoader = {
494
644
  // v3.20 — install mod instructions into the user's opencode config
495
645
  // (agents/, commands/, skills/). Triggered on registry install too.
496
646
  installModInstructions(id, target);
647
+ // v4.4.11 — Smoke-test the mod before reporting success. We
648
+ // uninstall on failure so a broken mod doesn't leave a half-
649
+ // copied directory the user has to clean up manually.
650
+ const validation = await this.validateModInstallation(id);
651
+ if (!validation.ok) {
652
+ uninstallModInstructions(id);
653
+ rmSync(target, { recursive: true, force: true });
654
+ const err = new Error(
655
+ `mod "${id}" failed post-install validation: ${validation.errors.join('; ')}`,
656
+ );
657
+ err.validation = validation;
658
+ throw err;
659
+ }
660
+ if (validation.warnings.length > 0) {
661
+ console.warn(
662
+ `[mods-loader] mod "${id}" installed with warnings: ${validation.warnings.join('; ')}`,
663
+ );
664
+ }
497
665
  return loadMod({ id, dir: target });
498
666
  },
499
667
 
package/cli/bin.mjs CHANGED
@@ -156,10 +156,11 @@ function showInstallHelp() {
156
156
  bizar install — Run the unified BizarHarness installer
157
157
 
158
158
  Usage:
159
- bizar install Install (or refresh) every component
160
- bizar install --dry-run Print what would happen, change nothing
161
- bizar install --force Overwrite existing files
162
- bizar install --help Show this help
159
+ bizar install Install (or refresh) every component
160
+ bizar install --dry-run Print what would happen, change nothing
161
+ bizar install --force Overwrite existing files
162
+ bizar install --with-mods a,b,c Opt-in: install specific mods as part of the run
163
+ bizar install --help Show this help
163
164
 
164
165
  Description:
165
166
  v4.4.7+ — unified installer. Same code path as 'bizar update'; the
@@ -194,6 +195,7 @@ function showUpdateHelp() {
194
195
  bizar update --dry-run Print what would happen, change nothing
195
196
  bizar update --force Override .bizar/PRE_PUSH_NOTES.md blockers
196
197
  bizar update --yes Same as --force, but named for one-line scripts
198
+ bizar update --with-mods a,b,c Opt-in: install specific mods as part of the run
197
199
  bizar update --help Show this help
198
200
 
199
201
  Components updated:
@@ -580,6 +582,22 @@ function parseFlag(name) {
580
582
  return args[idx + 1] || null;
581
583
  }
582
584
 
585
+ /**
586
+ * v4.4.11 — Parse `--with-mods <csv>` from the given subargs slice.
587
+ * Returns `null` if the flag isn't present (the provisioner's
588
+ * "don't touch mods" default), or a string[] of mod ids if it is.
589
+ */
590
+ function parseWithModsFlag(subargs) {
591
+ const idx = subargs.indexOf('--with-mods');
592
+ if (idx === -1) return null;
593
+ const raw = subargs[idx + 1];
594
+ if (!raw || raw.startsWith('--')) return [];
595
+ return raw
596
+ .split(',')
597
+ .map((s) => s.trim())
598
+ .filter(Boolean);
599
+ }
600
+
583
601
  async function readAutoLaunchWeb() {
584
602
  try {
585
603
  const fs = await import('node:fs');
@@ -677,7 +695,19 @@ async function main() {
677
695
  else await runTestGate();
678
696
  } else if (args[0] === 'update') {
679
697
  if (isHelpRequest) showUpdateHelp();
680
- else await runUpdate(args.slice(1));
698
+ else {
699
+ // v4.4.11 — Same --with-mods opt-in for update.
700
+ const withMods = parseWithModsFlag(args.slice(1));
701
+ // runUpdate expects (subargs: string[], opts?: object). Splice
702
+ // --with-mods <csv> out of subargs since the provisioner now
703
+ // takes it via opts, not as a positional arg.
704
+ const subargs = args.slice(1).filter((a, i, arr) => {
705
+ if (a === '--with-mods') return false;
706
+ if (arr[i - 1] === '--with-mods') return false;
707
+ return true;
708
+ });
709
+ await runUpdate(subargs, { withMods });
710
+ }
681
711
  } else if (args[0] === 'dev-link') {
682
712
  if (isHelpRequest) showDevLinkHelp();
683
713
  else {
@@ -741,7 +771,10 @@ async function main() {
741
771
  } else if (args[0] === 'install') {
742
772
  if (isHelpRequest) showInstallHelp();
743
773
  else {
744
- await runInstaller();
774
+ // v4.4.11 — Parse --with-mods <csv> to opt into mod installs
775
+ // during the run. Default: mods are NEVER touched.
776
+ const withMods = parseWithModsFlag(args.slice(1));
777
+ await runInstaller({ withMods });
745
778
  // v4.4.3 — After install, repair any stale bin symlinks so the
746
779
  // user picks up the new code (the installer itself may have
747
780
  // been running from a stale install path).
package/cli/provision.mjs CHANGED
@@ -38,7 +38,7 @@
38
38
 
39
39
  import chalk from 'chalk';
40
40
  import { execSync, spawn, spawnSync } from 'node:child_process';
41
- import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
41
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
42
42
  import { homedir } from 'node:os';
43
43
  import { join, dirname } from 'node:path';
44
44
  import { fileURLToPath } from 'node:url';
@@ -272,6 +272,13 @@ export function detectState({ cwd = process.cwd() } = {}) {
272
272
  // ── Git checkout? ─────────────────────────────────────────────────
273
273
  const gitRepo = existsSync(join(REPO_ROOT, '.git'));
274
274
 
275
+ // ── Installed mods ─────────────────────────────────────────────────
276
+ // v4.4.11 — `bizar install` and `bizar update` never install or
277
+ // upgrade mods. The list below is informational only. Use
278
+ // `bizar mod install <id>` to add a mod explicitly.
279
+ const MODS_DIR = join(BIZAR_HOME, 'mods');
280
+ const installedMods = listInstalledMods(MODS_DIR);
281
+
275
282
  return {
276
283
  pkgRoot,
277
284
  pkgVersion,
@@ -302,9 +309,52 @@ export function detectState({ cwd = process.cwd() } = {}) {
302
309
  opencodeCli,
303
310
  headsUpState,
304
311
  gitRepo,
312
+ installedMods,
305
313
  };
306
314
  }
307
315
 
316
+ /**
317
+ * v4.4.11 — Lightweight read-only scan of `~/.config/bizar/mods/`.
318
+ * Returns one entry per mod folder with the id + enabled flag parsed
319
+ * from mod.json. Never throws; mods with a missing or invalid mod.json
320
+ * are reported as `{id, error}` so the provisioner can surface them.
321
+ */
322
+ function listInstalledMods(modsDir) {
323
+ const out = [];
324
+ if (!existsSync(modsDir)) return out;
325
+ let entries;
326
+ try {
327
+ entries = readdirSync(modsDir, { withFileTypes: true });
328
+ } catch {
329
+ return out;
330
+ }
331
+ for (const entry of entries) {
332
+ if (!entry.isDirectory()) continue;
333
+ const dir = join(modsDir, entry.name);
334
+ const manifestPath = join(dir, 'mod.json');
335
+ if (!existsSync(manifestPath)) {
336
+ out.push({ id: entry.name, error: 'missing mod.json' });
337
+ continue;
338
+ }
339
+ let manifest;
340
+ try {
341
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
342
+ } catch {
343
+ out.push({ id: entry.name, error: 'invalid mod.json' });
344
+ continue;
345
+ }
346
+ out.push({
347
+ id: manifest.id || entry.name,
348
+ name: manifest.name || entry.name,
349
+ version: manifest.version || '?',
350
+ enabled: manifest.enabled !== false,
351
+ installedAt: manifest.installedAt || null,
352
+ path: dir,
353
+ });
354
+ }
355
+ return out;
356
+ }
357
+
308
358
  /**
309
359
  * Lightweight CommonJS-ish require for ESM contexts. Used to detect the
310
360
  * service-controller + heads-up modules without paying the static-import
@@ -681,6 +731,125 @@ export async function runDoctor({ silent = false } = {}) {
681
731
  }
682
732
  }
683
733
 
734
+ /**
735
+ * v4.4.11 — The mods step. By default, NEVER install or upgrade mods
736
+ * during `bizar install` or `bizar update`. The step just reports the
737
+ * current mod list so the user can see what's installed.
738
+ *
739
+ * To install a mod as part of the run, the user must opt in by:
740
+ * - passing `--with-mods <id1,id2>` to `bizar install` / `bizar update`
741
+ * - or setting the env var `BIZAR_MODS_AUTO_INSTALL=allow`
742
+ *
743
+ * When opted in, we shell to the running dashboard's `/api/mods`
744
+ * endpoint (which already validates the mod) — we don't duplicate the
745
+ * install logic here.
746
+ */
747
+ export async function runModsStep({ mode, dryRun, force, withMods, state }) {
748
+ // List the current mods (from the state we already detected).
749
+ const installed = state?.installedMods ?? [];
750
+ if (installed.length === 0) {
751
+ console.log(chalk.dim(' Mods: 0 installed.'));
752
+ return { ok: true, message: 'no mods installed', touched: false };
753
+ }
754
+
755
+ const enabled = installed.filter((m) => m.enabled).length;
756
+ const disabled = installed.length - enabled;
757
+ const summary = `${installed.length} installed (${enabled} enabled${disabled ? `, ${disabled} disabled` : ''})`;
758
+
759
+ // Resolve which mods the user asked us to install. The CLI parses
760
+ // `--with-mods a,b,c` into a string array; the provisioner accepts
761
+ // the same. We also accept a single env var as a comma-separated list.
762
+ const envList = (process.env.BIZAR_MODS_AUTO_INSTALL || '')
763
+ .split(',')
764
+ .map((s) => s.trim())
765
+ .filter(Boolean);
766
+ const wantedMods = Array.isArray(withMods) && withMods.length > 0
767
+ ? withMods
768
+ : (process.env.BIZAR_MODS_AUTO_INSTALL === 'allow' ? [] : envList);
769
+
770
+ // Default behavior: no install. Just print the list.
771
+ if (!wantedMods || wantedMods.length === 0) {
772
+ console.log(chalk.dim(` Mods: ${summary}. Not modified (use \`bizar mod install <id>\` to add one).`));
773
+ // Surface mods with errors so the user knows they need attention.
774
+ const broken = installed.filter((m) => m.error);
775
+ for (const m of broken) {
776
+ console.log(chalk.yellow(` ⚠ ${m.id}: ${m.error}`));
777
+ }
778
+ return { ok: true, message: `${summary}, not modified`, touched: false };
779
+ }
780
+
781
+ // Opt-in install path. Talk to the dashboard over HTTP — the
782
+ // dashboard's `POST /api/mods` endpoint already does the actual
783
+ // install + validation. If the dashboard isn't reachable, the
784
+ // install fails loudly.
785
+ console.log(chalk.cyan(` Installing ${wantedMods.length} mod(s) via dashboard API: ${wantedMods.join(', ')}`));
786
+ const errors = [];
787
+ for (const id of wantedMods) {
788
+ try {
789
+ const result = await installModViaDashboard(id, { dryRun });
790
+ if (result.ok) {
791
+ console.log(chalk.green(` ✓ ${id}: ${result.message}`));
792
+ } else {
793
+ console.log(chalk.red(` ✗ ${id}: ${result.message}`));
794
+ errors.push(`${id}: ${result.message}`);
795
+ }
796
+ } catch (err) {
797
+ const msg = err instanceof Error ? err.message : String(err);
798
+ console.log(chalk.red(` ✗ ${id}: ${msg}`));
799
+ errors.push(`${id}: ${msg}`);
800
+ }
801
+ }
802
+ if (errors.length > 0) {
803
+ return {
804
+ ok: false,
805
+ message: `mod install failed for ${errors.length} mod(s): ${errors.join('; ')}`,
806
+ touched: true,
807
+ };
808
+ }
809
+ return { ok: true, message: `${wantedMods.length} mod(s) installed`, touched: true };
810
+ }
811
+
812
+ /**
813
+ * v4.4.11 — POST to the dashboard's /api/mods endpoint to install a mod.
814
+ * We re-use the dashboard's own validation pipeline (mod-loader.mjs
815
+ * runs the same manifest + route.mjs + permissions checks) so we
816
+ * don't have to duplicate them here.
817
+ */
818
+ async function installModViaDashboard(id, { dryRun }) {
819
+ if (dryRun) {
820
+ return { ok: true, message: '[dry-run] would install via dashboard' };
821
+ }
822
+ // Find the dashboard's port from BIZAR_HOME/dashboard.port.
823
+ const portFile = join(BIZAR_HOME, 'dashboard.port');
824
+ let port = 4321;
825
+ try {
826
+ if (existsSync(portFile)) {
827
+ const parsed = parseInt(readTextSafe(portFile, '4321').trim(), 10);
828
+ if (Number.isFinite(parsed) && parsed > 0) port = parsed;
829
+ }
830
+ } catch { /* ignore */ }
831
+ // We need an auth token. The dashboard's install endpoint is under
832
+ // /api/* which is auth-gated. Fetch the auth status + token via
833
+ // /api/auth/status (skipped from auth via the skipPaths list in
834
+ // server.mjs). If the dashboard has auth enabled, the user needs to
835
+ // supply a token via BIZAR_DASHBOARD_TOKEN.
836
+ const headers = { 'content-type': 'application/json' };
837
+ const token = process.env.BIZAR_DASHBOARD_TOKEN;
838
+ if (token) headers['authorization'] = `Basic ${Buffer.from(`opencode:${token}`).toString('base64')}`;
839
+ const url = `http://127.0.0.1:${port}/api/mods`;
840
+ const res = await fetch(url, {
841
+ method: 'POST',
842
+ headers,
843
+ body: JSON.stringify({ id }),
844
+ });
845
+ if (!res.ok) {
846
+ const text = await res.text().catch(() => '');
847
+ return { ok: false, message: `dashboard returned ${res.status}: ${text.slice(0, 200) || '(no body)'}` };
848
+ }
849
+ const data = await res.json().catch(() => ({}));
850
+ return { ok: true, message: data?.name ? `installed ${data.name}@${data.version}` : 'installed' };
851
+ }
852
+
684
853
  // ─── Top-level orchestration ──────────────────────────────────────────────────
685
854
 
686
855
  /**
@@ -785,6 +954,7 @@ export async function runProvision(opts = {}) {
785
954
  skipSystemDeps = false,
786
955
  skipHeadsUps = false,
787
956
  yes = false,
957
+ withMods = null, // string[] — opt-in mod install. Default null = don't touch mods.
788
958
  } = opts;
789
959
 
790
960
  const banner = mode === 'update'
@@ -897,7 +1067,17 @@ export async function runProvision(opts = {}) {
897
1067
  stepResults.push({ label: 'dashboard-restart', ...r });
898
1068
  }
899
1069
 
900
- // ── 11. Doctor health check ────────────────────────────────────────
1070
+ // ── 11. Mods (opt-in only) ─────────────────────────────────────────
1071
+ // v4.4.11 — `bizar install` and `bizar update` never install or
1072
+ // upgrade mods by default. The provisioner reports the current mod
1073
+ // list so the user can see what's installed, then exits the mod step
1074
+ // without touching anything. To install a mod, pass
1075
+ // `--with-mods <id1,id2>` or set `BIZAR_MODS_AUTO_INSTALL=allow`.
1076
+ console.log('');
1077
+ const modsStep = await runModsStep({ mode, dryRun, force, withMods, state });
1078
+ stepResults.push({ label: 'mods', ...modsStep });
1079
+
1080
+ // ── 12. Doctor health check ───────────────────────────────────────
901
1081
  console.log('');
902
1082
  const doctor = await runDoctor({ silent: true });
903
1083
  if (doctor.failed > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "4.4.10",
3
+ "version": "4.4.11",
4
4
  "description": "Norse-pantheon multi-agent system for opencode — 13 agents across 4 cost tiers with cost-aware routing, plans, and a configurable agent harness. v4 ships as a single npm package bundling the dashboard server, opencode plugin, and typed SDK.",
5
5
  "type": "module",
6
6
  "bin": {