@geminilight/mindos 0.5.37 → 0.5.39
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/app/app/api/auth/route.ts +61 -9
- package/app/app/api/health/route.ts +46 -1
- package/app/app/globals.css +0 -1
- package/app/app/page.tsx +48 -2
- package/app/components/HomeContent.tsx +453 -76
- package/app/components/SidebarLayout.tsx +70 -235
- package/app/components/settings/McpSkillCreateForm.tsx +178 -0
- package/app/components/settings/McpSkillRow.tsx +145 -0
- package/app/components/settings/McpSkillsSection.tsx +71 -307
- package/app/hooks/useAskPanel.ts +117 -0
- package/app/hooks/useLeftPanel.ts +81 -0
- package/app/lib/actions.ts +35 -0
- package/app/lib/core/fs-ops.ts +2 -0
- package/app/lib/core/space-scaffold.ts +103 -0
- package/app/lib/i18n-en.ts +14 -3
- package/app/lib/i18n-zh.ts +14 -3
- package/app/lib/mcp-agents.ts +86 -7
- package/app/package.json +4 -2
- package/package.json +1 -5
- package/scripts/release.sh +18 -4
|
@@ -5,33 +5,85 @@ import { signJwt } from '@/lib/jwt';
|
|
|
5
5
|
const COOKIE_NAME = 'mindos-session';
|
|
6
6
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
|
7
7
|
|
|
8
|
+
// Allowed CORS origins for cross-origin auth (Capacitor, Electron remote).
|
|
9
|
+
// Localhost variants are always allowed; custom origins can be added here.
|
|
10
|
+
const ALLOWED_ORIGIN_PATTERNS = [
|
|
11
|
+
/^https?:\/\/localhost(:\d+)?$/,
|
|
12
|
+
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
|
|
13
|
+
/^https?:\/\/\[::1\](:\d+)?$/,
|
|
14
|
+
/^capacitor:\/\//,
|
|
15
|
+
/^file:\/\//,
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function isAllowedOrigin(origin: string): boolean {
|
|
19
|
+
return ALLOWED_ORIGIN_PATTERNS.some((p) => p.test(origin));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** CORS headers — validate origin against allowlist */
|
|
23
|
+
function getAuthCors(req: NextRequest): Record<string, string> {
|
|
24
|
+
const origin = req.headers.get('origin');
|
|
25
|
+
// No origin header (same-origin browser request or non-browser client) → no CORS needed
|
|
26
|
+
if (!origin) return {};
|
|
27
|
+
// Validate origin
|
|
28
|
+
if (!isAllowedOrigin(origin)) return {};
|
|
29
|
+
return {
|
|
30
|
+
'Access-Control-Allow-Origin': origin,
|
|
31
|
+
'Access-Control-Allow-Methods': 'POST, DELETE, OPTIONS',
|
|
32
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
33
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function withCors(res: NextResponse, req: NextRequest): NextResponse {
|
|
38
|
+
for (const [k, v] of Object.entries(getAuthCors(req))) res.headers.set(k, v);
|
|
39
|
+
return res;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// OPTIONS /api/auth — CORS preflight
|
|
43
|
+
export async function OPTIONS(req: NextRequest) {
|
|
44
|
+
const headers = getAuthCors(req);
|
|
45
|
+
if (Object.keys(headers).length === 0) {
|
|
46
|
+
return new Response(null, { status: 204 });
|
|
47
|
+
}
|
|
48
|
+
return new Response(null, { status: 204, headers });
|
|
49
|
+
}
|
|
50
|
+
|
|
8
51
|
// POST /api/auth — validate password and set JWT session cookie
|
|
9
52
|
export async function POST(req: NextRequest) {
|
|
10
53
|
const webPassword = process.env.WEB_PASSWORD;
|
|
11
|
-
if (!webPassword) return NextResponse.json({ ok: false }, { status: 401 });
|
|
54
|
+
if (!webPassword) return withCors(NextResponse.json({ ok: false }, { status: 401 }), req);
|
|
12
55
|
|
|
13
|
-
const body = await req.json().catch(() =>
|
|
14
|
-
if (body
|
|
56
|
+
const body = await req.json().catch(() => null);
|
|
57
|
+
if (!body || typeof body !== 'object') {
|
|
58
|
+
return withCors(NextResponse.json({ error: 'Invalid request body' }, { status: 400 }), req);
|
|
59
|
+
}
|
|
60
|
+
const { password } = body as { password?: string };
|
|
61
|
+
if (password !== webPassword) return withCors(NextResponse.json({ ok: false }, { status: 401 }), req);
|
|
15
62
|
|
|
16
63
|
const token = await signJwt(
|
|
17
64
|
{ sub: 'user', exp: Math.floor(Date.now() / 1000) + COOKIE_MAX_AGE },
|
|
18
65
|
webPassword,
|
|
19
66
|
);
|
|
20
67
|
|
|
21
|
-
// Only set Secure flag when served over HTTPS (x-forwarded-proto set by reverse proxy)
|
|
22
68
|
const isHttps = req.headers.get('x-forwarded-proto') === 'https';
|
|
69
|
+
const origin = req.headers.get('origin');
|
|
70
|
+
// Cross-origin requests need SameSite=None + Secure (HTTPS required by spec).
|
|
71
|
+
// Same-origin or no-origin → use Lax (works over HTTP).
|
|
72
|
+
const isCrossOrigin = !!origin && isAllowedOrigin(origin);
|
|
73
|
+
const sameSite = isCrossOrigin && isHttps ? 'None' : 'Lax';
|
|
23
74
|
const secure = isHttps ? '; Secure' : '';
|
|
75
|
+
|
|
24
76
|
const res = NextResponse.json({ ok: true });
|
|
25
77
|
res.headers.set(
|
|
26
78
|
'Set-Cookie',
|
|
27
|
-
`${COOKIE_NAME}=${token}; HttpOnly; SameSite
|
|
79
|
+
`${COOKIE_NAME}=${token}; HttpOnly; SameSite=${sameSite}; Max-Age=${COOKIE_MAX_AGE}; Path=/${secure}`,
|
|
28
80
|
);
|
|
29
|
-
return res;
|
|
81
|
+
return withCors(res, req);
|
|
30
82
|
}
|
|
31
83
|
|
|
32
84
|
// DELETE /api/auth — clear session cookie (logout)
|
|
33
|
-
export async function DELETE() {
|
|
85
|
+
export async function DELETE(req: NextRequest) {
|
|
34
86
|
const res = NextResponse.json({ ok: true });
|
|
35
|
-
res.headers.set('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; SameSite=
|
|
36
|
-
return res;
|
|
87
|
+
res.headers.set('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/`);
|
|
88
|
+
return withCors(res, req);
|
|
37
89
|
}
|
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
export const dynamic = 'force-dynamic';
|
|
2
2
|
import { NextResponse } from 'next/server';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { join, dirname } from 'path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read version from root package.json.
|
|
8
|
+
* Tries multiple paths to handle dev, production, and standalone builds.
|
|
9
|
+
*/
|
|
10
|
+
function readVersion(): string {
|
|
11
|
+
// 1. Env var set by CLI (most reliable)
|
|
12
|
+
if (process.env.npm_package_version) return process.env.npm_package_version;
|
|
13
|
+
|
|
14
|
+
// 2. Try known relative paths from Next.js app directory
|
|
15
|
+
const candidates = [
|
|
16
|
+
join(process.cwd(), '..', 'package.json'), // dev: app/ → root
|
|
17
|
+
join(process.cwd(), 'package.json'), // if cwd is root
|
|
18
|
+
join(dirname(process.cwd()), 'package.json'), // standalone edge case
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
for (const p of candidates) {
|
|
22
|
+
try {
|
|
23
|
+
const pkg = JSON.parse(readFileSync(p, 'utf-8'));
|
|
24
|
+
if (pkg.name === '@geminilight/mindos' && pkg.version) return pkg.version;
|
|
25
|
+
} catch { /* try next */ }
|
|
26
|
+
}
|
|
27
|
+
return '0.0.0';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const version = readVersion();
|
|
31
|
+
|
|
32
|
+
const CORS_HEADERS: Record<string, string> = {
|
|
33
|
+
'Access-Control-Allow-Origin': '*',
|
|
34
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
35
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
36
|
+
};
|
|
3
37
|
|
|
4
38
|
export async function GET() {
|
|
5
|
-
|
|
39
|
+
const res = NextResponse.json({
|
|
40
|
+
ok: true,
|
|
41
|
+
service: 'mindos',
|
|
42
|
+
version,
|
|
43
|
+
authRequired: !!process.env.WEB_PASSWORD,
|
|
44
|
+
});
|
|
45
|
+
for (const [k, v] of Object.entries(CORS_HEADERS)) res.headers.set(k, v);
|
|
46
|
+
return res;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function OPTIONS() {
|
|
50
|
+
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
6
51
|
}
|
package/app/app/globals.css
CHANGED
package/app/app/page.tsx
CHANGED
|
@@ -1,12 +1,56 @@
|
|
|
1
1
|
import { redirect } from 'next/navigation';
|
|
2
2
|
import { readSettings } from '@/lib/settings';
|
|
3
|
-
import { getRecentlyModified, getFileContent } from '@/lib/fs';
|
|
3
|
+
import { getRecentlyModified, getFileContent, getFileTree } from '@/lib/fs';
|
|
4
4
|
import { getAllRenderers } from '@/lib/renderers/registry';
|
|
5
5
|
import '@/lib/renderers/index'; // registers all renderers
|
|
6
6
|
import HomeContent from '@/components/HomeContent';
|
|
7
|
+
import type { FileNode } from '@/lib/core/types';
|
|
7
8
|
|
|
8
9
|
export const dynamic = 'force-dynamic';
|
|
9
10
|
|
|
11
|
+
export interface SpaceInfo {
|
|
12
|
+
name: string;
|
|
13
|
+
path: string;
|
|
14
|
+
fileCount: number;
|
|
15
|
+
description: string; // first paragraph from README.md (after title)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function countFiles(node: FileNode): number {
|
|
19
|
+
if (node.type === 'file') return 1;
|
|
20
|
+
return (node.children ?? []).reduce((sum, c) => sum + countFiles(c), 0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Extract the first non-empty paragraph after the title from a README.md */
|
|
24
|
+
function extractDescription(spacePath: string): string {
|
|
25
|
+
try {
|
|
26
|
+
const content = getFileContent(spacePath + 'README.md');
|
|
27
|
+
const lines = content.split('\n');
|
|
28
|
+
// Skip title line (# ...) and blank lines, return first non-empty non-heading line
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
const trimmed = line.trim();
|
|
31
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
32
|
+
return trimmed;
|
|
33
|
+
}
|
|
34
|
+
} catch { /* README.md doesn't exist */ }
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getTopLevelDirs(): SpaceInfo[] {
|
|
39
|
+
try {
|
|
40
|
+
const tree = getFileTree();
|
|
41
|
+
return tree
|
|
42
|
+
.filter(n => n.type === 'directory' && !n.name.startsWith('.'))
|
|
43
|
+
.map(n => ({
|
|
44
|
+
name: n.name,
|
|
45
|
+
path: n.path + '/',
|
|
46
|
+
fileCount: countFiles(n),
|
|
47
|
+
description: extractDescription(n.path + '/'),
|
|
48
|
+
}));
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
10
54
|
function getExistingFiles(paths: string[]): string[] {
|
|
11
55
|
return paths.filter(p => {
|
|
12
56
|
try { getFileContent(p); return true; } catch { return false; }
|
|
@@ -30,5 +74,7 @@ export default function HomePage() {
|
|
|
30
74
|
.filter((p): p is string => !!p);
|
|
31
75
|
const existingFiles = getExistingFiles(entryPaths);
|
|
32
76
|
|
|
33
|
-
|
|
77
|
+
const spaces = getTopLevelDirs();
|
|
78
|
+
|
|
79
|
+
return <HomeContent recent={recent} existingFiles={existingFiles} spaces={spaces} />;
|
|
34
80
|
}
|