mop-agent 0.1.0

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.
Files changed (86) hide show
  1. package/README.md +177 -0
  2. package/apps/web/.env.example +18 -0
  3. package/apps/web/app/api/actions/[id]/approve/route.ts +15 -0
  4. package/apps/web/app/api/actions/[id]/deny/route.ts +15 -0
  5. package/apps/web/app/api/actions/route.ts +29 -0
  6. package/apps/web/app/api/auth/[...all]/route.ts +4 -0
  7. package/apps/web/app/api/chat/route.ts +50 -0
  8. package/apps/web/app/api/consolidate/route.ts +10 -0
  9. package/apps/web/app/api/graph/route.ts +34 -0
  10. package/apps/web/app/api/invites/route.ts +38 -0
  11. package/apps/web/app/api/link/code/route.ts +13 -0
  12. package/apps/web/app/api/link/pair/route.ts +41 -0
  13. package/apps/web/app/api/me/route.ts +11 -0
  14. package/apps/web/app/api/members/route.ts +16 -0
  15. package/apps/web/app/api/projects/[id]/memory/route.ts +12 -0
  16. package/apps/web/app/api/projects/[id]/state/route.ts +19 -0
  17. package/apps/web/app/api/projects/route.ts +21 -0
  18. package/apps/web/app/api/providers/route.ts +32 -0
  19. package/apps/web/app/api/semantic/route.ts +9 -0
  20. package/apps/web/app/api/setup/status/route.ts +6 -0
  21. package/apps/web/app/api/skills/route.ts +23 -0
  22. package/apps/web/app/brain/[projectId]/page.tsx +50 -0
  23. package/apps/web/app/brain/graph/page.tsx +54 -0
  24. package/apps/web/app/brain/page.tsx +167 -0
  25. package/apps/web/app/chat/[projectId]/page.tsx +113 -0
  26. package/apps/web/app/layout.tsx +24 -0
  27. package/apps/web/app/page.tsx +72 -0
  28. package/apps/web/app/settings/page.tsx +63 -0
  29. package/apps/web/app/setup/page.tsx +113 -0
  30. package/apps/web/app/team/page.tsx +86 -0
  31. package/apps/web/bin/mop-agent.mjs +85 -0
  32. package/apps/web/lib/auth-client.ts +5 -0
  33. package/apps/web/lib/auth.ts +86 -0
  34. package/apps/web/lib/authz.ts +23 -0
  35. package/apps/web/lib/brain/answer.ts +27 -0
  36. package/apps/web/lib/brain/approvals.ts +81 -0
  37. package/apps/web/lib/brain/broker.ts +98 -0
  38. package/apps/web/lib/brain/consolidate.ts +133 -0
  39. package/apps/web/lib/brain/mirror.ts +80 -0
  40. package/apps/web/lib/brain/scheduler.ts +30 -0
  41. package/apps/web/lib/brain/skills.ts +34 -0
  42. package/apps/web/lib/channels/binding.ts +26 -0
  43. package/apps/web/lib/channels/discord.ts +28 -0
  44. package/apps/web/lib/channels/handler.ts +44 -0
  45. package/apps/web/lib/channels/index.ts +18 -0
  46. package/apps/web/lib/channels/telegram.ts +18 -0
  47. package/apps/web/lib/crypto.ts +35 -0
  48. package/apps/web/lib/db/client.ts +34 -0
  49. package/apps/web/lib/db/migrate.ts +116 -0
  50. package/apps/web/lib/db/paths.ts +25 -0
  51. package/apps/web/lib/db/schema.ts +105 -0
  52. package/apps/web/lib/link/store.ts +89 -0
  53. package/apps/web/lib/memory/embed.ts +111 -0
  54. package/apps/web/lib/memory/local-embedder.ts +26 -0
  55. package/apps/web/lib/providers/anthropic.ts +23 -0
  56. package/apps/web/lib/providers/config.ts +55 -0
  57. package/apps/web/lib/providers/echo.ts +26 -0
  58. package/apps/web/lib/providers/index.ts +41 -0
  59. package/apps/web/lib/providers/openrouter.ts +24 -0
  60. package/apps/web/lib/providers/types.ts +14 -0
  61. package/apps/web/lib/ws/gateway.ts +113 -0
  62. package/apps/web/next-env.d.ts +6 -0
  63. package/apps/web/next.config.mjs +9 -0
  64. package/apps/web/package.json +44 -0
  65. package/apps/web/scripts/migrate.ts +12 -0
  66. package/apps/web/server.ts +27 -0
  67. package/apps/web/tsconfig.json +31 -0
  68. package/installer/bootstrap.mjs +161 -0
  69. package/installer/lib.mjs +196 -0
  70. package/installer/mop-agent.mjs +322 -0
  71. package/npm-shrinkwrap.json +5032 -0
  72. package/package.json +71 -0
  73. package/packages/flow-connector/bin/cli.mjs +67 -0
  74. package/packages/flow-connector/package.json +26 -0
  75. package/packages/flow-connector/src/exec.ts +81 -0
  76. package/packages/flow-connector/src/index.ts +17 -0
  77. package/packages/flow-connector/src/linkfile.ts +46 -0
  78. package/packages/flow-connector/src/pair.ts +66 -0
  79. package/packages/flow-connector/src/serve.ts +103 -0
  80. package/packages/flow-connector/src/snapshot.ts +94 -0
  81. package/packages/flow-connector/src/tools.ts +198 -0
  82. package/packages/flow-connector/tsconfig.json +10 -0
  83. package/packages/link-protocol/package.json +17 -0
  84. package/packages/link-protocol/src/index.ts +245 -0
  85. package/packages/link-protocol/tsconfig.json +10 -0
  86. package/tsconfig.base.json +18 -0
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * npm bootstrap for the canonical command: `npx mop-agent`.
4
+ *
5
+ * The npx cache is temporary, so this file copies the packaged application to
6
+ * a durable directory before launching the real installer. It starts as the
7
+ * normal user and uses sudo only to create/fix ownership of the system app dir.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import {
11
+ accessSync,
12
+ constants,
13
+ cpSync,
14
+ existsSync,
15
+ readFileSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { platform } from "node:os";
19
+ import { dirname, relative, resolve, sep } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
23
+ const manifest = JSON.parse(readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf8"));
24
+ const VERSION = manifest.version;
25
+ const DEFAULT_DIR = "/opt/mop-agent";
26
+ const APP_DIR = resolve(process.env.MOP_AGENT_DIR || DEFAULT_DIR);
27
+ const argv = process.argv.slice(2);
28
+ const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
29
+
30
+ function fail(message) {
31
+ console.error(`\n✗ ${message}\n`);
32
+ process.exit(1);
33
+ }
34
+
35
+ function run(command, args, options = {}) {
36
+ const result = spawnSync(command, args, { stdio: "inherit", ...options });
37
+ if (result.error) fail(`${command} failed: ${result.error.message}`);
38
+ if (result.status !== 0) fail(`${command} exited with code ${result.status ?? 1}.`);
39
+ }
40
+
41
+ function help() {
42
+ console.log(`
43
+ MOP-AGENT ${VERSION}
44
+
45
+ Usage:
46
+ npx mop-agent Open the installer menu
47
+ npx mop-agent install Install nginx and Certbot
48
+ npx mop-agent setup Configure domain, HTTPS, app and systemd
49
+ npx mop-agent status Show service health and file locations
50
+ npx mop-agent update Apply the npm version selected by npx
51
+ npx mop-agent uninstall Remove service and nginx config
52
+
53
+ Environment:
54
+ MOP_AGENT_DIR Durable app directory (default: ${DEFAULT_DIR})
55
+
56
+ Run this command as your normal user. MOP-AGENT asks for sudo only when an OS
57
+ operation requires it. Native Windows/macOS production installation is not yet
58
+ supported; use WSL2 Ubuntu on Windows or a Linux host.
59
+ `);
60
+ }
61
+
62
+ function assertPlatform() {
63
+ if (platform() === "linux") return;
64
+ if (platform() === "win32") {
65
+ fail("Native Windows installation is not supported. Run `npx mop-agent` inside WSL2 Ubuntu.");
66
+ }
67
+ if (platform() === "darwin") {
68
+ fail("macOS production installation is not supported yet. Use development mode or a Linux host.");
69
+ }
70
+ fail(`Unsupported platform: ${platform()}.`);
71
+ }
72
+
73
+ function assertSafeDestination() {
74
+ const forbidden = new Set(["/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/proc", "/root", "/run", "/sbin", "/sys", "/usr", "/var"]);
75
+ if (forbidden.has(APP_DIR)) fail(`Refusing unsafe MOP_AGENT_DIR: ${APP_DIR}`);
76
+ }
77
+
78
+ function writable(path) {
79
+ try {
80
+ accessSync(path, constants.W_OK);
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ function ensureDestination() {
88
+ if (existsSync(APP_DIR) && writable(APP_DIR)) return;
89
+ if (isRoot) fail("Do not run `npx mop-agent` with sudo/root. Run it as your normal user.");
90
+ const uid = String(process.getuid());
91
+ const gid = String(process.getgid());
92
+ console.log(`\n▸ Preparing ${APP_DIR} (sudo is required once for this system directory)`);
93
+ run("sudo", ["install", "-d", "-o", uid, "-g", gid, APP_DIR]);
94
+ if (!writable(APP_DIR)) {
95
+ run("sudo", ["chown", "-R", `${uid}:${gid}`, APP_DIR]);
96
+ }
97
+ }
98
+
99
+ function shouldCopy(source) {
100
+ const rel = relative(PACKAGE_ROOT, source);
101
+ if (!rel) return true;
102
+ const first = rel.split(sep)[0];
103
+ if (["node_modules", ".git", ".next", "data"].includes(first)) return false;
104
+ if (rel === "apps/web/.env") return false;
105
+ return true;
106
+ }
107
+
108
+ function deployPackage() {
109
+ const marker = resolve(APP_DIR, ".mop-agent-version");
110
+ const current = existsSync(marker) ? readFileSync(marker, "utf8").trim() : "";
111
+ const ready = current === VERSION && existsSync(resolve(APP_DIR, "installer/mop-agent.mjs"));
112
+ if (ready) {
113
+ console.log(`✓ MOP-AGENT ${VERSION} already staged at ${APP_DIR}`);
114
+ return;
115
+ }
116
+
117
+ console.log(`\n▸ Staging MOP-AGENT ${VERSION} → ${APP_DIR}`);
118
+ cpSync(PACKAGE_ROOT, APP_DIR, {
119
+ recursive: true,
120
+ force: true,
121
+ filter: shouldCopy,
122
+ });
123
+
124
+ console.log("▸ Installing application dependencies");
125
+ const lock = existsSync(resolve(APP_DIR, "npm-shrinkwrap.json"));
126
+ run("npm", [lock ? "ci" : "install", "--include=dev", "--no-audit", "--no-fund"], { cwd: APP_DIR });
127
+ writeFileSync(marker, `${VERSION}\n`, { mode: 0o644 });
128
+ console.log(`✓ MOP-AGENT ${VERSION} staged\n`);
129
+ }
130
+
131
+ function selfTest() {
132
+ const required = [
133
+ "installer/mop-agent.mjs",
134
+ "installer/lib.mjs",
135
+ "apps/web/package.json",
136
+ "apps/web/server.ts",
137
+ "packages/link-protocol/package.json",
138
+ "packages/flow-connector/package.json",
139
+ ];
140
+ const missing = required.filter((file) => !existsSync(resolve(PACKAGE_ROOT, file)));
141
+ if (missing.length) fail(`Package is missing runtime files: ${missing.join(", ")}`);
142
+ console.log(`bootstrap self-test PASS (${VERSION})`);
143
+ }
144
+
145
+ if (argv.includes("--help") || argv.includes("-h")) {
146
+ help();
147
+ } else if (argv.includes("--version") || argv.includes("-v")) {
148
+ console.log(VERSION);
149
+ } else if (argv.includes("--self-test")) {
150
+ selfTest();
151
+ } else {
152
+ assertPlatform();
153
+ assertSafeDestination();
154
+ if (isRoot) fail("Do not run `npx mop-agent` with sudo/root. Run it as your normal user.");
155
+ ensureDestination();
156
+ deployPackage();
157
+ run(process.execPath, [resolve(APP_DIR, "installer/mop-agent.mjs"), ...argv], {
158
+ cwd: APP_DIR,
159
+ env: { ...process.env, MOP_AGENT_MANAGED_BY_NPX: "1" },
160
+ });
161
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * MOP-AGENT installer library — OS detection, config generators, and step plans.
3
+ * Pure / side-effect-free where possible so it's unit-testable. Execution lives
4
+ * in mop-agent.mjs (guarded by --dry-run / root check).
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { platform } from "node:os";
8
+
9
+ export const colors = {
10
+ reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
11
+ green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m", gray: "\x1b[90m",
12
+ };
13
+ export const c = (name, v) => `${colors[name] ?? ""}${v}${colors.reset}`;
14
+
15
+ /** Detect platform + distro family + package manager commands. */
16
+ export function detectOS() {
17
+ const plat = platform();
18
+ if (plat !== "linux") {
19
+ return { platform: plat, distro: plat, family: "unsupported", pkg: null };
20
+ }
21
+ let id = "", idLike = "", pretty = "Linux";
22
+ try {
23
+ const os = readFileSync("/etc/os-release", "utf8");
24
+ id = (os.match(/^ID=(.*)$/m)?.[1] ?? "").replace(/"/g, "");
25
+ idLike = (os.match(/^ID_LIKE=(.*)$/m)?.[1] ?? "").replace(/"/g, "");
26
+ pretty = (os.match(/^PRETTY_NAME=(.*)$/m)?.[1] ?? "Linux").replace(/"/g, "");
27
+ } catch {
28
+ /* no /etc/os-release */
29
+ }
30
+ const hay = `${id} ${idLike}`.toLowerCase();
31
+ let family = "unknown", pkg = null;
32
+ if (/debian|ubuntu|kali|mint/.test(hay)) {
33
+ family = "debian";
34
+ pkg = { update: "apt-get update -y", install: "DEBIAN_FRONTEND=noninteractive apt-get install -y",
35
+ pkgs: { nginx: "nginx", certbot: "certbot python3-certbot-nginx" } };
36
+ } else if (/rhel|fedora|centos|rocky|alma/.test(hay)) {
37
+ family = "rhel";
38
+ pkg = { update: "dnf -y makecache", install: "dnf install -y",
39
+ pkgs: { nginx: "nginx", certbot: "certbot python3-certbot-nginx" } };
40
+ } else if (/arch|manjaro/.test(hay)) {
41
+ family = "arch";
42
+ pkg = { update: "pacman -Sy --noconfirm", install: "pacman -S --noconfirm",
43
+ pkgs: { nginx: "nginx", certbot: "certbot certbot-nginx" } };
44
+ } else if (/alpine/.test(hay)) {
45
+ family = "alpine";
46
+ pkg = { update: "apk update", install: "apk add",
47
+ pkgs: { nginx: "nginx", certbot: "certbot certbot-nginx" } };
48
+ }
49
+ return { platform: plat, distro: id || "linux", pretty, family, pkg };
50
+ }
51
+
52
+ /** nginx reverse-proxy vhost — includes WebSocket upgrade for /link + streaming chat. */
53
+ export function renderNginxVhost({ domain, port }) {
54
+ return `# Managed by MOP-AGENT installer
55
+ server {
56
+ listen 80;
57
+ listen [::]:80;
58
+ server_name ${domain};
59
+
60
+ location / {
61
+ proxy_pass http://127.0.0.1:${port};
62
+ proxy_http_version 1.1;
63
+ proxy_set_header Upgrade $http_upgrade;
64
+ proxy_set_header Connection "upgrade";
65
+ proxy_set_header Host $host;
66
+ proxy_set_header X-Real-IP $remote_addr;
67
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
68
+ proxy_set_header X-Forwarded-Proto $scheme;
69
+ proxy_read_timeout 86400;
70
+ }
71
+ }
72
+ `;
73
+ }
74
+
75
+ /** nginx TLS vhost used after Certbot's standalone fallback. */
76
+ export function renderNginxTlsVhost({ domain, port }) {
77
+ return `# Managed by MOP-AGENT installer
78
+ server {
79
+ listen 80;
80
+ listen [::]:80;
81
+ server_name ${domain};
82
+ return 301 https://$host$request_uri;
83
+ }
84
+
85
+ server {
86
+ listen 443 ssl;
87
+ listen [::]:443 ssl;
88
+ server_name ${domain};
89
+
90
+ ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
91
+ ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
92
+
93
+ location / {
94
+ proxy_pass http://127.0.0.1:${port};
95
+ proxy_http_version 1.1;
96
+ proxy_set_header Upgrade $http_upgrade;
97
+ proxy_set_header Connection "upgrade";
98
+ proxy_set_header Host $host;
99
+ proxy_set_header X-Real-IP $remote_addr;
100
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
101
+ proxy_set_header X-Forwarded-Proto $scheme;
102
+ proxy_read_timeout 86400;
103
+ }
104
+ }
105
+ `;
106
+ }
107
+
108
+ /** systemd unit — auto-start on boot + restart on crash. */
109
+ export function renderSystemdUnit({ appDir, port, user = "root", npm = "npm" }) {
110
+ return `# Managed by MOP-AGENT installer
111
+ [Unit]
112
+ Description=MOP-AGENT (self-hostable AI brain)
113
+ After=network.target
114
+
115
+ [Service]
116
+ Type=simple
117
+ User=${user}
118
+ WorkingDirectory=${appDir}/apps/web
119
+ Environment=NODE_ENV=production
120
+ Environment=PORT=${port}
121
+ EnvironmentFile=${appDir}/apps/web/.env
122
+ ExecStart=${npm} run start
123
+ Restart=always
124
+ RestartSec=5
125
+ StandardOutput=journal
126
+ StandardError=journal
127
+
128
+ [Install]
129
+ WantedBy=multi-user.target
130
+ `;
131
+ }
132
+
133
+ /** Planned shell steps to install system deps (returned, not executed). */
134
+ export function planInstallDeps(os) {
135
+ if (!os.pkg) return [];
136
+ const p = os.pkg;
137
+ const steps = [{ label: "Refresh package index", cmd: p.update }];
138
+ steps.push({ label: "Install nginx", cmd: `${p.install} ${p.pkgs.nginx}` });
139
+ steps.push({ label: "Enable + start nginx", cmd: "systemctl enable --now nginx" });
140
+ steps.push({ label: "Install certbot", cmd: `${p.install} ${p.pkgs.certbot}` });
141
+ return steps;
142
+ }
143
+
144
+ /** SSL via certbot with a fallback when :80 is busy. Returns ordered attempts. */
145
+ export function planSsl({ domain, email }) {
146
+ const base = `certbot --nginx -d ${domain} --non-interactive --agree-tos -m ${email} --redirect`;
147
+ return {
148
+ primary: { label: "Obtain TLS cert (certbot --nginx)", cmd: base },
149
+ // fallback: free :80, use standalone, then reload nginx
150
+ fallback: [
151
+ { label: "Stop nginx (free port 80)", cmd: "systemctl stop nginx" },
152
+ { label: "Obtain cert (standalone)", cmd: `certbot certonly --standalone -d ${domain} --non-interactive --agree-tos -m ${email}` },
153
+ { label: "Start nginx", cmd: "systemctl start nginx" },
154
+ ],
155
+ };
156
+ }
157
+
158
+ /** What is listening on :80 (for the fallback scan). */
159
+ export const PORT80_SCAN = "ss -ltnp 'sport = :80' 2>/dev/null || lsof -i :80 2>/dev/null || true";
160
+
161
+ export function isValidDomain(value) {
162
+ return /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/.test(value);
163
+ }
164
+
165
+ export function isValidEmail(value) {
166
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) && !/["'`;|&<>$()]/.test(value);
167
+ }
168
+
169
+ export function isValidPort(value) {
170
+ const port = Number(value);
171
+ return /^\d+$/.test(String(value)) && Number.isInteger(port) && port >= 1 && port <= 65535;
172
+ }
173
+
174
+ /** nginx config location differs by distro (debian uses sites-available/enabled). */
175
+ export function nginxPaths(family) {
176
+ if (family === "debian") {
177
+ return { conf: "/etc/nginx/sites-available/mop-agent.conf", enabled: "/etc/nginx/sites-enabled/mop-agent.conf" };
178
+ }
179
+ // rhel / arch / alpine: drop-in conf.d is auto-included by the main nginx.conf
180
+ return { conf: "/etc/nginx/conf.d/mop-agent.conf", enabled: null };
181
+ }
182
+
183
+ /** Canonical install locations — shown in the TUI and the README. */
184
+ export function installPaths(appDir, family) {
185
+ const ng = nginxPaths(family);
186
+ return {
187
+ "app code": appDir,
188
+ "env file": `${appDir}/apps/web/.env`,
189
+ "brain + db": `${appDir}/data (SQLite + sqlite-vec; MOP_AGENT_DATA_DIR)`,
190
+ "nginx vhost": ng.conf,
191
+ "nginx enabled": ng.enabled ?? "(auto-included via conf.d)",
192
+ "systemd unit": "/etc/systemd/system/mop-agent.service",
193
+ "tls certs": "/etc/letsencrypt/live/<domain>/ (certbot-managed)",
194
+ logs: "journalctl -u mop-agent -f",
195
+ };
196
+ }
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MOP-AGENT installer / operator (TUI). Self-host with one command.
4
+ *
5
+ * npx mop-agent # interactive TUI
6
+ * npx mop-agent install # install system deps (nginx/certbot)
7
+ * npx mop-agent setup # domain / SQLite / ssl / systemd
8
+ * npx mop-agent update # migrate + build + restart staged npm version
9
+ * npx mop-agent status # health
10
+ * npx mop-agent uninstall # remove service + nginx vhost (keeps data unless --purge)
11
+ *
12
+ * Run as a normal user. Privileged OS operations request sudo individually.
13
+ */
14
+ import { spawnSync } from "node:child_process";
15
+ import { randomBytes } from "node:crypto";
16
+ import { chmodSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
17
+ import { createInterface } from "node:readline/promises";
18
+ import { stdin as input, stdout as output } from "node:process";
19
+ import { dirname, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import {
22
+ c, detectOS, renderNginxVhost, renderNginxTlsVhost, renderSystemdUnit,
23
+ planInstallDeps, planSsl, PORT80_SCAN, isValidDomain, isValidEmail, isValidPort,
24
+ nginxPaths, installPaths,
25
+ } from "./lib.mjs";
26
+
27
+ const APP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
28
+ const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
29
+
30
+ function parseArgs(argv) {
31
+ const out = { _: [] };
32
+ for (let i = 0; i < argv.length; i += 1) {
33
+ const a = argv[i];
34
+ if (!a.startsWith("--")) { out._.push(a); continue; }
35
+ const [k, inline] = a.slice(2).split("=", 2);
36
+ if (inline !== undefined) out[k] = inline;
37
+ else if (!argv[i + 1] || argv[i + 1].startsWith("--")) out[k] = true;
38
+ else { out[k] = argv[++i]; }
39
+ }
40
+ return out;
41
+ }
42
+ const args = parseArgs(process.argv.slice(2));
43
+ const DRY = !!args["dry-run"];
44
+ const managedByNpx = process.env.MOP_AGENT_MANAGED_BY_NPX === "1";
45
+
46
+ function banner() {
47
+ console.log(c("cyan", c("bold", "\n 🧠 MOP-AGENT installer")));
48
+ const os = detectOS();
49
+ console.log(c("gray", ` ${os.pretty} · family=${os.family} · ${isRoot ? "root" : "normal user (sudo on demand)"}`));
50
+ console.log("");
51
+ }
52
+
53
+ function supportedLinux(os = detectOS()) {
54
+ if (os.family !== "unsupported" && os.family !== "unknown" && os.pkg) return true;
55
+ console.log(c("red", ` Automated production install is not supported on ${os.pretty || os.platform}.`));
56
+ if (os.platform === "win32") {
57
+ console.log(c("yellow", " Windows: use WSL2 Ubuntu for production, or native PowerShell for development."));
58
+ } else if (os.platform === "darwin") {
59
+ console.log(c("yellow", " macOS: development mode only; deploy production on a supported Linux host."));
60
+ } else {
61
+ console.log(c("yellow", " Supported Linux families: Debian/Ubuntu, RHEL/Fedora, Arch, and Alpine."));
62
+ }
63
+ console.log(c("gray", " Guide: https://github.com/BURHANDEV-ENTERPRISE/mop-agent#platform-support\n"));
64
+ return false;
65
+ }
66
+
67
+ function printInstallLocations(os = detectOS()) {
68
+ if (!supportedLinux(os)) return false;
69
+ console.log(c("bold", "Installation locations"));
70
+ for (const [label, value] of Object.entries(installPaths(APP_DIR, os.family))) {
71
+ console.log(` ${label.padEnd(15)} ${value}`);
72
+ }
73
+ console.log(c("gray", " /var/www is not used: nginx reverse-proxies to this Node.js service.\n"));
74
+ return true;
75
+ }
76
+
77
+ /** Run a shell command, requesting sudo only for a privileged OS operation. */
78
+ function run(cmd, { capture = false, privileged = false, allowFailure = false } = {}) {
79
+ const sudo = privileged && !isRoot;
80
+ const shown = `${sudo ? "sudo " : ""}${cmd}`;
81
+ if (DRY) { console.log(c("gray", ` [dry-run] $ ${shown}`)); return { code: 0, stdout: "" }; }
82
+ console.log(c("dim", ` $ ${shown}`));
83
+ const executable = sudo ? "sudo" : "sh";
84
+ const commandArgs = sudo ? ["sh", "-c", cmd] : ["-c", cmd];
85
+ const r = spawnSync(executable, commandArgs, {
86
+ stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
87
+ encoding: "utf8",
88
+ });
89
+ if (!capture && r.status !== 0) console.log(c("red", ` ✗ exited ${r.status}`));
90
+ const code = r.status ?? 1;
91
+ if (code !== 0 && !allowFailure) throw new Error(`Command failed (${code}): ${shown}`);
92
+ return { code, stdout: r.stdout ?? "" };
93
+ }
94
+
95
+ function runSteps(steps, options = {}) {
96
+ for (const s of steps) {
97
+ console.log(c("cyan", `▸ ${s.label}`));
98
+ run(s.cmd, { ...options, ...s });
99
+ }
100
+ }
101
+
102
+ function q(value) {
103
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
104
+ }
105
+
106
+ // ---- commands ----------------------------------------------------------
107
+
108
+ function cmdInstall() {
109
+ banner();
110
+ const os = detectOS();
111
+ if (!printInstallLocations(os)) return;
112
+ console.log(c("bold", "Installing system dependencies (nginx and Certbot)…\n"));
113
+ runSteps(planInstallDeps(os), { privileged: true });
114
+ console.log(c("green", "\n✓ dependencies step complete. Next: mop-agent setup\n"));
115
+ }
116
+
117
+ async function cmdSetup() {
118
+ banner();
119
+ const os = detectOS();
120
+ if (!printInstallLocations(os)) return;
121
+ const rl = createInterface({ input, output });
122
+ const ask = async (q, def) => (await rl.question(c("cyan", ` ${q}${def ? c("gray", ` [${def}]`) : ""}: `))).trim() || def || "";
123
+
124
+ const domain = await ask("Domain (e.g. agent.mydomain.com)");
125
+ const port = await ask("App port", "3000");
126
+ const email = await ask("Email for Let's Encrypt", domain ? `admin@${domain.split(".").slice(-2).join(".")}` : "");
127
+ const wantSsl = (await ask("Obtain HTTPS cert now? (y/n)", "y")).toLowerCase().startsWith("y");
128
+ rl.close();
129
+
130
+ if (!isValidDomain(domain)) throw new Error(`Invalid domain: ${domain || "(empty)"}`);
131
+ if (!isValidPort(port)) throw new Error(`Invalid port: ${port}`);
132
+ if (wantSsl && !isValidEmail(email)) throw new Error(`Invalid Let's Encrypt email: ${email || "(empty)"}`);
133
+
134
+ // 1) .env
135
+ const secret = (n) => randomToken(n);
136
+ const env = [
137
+ `PORT=${port}`,
138
+ `BETTER_AUTH_URL=https://${domain}`,
139
+ `BETTER_AUTH_SECRET=${secret(48)}`,
140
+ `MOP_AGENT_SECRET=${secret(64).replace(/[^0-9a-f]/g, "").padEnd(64, "0").slice(0, 64)}`,
141
+ `MOP_AGENT_DATA_DIR=${APP_DIR}/data`,
142
+ `MOP_AGENT_CONSOLIDATE_CRON=0 3 * * *`,
143
+ ].join("\n") + "\n";
144
+ console.log(c("cyan", "\n▸ Write apps/web/.env"));
145
+ if (DRY) console.log(c("gray", env.split("\n").map((l) => " " + l).join("\n")));
146
+ else if (existsSync(`${APP_DIR}/apps/web/.env`) && !args.force) {
147
+ console.log(c("yellow", " Existing .env preserved (pass --force to regenerate secrets)."));
148
+ } else {
149
+ writeFileSync(`${APP_DIR}/apps/web/.env`, env, { mode: 0o600 });
150
+ chmodSync(`${APP_DIR}/apps/web/.env`, 0o600);
151
+ console.log(c("green", " ✓ wrote .env (mode 0600)"));
152
+ }
153
+
154
+ // 2) SQLite migration + production build
155
+ runSteps([
156
+ { label: "Install deps", cmd: `cd ${q(APP_DIR)} && npm ci` },
157
+ { label: "Migrate SQLite", cmd: `cd ${q(`${APP_DIR}/apps/web`)} && npm run db:migrate` },
158
+ { label: "Build", cmd: `cd ${q(`${APP_DIR}/apps/web`)} && npm run build` },
159
+ ]);
160
+
161
+ // 3) nginx vhost
162
+ const vhost = renderNginxVhost({ domain, port });
163
+ const nginx = nginxPaths(os.family);
164
+ const vhostPath = nginx.conf;
165
+ console.log(c("cyan", "▸ nginx reverse proxy"));
166
+ writeConf(vhostPath, vhost, { privileged: true });
167
+ runSteps([
168
+ ...(nginx.enabled
169
+ ? [{ label: "Enable site", cmd: `ln -sf ${vhostPath} ${nginx.enabled}` }]
170
+ : []),
171
+ { label: "Test nginx config", cmd: "nginx -t" },
172
+ { label: "Reload nginx", cmd: "systemctl reload nginx" },
173
+ ], { privileged: true });
174
+
175
+ // 4) systemd — run the app as the invoking user, never as root by default.
176
+ const serviceUser = isRoot ? process.env.SUDO_USER || "root" : process.env.USER || String(process.getuid?.() ?? "root");
177
+ const unit = renderSystemdUnit({ appDir: APP_DIR, port, user: serviceUser });
178
+ console.log(c("cyan", "▸ systemd service (auto-restart on boot)"));
179
+ writeConf("/etc/systemd/system/mop-agent.service", unit, { privileged: true });
180
+ runSteps([
181
+ { label: "Reload systemd", cmd: "systemctl daemon-reload" },
182
+ { label: "Enable + start MOP-AGENT", cmd: "systemctl enable --now mop-agent" },
183
+ ], { privileged: true });
184
+
185
+ // 5) SSL with a complete standalone fallback nginx configuration.
186
+ if (wantSsl && domain) {
187
+ console.log(c("cyan", "▸ HTTPS (Let's Encrypt)"));
188
+ const ssl = planSsl({ domain, email });
189
+ const { code } = run(ssl.primary.cmd, { privileged: true, allowFailure: true });
190
+ if (code !== 0 && !DRY) {
191
+ console.log(c("yellow", " certbot --nginx failed. Scanning :80 and retrying standalone…"));
192
+ const scan = run(PORT80_SCAN, { capture: true, privileged: true });
193
+ if (scan.stdout.trim()) console.log(c("gray", " port 80 in use by:\n" + scan.stdout.trim()));
194
+ runSteps(ssl.fallback, { privileged: true });
195
+ writeConf(vhostPath, renderNginxTlsVhost({ domain, port }), { privileged: true });
196
+ runSteps([
197
+ { label: "Test TLS nginx config", cmd: "nginx -t" },
198
+ { label: "Reload nginx with TLS", cmd: "systemctl reload nginx" },
199
+ ], { privileged: true });
200
+ }
201
+ }
202
+
203
+ console.log(c("green", `\n✓ Setup complete. Visit ${domain ? `https://${domain}` : `http://localhost:${port}`}/setup to create the owner.\n`));
204
+ printInstallLocations(os);
205
+ }
206
+
207
+ function cmdUpdate() {
208
+ banner();
209
+ if (!printInstallLocations()) return;
210
+ console.log(c("bold", "Updating MOP-AGENT…\n"));
211
+ runSteps([
212
+ ...(!managedByNpx ? [{ label: "Pull latest", cmd: `cd ${q(APP_DIR)} && git pull --ff-only` }] : []),
213
+ { label: "Install deps", cmd: `cd ${q(APP_DIR)} && npm ci` },
214
+ { label: "Migrate SQLite", cmd: `cd ${q(`${APP_DIR}/apps/web`)} && npm run db:migrate` },
215
+ { label: "Build", cmd: `cd ${q(`${APP_DIR}/apps/web`)} && npm run build` },
216
+ { label: "Restart service", cmd: "systemctl restart mop-agent", privileged: true },
217
+ ]);
218
+ console.log(c("green", "\n✓ updated\n"));
219
+ }
220
+
221
+ function cmdStatus() {
222
+ banner();
223
+ if (!printInstallLocations()) return;
224
+ const checks = [
225
+ ["service", "systemctl is-active mop-agent 2>/dev/null || echo inactive"],
226
+ ["nginx", "systemctl is-active nginx 2>/dev/null || echo inactive"],
227
+ [".env", existsSync(`${APP_DIR}/apps/web/.env`) ? "echo present" : "echo missing"],
228
+ ];
229
+ for (const [label, cmd] of checks) {
230
+ const r = run(cmd, { capture: true });
231
+ const val = DRY ? "(dry-run)" : r.stdout.trim();
232
+ console.log(` ${label.padEnd(10)} ${val === "active" || val === "present" ? c("green", val) : c("yellow", val)}`);
233
+ }
234
+ console.log("");
235
+ }
236
+
237
+ function cmdUninstall() {
238
+ banner();
239
+ const os = detectOS();
240
+ if (!printInstallLocations(os)) return;
241
+ const nginx = nginxPaths(os.family);
242
+ const nginxRemove = [nginx.enabled, nginx.conf].filter(Boolean).join(" ");
243
+ console.log(c("bold", "Removing MOP-AGENT service + nginx vhost…\n"));
244
+ runSteps([
245
+ { label: "Stop + disable service", cmd: "systemctl disable --now mop-agent 2>/dev/null || true" },
246
+ { label: "Remove unit", cmd: "rm -f /etc/systemd/system/mop-agent.service && systemctl daemon-reload" },
247
+ { label: "Remove nginx vhost", cmd: `rm -f ${nginxRemove} && (systemctl reload nginx || true)` },
248
+ ], { privileged: true });
249
+ if (args.purge) {
250
+ console.log(c("red", " --purge: removing SQLite brain data"));
251
+ runSteps([{ label: "Drop data dir", cmd: `rm -rf ${q(`${APP_DIR}/data`)}` }]);
252
+ } else {
253
+ console.log(c("gray", " (SQLite brain data kept; pass --purge to remove)"));
254
+ }
255
+ console.log(c("green", "\n✓ uninstalled\n"));
256
+ }
257
+
258
+ // ---- helpers -----------------------------------------------------------
259
+
260
+ function writeConf(path, content, { privileged = false } = {}) {
261
+ if (DRY) {
262
+ console.log(c("gray", ` [dry-run] write ${path}:`));
263
+ console.log(c("gray", content.split("\n").map((l) => " " + l).join("\n")));
264
+ return;
265
+ }
266
+ if (privileged && !isRoot) {
267
+ run(`mkdir -p ${q(dirname(path))}`, { privileged: true });
268
+ const r = spawnSync("sudo", ["tee", path], {
269
+ input: content,
270
+ stdio: ["pipe", "ignore", "inherit"],
271
+ encoding: "utf8",
272
+ });
273
+ if (r.status !== 0) throw new Error(`Unable to write ${path} with sudo.`);
274
+ } else {
275
+ mkdirSync(dirname(path), { recursive: true });
276
+ writeFileSync(path, content);
277
+ }
278
+ console.log(c("green", ` ✓ wrote ${path}`));
279
+ }
280
+
281
+ function randomToken(n) {
282
+ return randomBytes(Math.ceil(n / 2)).toString("hex").slice(0, n);
283
+ }
284
+
285
+ async function tui() {
286
+ banner();
287
+ if (!supportedLinux()) return;
288
+ if (DRY) console.log(c("yellow", " Running in DRY-RUN (no changes).\n"));
289
+ const rl = createInterface({ input, output });
290
+ const menu = [
291
+ ["1", "install", "Install system deps (nginx and Certbot)"],
292
+ ["2", "setup", "Configure domain, SQLite, SSL, systemd service"],
293
+ ["3", "status", "Show service health"],
294
+ ["4", "update", "Update to latest + restart"],
295
+ ["5", "uninstall", "Remove service + nginx vhost"],
296
+ ["q", "quit", "Exit"],
297
+ ];
298
+ for (const [k, , desc] of menu) console.log(` ${c("cyan", k)} ${desc}`);
299
+ const choice = (await rl.question(c("bold", "\n Select: "))).trim().toLowerCase();
300
+ rl.close();
301
+ const picked = menu.find((m) => m[0] === choice || m[1] === choice);
302
+ if (!picked || picked[1] === "quit") return;
303
+ await dispatch(picked[1]);
304
+ return tui();
305
+ }
306
+
307
+ async function dispatch(cmd) {
308
+ switch (cmd) {
309
+ case "install": return cmdInstall();
310
+ case "setup": return cmdSetup();
311
+ case "update": return cmdUpdate();
312
+ case "status": case "doctor": return cmdStatus();
313
+ case "uninstall": case "delete": return cmdUninstall();
314
+ default: return tui();
315
+ }
316
+ }
317
+
318
+ const command = args._[0];
319
+ (command ? dispatch(command) : tui()).catch((e) => {
320
+ console.error(c("red", e instanceof Error ? e.message : String(e)));
321
+ process.exit(1);
322
+ });