@meshxdata/fops 0.0.1 → 0.0.4
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/README.md +62 -40
- package/package.json +4 -3
- package/src/agent/agent.js +161 -68
- package/src/agent/agents.js +224 -0
- package/src/agent/context.js +287 -96
- package/src/agent/index.js +1 -0
- package/src/agent/llm.js +134 -20
- package/src/auth/coda.js +128 -0
- package/src/auth/index.js +1 -0
- package/src/auth/login.js +13 -13
- package/src/auth/oauth.js +4 -4
- package/src/commands/index.js +94 -21
- package/src/config.js +2 -2
- package/src/doctor.js +208 -22
- package/src/feature-flags.js +197 -0
- package/src/plugins/api.js +23 -0
- package/src/plugins/builtins/stack-api.js +36 -0
- package/src/plugins/index.js +1 -0
- package/src/plugins/knowledge.js +124 -0
- package/src/plugins/loader.js +67 -0
- package/src/plugins/registry.js +3 -0
- package/src/project.js +20 -1
- package/src/setup/aws.js +7 -7
- package/src/setup/setup.js +18 -12
- package/src/setup/wizard.js +86 -15
- package/src/shell.js +2 -2
- package/src/skills/foundation/SKILL.md +200 -66
- package/src/ui/confirm.js +3 -2
- package/src/ui/input.js +31 -34
- package/src/ui/spinner.js +39 -13
- package/src/ui/streaming.js +2 -2
- package/STRUCTURE.md +0 -43
- package/src/agent/agent.test.js +0 -233
- package/src/agent/context.test.js +0 -81
- package/src/agent/llm.test.js +0 -139
- package/src/auth/keychain.test.js +0 -185
- package/src/auth/login.test.js +0 -192
- package/src/auth/oauth.test.js +0 -118
- package/src/auth/resolve.test.js +0 -153
- package/src/config.test.js +0 -70
- package/src/doctor.test.js +0 -134
- package/src/plugins/api.test.js +0 -95
- package/src/plugins/discovery.test.js +0 -92
- package/src/plugins/hooks.test.js +0 -118
- package/src/plugins/manifest.test.js +0 -106
- package/src/plugins/registry.test.js +0 -43
- package/src/plugins/skills.test.js +0 -173
- package/src/project.test.js +0 -196
- package/src/setup/aws.test.js +0 -280
- package/src/shell.test.js +0 -72
- package/src/ui/banner.test.js +0 -97
- package/src/ui/spinner.test.js +0 -29
package/src/doctor.js
CHANGED
|
@@ -22,7 +22,7 @@ const KEY_PORTS = {
|
|
|
22
22
|
|
|
23
23
|
function header(title) {
|
|
24
24
|
console.log(chalk.bold.cyan(`\n ${title}`));
|
|
25
|
-
console.log(chalk.
|
|
25
|
+
console.log(chalk.dim(" " + "─".repeat(40)));
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
async function checkPort(port) {
|
|
@@ -100,15 +100,15 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
100
100
|
const fixes = []; // collect fix actions to run at the end
|
|
101
101
|
|
|
102
102
|
const ok = (name, detail) => {
|
|
103
|
-
console.log(chalk.green(" ✓ ") + name + (detail ? chalk.
|
|
103
|
+
console.log(chalk.green(" ✓ ") + name + (detail ? chalk.dim(` — ${detail}`) : ""));
|
|
104
104
|
passed++;
|
|
105
105
|
};
|
|
106
106
|
const warn = (name, detail) => {
|
|
107
|
-
console.log(chalk.yellow(" ⚠ ") + name + (detail ? chalk.
|
|
107
|
+
console.log(chalk.yellow(" ⚠ ") + name + (detail ? chalk.dim(` — ${detail}`) : ""));
|
|
108
108
|
warned++;
|
|
109
109
|
};
|
|
110
110
|
const fail = (name, detail, fixFn) => {
|
|
111
|
-
console.log(chalk.red(" ✗ ") + name + (detail ? chalk.
|
|
111
|
+
console.log(chalk.red(" ✗ ") + name + (detail ? chalk.dim(` — ${detail}`) : ""));
|
|
112
112
|
failed++;
|
|
113
113
|
if (fixFn) fixes.push({ name, fn: fixFn });
|
|
114
114
|
};
|
|
@@ -116,6 +116,29 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
116
116
|
// ── Prerequisites ──────────────────────────────────
|
|
117
117
|
header("Prerequisites");
|
|
118
118
|
|
|
119
|
+
// winget (Windows only — needed to install other tools)
|
|
120
|
+
let hasWinget = false;
|
|
121
|
+
if (process.platform === "win32") {
|
|
122
|
+
const wingetVer = await cmdVersion("winget");
|
|
123
|
+
if (wingetVer) {
|
|
124
|
+
ok("winget available", wingetVer);
|
|
125
|
+
hasWinget = true;
|
|
126
|
+
} else {
|
|
127
|
+
fail("winget not found", "needed to install Docker", async () => {
|
|
128
|
+
console.log(chalk.cyan(" ▶ Installing winget via Add-AppxPackage…"));
|
|
129
|
+
await execa("powershell", ["-Command", [
|
|
130
|
+
"$url = (Invoke-RestMethod 'https://api.github.com/repos/microsoft/winget-cli/releases/latest').assets",
|
|
131
|
+
"| Where-Object { $_.name -match '.msixbundle$' } | Select-Object -First 1 -ExpandProperty browser_download_url;",
|
|
132
|
+
"$tmp = Join-Path $env:TEMP 'winget.msixbundle';",
|
|
133
|
+
"Invoke-WebRequest -Uri $url -OutFile $tmp;",
|
|
134
|
+
"Add-AppxPackage -Path $tmp;",
|
|
135
|
+
"Remove-Item $tmp",
|
|
136
|
+
].join(" ")], { stdio: "inherit", timeout: 120_000 });
|
|
137
|
+
hasWinget = true;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
119
142
|
// Docker
|
|
120
143
|
const dockerVer = await cmdVersion("docker");
|
|
121
144
|
if (dockerVer) {
|
|
@@ -124,12 +147,91 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
124
147
|
await execa("docker", ["info"], { timeout: 5000 });
|
|
125
148
|
ok("Docker running", dockerVer);
|
|
126
149
|
} catch {
|
|
127
|
-
fail("Docker daemon not running", "start Docker Desktop or dockerd")
|
|
150
|
+
fail("Docker daemon not running", "start Docker Desktop or dockerd", async () => {
|
|
151
|
+
if (process.platform === "darwin") {
|
|
152
|
+
console.log(chalk.cyan(" ▶ open -a Docker"));
|
|
153
|
+
await execa("open", ["-a", "Docker"], { timeout: 10000 });
|
|
154
|
+
} else if (process.platform === "win32") {
|
|
155
|
+
console.log(chalk.cyan(' ▶ start "" "Docker Desktop"'));
|
|
156
|
+
await execa("cmd", ["/c", "start", "", "Docker Desktop"], { timeout: 10000 });
|
|
157
|
+
} else {
|
|
158
|
+
console.log(chalk.cyan(" ▶ sudo systemctl start docker"));
|
|
159
|
+
await execa("sudo", ["systemctl", "start", "docker"], { stdio: "inherit", timeout: 30000 });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// macOS / Windows: wait for daemon to become ready
|
|
163
|
+
console.log(chalk.dim(" Waiting for Docker daemon to start…"));
|
|
164
|
+
for (let i = 0; i < 30; i++) {
|
|
165
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
166
|
+
try {
|
|
167
|
+
await execa("docker", ["info"], { timeout: 5000 });
|
|
168
|
+
return;
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
throw new Error("Docker daemon did not start within 60 s");
|
|
172
|
+
});
|
|
128
173
|
}
|
|
129
174
|
} else {
|
|
130
|
-
fail("Docker not found", "install from docker.com")
|
|
175
|
+
fail("Docker not found", "install from docker.com", async () => {
|
|
176
|
+
if (process.platform === "darwin") {
|
|
177
|
+
let hasBrew = false;
|
|
178
|
+
try { await execa("brew", ["--version"]); hasBrew = true; } catch {}
|
|
179
|
+
if (!hasBrew) {
|
|
180
|
+
console.log(chalk.cyan(" ▶ Installing Homebrew…"));
|
|
181
|
+
await execa("bash", ["-c", 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'], {
|
|
182
|
+
stdio: "inherit", timeout: 300_000,
|
|
183
|
+
});
|
|
184
|
+
// Add brew to PATH for Apple Silicon
|
|
185
|
+
const brewPaths = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"];
|
|
186
|
+
for (const bp of brewPaths) {
|
|
187
|
+
if (fs.existsSync(bp)) {
|
|
188
|
+
const { stdout } = await execa(bp, ["shellenv"], { timeout: 5000 });
|
|
189
|
+
for (const line of stdout.split("\n")) {
|
|
190
|
+
const m = line.match(/export\s+PATH="([^"]+)"/);
|
|
191
|
+
if (m) process.env.PATH = m[1] + ":" + process.env.PATH;
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
hasBrew = true;
|
|
197
|
+
}
|
|
198
|
+
console.log(chalk.cyan(" ▶ brew install --cask docker"));
|
|
199
|
+
await execa("brew", ["install", "--cask", "docker"], { stdio: "inherit", timeout: 300_000 });
|
|
200
|
+
console.log(chalk.cyan(" ▶ open -a Docker"));
|
|
201
|
+
await execa("open", ["-a", "Docker"], { timeout: 10000 });
|
|
202
|
+
} else if (process.platform === "win32") {
|
|
203
|
+
if (!hasWinget) throw new Error("winget is required to install Docker — fix winget first");
|
|
204
|
+
console.log(chalk.cyan(" ▶ winget install Docker.DockerDesktop"));
|
|
205
|
+
await execa("winget", ["install", "Docker.DockerDesktop", "--accept-source-agreements", "--accept-package-agreements"], {
|
|
206
|
+
stdio: "inherit", timeout: 300_000,
|
|
207
|
+
});
|
|
208
|
+
console.log(chalk.cyan(' ▶ start "" "Docker Desktop"'));
|
|
209
|
+
await execa("cmd", ["/c", "start", "", "Docker Desktop"], { timeout: 10000 });
|
|
210
|
+
} else {
|
|
211
|
+
console.log(chalk.cyan(" ▶ curl -fsSL https://get.docker.com | sudo sh"));
|
|
212
|
+
await execa("sh", ["-c", "curl -fsSL https://get.docker.com | sudo sh"], {
|
|
213
|
+
stdio: "inherit", timeout: 300_000,
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// macOS / Windows: wait for daemon after install
|
|
218
|
+
console.log(chalk.dim(" Waiting for Docker daemon to start…"));
|
|
219
|
+
for (let i = 0; i < 30; i++) {
|
|
220
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
221
|
+
try {
|
|
222
|
+
await execa("docker", ["info"], { timeout: 5000 });
|
|
223
|
+
return;
|
|
224
|
+
} catch {}
|
|
225
|
+
}
|
|
226
|
+
throw new Error("Docker daemon did not start within 60 s");
|
|
227
|
+
});
|
|
131
228
|
}
|
|
132
229
|
|
|
230
|
+
// Docker Compose (bundled with Docker Desktop, but verify)
|
|
231
|
+
const composeVer = await cmdVersion("docker", ["compose", "version"]);
|
|
232
|
+
if (composeVer) ok("Docker Compose", composeVer);
|
|
233
|
+
else fail("Docker Compose not found", "included with Docker Desktop — restart Docker or install the compose plugin");
|
|
234
|
+
|
|
133
235
|
// Git
|
|
134
236
|
const gitVer = await cmdVersion("git");
|
|
135
237
|
if (gitVer) ok("Git available", gitVer);
|
|
@@ -141,6 +243,11 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
141
243
|
if (nodeMajor >= 18) ok(`Node.js v${nodeVer}`, ">=18 required");
|
|
142
244
|
else fail(`Node.js v${nodeVer}`, "upgrade to >=18");
|
|
143
245
|
|
|
246
|
+
// Claude CLI (bundled as a dependency)
|
|
247
|
+
const claudeVer = await cmdVersion("claude");
|
|
248
|
+
if (claudeVer) ok("Claude CLI", claudeVer);
|
|
249
|
+
else fail("Claude CLI not found", "run: npm install (included as a dependency)");
|
|
250
|
+
|
|
144
251
|
// AWS CLI (optional)
|
|
145
252
|
const awsVer = await cmdVersion("aws");
|
|
146
253
|
if (awsVer) ok("AWS CLI", awsVer);
|
|
@@ -316,21 +423,42 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
316
423
|
|
|
317
424
|
// Host disk available
|
|
318
425
|
try {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
426
|
+
let size, avail, pct;
|
|
427
|
+
if (process.platform === "win32") {
|
|
428
|
+
const { stdout: wmicOut } = await execa("wmic", ["logicaldisk", "where", "DeviceID='C:'", "get", "Size,FreeSpace", "/format:csv"], {
|
|
429
|
+
timeout: 5000, reject: false,
|
|
430
|
+
});
|
|
431
|
+
if (wmicOut) {
|
|
432
|
+
const parts = wmicOut.trim().split("\n").pop()?.split(",");
|
|
433
|
+
if (parts?.length >= 3) {
|
|
434
|
+
const free = parseInt(parts[1], 10);
|
|
435
|
+
const total = parseInt(parts[2], 10);
|
|
436
|
+
if (total > 0) {
|
|
437
|
+
size = `${(total / (1024 ** 3)).toFixed(0)} GB`;
|
|
438
|
+
avail = `${(free / (1024 ** 3)).toFixed(0)} GB`;
|
|
439
|
+
pct = Math.round(((total - free) / total) * 100);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
const { stdout: dfHost } = await execa("df", ["-h", "/"], {
|
|
445
|
+
timeout: 5000, reject: false,
|
|
446
|
+
});
|
|
447
|
+
if (dfHost) {
|
|
448
|
+
const lines = dfHost.trim().split("\n");
|
|
449
|
+
if (lines.length >= 2) {
|
|
450
|
+
const cols = lines[lines.length - 1].split(/\s+/);
|
|
451
|
+
size = cols[1];
|
|
452
|
+
avail = cols[3];
|
|
453
|
+
pct = parseInt(cols[4], 10);
|
|
454
|
+
}
|
|
332
455
|
}
|
|
333
456
|
}
|
|
457
|
+
if (size && avail && pct != null) {
|
|
458
|
+
if (pct >= 90) fail(`Host disk: ${avail} free of ${size}`, "critically low — Docker needs room");
|
|
459
|
+
else if (pct >= 80) warn(`Host disk: ${avail} free of ${size}`, "getting low");
|
|
460
|
+
else ok(`Host disk: ${avail} free of ${size}`);
|
|
461
|
+
}
|
|
334
462
|
} catch {}
|
|
335
463
|
|
|
336
464
|
// Port conflicts
|
|
@@ -386,6 +514,64 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
386
514
|
}
|
|
387
515
|
}
|
|
388
516
|
|
|
517
|
+
// ── Logs ──────────────────────────────────────────────
|
|
518
|
+
if (dir && dockerVer) {
|
|
519
|
+
header("Logs");
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
const { stdout: logOut } = await execa("docker", [
|
|
523
|
+
"compose", "logs", "--tail", "50", "--no-color",
|
|
524
|
+
], { cwd: dir, reject: false, timeout: 30000 });
|
|
525
|
+
|
|
526
|
+
if (logOut?.trim()) {
|
|
527
|
+
const serviceIssues = {};
|
|
528
|
+
const ERROR_RE = /\b(ERROR|FATAL|PANIC|CRITICAL)\b/;
|
|
529
|
+
const CRASH_RE = /\b(OOM|OutOfMemory|out of memory|segmentation fault|segfault)\b/i;
|
|
530
|
+
const CONN_RE = /\b(ECONNREFUSED|ETIMEDOUT|connection refused)\b/i;
|
|
531
|
+
|
|
532
|
+
for (const line of logOut.split("\n")) {
|
|
533
|
+
const sep = line.indexOf(" | ");
|
|
534
|
+
if (sep === -1) continue;
|
|
535
|
+
const service = line.slice(0, sep).trim();
|
|
536
|
+
const msg = line.slice(sep + 3);
|
|
537
|
+
|
|
538
|
+
let level = null;
|
|
539
|
+
if (CRASH_RE.test(msg)) level = "crash";
|
|
540
|
+
else if (ERROR_RE.test(msg)) level = "error";
|
|
541
|
+
else if (CONN_RE.test(msg)) level = "conn";
|
|
542
|
+
else continue;
|
|
543
|
+
|
|
544
|
+
if (!serviceIssues[service]) serviceIssues[service] = { errors: 0, crashes: 0, conn: 0, last: "" };
|
|
545
|
+
const entry = serviceIssues[service];
|
|
546
|
+
if (level === "crash") entry.crashes++;
|
|
547
|
+
else if (level === "conn") entry.conn++;
|
|
548
|
+
else entry.errors++;
|
|
549
|
+
entry.last = msg.trim();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const services = Object.keys(serviceIssues);
|
|
553
|
+
if (services.length === 0) {
|
|
554
|
+
ok("No errors in recent logs");
|
|
555
|
+
} else {
|
|
556
|
+
for (const svc of services) {
|
|
557
|
+
const { errors, crashes, conn, last } = serviceIssues[svc];
|
|
558
|
+
const parts = [];
|
|
559
|
+
if (crashes) parts.push(`${crashes} crash`);
|
|
560
|
+
if (errors) parts.push(`${errors} error${errors > 1 ? "s" : ""}`);
|
|
561
|
+
if (conn) parts.push(`${conn} conn issue${conn > 1 ? "s" : ""}`);
|
|
562
|
+
const sample = last.length > 120 ? last.slice(0, 120) + "…" : last;
|
|
563
|
+
if (crashes) fail(svc, `${parts.join(", ")} — ${sample}`);
|
|
564
|
+
else warn(svc, `${parts.join(", ")} — ${sample}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
} else {
|
|
568
|
+
ok("No log output", "services may not be running");
|
|
569
|
+
}
|
|
570
|
+
} catch {
|
|
571
|
+
warn("Could not fetch logs", "Docker Compose error");
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
389
575
|
// ── Images ──────────────────────────────────────────
|
|
390
576
|
if (dir && dockerVer) {
|
|
391
577
|
header("Images");
|
|
@@ -451,12 +637,12 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
451
637
|
}
|
|
452
638
|
|
|
453
639
|
// ── Summary ────────────────────────────────────────
|
|
454
|
-
console.log(chalk.
|
|
640
|
+
console.log(chalk.dim("\n " + "─".repeat(40)));
|
|
455
641
|
const parts = [];
|
|
456
642
|
if (passed) parts.push(chalk.green(`${passed} passed`));
|
|
457
643
|
if (warned) parts.push(chalk.yellow(`${warned} warnings`));
|
|
458
644
|
if (failed) parts.push(chalk.red(`${failed} failed`));
|
|
459
|
-
console.log(" " + parts.join(chalk.
|
|
645
|
+
console.log(" " + parts.join(chalk.dim(" · ")));
|
|
460
646
|
|
|
461
647
|
if (failed > 0 && fixes.length > 0) {
|
|
462
648
|
const shouldFix = opts.fix || await confirm(`\n Fix ${fixes.length} issue(s) automatically?`, true);
|
|
@@ -471,7 +657,7 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
471
657
|
console.log(chalk.red(` ✗ Fix failed: ${err.message}\n`));
|
|
472
658
|
}
|
|
473
659
|
}
|
|
474
|
-
console.log(chalk.
|
|
660
|
+
console.log(chalk.dim(" Run fops doctor again to verify.\n"));
|
|
475
661
|
} else {
|
|
476
662
|
console.log("");
|
|
477
663
|
process.exit(1);
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { execa } from "execa";
|
|
5
|
+
import inquirer from "inquirer";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Canonical feature flags — the complete set known to the platform.
|
|
9
|
+
* Label is a human-readable short description for the toggle UI.
|
|
10
|
+
*/
|
|
11
|
+
const KNOWN_FLAGS = {
|
|
12
|
+
MX_FF_STORAGE_EXPLORER_ENABLED: "Storage Explorer",
|
|
13
|
+
MX_FF_USER_MANAGEMENT_ENABLED: "User Management",
|
|
14
|
+
MX_FF_SETTINGS_PAGE_ENABLED: "Settings Page",
|
|
15
|
+
MX_FF_ENCRYPTION_STATUS_DISPLAY: "Encryption Status",
|
|
16
|
+
MX_FF_USER_PAT_ENABLED: "User PAT (Personal Access Tokens)",
|
|
17
|
+
MX_FF_SENTRY_ENABLED: "Sentry Error Tracking",
|
|
18
|
+
MX_FF_EXPLORER_ENABLED: "Data Explorer",
|
|
19
|
+
MX_FF_NEW_PROFILE_ENABLED: "New Profile Page",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse docker-compose.yaml for all MX_FF_* entries.
|
|
24
|
+
* Returns a map: flagName → { value, services: Set<string>, lines: [{ lineNum, original }] }
|
|
25
|
+
*/
|
|
26
|
+
function parseComposeFlags(composePath) {
|
|
27
|
+
const content = fs.readFileSync(composePath, "utf8");
|
|
28
|
+
const lines = content.split("\n");
|
|
29
|
+
const flags = {};
|
|
30
|
+
|
|
31
|
+
// Track which service block we're in
|
|
32
|
+
let currentService = null;
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < lines.length; i++) {
|
|
35
|
+
const line = lines[i];
|
|
36
|
+
|
|
37
|
+
// Service definition: exactly 2-space indent, ends with colon only
|
|
38
|
+
const svcMatch = line.match(/^ ([a-z][\w-]+):\s*$/);
|
|
39
|
+
if (svcMatch) {
|
|
40
|
+
currentService = svcMatch[1];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Match MX_FF_* in both YAML map and list formats
|
|
44
|
+
// Map format: MX_FF_NAME: "value"
|
|
45
|
+
const mapMatch = line.match(/\b(MX_FF_\w+)\s*:\s*"?(true|false)"?/);
|
|
46
|
+
// List format: - MX_FF_NAME=value
|
|
47
|
+
const listMatch = !mapMatch && line.match(/[-]\s*(MX_FF_\w+)\s*=\s*(true|false)/);
|
|
48
|
+
|
|
49
|
+
const match = mapMatch || listMatch;
|
|
50
|
+
if (match) {
|
|
51
|
+
const name = match[1];
|
|
52
|
+
const value = match[2] === "true";
|
|
53
|
+
|
|
54
|
+
if (!flags[name]) {
|
|
55
|
+
flags[name] = { value, services: new Set(), lines: [] };
|
|
56
|
+
}
|
|
57
|
+
flags[name].lines.push({ lineNum: i, original: line });
|
|
58
|
+
if (currentService) flags[name].services.add(currentService);
|
|
59
|
+
// If any occurrence is true, treat the flag as enabled
|
|
60
|
+
if (value) flags[name].value = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return flags;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Update docker-compose.yaml by flipping flag values on specific lines.
|
|
69
|
+
*/
|
|
70
|
+
function updateComposeFlags(composePath, changes) {
|
|
71
|
+
const content = fs.readFileSync(composePath, "utf8");
|
|
72
|
+
const lines = content.split("\n");
|
|
73
|
+
|
|
74
|
+
for (const { lineNum, newValue } of changes) {
|
|
75
|
+
const line = lines[lineNum];
|
|
76
|
+
lines[lineNum] = line
|
|
77
|
+
.replace(/(MX_FF_\w+\s*:\s*)"?(true|false)"?/, `$1"${newValue}"`)
|
|
78
|
+
.replace(/(MX_FF_\w+=)(true|false)/, `$1${newValue}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fs.writeFileSync(composePath, lines.join("\n"));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Interactive feature flag configuration.
|
|
86
|
+
* Reads flags from compose, presents toggle UI, applies changes, restarts services.
|
|
87
|
+
*/
|
|
88
|
+
export async function runFeatureFlags(root) {
|
|
89
|
+
const composePath = path.join(root, "docker-compose.yaml");
|
|
90
|
+
if (!fs.existsSync(composePath)) {
|
|
91
|
+
console.log(chalk.red(" No docker-compose.yaml found."));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(chalk.bold.cyan("\n Feature Flags\n"));
|
|
96
|
+
|
|
97
|
+
// Parse current state from compose
|
|
98
|
+
const composeFlags = parseComposeFlags(composePath);
|
|
99
|
+
|
|
100
|
+
// Build the full flag list: compose flags + canonical flags not yet in compose
|
|
101
|
+
const allFlags = {};
|
|
102
|
+
for (const [name, info] of Object.entries(composeFlags)) {
|
|
103
|
+
allFlags[name] = { ...info, inCompose: true };
|
|
104
|
+
}
|
|
105
|
+
for (const name of Object.keys(KNOWN_FLAGS)) {
|
|
106
|
+
if (!allFlags[name]) {
|
|
107
|
+
allFlags[name] = { value: false, services: new Set(), lines: [], inCompose: false };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const flagNames = Object.keys(allFlags).sort();
|
|
112
|
+
|
|
113
|
+
// Show current state
|
|
114
|
+
for (const name of flagNames) {
|
|
115
|
+
const flag = allFlags[name];
|
|
116
|
+
const label = KNOWN_FLAGS[name] || name;
|
|
117
|
+
const services = flag.services.size > 0 ? chalk.dim(` (${[...flag.services].join(", ")})`) : "";
|
|
118
|
+
if (flag.value) {
|
|
119
|
+
console.log(chalk.green(` ✓ ${label}`) + services);
|
|
120
|
+
} else {
|
|
121
|
+
console.log(chalk.dim(` · ${label}`) + services);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
console.log("");
|
|
125
|
+
|
|
126
|
+
// Checkbox prompt
|
|
127
|
+
const choices = flagNames.map((name) => ({
|
|
128
|
+
name: KNOWN_FLAGS[name] || name,
|
|
129
|
+
value: name,
|
|
130
|
+
checked: allFlags[name].value,
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
const { enabled } = await inquirer.prompt([{
|
|
134
|
+
type: "checkbox",
|
|
135
|
+
name: "enabled",
|
|
136
|
+
message: "Toggle feature flags:",
|
|
137
|
+
choices,
|
|
138
|
+
}]);
|
|
139
|
+
|
|
140
|
+
// Calculate changes
|
|
141
|
+
const changes = [];
|
|
142
|
+
const affectedServices = new Set();
|
|
143
|
+
|
|
144
|
+
for (const name of flagNames) {
|
|
145
|
+
const flag = allFlags[name];
|
|
146
|
+
const newValue = enabled.includes(name);
|
|
147
|
+
|
|
148
|
+
if (newValue !== flag.value) {
|
|
149
|
+
if (flag.inCompose) {
|
|
150
|
+
for (const line of flag.lines) {
|
|
151
|
+
changes.push({ lineNum: line.lineNum, newValue: String(newValue) });
|
|
152
|
+
}
|
|
153
|
+
for (const svc of flag.services) affectedServices.add(svc);
|
|
154
|
+
} else if (newValue) {
|
|
155
|
+
console.log(chalk.yellow(` ⚠ ${KNOWN_FLAGS[name] || name} not in docker-compose.yaml — add it to service environments to take effect`));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (changes.length === 0) {
|
|
161
|
+
console.log(chalk.dim("\n No changes.\n"));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Apply changes to compose file
|
|
166
|
+
updateComposeFlags(composePath, changes);
|
|
167
|
+
console.log(chalk.green(`\n ✓ Updated ${changes.length} flag value(s) in docker-compose.yaml`));
|
|
168
|
+
|
|
169
|
+
if (affectedServices.size === 0) {
|
|
170
|
+
console.log("");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Restart affected services
|
|
175
|
+
const serviceList = [...affectedServices];
|
|
176
|
+
console.log(chalk.dim(` Affected: ${serviceList.join(", ")}`));
|
|
177
|
+
|
|
178
|
+
const { restart } = await inquirer.prompt([{
|
|
179
|
+
type: "confirm",
|
|
180
|
+
name: "restart",
|
|
181
|
+
message: `Restart ${serviceList.length} service(s)?`,
|
|
182
|
+
default: true,
|
|
183
|
+
}]);
|
|
184
|
+
|
|
185
|
+
if (restart) {
|
|
186
|
+
console.log(chalk.cyan(`\n ▶ docker compose up -d ${serviceList.join(" ")}\n`));
|
|
187
|
+
await execa("docker", ["compose", "up", "-d", ...serviceList], {
|
|
188
|
+
cwd: root,
|
|
189
|
+
stdio: "inherit",
|
|
190
|
+
reject: false,
|
|
191
|
+
timeout: 120_000,
|
|
192
|
+
});
|
|
193
|
+
console.log(chalk.green("\n ✓ Services restarted.\n"));
|
|
194
|
+
} else {
|
|
195
|
+
console.log(chalk.dim("\n Changes saved. Restart manually: docker compose up -d\n"));
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/plugins/api.js
CHANGED
|
@@ -33,5 +33,28 @@ export function createPluginApi(pluginId, registry) {
|
|
|
33
33
|
registerHook(event, handler, priority = 0) {
|
|
34
34
|
registry.hooks.push({ pluginId, event, handler, priority });
|
|
35
35
|
},
|
|
36
|
+
|
|
37
|
+
registerKnowledgeSource(source) {
|
|
38
|
+
registry.knowledgeSources.push({
|
|
39
|
+
pluginId,
|
|
40
|
+
name: source.name,
|
|
41
|
+
description: source.description || "",
|
|
42
|
+
search: source.search,
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
registerAutoRunPattern(pattern) {
|
|
47
|
+
registry.autoRunPatterns.push({ pluginId, pattern });
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
registerAgent(agent) {
|
|
51
|
+
registry.agents.push({
|
|
52
|
+
pluginId,
|
|
53
|
+
name: agent.name,
|
|
54
|
+
description: agent.description || "",
|
|
55
|
+
systemPrompt: agent.systemPrompt,
|
|
56
|
+
contextMode: agent.contextMode || "full",
|
|
57
|
+
});
|
|
58
|
+
},
|
|
36
59
|
};
|
|
37
60
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in Stack API plugin.
|
|
5
|
+
* Registers a doctor check (health ping) and auto-run patterns for curl commands.
|
|
6
|
+
*/
|
|
7
|
+
export function register(api) {
|
|
8
|
+
// Doctor check — ping GET /health on localhost:3090
|
|
9
|
+
api.registerDoctorCheck({
|
|
10
|
+
name: "Stack API",
|
|
11
|
+
fn: async (ok, warn) => {
|
|
12
|
+
try {
|
|
13
|
+
const body = await new Promise((resolve, reject) => {
|
|
14
|
+
const req = http.get("http://localhost:3090/health", { timeout: 3000 }, (res) => {
|
|
15
|
+
let data = "";
|
|
16
|
+
res.on("data", (chunk) => { data += chunk; });
|
|
17
|
+
res.on("end", () => {
|
|
18
|
+
if (res.statusCode === 200) resolve(data);
|
|
19
|
+
else reject(new Error(`HTTP ${res.statusCode}`));
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
req.on("error", reject);
|
|
23
|
+
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
|
|
24
|
+
});
|
|
25
|
+
ok("Stack API", `healthy — ${body.trim().slice(0, 60)}`);
|
|
26
|
+
} catch {
|
|
27
|
+
warn("Stack API", "not reachable on localhost:3090");
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Auto-run: GET curl commands to the stack API execute without confirmation
|
|
33
|
+
api.registerAutoRunPattern("curl http://localhost:3090/");
|
|
34
|
+
api.registerAutoRunPattern("curl -s http://localhost:3090/");
|
|
35
|
+
api.registerAutoRunPattern("curl --silent http://localhost:3090/");
|
|
36
|
+
}
|
package/src/plugins/index.js
CHANGED