@invago/mixin 1.0.8 → 1.0.9

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.
@@ -13,6 +13,10 @@ export type MixinReplyPlan =
13
13
  | { kind: "buttons"; intro?: string; buttons: MixinButton[] }
14
14
  | { kind: "card"; card: MixinCard };
15
15
 
16
+ export type MixinReplyPlanResolution =
17
+ | { matchedTemplate: false; plan: MixinReplyPlan | null }
18
+ | { matchedTemplate: true; plan: MixinReplyPlan | null; error?: string };
19
+
16
20
  const MAX_BUTTONS = 6;
17
21
  const MAX_BUTTON_LABEL = 36;
18
22
  const MAX_CARD_TITLE = 36;
@@ -166,40 +170,40 @@ function parseAudioTemplate(body: string): MixinReplyPlan | null {
166
170
  };
167
171
  }
168
172
 
169
- function parseExplicitTemplate(text: string): MixinReplyPlan | null {
173
+ function parseExplicitTemplate(text: string): MixinReplyPlanResolution {
170
174
  const match = text.match(TEMPLATE_REGEX);
171
175
  if (!match) {
172
- return null;
176
+ return { matchedTemplate: false, plan: null };
173
177
  }
174
178
 
175
179
  const templateType = (match[1] ?? "").toLowerCase();
176
180
  const body = match[2] ?? "";
177
181
 
178
182
  if (templateType === "text") {
179
- return parseTextTemplate(body);
183
+ return { matchedTemplate: true, plan: parseTextTemplate(body), error: "Invalid mixin-text template body" };
180
184
  }
181
185
 
182
186
  if (templateType === "post") {
183
- return parsePostTemplate(body);
187
+ return { matchedTemplate: true, plan: parsePostTemplate(body), error: "Invalid mixin-post template body" };
184
188
  }
185
189
 
186
190
  if (templateType === "buttons") {
187
- return parseButtonsTemplate(body);
191
+ return { matchedTemplate: true, plan: parseButtonsTemplate(body), error: "Invalid mixin-buttons template JSON" };
188
192
  }
189
193
 
190
194
  if (templateType === "card") {
191
- return parseCardTemplate(body);
195
+ return { matchedTemplate: true, plan: parseCardTemplate(body), error: "Invalid mixin-card template JSON" };
192
196
  }
193
197
 
194
198
  if (templateType === "file") {
195
- return parseFileTemplate(body);
199
+ return { matchedTemplate: true, plan: parseFileTemplate(body), error: "Invalid mixin-file template JSON" };
196
200
  }
197
201
 
198
202
  if (templateType === "audio") {
199
- return parseAudioTemplate(body);
203
+ return { matchedTemplate: true, plan: parseAudioTemplate(body), error: "Invalid mixin-audio template JSON" };
200
204
  }
201
205
 
202
- return null;
206
+ return { matchedTemplate: true, plan: null, error: "Unknown Mixin template type" };
203
207
  }
204
208
 
205
209
  function toPlainText(text: string): string {
@@ -288,21 +292,21 @@ function isLongStructuredText(text: string): boolean {
288
292
  );
289
293
  }
290
294
 
291
- export function buildMixinReplyPlan(text: string): MixinReplyPlan | null {
295
+ export function resolveMixinReplyPlan(text: string): MixinReplyPlanResolution {
292
296
  const normalized = normalizeWhitespace(text);
293
297
  if (!normalized) {
294
- return null;
298
+ return { matchedTemplate: false, plan: null };
295
299
  }
296
300
 
297
301
  const explicit = parseExplicitTemplate(normalized);
298
- if (explicit) {
302
+ if (explicit.matchedTemplate) {
299
303
  return explicit;
300
304
  }
301
305
 
302
306
  const links = extractLinks(normalized);
303
307
 
304
308
  if (isLongStructuredText(normalized)) {
305
- return { kind: "post", text: normalized };
309
+ return { matchedTemplate: false, plan: { kind: "post", text: normalized } };
306
310
  }
307
311
 
308
312
  if (links.length >= 2 && links.length <= MAX_BUTTONS) {
@@ -310,9 +314,12 @@ export function buildMixinReplyPlan(text: string): MixinReplyPlan | null {
310
314
  normalized.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "").replace(/https?:\/\/[^\s)]+/g, ""),
311
315
  );
312
316
  return {
313
- kind: "buttons",
314
- intro: intro || undefined,
315
- buttons: buildButtons(links),
317
+ matchedTemplate: false,
318
+ plan: {
319
+ kind: "buttons",
320
+ intro: intro || undefined,
321
+ buttons: buildButtons(links),
322
+ },
316
323
  };
317
324
  }
318
325
 
@@ -320,15 +327,22 @@ export function buildMixinReplyPlan(text: string): MixinReplyPlan | null {
320
327
  const title = detectTitle(normalized, links[0].label);
321
328
  const description = detectCardDescription(normalized, title) || truncate(links[0].url, MAX_CARD_DESCRIPTION);
322
329
  return {
323
- kind: "card",
324
- card: {
325
- title,
326
- description,
327
- action: links[0].url,
328
- shareable: true,
330
+ matchedTemplate: false,
331
+ plan: {
332
+ kind: "card",
333
+ card: {
334
+ title,
335
+ description,
336
+ action: links[0].url,
337
+ shareable: true,
338
+ },
329
339
  },
330
340
  };
331
341
  }
332
342
 
333
- return { kind: "text", text: toPlainText(normalized) };
343
+ return { matchedTemplate: false, plan: { kind: "text", text: toPlainText(normalized) } };
344
+ }
345
+
346
+ export function buildMixinReplyPlan(text: string): MixinReplyPlan | null {
347
+ return resolveMixinReplyPlan(text).plan;
334
348
  }
@@ -230,6 +230,17 @@ function resolveOutboxPaths(): {
230
230
  };
231
231
  }
232
232
 
233
+ export function getOutboxPathsSnapshot(): {
234
+ outboxDir: string;
235
+ outboxFile: string;
236
+ } {
237
+ const { outboxDir, outboxFile } = resolveOutboxPaths();
238
+ return {
239
+ outboxDir,
240
+ outboxFile,
241
+ };
242
+ }
243
+
233
244
  function normalizeErrorMessage(message: string): string {
234
245
  if (message.length <= MAX_ERROR_LENGTH) {
235
246
  return message;
package/src/status.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary } from "openclaw/plugin-sdk";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import { getAccountConfig, resolveDefaultAccountId } from "./config.js";
4
+ import { getOutboxPathsSnapshot, type OutboxStatus } from "./send-service.js";
5
+
6
+ type RuntimeLifecycleSnapshot = {
7
+ running?: boolean | null;
8
+ lastStartAt?: number | null;
9
+ lastStopAt?: number | null;
10
+ lastError?: string | null;
11
+ lastInboundAt?: number | null;
12
+ lastOutboundAt?: number | null;
13
+ };
14
+
15
+ type MixinChannelStatusSnapshot = {
16
+ configured?: boolean | null;
17
+ running?: boolean | null;
18
+ lastStartAt?: number | null;
19
+ lastStopAt?: number | null;
20
+ lastError?: string | null;
21
+ defaultAccountId?: string | null;
22
+ outboxDir?: string | null;
23
+ outboxFile?: string | null;
24
+ outboxPending?: number | null;
25
+ mediaMaxMb?: number | null;
26
+ };
27
+
28
+ type MixinStatusAccount = {
29
+ accountId: string;
30
+ name?: string;
31
+ enabled?: boolean;
32
+ configured?: boolean;
33
+ config: {
34
+ requireMentionInGroup?: boolean;
35
+ mediaBypassMentionInGroup?: boolean;
36
+ mediaMaxMb?: number;
37
+ audioAutoDetectDuration?: boolean;
38
+ audioSendAsVoiceByDefault?: boolean;
39
+ audioRequireFfprobe?: boolean;
40
+ };
41
+ };
42
+
43
+ export function resolveMixinStatusSnapshot(
44
+ cfg: OpenClawConfig,
45
+ accountId?: string,
46
+ outboxStatus?: OutboxStatus | null,
47
+ ): {
48
+ defaultAccountId: string;
49
+ outboxDir: string;
50
+ outboxFile: string;
51
+ outboxPending: number;
52
+ mediaMaxMb: number | null;
53
+ } {
54
+ const defaultAccountId = resolveDefaultAccountId(cfg);
55
+ const resolvedAccountId = accountId ?? defaultAccountId;
56
+ const accountConfig = getAccountConfig(cfg, resolvedAccountId);
57
+ const { outboxDir, outboxFile } = getOutboxPathsSnapshot();
58
+ return {
59
+ defaultAccountId,
60
+ outboxDir,
61
+ outboxFile,
62
+ outboxPending: outboxStatus?.totalPending ?? 0,
63
+ mediaMaxMb: accountConfig.mediaMaxMb ?? null,
64
+ };
65
+ }
66
+
67
+ export function buildMixinChannelSummary(params: {
68
+ snapshot: MixinChannelStatusSnapshot;
69
+ }) {
70
+ const { snapshot } = params;
71
+ return {
72
+ ...buildBaseChannelStatusSummary(snapshot),
73
+ defaultAccountId: snapshot.defaultAccountId ?? null,
74
+ outboxDir: snapshot.outboxDir ?? null,
75
+ outboxFile: snapshot.outboxFile ?? null,
76
+ outboxPending: snapshot.outboxPending ?? 0,
77
+ mediaMaxMb: snapshot.mediaMaxMb ?? null,
78
+ };
79
+ }
80
+
81
+ export function buildMixinAccountSnapshot(params: {
82
+ account: MixinStatusAccount;
83
+ runtime?: RuntimeLifecycleSnapshot | null;
84
+ probe?: unknown;
85
+ defaultAccountId?: string | null;
86
+ outboxPending?: number | null;
87
+ }) {
88
+ const { account, runtime, probe, defaultAccountId, outboxPending } = params;
89
+ return {
90
+ ...buildBaseAccountStatusSnapshot({ account, runtime, probe }),
91
+ defaultAccountId: defaultAccountId ?? null,
92
+ outboxPending: outboxPending ?? 0,
93
+ requireMentionInGroup: account.config.requireMentionInGroup ?? true,
94
+ mediaBypassMentionInGroup: account.config.mediaBypassMentionInGroup ?? true,
95
+ mediaMaxMb: account.config.mediaMaxMb ?? null,
96
+ audioAutoDetectDuration: account.config.audioAutoDetectDuration ?? true,
97
+ audioSendAsVoiceByDefault: account.config.audioSendAsVoiceByDefault ?? true,
98
+ audioRequireFfprobe: account.config.audioRequireFfprobe ?? false,
99
+ };
100
+ }
@@ -0,0 +1,98 @@
1
+ # Mixin Plugin Onboarding CLI
2
+
3
+ This CLI is bundled inside [`@invago/mixin`](https://www.npmjs.com/package/@invago/mixin). It helps inspect local OpenClaw installation state, verify key paths, and automate plugin install or update commands.
4
+
5
+ ## Commands
6
+
7
+ ### `info`
8
+
9
+ Prints the current local OpenClaw and Mixin plugin context:
10
+
11
+ - OpenClaw home, state, and extensions directories
12
+ - detected `openclaw.json` path
13
+ - detected Mixin plugin directories
14
+ - whether the plugin looks enabled in config
15
+ - current outbox path
16
+ - whether `ffprobe` is available
17
+
18
+ Run:
19
+
20
+ ```bash
21
+ npx -y @invago/mixin info
22
+ ```
23
+
24
+ ### `doctor`
25
+
26
+ Runs a basic local diagnosis and returns a non-zero exit code when required checks fail.
27
+
28
+ Current checks:
29
+
30
+ - config file found
31
+ - `channels.mixin` present
32
+ - plugin enabled in config
33
+ - plugin installed in extensions
34
+ - outbox directory writable
35
+ - `ffprobe` available
36
+
37
+ It also reports leftover `.openclaw-install-stage-*` directories if any are detected.
38
+
39
+ Run:
40
+
41
+ ```bash
42
+ npx -y @invago/mixin doctor
43
+ ```
44
+
45
+ ### `install`
46
+
47
+ Runs:
48
+
49
+ ```bash
50
+ openclaw plugins install @invago/mixin
51
+ ```
52
+
53
+ You can also pass a custom npm spec:
54
+
55
+ ```bash
56
+ npx -y @invago/mixin install @invago/mixin@latest
57
+ ```
58
+
59
+ ### `update`
60
+
61
+ Runs:
62
+
63
+ ```bash
64
+ openclaw plugins install @invago/mixin@latest
65
+ ```
66
+
67
+ Run:
68
+
69
+ ```bash
70
+ npx -y @invago/mixin update
71
+ ```
72
+
73
+ ## Local Development
74
+
75
+ From this repository:
76
+
77
+ ```bash
78
+ node --import jiti/register.js tools/mixin-plugin-onboard/src/index.ts info
79
+ node --import jiti/register.js tools/mixin-plugin-onboard/src/index.ts doctor
80
+ ```
81
+
82
+ Or from the tool directory:
83
+
84
+ ```bash
85
+ cd tools/mixin-plugin-onboard
86
+ npm run info
87
+ npm run doctor
88
+ ```
89
+
90
+ ## Publish
91
+
92
+ This CLI is published together with the main `@invago/mixin` package from the repository root.
93
+
94
+ ## Notes
95
+
96
+ - This CLI is intentionally read-mostly right now.
97
+ - `install` and `update` delegate to the local `openclaw` command.
98
+ - `doctor` currently treats missing `ffprobe` as a failed check because native outbound audio-as-voice depends on it.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import "jiti/register.js";
3
+ await import("../src/index.ts");
@@ -0,0 +1,28 @@
1
+ import { buildContext, checkWritableDir, findMixinPluginDirs, isPluginEnabled, readMixinConfig, runFfprobeCheck } from "../utils.ts";
2
+
3
+ export async function runDoctor(): Promise<number> {
4
+ const ctx = await buildContext();
5
+ const pluginDirs = await findMixinPluginDirs(ctx.extensionsDir);
6
+ const mixinConfig = readMixinConfig(ctx.config);
7
+ const checks = [
8
+ { label: "config_found", ok: Boolean(ctx.configPath) },
9
+ { label: "mixin_config_present", ok: Boolean(mixinConfig) },
10
+ { label: "plugin_enabled", ok: isPluginEnabled(ctx.config) },
11
+ { label: "plugin_installed", ok: pluginDirs.length > 0 },
12
+ { label: "outbox_writable", ok: await checkWritableDir(ctx.outboxDir) },
13
+ { label: "ffprobe_available", ok: runFfprobeCheck() },
14
+ ];
15
+
16
+ const stageDirs = pluginDirs.filter((dir) => dir.includes(".openclaw-install-stage-"));
17
+ console.log(JSON.stringify({
18
+ ok: checks.every((item) => item.ok),
19
+ checks,
20
+ stageDirs,
21
+ pluginDirs,
22
+ configPath: ctx.configPath,
23
+ outboxDir: ctx.outboxDir,
24
+ outboxFile: ctx.outboxFile,
25
+ }, null, 2));
26
+
27
+ return checks.every((item) => item.ok) ? 0 : 1;
28
+ }
@@ -0,0 +1,23 @@
1
+ import { buildContext, findMixinPluginDirs, isPluginEnabled, readMixinConfig, runFfprobeCheck } from "../utils.ts";
2
+
3
+ export async function runInfo(): Promise<number> {
4
+ const ctx = await buildContext();
5
+ const pluginDirs = await findMixinPluginDirs(ctx.extensionsDir);
6
+ const mixinConfig = readMixinConfig(ctx.config);
7
+
8
+ console.log(JSON.stringify({
9
+ homeDir: ctx.homeDir,
10
+ stateDir: ctx.stateDir,
11
+ extensionsDir: ctx.extensionsDir,
12
+ configPath: ctx.configPath,
13
+ pluginDirs,
14
+ pluginEnabled: isPluginEnabled(ctx.config),
15
+ mixinConfigured: Boolean(mixinConfig),
16
+ defaultAccount: typeof mixinConfig?.defaultAccount === "string" ? mixinConfig.defaultAccount : "default",
17
+ outboxDir: ctx.outboxDir,
18
+ outboxFile: ctx.outboxFile,
19
+ ffprobeAvailable: runFfprobeCheck(),
20
+ }, null, 2));
21
+
22
+ return 0;
23
+ }
@@ -0,0 +1,5 @@
1
+ import { runOpenClawInstall } from "../utils.ts";
2
+
3
+ export async function runInstall(spec?: string): Promise<number> {
4
+ return runOpenClawInstall(spec?.trim() || "@invago/mixin");
5
+ }
@@ -0,0 +1,5 @@
1
+ import { runOpenClawInstall } from "../utils.ts";
2
+
3
+ export async function runUpdate(spec?: string): Promise<number> {
4
+ return runOpenClawInstall(spec?.trim() || "@invago/mixin@latest");
5
+ }
@@ -0,0 +1,49 @@
1
+ import { runDoctor } from "./commands/doctor.ts";
2
+ import { runInfo } from "./commands/info.ts";
3
+ import { runInstall } from "./commands/install.ts";
4
+ import { runUpdate } from "./commands/update.ts";
5
+
6
+ function printUsage(): void {
7
+ console.log(`mixin-plugin-onboard <command>
8
+
9
+ Commands:
10
+ info
11
+ doctor
12
+ install [npm-spec]
13
+ update [npm-spec]
14
+ `);
15
+ }
16
+
17
+ async function main(): Promise<void> {
18
+ const [, , command, arg] = process.argv;
19
+ if (!command || command === "help" || command === "--help" || command === "-h") {
20
+ printUsage();
21
+ process.exitCode = 0;
22
+ return;
23
+ }
24
+
25
+ if (command === "info") {
26
+ process.exitCode = await runInfo();
27
+ return;
28
+ }
29
+
30
+ if (command === "doctor") {
31
+ process.exitCode = await runDoctor();
32
+ return;
33
+ }
34
+
35
+ if (command === "install") {
36
+ process.exitCode = await runInstall(arg);
37
+ return;
38
+ }
39
+
40
+ if (command === "update") {
41
+ process.exitCode = await runUpdate(arg);
42
+ return;
43
+ }
44
+
45
+ printUsage();
46
+ process.exitCode = 1;
47
+ }
48
+
49
+ await main();
@@ -0,0 +1,189 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ export type OpenClawContext = {
7
+ homeDir: string;
8
+ stateDir: string;
9
+ extensionsDir: string;
10
+ configPath: string | null;
11
+ config: Record<string, unknown> | null;
12
+ outboxDir: string;
13
+ outboxFile: string;
14
+ };
15
+
16
+ export function resolveHomeDir(env: NodeJS.ProcessEnv = process.env): string {
17
+ const configured = env.OPENCLAW_HOME?.trim();
18
+ if (configured) {
19
+ return configured;
20
+ }
21
+ return path.join(os.homedir(), ".openclaw");
22
+ }
23
+
24
+ export function resolveStateDir(env: NodeJS.ProcessEnv = process.env): string {
25
+ const configured = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
26
+ if (configured) {
27
+ return configured;
28
+ }
29
+ return path.join(resolveHomeDir(env), "state");
30
+ }
31
+
32
+ export function resolveExtensionsDir(env: NodeJS.ProcessEnv = process.env): string {
33
+ return path.join(resolveHomeDir(env), "extensions");
34
+ }
35
+
36
+ export function resolveOutboxPaths(env: NodeJS.ProcessEnv = process.env): {
37
+ outboxDir: string;
38
+ outboxFile: string;
39
+ } {
40
+ const outboxDir = path.join(resolveStateDir(env), "mixin");
41
+ return {
42
+ outboxDir,
43
+ outboxFile: path.join(outboxDir, "mixin-outbox.json"),
44
+ };
45
+ }
46
+
47
+ export async function readConfig(env: NodeJS.ProcessEnv = process.env): Promise<{
48
+ path: string | null;
49
+ config: Record<string, unknown> | null;
50
+ }> {
51
+ const explicit = env.OPENCLAW_CONFIG?.trim();
52
+ const candidates = [
53
+ explicit,
54
+ path.join(resolveHomeDir(env), "openclaw.json"),
55
+ path.join(process.cwd(), "openclaw.json"),
56
+ ].filter((value): value is string => Boolean(value));
57
+
58
+ for (const candidate of candidates) {
59
+ try {
60
+ const raw = await fs.readFile(candidate, "utf8");
61
+ return {
62
+ path: candidate,
63
+ config: parseLooseConfig(raw),
64
+ };
65
+ } catch {
66
+ continue;
67
+ }
68
+ }
69
+
70
+ return {
71
+ path: null,
72
+ config: null,
73
+ };
74
+ }
75
+
76
+ function parseLooseConfig(raw: string): Record<string, unknown> {
77
+ try {
78
+ return JSON.parse(raw) as Record<string, unknown>;
79
+ } catch {
80
+ const relaxed = raw
81
+ .replace(/^\uFEFF/, "")
82
+ .replace(/\/\*[\s\S]*?\*\//g, "")
83
+ .replace(/^\s*\/\/.*$/gm, "")
84
+ .replace(/,\s*([}\]])/g, "$1");
85
+ return JSON.parse(relaxed) as Record<string, unknown>;
86
+ }
87
+ }
88
+
89
+ export async function buildContext(env: NodeJS.ProcessEnv = process.env): Promise<OpenClawContext> {
90
+ const config = await readConfig(env);
91
+ const outbox = resolveOutboxPaths(env);
92
+ return {
93
+ homeDir: resolveHomeDir(env),
94
+ stateDir: resolveStateDir(env),
95
+ extensionsDir: resolveExtensionsDir(env),
96
+ configPath: config.path,
97
+ config: config.config,
98
+ outboxDir: outbox.outboxDir,
99
+ outboxFile: outbox.outboxFile,
100
+ };
101
+ }
102
+
103
+ export async function findMixinPluginDirs(extensionsDir: string): Promise<string[]> {
104
+ try {
105
+ const entries = await fs.readdir(extensionsDir, { withFileTypes: true });
106
+ const matched: string[] = [];
107
+ for (const entry of entries) {
108
+ if (!entry.isDirectory()) {
109
+ continue;
110
+ }
111
+ const dirPath = path.join(extensionsDir, entry.name);
112
+ const openclawPluginPath = path.join(dirPath, "openclaw.plugin.json");
113
+ const packageJsonPath = path.join(dirPath, "package.json");
114
+ try {
115
+ const pluginRaw = await fs.readFile(openclawPluginPath, "utf8");
116
+ if (pluginRaw.includes("\"mixin\"")) {
117
+ matched.push(dirPath);
118
+ continue;
119
+ }
120
+ } catch {
121
+ }
122
+ try {
123
+ const packageRaw = await fs.readFile(packageJsonPath, "utf8");
124
+ if (packageRaw.includes("\"@invago/mixin\"") || packageRaw.includes("\"id\":\"mixin\"")) {
125
+ matched.push(dirPath);
126
+ }
127
+ } catch {
128
+ }
129
+ }
130
+ return matched;
131
+ } catch {
132
+ return [];
133
+ }
134
+ }
135
+
136
+ export async function checkWritableDir(dirPath: string): Promise<boolean> {
137
+ try {
138
+ await fs.mkdir(dirPath, { recursive: true });
139
+ const testPath = path.join(dirPath, `.write-test-${Date.now()}`);
140
+ await fs.writeFile(testPath, "ok", "utf8");
141
+ await fs.rm(testPath, { force: true });
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ export function runOpenClawInstall(spec: string): number {
149
+ const result = spawnSync("openclaw", ["plugins", "install", spec], {
150
+ stdio: "inherit",
151
+ shell: process.platform === "win32",
152
+ });
153
+ return result.status ?? 1;
154
+ }
155
+
156
+ export function runFfprobeCheck(): boolean {
157
+ const result = spawnSync(process.platform === "win32" ? "ffprobe.exe" : "ffprobe", ["-version"], {
158
+ stdio: "ignore",
159
+ shell: false,
160
+ });
161
+ return result.status === 0;
162
+ }
163
+
164
+ export function readMixinConfig(config: Record<string, unknown> | null): Record<string, unknown> | null {
165
+ const channels = config?.channels;
166
+ if (!channels || typeof channels !== "object") {
167
+ return null;
168
+ }
169
+ const mixin = (channels as Record<string, unknown>).mixin;
170
+ return mixin && typeof mixin === "object" ? (mixin as Record<string, unknown>) : null;
171
+ }
172
+
173
+ export function isPluginEnabled(config: Record<string, unknown> | null): boolean {
174
+ const plugins = config?.plugins;
175
+ if (!plugins || typeof plugins !== "object") {
176
+ return false;
177
+ }
178
+ const allow = Array.isArray((plugins as Record<string, unknown>).allow)
179
+ ? ((plugins as Record<string, unknown>).allow as unknown[]).map(String)
180
+ : [];
181
+ const entries = (plugins as Record<string, unknown>).entries;
182
+ const mixinEntry = entries && typeof entries === "object"
183
+ ? (entries as Record<string, unknown>).mixin
184
+ : null;
185
+ const enabled = mixinEntry && typeof mixinEntry === "object"
186
+ ? (mixinEntry as Record<string, unknown>).enabled !== false
187
+ : false;
188
+ return allow.includes("mixin") && enabled;
189
+ }