@geminilight/mindos 0.2.1 â 0.3.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.
- package/app/app/api/init/route.ts +7 -41
- package/app/app/api/settings/route.ts +3 -0
- package/app/app/api/setup/generate-token/route.ts +23 -0
- package/app/app/api/setup/route.ts +81 -0
- package/app/app/page.tsx +5 -0
- package/app/app/setup/page.tsx +9 -0
- package/app/components/SetupWizard.tsx +479 -0
- package/app/lib/i18n.ts +92 -0
- package/app/lib/settings.ts +12 -1
- package/app/lib/template.ts +45 -0
- package/package.json +1 -1
- package/scripts/setup.js +93 -1
|
@@ -1,56 +1,22 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
import fs from 'fs';
|
|
3
|
-
import path from 'path';
|
|
4
2
|
import { getMindRoot } from '@/lib/fs';
|
|
5
|
-
|
|
6
|
-
function copyRecursive(src: string, dest: string) {
|
|
7
|
-
const stat = fs.statSync(src);
|
|
8
|
-
if (stat.isDirectory()) {
|
|
9
|
-
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
10
|
-
for (const entry of fs.readdirSync(src)) {
|
|
11
|
-
copyRecursive(path.join(src, entry), path.join(dest, entry));
|
|
12
|
-
}
|
|
13
|
-
} else {
|
|
14
|
-
// Skip if file already exists
|
|
15
|
-
if (fs.existsSync(dest)) return;
|
|
16
|
-
// Ensure parent directory exists
|
|
17
|
-
const dir = path.dirname(dest);
|
|
18
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
19
|
-
fs.copyFileSync(src, dest);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
3
|
+
import { applyTemplate } from '@/lib/template';
|
|
22
4
|
|
|
23
5
|
export async function POST(req: NextRequest) {
|
|
24
6
|
try {
|
|
25
7
|
const body = await req.json();
|
|
26
8
|
const template = body.template as string;
|
|
27
9
|
|
|
28
|
-
if (!['en', 'zh', 'empty'].includes(template)) {
|
|
29
|
-
return NextResponse.json({ error: 'Invalid template' }, { status: 400 });
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Resolve template source directory
|
|
33
|
-
// templates/ is at the repo root (sibling of app/)
|
|
34
|
-
const repoRoot = path.resolve(process.cwd(), '..');
|
|
35
|
-
const templateDir = path.join(repoRoot, 'templates', template);
|
|
36
|
-
|
|
37
|
-
if (!fs.existsSync(templateDir)) {
|
|
38
|
-
return NextResponse.json({ error: `Template "${template}" not found` }, { status: 404 });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
10
|
const mindRoot = getMindRoot();
|
|
42
|
-
|
|
43
|
-
fs.mkdirSync(mindRoot, { recursive: true });
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
copyRecursive(templateDir, mindRoot);
|
|
11
|
+
applyTemplate(template, mindRoot);
|
|
47
12
|
|
|
48
13
|
return NextResponse.json({ ok: true, template });
|
|
49
14
|
} catch (e) {
|
|
50
15
|
console.error('[/api/init] Error:', e);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
16
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
17
|
+
const status = msg.startsWith('Invalid template') ? 400
|
|
18
|
+
: msg.includes('not found') ? 404
|
|
19
|
+
: 500;
|
|
20
|
+
return NextResponse.json({ error: msg }, { status });
|
|
55
21
|
}
|
|
56
22
|
}
|
|
@@ -112,6 +112,9 @@ export async function POST(req: NextRequest) {
|
|
|
112
112
|
mindRoot: body.mindRoot ?? current.mindRoot,
|
|
113
113
|
webPassword: resolvedWebPassword,
|
|
114
114
|
authToken: resolvedAuthToken,
|
|
115
|
+
port: typeof body.port === 'number' ? body.port : current.port,
|
|
116
|
+
mcpPort: typeof body.mcpPort === 'number' ? body.mcpPort : current.mcpPort,
|
|
117
|
+
startMode: body.startMode ?? current.startMode,
|
|
115
118
|
};
|
|
116
119
|
|
|
117
120
|
writeSettings(next);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const dynamic = 'force-dynamic';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { randomBytes, createHash } from 'crypto';
|
|
4
|
+
|
|
5
|
+
export async function POST(req: Request) {
|
|
6
|
+
try {
|
|
7
|
+
const { seed } = await req.json().catch(() => ({} as { seed?: string }));
|
|
8
|
+
let raw: string;
|
|
9
|
+
if (seed && typeof seed === 'string' && seed.trim()) {
|
|
10
|
+
raw = createHash('sha256').update(seed.trim()).digest('hex').slice(0, 24);
|
|
11
|
+
} else {
|
|
12
|
+
raw = randomBytes(12).toString('hex'); // 24 hex chars
|
|
13
|
+
}
|
|
14
|
+
// Format as xxxx-xxxx-xxxx-xxxx-xxxx-xxxx
|
|
15
|
+
const token = raw.match(/.{4}/g)!.join('-');
|
|
16
|
+
return NextResponse.json({ token });
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return NextResponse.json(
|
|
19
|
+
{ error: e instanceof Error ? e.message : String(e) },
|
|
20
|
+
{ status: 500 },
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export const dynamic = 'force-dynamic';
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { readSettings, writeSettings, ServerSettings } from '@/lib/settings';
|
|
6
|
+
import { applyTemplate } from '@/lib/template';
|
|
7
|
+
|
|
8
|
+
function expandHome(p: string): string {
|
|
9
|
+
if (p.startsWith('~/')) return p.replace('~', os.homedir());
|
|
10
|
+
if (p === '~') return os.homedir();
|
|
11
|
+
return p;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function POST(req: NextRequest) {
|
|
15
|
+
try {
|
|
16
|
+
const body = await req.json();
|
|
17
|
+
const { mindRoot, template, port, mcpPort, authToken, webPassword, ai } = body;
|
|
18
|
+
|
|
19
|
+
// Validate required fields
|
|
20
|
+
if (!mindRoot || typeof mindRoot !== 'string') {
|
|
21
|
+
return NextResponse.json({ error: 'mindRoot is required' }, { status: 400 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const resolvedRoot = expandHome(mindRoot.trim());
|
|
25
|
+
|
|
26
|
+
// Validate ports
|
|
27
|
+
const webPort = typeof port === 'number' ? port : 3000;
|
|
28
|
+
const mcpPortNum = typeof mcpPort === 'number' ? mcpPort : 8787;
|
|
29
|
+
if (webPort < 1024 || webPort > 65535) {
|
|
30
|
+
return NextResponse.json({ error: `Invalid web port: ${webPort}` }, { status: 400 });
|
|
31
|
+
}
|
|
32
|
+
if (mcpPortNum < 1024 || mcpPortNum > 65535) {
|
|
33
|
+
return NextResponse.json({ error: `Invalid MCP port: ${mcpPortNum}` }, { status: 400 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Apply template if mindRoot doesn't exist or is empty
|
|
37
|
+
const dirExists = fs.existsSync(resolvedRoot);
|
|
38
|
+
let dirEmpty = true;
|
|
39
|
+
if (dirExists) {
|
|
40
|
+
try {
|
|
41
|
+
const entries = fs.readdirSync(resolvedRoot).filter(e => !e.startsWith('.'));
|
|
42
|
+
dirEmpty = entries.length === 0;
|
|
43
|
+
} catch { /* treat as empty */ }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (template && (!dirExists || dirEmpty)) {
|
|
47
|
+
applyTemplate(template, resolvedRoot);
|
|
48
|
+
} else if (!dirExists) {
|
|
49
|
+
fs.mkdirSync(resolvedRoot, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Read current running port for portChanged detection
|
|
53
|
+
const current = readSettings();
|
|
54
|
+
const currentPort = current.port ?? 3000;
|
|
55
|
+
|
|
56
|
+
// Build config
|
|
57
|
+
const config: ServerSettings = {
|
|
58
|
+
ai: ai ?? current.ai,
|
|
59
|
+
mindRoot: resolvedRoot,
|
|
60
|
+
port: webPort,
|
|
61
|
+
mcpPort: mcpPortNum,
|
|
62
|
+
authToken: authToken ?? current.authToken,
|
|
63
|
+
webPassword: webPassword ?? '',
|
|
64
|
+
startMode: current.startMode,
|
|
65
|
+
setupPending: false, // clear the flag
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
writeSettings(config);
|
|
69
|
+
|
|
70
|
+
return NextResponse.json({
|
|
71
|
+
ok: true,
|
|
72
|
+
portChanged: webPort !== currentPort,
|
|
73
|
+
});
|
|
74
|
+
} catch (e) {
|
|
75
|
+
console.error('[/api/setup] Error:', e);
|
|
76
|
+
return NextResponse.json(
|
|
77
|
+
{ error: e instanceof Error ? e.message : String(e) },
|
|
78
|
+
{ status: 500 },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
package/app/app/page.tsx
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { redirect } from 'next/navigation';
|
|
2
|
+
import { readSettings } from '@/lib/settings';
|
|
1
3
|
import { getRecentlyModified } from '@/lib/fs';
|
|
2
4
|
import HomeContent from '@/components/HomeContent';
|
|
3
5
|
|
|
4
6
|
export default function HomePage() {
|
|
7
|
+
const settings = readSettings();
|
|
8
|
+
if (settings.setupPending) redirect('/setup');
|
|
9
|
+
|
|
5
10
|
let recent: { path: string; mtime: number }[] = [];
|
|
6
11
|
try {
|
|
7
12
|
recent = getRecentlyModified(15);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { redirect } from 'next/navigation';
|
|
2
|
+
import { readSettings } from '@/lib/settings';
|
|
3
|
+
import SetupWizard from '@/components/SetupWizard';
|
|
4
|
+
|
|
5
|
+
export default function SetupPage() {
|
|
6
|
+
const settings = readSettings();
|
|
7
|
+
if (!settings.setupPending) redirect('/');
|
|
8
|
+
return <SetupWizard />;
|
|
9
|
+
}
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { Sparkles, Globe, BookOpen, FileText, Copy, Check, RefreshCw, Loader2, ChevronLeft, ChevronRight, AlertTriangle } from 'lucide-react';
|
|
6
|
+
import { useLocale } from '@/lib/LocaleContext';
|
|
7
|
+
import { Field, Input, Select, ApiKeyInput } from '@/components/settings/Primitives';
|
|
8
|
+
|
|
9
|
+
type Template = 'en' | 'zh' | 'empty' | '';
|
|
10
|
+
|
|
11
|
+
interface SetupState {
|
|
12
|
+
mindRoot: string;
|
|
13
|
+
template: Template;
|
|
14
|
+
provider: 'anthropic' | 'openai' | 'skip';
|
|
15
|
+
anthropicKey: string;
|
|
16
|
+
anthropicModel: string;
|
|
17
|
+
openaiKey: string;
|
|
18
|
+
openaiModel: string;
|
|
19
|
+
openaiBaseUrl: string;
|
|
20
|
+
webPort: number;
|
|
21
|
+
mcpPort: number;
|
|
22
|
+
authToken: string;
|
|
23
|
+
webPassword: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TEMPLATES: Array<{
|
|
27
|
+
id: Template;
|
|
28
|
+
icon: React.ReactNode;
|
|
29
|
+
dirs: string[];
|
|
30
|
+
}> = [
|
|
31
|
+
{ id: 'en', icon: <Globe size={18} />, dirs: ['Profile/', 'Connections/', 'Notes/', 'Workflows/', 'Resources/', 'Projects/'] },
|
|
32
|
+
{ id: 'zh', icon: <BookOpen size={18} />, dirs: ['įŧå/', 'å
ģįģŧ/', 'įŽčŪ°/', 'æĩįĻ/', 'čĩæš/', 'éĄđįŪ/'] },
|
|
33
|
+
{ id: 'empty', icon: <FileText size={18} />, dirs: ['README.md', 'CONFIG.json', 'INSTRUCTION.md'] },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const TOTAL_STEPS = 5;
|
|
37
|
+
|
|
38
|
+
export default function SetupWizard() {
|
|
39
|
+
const { t } = useLocale();
|
|
40
|
+
const router = useRouter();
|
|
41
|
+
const s = t.setup;
|
|
42
|
+
|
|
43
|
+
const [step, setStep] = useState(0);
|
|
44
|
+
const [state, setState] = useState<SetupState>({
|
|
45
|
+
mindRoot: '~/MindOS',
|
|
46
|
+
template: 'en',
|
|
47
|
+
provider: 'anthropic',
|
|
48
|
+
anthropicKey: '',
|
|
49
|
+
anthropicModel: 'claude-sonnet-4-6',
|
|
50
|
+
openaiKey: '',
|
|
51
|
+
openaiModel: 'gpt-5.4',
|
|
52
|
+
openaiBaseUrl: '',
|
|
53
|
+
webPort: 3000,
|
|
54
|
+
mcpPort: 8787,
|
|
55
|
+
authToken: '',
|
|
56
|
+
webPassword: '',
|
|
57
|
+
});
|
|
58
|
+
const [tokenCopied, setTokenCopied] = useState(false);
|
|
59
|
+
const [submitting, setSubmitting] = useState(false);
|
|
60
|
+
const [error, setError] = useState('');
|
|
61
|
+
const [portChanged, setPortChanged] = useState(false);
|
|
62
|
+
|
|
63
|
+
// Generate token on mount
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
fetch('/api/setup/generate-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
|
66
|
+
.then(r => r.json())
|
|
67
|
+
.then(data => { if (data.token) setState(prev => ({ ...prev, authToken: data.token })); })
|
|
68
|
+
.catch(() => {});
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const update = useCallback(<K extends keyof SetupState>(key: K, val: SetupState[K]) => {
|
|
72
|
+
setState(prev => ({ ...prev, [key]: val }));
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
const generateToken = async (seed?: string) => {
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch('/api/setup/generate-token', {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({ seed: seed || undefined }),
|
|
81
|
+
});
|
|
82
|
+
const data = await res.json();
|
|
83
|
+
if (data.token) update('authToken', data.token);
|
|
84
|
+
} catch { /* ignore */ }
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const copyToken = () => {
|
|
88
|
+
navigator.clipboard.writeText(state.authToken);
|
|
89
|
+
setTokenCopied(true);
|
|
90
|
+
setTimeout(() => setTokenCopied(false), 2000);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handleComplete = async () => {
|
|
94
|
+
setSubmitting(true);
|
|
95
|
+
setError('');
|
|
96
|
+
try {
|
|
97
|
+
const payload = {
|
|
98
|
+
mindRoot: state.mindRoot.startsWith('~')
|
|
99
|
+
? state.mindRoot // server will resolve
|
|
100
|
+
: state.mindRoot,
|
|
101
|
+
template: state.template || undefined,
|
|
102
|
+
port: state.webPort,
|
|
103
|
+
mcpPort: state.mcpPort,
|
|
104
|
+
authToken: state.authToken,
|
|
105
|
+
webPassword: state.webPassword,
|
|
106
|
+
ai: state.provider === 'skip' ? undefined : {
|
|
107
|
+
provider: state.provider,
|
|
108
|
+
providers: {
|
|
109
|
+
anthropic: { apiKey: state.anthropicKey, model: state.anthropicModel },
|
|
110
|
+
openai: { apiKey: state.openaiKey, model: state.openaiModel, baseUrl: state.openaiBaseUrl },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const res = await fetch('/api/setup', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
body: JSON.stringify(payload),
|
|
118
|
+
});
|
|
119
|
+
const data = await res.json();
|
|
120
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
121
|
+
if (data.portChanged) setPortChanged(true);
|
|
122
|
+
else router.push('/');
|
|
123
|
+
} catch (e) {
|
|
124
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
125
|
+
} finally {
|
|
126
|
+
setSubmitting(false);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const canNext = () => {
|
|
131
|
+
if (step === 0) return state.mindRoot.trim().length > 0;
|
|
132
|
+
if (step === 2) return state.webPort >= 1024 && state.webPort <= 65535 && state.mcpPort >= 1024 && state.mcpPort <= 65535;
|
|
133
|
+
return true;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const maskKey = (key: string) => {
|
|
137
|
+
if (!key) return '(not set)';
|
|
138
|
+
if (key.length <= 8) return 'âĒâĒâĒ';
|
|
139
|
+
return key.slice(0, 6) + 'âĒâĒâĒ' + key.slice(-3);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Step indicator dots
|
|
143
|
+
const StepDots = () => (
|
|
144
|
+
<div className="flex items-center gap-2 mb-8">
|
|
145
|
+
{s.stepTitles.map((title: string, i: number) => (
|
|
146
|
+
<div key={i} className="flex items-center gap-2">
|
|
147
|
+
{i > 0 && <div className="w-8 h-px" style={{ background: i <= step ? 'var(--amber)' : 'var(--border)' }} />}
|
|
148
|
+
<button
|
|
149
|
+
onClick={() => i < step && setStep(i)}
|
|
150
|
+
className="flex items-center gap-1.5"
|
|
151
|
+
disabled={i > step}
|
|
152
|
+
>
|
|
153
|
+
<div
|
|
154
|
+
className="w-6 h-6 rounded-full text-xs font-medium flex items-center justify-center transition-colors"
|
|
155
|
+
style={{
|
|
156
|
+
background: i === step ? 'var(--amber)' : i < step ? 'var(--amber)' : 'var(--muted)',
|
|
157
|
+
color: i <= step ? 'white' : 'var(--muted-foreground)',
|
|
158
|
+
opacity: i <= step ? 1 : 0.5,
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
{i + 1}
|
|
162
|
+
</div>
|
|
163
|
+
<span
|
|
164
|
+
className="text-xs hidden sm:inline"
|
|
165
|
+
style={{ color: i === step ? 'var(--foreground)' : 'var(--muted-foreground)', opacity: i <= step ? 1 : 0.5 }}
|
|
166
|
+
>
|
|
167
|
+
{title}
|
|
168
|
+
</span>
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Step 1: Knowledge Base
|
|
176
|
+
const Step1 = () => (
|
|
177
|
+
<div className="space-y-6">
|
|
178
|
+
<Field label={s.kbPath} hint={s.kbPathHint}>
|
|
179
|
+
<Input
|
|
180
|
+
value={state.mindRoot}
|
|
181
|
+
onChange={e => update('mindRoot', e.target.value)}
|
|
182
|
+
placeholder={s.kbPathDefault}
|
|
183
|
+
/>
|
|
184
|
+
</Field>
|
|
185
|
+
<div>
|
|
186
|
+
<label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
|
|
187
|
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
188
|
+
{TEMPLATES.map(tpl => (
|
|
189
|
+
<button
|
|
190
|
+
key={tpl.id}
|
|
191
|
+
onClick={() => update('template', tpl.id)}
|
|
192
|
+
className="flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all duration-150"
|
|
193
|
+
style={{
|
|
194
|
+
background: state.template === tpl.id ? 'var(--amber-subtle, rgba(200,135,30,0.08))' : 'var(--card)',
|
|
195
|
+
borderColor: state.template === tpl.id ? 'var(--amber)' : 'var(--border)',
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
<div className="flex items-center gap-2">
|
|
199
|
+
<span style={{ color: 'var(--amber)' }}>{tpl.icon}</span>
|
|
200
|
+
<span className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>
|
|
201
|
+
{t.onboarding.templates[tpl.id as 'en' | 'zh' | 'empty'].title}
|
|
202
|
+
</span>
|
|
203
|
+
</div>
|
|
204
|
+
<div
|
|
205
|
+
className="w-full rounded-lg px-2.5 py-1.5 text-[11px] leading-relaxed"
|
|
206
|
+
style={{ background: 'var(--muted)', fontFamily: "'IBM Plex Mono', monospace", color: 'var(--muted-foreground)' }}
|
|
207
|
+
>
|
|
208
|
+
{tpl.dirs.map(d => <div key={d}>{d}</div>)}
|
|
209
|
+
</div>
|
|
210
|
+
</button>
|
|
211
|
+
))}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Step 2: AI Provider
|
|
218
|
+
const Step2 = () => (
|
|
219
|
+
<div className="space-y-5">
|
|
220
|
+
<Field label={s.aiProvider} hint={s.aiProviderHint}>
|
|
221
|
+
<Select value={state.provider} onChange={e => update('provider', e.target.value as SetupState['provider'])}>
|
|
222
|
+
<option value="anthropic">Anthropic</option>
|
|
223
|
+
<option value="openai">OpenAI</option>
|
|
224
|
+
<option value="skip">{s.aiSkip}</option>
|
|
225
|
+
</Select>
|
|
226
|
+
</Field>
|
|
227
|
+
{state.provider !== 'skip' && (
|
|
228
|
+
<>
|
|
229
|
+
<Field label={s.apiKey}>
|
|
230
|
+
<ApiKeyInput
|
|
231
|
+
value={state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey}
|
|
232
|
+
onChange={v => update(state.provider === 'anthropic' ? 'anthropicKey' : 'openaiKey', v)}
|
|
233
|
+
placeholder={state.provider === 'anthropic' ? 'sk-ant-...' : 'sk-...'}
|
|
234
|
+
/>
|
|
235
|
+
</Field>
|
|
236
|
+
<Field label={s.model}>
|
|
237
|
+
<Input
|
|
238
|
+
value={state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel}
|
|
239
|
+
onChange={e => update(state.provider === 'anthropic' ? 'anthropicModel' : 'openaiModel', e.target.value)}
|
|
240
|
+
/>
|
|
241
|
+
</Field>
|
|
242
|
+
{state.provider === 'openai' && (
|
|
243
|
+
<Field label={s.baseUrl} hint={s.baseUrlHint}>
|
|
244
|
+
<Input
|
|
245
|
+
value={state.openaiBaseUrl}
|
|
246
|
+
onChange={e => update('openaiBaseUrl', e.target.value)}
|
|
247
|
+
placeholder="https://api.openai.com/v1"
|
|
248
|
+
/>
|
|
249
|
+
</Field>
|
|
250
|
+
)}
|
|
251
|
+
</>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Step 3: Ports
|
|
257
|
+
const Step3 = () => (
|
|
258
|
+
<div className="space-y-5">
|
|
259
|
+
<Field label={s.webPort} hint={s.portHint}>
|
|
260
|
+
<Input
|
|
261
|
+
type="number"
|
|
262
|
+
min={1024}
|
|
263
|
+
max={65535}
|
|
264
|
+
value={state.webPort}
|
|
265
|
+
onChange={e => update('webPort', parseInt(e.target.value, 10) || 3000)}
|
|
266
|
+
/>
|
|
267
|
+
</Field>
|
|
268
|
+
<Field label={s.mcpPort} hint={s.portHint}>
|
|
269
|
+
<Input
|
|
270
|
+
type="number"
|
|
271
|
+
min={1024}
|
|
272
|
+
max={65535}
|
|
273
|
+
value={state.mcpPort}
|
|
274
|
+
onChange={e => update('mcpPort', parseInt(e.target.value, 10) || 8787)}
|
|
275
|
+
/>
|
|
276
|
+
</Field>
|
|
277
|
+
<p className="text-xs flex items-center gap-1.5" style={{ color: 'var(--muted-foreground)' }}>
|
|
278
|
+
<AlertTriangle size={12} />
|
|
279
|
+
{s.portRestartWarning}
|
|
280
|
+
</p>
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// Step 4: Security
|
|
285
|
+
const Step4 = () => {
|
|
286
|
+
const [seed, setSeed] = useState('');
|
|
287
|
+
const [showSeed, setShowSeed] = useState(false);
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<div className="space-y-5">
|
|
291
|
+
<Field label={s.authToken} hint={s.authTokenHint}>
|
|
292
|
+
<div className="flex gap-2">
|
|
293
|
+
<Input value={state.authToken} readOnly className="font-mono text-xs" />
|
|
294
|
+
<button
|
|
295
|
+
onClick={copyToken}
|
|
296
|
+
className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
|
|
297
|
+
style={{ color: 'var(--foreground)' }}
|
|
298
|
+
>
|
|
299
|
+
{tokenCopied ? <Check size={14} /> : <Copy size={14} />}
|
|
300
|
+
{tokenCopied ? s.copiedToken : s.copyToken}
|
|
301
|
+
</button>
|
|
302
|
+
<button
|
|
303
|
+
onClick={() => generateToken()}
|
|
304
|
+
className="flex items-center gap-1 px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
|
|
305
|
+
style={{ color: 'var(--foreground)' }}
|
|
306
|
+
>
|
|
307
|
+
<RefreshCw size={14} />
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
310
|
+
</Field>
|
|
311
|
+
|
|
312
|
+
<div>
|
|
313
|
+
<button
|
|
314
|
+
onClick={() => setShowSeed(!showSeed)}
|
|
315
|
+
className="text-xs underline"
|
|
316
|
+
style={{ color: 'var(--muted-foreground)' }}
|
|
317
|
+
>
|
|
318
|
+
{s.authTokenSeed}
|
|
319
|
+
</button>
|
|
320
|
+
{showSeed && (
|
|
321
|
+
<div className="mt-2 flex gap-2">
|
|
322
|
+
<Input
|
|
323
|
+
value={seed}
|
|
324
|
+
onChange={e => setSeed(e.target.value)}
|
|
325
|
+
placeholder={s.authTokenSeedHint}
|
|
326
|
+
/>
|
|
327
|
+
<button
|
|
328
|
+
onClick={() => { if (seed.trim()) generateToken(seed); }}
|
|
329
|
+
className="px-3 py-2 text-xs rounded-lg border border-border hover:bg-muted transition-colors shrink-0"
|
|
330
|
+
style={{ color: 'var(--foreground)' }}
|
|
331
|
+
>
|
|
332
|
+
{s.generateToken}
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
<Field label={s.webPassword} hint={s.webPasswordHint}>
|
|
339
|
+
<Input
|
|
340
|
+
type="password"
|
|
341
|
+
value={state.webPassword}
|
|
342
|
+
onChange={e => update('webPassword', e.target.value)}
|
|
343
|
+
placeholder="(optional)"
|
|
344
|
+
/>
|
|
345
|
+
</Field>
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Step 5: Review
|
|
351
|
+
const Step5 = () => {
|
|
352
|
+
const rows: [string, string][] = [
|
|
353
|
+
[s.kbPath, state.mindRoot],
|
|
354
|
+
[s.template, state.template || 'â'],
|
|
355
|
+
[s.aiProvider, state.provider],
|
|
356
|
+
...(state.provider !== 'skip' ? [
|
|
357
|
+
[s.apiKey, maskKey(state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey)] as [string, string],
|
|
358
|
+
[s.model, state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel] as [string, string],
|
|
359
|
+
] : []),
|
|
360
|
+
[s.webPort, String(state.webPort)],
|
|
361
|
+
[s.mcpPort, String(state.mcpPort)],
|
|
362
|
+
[s.authToken, state.authToken || 'â'],
|
|
363
|
+
[s.webPassword, state.webPassword ? 'âĒâĒâĒâĒâĒâĒâĒâĒ' : '(none)'],
|
|
364
|
+
];
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<div className="space-y-5">
|
|
368
|
+
<p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.reviewHint}</p>
|
|
369
|
+
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
|
|
370
|
+
{rows.map(([label, value], i) => (
|
|
371
|
+
<div
|
|
372
|
+
key={i}
|
|
373
|
+
className="flex items-center justify-between px-4 py-3 text-sm"
|
|
374
|
+
style={{
|
|
375
|
+
background: i % 2 === 0 ? 'var(--card)' : 'transparent',
|
|
376
|
+
borderTop: i > 0 ? '1px solid var(--border)' : undefined,
|
|
377
|
+
}}
|
|
378
|
+
>
|
|
379
|
+
<span style={{ color: 'var(--muted-foreground)' }}>{label}</span>
|
|
380
|
+
<span className="font-mono text-xs" style={{ color: 'var(--foreground)' }}>{value}</span>
|
|
381
|
+
</div>
|
|
382
|
+
))}
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
{error && (
|
|
386
|
+
<div className="p-3 rounded-lg text-sm text-red-500" style={{ background: 'rgba(239,68,68,0.1)' }}>
|
|
387
|
+
{s.completeFailed}: {error}
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
|
|
391
|
+
{portChanged && (
|
|
392
|
+
<div className="space-y-3">
|
|
393
|
+
<div className="p-3 rounded-lg text-sm flex items-center gap-2" style={{ background: 'rgba(200,135,30,0.1)', color: 'var(--amber)' }}>
|
|
394
|
+
<AlertTriangle size={14} />
|
|
395
|
+
{s.portChanged}
|
|
396
|
+
</div>
|
|
397
|
+
<a
|
|
398
|
+
href="/"
|
|
399
|
+
className="inline-flex items-center gap-1 px-4 py-2 text-sm rounded-lg transition-colors"
|
|
400
|
+
style={{ background: 'var(--amber)', color: 'white' }}
|
|
401
|
+
>
|
|
402
|
+
{s.completeDone} →
|
|
403
|
+
</a>
|
|
404
|
+
</div>
|
|
405
|
+
)}
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const steps = [Step1, Step2, Step3, Step4, Step5];
|
|
411
|
+
const CurrentStep = steps[step];
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style={{ background: 'var(--background)' }}>
|
|
415
|
+
<div className="w-full max-w-xl mx-auto px-6 py-12">
|
|
416
|
+
{/* Header */}
|
|
417
|
+
<div className="text-center mb-8">
|
|
418
|
+
<div className="inline-flex items-center gap-2 mb-2">
|
|
419
|
+
<Sparkles size={18} style={{ color: 'var(--amber)' }} />
|
|
420
|
+
<h1
|
|
421
|
+
className="text-2xl font-semibold tracking-tight"
|
|
422
|
+
style={{ fontFamily: "'IBM Plex Mono', monospace", color: 'var(--foreground)' }}
|
|
423
|
+
>
|
|
424
|
+
MindOS
|
|
425
|
+
</h1>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
{/* Step dots */}
|
|
430
|
+
<div className="flex justify-center">
|
|
431
|
+
<StepDots />
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
{/* Step title */}
|
|
435
|
+
<h2 className="text-lg font-semibold mb-5" style={{ color: 'var(--foreground)' }}>
|
|
436
|
+
{s.stepTitles[step]}
|
|
437
|
+
</h2>
|
|
438
|
+
|
|
439
|
+
{/* Step content */}
|
|
440
|
+
<CurrentStep />
|
|
441
|
+
|
|
442
|
+
{/* Navigation */}
|
|
443
|
+
<div className="flex items-center justify-between mt-8 pt-6" style={{ borderTop: '1px solid var(--border)' }}>
|
|
444
|
+
<button
|
|
445
|
+
onClick={() => setStep(step - 1)}
|
|
446
|
+
disabled={step === 0}
|
|
447
|
+
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg border border-border hover:bg-muted transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
448
|
+
style={{ color: 'var(--foreground)' }}
|
|
449
|
+
>
|
|
450
|
+
<ChevronLeft size={14} />
|
|
451
|
+
{s.back}
|
|
452
|
+
</button>
|
|
453
|
+
|
|
454
|
+
{step < TOTAL_STEPS - 1 ? (
|
|
455
|
+
<button
|
|
456
|
+
onClick={() => setStep(step + 1)}
|
|
457
|
+
disabled={!canNext()}
|
|
458
|
+
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
459
|
+
style={{ background: 'var(--amber)', color: 'white' }}
|
|
460
|
+
>
|
|
461
|
+
{s.next}
|
|
462
|
+
<ChevronRight size={14} />
|
|
463
|
+
</button>
|
|
464
|
+
) : (
|
|
465
|
+
<button
|
|
466
|
+
onClick={handleComplete}
|
|
467
|
+
disabled={submitting || portChanged}
|
|
468
|
+
className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
|
469
|
+
style={{ background: 'var(--amber)', color: 'white' }}
|
|
470
|
+
>
|
|
471
|
+
{submitting && <Loader2 size={14} className="animate-spin" />}
|
|
472
|
+
{submitting ? s.completing : portChanged ? s.completeDone : s.complete}
|
|
473
|
+
</button>
|
|
474
|
+
)}
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
);
|
|
479
|
+
}
|
package/app/lib/i18n.ts
CHANGED
|
@@ -186,6 +186,52 @@ export const messages = {
|
|
|
186
186
|
{ keys: ['Esc'], description: 'Cancel edit / close modal' },
|
|
187
187
|
{ keys: ['@'], description: 'Attach file in Ask AI' },
|
|
188
188
|
],
|
|
189
|
+
setup: {
|
|
190
|
+
stepTitles: ['Knowledge Base', 'AI Provider', 'Ports', 'Security', 'Review'],
|
|
191
|
+
// Step 1
|
|
192
|
+
kbPath: 'Knowledge base path',
|
|
193
|
+
kbPathHint: 'Absolute path to your notes directory.',
|
|
194
|
+
kbPathDefault: '~/MindOS',
|
|
195
|
+
template: 'Starter template',
|
|
196
|
+
templateSkip: 'Skip (directory already has files)',
|
|
197
|
+
// Step 2
|
|
198
|
+
aiProvider: 'AI Provider',
|
|
199
|
+
aiProviderHint: 'Choose your preferred AI service.',
|
|
200
|
+
aiSkip: 'Skip â configure later',
|
|
201
|
+
apiKey: 'API Key',
|
|
202
|
+
model: 'Model',
|
|
203
|
+
baseUrl: 'Base URL',
|
|
204
|
+
baseUrlHint: 'Optional. For proxies or OpenAI-compatible APIs.',
|
|
205
|
+
// Step 3
|
|
206
|
+
webPort: 'Web UI port',
|
|
207
|
+
mcpPort: 'MCP server port',
|
|
208
|
+
portHint: 'Valid range: 1024â65535',
|
|
209
|
+
portRestartWarning: 'Port changes take effect after server restart.',
|
|
210
|
+
// Step 4
|
|
211
|
+
authToken: 'Auth Token',
|
|
212
|
+
authTokenHint: 'Bearer token for MCP / API clients. Auto-generated.',
|
|
213
|
+
authTokenSeed: 'Custom seed (optional)',
|
|
214
|
+
authTokenSeedHint: 'Enter a passphrase to derive a deterministic token.',
|
|
215
|
+
generateToken: 'Generate',
|
|
216
|
+
copyToken: 'Copy',
|
|
217
|
+
copiedToken: 'Copied!',
|
|
218
|
+
webPassword: 'Web UI Password',
|
|
219
|
+
webPasswordHint: 'Optional. Protect browser access with a password.',
|
|
220
|
+
// Step 5
|
|
221
|
+
reviewTitle: 'Review Configuration',
|
|
222
|
+
reviewHint: 'Verify your settings before completing setup.',
|
|
223
|
+
keyMasked: (key: string) => key.slice(0, 6) + 'âĒâĒâĒ' + key.slice(-3),
|
|
224
|
+
portChanged: 'Port changed â please restart the server for it to take effect.',
|
|
225
|
+
// Buttons
|
|
226
|
+
back: 'Back',
|
|
227
|
+
next: 'Next',
|
|
228
|
+
complete: 'Complete Setup',
|
|
229
|
+
skip: 'Skip',
|
|
230
|
+
// Status
|
|
231
|
+
completing: 'Saving...',
|
|
232
|
+
completeDone: 'Setup complete!',
|
|
233
|
+
completeFailed: 'Setup failed. Please try again.',
|
|
234
|
+
},
|
|
189
235
|
},
|
|
190
236
|
zh: {
|
|
191
237
|
common: {
|
|
@@ -372,6 +418,52 @@ export const messages = {
|
|
|
372
418
|
{ keys: ['Esc'], description: 'åæķįžčū / å
ģéåžđįŠ' },
|
|
373
419
|
{ keys: ['@'], description: 'åĻ AI åŊđčŊäļæ·ŧå éäŧķ' },
|
|
374
420
|
],
|
|
421
|
+
setup: {
|
|
422
|
+
stepTitles: ['įĨčŊåš', 'AI æåĄå', 'įŦŊåĢ', 'åŪå
Ļ', 'įĄŪčŪĪ'],
|
|
423
|
+
// Step 1
|
|
424
|
+
kbPath: 'įĨčŊåšč·Ŋåū',
|
|
425
|
+
kbPathHint: 'įŽčŪ°įŪå―įįŧåŊđč·Ŋåūã',
|
|
426
|
+
kbPathDefault: '~/MindOS',
|
|
427
|
+
template: 'åå§æĻĄæŋ',
|
|
428
|
+
templateSkip: 'č·ģčŋïžįŪå―å·ēææäŧķïž',
|
|
429
|
+
// Step 2
|
|
430
|
+
aiProvider: 'AI æåĄå',
|
|
431
|
+
aiProviderHint: 'éæĐä― ååĨ―į AI æåĄã',
|
|
432
|
+
aiSkip: 'č·ģčŋ â įĻåé
į―Ū',
|
|
433
|
+
apiKey: 'API åŊéĨ',
|
|
434
|
+
model: 'æĻĄå',
|
|
435
|
+
baseUrl: 'æĨåĢå°å',
|
|
436
|
+
baseUrlHint: 'åŊéãįĻäšäŧĢįæ OpenAI å
žåŪđ APIã',
|
|
437
|
+
// Step 3
|
|
438
|
+
webPort: 'Web UI įŦŊåĢ',
|
|
439
|
+
mcpPort: 'MCP æåĄįŦŊåĢ',
|
|
440
|
+
portHint: 'ææčåīïž1024â65535',
|
|
441
|
+
portRestartWarning: 'įŦŊåĢäŋŪæđééåŊæåĄåįæã',
|
|
442
|
+
// Step 4
|
|
443
|
+
authToken: 'Auth Token',
|
|
444
|
+
authTokenHint: 'MCP / API åŪĒæ·įŦŊä―ŋįĻį Bearer TokenïžčŠåĻįæã',
|
|
445
|
+
authTokenSeed: 'čŠåŪäđį§åïžåŊéïž',
|
|
446
|
+
authTokenSeedHint: 'čūå
ĨåĢäŧĪįčŊįæįĄŪåŪæ§ Tokenã',
|
|
447
|
+
generateToken: 'įæ',
|
|
448
|
+
copyToken: 'åĪåķ',
|
|
449
|
+
copiedToken: 'å·ēåĪåķïž',
|
|
450
|
+
webPassword: 'į―éĄĩčŪŋéŪåŊį ',
|
|
451
|
+
webPasswordHint: 'åŊéãčŪūį―Ūåæĩč§åĻčŪŋéŪéčĶįŧå―ã',
|
|
452
|
+
// Step 5
|
|
453
|
+
reviewTitle: 'įĄŪčŪĪé
į―Ū',
|
|
454
|
+
reviewHint: 'åŪæčŪūį―ŪåčŊ·įĄŪčŪĪäŧĨäļäŋĄæŊã',
|
|
455
|
+
keyMasked: (key: string) => key.slice(0, 6) + 'âĒâĒâĒ' + key.slice(-3),
|
|
456
|
+
portChanged: 'įŦŊåĢå·ēåæī â čŊ·éåŊæåĄäŧĨä―ŋå
ķįæã',
|
|
457
|
+
// Buttons
|
|
458
|
+
back: 'äļäļæĨ',
|
|
459
|
+
next: 'äļäļæĨ',
|
|
460
|
+
complete: 'åŪæčŪūį―Ū',
|
|
461
|
+
skip: 'č·ģčŋ',
|
|
462
|
+
// Status
|
|
463
|
+
completing: 'äŋåäļ...',
|
|
464
|
+
completeDone: 'čŪūį―ŪåŪæïž',
|
|
465
|
+
completeFailed: 'čŪūį―ŪåĪąčīĨïžčŊ·éčŊã',
|
|
466
|
+
},
|
|
375
467
|
},
|
|
376
468
|
} as const;
|
|
377
469
|
|
package/app/lib/settings.ts
CHANGED
|
@@ -21,12 +21,12 @@ export interface AiConfig {
|
|
|
21
21
|
export interface ServerSettings {
|
|
22
22
|
ai: AiConfig;
|
|
23
23
|
mindRoot: string; // empty = use env var / default
|
|
24
|
-
// Fields managed by CLI only (not edited via GUI, preserved on write)
|
|
25
24
|
port?: number;
|
|
26
25
|
mcpPort?: number;
|
|
27
26
|
authToken?: string;
|
|
28
27
|
webPassword?: string;
|
|
29
28
|
startMode?: 'dev' | 'start' | 'daemon';
|
|
29
|
+
setupPending?: boolean; // true â / redirects to /setup
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const DEFAULTS: ServerSettings = {
|
|
@@ -108,6 +108,9 @@ export function readSettings(): ServerSettings {
|
|
|
108
108
|
webPassword: typeof parsed.webPassword === 'string' ? parsed.webPassword : undefined,
|
|
109
109
|
authToken: typeof parsed.authToken === 'string' ? parsed.authToken : undefined,
|
|
110
110
|
mcpPort: typeof parsed.mcpPort === 'number' ? parsed.mcpPort : undefined,
|
|
111
|
+
port: typeof parsed.port === 'number' ? parsed.port : undefined,
|
|
112
|
+
startMode: typeof parsed.startMode === 'string' ? parsed.startMode as ServerSettings['startMode'] : undefined,
|
|
113
|
+
setupPending: parsed.setupPending === true ? true : undefined,
|
|
111
114
|
};
|
|
112
115
|
} catch {
|
|
113
116
|
return { ...DEFAULTS, ai: { ...DEFAULTS.ai, providers: { ...DEFAULTS.ai.providers } } };
|
|
@@ -123,6 +126,14 @@ export function writeSettings(settings: ServerSettings): void {
|
|
|
123
126
|
const merged: Record<string, unknown> = { ...existing, ai: settings.ai, mindRoot: settings.mindRoot };
|
|
124
127
|
if (settings.webPassword !== undefined) merged.webPassword = settings.webPassword;
|
|
125
128
|
if (settings.authToken !== undefined) merged.authToken = settings.authToken;
|
|
129
|
+
if (settings.port !== undefined) merged.port = settings.port;
|
|
130
|
+
if (settings.mcpPort !== undefined) merged.mcpPort = settings.mcpPort;
|
|
131
|
+
if (settings.startMode !== undefined) merged.startMode = settings.startMode;
|
|
132
|
+
// setupPending: false/undefined â remove the field (cleanup); true â set it
|
|
133
|
+
if ('setupPending' in settings) {
|
|
134
|
+
if (settings.setupPending) merged.setupPending = true;
|
|
135
|
+
else delete merged.setupPending;
|
|
136
|
+
}
|
|
126
137
|
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
127
138
|
}
|
|
128
139
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recursively copy `src` to `dest`, skipping files that already exist in dest.
|
|
6
|
+
*/
|
|
7
|
+
export function copyRecursive(src: string, dest: string) {
|
|
8
|
+
const stat = fs.statSync(src);
|
|
9
|
+
if (stat.isDirectory()) {
|
|
10
|
+
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
11
|
+
for (const entry of fs.readdirSync(src)) {
|
|
12
|
+
copyRecursive(path.join(src, entry), path.join(dest, entry));
|
|
13
|
+
}
|
|
14
|
+
} else {
|
|
15
|
+
// Skip if file already exists
|
|
16
|
+
if (fs.existsSync(dest)) return;
|
|
17
|
+
const dir = path.dirname(dest);
|
|
18
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
fs.copyFileSync(src, dest);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Apply a built-in template (en / zh / empty) to the given directory.
|
|
25
|
+
* Returns true on success, throws on error.
|
|
26
|
+
*/
|
|
27
|
+
export function applyTemplate(template: string, destDir: string): void {
|
|
28
|
+
if (!['en', 'zh', 'empty'].includes(template)) {
|
|
29
|
+
throw new Error(`Invalid template: ${template}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// templates/ is at the repo root (sibling of app/)
|
|
33
|
+
const repoRoot = path.resolve(process.cwd(), '..');
|
|
34
|
+
const templateDir = path.join(repoRoot, 'templates', template);
|
|
35
|
+
|
|
36
|
+
if (!fs.existsSync(templateDir)) {
|
|
37
|
+
throw new Error(`Template "${template}" not found at ${templateDir}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!fs.existsSync(destDir)) {
|
|
41
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
copyRecursive(templateDir, destDir);
|
|
45
|
+
}
|
package/package.json
CHANGED
package/scripts/setup.js
CHANGED
|
@@ -26,7 +26,7 @@ import { homedir, tmpdir, networkInterfaces } from 'node:os';
|
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
27
|
import { createInterface } from 'node:readline';
|
|
28
28
|
import { pipeline } from 'node:stream/promises';
|
|
29
|
-
import { execSync } from 'node:child_process';
|
|
29
|
+
import { execSync, spawn } from 'node:child_process';
|
|
30
30
|
import { randomBytes, createHash } from 'node:crypto';
|
|
31
31
|
import { createConnection } from 'node:net';
|
|
32
32
|
|
|
@@ -41,6 +41,14 @@ const T = {
|
|
|
41
41
|
title: { en: 'ð§ MindOS Setup', zh: 'ð§ MindOS åå§å' },
|
|
42
42
|
langHint: { en: ' â â switch language / åæĒčŊčĻ â â navigate Enter confirm', zh: ' â â switch language / åæĒčŊčĻ â â äļäļåæĒ Enter įĄŪčŪĪ' },
|
|
43
43
|
|
|
44
|
+
// mode selection
|
|
45
|
+
modePrompt: { en: 'Setup mode', zh: 'é
į―Ūæđåž' },
|
|
46
|
+
modeOpts: { en: ['CLI â terminal wizard', 'GUI â browser wizard (recommended)'], zh: ['CLI â įŧįŦŊååŊž', 'GUI â æĩč§åĻååŊžïžæĻčïž'] },
|
|
47
|
+
modeVals: ['cli', 'gui'],
|
|
48
|
+
guiStarting: { en: 'âģ Starting server for GUI setup...', zh: 'âģ æĢåĻåŊåĻæåĄ...' },
|
|
49
|
+
guiReady: { en: (url) => `ð Complete setup in browser: ${url}`, zh: (url) => `ð åĻæĩč§åĻäļåŪæé
į―Ū: ${url}` },
|
|
50
|
+
guiOpenFailed: { en: (url) => ` Could not open browser automatically. Open this URL manually:\n ${url}`, zh: (url) => ` æ æģčŠåĻæåžæĩč§åĻïžčŊ·æåĻčŪŋéŪïž\n ${url}` },
|
|
51
|
+
|
|
44
52
|
// step labels
|
|
45
53
|
step: { en: (n, total) => `Step ${n}/${total}`, zh: (n, total) => `æĨéŠĪ ${n}/${total}` },
|
|
46
54
|
stepTitles: {
|
|
@@ -493,11 +501,95 @@ async function applyTemplate(tpl, mindDir) {
|
|
|
493
501
|
}
|
|
494
502
|
}
|
|
495
503
|
|
|
504
|
+
// ââ GUI Setup âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
|
|
505
|
+
|
|
506
|
+
function openBrowser(url) {
|
|
507
|
+
try {
|
|
508
|
+
const platform = process.platform;
|
|
509
|
+
if (platform === 'darwin') {
|
|
510
|
+
execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
511
|
+
} else if (platform === 'linux') {
|
|
512
|
+
// Check for WSL
|
|
513
|
+
const isWSL = existsSync('/proc/version') &&
|
|
514
|
+
readFileSync('/proc/version', 'utf-8').toLowerCase().includes('microsoft');
|
|
515
|
+
if (isWSL) {
|
|
516
|
+
execSync(`cmd.exe /c start "${url}"`, { stdio: 'ignore' });
|
|
517
|
+
} else {
|
|
518
|
+
execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
execSync(`cmd.exe /c start "${url}"`, { stdio: 'ignore' });
|
|
522
|
+
}
|
|
523
|
+
return true;
|
|
524
|
+
} catch {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function startGuiSetup() {
|
|
530
|
+
// Ensure ~/.mindos directory exists
|
|
531
|
+
mkdirSync(MINDOS_DIR, { recursive: true });
|
|
532
|
+
|
|
533
|
+
// Read or create config, set setupPending
|
|
534
|
+
let config = {};
|
|
535
|
+
try { config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')); } catch { /* ignore */ }
|
|
536
|
+
config.setupPending = true;
|
|
537
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
538
|
+
|
|
539
|
+
// Find a free port
|
|
540
|
+
const port = await findFreePort(3000);
|
|
541
|
+
if (config.port === undefined) {
|
|
542
|
+
config.port = port;
|
|
543
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
544
|
+
}
|
|
545
|
+
const usePort = config.port || port;
|
|
546
|
+
|
|
547
|
+
write(c.yellow(t('guiStarting') + '\n'));
|
|
548
|
+
|
|
549
|
+
// Start the server in the background
|
|
550
|
+
const cliPath = resolve(__dirname, '../bin/cli.js');
|
|
551
|
+
const child = spawn(process.execPath, [cliPath, 'start'], {
|
|
552
|
+
detached: true,
|
|
553
|
+
stdio: 'ignore',
|
|
554
|
+
env: { ...process.env, PORT: String(usePort) },
|
|
555
|
+
});
|
|
556
|
+
child.unref();
|
|
557
|
+
|
|
558
|
+
// Wait for the server to be ready
|
|
559
|
+
const { waitForHttp } = await import('../bin/lib/gateway.js');
|
|
560
|
+
const ready = await waitForHttp(usePort, { retries: 60, intervalMs: 1000, label: 'MindOS' });
|
|
561
|
+
|
|
562
|
+
if (!ready) {
|
|
563
|
+
write(c.red('\nâ Server failed to start.\n'));
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const url = `http://localhost:${usePort}/setup`;
|
|
568
|
+
console.log(`\n${c.green(tf('guiReady', url))}\n`);
|
|
569
|
+
|
|
570
|
+
const opened = openBrowser(url);
|
|
571
|
+
if (!opened) {
|
|
572
|
+
console.log(c.dim(tf('guiOpenFailed', url)));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
process.exit(0);
|
|
576
|
+
}
|
|
577
|
+
|
|
496
578
|
// ââ Main ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
|
|
497
579
|
|
|
498
580
|
async function main() {
|
|
499
581
|
console.log(`\n${c.bold(t('title'))}\n\n${c.dim(t('langHint'))}\n`);
|
|
500
582
|
|
|
583
|
+
// ââ Mode selection: CLI or GUI âââââââââââââââââââââââââââââââââââââââââââ
|
|
584
|
+
const mode = await select('modePrompt', 'modeOpts', 'modeVals');
|
|
585
|
+
|
|
586
|
+
if (mode === 'gui') {
|
|
587
|
+
await startGuiSetup();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ââ CLI mode continues below âââââââââââââââââââââââââââââââââââââââââââââ
|
|
592
|
+
|
|
501
593
|
// ââ Early overwrite check âââââââââââââââââââââââââââââââââââââââââââââââââ
|
|
502
594
|
if (existsSync(CONFIG_PATH)) {
|
|
503
595
|
let existing = {};
|