@geminilight/mindos 0.5.10 → 0.5.11

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.
@@ -0,0 +1,111 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextRequest, NextResponse } from 'next/server';
3
+ import { effectiveAiConfig } from '@/lib/settings';
4
+
5
+ const TIMEOUT = 10_000;
6
+
7
+ type ErrorCode = 'auth_error' | 'model_not_found' | 'rate_limited' | 'network_error' | 'unknown';
8
+
9
+ function classifyError(status: number, body: string): { code: ErrorCode; error: string } {
10
+ if (status === 401 || status === 403) return { code: 'auth_error', error: 'Invalid API key' };
11
+ if (status === 404) return { code: 'model_not_found', error: 'Model not found' };
12
+ if (status === 429) return { code: 'rate_limited', error: 'Rate limited' };
13
+ // Try to extract error message from response body
14
+ try {
15
+ const parsed = JSON.parse(body);
16
+ const msg = parsed?.error?.message || parsed?.error || '';
17
+ if (typeof msg === 'string' && msg.length > 0) return { code: 'unknown', error: msg.slice(0, 200) };
18
+ } catch { /* not JSON */ }
19
+ return { code: 'unknown', error: `HTTP ${status}` };
20
+ }
21
+
22
+ async function testAnthropic(apiKey: string, model: string): Promise<{ ok: boolean; latency?: number; code?: ErrorCode; error?: string }> {
23
+ const start = Date.now();
24
+ const ctrl = new AbortController();
25
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT);
26
+ try {
27
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
28
+ method: 'POST',
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ 'x-api-key': apiKey,
32
+ 'anthropic-version': '2023-06-01',
33
+ },
34
+ body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
35
+ signal: ctrl.signal,
36
+ });
37
+ const latency = Date.now() - start;
38
+ if (res.ok) return { ok: true, latency };
39
+ const body = await res.text();
40
+ return { ok: false, ...classifyError(res.status, body) };
41
+ } catch (e: unknown) {
42
+ if (e instanceof Error && e.name === 'AbortError') return { ok: false, code: 'network_error', error: 'Request timed out' };
43
+ return { ok: false, code: 'network_error', error: e instanceof Error ? e.message : 'Network error' };
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ }
48
+
49
+ async function testOpenAI(apiKey: string, model: string, baseUrl: string): Promise<{ ok: boolean; latency?: number; code?: ErrorCode; error?: string }> {
50
+ const start = Date.now();
51
+ const ctrl = new AbortController();
52
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT);
53
+ const url = (baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '') + '/chat/completions';
54
+ try {
55
+ const res = await fetch(url, {
56
+ method: 'POST',
57
+ headers: {
58
+ 'Content-Type': 'application/json',
59
+ 'Authorization': `Bearer ${apiKey}`,
60
+ },
61
+ body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
62
+ signal: ctrl.signal,
63
+ });
64
+ const latency = Date.now() - start;
65
+ if (res.ok) return { ok: true, latency };
66
+ const body = await res.text();
67
+ return { ok: false, ...classifyError(res.status, body) };
68
+ } catch (e: unknown) {
69
+ if (e instanceof Error && e.name === 'AbortError') return { ok: false, code: 'network_error', error: 'Request timed out' };
70
+ return { ok: false, code: 'network_error', error: e instanceof Error ? e.message : 'Network error' };
71
+ } finally {
72
+ clearTimeout(timer);
73
+ }
74
+ }
75
+
76
+ export async function POST(req: NextRequest) {
77
+ try {
78
+ const body = await req.json();
79
+ const { provider, apiKey, model, baseUrl } = body as {
80
+ provider?: string;
81
+ apiKey?: string;
82
+ model?: string;
83
+ baseUrl?: string;
84
+ };
85
+
86
+ if (provider !== 'anthropic' && provider !== 'openai') {
87
+ return NextResponse.json({ ok: false, code: 'unknown', error: 'Invalid provider' }, { status: 400 });
88
+ }
89
+
90
+ // Resolve actual API key: use provided key, fallback to config/env for masked or missing
91
+ const cfg = effectiveAiConfig();
92
+ let resolvedKey = apiKey || '';
93
+ if (!resolvedKey || resolvedKey === '***set***') {
94
+ resolvedKey = provider === 'anthropic' ? cfg.anthropicApiKey : cfg.openaiApiKey;
95
+ }
96
+
97
+ if (!resolvedKey) {
98
+ return NextResponse.json({ ok: false, code: 'auth_error', error: 'No API key configured' });
99
+ }
100
+
101
+ const resolvedModel = model || (provider === 'anthropic' ? cfg.anthropicModel : cfg.openaiModel);
102
+
103
+ const result = provider === 'anthropic'
104
+ ? await testAnthropic(resolvedKey, resolvedModel)
105
+ : await testOpenAI(resolvedKey, resolvedModel, baseUrl || cfg.openaiBaseUrl);
106
+
107
+ return NextResponse.json(result);
108
+ } catch (err) {
109
+ return NextResponse.json({ ok: false, code: 'unknown', error: String(err) }, { status: 500 });
110
+ }
111
+ }
@@ -95,25 +95,13 @@ export async function POST(req: NextRequest) {
95
95
  return NextResponse.json({ error: 'Sync already configured' }, { status: 400 });
96
96
  }
97
97
 
98
- // Build the effective remote URL (inject token for HTTPS)
99
- let effectiveRemote = remote;
100
- if (isHTTPS && body.token) {
101
- try {
102
- const urlObj = new URL(remote);
103
- urlObj.username = 'oauth2';
104
- urlObj.password = body.token;
105
- effectiveRemote = urlObj.toString();
106
- } catch {
107
- return NextResponse.json({ error: 'Invalid remote URL' }, { status: 400 });
108
- }
109
- }
110
-
111
98
  const branch = body.branch?.trim() || 'main';
112
99
 
113
- // Call CLI's sync init via execFile (avoids module resolution issues with Turbopack)
100
+ // Call CLI's sync init pass clean remote + token separately (never embed token in URL)
114
101
  try {
115
102
  const cliPath = resolve(process.cwd(), '..', 'bin', 'cli.js');
116
- const args = ['sync', 'init', '--non-interactive', '--remote', effectiveRemote, '--branch', branch];
103
+ const args = ['sync', 'init', '--non-interactive', '--remote', remote, '--branch', branch];
104
+ if (body.token) args.push('--token', body.token);
117
105
 
118
106
  await new Promise<void>((res, rej) => {
119
107
  execFile('node', [cliPath, ...args], { timeout: 30000 }, (err, stdout, stderr) => {
@@ -132,23 +120,20 @@ export async function POST(req: NextRequest) {
132
120
  if (!isGitRepo(mindRoot)) {
133
121
  return NextResponse.json({ error: 'Not a git repository' }, { status: 400 });
134
122
  }
135
- // Pull
136
- try { execSync('git pull --rebase --autostash', { cwd: mindRoot, stdio: 'pipe' }); } catch {
137
- try { execSync('git rebase --abort', { cwd: mindRoot, stdio: 'pipe' }); } catch {}
138
- try { execSync('git pull --no-rebase', { cwd: mindRoot, stdio: 'pipe' }); } catch {}
139
- }
140
- // Commit + push
141
- execSync('git add -A', { cwd: mindRoot, stdio: 'pipe' });
142
- const status = execSync('git status --porcelain', { cwd: mindRoot, encoding: 'utf-8' }).trim();
143
- if (status) {
144
- const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
145
- execSync(`git commit -m "auto-sync: ${timestamp}"`, { cwd: mindRoot, stdio: 'pipe' });
146
- execSync('git push', { cwd: mindRoot, stdio: 'pipe' });
123
+ // Delegate to CLI for unified conflict handling
124
+ try {
125
+ const cliPath = resolve(process.cwd(), '..', 'bin', 'cli.js');
126
+ await new Promise<void>((res, rej) => {
127
+ execFile('node', [cliPath, 'sync', 'now'], { timeout: 60000 }, (err, stdout, stderr) => {
128
+ if (err) rej(new Error(stderr?.trim() || err.message));
129
+ else res();
130
+ });
131
+ });
132
+ return NextResponse.json({ ok: true });
133
+ } catch (err: unknown) {
134
+ const errMsg = err instanceof Error ? err.message : String(err);
135
+ return NextResponse.json({ error: errMsg }, { status: 500 });
147
136
  }
148
- const state = loadSyncState();
149
- state.lastSync = new Date().toISOString();
150
- writeFileSync(SYNC_STATE_PATH, JSON.stringify(state, null, 2) + '\n');
151
- return NextResponse.json({ ok: true });
152
137
  }
153
138
 
154
139
  case 'on': {
@@ -1,9 +1,30 @@
1
1
  'use client';
2
2
 
3
- import { AlertCircle } from 'lucide-react';
3
+ import { useState, useRef, useCallback, useEffect } from 'react';
4
+ import { AlertCircle, Loader2 } from 'lucide-react';
4
5
  import type { AiSettings, ProviderConfig, SettingsData } from './types';
5
6
  import { Field, Select, Input, EnvBadge, ApiKeyInput } from './Primitives';
6
7
 
8
+ type TestState = 'idle' | 'testing' | 'ok' | 'error';
9
+ type ErrorCode = 'auth_error' | 'model_not_found' | 'rate_limited' | 'network_error' | 'unknown';
10
+
11
+ interface TestResult {
12
+ state: TestState;
13
+ latency?: number;
14
+ error?: string;
15
+ code?: ErrorCode;
16
+ }
17
+
18
+ function errorMessage(t: any, code?: ErrorCode): string {
19
+ switch (code) {
20
+ case 'auth_error': return t.settings.ai.testKeyAuthError;
21
+ case 'model_not_found': return t.settings.ai.testKeyModelNotFound;
22
+ case 'rate_limited': return t.settings.ai.testKeyRateLimited;
23
+ case 'network_error': return t.settings.ai.testKeyNetworkError;
24
+ default: return t.settings.ai.testKeyUnknown;
25
+ }
26
+ }
27
+
7
28
  interface AiTabProps {
8
29
  data: SettingsData;
9
30
  updateAi: (patch: Partial<AiSettings>) => void;
@@ -15,13 +36,76 @@ export function AiTab({ data, updateAi, t }: AiTabProps) {
15
36
  const envVal = data.envValues ?? {};
16
37
  const provider = data.ai.provider;
17
38
 
18
- function patchProvider(name: 'anthropic' | 'openai', patch: Partial<ProviderConfig>) {
39
+ // --- Test key state ---
40
+ const [testResult, setTestResult] = useState<Record<string, TestResult>>({});
41
+ const okTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
42
+ const prevProviderRef = useRef(provider);
43
+
44
+ // Reset test result when provider changes
45
+ useEffect(() => {
46
+ if (prevProviderRef.current !== provider) {
47
+ prevProviderRef.current = provider;
48
+ setTestResult({});
49
+ if (okTimerRef.current) { clearTimeout(okTimerRef.current); okTimerRef.current = undefined; }
50
+ }
51
+ }, [provider]);
52
+
53
+ // Cleanup ok timer
54
+ useEffect(() => () => { if (okTimerRef.current) clearTimeout(okTimerRef.current); }, []);
55
+
56
+ const handleTestKey = useCallback(async (providerName: 'anthropic' | 'openai') => {
57
+ const prov = data.ai.providers?.[providerName] ?? {} as ProviderConfig;
58
+ setTestResult(prev => ({ ...prev, [providerName]: { state: 'testing' } }));
59
+
60
+ try {
61
+ const body: Record<string, string> = { provider: providerName };
62
+ if (prov.apiKey) body.apiKey = prov.apiKey;
63
+ if (prov.model) body.model = prov.model;
64
+ if (providerName === 'openai' && prov.baseUrl) body.baseUrl = prov.baseUrl;
65
+
66
+ const res = await fetch('/api/settings/test-key', {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify(body),
70
+ });
71
+ const json = await res.json();
72
+
73
+ if (json.ok) {
74
+ setTestResult(prev => ({ ...prev, [providerName]: { state: 'ok', latency: json.latency } }));
75
+ // Auto-clear after 5s
76
+ if (okTimerRef.current) clearTimeout(okTimerRef.current);
77
+ okTimerRef.current = setTimeout(() => {
78
+ setTestResult(prev => ({ ...prev, [providerName]: { state: 'idle' } }));
79
+ }, 5000);
80
+ } else {
81
+ setTestResult(prev => ({
82
+ ...prev,
83
+ [providerName]: { state: 'error', error: json.error, code: json.code },
84
+ }));
85
+ }
86
+ } catch {
87
+ setTestResult(prev => ({
88
+ ...prev,
89
+ [providerName]: { state: 'error', code: 'network_error', error: 'Network error' },
90
+ }));
91
+ }
92
+ }, [data.ai.providers]);
93
+
94
+ // Reset test result when key changes
95
+ const patchProviderWithReset = useCallback((name: 'anthropic' | 'openai', patch: Partial<ProviderConfig>) => {
96
+ if ('apiKey' in patch) {
97
+ setTestResult(prev => ({ ...prev, [name]: { state: 'idle' } }));
98
+ }
19
99
  updateAi({
20
100
  providers: {
21
101
  ...data.ai.providers,
22
102
  [name]: { ...data.ai.providers?.[name], ...patch },
23
103
  },
24
104
  });
105
+ }, [data.ai.providers, updateAi]);
106
+
107
+ function patchProvider(name: 'anthropic' | 'openai', patch: Partial<ProviderConfig>) {
108
+ patchProviderWithReset(name, patch);
25
109
  }
26
110
 
27
111
  const anthropic = data.ai.providers?.anthropic ?? { apiKey: '', model: '' };
@@ -31,6 +115,38 @@ export function AiTab({ data, updateAi, t }: AiTabProps) {
31
115
  const activeEnvKey = provider === 'anthropic' ? env.ANTHROPIC_API_KEY : env.OPENAI_API_KEY;
32
116
  const missingApiKey = !activeApiKey && !activeEnvKey;
33
117
 
118
+ // Test button helper
119
+ const renderTestButton = (providerName: 'anthropic' | 'openai', hasKey: boolean, hasEnv: boolean) => {
120
+ const result = testResult[providerName] ?? { state: 'idle' as TestState };
121
+ const disabled = result.state === 'testing' || (!hasKey && !hasEnv);
122
+
123
+ return (
124
+ <div className="flex items-center gap-2 mt-1.5">
125
+ <button
126
+ type="button"
127
+ disabled={disabled}
128
+ onClick={() => handleTestKey(providerName)}
129
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
130
+ >
131
+ {result.state === 'testing' ? (
132
+ <>
133
+ <Loader2 size={12} className="animate-spin" />
134
+ {t.settings.ai.testKeyTesting}
135
+ </>
136
+ ) : (
137
+ t.settings.ai.testKey
138
+ )}
139
+ </button>
140
+ {result.state === 'ok' && result.latency != null && (
141
+ <span className="text-xs text-success">{t.settings.ai.testKeyOk(result.latency)}</span>
142
+ )}
143
+ {result.state === 'error' && (
144
+ <span className="text-xs text-error">✗ {errorMessage(t, result.code)}</span>
145
+ )}
146
+ </div>
147
+ );
148
+ };
149
+
34
150
  return (
35
151
  <div className="space-y-5">
36
152
  <Field label={<>{t.settings.ai.provider} <EnvBadge overridden={env.AI_PROVIDER} /></>}>
@@ -60,6 +176,7 @@ export function AiTab({ data, updateAi, t }: AiTabProps) {
60
176
  value={anthropic.apiKey}
61
177
  onChange={v => patchProvider('anthropic', { apiKey: v })}
62
178
  />
179
+ {renderTestButton('anthropic', !!anthropic.apiKey, !!env.ANTHROPIC_API_KEY)}
63
180
  </Field>
64
181
  </>
65
182
  ) : (
@@ -79,6 +196,7 @@ export function AiTab({ data, updateAi, t }: AiTabProps) {
79
196
  value={openai.apiKey}
80
197
  onChange={v => patchProvider('openai', { apiKey: v })}
81
198
  />
199
+ {renderTestButton('openai', !!openai.apiKey, !!env.OPENAI_API_KEY)}
82
200
  </Field>
83
201
  <Field
84
202
  label={<>{t.settings.ai.baseUrl} <EnvBadge overridden={env.OPENAI_BASE_URL} /></>}
@@ -0,0 +1,19 @@
1
+ export async function register() {
2
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
3
+ const { readFileSync } = await import('fs');
4
+ const { join, resolve } = await import('path');
5
+ const { homedir } = await import('os');
6
+ try {
7
+ const configPath = join(homedir(), '.mindos', 'config.json');
8
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
9
+ if (config.sync?.enabled && config.mindRoot) {
10
+ // Resolve absolute path to avoid Turbopack bundling issues
11
+ const syncModule = resolve(process.cwd(), '..', 'bin', 'lib', 'sync.js');
12
+ const { startSyncDaemon } = await import(/* webpackIgnore: true */ syncModule);
13
+ await startSyncDaemon(config.mindRoot);
14
+ }
15
+ } catch {
16
+ // Sync not configured or failed to start — silently skip
17
+ }
18
+ }
19
+ }
package/app/lib/i18n.ts CHANGED
@@ -119,6 +119,15 @@ export const messages = {
119
119
  resetToEnv: 'Reset to env value',
120
120
  restoreFromEnv: 'Restore from env',
121
121
  noApiKey: 'API key is not set. AI features will be unavailable until you add one.',
122
+ testKey: 'Test',
123
+ testKeyTesting: 'Testing...',
124
+ testKeyOk: (ms: number) => `\u2713 ${ms}ms`,
125
+ testKeyAuthError: 'Invalid API key',
126
+ testKeyModelNotFound: 'Model not found',
127
+ testKeyRateLimited: 'Rate limited, try again later',
128
+ testKeyNetworkError: 'Network error',
129
+ testKeyNoKey: 'No API key configured',
130
+ testKeyUnknown: 'Test failed',
122
131
  },
123
132
  appearance: {
124
133
  readingFont: 'Reading font',
@@ -514,6 +523,15 @@ export const messages = {
514
523
  resetToEnv: '恢复为环境变量',
515
524
  restoreFromEnv: '从环境变量恢复',
516
525
  noApiKey: 'API 密钥未设置,AI 功能暂不可用,请在此填写。',
526
+ testKey: '测试',
527
+ testKeyTesting: '测试中...',
528
+ testKeyOk: (ms: number) => `\u2713 ${ms}ms`,
529
+ testKeyAuthError: 'API Key 无效',
530
+ testKeyModelNotFound: '模型不存在',
531
+ testKeyRateLimited: '请求频率限制,稍后重试',
532
+ testKeyNetworkError: '网络错误',
533
+ testKeyNoKey: '未配置 API Key',
534
+ testKeyUnknown: '测试失败',
517
535
  },
518
536
  appearance: {
519
537
  readingFont: '正文字体',
@@ -3,7 +3,7 @@ import path from "path";
3
3
 
4
4
  const nextConfig: NextConfig = {
5
5
  transpilePackages: ['github-slugger'],
6
- serverExternalPackages: ['pdfjs-dist', 'pdf-parse'],
6
+ serverExternalPackages: ['pdfjs-dist', 'pdf-parse', 'chokidar'],
7
7
  outputFileTracingRoot: path.join(__dirname),
8
8
  turbopack: {
9
9
  root: path.join(__dirname),
package/bin/cli.js CHANGED
@@ -803,7 +803,14 @@ ${bold('Examples:')}
803
803
  }
804
804
 
805
805
  if (sub === 'now') {
806
- manualSync(mindRoot);
806
+ try {
807
+ console.log(dim('Pulling...'));
808
+ manualSync(mindRoot);
809
+ console.log(green('✔ Sync complete'));
810
+ } catch (err) {
811
+ console.error(red(err.message));
812
+ process.exit(1);
813
+ }
807
814
  return;
808
815
  }
809
816
 
package/bin/lib/sync.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
+ import { homedir } from 'node:os';
4
5
  import { CONFIG_PATH, MINDOS_DIR } from './constants.js';
5
6
  import { bold, dim, cyan, green, red, yellow } from './colors.js';
6
7
 
@@ -129,12 +130,24 @@ function autoPull(mindRoot) {
129
130
  }
130
131
  }
131
132
  }
133
+
134
+ // Retry any pending pushes (handles previous push failures)
135
+ try {
136
+ const unpushed = gitExec('git rev-list --count @{u}..HEAD', mindRoot);
137
+ if (parseInt(unpushed) > 0) {
138
+ execSync('git push', { cwd: mindRoot, stdio: 'pipe' });
139
+ saveSyncState({ ...loadSyncState(), lastSync: new Date().toISOString(), lastError: null });
140
+ }
141
+ } catch {
142
+ // No upstream tracking or push failed — ignore silently, autoCommitAndPush handles primary pushes
143
+ }
132
144
  }
133
145
 
134
146
  // ── Exported API ────────────────────────────────────────────────────────────
135
147
 
136
148
  let activeWatcher = null;
137
149
  let activePullInterval = null;
150
+ let activeShutdownHandler = null;
138
151
 
139
152
  /**
140
153
  * Interactive sync init — configure remote git repo
@@ -187,18 +200,43 @@ export async function initSync(mindRoot, opts = {}) {
187
200
  try { execSync('git checkout -b main', { cwd: mindRoot, stdio: 'pipe' }); } catch {}
188
201
  }
189
202
 
203
+ // 1b. Ensure .gitignore exists
204
+ const gitignorePath = resolve(mindRoot, '.gitignore');
205
+ if (!existsSync(gitignorePath)) {
206
+ writeFileSync(gitignorePath, [
207
+ '# MindOS auto-generated',
208
+ '.DS_Store',
209
+ 'Thumbs.db',
210
+ '*.tmp',
211
+ '*.bak',
212
+ '*.swp',
213
+ '*.sync-conflict',
214
+ 'node_modules/',
215
+ '.obsidian/',
216
+ '',
217
+ ].join('\n'), 'utf-8');
218
+ }
219
+
190
220
  // Handle token for HTTPS
191
221
  if (token && remoteUrl.startsWith('https://')) {
192
222
  const urlObj = new URL(remoteUrl);
193
- urlObj.username = 'oauth2';
194
- urlObj.password = token;
195
- // Configure credential helper
196
- try { execSync(`git config credential.helper store`, { cwd: mindRoot, stdio: 'pipe' }); } catch {}
197
- // Store the credential
223
+ // Choose credential helper by platform
224
+ const platform = process.platform;
225
+ let helper;
226
+ if (platform === 'darwin') helper = 'osxkeychain';
227
+ else if (platform === 'win32') helper = 'manager';
228
+ else helper = 'store';
229
+ try { execSync(`git config credential.helper '${helper}'`, { cwd: mindRoot, stdio: 'pipe' }); } catch {}
230
+ // Store the credential via git credential approve
198
231
  try {
199
232
  const credInput = `protocol=${urlObj.protocol.replace(':', '')}\nhost=${urlObj.host}\nusername=oauth2\npassword=${token}\n\n`;
200
233
  execSync('git credential approve', { cwd: mindRoot, input: credInput, stdio: 'pipe' });
201
234
  } catch {}
235
+ // For 'store' helper, restrict file permissions AFTER credential file is created
236
+ if (helper === 'store') {
237
+ const credFile = resolve(process.env.HOME || homedir(), '.git-credentials');
238
+ try { execSync(`chmod 600 "${credFile}"`, { stdio: 'pipe' }); } catch {}
239
+ }
202
240
  }
203
241
 
204
242
  // 4. Set remote
@@ -257,6 +295,7 @@ export async function initSync(mindRoot, opts = {}) {
257
295
  * Start file watcher + periodic pull
258
296
  */
259
297
  export async function startSyncDaemon(mindRoot) {
298
+ if (activeWatcher) return null; // already running — idempotent guard
260
299
  const config = loadSyncConfig();
261
300
  if (!config.enabled) return null;
262
301
  if (!mindRoot || !isGitRepo(mindRoot)) return null;
@@ -281,10 +320,20 @@ export async function startSyncDaemon(mindRoot) {
281
320
  // Pull on startup
282
321
  autoPull(mindRoot);
283
322
 
323
+ // Graceful shutdown: flush pending changes before exit
324
+ const gracefulShutdown = () => {
325
+ if (commitTimer) { clearTimeout(commitTimer); commitTimer = null; }
326
+ try { autoCommitAndPush(mindRoot); } catch {}
327
+ stopSyncDaemon();
328
+ };
329
+ process.on('SIGTERM', gracefulShutdown);
330
+ process.on('SIGINT', gracefulShutdown);
331
+
284
332
  activeWatcher = watcher;
285
333
  activePullInterval = pullInterval;
334
+ activeShutdownHandler = gracefulShutdown;
286
335
 
287
- return { watcher, pullInterval };
336
+ return { watcher, pullInterval, gracefulShutdown };
288
337
  }
289
338
 
290
339
  /**
@@ -299,6 +348,11 @@ export function stopSyncDaemon() {
299
348
  clearInterval(activePullInterval);
300
349
  activePullInterval = null;
301
350
  }
351
+ if (activeShutdownHandler) {
352
+ process.removeListener('SIGTERM', activeShutdownHandler);
353
+ process.removeListener('SIGINT', activeShutdownHandler);
354
+ activeShutdownHandler = null;
355
+ }
302
356
  }
303
357
 
304
358
  /**
@@ -336,14 +390,10 @@ export function getSyncStatus(mindRoot) {
336
390
  */
337
391
  export function manualSync(mindRoot) {
338
392
  if (!mindRoot || !isGitRepo(mindRoot)) {
339
- console.error(red('Not a git repository. Run `mindos sync init` first.'));
340
- process.exit(1);
393
+ throw new Error('Not a git repository. Run `mindos sync init` first.');
341
394
  }
342
- console.log(dim('Pulling...'));
343
395
  autoPull(mindRoot);
344
- console.log(dim('Committing & pushing...'));
345
396
  autoCommitAndPush(mindRoot);
346
- console.log(green('✔ Sync complete'));
347
397
  }
348
398
 
349
399
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geminilight/mindos",
3
- "version": "0.5.10",
3
+ "version": "0.5.11",
4
4
  "description": "MindOS — Human-Agent Collaborative Mind System. Local-first knowledge base that syncs your mind to all AI Agents via MCP.",
5
5
  "keywords": [
6
6
  "mindos",
@@ -54,8 +54,10 @@
54
54
  "!assets/capture-demo.mjs",
55
55
  "!assets/demo-flow.html",
56
56
  "!assets/demo-flow-zh.html",
57
+ "!assets/images",
57
58
  "!mcp/node_modules",
58
- "!mcp/dist"
59
+ "!mcp/dist",
60
+ "!mcp/package-lock.json"
59
61
  ],
60
62
  "scripts": {
61
63
  "setup": "node scripts/setup.js",
Binary file
Binary file
Binary file
Binary file
Binary file