@noesis-brain/mcp-server 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/database/PostgresAdapter.js +216 -216
- package/dist/services/embedding.js +19 -19
- package/dist/tools/index.js +5 -5
- package/package.json +2 -2
- package/scripts/noesis-sync.mjs +469 -469
- package/skill-templates/noesis-refine-note.md +92 -92
- package/skill-templates/noesis-sync.md +110 -110
- package/templates/claude-md-block.md +22 -22
package/scripts/noesis-sync.mjs
CHANGED
|
@@ -1,469 +1,469 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* noesis-sync.mjs — Standalone sync script for Noesis cloud API.
|
|
4
|
-
* Zero dependencies (Node.js 18+ built-ins only).
|
|
5
|
-
*
|
|
6
|
-
* Usage:
|
|
7
|
-
* node noesis-sync.mjs # auto-detect from CWD
|
|
8
|
-
* node noesis-sync.mjs --files a.md b.md # sync specific files
|
|
9
|
-
* node noesis-sync.mjs --root my-notes # sync a named root
|
|
10
|
-
* node noesis-sync.mjs --dry-run # preview only
|
|
11
|
-
*
|
|
12
|
-
* Credentials: reads from ~/.claude/.mcp.json → mcpServers.noesis.env
|
|
13
|
-
* or env vars NOESIS_API_TOKEN / NOESIS_API_URL.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { createHash } from 'node:crypto';
|
|
17
|
-
import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs';
|
|
18
|
-
import { resolve, dirname, basename, relative, join, sep } from 'node:path';
|
|
19
|
-
import { homedir } from 'node:os';
|
|
20
|
-
|
|
21
|
-
// ── Path helpers ────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Tilde-expand `~/Noesis/...` against this machine's home directory.
|
|
25
|
-
* Cloud-flow roots store `~` in the DB because the cloud doesn't know each
|
|
26
|
-
* machine's $HOME — we expand here at sync time. No-op for already-
|
|
27
|
-
* absolute paths.
|
|
28
|
-
*/
|
|
29
|
-
function expandHome(p) {
|
|
30
|
-
if (!p) return '';
|
|
31
|
-
if (p === '~') return homedir();
|
|
32
|
-
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
33
|
-
return join(homedir(), p.slice(2));
|
|
34
|
-
}
|
|
35
|
-
return p;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ── Config ──────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
function loadCredentials() {
|
|
41
|
-
// Env vars take priority
|
|
42
|
-
let token = process.env.NOESIS_API_TOKEN;
|
|
43
|
-
let url = process.env.NOESIS_API_URL;
|
|
44
|
-
|
|
45
|
-
if (token && url) return { token, url };
|
|
46
|
-
|
|
47
|
-
// Fall back to ~/.claude/.mcp.json
|
|
48
|
-
const mcpPath = join(homedir(), '.claude', '.mcp.json');
|
|
49
|
-
if (!existsSync(mcpPath)) {
|
|
50
|
-
console.error(`ERROR: No credentials. Set NOESIS_API_TOKEN + NOESIS_API_URL or configure ${mcpPath}`);
|
|
51
|
-
process.exit(1);
|
|
52
|
-
}
|
|
53
|
-
const mcp = JSON.parse(readFileSync(mcpPath, 'utf-8'));
|
|
54
|
-
const env = mcp?.mcpServers?.noesis?.env;
|
|
55
|
-
if (!env?.NOESIS_API_TOKEN || !env?.NOESIS_API_URL) {
|
|
56
|
-
console.error('ERROR: mcpServers.noesis.env missing NOESIS_API_TOKEN or NOESIS_API_URL in', mcpPath);
|
|
57
|
-
process.exit(1);
|
|
58
|
-
}
|
|
59
|
-
return { token: env.NOESIS_API_TOKEN, url: env.NOESIS_API_URL };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ── HTTP helpers ────────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
async function api(method, path, body, { token, url }) {
|
|
65
|
-
const res = await fetch(`${url.replace(/\/$/, '')}${path}`, {
|
|
66
|
-
method,
|
|
67
|
-
headers: {
|
|
68
|
-
'Authorization': `Bearer ${token}`,
|
|
69
|
-
'Content-Type': 'application/json',
|
|
70
|
-
},
|
|
71
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
72
|
-
});
|
|
73
|
-
if (!res.ok) {
|
|
74
|
-
const err = await res.json().catch(() => ({}));
|
|
75
|
-
throw new Error(`API ${method} ${path} → ${res.status}: ${err.error || res.statusText}`);
|
|
76
|
-
}
|
|
77
|
-
return res.json();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ── Hashing (matches NoesisClient.computeHash) ─────────────────────
|
|
81
|
-
|
|
82
|
-
function computeHash(content) {
|
|
83
|
-
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
84
|
-
return createHash('sha256').update(normalized, 'utf8').digest('hex');
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// ── Git/root detection (mirrors tools/index.ts) ─────────────────────
|
|
88
|
-
|
|
89
|
-
function findGitRoot(startPath) {
|
|
90
|
-
let current = resolve(startPath);
|
|
91
|
-
while (current !== dirname(current)) {
|
|
92
|
-
if (existsSync(join(current, '.git'))) return current;
|
|
93
|
-
current = dirname(current);
|
|
94
|
-
}
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function detectRootFromCwd() {
|
|
99
|
-
const gitRoot = findGitRoot(process.cwd());
|
|
100
|
-
if (!gitRoot) return null;
|
|
101
|
-
return { rootPath: dirname(gitRoot), projectName: basename(gitRoot) };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function detectProjectForFile(filePath, rootPath) {
|
|
105
|
-
const rel = relative(rootPath, filePath);
|
|
106
|
-
const parts = rel.split(sep).filter(Boolean);
|
|
107
|
-
// Walk up from file to find .git
|
|
108
|
-
let current = resolve(filePath);
|
|
109
|
-
while (current !== resolve(rootPath) && current !== dirname(current)) {
|
|
110
|
-
current = dirname(current);
|
|
111
|
-
if (existsSync(join(current, '.git'))) return basename(current);
|
|
112
|
-
}
|
|
113
|
-
return parts[0] || basename(dirname(filePath));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// ── Path normalization ───────────────────────────────────────────────
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Normalize a file path: forward slashes + lowercase drive letter on Windows.
|
|
120
|
-
*/
|
|
121
|
-
function normalizePath(p) {
|
|
122
|
-
let normalized = p.replace(/\\/g, '/');
|
|
123
|
-
if (normalized.length >= 2 && normalized[1] === ':') {
|
|
124
|
-
normalized = normalized[0].toLowerCase() + normalized.slice(1);
|
|
125
|
-
}
|
|
126
|
-
return normalized;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ── File scanning ───────────────────────────────────────────────────
|
|
130
|
-
|
|
131
|
-
function scanMarkdownFiles(rootPath) {
|
|
132
|
-
const files = [];
|
|
133
|
-
function walk(dir) {
|
|
134
|
-
let entries;
|
|
135
|
-
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
136
|
-
for (const e of entries) {
|
|
137
|
-
const full = join(dir, e.name);
|
|
138
|
-
if (e.isDirectory()) {
|
|
139
|
-
if (e.name.startsWith('.') && e.name !== '.noesis' && e.name !== '.claude-notes') continue;
|
|
140
|
-
if (e.name === 'node_modules' || e.name === 'dist' || e.name === '.git') continue;
|
|
141
|
-
walk(full);
|
|
142
|
-
} else if (e.name.endsWith('.md')) {
|
|
143
|
-
files.push(full);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
walk(rootPath);
|
|
148
|
-
return files;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// ── Parse YAML frontmatter ──────────────────────────────────────────
|
|
152
|
-
|
|
153
|
-
function parseFrontmatter(content) {
|
|
154
|
-
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
155
|
-
if (!match) return {};
|
|
156
|
-
const meta = {};
|
|
157
|
-
const lines = match[1].split('\n');
|
|
158
|
-
let i = 0;
|
|
159
|
-
while (i < lines.length) {
|
|
160
|
-
const keyMatch = lines[i].match(/^(\w[\w-]*):\s*(.*)/);
|
|
161
|
-
if (!keyMatch) { i++; continue; }
|
|
162
|
-
const key = keyMatch[1];
|
|
163
|
-
const inline = keyMatch[2].trim();
|
|
164
|
-
|
|
165
|
-
// Block scalar: >-, >, |-, |
|
|
166
|
-
if (/^[>|]-?$/.test(inline)) {
|
|
167
|
-
const parts = [];
|
|
168
|
-
i++;
|
|
169
|
-
while (i < lines.length && /^\s+/.test(lines[i])) {
|
|
170
|
-
parts.push(lines[i].trim());
|
|
171
|
-
i++;
|
|
172
|
-
}
|
|
173
|
-
meta[key] = parts.join(inline.startsWith('>') ? ' ' : '\n');
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Array: key with no inline value, followed by " - item" lines
|
|
178
|
-
if (inline === '') {
|
|
179
|
-
const items = [];
|
|
180
|
-
let j = i + 1;
|
|
181
|
-
while (j < lines.length && /^\s+-\s+/.test(lines[j])) {
|
|
182
|
-
items.push(lines[j].replace(/^\s+-\s+/, '').trim());
|
|
183
|
-
j++;
|
|
184
|
-
}
|
|
185
|
-
if (items.length > 0) {
|
|
186
|
-
meta[key] = items;
|
|
187
|
-
i = j;
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Simple key: value
|
|
193
|
-
if (inline) {
|
|
194
|
-
meta[key] = inline.replace(/^["']|["']$/g, '');
|
|
195
|
-
}
|
|
196
|
-
i++;
|
|
197
|
-
}
|
|
198
|
-
return meta;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ── Main sync logic ─────────────────────────────────────────────────
|
|
202
|
-
|
|
203
|
-
async function syncFiles(filePaths, { creds, dryRun, force, rootFilter }) {
|
|
204
|
-
// Get roots
|
|
205
|
-
const { roots } = await api('GET', '/api/mcp/roots?forSync=true', null, creds);
|
|
206
|
-
let targetRoots = roots;
|
|
207
|
-
|
|
208
|
-
// Auto-detect or create root if none match
|
|
209
|
-
const detected = detectRootFromCwd();
|
|
210
|
-
|
|
211
|
-
if (rootFilter) {
|
|
212
|
-
targetRoots = roots.filter(r => r.name.toLowerCase().includes(rootFilter.toLowerCase()));
|
|
213
|
-
if (targetRoots.length === 0) {
|
|
214
|
-
console.error(`No root matching "${rootFilter}". Available: ${roots.map(r => r.name).join(', ')}`);
|
|
215
|
-
process.exit(1);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// If syncing specific files, resolve their roots
|
|
220
|
-
if (filePaths.length > 0) {
|
|
221
|
-
return await syncSpecificFiles(filePaths, targetRoots, detected, creds, dryRun, force);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Full root sync — auto-detect from CWD
|
|
225
|
-
if (!rootFilter && detected) {
|
|
226
|
-
const match = targetRoots.find(r =>
|
|
227
|
-
resolve(r.path).toLowerCase() === resolve(detected.rootPath).toLowerCase()
|
|
228
|
-
);
|
|
229
|
-
if (match) {
|
|
230
|
-
targetRoots = [match];
|
|
231
|
-
} else if (!dryRun) {
|
|
232
|
-
// Auto-create root
|
|
233
|
-
console.log(`Creating new root: ${basename(detected.rootPath)} → ${detected.rootPath}`);
|
|
234
|
-
const { root } = await api('POST', '/api/mcp/roots', {
|
|
235
|
-
name: basename(detected.rootPath),
|
|
236
|
-
path: detected.rootPath,
|
|
237
|
-
}, creds);
|
|
238
|
-
targetRoots = [{ ...root, last_scanned_at: null }];
|
|
239
|
-
} else {
|
|
240
|
-
console.log(`[dry-run] Would create root: ${detected.rootPath}`);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (targetRoots.length === 0) {
|
|
246
|
-
console.error('No roots to sync. Run from a git repo or specify --root.');
|
|
247
|
-
process.exit(1);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
let totalAdded = 0, totalUpdated = 0, totalSkipped = 0;
|
|
251
|
-
|
|
252
|
-
for (const root of targetRoots) {
|
|
253
|
-
// Tilde-expand cloud-flow paths (~/Noesis/...) once at the top of the
|
|
254
|
-
// loop so all downstream uses see the absolute on-disk path.
|
|
255
|
-
const rootPath = expandHome(root.path);
|
|
256
|
-
if (!rootPath) {
|
|
257
|
-
console.log(`\nSkipping root '${root.name}': no path configured for this OS`);
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
console.log(`\nSyncing root: ${root.name} (${rootPath})`);
|
|
261
|
-
|
|
262
|
-
const mdFiles = scanMarkdownFiles(rootPath);
|
|
263
|
-
console.log(` Found ${mdFiles.length} markdown files`);
|
|
264
|
-
|
|
265
|
-
// Get existing hashes from cloud
|
|
266
|
-
const { hashes } = await api('GET', `/api/mcp/roots/${root.id}/hashes`, null, creds);
|
|
267
|
-
|
|
268
|
-
let added = 0, updated = 0, skipped = 0;
|
|
269
|
-
|
|
270
|
-
for (const filePath of mdFiles) {
|
|
271
|
-
const content = readFileSync(filePath, 'utf-8');
|
|
272
|
-
const hash = computeHash(content);
|
|
273
|
-
const stats = statSync(filePath);
|
|
274
|
-
const relativePath = normalizePath(relative(rootPath, filePath));
|
|
275
|
-
const existingHash = hashes[relativePath];
|
|
276
|
-
|
|
277
|
-
if (existingHash === hash && !force) { skipped++; continue; }
|
|
278
|
-
const action = existingHash ? 'update' : 'create';
|
|
279
|
-
|
|
280
|
-
if (dryRun) {
|
|
281
|
-
console.log(` [dry-run] Would ${action}: ${relativePath}`);
|
|
282
|
-
action === 'create' ? added++ : updated++;
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const project = detectProjectForFile(filePath, rootPath);
|
|
287
|
-
const metadata = parseFrontmatter(content);
|
|
288
|
-
|
|
289
|
-
await api('POST', '/api/mcp/notes/upsert', {
|
|
290
|
-
file: {
|
|
291
|
-
path: normalizePath(filePath),
|
|
292
|
-
relativePath,
|
|
293
|
-
content,
|
|
294
|
-
hash,
|
|
295
|
-
mtime: stats.mtime,
|
|
296
|
-
size: stats.size,
|
|
297
|
-
rootId: root.id,
|
|
298
|
-
rootName: root.name,
|
|
299
|
-
project,
|
|
300
|
-
},
|
|
301
|
-
metadata,
|
|
302
|
-
force,
|
|
303
|
-
}, creds);
|
|
304
|
-
|
|
305
|
-
console.log(` ${action === 'create' ? '+' : '~'} ${relativePath}`);
|
|
306
|
-
action === 'create' ? added++ : updated++;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
console.log(` Done: +${added} ~${updated} =${skipped}`);
|
|
310
|
-
totalAdded += added; totalUpdated += updated; totalSkipped += skipped;
|
|
311
|
-
|
|
312
|
-
if (!dryRun && (added > 0 || updated > 0)) {
|
|
313
|
-
await api('POST', '/api/mcp/sync/log', {
|
|
314
|
-
rootId: root.id,
|
|
315
|
-
filesScanned: mdFiles.length,
|
|
316
|
-
filesAdded: added,
|
|
317
|
-
filesUpdated: updated,
|
|
318
|
-
filesDeleted: 0,
|
|
319
|
-
source: 'noesis-sync-script',
|
|
320
|
-
}, creds);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
console.log(`\nTotal: +${totalAdded} ~${totalUpdated} =${totalSkipped}`);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
async function syncSpecificFiles(filePaths, roots, detected, creds, dryRun, force) {
|
|
328
|
-
let added = 0, updated = 0, errors = 0;
|
|
329
|
-
const affectedRoots = new Map();
|
|
330
|
-
|
|
331
|
-
for (const filePath of filePaths) {
|
|
332
|
-
const absPath = normalizePath(resolve(filePath));
|
|
333
|
-
|
|
334
|
-
if (!absPath.endsWith('.md')) {
|
|
335
|
-
console.error(` SKIP (not .md): ${filePath}`);
|
|
336
|
-
errors++; continue;
|
|
337
|
-
}
|
|
338
|
-
if (!existsSync(absPath)) {
|
|
339
|
-
console.error(` SKIP (not found): ${filePath}`);
|
|
340
|
-
errors++; continue;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Find matching root (case-insensitive path comparison for Windows)
|
|
344
|
-
let matchingRoot = roots.find(r => absPath.startsWith(normalizePath(resolve(r.path))));
|
|
345
|
-
|
|
346
|
-
if (!matchingRoot && detected) {
|
|
347
|
-
// Try auto-creating root from CWD detection
|
|
348
|
-
const existingByPath = roots.find(r =>
|
|
349
|
-
normalizePath(resolve(r.path)) === normalizePath(resolve(detected.rootPath))
|
|
350
|
-
);
|
|
351
|
-
if (existingByPath) {
|
|
352
|
-
matchingRoot = existingByPath;
|
|
353
|
-
} else if (!dryRun) {
|
|
354
|
-
console.log(`Creating root: ${basename(detected.rootPath)} → ${detected.rootPath}`);
|
|
355
|
-
const { root } = await api('POST', '/api/mcp/roots', {
|
|
356
|
-
name: basename(detected.rootPath),
|
|
357
|
-
path: normalizePath(detected.rootPath),
|
|
358
|
-
}, creds);
|
|
359
|
-
matchingRoot = root;
|
|
360
|
-
roots.push(root);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (!matchingRoot) {
|
|
365
|
-
console.error(` SKIP (no root): ${filePath}`);
|
|
366
|
-
errors++; continue;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const content = readFileSync(absPath, 'utf-8');
|
|
370
|
-
const hash = computeHash(content);
|
|
371
|
-
const stats = statSync(absPath);
|
|
372
|
-
const relativePath = normalizePath(relative(matchingRoot.path, absPath));
|
|
373
|
-
const project = detectProjectForFile(absPath, matchingRoot.path);
|
|
374
|
-
const metadata = parseFrontmatter(content);
|
|
375
|
-
|
|
376
|
-
if (dryRun) {
|
|
377
|
-
console.log(` [dry-run] Would sync: ${relativePath} → root "${matchingRoot.name}"`);
|
|
378
|
-
added++; continue;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
const result = await api('POST', '/api/mcp/notes/upsert', {
|
|
382
|
-
file: {
|
|
383
|
-
path: absPath,
|
|
384
|
-
relativePath,
|
|
385
|
-
content,
|
|
386
|
-
hash,
|
|
387
|
-
mtime: stats.mtime,
|
|
388
|
-
size: stats.size,
|
|
389
|
-
rootId: matchingRoot.id,
|
|
390
|
-
rootName: matchingRoot.name,
|
|
391
|
-
project,
|
|
392
|
-
},
|
|
393
|
-
metadata,
|
|
394
|
-
force,
|
|
395
|
-
}, creds);
|
|
396
|
-
|
|
397
|
-
const action = result.action || 'synced';
|
|
398
|
-
console.log(` ${action}: ${relativePath}`);
|
|
399
|
-
action === 'created' ? added++ : updated++;
|
|
400
|
-
|
|
401
|
-
// Track for sync log
|
|
402
|
-
if (!affectedRoots.has(matchingRoot.id)) {
|
|
403
|
-
affectedRoots.set(matchingRoot.id, { root: matchingRoot, added: 0, updated: 0, scanned: 0 });
|
|
404
|
-
}
|
|
405
|
-
const rInfo = affectedRoots.get(matchingRoot.id);
|
|
406
|
-
rInfo.scanned++;
|
|
407
|
-
action === 'created' ? rInfo.added++ : rInfo.updated++;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Log sync for each affected root
|
|
411
|
-
if (!dryRun) {
|
|
412
|
-
for (const [rootId, info] of affectedRoots) {
|
|
413
|
-
if (info.added > 0 || info.updated > 0) {
|
|
414
|
-
await api('POST', '/api/mcp/sync/log', {
|
|
415
|
-
rootId,
|
|
416
|
-
filesScanned: info.scanned,
|
|
417
|
-
filesAdded: info.added,
|
|
418
|
-
filesUpdated: info.updated,
|
|
419
|
-
filesDeleted: 0,
|
|
420
|
-
source: 'noesis-sync-script',
|
|
421
|
-
}, creds);
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
console.log(`\nDone: +${added} ~${updated}${errors ? ` errors:${errors}` : ''}`);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// ── CLI ─────────────────────────────────────────────────────────────
|
|
430
|
-
|
|
431
|
-
const args = process.argv.slice(2);
|
|
432
|
-
const dryRun = args.includes('--dry-run');
|
|
433
|
-
const force = args.includes('--force');
|
|
434
|
-
const filesIdx = args.indexOf('--files');
|
|
435
|
-
const rootIdx = args.indexOf('--root');
|
|
436
|
-
|
|
437
|
-
let files = [];
|
|
438
|
-
let rootFilter = null;
|
|
439
|
-
|
|
440
|
-
if (filesIdx !== -1) {
|
|
441
|
-
// Collect all args after --files until next flag or end
|
|
442
|
-
for (let i = filesIdx + 1; i < args.length; i++) {
|
|
443
|
-
if (args[i].startsWith('--')) break;
|
|
444
|
-
files.push(args[i]);
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
if (rootIdx !== -1 && args[rootIdx + 1]) {
|
|
449
|
-
rootFilter = args[rootIdx + 1];
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// Positional arg that isn't a flag — treat as root name or file
|
|
453
|
-
if (files.length === 0 && !rootFilter) {
|
|
454
|
-
for (const a of args) {
|
|
455
|
-
if (a.startsWith('--')) continue;
|
|
456
|
-
if (a.endsWith('.md')) files.push(a);
|
|
457
|
-
else rootFilter = a;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
const creds = loadCredentials();
|
|
462
|
-
console.log(`Noesis sync → ${creds.url}`);
|
|
463
|
-
if (dryRun) console.log('[DRY RUN MODE]');
|
|
464
|
-
if (force) console.log('[FORCE MODE]');
|
|
465
|
-
|
|
466
|
-
syncFiles(files, { creds, dryRun, force, rootFilter }).catch(err => {
|
|
467
|
-
console.error('Sync failed:', err.message);
|
|
468
|
-
process.exit(1);
|
|
469
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* noesis-sync.mjs — Standalone sync script for Noesis cloud API.
|
|
4
|
+
* Zero dependencies (Node.js 18+ built-ins only).
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node noesis-sync.mjs # auto-detect from CWD
|
|
8
|
+
* node noesis-sync.mjs --files a.md b.md # sync specific files
|
|
9
|
+
* node noesis-sync.mjs --root my-notes # sync a named root
|
|
10
|
+
* node noesis-sync.mjs --dry-run # preview only
|
|
11
|
+
*
|
|
12
|
+
* Credentials: reads from ~/.claude/.mcp.json → mcpServers.noesis.env
|
|
13
|
+
* or env vars NOESIS_API_TOKEN / NOESIS_API_URL.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs';
|
|
18
|
+
import { resolve, dirname, basename, relative, join, sep } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
|
|
21
|
+
// ── Path helpers ────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Tilde-expand `~/Noesis/...` against this machine's home directory.
|
|
25
|
+
* Cloud-flow roots store `~` in the DB because the cloud doesn't know each
|
|
26
|
+
* machine's $HOME — we expand here at sync time. No-op for already-
|
|
27
|
+
* absolute paths.
|
|
28
|
+
*/
|
|
29
|
+
function expandHome(p) {
|
|
30
|
+
if (!p) return '';
|
|
31
|
+
if (p === '~') return homedir();
|
|
32
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
33
|
+
return join(homedir(), p.slice(2));
|
|
34
|
+
}
|
|
35
|
+
return p;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Config ──────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function loadCredentials() {
|
|
41
|
+
// Env vars take priority
|
|
42
|
+
let token = process.env.NOESIS_API_TOKEN;
|
|
43
|
+
let url = process.env.NOESIS_API_URL;
|
|
44
|
+
|
|
45
|
+
if (token && url) return { token, url };
|
|
46
|
+
|
|
47
|
+
// Fall back to ~/.claude/.mcp.json
|
|
48
|
+
const mcpPath = join(homedir(), '.claude', '.mcp.json');
|
|
49
|
+
if (!existsSync(mcpPath)) {
|
|
50
|
+
console.error(`ERROR: No credentials. Set NOESIS_API_TOKEN + NOESIS_API_URL or configure ${mcpPath}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const mcp = JSON.parse(readFileSync(mcpPath, 'utf-8'));
|
|
54
|
+
const env = mcp?.mcpServers?.noesis?.env;
|
|
55
|
+
if (!env?.NOESIS_API_TOKEN || !env?.NOESIS_API_URL) {
|
|
56
|
+
console.error('ERROR: mcpServers.noesis.env missing NOESIS_API_TOKEN or NOESIS_API_URL in', mcpPath);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
return { token: env.NOESIS_API_TOKEN, url: env.NOESIS_API_URL };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── HTTP helpers ────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
async function api(method, path, body, { token, url }) {
|
|
65
|
+
const res = await fetch(`${url.replace(/\/$/, '')}${path}`, {
|
|
66
|
+
method,
|
|
67
|
+
headers: {
|
|
68
|
+
'Authorization': `Bearer ${token}`,
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
},
|
|
71
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const err = await res.json().catch(() => ({}));
|
|
75
|
+
throw new Error(`API ${method} ${path} → ${res.status}: ${err.error || res.statusText}`);
|
|
76
|
+
}
|
|
77
|
+
return res.json();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Hashing (matches NoesisClient.computeHash) ─────────────────────
|
|
81
|
+
|
|
82
|
+
function computeHash(content) {
|
|
83
|
+
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
84
|
+
return createHash('sha256').update(normalized, 'utf8').digest('hex');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Git/root detection (mirrors tools/index.ts) ─────────────────────
|
|
88
|
+
|
|
89
|
+
function findGitRoot(startPath) {
|
|
90
|
+
let current = resolve(startPath);
|
|
91
|
+
while (current !== dirname(current)) {
|
|
92
|
+
if (existsSync(join(current, '.git'))) return current;
|
|
93
|
+
current = dirname(current);
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function detectRootFromCwd() {
|
|
99
|
+
const gitRoot = findGitRoot(process.cwd());
|
|
100
|
+
if (!gitRoot) return null;
|
|
101
|
+
return { rootPath: dirname(gitRoot), projectName: basename(gitRoot) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function detectProjectForFile(filePath, rootPath) {
|
|
105
|
+
const rel = relative(rootPath, filePath);
|
|
106
|
+
const parts = rel.split(sep).filter(Boolean);
|
|
107
|
+
// Walk up from file to find .git
|
|
108
|
+
let current = resolve(filePath);
|
|
109
|
+
while (current !== resolve(rootPath) && current !== dirname(current)) {
|
|
110
|
+
current = dirname(current);
|
|
111
|
+
if (existsSync(join(current, '.git'))) return basename(current);
|
|
112
|
+
}
|
|
113
|
+
return parts[0] || basename(dirname(filePath));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Path normalization ───────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Normalize a file path: forward slashes + lowercase drive letter on Windows.
|
|
120
|
+
*/
|
|
121
|
+
function normalizePath(p) {
|
|
122
|
+
let normalized = p.replace(/\\/g, '/');
|
|
123
|
+
if (normalized.length >= 2 && normalized[1] === ':') {
|
|
124
|
+
normalized = normalized[0].toLowerCase() + normalized.slice(1);
|
|
125
|
+
}
|
|
126
|
+
return normalized;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── File scanning ───────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function scanMarkdownFiles(rootPath) {
|
|
132
|
+
const files = [];
|
|
133
|
+
function walk(dir) {
|
|
134
|
+
let entries;
|
|
135
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
136
|
+
for (const e of entries) {
|
|
137
|
+
const full = join(dir, e.name);
|
|
138
|
+
if (e.isDirectory()) {
|
|
139
|
+
if (e.name.startsWith('.') && e.name !== '.noesis' && e.name !== '.claude-notes') continue;
|
|
140
|
+
if (e.name === 'node_modules' || e.name === 'dist' || e.name === '.git') continue;
|
|
141
|
+
walk(full);
|
|
142
|
+
} else if (e.name.endsWith('.md')) {
|
|
143
|
+
files.push(full);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
walk(rootPath);
|
|
148
|
+
return files;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Parse YAML frontmatter ──────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
function parseFrontmatter(content) {
|
|
154
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
155
|
+
if (!match) return {};
|
|
156
|
+
const meta = {};
|
|
157
|
+
const lines = match[1].split('\n');
|
|
158
|
+
let i = 0;
|
|
159
|
+
while (i < lines.length) {
|
|
160
|
+
const keyMatch = lines[i].match(/^(\w[\w-]*):\s*(.*)/);
|
|
161
|
+
if (!keyMatch) { i++; continue; }
|
|
162
|
+
const key = keyMatch[1];
|
|
163
|
+
const inline = keyMatch[2].trim();
|
|
164
|
+
|
|
165
|
+
// Block scalar: >-, >, |-, |
|
|
166
|
+
if (/^[>|]-?$/.test(inline)) {
|
|
167
|
+
const parts = [];
|
|
168
|
+
i++;
|
|
169
|
+
while (i < lines.length && /^\s+/.test(lines[i])) {
|
|
170
|
+
parts.push(lines[i].trim());
|
|
171
|
+
i++;
|
|
172
|
+
}
|
|
173
|
+
meta[key] = parts.join(inline.startsWith('>') ? ' ' : '\n');
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Array: key with no inline value, followed by " - item" lines
|
|
178
|
+
if (inline === '') {
|
|
179
|
+
const items = [];
|
|
180
|
+
let j = i + 1;
|
|
181
|
+
while (j < lines.length && /^\s+-\s+/.test(lines[j])) {
|
|
182
|
+
items.push(lines[j].replace(/^\s+-\s+/, '').trim());
|
|
183
|
+
j++;
|
|
184
|
+
}
|
|
185
|
+
if (items.length > 0) {
|
|
186
|
+
meta[key] = items;
|
|
187
|
+
i = j;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Simple key: value
|
|
193
|
+
if (inline) {
|
|
194
|
+
meta[key] = inline.replace(/^["']|["']$/g, '');
|
|
195
|
+
}
|
|
196
|
+
i++;
|
|
197
|
+
}
|
|
198
|
+
return meta;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Main sync logic ─────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
async function syncFiles(filePaths, { creds, dryRun, force, rootFilter }) {
|
|
204
|
+
// Get roots
|
|
205
|
+
const { roots } = await api('GET', '/api/mcp/roots?forSync=true', null, creds);
|
|
206
|
+
let targetRoots = roots;
|
|
207
|
+
|
|
208
|
+
// Auto-detect or create root if none match
|
|
209
|
+
const detected = detectRootFromCwd();
|
|
210
|
+
|
|
211
|
+
if (rootFilter) {
|
|
212
|
+
targetRoots = roots.filter(r => r.name.toLowerCase().includes(rootFilter.toLowerCase()));
|
|
213
|
+
if (targetRoots.length === 0) {
|
|
214
|
+
console.error(`No root matching "${rootFilter}". Available: ${roots.map(r => r.name).join(', ')}`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// If syncing specific files, resolve their roots
|
|
220
|
+
if (filePaths.length > 0) {
|
|
221
|
+
return await syncSpecificFiles(filePaths, targetRoots, detected, creds, dryRun, force);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Full root sync — auto-detect from CWD
|
|
225
|
+
if (!rootFilter && detected) {
|
|
226
|
+
const match = targetRoots.find(r =>
|
|
227
|
+
resolve(r.path).toLowerCase() === resolve(detected.rootPath).toLowerCase()
|
|
228
|
+
);
|
|
229
|
+
if (match) {
|
|
230
|
+
targetRoots = [match];
|
|
231
|
+
} else if (!dryRun) {
|
|
232
|
+
// Auto-create root
|
|
233
|
+
console.log(`Creating new root: ${basename(detected.rootPath)} → ${detected.rootPath}`);
|
|
234
|
+
const { root } = await api('POST', '/api/mcp/roots', {
|
|
235
|
+
name: basename(detected.rootPath),
|
|
236
|
+
path: detected.rootPath,
|
|
237
|
+
}, creds);
|
|
238
|
+
targetRoots = [{ ...root, last_scanned_at: null }];
|
|
239
|
+
} else {
|
|
240
|
+
console.log(`[dry-run] Would create root: ${detected.rootPath}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (targetRoots.length === 0) {
|
|
246
|
+
console.error('No roots to sync. Run from a git repo or specify --root.');
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let totalAdded = 0, totalUpdated = 0, totalSkipped = 0;
|
|
251
|
+
|
|
252
|
+
for (const root of targetRoots) {
|
|
253
|
+
// Tilde-expand cloud-flow paths (~/Noesis/...) once at the top of the
|
|
254
|
+
// loop so all downstream uses see the absolute on-disk path.
|
|
255
|
+
const rootPath = expandHome(root.path);
|
|
256
|
+
if (!rootPath) {
|
|
257
|
+
console.log(`\nSkipping root '${root.name}': no path configured for this OS`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
console.log(`\nSyncing root: ${root.name} (${rootPath})`);
|
|
261
|
+
|
|
262
|
+
const mdFiles = scanMarkdownFiles(rootPath);
|
|
263
|
+
console.log(` Found ${mdFiles.length} markdown files`);
|
|
264
|
+
|
|
265
|
+
// Get existing hashes from cloud
|
|
266
|
+
const { hashes } = await api('GET', `/api/mcp/roots/${root.id}/hashes`, null, creds);
|
|
267
|
+
|
|
268
|
+
let added = 0, updated = 0, skipped = 0;
|
|
269
|
+
|
|
270
|
+
for (const filePath of mdFiles) {
|
|
271
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
272
|
+
const hash = computeHash(content);
|
|
273
|
+
const stats = statSync(filePath);
|
|
274
|
+
const relativePath = normalizePath(relative(rootPath, filePath));
|
|
275
|
+
const existingHash = hashes[relativePath];
|
|
276
|
+
|
|
277
|
+
if (existingHash === hash && !force) { skipped++; continue; }
|
|
278
|
+
const action = existingHash ? 'update' : 'create';
|
|
279
|
+
|
|
280
|
+
if (dryRun) {
|
|
281
|
+
console.log(` [dry-run] Would ${action}: ${relativePath}`);
|
|
282
|
+
action === 'create' ? added++ : updated++;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const project = detectProjectForFile(filePath, rootPath);
|
|
287
|
+
const metadata = parseFrontmatter(content);
|
|
288
|
+
|
|
289
|
+
await api('POST', '/api/mcp/notes/upsert', {
|
|
290
|
+
file: {
|
|
291
|
+
path: normalizePath(filePath),
|
|
292
|
+
relativePath,
|
|
293
|
+
content,
|
|
294
|
+
hash,
|
|
295
|
+
mtime: stats.mtime,
|
|
296
|
+
size: stats.size,
|
|
297
|
+
rootId: root.id,
|
|
298
|
+
rootName: root.name,
|
|
299
|
+
project,
|
|
300
|
+
},
|
|
301
|
+
metadata,
|
|
302
|
+
force,
|
|
303
|
+
}, creds);
|
|
304
|
+
|
|
305
|
+
console.log(` ${action === 'create' ? '+' : '~'} ${relativePath}`);
|
|
306
|
+
action === 'create' ? added++ : updated++;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
console.log(` Done: +${added} ~${updated} =${skipped}`);
|
|
310
|
+
totalAdded += added; totalUpdated += updated; totalSkipped += skipped;
|
|
311
|
+
|
|
312
|
+
if (!dryRun && (added > 0 || updated > 0)) {
|
|
313
|
+
await api('POST', '/api/mcp/sync/log', {
|
|
314
|
+
rootId: root.id,
|
|
315
|
+
filesScanned: mdFiles.length,
|
|
316
|
+
filesAdded: added,
|
|
317
|
+
filesUpdated: updated,
|
|
318
|
+
filesDeleted: 0,
|
|
319
|
+
source: 'noesis-sync-script',
|
|
320
|
+
}, creds);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
console.log(`\nTotal: +${totalAdded} ~${totalUpdated} =${totalSkipped}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function syncSpecificFiles(filePaths, roots, detected, creds, dryRun, force) {
|
|
328
|
+
let added = 0, updated = 0, errors = 0;
|
|
329
|
+
const affectedRoots = new Map();
|
|
330
|
+
|
|
331
|
+
for (const filePath of filePaths) {
|
|
332
|
+
const absPath = normalizePath(resolve(filePath));
|
|
333
|
+
|
|
334
|
+
if (!absPath.endsWith('.md')) {
|
|
335
|
+
console.error(` SKIP (not .md): ${filePath}`);
|
|
336
|
+
errors++; continue;
|
|
337
|
+
}
|
|
338
|
+
if (!existsSync(absPath)) {
|
|
339
|
+
console.error(` SKIP (not found): ${filePath}`);
|
|
340
|
+
errors++; continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Find matching root (case-insensitive path comparison for Windows)
|
|
344
|
+
let matchingRoot = roots.find(r => absPath.startsWith(normalizePath(resolve(r.path))));
|
|
345
|
+
|
|
346
|
+
if (!matchingRoot && detected) {
|
|
347
|
+
// Try auto-creating root from CWD detection
|
|
348
|
+
const existingByPath = roots.find(r =>
|
|
349
|
+
normalizePath(resolve(r.path)) === normalizePath(resolve(detected.rootPath))
|
|
350
|
+
);
|
|
351
|
+
if (existingByPath) {
|
|
352
|
+
matchingRoot = existingByPath;
|
|
353
|
+
} else if (!dryRun) {
|
|
354
|
+
console.log(`Creating root: ${basename(detected.rootPath)} → ${detected.rootPath}`);
|
|
355
|
+
const { root } = await api('POST', '/api/mcp/roots', {
|
|
356
|
+
name: basename(detected.rootPath),
|
|
357
|
+
path: normalizePath(detected.rootPath),
|
|
358
|
+
}, creds);
|
|
359
|
+
matchingRoot = root;
|
|
360
|
+
roots.push(root);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!matchingRoot) {
|
|
365
|
+
console.error(` SKIP (no root): ${filePath}`);
|
|
366
|
+
errors++; continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const content = readFileSync(absPath, 'utf-8');
|
|
370
|
+
const hash = computeHash(content);
|
|
371
|
+
const stats = statSync(absPath);
|
|
372
|
+
const relativePath = normalizePath(relative(matchingRoot.path, absPath));
|
|
373
|
+
const project = detectProjectForFile(absPath, matchingRoot.path);
|
|
374
|
+
const metadata = parseFrontmatter(content);
|
|
375
|
+
|
|
376
|
+
if (dryRun) {
|
|
377
|
+
console.log(` [dry-run] Would sync: ${relativePath} → root "${matchingRoot.name}"`);
|
|
378
|
+
added++; continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const result = await api('POST', '/api/mcp/notes/upsert', {
|
|
382
|
+
file: {
|
|
383
|
+
path: absPath,
|
|
384
|
+
relativePath,
|
|
385
|
+
content,
|
|
386
|
+
hash,
|
|
387
|
+
mtime: stats.mtime,
|
|
388
|
+
size: stats.size,
|
|
389
|
+
rootId: matchingRoot.id,
|
|
390
|
+
rootName: matchingRoot.name,
|
|
391
|
+
project,
|
|
392
|
+
},
|
|
393
|
+
metadata,
|
|
394
|
+
force,
|
|
395
|
+
}, creds);
|
|
396
|
+
|
|
397
|
+
const action = result.action || 'synced';
|
|
398
|
+
console.log(` ${action}: ${relativePath}`);
|
|
399
|
+
action === 'created' ? added++ : updated++;
|
|
400
|
+
|
|
401
|
+
// Track for sync log
|
|
402
|
+
if (!affectedRoots.has(matchingRoot.id)) {
|
|
403
|
+
affectedRoots.set(matchingRoot.id, { root: matchingRoot, added: 0, updated: 0, scanned: 0 });
|
|
404
|
+
}
|
|
405
|
+
const rInfo = affectedRoots.get(matchingRoot.id);
|
|
406
|
+
rInfo.scanned++;
|
|
407
|
+
action === 'created' ? rInfo.added++ : rInfo.updated++;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Log sync for each affected root
|
|
411
|
+
if (!dryRun) {
|
|
412
|
+
for (const [rootId, info] of affectedRoots) {
|
|
413
|
+
if (info.added > 0 || info.updated > 0) {
|
|
414
|
+
await api('POST', '/api/mcp/sync/log', {
|
|
415
|
+
rootId,
|
|
416
|
+
filesScanned: info.scanned,
|
|
417
|
+
filesAdded: info.added,
|
|
418
|
+
filesUpdated: info.updated,
|
|
419
|
+
filesDeleted: 0,
|
|
420
|
+
source: 'noesis-sync-script',
|
|
421
|
+
}, creds);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
console.log(`\nDone: +${added} ~${updated}${errors ? ` errors:${errors}` : ''}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── CLI ─────────────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
const args = process.argv.slice(2);
|
|
432
|
+
const dryRun = args.includes('--dry-run');
|
|
433
|
+
const force = args.includes('--force');
|
|
434
|
+
const filesIdx = args.indexOf('--files');
|
|
435
|
+
const rootIdx = args.indexOf('--root');
|
|
436
|
+
|
|
437
|
+
let files = [];
|
|
438
|
+
let rootFilter = null;
|
|
439
|
+
|
|
440
|
+
if (filesIdx !== -1) {
|
|
441
|
+
// Collect all args after --files until next flag or end
|
|
442
|
+
for (let i = filesIdx + 1; i < args.length; i++) {
|
|
443
|
+
if (args[i].startsWith('--')) break;
|
|
444
|
+
files.push(args[i]);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (rootIdx !== -1 && args[rootIdx + 1]) {
|
|
449
|
+
rootFilter = args[rootIdx + 1];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Positional arg that isn't a flag — treat as root name or file
|
|
453
|
+
if (files.length === 0 && !rootFilter) {
|
|
454
|
+
for (const a of args) {
|
|
455
|
+
if (a.startsWith('--')) continue;
|
|
456
|
+
if (a.endsWith('.md')) files.push(a);
|
|
457
|
+
else rootFilter = a;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const creds = loadCredentials();
|
|
462
|
+
console.log(`Noesis sync → ${creds.url}`);
|
|
463
|
+
if (dryRun) console.log('[DRY RUN MODE]');
|
|
464
|
+
if (force) console.log('[FORCE MODE]');
|
|
465
|
+
|
|
466
|
+
syncFiles(files, { creds, dryRun, force, rootFilter }).catch(err => {
|
|
467
|
+
console.error('Sync failed:', err.message);
|
|
468
|
+
process.exit(1);
|
|
469
|
+
});
|