mop-agent 0.1.8 → 0.1.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.
package/README.md CHANGED
@@ -5,8 +5,8 @@ through MOP-FLOW. It stores project memory, performs semantic recall and
5
5
  consolidation, serves grounded chat, and can request approved actions from a
6
6
  linked FLOW node.
7
7
 
8
- > **Release status:** npm package `mop-agent@0.1.8` contains the corrected VPS
9
- > installer, first-run Admin/Assistant flow, and shared retro application shell.
8
+ > **Release status:** npm package `mop-agent@0.1.9` contains the corrected VPS
9
+ > installer, one-time Admin setup/login flow, and shared retro application shell.
10
10
  > The canonical installation command is exactly `npx mop-agent`.
11
11
 
12
12
  ## Current status
@@ -55,7 +55,8 @@ The first run copies the npm-packaged runtime from the temporary npx cache into
55
55
 
56
56
  - `Install` — installs nginx/Certbot and immediately continues through the
57
57
  complete domain, SQLite, HTTPS, and systemd setup.
58
- - `Update` — updates only MOP-AGENT, migrates/builds, and restarts it.
58
+ - `Update` — migrates/builds/restarts MOP-AGENT, restores the installer-owned
59
+ nginx vhost, reloads nginx, and verifies both local and domain proxy health.
59
60
  - `Status` — reports service health and filesystem locations.
60
61
  - `Delete` — removes the service and nginx configuration while preserving data
61
62
  unless purge is explicitly requested.
@@ -63,12 +64,15 @@ The first run copies the npm-packaged runtime from the temporary npx cache into
63
64
  After installation, open the configured URL in a browser. The application flow
64
65
  is intentionally separate from the server installer:
65
66
 
66
- 1. On a fresh database, `/` redirects to **Create Admin account**.
67
+ 1. On a fresh database, `/setup` shows **Create Admin account** once.
67
68
  2. Creating the first Admin also signs that account in.
68
- 3. Returning users are shown the login form.
69
- 4. Successful setup/login opens the main **Assistant**. It can be used before
69
+ 3. After an Admin exists, `/setup` always redirects to `/login` when signed out
70
+ or `/assistant` when already signed in.
71
+ 4. Admin creates ready-to-login accounts under **Settings → Users**; there is
72
+ no public invited-account signup link.
73
+ 5. Successful setup/login opens the main **Assistant**. It can be used before
70
74
  any project is linked; Brain is the optional memory/project control surface.
71
- 5. Add OpenRouter or Anthropic under **Providers** for full model responses.
75
+ 6. Add OpenRouter or Anthropic under **Settings → Providers** for full model responses.
72
76
  Until then, the built-in offline echo provider confirms the chat pipeline.
73
77
 
74
78
  During `setup`, choose one deployment mode:
@@ -1,4 +1,5 @@
1
- /** GET /api/members — list users + roles (owner). */
1
+ /** GET/POST /api/members — list users or create a login directly (owner). */
2
+ import { auth } from "@/lib/auth";
2
3
  import { getSqlite } from "@/lib/db/client";
3
4
  import { requireRole } from "@/lib/authz";
4
5
 
@@ -14,3 +15,40 @@ export async function GET(req: Request): Promise<Response> {
14
15
  .all();
15
16
  return Response.json({ members });
16
17
  }
18
+
19
+ export async function POST(req: Request): Promise<Response> {
20
+ const access = await requireRole(req, ["owner"]);
21
+ if (!access.ok) return access.response;
22
+
23
+ const body = (await req.json()) as { name?: string; email?: string; password?: string; role?: "member" | "owner" };
24
+ const name = body.name?.trim();
25
+ const email = body.email?.trim().toLowerCase();
26
+ const password = body.password ?? "";
27
+ const role = body.role === "owner" ? "owner" : "member";
28
+ if (!name || !email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
29
+ return Response.json({ error: "invalid_name_or_email" }, { status: 400 });
30
+ }
31
+ if (password.length < 8) return Response.json({ error: "password_too_short" }, { status: 400 });
32
+
33
+ const sqlite = getSqlite();
34
+ const exists = sqlite.prepare("SELECT 1 FROM user WHERE lower(email) = lower(?) LIMIT 1").get(email);
35
+ if (exists) return Response.json({ error: "user_already_exists" }, { status: 409 });
36
+
37
+ const now = Date.now();
38
+ sqlite.prepare(
39
+ `INSERT INTO invite(email, role, expires_at, used_at, invited_by, created_at)
40
+ VALUES(?, ?, ?, NULL, ?, ?)
41
+ ON CONFLICT(email) DO UPDATE SET role=excluded.role, expires_at=excluded.expires_at,
42
+ used_at=NULL, invited_by=excluded.invited_by`,
43
+ ).run(email, role, now + 10 * 60_000, access.userId, now);
44
+
45
+ try {
46
+ await auth.api.signUpEmail({ body: { name, email, password } });
47
+ } catch (error) {
48
+ sqlite.prepare("DELETE FROM invite WHERE email = ? AND used_at IS NULL").run(email);
49
+ const message = error instanceof Error ? error.message : "create_user_failed";
50
+ return Response.json({ error: message }, { status: 400 });
51
+ }
52
+
53
+ return Response.json({ ok: true, user: { name, email, role } }, { status: 201 });
54
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from "react";
2
+ import { unstable_noStore as noStore } from "next/cache";
3
+ import { headers } from "next/headers";
4
+ import { redirect } from "next/navigation";
5
+ import { auth, ownerExists } from "@/lib/auth";
6
+
7
+ export const dynamic = "force-dynamic";
8
+ export const revalidate = 0;
9
+
10
+ export default async function LoginLayout({ children }: { children: ReactNode }) {
11
+ noStore();
12
+ if (!ownerExists()) redirect("/setup");
13
+ const session = await auth.api.getSession({ headers: await headers() });
14
+ if (session) redirect("/assistant");
15
+ return children;
16
+ }
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { signIn } from "@/lib/auth-client";
5
+
6
+ type SetupStatus = { authenticated: boolean };
7
+
8
+ async function waitForSession(): Promise<boolean> {
9
+ for (let attempt = 0; attempt < 5; attempt += 1) {
10
+ try {
11
+ const response = await fetch(`/api/setup/status?t=${Date.now()}`, { cache: "no-store", credentials: "include" });
12
+ const status = await response.json() as SetupStatus;
13
+ if (status.authenticated) return true;
14
+ } catch {
15
+ // Retry a short proxy/session propagation gap.
16
+ }
17
+ await new Promise((resolve) => setTimeout(resolve, 250));
18
+ }
19
+ return false;
20
+ }
21
+
22
+ export default function LoginPage() {
23
+ const [email, setEmail] = useState("");
24
+ const [password, setPassword] = useState("");
25
+ const [msg, setMsg] = useState("");
26
+ const [busy, setBusy] = useState(false);
27
+
28
+ async function submit(e: React.FormEvent) {
29
+ e.preventDefault();
30
+ setBusy(true);
31
+ setMsg("");
32
+ const result = await signIn.email({ email, password });
33
+ if (result.error) {
34
+ setMsg(result.error.message ?? "Sign in failed.");
35
+ setBusy(false);
36
+ return;
37
+ }
38
+ if (await waitForSession()) {
39
+ window.location.replace(`/assistant?auth=${Date.now()}`);
40
+ return;
41
+ }
42
+ setMsg("Sign in succeeded, but the session cookie was not accepted. Use the configured domain and HTTPS address.");
43
+ setBusy(false);
44
+ }
45
+
46
+ return (
47
+ <main className="mop-setup-shell" style={shell}>
48
+ <section className="mop-setup-brand" style={brandPanel}>
49
+ <img src="/icon.svg" alt="MOP-AGENT" style={heroLogo} />
50
+ </section>
51
+ <section className="mop-setup-form" style={formWrap}>
52
+ <div style={formCard}>
53
+ <p style={eyebrow}>WELCOME BACK</p>
54
+ <h1 style={{ fontSize: 27, margin: "8px 0" }}>Sign in to MOP-AGENT</h1>
55
+ <p style={description}>Use the account created for you by the Administrator.</p>
56
+ <form onSubmit={submit} style={{ display: "grid", gap: 14, marginTop: 28 }}>
57
+ <label style={label}>Email
58
+ <input placeholder="you@example.com" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
59
+ </label>
60
+ <label style={label}>Password
61
+ <input placeholder="Your password" type="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} style={inputStyle} />
62
+ </label>
63
+ <button type="submit" disabled={busy} style={{ ...buttonStyle, opacity: busy ? 0.65 : 1 }}>
64
+ {busy ? "Signing in…" : "Sign in"}
65
+ </button>
66
+ </form>
67
+ {msg && <p role="alert" style={{ marginTop: 16, color: "#742220" }}>{msg}</p>}
68
+ </div>
69
+ </section>
70
+ </main>
71
+ );
72
+ }
73
+
74
+ const shell: React.CSSProperties = { minHeight: "100vh", display: "grid", gridTemplateColumns: "minmax(0, 1.25fr) minmax(380px, .75fr)", background: "#fef9e1" };
75
+ const brandPanel: React.CSSProperties = { padding: "clamp(48px, 8vw, 110px)", display: "flex", alignItems: "center", justifyContent: "center", overflow: "hidden", background: "radial-gradient(circle at 50% 45%, #49685c 0, #2d4a3e 58%, #21382f 100%)" };
76
+ const heroLogo: React.CSSProperties = { display: "block", width: "min(72%, 560px)", height: "auto", maxHeight: "72vh", objectFit: "contain", filter: "drop-shadow(0 24px 34px rgba(0,0,0,.28))" };
77
+ const formWrap: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", padding: 28, background: "#fef9e1", borderLeft: "1px solid #2d4a3e" };
78
+ const formCard: React.CSSProperties = { width: "min(100%, 430px)", padding: "38px 34px", border: "1px solid rgba(45,74,62,.45)", borderRadius: 18, background: "#fffdf2", boxShadow: "0 24px 70px rgba(45,74,62,.14)" };
79
+ const eyebrow: React.CSSProperties = { margin: "20px 0 0", color: "#742220", fontSize: 12, fontWeight: 800, letterSpacing: ".16em" };
80
+ const description: React.CSSProperties = { color: "#7f8da2", lineHeight: 1.55, marginTop: 0 };
81
+ const label: React.CSSProperties = { display: "grid", gap: 7, color: "#2d4a3e", fontSize: 13, fontWeight: 650 };
82
+ const inputStyle: React.CSSProperties = { padding: "12px 13px", borderRadius: 9, border: "1px solid rgba(45,74,62,.42)", outline: "none", background: "#fef9e1", color: "#2d4a3e", fontSize: 15 };
83
+ const buttonStyle: React.CSSProperties = { marginTop: 4, padding: "12px 14px", borderRadius: 9, border: "1px solid #742220", background: "#742220", color: "#fef9e1", fontWeight: 750, fontSize: 15, cursor: "pointer" };
@@ -6,7 +6,6 @@ import { useEffect, useState } from "react";
6
6
  type Section = "providers" | "users";
7
7
  type Masked = { configured: boolean; provider?: string; model?: string | null; keyHint?: string };
8
8
  type Member = { id: string; email: string; name: string; role: string };
9
- type Invite = { email: string; role: string; expiresAt: number; usedAt: number | null };
10
9
 
11
10
  export default function SettingsPage() {
12
11
  const [section, setSection] = useState<Section>("providers");
@@ -17,8 +16,9 @@ export default function SettingsPage() {
17
16
  const [model, setModel] = useState("");
18
17
  const [providerMsg, setProviderMsg] = useState("");
19
18
  const [members, setMembers] = useState<Member[]>([]);
20
- const [invites, setInvites] = useState<Invite[]>([]);
19
+ const [userName, setUserName] = useState("");
21
20
  const [email, setEmail] = useState("");
21
+ const [password, setPassword] = useState("");
22
22
  const [role, setRole] = useState<"member" | "owner">("member");
23
23
  const [userMsg, setUserMsg] = useState("");
24
24
 
@@ -31,7 +31,6 @@ export default function SettingsPage() {
31
31
 
32
32
  function loadUsers() {
33
33
  fetch("/api/members").then((r) => (r.ok ? r.json() : { members: [] })).then((data) => setMembers(data.members ?? [])).catch(() => {});
34
- fetch("/api/invites").then((r) => (r.ok ? r.json() : { invites: [] })).then((data) => setInvites(data.invites ?? [])).catch(() => {});
35
34
  }
36
35
 
37
36
  useEffect(() => {
@@ -61,21 +60,21 @@ export default function SettingsPage() {
61
60
  if (response.ok) setConfig(data.config);
62
61
  }
63
62
 
64
- async function invite(e: React.FormEvent) {
63
+ async function createUser(e: React.FormEvent) {
65
64
  e.preventDefault();
66
- setUserMsg("Creating invite…");
67
- const response = await fetch("/api/invites", {
65
+ setUserMsg("Creating user…");
66
+ const response = await fetch("/api/members", {
68
67
  method: "POST",
69
68
  headers: { "content-type": "application/json" },
70
- body: JSON.stringify({ email, role }),
69
+ body: JSON.stringify({ name: userName, email, password, role }),
71
70
  });
72
- setUserMsg(response.ok ? `Invite created for ${email}.` : `Unable to invite (error ${response.status}).`);
73
- if (response.ok) setEmail("");
74
- loadUsers();
75
- }
76
-
77
- async function revoke(inviteEmail: string) {
78
- await fetch(`/api/invites?email=${encodeURIComponent(inviteEmail)}`, { method: "DELETE" });
71
+ const data = await response.json();
72
+ setUserMsg(response.ok ? `User ${email} created and ready to sign in.` : `Unable to create user: ${data.error ?? response.status}`);
73
+ if (response.ok) {
74
+ setUserName("");
75
+ setEmail("");
76
+ setPassword("");
77
+ }
79
78
  loadUsers();
80
79
  }
81
80
 
@@ -166,29 +165,21 @@ export default function SettingsPage() {
166
165
  </table>
167
166
  </div>
168
167
 
169
- <div style={invitePanel}>
170
- <h3 style={{ margin: 0, fontSize: 15 }}>Invite a user</h3>
171
- <form onSubmit={invite} style={{ display: "grid", gridTemplateColumns: "minmax(180px,1fr) 130px auto", gap: 9, marginTop: 13 }} className="mop-user-invite-form">
168
+ <div style={userPanel}>
169
+ <h3 style={{ margin: 0, fontSize: 15 }}>Add a user</h3>
170
+ <p style={{ ...muted, margin: "6px 0 0", fontSize: 12 }}>Create the account here, then give the user their email and temporary password.</p>
171
+ <form onSubmit={createUser} style={{ display: "grid", gridTemplateColumns: "minmax(140px,.8fr) minmax(190px,1fr) minmax(150px,.8fr) 110px auto", gap: 9, marginTop: 13 }} className="mop-user-invite-form">
172
+ <input placeholder="Display name" required value={userName} onChange={(e) => setUserName(e.target.value)} style={inputStyle} />
172
173
  <input placeholder="user@example.com" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
174
+ <input placeholder="Temporary password" type="password" minLength={8} required value={password} onChange={(e) => setPassword(e.target.value)} style={inputStyle} />
173
175
  <select value={role} onChange={(e) => setRole(e.target.value as "member" | "owner")} style={inputStyle}>
174
176
  <option value="member">Member</option>
175
177
  <option value="owner">Admin</option>
176
178
  </select>
177
- <button type="submit" style={primaryButton}>CREATE INVITE</button>
179
+ <button type="submit" style={primaryButton}>ADD USER</button>
178
180
  </form>
179
181
  {userMsg && <p style={messageStyle}>{userMsg}</p>}
180
182
  </div>
181
-
182
- <h3 style={{ margin: "26px 0 10px", fontSize: 14 }}>Pending invites</h3>
183
- <div style={{ display: "grid", gap: 7 }}>
184
- {invites.filter((item) => !item.usedAt).map((item) => (
185
- <div key={item.email} style={inviteRow}>
186
- <span><strong>{item.email}</strong><small style={{ ...muted, marginLeft: 8 }}>{item.role === "owner" ? "admin" : "member"}</small></span>
187
- <button onClick={() => revoke(item.email)} style={secondaryButton}>REVOKE</button>
188
- </div>
189
- ))}
190
- {invites.filter((item) => !item.usedAt).length === 0 && <p style={muted}>No pending invites.</p>}
191
- </div>
192
183
  </>
193
184
  )}
194
185
  </section>
@@ -207,11 +198,9 @@ const formGrid: CSSProperties = { display: "grid", gap: 13, marginTop: 20 };
207
198
  const labelStyle: CSSProperties = { display: "grid", gap: 6, color: "#742220", fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 11, fontWeight: 800, letterSpacing: ".07em", textTransform: "uppercase" };
208
199
  const inputStyle: CSSProperties = { width: "100%", minHeight: 40, padding: "9px 11px", border: "1px solid rgba(45,74,62,.4)", background: "#fffdf2", color: "#2d4a3e" };
209
200
  const primaryButton: CSSProperties = { minHeight: 40, padding: "9px 15px", border: "1px solid #742220", background: "#742220", color: "#fef9e1", fontFamily: '"SFMono-Regular", Consolas, monospace', fontSize: 10, fontWeight: 900, cursor: "pointer" };
210
- const secondaryButton: CSSProperties = { padding: "6px 10px", border: "1px solid #742220", background: "transparent", color: "#742220", fontSize: 9, fontWeight: 900, cursor: "pointer" };
211
201
  const messageStyle: CSSProperties = { padding: "9px 11px", borderLeft: "3px solid #742220", background: "rgba(116,34,32,.06)", fontSize: 13 };
212
202
  const tableStyle: CSSProperties = { width: "100%", borderCollapse: "collapse", fontSize: 13 };
213
203
  const miniAvatar: CSSProperties = { width: 28, height: 28, display: "inline-grid", placeItems: "center", marginRight: 9, background: "#2d4a3e", color: "#fef9e1", fontWeight: 900 };
214
204
  const ownerRole: CSSProperties = { padding: "4px 7px", background: "#742220", color: "#fef9e1", fontSize: 9, fontWeight: 900 };
215
205
  const memberRole: CSSProperties = { ...ownerRole, color: "#2d4a3e", background: "rgba(45,74,62,.12)" };
216
- const invitePanel: CSSProperties = { marginTop: 26, padding: 16, border: "1px solid rgba(45,74,62,.27)", background: "rgba(254,249,225,.55)" };
217
- const inviteRow: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: 10, border: "1px solid rgba(45,74,62,.22)" };
206
+ const userPanel: CSSProperties = { marginTop: 26, padding: 16, border: "1px solid rgba(45,74,62,.27)", background: "rgba(254,249,225,.55)" };
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from "react";
2
+ import { unstable_noStore as noStore } from "next/cache";
3
+ import { headers } from "next/headers";
4
+ import { redirect } from "next/navigation";
5
+ import { auth, ownerExists } from "@/lib/auth";
6
+
7
+ export const dynamic = "force-dynamic";
8
+ export const revalidate = 0;
9
+
10
+ export default async function SetupLayout({ children }: { children: ReactNode }) {
11
+ noStore();
12
+ if (!ownerExists()) return children;
13
+ const session = await auth.api.getSession({ headers: await headers() });
14
+ redirect(session ? "/assistant" : "/login");
15
+ }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import { useState } from "react";
4
4
  import { signIn, signUp } from "@/lib/auth-client";
5
5
 
6
6
  type SetupStatus = { ownerExists: boolean; authenticated: boolean };
@@ -20,7 +20,7 @@ async function waitForSession(): Promise<boolean> {
20
20
  try {
21
21
  if ((await getSetupStatus()).authenticated) return true;
22
22
  } catch {
23
- // A short deployment/network gap should not strand the form in busy mode.
23
+ // Allow a short deployment/network gap before falling back to Login.
24
24
  }
25
25
  await new Promise((resolve) => setTimeout(resolve, 250));
26
26
  }
@@ -28,78 +28,34 @@ async function waitForSession(): Promise<boolean> {
28
28
  }
29
29
 
30
30
  export default function SetupPage() {
31
- const [ownerExists, setOwnerExists] = useState<boolean | null>(null);
32
31
  const [email, setEmail] = useState("");
33
32
  const [password, setPassword] = useState("");
34
33
  const [name, setName] = useState("");
35
34
  const [msg, setMsg] = useState("");
36
35
  const [busy, setBusy] = useState(false);
37
- const [mode, setMode] = useState<"signup" | "signin">("signup");
38
-
39
- useEffect(() => {
40
- getSetupStatus()
41
- .then((status) => {
42
- if (status.authenticated) {
43
- window.location.replace(`/assistant?auth=${Date.now()}`);
44
- return;
45
- }
46
- setOwnerExists(status.ownerExists);
47
- setMode(status.ownerExists ? "signin" : "signup");
48
- })
49
- .catch(() => setMsg("Unable to reach MOP-AGENT. Please refresh."));
50
- }, []);
51
36
 
52
37
  async function submit(e: React.FormEvent) {
53
38
  e.preventDefault();
54
39
  setBusy(true);
55
40
  setMsg("");
56
41
 
57
- if (mode === "signup") {
58
- const res = await signUp.email({ email, password, name: name.trim() || email });
59
- if (res.error) {
60
- setMsg(res.error.message ?? "Account setup failed.");
61
- setBusy(false);
62
- return;
63
- }
64
- // Verify the cookie before entering a server-protected route. If the
65
- // signup response did not establish it, explicitly sign in once.
66
- if (!(await waitForSession())) {
67
- const login = await signIn.email({ email, password });
68
- if (login.error) {
69
- setMode("signin");
70
- setOwnerExists(true);
71
- setMsg(login.error.message ?? "Admin created, but sign in failed. Please sign in below.");
72
- setBusy(false);
73
- return;
74
- }
75
- }
76
- if (await waitForSession()) {
77
- window.location.replace(`/assistant?auth=${Date.now()}`);
78
- return;
79
- }
80
- setMode("signin");
81
- setOwnerExists(true);
82
- setMsg("Admin created, but the session cookie was not accepted. Please sign in again.");
42
+ const result = await signUp.email({ email, password, name: name.trim() || email });
43
+ if (result.error) {
44
+ setMsg(result.error.message ?? "Admin setup failed.");
83
45
  setBusy(false);
84
46
  return;
85
47
  }
86
48
 
87
- const res = await signIn.email({ email, password });
88
- if (res.error) {
89
- setMsg(res.error.message ?? "Sign in failed.");
90
- setBusy(false);
91
- return;
92
- }
49
+ // Better Auth normally creates the session during signup. Explicitly sign
50
+ // in once if a proxy/browser did not retain that first response cookie.
51
+ if (!(await waitForSession())) await signIn.email({ email, password });
93
52
  if (await waitForSession()) {
94
53
  window.location.replace(`/assistant?auth=${Date.now()}`);
95
54
  return;
96
55
  }
97
- setMsg("Sign in succeeded, but the session cookie was not accepted. Check that you are using the configured HTTPS domain.");
98
- setBusy(false);
56
+ window.location.replace("/login?admin-created=1");
99
57
  }
100
58
 
101
- const loading = ownerExists === null && !msg;
102
-
103
59
  return (
104
60
  <main className="mop-setup-shell" style={shell}>
105
61
  <section className="mop-setup-brand" style={brandPanel}>
@@ -108,45 +64,26 @@ export default function SetupPage() {
108
64
 
109
65
  <section className="mop-setup-form" style={formWrap}>
110
66
  <div style={formCard}>
111
- <p style={eyebrow}>{mode === "signup" ? "FIRST-RUN SETUP" : "WELCOME BACK"}</p>
112
- <h2 style={{ fontSize: 27, margin: "8px 0" }}>
113
- {loading ? "Checking server…" : mode === "signup" ? "Create Admin account" : "Sign in to MOP-AGENT"}
114
- </h2>
115
- <p style={{ color: "#8c9bb0", lineHeight: 1.55, marginTop: 0 }}>
116
- {mode === "signup"
117
- ? "This first account controls providers, team access, projects, and system memory."
118
- : "Use your Admin or invited team account."}
119
- </p>
120
-
121
- {!loading && (
122
- <form onSubmit={submit} style={{ display: "grid", gap: 14, marginTop: 28 }}>
123
- {mode === "signup" && (
124
- <label style={label}>Display name
125
- <input placeholder="Admin" autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} style={inputStyle} />
126
- </label>
127
- )}
128
- <label style={label}>Email
129
- <input placeholder="admin@example.com" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
130
- </label>
131
- <label style={label}>Password
132
- <input placeholder="Minimum 8 characters" type="password" autoComplete={mode === "signup" ? "new-password" : "current-password"} required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} style={inputStyle} />
133
- </label>
134
- <button type="submit" disabled={busy} style={{ ...buttonStyle, opacity: busy ? 0.65 : 1 }}>
135
- {busy ? "Please wait…" : mode === "signup" ? "Create Admin & continue" : "Sign in"}
136
- </button>
137
- </form>
138
- )}
67
+ <p style={eyebrow}>FIRST-RUN SETUP</p>
68
+ <h1 style={{ fontSize: 27, margin: "8px 0" }}>Create Admin account</h1>
69
+ <p style={description}>This one-time account controls providers, users, projects, and system memory.</p>
70
+
71
+ <form onSubmit={submit} style={{ display: "grid", gap: 14, marginTop: 28 }}>
72
+ <label style={label}>Display name
73
+ <input placeholder="Admin" autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} style={inputStyle} />
74
+ </label>
75
+ <label style={label}>Email
76
+ <input placeholder="admin@example.com" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
77
+ </label>
78
+ <label style={label}>Password
79
+ <input placeholder="Minimum 8 characters" type="password" autoComplete="new-password" required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} style={inputStyle} />
80
+ </label>
81
+ <button type="submit" disabled={busy} style={{ ...buttonStyle, opacity: busy ? 0.65 : 1 }}>
82
+ {busy ? "Creating Admin…" : "Create Admin & continue"}
83
+ </button>
84
+ </form>
139
85
 
140
86
  {msg && <p role="alert" style={{ marginTop: 16, color: "#742220" }}>{msg}</p>}
141
-
142
- {!loading && (
143
- <p style={{ marginTop: 22, fontSize: 13, color: "#7f8da2" }}>
144
- {mode === "signin" ? "Invited team member? " : "Already created an account? "}
145
- <button type="button" onClick={() => { setMode(mode === "signin" ? "signup" : "signin"); setMsg(""); }} style={textButton}>
146
- {mode === "signin" ? "Create invited account" : "Sign in"}
147
- </button>
148
- </p>
149
- )}
150
87
  </div>
151
88
  </section>
152
89
  </main>
@@ -159,7 +96,7 @@ const heroLogo: React.CSSProperties = { display: "block", width: "min(72%, 560px
159
96
  const formWrap: React.CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", padding: 28, background: "#fef9e1", borderLeft: "1px solid #2d4a3e" };
160
97
  const formCard: React.CSSProperties = { width: "min(100%, 430px)", padding: "38px 34px", border: "1px solid rgba(45,74,62,.45)", borderRadius: 18, background: "#fffdf2", boxShadow: "0 24px 70px rgba(45,74,62,.14)" };
161
98
  const eyebrow: React.CSSProperties = { margin: "20px 0 0", color: "#742220", fontSize: 12, fontWeight: 800, letterSpacing: ".16em" };
99
+ const description: React.CSSProperties = { color: "#7f8da2", lineHeight: 1.55, marginTop: 0 };
162
100
  const label: React.CSSProperties = { display: "grid", gap: 7, color: "#2d4a3e", fontSize: 13, fontWeight: 650 };
163
101
  const inputStyle: React.CSSProperties = { padding: "12px 13px", borderRadius: 9, border: "1px solid rgba(45,74,62,.42)", outline: "none", background: "#fef9e1", color: "#2d4a3e", fontSize: 15 };
164
102
  const buttonStyle: React.CSSProperties = { marginTop: 4, padding: "12px 14px", borderRadius: 9, border: "1px solid #742220", background: "#742220", color: "#fef9e1", fontWeight: 750, fontSize: 15, cursor: "pointer" };
165
- const textButton: React.CSSProperties = { padding: 0, border: 0, background: "none", color: "#742220", cursor: "pointer" };
@@ -29,7 +29,7 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
29
29
 
30
30
  async function logout() {
31
31
  await signOut();
32
- window.location.replace("/setup");
32
+ window.location.replace("/login");
33
33
  }
34
34
 
35
35
  const nav = [
@@ -61,7 +61,7 @@ export function AppShell({ viewer, children }: { viewer: AppViewer; children: Re
61
61
  <div className="mop-topbar-center">MOP MEMORYCORE</div>
62
62
  <div className="mop-topbar-meta">
63
63
  <span>{isAdmin ? "ADMIN" : "MEMBER"}</span>
64
- <span className="mop-version">v0.1.8</span>
64
+ <span className="mop-version">v0.1.9</span>
65
65
  </div>
66
66
  </div>
67
67
  </header>
@@ -10,7 +10,7 @@ export async function requirePageSession() {
10
10
  if (!ownerExists()) redirect("/setup");
11
11
 
12
12
  const session = await auth.api.getSession({ headers: await headers() });
13
- if (!session) redirect("/setup");
13
+ if (!session) redirect("/login");
14
14
  return session;
15
15
  }
16
16
 
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { spawnSync } from "node:child_process";
14
14
  import { randomBytes } from "node:crypto";
15
- import { chmodSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
15
+ import { chmodSync, writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
16
16
  import { createInterface } from "node:readline/promises";
17
17
  import { stdin as input, stdout as output } from "node:process";
18
18
  import { dirname, resolve } from "node:path";
@@ -102,6 +102,57 @@ function q(value) {
102
102
  return `'${String(value).replaceAll("'", `'"'"'`)}'`;
103
103
  }
104
104
 
105
+ function readRuntimeConfig() {
106
+ const envPath = `${APP_DIR}/apps/web/.env`;
107
+ if (!existsSync(envPath)) throw new Error(`Runtime environment is missing: ${envPath}`);
108
+ const env = {};
109
+ for (const raw of readFileSync(envPath, "utf8").split(/\r?\n/)) {
110
+ const line = raw.trim();
111
+ if (!line || line.startsWith("#")) continue;
112
+ const at = line.indexOf("=");
113
+ if (at < 1) continue;
114
+ env[line.slice(0, at)] = line.slice(at + 1).replace(/^(['"])(.*)\1$/, "$2");
115
+ }
116
+ const port = env.PORT || "3000";
117
+ if (!isValidPort(port)) throw new Error(`Invalid PORT in ${envPath}: ${port}`);
118
+ let publicUrl;
119
+ try {
120
+ publicUrl = new URL(env.BETTER_AUTH_URL || `http://localhost:${port}`);
121
+ } catch {
122
+ throw new Error(`Invalid BETTER_AUTH_URL in ${envPath}`);
123
+ }
124
+ return { env, port, publicUrl };
125
+ }
126
+
127
+ /** Restore the installer-owned vhost after every update, including TLS. */
128
+ function reconcileNginx() {
129
+ const os = detectOS();
130
+ const { port, publicUrl } = readRuntimeConfig();
131
+ const domain = publicUrl.hostname;
132
+ if (!isValidDomain(domain) && domain !== "localhost") {
133
+ throw new Error(`Invalid domain in BETTER_AUTH_URL: ${domain}`);
134
+ }
135
+ const nginx = nginxPaths(os.family);
136
+ const certDir = `/etc/letsencrypt/live/${domain}`;
137
+ const hasTls = publicUrl.protocol === "https:" && existsSync(`${certDir}/fullchain.pem`) && existsSync(`${certDir}/privkey.pem`);
138
+ const vhost = hasTls ? renderNginxTlsVhost({ domain, port }) : renderNginxVhost({ domain, port });
139
+
140
+ console.log(c("cyan", "▸ Restore nginx reverse proxy"));
141
+ writeConf(nginx.conf, vhost, { privileged: true });
142
+ runSteps([
143
+ ...(nginx.enabled ? [{ label: "Enable nginx vhost", cmd: `ln -sf ${nginx.conf} ${nginx.enabled}` }] : []),
144
+ { label: "Verify nginx configuration", cmd: "nginx -t" },
145
+ { label: "Enable + start nginx", cmd: "systemctl enable --now nginx" },
146
+ { label: "Reload nginx", cmd: "systemctl reload nginx" },
147
+ { label: "Verify local application", cmd: `curl --fail --silent --show-error --max-time 15 ${q(`http://127.0.0.1:${port}/api/setup/status`)} >/dev/null` },
148
+ {
149
+ label: "Verify domain reverse proxy",
150
+ cmd: `curl --fail --silent --show-error --max-time 15 --resolve ${q(`${domain}:${hasTls ? "443" : "80"}:127.0.0.1`)} ${q(`${hasTls ? "https" : "http"}://${domain}/api/setup/status`)} >/dev/null`,
151
+ },
152
+ ], { privileged: true });
153
+ return { domain, port, protocol: hasTls ? "https" : "http" };
154
+ }
155
+
105
156
  // ---- commands ----------------------------------------------------------
106
157
 
107
158
  async function cmdInstall() {
@@ -242,21 +293,29 @@ function cmdUpdate() {
242
293
  { label: "Start new service", cmd: "systemctl start mop-agent", privileged: true },
243
294
  { label: "Verify service", cmd: "sleep 2 && systemctl is-active --quiet mop-agent", privileged: true },
244
295
  ]);
245
- console.log(c("green", "\n✓ updated, rebuilt, and service verified\n"));
296
+ const proxy = reconcileNginx();
297
+ console.log(c("green", `\n✓ updated and verified through ${proxy.protocol}://${proxy.domain}\n`));
246
298
  }
247
299
 
248
300
  function cmdStatus() {
249
301
  banner();
250
302
  if (!printInstallLocations()) return;
303
+ let runtime;
304
+ try { runtime = readRuntimeConfig(); } catch { runtime = null; }
305
+ const os = detectOS();
306
+ const nginx = nginxPaths(os.family);
251
307
  const checks = [
252
308
  ["service", "systemctl is-active mop-agent 2>/dev/null || echo inactive"],
253
309
  ["nginx", "systemctl is-active nginx 2>/dev/null || echo inactive"],
310
+ ["nginx conf", `test -f ${q(nginx.conf)} && echo present || echo missing`],
311
+ ...(nginx.enabled ? [["nginx link", `test -L ${q(nginx.enabled)} && echo present || echo missing`]] : []),
254
312
  [".env", existsSync(`${APP_DIR}/apps/web/.env`) ? "echo present" : "echo missing"],
313
+ ...(runtime ? [["local app", `curl --silent --output /dev/null --write-out '%{http_code}' --max-time 5 ${q(`http://127.0.0.1:${runtime.port}/api/setup/status`)} || echo failed`]] : []),
255
314
  ];
256
315
  for (const [label, cmd] of checks) {
257
316
  const r = run(cmd, { capture: true });
258
317
  const val = DRY ? "(dry-run)" : r.stdout.trim();
259
- console.log(` ${label.padEnd(10)} ${val === "active" || val === "present" ? c("green", val) : c("yellow", val)}`);
318
+ console.log(` ${label.padEnd(10)} ${val === "active" || val === "present" || val === "200" ? c("green", val) : c("yellow", val)}`);
260
319
  }
261
320
  console.log("");
262
321
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "mop-agent",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "mop-agent",
9
- "version": "0.1.8",
9
+ "version": "0.1.9",
10
10
  "license": "UNLICENSED",
11
11
  "workspaces": [
12
12
  "packages/*",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mop-agent",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Self-hosted AI assistant with persistent cross-project memory, installed with npx mop-agent.",
5
5
  "author": "BURHANDEV ENTERPRISE",
6
6
  "license": "UNLICENSED",