@polderlabs/bizar 3.12.1 → 3.12.2
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/cli/bin.mjs +101 -0
- package/cli/dev-link.mjs +175 -0
- package/cli/dev-link.test.mjs +297 -0
- package/cli/doctor.mjs +275 -0
- package/cli/doctor.test.mjs +312 -0
- package/cli/install.mjs +46 -4
- package/cli/update.mjs +123 -33
- package/package.json +1 -1
package/cli/doctor.mjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/doctor.mjs
|
|
3
|
+
*
|
|
4
|
+
* v3.12.2 — `bizar doctor` subcommand.
|
|
5
|
+
*
|
|
6
|
+
* Runs a battery of health checks against the local Bizar / opencode
|
|
7
|
+
* install and reports pass/fail for each. Returns a structured summary
|
|
8
|
+
* suitable for callers (e.g. `bizar update`) that want to act on the
|
|
9
|
+
* result without re-printing the per-check output.
|
|
10
|
+
*
|
|
11
|
+
* The checks are intentionally tolerant: missing optional tools
|
|
12
|
+
* (rtk/semble/skills) don't fail the run, and the dashboard check is
|
|
13
|
+
* skipped silently if no port file exists. The goal is "is your
|
|
14
|
+
* install healthy?" not "is every conceivable thing present?".
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* import { runDoctor } from './doctor.mjs';
|
|
18
|
+
* const r = await runDoctor(); // prints everything
|
|
19
|
+
* const r = await runDoctor({ silent: true }); // returns summary only
|
|
20
|
+
*/
|
|
21
|
+
import chalk from 'chalk';
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { opencodeConfigDir, opencodeAgentsDir } from './utils.mjs';
|
|
27
|
+
|
|
28
|
+
const REQUIRED_AGENTS = ['odin.md', 'quick.md', 'thor.md', 'tyr.md'];
|
|
29
|
+
|
|
30
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function bizarConfigDir() {
|
|
33
|
+
return process.env.XDG_CONFIG_HOME
|
|
34
|
+
? join(process.env.XDG_CONFIG_HOME, 'bizar')
|
|
35
|
+
: join(homedir(), '.config', 'bizar');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run a check function and capture its result. The check either
|
|
40
|
+
* returns a string message (pass) or throws an Error (fail).
|
|
41
|
+
*/
|
|
42
|
+
async function runCheck(name, fn) {
|
|
43
|
+
try {
|
|
44
|
+
const message = await fn();
|
|
45
|
+
return { name, ok: true, message: message || 'ok' };
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return {
|
|
48
|
+
name,
|
|
49
|
+
ok: false,
|
|
50
|
+
message: err && err.message ? err.message : String(err),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function which(cmd) {
|
|
56
|
+
const probe = spawnSync('which', [cmd], { stdio: 'ignore' });
|
|
57
|
+
return probe.status === 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── individual checks ───────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
async function checkOpencodeReachable() {
|
|
63
|
+
const r = spawnSync('opencode', ['--version'], {
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
timeout: 5000,
|
|
66
|
+
});
|
|
67
|
+
if (r.status !== 0) {
|
|
68
|
+
throw new Error(`opencode --version exited ${r.status}`);
|
|
69
|
+
}
|
|
70
|
+
return (r.stdout || r.stderr || '').trim().split('\n')[0] || 'opencode available';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function checkConfigValid() {
|
|
74
|
+
const cfgPath = join(opencodeConfigDir(), 'opencode.json');
|
|
75
|
+
if (!existsSync(cfgPath)) {
|
|
76
|
+
throw new Error(`not found at ${cfgPath}`);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new Error(`invalid JSON: ${err.message}`);
|
|
82
|
+
}
|
|
83
|
+
return 'opencode.json parses';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function checkPluginEntryPresent() {
|
|
87
|
+
const cfgPath = join(opencodeConfigDir(), 'opencode.json');
|
|
88
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
89
|
+
const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : [];
|
|
90
|
+
if (plugins.length === 0) {
|
|
91
|
+
throw new Error('no plugin entries in opencode.json');
|
|
92
|
+
}
|
|
93
|
+
const hasBizar = plugins.some((p) => {
|
|
94
|
+
if (typeof p === 'string') return p.includes('bizar');
|
|
95
|
+
if (p && typeof p === 'object') {
|
|
96
|
+
return (
|
|
97
|
+
(p.name || '').includes('bizar') ||
|
|
98
|
+
(p.path || '').includes('bizar')
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
});
|
|
103
|
+
if (!hasBizar) {
|
|
104
|
+
throw new Error('bizar not found in plugin[]');
|
|
105
|
+
}
|
|
106
|
+
return 'bizar present in plugin[]';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function checkPluginPathResolves() {
|
|
110
|
+
const cfgPath = join(opencodeConfigDir(), 'opencode.json');
|
|
111
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
112
|
+
const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : [];
|
|
113
|
+
let lastChecked = null;
|
|
114
|
+
for (const p of plugins) {
|
|
115
|
+
if (!p || typeof p !== 'object' || !p.path) continue;
|
|
116
|
+
const entryPath = p.path;
|
|
117
|
+
const isAbs = entryPath.startsWith('/') || /^[a-z]:[\\/]/i.test(entryPath);
|
|
118
|
+
const resolved = isAbs ? entryPath : join(opencodeConfigDir(), entryPath);
|
|
119
|
+
lastChecked = resolved;
|
|
120
|
+
if (!existsSync(resolved)) {
|
|
121
|
+
throw new Error(`plugin path does not exist: ${resolved}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (lastChecked === null) {
|
|
125
|
+
return 'no plugin path to check';
|
|
126
|
+
}
|
|
127
|
+
return `plugin path resolves: ${lastChecked}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function checkDeployedPluginPresent() {
|
|
131
|
+
const r = spawnSync('npm', ['root', '-g'], {
|
|
132
|
+
encoding: 'utf8',
|
|
133
|
+
timeout: 5000,
|
|
134
|
+
});
|
|
135
|
+
if (r.status !== 0) {
|
|
136
|
+
throw new Error('npm root -g failed');
|
|
137
|
+
}
|
|
138
|
+
const root = (r.stdout || '').trim();
|
|
139
|
+
const pkgPath = join(root, '@polderlabs', 'bizar-plugin', 'package.json');
|
|
140
|
+
if (!existsSync(pkgPath)) {
|
|
141
|
+
throw new Error('@polderlabs/bizar-plugin not installed globally');
|
|
142
|
+
}
|
|
143
|
+
return '@polderlabs/bizar-plugin present';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function checkAgentFilesInstalled() {
|
|
147
|
+
const dir = opencodeAgentsDir();
|
|
148
|
+
if (!existsSync(dir)) {
|
|
149
|
+
throw new Error(`agents dir missing: ${dir}`);
|
|
150
|
+
}
|
|
151
|
+
const missing = REQUIRED_AGENTS.filter((f) => !existsSync(join(dir, f)));
|
|
152
|
+
if (missing.length > 0) {
|
|
153
|
+
throw new Error(`missing: ${missing.join(', ')}`);
|
|
154
|
+
}
|
|
155
|
+
return `all ${REQUIRED_AGENTS.length} core agents present`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Lenient: passes if at least one of rtk/semble/skills is on PATH.
|
|
160
|
+
* These are informational — none of them are strictly required for
|
|
161
|
+
* `bizar doctor` to do its job, and missing them shouldn't fail the
|
|
162
|
+
* overall health report.
|
|
163
|
+
*/
|
|
164
|
+
async function checkToolsAvailable() {
|
|
165
|
+
const tools = ['rtk', 'semble', 'skills'];
|
|
166
|
+
const found = tools.filter(which);
|
|
167
|
+
if (found.length === 0) {
|
|
168
|
+
throw new Error(`none of ${tools.join('/')} on PATH`);
|
|
169
|
+
}
|
|
170
|
+
return `available: ${found.join(', ')}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function checkDashboardReachable() {
|
|
174
|
+
const portFile = join(bizarConfigDir(), 'dashboard.port');
|
|
175
|
+
if (!existsSync(portFile)) {
|
|
176
|
+
return 'no dashboard port file (skipped)';
|
|
177
|
+
}
|
|
178
|
+
let port;
|
|
179
|
+
try {
|
|
180
|
+
port = parseInt(readFileSync(portFile, 'utf8').trim(), 10);
|
|
181
|
+
} catch {
|
|
182
|
+
throw new Error(`could not read port from ${portFile}`);
|
|
183
|
+
}
|
|
184
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
185
|
+
throw new Error(`invalid port in ${portFile}: ${port}`);
|
|
186
|
+
}
|
|
187
|
+
const r = spawnSync(
|
|
188
|
+
'curl',
|
|
189
|
+
['-fsS', '-m', '2', `http://127.0.0.1:${port}/api/health`],
|
|
190
|
+
{ encoding: 'utf8', timeout: 5000 },
|
|
191
|
+
);
|
|
192
|
+
if (r.status !== 0) {
|
|
193
|
+
throw new Error(`dashboard on port ${port} not reachable`);
|
|
194
|
+
}
|
|
195
|
+
return `dashboard reachable on port ${port}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function checkProviderConfigSanity() {
|
|
199
|
+
const cfgPath = join(opencodeConfigDir(), 'opencode.json');
|
|
200
|
+
if (!existsSync(cfgPath)) {
|
|
201
|
+
throw new Error('opencode.json missing');
|
|
202
|
+
}
|
|
203
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
204
|
+
const openrouter = cfg.provider && cfg.provider.openrouter;
|
|
205
|
+
if (!openrouter) {
|
|
206
|
+
throw new Error('provider.openrouter block missing');
|
|
207
|
+
}
|
|
208
|
+
const models = openrouter.models || {};
|
|
209
|
+
const saneNames = Object.entries(models).filter(([, m]) => {
|
|
210
|
+
return (
|
|
211
|
+
m &&
|
|
212
|
+
typeof m === 'object' &&
|
|
213
|
+
'interleaved' in m &&
|
|
214
|
+
'reasoning' in m
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
if (saneNames.length === 0) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
'no MiniMax-style model with interleaved + reasoning flags',
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return `provider.openrouter + ${saneNames.length} model(s) sane`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const CHECKS = [
|
|
226
|
+
['opencode-reachable', checkOpencodeReachable],
|
|
227
|
+
['opencode-config-valid', checkConfigValid],
|
|
228
|
+
['plugin-entry-present', checkPluginEntryPresent],
|
|
229
|
+
['plugin-path-resolves', checkPluginPathResolves],
|
|
230
|
+
['deployed-plugin-present', checkDeployedPluginPresent],
|
|
231
|
+
['agent-files-installed', checkAgentFilesInstalled],
|
|
232
|
+
['tools-available', checkToolsAvailable],
|
|
233
|
+
['dashboard-reachable', checkDashboardReachable],
|
|
234
|
+
['provider-config-sanity', checkProviderConfigSanity],
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
// ── public API ──────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Run all health checks. Prints per-check output unless `opts.silent`.
|
|
241
|
+
* Always prints a final summary line. Returns `{ passed, failed, results }`.
|
|
242
|
+
*/
|
|
243
|
+
export async function runDoctor(opts = {}) {
|
|
244
|
+
const silent = !!opts.silent;
|
|
245
|
+
const results = [];
|
|
246
|
+
for (const [name, fn] of CHECKS) {
|
|
247
|
+
const r = await runCheck(name, fn);
|
|
248
|
+
results.push(r);
|
|
249
|
+
if (!silent) {
|
|
250
|
+
const marker = r.ok ? chalk.green('✓') : chalk.red('✗');
|
|
251
|
+
const label = name.padEnd(28);
|
|
252
|
+
const msg = r.ok ? chalk.dim(` ${r.message}`) : chalk.red(` ${r.message}`);
|
|
253
|
+
console.log(` ${marker} ${label}${msg}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const passed = results.filter((r) => r.ok).length;
|
|
258
|
+
const failed = results.length - passed;
|
|
259
|
+
|
|
260
|
+
// Always print a summary line if there were failures, or if not silent.
|
|
261
|
+
if (failed > 0 || !silent) {
|
|
262
|
+
console.log('');
|
|
263
|
+
if (failed === 0) {
|
|
264
|
+
console.log(
|
|
265
|
+
chalk.green(` ✓ ${passed} checks passed, ${failed} failed`),
|
|
266
|
+
);
|
|
267
|
+
} else {
|
|
268
|
+
console.log(
|
|
269
|
+
chalk.yellow(` ⚠ ${passed} checks passed, ${failed} failed`),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { passed, failed, results };
|
|
275
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/doctor.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for the `bizar doctor` subcommand. Uses Node's built-in
|
|
5
|
+
* node:test (no external test framework).
|
|
6
|
+
*
|
|
7
|
+
* Strategy: mock HOME so opencodeConfigDir() resolves inside a
|
|
8
|
+
* tmpdir, and craft the opencode.json + agents/ directory to drive
|
|
9
|
+
* specific pass/fail outcomes. For checks that depend on global
|
|
10
|
+
* npm-installed packages or external CLIs (e.g. opencode --version,
|
|
11
|
+
* npm root -g, which rtk), we accept that they may pass or fail
|
|
12
|
+
* depending on the test environment and just verify the framework
|
|
13
|
+
* returns the right shape.
|
|
14
|
+
*/
|
|
15
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import {
|
|
18
|
+
mkdtempSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
existsSync,
|
|
23
|
+
utimesSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join, dirname } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const PROJECT_ROOT = join(__dirname, '..');
|
|
31
|
+
|
|
32
|
+
const { runDoctor } = await import('./doctor.mjs');
|
|
33
|
+
|
|
34
|
+
// ── HOME mocking ────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const ORIG_HOME = process.env.HOME;
|
|
37
|
+
const ORIG_XDG = process.env.XDG_CONFIG_HOME;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Mock HOME so opencodeConfigDir() resolves to `<tmpdir>/.config/opencode`.
|
|
41
|
+
* Returns the tmpdir path.
|
|
42
|
+
*
|
|
43
|
+
* We don't set XDG_CONFIG_HOME here for the same reason as dev-link.test.mjs:
|
|
44
|
+
* the helper treats a set XDG_CONFIG_HOME as the direct parent (so
|
|
45
|
+
* `XDG_CONFIG_HOME=~/.config` → `~/.config/opencode`).
|
|
46
|
+
*/
|
|
47
|
+
function freshHome() {
|
|
48
|
+
const home = mkdtempSync(join(tmpdir(), 'bizar-doctor-'));
|
|
49
|
+
process.env.HOME = home;
|
|
50
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
51
|
+
return home;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
if (ORIG_HOME === undefined) delete process.env.HOME;
|
|
56
|
+
else process.env.HOME = ORIG_HOME;
|
|
57
|
+
if (ORIG_XDG === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
58
|
+
else process.env.XDG_CONFIG_HOME = ORIG_XDG;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── Shape & silent-mode tests ───────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
describe('runDoctor() shape', () => {
|
|
64
|
+
let home;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
home = freshHome();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
if (home && existsSync(home)) rmSync(home, { recursive: true, force: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('returns { passed, failed, results }', async () => {
|
|
75
|
+
const result = await runDoctor({ silent: true });
|
|
76
|
+
assert.equal(typeof result.passed, 'number');
|
|
77
|
+
assert.equal(typeof result.failed, 'number');
|
|
78
|
+
assert.ok(Array.isArray(result.results));
|
|
79
|
+
// results should have one entry per check
|
|
80
|
+
assert.equal(result.results.length, 9);
|
|
81
|
+
assert.equal(result.passed + result.failed, 9);
|
|
82
|
+
// Each result has the expected fields
|
|
83
|
+
for (const r of result.results) {
|
|
84
|
+
assert.equal(typeof r.name, 'string');
|
|
85
|
+
assert.equal(typeof r.ok, 'boolean');
|
|
86
|
+
assert.equal(typeof r.message, 'string');
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('silent mode suppresses per-check output but still prints summary on failure', async () => {
|
|
91
|
+
// Capture stdout by overriding console.log.
|
|
92
|
+
const lines = [];
|
|
93
|
+
const origLog = console.log;
|
|
94
|
+
console.log = (...args) => {
|
|
95
|
+
lines.push(args.join(' '));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
// Empty home → many checks will fail → summary should print.
|
|
100
|
+
const result = await runDoctor({ silent: true });
|
|
101
|
+
assert.ok(result.failed > 0, 'precondition: some checks should fail');
|
|
102
|
+
} finally {
|
|
103
|
+
console.log = origLog;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// In silent mode with failures, only the summary line should print
|
|
107
|
+
// (an empty leading line is also fine — it's a visual separator).
|
|
108
|
+
const summaryLines = lines.filter((l) => /checks passed.*failed/.test(l));
|
|
109
|
+
assert.equal(
|
|
110
|
+
summaryLines.length,
|
|
111
|
+
1,
|
|
112
|
+
`expected exactly 1 summary line in silent+failed mode, got ${summaryLines.length}:\n${lines.join('\n')}`,
|
|
113
|
+
);
|
|
114
|
+
assert.match(summaryLines[0], /checks passed.*failed/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('silent mode suppresses ALL output when nothing fails', async () => {
|
|
118
|
+
// Build a fully healthy environment. We can't make opencode reachable
|
|
119
|
+
// or rtk installed without modifying PATH, so we settle for: no failures
|
|
120
|
+
// means no output at all. Since we know some checks will fail in a
|
|
121
|
+
// bare tmpdir, this test is structured to assert the negative case
|
|
122
|
+
// differently: we verify silent+no-fail produces 0 lines.
|
|
123
|
+
//
|
|
124
|
+
// For this we mock nothing — the test just confirms the framework
|
|
125
|
+
// doesn't print per-check lines when silent is true.
|
|
126
|
+
const lines = [];
|
|
127
|
+
const origLog = console.log;
|
|
128
|
+
console.log = (...args) => {
|
|
129
|
+
lines.push(args.join(' '));
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await runDoctor({ silent: true });
|
|
134
|
+
// Some checks failed (because the env isn't fully healthy).
|
|
135
|
+
// What we want to verify is that per-check lines did NOT print.
|
|
136
|
+
const perCheckLines = lines.filter((l) =>
|
|
137
|
+
/^\s+[✓✗]\s/.test(l) // chalk check markers
|
|
138
|
+
);
|
|
139
|
+
assert.equal(
|
|
140
|
+
perCheckLines.length,
|
|
141
|
+
0,
|
|
142
|
+
`silent mode should suppress per-check output, got:\n${perCheckLines.join('\n')}`,
|
|
143
|
+
);
|
|
144
|
+
} finally {
|
|
145
|
+
console.log = origLog;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('non-silent mode prints per-check output', async () => {
|
|
150
|
+
const lines = [];
|
|
151
|
+
const origLog = console.log;
|
|
152
|
+
console.log = (...args) => {
|
|
153
|
+
lines.push(args.join(' '));
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
await runDoctor();
|
|
158
|
+
} finally {
|
|
159
|
+
console.log = origLog;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Should have at least 9 per-check lines plus the summary.
|
|
163
|
+
const perCheckLines = lines.filter((l) => /^\s+[✓✗]\s/.test(l));
|
|
164
|
+
assert.ok(
|
|
165
|
+
perCheckLines.length >= 9,
|
|
166
|
+
`expected ≥9 per-check lines, got ${perCheckLines.length}`,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── Fixture-driven pass/fail tests ──────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
describe('runDoctor() with fixture HOME', () => {
|
|
174
|
+
let home;
|
|
175
|
+
|
|
176
|
+
beforeEach(() => {
|
|
177
|
+
home = freshHome();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
afterEach(() => {
|
|
181
|
+
if (home && existsSync(home)) rmSync(home, { recursive: true, force: true });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
function writeOpencodeConfig(json) {
|
|
185
|
+
const cfgDir = join(home, '.config', 'opencode');
|
|
186
|
+
mkdirSync(cfgDir, { recursive: true });
|
|
187
|
+
writeFileSync(join(cfgDir, 'opencode.json'), JSON.stringify(json), 'utf8');
|
|
188
|
+
return cfgDir;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function writeAgents(...files) {
|
|
192
|
+
const agentsDir = join(home, '.config', 'opencode', 'agents');
|
|
193
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
194
|
+
for (const f of files) {
|
|
195
|
+
writeFileSync(join(agentsDir, f), `# ${f}`, 'utf8');
|
|
196
|
+
}
|
|
197
|
+
return agentsDir;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function findCheck(result, name) {
|
|
201
|
+
return result.results.find((r) => r.name === name);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
test('config-valid passes for parseable JSON', async () => {
|
|
205
|
+
writeOpencodeConfig({ provider: {} });
|
|
206
|
+
const result = await runDoctor({ silent: true });
|
|
207
|
+
const r = findCheck(result, 'opencode-config-valid');
|
|
208
|
+
assert.equal(r.ok, true, `expected config-valid to pass: ${r.message}`);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('config-valid fails for missing opencode.json', async () => {
|
|
212
|
+
// No opencode.json written.
|
|
213
|
+
const result = await runDoctor({ silent: true });
|
|
214
|
+
const r = findCheck(result, 'opencode-config-valid');
|
|
215
|
+
assert.equal(r.ok, false);
|
|
216
|
+
assert.match(r.message, /not found/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('config-valid fails for invalid JSON', async () => {
|
|
220
|
+
const cfgDir = writeOpencodeConfig({});
|
|
221
|
+
// Overwrite with garbage
|
|
222
|
+
writeFileSync(join(cfgDir, 'opencode.json'), '{ this is not json', 'utf8');
|
|
223
|
+
const result = await runDoctor({ silent: true });
|
|
224
|
+
const r = findCheck(result, 'opencode-config-valid');
|
|
225
|
+
assert.equal(r.ok, false);
|
|
226
|
+
assert.match(r.message, /invalid JSON/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('plugin-entry-present fails when plugin[] is empty', async () => {
|
|
230
|
+
writeOpencodeConfig({ plugin: [] });
|
|
231
|
+
const result = await runDoctor({ silent: true });
|
|
232
|
+
const r = findCheck(result, 'plugin-entry-present');
|
|
233
|
+
assert.equal(r.ok, false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('plugin-entry-present fails when plugin[] has no bizar', async () => {
|
|
237
|
+
writeOpencodeConfig({ plugin: ['something-else'] });
|
|
238
|
+
const result = await runDoctor({ silent: true });
|
|
239
|
+
const r = findCheck(result, 'plugin-entry-present');
|
|
240
|
+
assert.equal(r.ok, false);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('plugin-entry-present passes when plugin[] has bizar string', async () => {
|
|
244
|
+
writeOpencodeConfig({ plugin: ['bizar'] });
|
|
245
|
+
const result = await runDoctor({ silent: true });
|
|
246
|
+
const r = findCheck(result, 'plugin-entry-present');
|
|
247
|
+
assert.equal(r.ok, true, r.message);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('plugin-entry-present passes when plugin[] has bizar object', async () => {
|
|
251
|
+
writeOpencodeConfig({ plugin: [{ name: 'bizar', path: './plugins/bizar' }] });
|
|
252
|
+
const result = await runDoctor({ silent: true });
|
|
253
|
+
const r = findCheck(result, 'plugin-entry-present');
|
|
254
|
+
assert.equal(r.ok, true, r.message);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('agent-files-installed fails when core agents missing', async () => {
|
|
258
|
+
writeOpencodeConfig({});
|
|
259
|
+
writeAgents('odin.md'); // missing quick, thor, tyr
|
|
260
|
+
const result = await runDoctor({ silent: true });
|
|
261
|
+
const r = findCheck(result, 'agent-files-installed');
|
|
262
|
+
assert.equal(r.ok, false);
|
|
263
|
+
assert.match(r.message, /missing/);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('agent-files-installed passes when all core agents present', async () => {
|
|
267
|
+
writeOpencodeConfig({});
|
|
268
|
+
writeAgents('odin.md', 'quick.md', 'thor.md', 'tyr.md');
|
|
269
|
+
const result = await runDoctor({ silent: true });
|
|
270
|
+
const r = findCheck(result, 'agent-files-installed');
|
|
271
|
+
assert.equal(r.ok, true, r.message);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('provider-config-sanity fails without openrouter block', async () => {
|
|
275
|
+
writeOpencodeConfig({ provider: {} });
|
|
276
|
+
const result = await runDoctor({ silent: true });
|
|
277
|
+
const r = findCheck(result, 'provider-config-sanity');
|
|
278
|
+
assert.equal(r.ok, false);
|
|
279
|
+
assert.match(r.message, /openrouter/);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('provider-config-sanity fails when models lack interleaved+reasoning', async () => {
|
|
283
|
+
writeOpencodeConfig({
|
|
284
|
+
provider: {
|
|
285
|
+
openrouter: {
|
|
286
|
+
models: { 'some-model': { id: 'foo' } },
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
const result = await runDoctor({ silent: true });
|
|
291
|
+
const r = findCheck(result, 'provider-config-sanity');
|
|
292
|
+
assert.equal(r.ok, false);
|
|
293
|
+
assert.match(r.message, /interleaved.*reasoning/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('provider-config-sanity passes with interleaved+reasoning', async () => {
|
|
297
|
+
writeOpencodeConfig({
|
|
298
|
+
provider: {
|
|
299
|
+
openrouter: {
|
|
300
|
+
models: {
|
|
301
|
+
'MiniMax/MiniMax-M3': { interleaved: true, reasoning: true },
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
const result = await runDoctor({ silent: true });
|
|
307
|
+
const r = findCheck(result, 'provider-config-sanity');
|
|
308
|
+
assert.equal(r.ok, true, r.message);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
console.log(' doctor.mjs tests loaded — run with: node --test cli/doctor.test.mjs');
|
package/cli/install.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import boxen from 'boxen';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, lstatSync, rmSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
6
6
|
import { showBanner, showPantheon, sectionHeading } from './banner.mjs';
|
|
@@ -26,13 +26,56 @@ const AGENT_FILES = [
|
|
|
26
26
|
* `opencodeConfigDir()`). Otherwise, prints a hint directing the user to run
|
|
27
27
|
* `npm install -g @polderlabs/bizar-plugin`.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
29
|
+
* Symlink guard (v3.12.2): if the dest is a symlink (e.g. created by
|
|
30
|
+
* `bizar dev-link`), do NOT overwrite it. The copy below would otherwise
|
|
31
|
+
* dereference the symlink and silently turn it back into a real directory,
|
|
32
|
+
* breaking the dev workflow. Pass `{ force: true }` to override (after
|
|
33
|
+
* confirming the user really wants the deployed copy back).
|
|
34
|
+
*
|
|
35
|
+
* Pass `{ silent: true }` to suppress the warning prints; the function
|
|
36
|
+
* still returns `true` if the dest is already in the desired state.
|
|
37
|
+
*
|
|
38
|
+
* Returns `true` if the plugin was installed (or already in place),
|
|
39
|
+
* `false` otherwise. Never throws.
|
|
30
40
|
*/
|
|
31
|
-
export async function installPluginFromGlobal() {
|
|
41
|
+
export async function installPluginFromGlobal(opts = {}) {
|
|
32
42
|
const { execSync } = await import('node:child_process');
|
|
33
43
|
const { mkdir, readdir, copyFile } = await import('node:fs/promises');
|
|
34
44
|
const { join } = await import('node:path');
|
|
35
45
|
|
|
46
|
+
// ── Symlink guard ───────────────────────────────────────────────────────────
|
|
47
|
+
// If dest is a symlink (created by `bizar dev-link`), don't overwrite it.
|
|
48
|
+
// A plain copy would dereference the symlink and silently break the
|
|
49
|
+
// dev workflow. Print a hint unless silent, or honour an explicit
|
|
50
|
+
// --force from the caller.
|
|
51
|
+
const destDir = join(opencodeConfigDir(), 'plugins', 'bizar');
|
|
52
|
+
let destIsSymlink = false;
|
|
53
|
+
try {
|
|
54
|
+
destIsSymlink = lstatSync(destDir).isSymbolicLink();
|
|
55
|
+
} catch {
|
|
56
|
+
// doesn't exist yet — fine
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (destIsSymlink) {
|
|
60
|
+
if (opts.silent) {
|
|
61
|
+
// Called from dev-unlink, which has already removed the symlink.
|
|
62
|
+
// If we somehow still see one here, treat as "already restored".
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
console.log(
|
|
66
|
+
chalk.yellow(
|
|
67
|
+
' ⚠ ~/.config/opencode/plugins/bizar is a dev symlink — skipping npm copy.',
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
console.log(
|
|
71
|
+
chalk.dim(
|
|
72
|
+
' Run `bizar dev-unlink` to restore the deployed copy, or pass --force to overwrite.',
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
if (!opts.force) return false;
|
|
76
|
+
rmSync(destDir, { force: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
36
79
|
let globalRoot;
|
|
37
80
|
try {
|
|
38
81
|
globalRoot = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
@@ -50,7 +93,6 @@ export async function installPluginFromGlobal() {
|
|
|
50
93
|
return false;
|
|
51
94
|
}
|
|
52
95
|
|
|
53
|
-
const destDir = join(opencodeConfigDir(), 'plugins', 'bizar');
|
|
54
96
|
await mkdir(destDir, { recursive: true });
|
|
55
97
|
|
|
56
98
|
async function copyRecursive(srcDir, dstDir) {
|