@polderlabs/bizar 4.4.10 → 4.4.12
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/bizar-dash/dist/assets/{main-Nq8Dq3VR.css → main-BKXEqU1w.css} +1 -1
- package/bizar-dash/dist/assets/main-EK_fzXn_.js +352 -0
- package/bizar-dash/dist/assets/main-EK_fzXn_.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +1 -1
- package/bizar-dash/dist/index.html +2 -2
- package/bizar-dash/src/server/api.mjs +2 -0
- package/bizar-dash/src/server/minimax.mjs +352 -0
- package/bizar-dash/src/server/mods-loader.mjs +169 -1
- package/bizar-dash/src/server/routes/_shared.mjs +15 -0
- package/bizar-dash/src/server/routes/minimax.mjs +140 -0
- package/bizar-dash/src/web/App.tsx +2 -0
- package/bizar-dash/src/web/components/Topbar.tsx +2 -0
- package/bizar-dash/src/web/lib/types.ts +12 -0
- package/bizar-dash/src/web/main.tsx +1 -0
- package/bizar-dash/src/web/styles/minimax-usage.css +371 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +543 -0
- package/cli/bin.mjs +39 -6
- package/cli/provision.mjs +182 -2
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-CEazNxxy.js +0 -347
- package/bizar-dash/dist/assets/main-CEazNxxy.js.map +0 -1
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
}
|
|
30
30
|
})();
|
|
31
31
|
</script>
|
|
32
|
-
<script type="module" crossorigin src="/assets/main-
|
|
32
|
+
<script type="module" crossorigin src="/assets/main-EK_fzXn_.js"></script>
|
|
33
33
|
<link rel="modulepreload" crossorigin href="/assets/mobile-Dl1q7Cyq.js">
|
|
34
34
|
<link rel="stylesheet" crossorigin href="/assets/mobile-CsZQAswA.css">
|
|
35
|
-
<link rel="stylesheet" crossorigin href="/assets/main-
|
|
35
|
+
<link rel="stylesheet" crossorigin href="/assets/main-BKXEqU1w.css">
|
|
36
36
|
</head>
|
|
37
37
|
<body>
|
|
38
38
|
<div id="root"></div>
|
|
@@ -48,6 +48,7 @@ import { createDiagnosticsRouter } from './routes/diagnostics.mjs';
|
|
|
48
48
|
import { createPairRouter } from './routes/pair.mjs';
|
|
49
49
|
import { createThemesRouter } from './routes/themes.mjs';
|
|
50
50
|
import { createNotificationsRouter } from './routes/notifications.mjs';
|
|
51
|
+
import { createMinimaxRouter } from './routes/minimax.mjs';
|
|
51
52
|
import { createMiscRouter } from './routes/misc.mjs';
|
|
52
53
|
|
|
53
54
|
/**
|
|
@@ -118,6 +119,7 @@ export async function createApiRouter({
|
|
|
118
119
|
router.use(createThemesRouter({ state }));
|
|
119
120
|
router.use(createNotificationsRouter({ broadcast }));
|
|
120
121
|
router.use(createArtifactsRouter({ state, broadcast, projectRoot }));
|
|
122
|
+
router.use(createMinimaxRouter({ state, broadcast }));
|
|
121
123
|
router.use(createMiscRouter({ state, broadcast }));
|
|
122
124
|
|
|
123
125
|
// /api/auth/* must be reachable WITHOUT the bearer token so a fresh
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/minimax.mjs
|
|
3
|
+
*
|
|
4
|
+
* v4.5.0 — MiniMax API client.
|
|
5
|
+
*
|
|
6
|
+
* Owns the network calls to platform.minimax.io for the Bizar
|
|
7
|
+
* dashboard. Two surfaces:
|
|
8
|
+
*
|
|
9
|
+
* 1. fetchRemains() — the public `/v1/token_plan/remains` endpoint.
|
|
10
|
+
* Returns the user's remaining Token Plan quota per model — both
|
|
11
|
+
* 5-hour rolling window and weekly window — including the reset
|
|
12
|
+
* times for each. This is the headline data the user wants to see.
|
|
13
|
+
*
|
|
14
|
+
* 2. chatCompletion() — a thin wrapper around `/v1/chat/completions`
|
|
15
|
+
* (OpenAI-compatible) for the dashboard's "Send a test prompt"
|
|
16
|
+
* button. Also captures the `usage` block from the response so the
|
|
17
|
+
* dashboard can show per-call consumption.
|
|
18
|
+
*
|
|
19
|
+
* The client always reads the API key from the user's settings
|
|
20
|
+
* (settings.minimax.apiKey) and never logs it. The base URL is
|
|
21
|
+
* configurable but defaults to `https://www.minimax.io` per the docs.
|
|
22
|
+
*
|
|
23
|
+
* Token Plan endpoints live on `www.minimax.io` (NOT `api.minimax.io`
|
|
24
|
+
* — the chat completions endpoint uses that one). The default
|
|
25
|
+
* `baseUrl` in the settings uses the Token Plan host so `fetchRemains`
|
|
26
|
+
* works out of the box.
|
|
27
|
+
*
|
|
28
|
+
* All network calls are guarded by a 10s timeout so a hung API
|
|
29
|
+
* never wedges the dashboard.
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
32
|
+
import { dirname, join } from 'node:path';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
|
|
35
|
+
const HOME = homedir();
|
|
36
|
+
const BIZAR_HOME = join(HOME, '.config', 'bizar');
|
|
37
|
+
const CACHE_DIR = join(BIZAR_HOME, 'minimax');
|
|
38
|
+
const CACHE_FILE = join(CACHE_DIR, 'remains-cache.json');
|
|
39
|
+
|
|
40
|
+
// Default base URL. The docs say `https://www.minimax.io/v1/token_plan/remains`
|
|
41
|
+
// (note: the `www` host, not `api`). Allow override per-user in settings.
|
|
42
|
+
export const DEFAULT_BASE_URL = 'https://www.minimax.io';
|
|
43
|
+
// Chat completions / OpenAI-style surface lives on api.minimax.io.
|
|
44
|
+
export const DEFAULT_CHAT_BASE_URL = 'https://api.minimax.io/v1';
|
|
45
|
+
|
|
46
|
+
// Models Bizar exposes by default in the dashboard.
|
|
47
|
+
export const KNOWN_MODELS = [
|
|
48
|
+
'MiniMax-M3',
|
|
49
|
+
'MiniMax-M2.7',
|
|
50
|
+
'MiniMax-M2.5',
|
|
51
|
+
'MiniMax-M2.1',
|
|
52
|
+
'MiniMax-M2',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read the merged settings.json and return the MiniMax config block.
|
|
57
|
+
* Never throws — returns sane defaults on parse failure.
|
|
58
|
+
*/
|
|
59
|
+
export function readMinimaxSettings() {
|
|
60
|
+
const file = join(BIZAR_HOME, 'settings.json');
|
|
61
|
+
if (!existsSync(file)) {
|
|
62
|
+
return defaultMinimaxSettings();
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
66
|
+
const m = parsed.minimax;
|
|
67
|
+
if (!m || typeof m !== 'object') return defaultMinimaxSettings();
|
|
68
|
+
return {
|
|
69
|
+
enabled: m.enabled !== false,
|
|
70
|
+
apiKey: typeof m.apiKey === 'string' ? m.apiKey : '',
|
|
71
|
+
groupId: typeof m.groupId === 'string' ? m.groupId : 'default',
|
|
72
|
+
baseUrl: typeof m.baseUrl === 'string' ? m.baseUrl : DEFAULT_BASE_URL,
|
|
73
|
+
chatBaseUrl: typeof m.chatBaseUrl === 'string' ? m.chatBaseUrl : DEFAULT_CHAT_BASE_URL,
|
|
74
|
+
};
|
|
75
|
+
} catch {
|
|
76
|
+
return defaultMinimaxSettings();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function defaultMinimaxSettings() {
|
|
81
|
+
return {
|
|
82
|
+
enabled: true,
|
|
83
|
+
apiKey: '',
|
|
84
|
+
groupId: 'default',
|
|
85
|
+
baseUrl: DEFAULT_BASE_URL,
|
|
86
|
+
chatBaseUrl: DEFAULT_CHAT_BASE_URL,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Cache (in-memory + disk) ──────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* In-memory snapshot of the last remains response. Avoids re-hitting the
|
|
94
|
+
* API on every dashboard render.
|
|
95
|
+
*/
|
|
96
|
+
let memoryCache = null;
|
|
97
|
+
|
|
98
|
+
function readDiskCache() {
|
|
99
|
+
try {
|
|
100
|
+
if (!existsSync(CACHE_FILE)) return null;
|
|
101
|
+
const stat = require('node:fs').statSync(CACHE_FILE);
|
|
102
|
+
if (Date.now() - stat.mtimeMs > 5 * 60 * 1000) return null; // 5 min TTL
|
|
103
|
+
return JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function writeDiskCache(snapshot) {
|
|
110
|
+
try {
|
|
111
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
112
|
+
writeFileSync(CACHE_FILE, JSON.stringify(snapshot, null, 2) + '\n', 'utf8');
|
|
113
|
+
} catch {
|
|
114
|
+
/* best-effort */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function clearRemainsCache() {
|
|
119
|
+
memoryCache = null;
|
|
120
|
+
try {
|
|
121
|
+
if (existsSync(CACHE_FILE)) {
|
|
122
|
+
require('node:fs').unlinkSync(CACHE_FILE);
|
|
123
|
+
}
|
|
124
|
+
} catch { /* best-effort */ }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Try in-memory → disk cache. Returns the cached snapshot or null.
|
|
129
|
+
*/
|
|
130
|
+
export function readCachedRemains() {
|
|
131
|
+
if (memoryCache) return memoryCache;
|
|
132
|
+
const fromDisk = readDiskCache();
|
|
133
|
+
if (fromDisk) {
|
|
134
|
+
memoryCache = fromDisk;
|
|
135
|
+
return fromDisk;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Network helpers ──────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Fetch with a hard timeout. Uses undici (Node 18+ global fetch is
|
|
144
|
+
* fine but AbortSignal.timeout is simpler with node:fetch).
|
|
145
|
+
*/
|
|
146
|
+
async function fetchWithTimeout(url, opts = {}, timeoutMs = 10_000) {
|
|
147
|
+
const ctl = new AbortController();
|
|
148
|
+
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
149
|
+
try {
|
|
150
|
+
return await fetch(url, { ...opts, signal: ctl.signal });
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(t);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Hit /v1/token_plan/remains with the user's API key + group_id.
|
|
160
|
+
*
|
|
161
|
+
* Response shape:
|
|
162
|
+
* {
|
|
163
|
+
* model_remains: [
|
|
164
|
+
* { model_name, current_interval_remaining_percent, current_weekly_remaining_percent,
|
|
165
|
+
* start_time, end_time, remains_time, weekly_start_time, weekly_end_time,
|
|
166
|
+
* weekly_remains_time, current_interval_total_count, current_interval_usage_count,
|
|
167
|
+
* current_weekly_total_count, current_weekly_usage_count,
|
|
168
|
+
* current_interval_status, current_weekly_status },
|
|
169
|
+
* ...
|
|
170
|
+
* ],
|
|
171
|
+
* base_resp: { status_code, status_msg }
|
|
172
|
+
* }
|
|
173
|
+
*
|
|
174
|
+
* Returns the raw response, augmented with `fetchedAt` and (if we got
|
|
175
|
+
* data) the per-model `endTimeISO` / `weeklyEndTimeISO` strings for
|
|
176
|
+
* convenient rendering in the UI.
|
|
177
|
+
*/
|
|
178
|
+
export async function fetchRemains({ apiKey, groupId = 'default', baseUrl = DEFAULT_BASE_URL, force = false } = {}) {
|
|
179
|
+
if (!apiKey || !apiKey.trim()) {
|
|
180
|
+
return { ok: false, error: 'no_api_key', message: 'MiniMax API key is not configured' };
|
|
181
|
+
}
|
|
182
|
+
if (!force) {
|
|
183
|
+
const cached = readCachedRemains();
|
|
184
|
+
if (cached && cached.apiKeyHint === maskKey(apiKey) && (Date.now() - cached.fetchedAt) < 60_000) {
|
|
185
|
+
return { ok: true, cached: true, ...cached };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const url = `${baseUrl.replace(/\/$/, '')}/v1/token_plan/remains?group_id=${encodeURIComponent(groupId)}`;
|
|
189
|
+
let resp;
|
|
190
|
+
try {
|
|
191
|
+
resp = await fetchWithTimeout(url, {
|
|
192
|
+
method: 'GET',
|
|
193
|
+
headers: {
|
|
194
|
+
Authorization: `Bearer ${apiKey}`,
|
|
195
|
+
'Content-Type': 'application/json',
|
|
196
|
+
Accept: 'application/json',
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return { ok: false, error: 'network_error', message: err.message || 'fetch failed' };
|
|
201
|
+
}
|
|
202
|
+
const text = await resp.text();
|
|
203
|
+
let body = null;
|
|
204
|
+
try { body = text ? JSON.parse(text) : null; } catch { /* keep as null */ }
|
|
205
|
+
if (!resp.ok) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
error: `http_${resp.status}`,
|
|
209
|
+
message: body?.base_resp?.status_msg || resp.statusText || 'request failed',
|
|
210
|
+
status: resp.status,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (!body || body.base_resp?.status_code !== 0) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: 'api_error',
|
|
217
|
+
message: body?.base_resp?.status_msg || 'unknown error',
|
|
218
|
+
raw: body,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Augment with ISO timestamps so the UI doesn't have to do ms → Date
|
|
223
|
+
// arithmetic for every cell.
|
|
224
|
+
const models = (body.model_remains || []).map((m) => ({
|
|
225
|
+
...m,
|
|
226
|
+
endTimeISO: new Date(m.end_time).toISOString(),
|
|
227
|
+
weeklyEndTimeISO: new Date(m.weekly_end_time).toISOString(),
|
|
228
|
+
intervalResetInMs: m.remains_time,
|
|
229
|
+
weeklyResetInMs: m.weekly_remains_time,
|
|
230
|
+
intervalResetInHuman: humanizeDuration(m.remains_time),
|
|
231
|
+
weeklyResetInHuman: humanizeDuration(m.weekly_remains_time),
|
|
232
|
+
intervalConsumedPercent: clamp(100 - (m.current_interval_remaining_percent || 0), 0, 100),
|
|
233
|
+
weeklyConsumedPercent: clamp(100 - (m.current_weekly_remaining_percent || 0), 0, 100),
|
|
234
|
+
intervalUsed: m.current_interval_usage_count || 0,
|
|
235
|
+
intervalTotal: m.current_interval_total_count || 0,
|
|
236
|
+
weeklyUsed: m.current_weekly_usage_count || 0,
|
|
237
|
+
weeklyTotal: m.current_weekly_total_count || 0,
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
const snapshot = {
|
|
241
|
+
ok: true,
|
|
242
|
+
fetchedAt: Date.now(),
|
|
243
|
+
apiKeyHint: maskKey(apiKey),
|
|
244
|
+
groupId,
|
|
245
|
+
baseUrl,
|
|
246
|
+
models,
|
|
247
|
+
baseResp: body.base_resp,
|
|
248
|
+
};
|
|
249
|
+
memoryCache = snapshot;
|
|
250
|
+
writeDiskCache(snapshot);
|
|
251
|
+
return snapshot;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Send a single chat-completion to /v1/chat/completions and return
|
|
256
|
+
* the parsed response + parsed `usage` block. Used by the dashboard's
|
|
257
|
+
* "Send a test prompt" button so the user can verify their key works
|
|
258
|
+
* and see live token usage.
|
|
259
|
+
*/
|
|
260
|
+
export async function chatCompletion({
|
|
261
|
+
apiKey,
|
|
262
|
+
prompt,
|
|
263
|
+
model = 'MiniMax-M3',
|
|
264
|
+
baseUrl = DEFAULT_CHAT_BASE_URL,
|
|
265
|
+
maxTokens = 256,
|
|
266
|
+
} = {}) {
|
|
267
|
+
if (!apiKey || !apiKey.trim()) {
|
|
268
|
+
return { ok: false, error: 'no_api_key', message: 'MiniMax API key is not configured' };
|
|
269
|
+
}
|
|
270
|
+
if (!prompt || !prompt.trim()) {
|
|
271
|
+
return { ok: false, error: 'no_prompt', message: 'prompt is empty' };
|
|
272
|
+
}
|
|
273
|
+
const url = `${baseUrl.replace(/\/$/, '')}/chat/completions`;
|
|
274
|
+
const body = JSON.stringify({
|
|
275
|
+
model,
|
|
276
|
+
messages: [{ role: 'user', content: prompt }],
|
|
277
|
+
max_completion_tokens: maxTokens,
|
|
278
|
+
stream: false,
|
|
279
|
+
});
|
|
280
|
+
let resp;
|
|
281
|
+
try {
|
|
282
|
+
resp = await fetchWithTimeout(url, {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
headers: {
|
|
285
|
+
Authorization: `Bearer ${apiKey}`,
|
|
286
|
+
'Content-Type': 'application/json',
|
|
287
|
+
Accept: 'application/json',
|
|
288
|
+
},
|
|
289
|
+
body,
|
|
290
|
+
});
|
|
291
|
+
} catch (err) {
|
|
292
|
+
return { ok: false, error: 'network_error', message: err.message || 'fetch failed' };
|
|
293
|
+
}
|
|
294
|
+
const text = await resp.text();
|
|
295
|
+
let data = null;
|
|
296
|
+
try { data = text ? JSON.parse(text) : null; } catch { /* ignore */ }
|
|
297
|
+
if (!resp.ok) {
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
error: `http_${resp.status}`,
|
|
301
|
+
message: data?.base_resp?.status_msg || resp.statusText || 'request failed',
|
|
302
|
+
status: resp.status,
|
|
303
|
+
raw: data,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (!data || data.base_resp?.status_code !== 0) {
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
error: 'api_error',
|
|
310
|
+
message: data?.base_resp?.status_msg || 'unknown error',
|
|
311
|
+
raw: data,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const choice = (data.choices || [])[0] || {};
|
|
315
|
+
const content = choice?.message?.content || '';
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
model: data.model || model,
|
|
319
|
+
content,
|
|
320
|
+
reasoning: choice?.message?.reasoning_content || null,
|
|
321
|
+
finishReason: choice?.finish_reason || null,
|
|
322
|
+
usage: data.usage || null,
|
|
323
|
+
baseResp: data.base_resp,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
/** Mask the API key for safe logging / display. Shows last 4 chars. */
|
|
330
|
+
export function maskKey(key) {
|
|
331
|
+
if (!key) return '';
|
|
332
|
+
const trimmed = String(key).trim();
|
|
333
|
+
if (trimmed.length <= 6) return '****';
|
|
334
|
+
return `****${trimmed.slice(-4)}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Convert a millisecond duration into a compact human label. */
|
|
338
|
+
export function humanizeDuration(ms) {
|
|
339
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) return 'now';
|
|
340
|
+
const sec = Math.floor(ms / 1000);
|
|
341
|
+
const min = Math.floor(sec / 60);
|
|
342
|
+
const hr = Math.floor(min / 60);
|
|
343
|
+
const day = Math.floor(hr / 24);
|
|
344
|
+
if (day >= 1) return `${day}d ${hr % 24}h`;
|
|
345
|
+
if (hr >= 1) return `${hr}h ${min % 60}m`;
|
|
346
|
+
if (min >= 1) return `${min}m`;
|
|
347
|
+
return `${sec}s`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function clamp(n, lo, hi) {
|
|
351
|
+
return Math.max(lo, Math.min(hi, n));
|
|
352
|
+
}
|
|
@@ -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
|
|
|
@@ -134,6 +134,20 @@ export const DEFAULT_SETTINGS = {
|
|
|
134
134
|
provider: 'opencode',
|
|
135
135
|
model: 'opencode/deepseek-v4-flash-free',
|
|
136
136
|
},
|
|
137
|
+
// v4.5.0 — MiniMax Token Plan integration. `apiKey` is the
|
|
138
|
+
// user's Subscription Key from
|
|
139
|
+
// https://platform.minimax.io/user-center/payment/token-plan
|
|
140
|
+
// `groupId` is usually 'default' for an individual team. `baseUrl`
|
|
141
|
+
// is the Token Plan host (the remains endpoint lives on www.*, NOT
|
|
142
|
+
// api.*). `chatBaseUrl` is the chat-completions host (api.*).
|
|
143
|
+
// No key is shipped by default — the user enters their own.
|
|
144
|
+
minimax: {
|
|
145
|
+
enabled: true,
|
|
146
|
+
apiKey: '',
|
|
147
|
+
groupId: 'default',
|
|
148
|
+
baseUrl: 'https://www.minimax.io',
|
|
149
|
+
chatBaseUrl: 'https://api.minimax.io/v1',
|
|
150
|
+
},
|
|
137
151
|
};
|
|
138
152
|
|
|
139
153
|
/**
|
|
@@ -159,6 +173,7 @@ export function mergeSettings(existing) {
|
|
|
159
173
|
merged.about = { ...DEFAULT_SETTINGS.about, ...(existing.about || {}) };
|
|
160
174
|
merged.agents = { ...DEFAULT_SETTINGS.agents, ...(existing.agents || {}) };
|
|
161
175
|
merged.systemLlm = { ...DEFAULT_SETTINGS.systemLlm, ...(existing.systemLlm || {}) };
|
|
176
|
+
merged.minimax = { ...DEFAULT_SETTINGS.minimax, ...(existing.minimax || {}) };
|
|
162
177
|
// Always use the package version — never let user settings override it
|
|
163
178
|
merged.about.version = DEFAULT_SETTINGS.about.version;
|
|
164
179
|
return merged;
|