@monoes/monomindcli 2.10.1 → 2.10.2

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.
@@ -849,6 +849,13 @@ html, body {
849
849
  <div class="hdr-chip">TOPOLOGY <span id="hdr-topology">—</span></div>
850
850
  <div class="hdr-chip" id="hdr-created-wrap" style="display:none">SINCE <span id="hdr-created">—</span></div>
851
851
  </div>
852
+ <button id="upload-monoes-btn" onclick="uploadCurrentOrgToMonoes()"
853
+ style="font-size:9px; font-family:var(--mono); letter-spacing:1px; color:var(--text-mid);
854
+ border:1px solid var(--border); background:none; padding:4px 12px;
855
+ border-radius:3px; cursor:pointer; text-transform:uppercase; transition:background 0.12s;"
856
+ onmouseover="this.style.background='var(--bg-hover, rgba(255,255,255,0.06))'"
857
+ onmouseout="this.style.background='none'"
858
+ title="Upload this org to your monoes.me community account">⬡ UPLOAD TO MONOES.ME</button>
852
859
  <button id="stop-btn" onclick="stopCurrentOrg()" style="display:none;
853
860
  font-size:9px; font-family:var(--mono); letter-spacing:1px; color:var(--red);
854
861
  border:1px solid oklch(62% 0.22 25 / 0.35); background:none; padding:4px 12px;
@@ -2233,6 +2240,39 @@ function handleMmEvent(ev) {
2233
2240
  }
2234
2241
  }
2235
2242
 
2243
+ // ── Upload to monoes.me action ───────────────────────────────────────
2244
+ window.uploadCurrentOrgToMonoes = async () => {
2245
+ if (!selectedOrg) return;
2246
+ const btn = document.getElementById('upload-monoes-btn');
2247
+ const originalLabel = btn.textContent;
2248
+ btn.disabled = true;
2249
+ btn.textContent = 'UPLOADING…';
2250
+ try {
2251
+ const res = await fetch('/api/monoes/upload-org', {
2252
+ method: 'POST',
2253
+ headers: { 'Content-Type': 'application/json' },
2254
+ body: JSON.stringify({ orgName: selectedOrg }),
2255
+ });
2256
+ const body = await res.json();
2257
+ if (!res.ok) {
2258
+ if (body.error === 'not_connected') {
2259
+ alert('Connect to monoes.me first (button in the sidebar) before uploading an org.');
2260
+ } else {
2261
+ alert(`Upload failed: ${body.error || res.statusText}`);
2262
+ }
2263
+ return;
2264
+ }
2265
+ if (confirm(`Uploaded to monoes.me. Open it now?`)) {
2266
+ window.open(body.url, '_blank');
2267
+ }
2268
+ } catch (e) {
2269
+ alert(`Upload failed: ${e.message}`);
2270
+ } finally {
2271
+ btn.disabled = false;
2272
+ btn.textContent = originalLabel;
2273
+ }
2274
+ };
2275
+
2236
2276
  // ── Stop org action ────────────────────────────────────────────────
2237
2277
  window.stopCurrentOrg = async () => {
2238
2278
  if (!selectedOrg) return;
@@ -0,0 +1,336 @@
1
+ // ── monoes.me connection (OAuth) ────────────────────────────────────────
2
+ // Lets the dashboard connect a monoes.me community account: upload local
3
+ // orgs directly (routes-monoes-upload, added alongside this), and register
4
+ // monoes.me's MCP server into .mcp.json (mcp-generator.ts) so agent
5
+ // sessions in this project get monoes.me's community tools automatically.
6
+ //
7
+ // Mirrors the existing dashboard-token file convention (one JSON file
8
+ // under MONOMIND_HOME/.monomind/, plain fs read/write, no external auth
9
+ // library) rather than introducing new dependencies for a single OAuth
10
+ // client flow.
11
+ import crypto from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ const MONOES_BASE_URL = process.env.MONOMIND_MONOES_URL || 'https://monoes.me';
16
+ const MONOES_SCOPE = 'community:read community:write';
17
+
18
+ // state -> { codeVerifier, createdAt } — short-lived, in-memory only. The
19
+ // user completes the browser redirect within a few minutes of clicking
20
+ // "Connect"; losing this on a dashboard restart just means the in-flight
21
+ // connect attempt has to be retried, not a security issue.
22
+ const _pendingStates = new Map();
23
+ const STATE_TTL_MS = 10 * 60 * 1000;
24
+
25
+ function _connectionFile(monomindHome) {
26
+ return path.join(monomindHome, '.monomind', 'monoes-connection.json');
27
+ }
28
+
29
+ export function readMonoesConnection(monomindHome) {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(_connectionFile(monomindHome), 'utf8'));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function _writeMonoesConnection(monomindHome, data) {
38
+ const file = _connectionFile(monomindHome);
39
+ fs.mkdirSync(path.dirname(file), { recursive: true });
40
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 });
41
+ }
42
+
43
+ function _deleteMonoesConnection(monomindHome) {
44
+ try {
45
+ fs.unlinkSync(_connectionFile(monomindHome));
46
+ } catch {}
47
+ }
48
+
49
+ function _base64url(buffer) {
50
+ return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
51
+ }
52
+
53
+ export function createPkcePair() {
54
+ const verifier = _base64url(crypto.randomBytes(32));
55
+ const challenge = _base64url(crypto.createHash('sha256').update(verifier).digest());
56
+ return { verifier, challenge };
57
+ }
58
+
59
+ function _pruneExpiredStates() {
60
+ const now = Date.now();
61
+ for (const [state, entry] of _pendingStates) {
62
+ if (now - entry.createdAt > STATE_TTL_MS) _pendingStates.delete(state);
63
+ }
64
+ }
65
+
66
+ async function _registerClient(redirectUri) {
67
+ const res = await fetch(`${MONOES_BASE_URL}/api/auth/oauth2/register`, {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/json' },
70
+ body: JSON.stringify({
71
+ redirect_uris: [redirectUri],
72
+ token_endpoint_auth_method: 'none',
73
+ grant_types: ['authorization_code', 'refresh_token'],
74
+ // The dashboard's redirect_uri is http://127.0.0.1:<port>/... — the
75
+ // OAuth server only allows plain-http loopback redirects for native
76
+ // clients (RFC 8252 §7.3); "web" (the DCR default) requires https.
77
+ application_type: 'native',
78
+ }),
79
+ });
80
+ if (!res.ok) throw new Error(`monoes.me client registration failed: ${res.status}`);
81
+ const data = await res.json();
82
+ return data.client_id;
83
+ }
84
+
85
+ async function _getOrRegisterClientId(monomindHome, redirectUri) {
86
+ const existing = readMonoesConnection(monomindHome);
87
+ if (existing?.clientId) return existing.clientId;
88
+ const clientId = await _registerClient(redirectUri);
89
+ _writeMonoesConnection(monomindHome, { ...(existing || {}), clientId });
90
+ return clientId;
91
+ }
92
+
93
+ function _buildMonoesMcpEntry(accessToken) {
94
+ return {
95
+ type: 'http',
96
+ url: `${MONOES_BASE_URL}/api/mcp`,
97
+ headers: { Authorization: `Bearer ${accessToken}` },
98
+ };
99
+ }
100
+
101
+ /** Merges (or removes) the `monoes` entry in the project's existing
102
+ * .mcp.json. Only touches that one key — never creates or rewrites the
103
+ * rest of the file, since .mcp.json is otherwise owned by `init`/the user.
104
+ * No-ops if .mcp.json doesn't exist yet (nothing to merge into). */
105
+ function _syncMonoesMcpEntry(projectDir, accessToken) {
106
+ const mcpPath = path.join(projectDir, '.mcp.json');
107
+ let config;
108
+ try {
109
+ config = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
110
+ } catch {
111
+ return;
112
+ }
113
+ config.mcpServers = config.mcpServers || {};
114
+ if (accessToken) {
115
+ config.mcpServers.monoes = _buildMonoesMcpEntry(accessToken);
116
+ } else {
117
+ delete config.mcpServers.monoes;
118
+ }
119
+ fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2));
120
+ }
121
+
122
+ function _json(res, corsOrigin, status, body) {
123
+ res.writeHead(status, {
124
+ 'Content-Type': 'application/json',
125
+ ...(corsOrigin ? { 'Access-Control-Allow-Origin': corsOrigin } : {}),
126
+ });
127
+ res.end(JSON.stringify(body));
128
+ }
129
+
130
+ /** Given a stored connection, returns a still-valid access token — silently
131
+ * refreshing via the refresh_token grant if the current one is expired or
132
+ * about to expire. Returns null (and deletes the connection) if refresh
133
+ * itself fails, treating that identically to "never connected". */
134
+ export async function getValidMonoesToken(monomindHome) {
135
+ const conn = readMonoesConnection(monomindHome);
136
+ if (!conn?.accessToken) return null;
137
+
138
+ const expiresSoon = !conn.expiresAt || conn.expiresAt - Date.now() < 60_000;
139
+ if (!expiresSoon) return conn.accessToken;
140
+ if (!conn.refreshToken) {
141
+ _deleteMonoesConnection(monomindHome);
142
+ return null;
143
+ }
144
+
145
+ try {
146
+ const res = await fetch(`${MONOES_BASE_URL}/api/auth/oauth2/token`, {
147
+ method: 'POST',
148
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
149
+ body: new URLSearchParams({
150
+ grant_type: 'refresh_token',
151
+ refresh_token: /* value */ conn.refreshToken,
152
+ client_id: conn.clientId,
153
+ }),
154
+ });
155
+ if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
156
+ const data = await res.json();
157
+ const updated = {
158
+ ...conn,
159
+ accessToken: /* value */ data.access_token,
160
+ refreshToken: /* value */ data.refresh_token || conn.refreshToken,
161
+ expiresAt: Date.now() + (data.expires_in || 3600) * 1000,
162
+ };
163
+ _writeMonoesConnection(monomindHome, updated);
164
+ return updated.accessToken;
165
+ } catch {
166
+ _deleteMonoesConnection(monomindHome);
167
+ return null;
168
+ }
169
+ }
170
+
171
+ export async function handleMonoesRoutes(req, res, url, corsOrigin, ctx) {
172
+ const { MONOMIND_HOME, dashboardPort, projectDir } = ctx;
173
+ const redirectUri = `http://127.0.0.1:${dashboardPort}/api/monoes/callback`;
174
+
175
+ // --------------------------------------------------- POST /api/monoes/connect
176
+ if (req.method === 'POST' && url === '/api/monoes/connect') {
177
+ try {
178
+ _pruneExpiredStates();
179
+ const clientId = await _getOrRegisterClientId(MONOMIND_HOME, redirectUri);
180
+ const { verifier, challenge } = createPkcePair();
181
+ const state = _base64url(crypto.randomBytes(16));
182
+ _pendingStates.set(state, { codeVerifier: verifier, createdAt: Date.now() });
183
+
184
+ const authorizeUrl =
185
+ `${MONOES_BASE_URL}/api/auth/oauth2/authorize?client_id=${encodeURIComponent(clientId)}` +
186
+ `&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code` +
187
+ `&scope=${encodeURIComponent(MONOES_SCOPE)}` +
188
+ `&code_challenge=${challenge}&code_challenge_method=S256&state=${state}`;
189
+
190
+ _json(res, corsOrigin, 200, { authorizeUrl });
191
+ } catch (err) {
192
+ _json(res, corsOrigin, 500, { error: err.message });
193
+ }
194
+ return true;
195
+ }
196
+
197
+ // -------------------------------------------------- GET /api/monoes/callback
198
+ if (req.method === 'GET' && url.startsWith('/api/monoes/callback')) {
199
+ const qs = new URL(req.url, 'http://localhost').searchParams;
200
+ const code = qs.get('code');
201
+ const state = qs.get('state');
202
+ const pending = state ? _pendingStates.get(state) : null;
203
+
204
+ const closeTab = (message) => {
205
+ res.writeHead(200, { 'Content-Type': 'text/html' });
206
+ res.end(
207
+ `<!doctype html><html><body style="font-family:sans-serif;padding:2rem;">${message}</body></html>`,
208
+ );
209
+ };
210
+
211
+ if (!code || !pending) {
212
+ closeTab('Connection failed — the request was invalid or expired. You can close this tab and try again.');
213
+ return true;
214
+ }
215
+ _pendingStates.delete(state);
216
+
217
+ try {
218
+ const existing = readMonoesConnection(MONOMIND_HOME);
219
+ const tokenRes = await fetch(`${MONOES_BASE_URL}/api/auth/oauth2/token`, {
220
+ method: 'POST',
221
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
222
+ body: new URLSearchParams({
223
+ grant_type: 'authorization_code',
224
+ code,
225
+ redirect_uri: redirectUri,
226
+ client_id: existing.clientId,
227
+ code_verifier: pending.codeVerifier,
228
+ }),
229
+ });
230
+ if (!tokenRes.ok) throw new Error(`token exchange failed: ${tokenRes.status}`);
231
+ const tokenData = await tokenRes.json();
232
+
233
+ const meRes = await fetch(`${MONOES_BASE_URL}/api/community/me`, {
234
+ headers: { Authorization: `Bearer ${tokenData.access_token}` },
235
+ });
236
+ const me = meRes.ok ? await meRes.json() : { username: null };
237
+
238
+ _writeMonoesConnection(MONOMIND_HOME, {
239
+ clientId: existing.clientId,
240
+ accessToken: /* value */ tokenData.access_token,
241
+ refreshToken: /* value */ tokenData.refresh_token || null,
242
+ expiresAt: Date.now() + (tokenData.expires_in || 3600) * 1000,
243
+ connectedUsername: me.username || null,
244
+ });
245
+ _syncMonoesMcpEntry(path.resolve(projectDir || process.cwd()), tokenData.access_token);
246
+
247
+ closeTab('Connected to monoes.me — you can close this tab.');
248
+ } catch (err) {
249
+ closeTab(`Connection failed: ${err.message}. You can close this tab and try again.`);
250
+ }
251
+ return true;
252
+ }
253
+
254
+ // ---------------------------------------------------- GET /api/monoes/status
255
+ // Polled on every dashboard load — piggybacks the silent-refresh check so
256
+ // .mcp.json's embedded access token self-heals (refreshed or removed)
257
+ // roughly as often as the dashboard is opened, instead of only on the
258
+ // explicit connect/disconnect actions.
259
+ if (req.method === 'GET' && url === '/api/monoes/status') {
260
+ const beforeConn = readMonoesConnection(MONOMIND_HOME);
261
+ const validToken = /* value */ beforeConn?.accessToken ? await getValidMonoesToken(MONOMIND_HOME) : null;
262
+ const afterConn = readMonoesConnection(MONOMIND_HOME);
263
+ if (validToken !== (beforeConn?.accessToken || null)) {
264
+ _syncMonoesMcpEntry(path.resolve(projectDir || process.cwd()), validToken);
265
+ }
266
+ _json(res, corsOrigin, 200, {
267
+ connected: !!validToken,
268
+ username: validToken ? afterConn?.connectedUsername || null : null,
269
+ });
270
+ return true;
271
+ }
272
+
273
+ // ------------------------------------------------ POST /api/monoes/disconnect
274
+ if (req.method === 'POST' && url === '/api/monoes/disconnect') {
275
+ _deleteMonoesConnection(MONOMIND_HOME);
276
+ _syncMonoesMcpEntry(path.resolve(projectDir || process.cwd()), null);
277
+ _json(res, corsOrigin, 200, { connected: false });
278
+ return true;
279
+ }
280
+
281
+ // ----------------------------------------------- POST /api/monoes/upload-org
282
+ // Reads the org's existing on-disk definition (the same
283
+ // .monomind/orgs/<name>.json file GET /api/orgs/:name already serves —
284
+ // no new read path) and forwards it to monoes.me's real upload endpoint.
285
+ if (req.method === 'POST' && url === '/api/monoes/upload-org') {
286
+ let body = '';
287
+ req.on('data', (c) => {
288
+ body += c;
289
+ if (body.length > 1e6) req.destroy();
290
+ });
291
+ req.on('end', async () => {
292
+ try {
293
+ const { orgName, dir } = JSON.parse(body || '{}');
294
+ if (!orgName || orgName.length > 64 || !/^[a-z0-9][a-z0-9_-]*$/i.test(orgName)) {
295
+ _json(res, corsOrigin, 400, { error: 'Invalid org name' });
296
+ return;
297
+ }
298
+
299
+ const token = await getValidMonoesToken(MONOMIND_HOME);
300
+ if (!token) {
301
+ _json(res, corsOrigin, 401, { error: 'not_connected' });
302
+ return;
303
+ }
304
+
305
+ const root = path.resolve(dir || ctx.projectDir || process.cwd());
306
+ const projDir = ctx._resolveOrgProjectDir(orgName, root) || root;
307
+ const orgFile = path.join(projDir, '.monomind', 'orgs', `${orgName}.json`);
308
+ if (!fs.existsSync(orgFile)) {
309
+ _json(res, corsOrigin, 404, { error: 'Org not found' });
310
+ return;
311
+ }
312
+ const orgJson = fs.readFileSync(orgFile, 'utf8');
313
+
314
+ const uploadRes = await fetch(`${MONOES_BASE_URL}/api/community/orgs`, {
315
+ method: 'POST',
316
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
317
+ body: JSON.stringify({ orgJson }),
318
+ });
319
+ const uploadBody = await uploadRes.json().catch(() => ({}));
320
+ if (!uploadRes.ok) {
321
+ _json(res, corsOrigin, uploadRes.status, uploadBody);
322
+ return;
323
+ }
324
+ _json(res, corsOrigin, 201, {
325
+ ...uploadBody,
326
+ url: `${MONOES_BASE_URL}/community/orgs/${uploadBody.id}`,
327
+ });
328
+ } catch (err) {
329
+ _json(res, corsOrigin, 500, { error: err.message });
330
+ }
331
+ });
332
+ return true;
333
+ }
334
+
335
+ return false;
336
+ }
@@ -19,6 +19,7 @@ import {
19
19
  collectTokens,
20
20
  } from './collector.mjs';
21
21
  import { handleMonographRoutes } from './routes-monograph.mjs';
22
+ import { handleMonoesRoutes } from './routes-monoes.mjs';
22
23
  import { handleOrgRoutes } from './routes-org.mjs';
23
24
  import {
24
25
  addMmClient,
@@ -1778,7 +1779,13 @@ export async function startServer({
1778
1779
  ]);
1779
1780
  const _isOpenRoute = (url, method) =>
1780
1781
  (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') &&
1781
- (_OPEN_ROUTES.has(url) || /^\/data\/avatars\/[A-Za-z0-9._-]+\.svg$/.test(url));
1782
+ (_OPEN_ROUTES.has(url) ||
1783
+ /^\/data\/avatars\/[A-Za-z0-9._-]+\.svg$/.test(url) ||
1784
+ // The browser hits this directly via monoes.me's OAuth redirect — it
1785
+ // never has this dashboard's own auth token to attach. Protected
1786
+ // instead by the OAuth `state` parameter (routes-monoes.mjs rejects
1787
+ // any code/state pair it didn't itself generate).
1788
+ url.startsWith('/api/monoes/callback'));
1782
1789
 
1783
1790
  const server = http.createServer(async (req, res) => {
1784
1791
  const url = req.url.split('?')[0];
@@ -5296,6 +5303,17 @@ export async function startServer({
5296
5303
  return;
5297
5304
  }
5298
5305
 
5306
+ // ── monoes.me connection routes (extracted to routes-monoes.mjs) ───────
5307
+ if (
5308
+ await handleMonoesRoutes(req, res, url, corsOrigin, {
5309
+ MONOMIND_HOME,
5310
+ dashboardPort: _boundPortForCors,
5311
+ projectDir,
5312
+ _resolveOrgProjectDir,
5313
+ })
5314
+ )
5315
+ return;
5316
+
5299
5317
  // ── Org/mastermind routes (extracted to routes-org.mjs) ────────────────
5300
5318
  if (
5301
5319
  await handleOrgRoutes(req, res, url, corsOrigin, {