@andespindola/brainlink 1.6.9 → 1.6.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.
|
@@ -183,6 +183,113 @@ select {
|
|
|
183
183
|
.settings-back:hover,
|
|
184
184
|
.settings-back:focus {
|
|
185
185
|
color: var(--accent);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.settings-section {
|
|
189
|
+
margin-top: 28px;
|
|
190
|
+
padding-top: 24px;
|
|
191
|
+
border-top: 1px solid var(--line);
|
|
192
|
+
display: grid;
|
|
193
|
+
gap: 16px;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.settings-section h2 {
|
|
197
|
+
margin: 0;
|
|
198
|
+
font-size: 16px;
|
|
199
|
+
letter-spacing: 0.01em;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.settings-hint {
|
|
203
|
+
margin: 0;
|
|
204
|
+
color: var(--muted);
|
|
205
|
+
font-size: 12px;
|
|
206
|
+
line-height: 1.5;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.settings-status {
|
|
210
|
+
margin: 0;
|
|
211
|
+
padding: 10px 12px;
|
|
212
|
+
border: 1px solid var(--line);
|
|
213
|
+
border-radius: 8px;
|
|
214
|
+
background: rgba(12, 24, 36, 0.6);
|
|
215
|
+
color: var(--muted);
|
|
216
|
+
font-size: 12px;
|
|
217
|
+
line-height: 1.5;
|
|
218
|
+
overflow-wrap: anywhere;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.settings-secondary {
|
|
222
|
+
height: 42px;
|
|
223
|
+
border: 1px solid var(--line);
|
|
224
|
+
border-radius: 8px;
|
|
225
|
+
background: rgba(12, 24, 36, 0.94);
|
|
226
|
+
color: var(--text);
|
|
227
|
+
cursor: pointer;
|
|
228
|
+
transition: border-color 120ms ease, background 120ms ease, opacity 120ms ease;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.settings-secondary:hover:not(:disabled),
|
|
232
|
+
.settings-secondary:focus:not(:disabled) {
|
|
233
|
+
border-color: var(--accent);
|
|
234
|
+
background: rgba(90, 168, 255, 0.14);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.settings-secondary:disabled {
|
|
238
|
+
cursor: progress;
|
|
239
|
+
opacity: 0.6;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.settings-key {
|
|
243
|
+
display: grid;
|
|
244
|
+
gap: 8px;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.settings-key[hidden] {
|
|
248
|
+
display: none;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.settings-key textarea {
|
|
252
|
+
width: 100%;
|
|
253
|
+
min-height: 96px;
|
|
254
|
+
padding: 10px 12px;
|
|
255
|
+
border: 1px solid var(--line);
|
|
256
|
+
border-radius: 8px;
|
|
257
|
+
outline: none;
|
|
258
|
+
resize: vertical;
|
|
259
|
+
background: rgba(12, 24, 36, 0.94);
|
|
260
|
+
color: var(--text);
|
|
261
|
+
font: inherit;
|
|
262
|
+
font-size: 11px;
|
|
263
|
+
line-height: 1.4;
|
|
264
|
+
overflow-wrap: anywhere;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.settings-key textarea:focus {
|
|
268
|
+
border-color: var(--accent);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.settings-key-actions {
|
|
272
|
+
display: flex;
|
|
273
|
+
align-items: center;
|
|
274
|
+
gap: 12px;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.settings-copy {
|
|
278
|
+
height: 32px;
|
|
279
|
+
padding: 0 14px;
|
|
280
|
+
border: 1px solid var(--line);
|
|
281
|
+
border-radius: 8px;
|
|
282
|
+
background: rgba(12, 24, 36, 0.94);
|
|
283
|
+
color: var(--text);
|
|
284
|
+
cursor: pointer;
|
|
285
|
+
font-size: 12px;
|
|
286
|
+
transition: border-color 120ms ease, background 120ms ease;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.settings-copy:hover,
|
|
290
|
+
.settings-copy:focus {
|
|
291
|
+
border-color: var(--accent);
|
|
292
|
+
background: rgba(90, 168, 255, 0.14);
|
|
186
293
|
}`;
|
|
187
294
|
const settingsScript = () => `(function () {
|
|
188
295
|
var form = document.getElementById('settingsForm');
|
|
@@ -268,6 +375,187 @@ const settingsScript = () => `(function () {
|
|
|
268
375
|
button.disabled = false;
|
|
269
376
|
});
|
|
270
377
|
});
|
|
378
|
+
|
|
379
|
+
var generateKey = document.getElementById('generateKey');
|
|
380
|
+
var deployKey = document.getElementById('deployKey');
|
|
381
|
+
var deployKeyWrap = document.getElementById('deployKeyWrap');
|
|
382
|
+
var copyKey = document.getElementById('copyKey');
|
|
383
|
+
var remoteUrl = document.getElementById('remoteUrl');
|
|
384
|
+
var connectRemote = document.getElementById('connectRemote');
|
|
385
|
+
var versioningMessage = document.getElementById('versioningMessage');
|
|
386
|
+
var versioningStatus = document.getElementById('versioningStatus');
|
|
387
|
+
|
|
388
|
+
function showVersioning(text, kind) {
|
|
389
|
+
versioningMessage.textContent = text;
|
|
390
|
+
versioningMessage.className =
|
|
391
|
+
'settings-message ' + (kind === 'success' ? 'is-success' : 'is-error');
|
|
392
|
+
versioningMessage.hidden = false;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function clearVersioning() {
|
|
396
|
+
versioningMessage.textContent = '';
|
|
397
|
+
versioningMessage.className = 'settings-message';
|
|
398
|
+
versioningMessage.hidden = true;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
var connectReasons = {
|
|
402
|
+
'ssh-url-required': 'Use an SSH URL (git@github.com:...)',
|
|
403
|
+
'missing-remote': 'Enter the repository URL',
|
|
404
|
+
'no-deploy-key': 'Generate the deploy key first'
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
fetch('/api/versioning-status', { headers: { accept: 'application/json' } })
|
|
408
|
+
.then(function (response) {
|
|
409
|
+
if (!response.ok) {
|
|
410
|
+
return {};
|
|
411
|
+
}
|
|
412
|
+
return response.json().catch(function () {
|
|
413
|
+
return {};
|
|
414
|
+
});
|
|
415
|
+
})
|
|
416
|
+
.then(function (data) {
|
|
417
|
+
data = data || {};
|
|
418
|
+
var parts = [];
|
|
419
|
+
parts.push(data.hasKey ? 'Deploy key: present' : 'Deploy key: not generated');
|
|
420
|
+
if (typeof data.remote === 'string' && data.remote.length > 0) {
|
|
421
|
+
versioningStatus.textContent = '';
|
|
422
|
+
var prefix = document.createTextNode('Deploy key: ' + (data.hasKey ? 'present' : 'not generated') + ' · Remote: ');
|
|
423
|
+
var code = document.createElement('code');
|
|
424
|
+
code.textContent = data.remote;
|
|
425
|
+
versioningStatus.appendChild(prefix);
|
|
426
|
+
versioningStatus.appendChild(code);
|
|
427
|
+
versioningStatus.appendChild(
|
|
428
|
+
document.createTextNode(' · Auto-version: ' + (data.autoVersion === true ? 'on' : 'off'))
|
|
429
|
+
);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
parts.push('Remote: none');
|
|
433
|
+
parts.push('Auto-version: ' + (data.autoVersion === true ? 'on' : 'off'));
|
|
434
|
+
versioningStatus.textContent = parts.join(' · ');
|
|
435
|
+
})
|
|
436
|
+
.catch(function () {
|
|
437
|
+
versioningStatus.textContent = 'Versioning status unavailable.';
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
generateKey.addEventListener('click', function () {
|
|
441
|
+
clearVersioning();
|
|
442
|
+
generateKey.disabled = true;
|
|
443
|
+
fetch('/api/versioning/generate-key', {
|
|
444
|
+
method: 'POST',
|
|
445
|
+
headers: { 'content-type': 'application/json' }
|
|
446
|
+
})
|
|
447
|
+
.then(function (response) {
|
|
448
|
+
if (!response.ok) {
|
|
449
|
+
showVersioning('Unable to generate a deploy key. Please try again.', 'error');
|
|
450
|
+
generateKey.disabled = false;
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
return response
|
|
454
|
+
.json()
|
|
455
|
+
.catch(function () {
|
|
456
|
+
return {};
|
|
457
|
+
})
|
|
458
|
+
.then(function (data) {
|
|
459
|
+
data = data || {};
|
|
460
|
+
deployKey.value = typeof data.publicKey === 'string' ? data.publicKey : '';
|
|
461
|
+
deployKeyWrap.hidden = false;
|
|
462
|
+
showVersioning(
|
|
463
|
+
'Add this as a Deploy key (with write access) in your GitHub repo settings, then connect below.',
|
|
464
|
+
'success'
|
|
465
|
+
);
|
|
466
|
+
generateKey.disabled = false;
|
|
467
|
+
});
|
|
468
|
+
})
|
|
469
|
+
.catch(function () {
|
|
470
|
+
showVersioning('Something went wrong. Please try again.', 'error');
|
|
471
|
+
generateKey.disabled = false;
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
copyKey.addEventListener('click', function () {
|
|
476
|
+
var text = deployKey.value;
|
|
477
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
478
|
+
navigator.clipboard.writeText(text).then(
|
|
479
|
+
function () {
|
|
480
|
+
copyKey.textContent = 'Copied';
|
|
481
|
+
setTimeout(function () {
|
|
482
|
+
copyKey.textContent = 'Copy';
|
|
483
|
+
}, 1200);
|
|
484
|
+
},
|
|
485
|
+
function () {
|
|
486
|
+
deployKey.select();
|
|
487
|
+
}
|
|
488
|
+
);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
deployKey.select();
|
|
492
|
+
try {
|
|
493
|
+
document.execCommand('copy');
|
|
494
|
+
copyKey.textContent = 'Copied';
|
|
495
|
+
setTimeout(function () {
|
|
496
|
+
copyKey.textContent = 'Copy';
|
|
497
|
+
}, 1200);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// Selection is left in place so the user can copy manually.
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
connectRemote.addEventListener('click', function () {
|
|
504
|
+
clearVersioning();
|
|
505
|
+
connectRemote.disabled = true;
|
|
506
|
+
var url = remoteUrl.value.trim();
|
|
507
|
+
fetch('/api/versioning/connect', {
|
|
508
|
+
method: 'POST',
|
|
509
|
+
headers: { 'content-type': 'application/json' },
|
|
510
|
+
body: JSON.stringify({ remoteUrl: url })
|
|
511
|
+
})
|
|
512
|
+
.then(function (response) {
|
|
513
|
+
if (response.ok) {
|
|
514
|
+
return response
|
|
515
|
+
.json()
|
|
516
|
+
.catch(function () {
|
|
517
|
+
return {};
|
|
518
|
+
})
|
|
519
|
+
.then(function (data) {
|
|
520
|
+
data = data || {};
|
|
521
|
+
if (data.aggregated) {
|
|
522
|
+
var imported =
|
|
523
|
+
typeof data.aggregated.imported === 'number' ? data.aggregated.imported : 0;
|
|
524
|
+
var conflicts =
|
|
525
|
+
typeof data.aggregated.conflicts === 'number' ? data.aggregated.conflicts : 0;
|
|
526
|
+
showVersioning(
|
|
527
|
+
'Connected. Imported ' +
|
|
528
|
+
imported +
|
|
529
|
+
' notes from the repo (' +
|
|
530
|
+
conflicts +
|
|
531
|
+
' conflicts preserved).',
|
|
532
|
+
'success'
|
|
533
|
+
);
|
|
534
|
+
} else {
|
|
535
|
+
showVersioning('Connected and synced to the repository.', 'success');
|
|
536
|
+
}
|
|
537
|
+
connectRemote.disabled = false;
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
return response
|
|
541
|
+
.json()
|
|
542
|
+
.catch(function () {
|
|
543
|
+
return {};
|
|
544
|
+
})
|
|
545
|
+
.then(function (data) {
|
|
546
|
+
var reason = data && data.reason ? data.reason : '';
|
|
547
|
+
showVersioning(
|
|
548
|
+
connectReasons[reason] || 'Unable to connect the repository. Please try again.',
|
|
549
|
+
'error'
|
|
550
|
+
);
|
|
551
|
+
connectRemote.disabled = false;
|
|
552
|
+
});
|
|
553
|
+
})
|
|
554
|
+
.catch(function () {
|
|
555
|
+
showVersioning('Something went wrong. Please try again.', 'error');
|
|
556
|
+
connectRemote.disabled = false;
|
|
557
|
+
});
|
|
558
|
+
});
|
|
271
559
|
})();`;
|
|
272
560
|
export const createSettingsPageHtml = () => `<!DOCTYPE html>
|
|
273
561
|
<html lang="en">
|
|
@@ -315,6 +603,28 @@ export const createSettingsPageHtml = () => `<!DOCTYPE html>
|
|
|
315
603
|
<p id="message" class="settings-message" role="alert" aria-live="polite" hidden></p>
|
|
316
604
|
<button id="submit" class="settings-submit" type="submit">Save</button>
|
|
317
605
|
</form>
|
|
606
|
+
<section class="settings-section" aria-labelledby="versioningTitle">
|
|
607
|
+
<h2 id="versioningTitle">Vault versioning (git)</h2>
|
|
608
|
+
<p class="settings-hint">
|
|
609
|
+
Push your vault to a private GitHub repository over SSH so its history is versioned and portable.
|
|
610
|
+
</p>
|
|
611
|
+
<p id="versioningStatus" class="settings-status" aria-live="polite">Loading versioning status…</p>
|
|
612
|
+
<button id="generateKey" class="settings-secondary" type="button">Generate deploy key</button>
|
|
613
|
+
<div id="deployKeyWrap" class="settings-key" hidden>
|
|
614
|
+
<label for="deployKey">Deploy key (public)</label>
|
|
615
|
+
<textarea id="deployKey" readonly rows="4" spellcheck="false"></textarea>
|
|
616
|
+
<div class="settings-key-actions">
|
|
617
|
+
<button id="copyKey" class="settings-copy" type="button">Copy</button>
|
|
618
|
+
<span class="settings-hint">Add it as a Deploy key with write access in your GitHub repo settings.</span>
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
<div class="settings-field">
|
|
622
|
+
<label for="remoteUrl">Repository URL</label>
|
|
623
|
+
<input id="remoteUrl" name="remoteUrl" type="text" autocomplete="off" placeholder="git@github.com:user/repo.git" />
|
|
624
|
+
</div>
|
|
625
|
+
<p id="versioningMessage" class="settings-message" role="status" aria-live="polite" hidden></p>
|
|
626
|
+
<button id="connectRemote" class="settings-secondary" type="button">Connect & sync</button>
|
|
627
|
+
</section>
|
|
318
628
|
<a class="settings-back" href="/">← Back</a>
|
|
319
629
|
</main>
|
|
320
630
|
<script>${settingsScript()}</script>
|
|
@@ -29,14 +29,25 @@ const writePersistedLayout = async (vaultPath, options, cached) => {
|
|
|
29
29
|
await writeFile(temp, `${JSON.stringify(cached)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
30
30
|
await rename(temp, target);
|
|
31
31
|
};
|
|
32
|
+
// Invalidates the persisted graph layout when the index changes. The default
|
|
33
|
+
// binary backend never writes `.brainlink/index.json` (it uses `store/` +
|
|
34
|
+
// `index-state.json`), so signing on index.json alone left the layout stat
|
|
35
|
+
// permanently at '0:0' — the cached layout never refreshed and the graph froze
|
|
36
|
+
// on its first snapshot. `index-state.json` is rewritten on every reindex for
|
|
37
|
+
// every backend, so it is the reliable invalidation source (index.json stays as
|
|
38
|
+
// a fallback for the legacy JSON backend).
|
|
39
|
+
const indexStateStoragePath = (vaultPath) => join(vaultPath, '.brainlink', 'index-state.json');
|
|
32
40
|
const readDatabaseSignature = async (vaultPath) => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
for (const candidate of [indexStateStoragePath(vaultPath), indexStoragePath(vaultPath)]) {
|
|
42
|
+
try {
|
|
43
|
+
const info = await stat(candidate);
|
|
44
|
+
return `${Math.floor(info.mtimeMs)}:${info.size}`;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Try the next candidate.
|
|
48
|
+
}
|
|
39
49
|
}
|
|
50
|
+
return '0:0';
|
|
40
51
|
};
|
|
41
52
|
const createGraphSignature = (graph) => {
|
|
42
53
|
const nodesSignature = graph.nodes.map((node) => `${node.id}|${node.agentId}|${node.title}|${node.path}`).join('\n');
|
|
@@ -26,6 +26,7 @@ import { createLoginPageHtml, createChangePasswordPageHtml } from '../frontend/l
|
|
|
26
26
|
import { buildClearSessionCookie, buildSessionSetCookie, changeWebPassword, createSessionToken, parseCookies, resolveWebAuth, SESSION_COOKIE_NAME, verifySessionToken, verifyWebPassword } from './web-auth.js';
|
|
27
27
|
import { getGraphVersion } from './graph-version.js';
|
|
28
28
|
import { readAuthStatus, readEditableSettings, updateEditableSettings } from './settings-store.js';
|
|
29
|
+
import { connectVaultRemote, ensureDeployKey, readVersioningStatus } from './versioning.js';
|
|
29
30
|
import { createSettingsPageHtml } from '../frontend/settings-page.js';
|
|
30
31
|
import { contentTypes, createJsonResponse, isReadMethod, parsePositiveInteger } from './http.js';
|
|
31
32
|
import { parseMultipartForm } from './multipart.js';
|
|
@@ -544,6 +545,24 @@ export const route = async (request, url, vaultPath, options = {}) => {
|
|
|
544
545
|
if (isReadMethod(request) && url.pathname === '/settings') {
|
|
545
546
|
return createResponse(createSettingsPageHtml(), 200, contentTypes['.html']);
|
|
546
547
|
}
|
|
548
|
+
// Vault git versioning: generate an SSH deploy key on demand and connect a
|
|
549
|
+
// GitHub repo (clone-and-aggregate, conflict-preserving — never overwrites).
|
|
550
|
+
if (isReadMethod(request) && url.pathname === '/api/versioning-status') {
|
|
551
|
+
return createResponse(createJsonResponse(await readVersioningStatus(vaultPath)), 200, contentTypes['.json']);
|
|
552
|
+
}
|
|
553
|
+
if (request.method === 'POST' && url.pathname === '/api/versioning/generate-key') {
|
|
554
|
+
try {
|
|
555
|
+
return createResponse(createJsonResponse(await ensureDeployKey()), 200, contentTypes['.json']);
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
return createResponse(createJsonResponse({ error: error instanceof Error ? error.message : 'keygen-failed' }), 500, contentTypes['.json']);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (request.method === 'POST' && url.pathname === '/api/versioning/connect') {
|
|
562
|
+
const body = await readJsonBody(request);
|
|
563
|
+
const result = await connectVaultRemote(vaultPath, readStringField(body, 'remoteUrl'));
|
|
564
|
+
return createResponse(createJsonResponse(result), result.ok ? 200 : 400, contentTypes['.json']);
|
|
565
|
+
}
|
|
547
566
|
if (isReadMethod(request) && url.pathname === '/api/graph-contexts') {
|
|
548
567
|
return createResponse(createJsonResponse({ contexts: await getGraphContexts(vaultPath, readAgentQuery(url)) }), 200, contentTypes['.json']);
|
|
549
568
|
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Backend for configuring vault git versioning from the hosted Settings screen:
|
|
2
|
+
// generate an SSH deploy key on demand, connect a GitHub repo over SSH, and
|
|
3
|
+
// CLONE-AND-AGGREGATE existing versioned data into the vault without ever
|
|
4
|
+
// overwriting local notes. Aggregation reuses the conflict-preserving snapshot
|
|
5
|
+
// primitives (buildSnapshotEnvelope + applySnapshotEnvelope), which write
|
|
6
|
+
// divergent incoming files as `*.conflict-<ts>.md` rather than overwriting.
|
|
7
|
+
//
|
|
8
|
+
// Git is driven through the system binary via spawn (never a shell, args as an
|
|
9
|
+
// array — no injection surface), mirroring vault-git's spawning style but with a
|
|
10
|
+
// custom GIT_SSH_COMMAND env so clone/fetch/push authenticate with the deploy
|
|
11
|
+
// key. No new dependencies; Node built-ins only.
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { chmod, mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { getBrainlinkHomePath } from '../../infrastructure/paths.js';
|
|
17
|
+
import { getGlobalConfigPath, loadBrainlinkConfig, writeRawConfig } from '../../infrastructure/config.js';
|
|
18
|
+
import { initVaultGit, commitVault, pushVault } from '../vault-git.js';
|
|
19
|
+
import { buildSnapshotEnvelope, applySnapshotEnvelope } from '../vault-snapshot.js';
|
|
20
|
+
export const getVaultDeployKeyPath = () => join(getBrainlinkHomePath(), 'keys', 'vault-deploy-ed25519');
|
|
21
|
+
// Run a binary with args as an array and no shell, so untrusted values (remote
|
|
22
|
+
// URLs, key paths) can never be interpreted as shell syntax. An optional env is
|
|
23
|
+
// merged over the current process env, used to inject GIT_SSH_COMMAND for git.
|
|
24
|
+
const runProcess = (command, args, options = {}) => new Promise((resolvePromise, rejectPromise) => {
|
|
25
|
+
const child = spawn(command, [...args], {
|
|
26
|
+
cwd: options.cwd,
|
|
27
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
28
|
+
shell: false
|
|
29
|
+
});
|
|
30
|
+
let stdout = '';
|
|
31
|
+
let stderr = '';
|
|
32
|
+
child.stdout?.on('data', (chunk) => {
|
|
33
|
+
stdout += chunk.toString('utf8');
|
|
34
|
+
});
|
|
35
|
+
child.stderr?.on('data', (chunk) => {
|
|
36
|
+
stderr += chunk.toString('utf8');
|
|
37
|
+
});
|
|
38
|
+
child.on('error', (error) => rejectPromise(error));
|
|
39
|
+
child.on('close', (code) => resolvePromise({ code: code ?? 1, stdout, stderr }));
|
|
40
|
+
});
|
|
41
|
+
const buildSshEnv = (keyPath) => ({
|
|
42
|
+
GIT_SSH_COMMAND: `ssh -i ${keyPath} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new`
|
|
43
|
+
});
|
|
44
|
+
const fileExists = async (path) => {
|
|
45
|
+
try {
|
|
46
|
+
await readFile(path);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
export const ensureDeployKey = async () => {
|
|
54
|
+
const keyPath = getVaultDeployKeyPath();
|
|
55
|
+
const publicKeyPath = `${keyPath}.pub`;
|
|
56
|
+
if (await fileExists(keyPath)) {
|
|
57
|
+
const publicKey = await readFile(publicKeyPath, 'utf8');
|
|
58
|
+
return { publicKey: publicKey.trim(), created: false };
|
|
59
|
+
}
|
|
60
|
+
await mkdir(join(getBrainlinkHomePath(), 'keys'), { recursive: true, mode: 0o700 });
|
|
61
|
+
const result = await runProcess('ssh-keygen', [
|
|
62
|
+
'-t',
|
|
63
|
+
'ed25519',
|
|
64
|
+
'-N',
|
|
65
|
+
'',
|
|
66
|
+
'-C',
|
|
67
|
+
'brainlink-vault',
|
|
68
|
+
'-f',
|
|
69
|
+
keyPath
|
|
70
|
+
]).catch(() => {
|
|
71
|
+
throw new Error('ssh-keygen is not available on this host');
|
|
72
|
+
});
|
|
73
|
+
if (result.code !== 0) {
|
|
74
|
+
throw new Error('ssh-keygen is not available on this host');
|
|
75
|
+
}
|
|
76
|
+
await chmod(keyPath, 0o600);
|
|
77
|
+
const publicKey = await readFile(publicKeyPath, 'utf8');
|
|
78
|
+
return { publicKey: publicKey.trim(), created: true };
|
|
79
|
+
};
|
|
80
|
+
export const readVersioningStatus = async (_vaultPath) => {
|
|
81
|
+
const publicKeyPath = `${getVaultDeployKeyPath()}.pub`;
|
|
82
|
+
const publicKey = await readFile(publicKeyPath, 'utf8').then((value) => value.trim()).catch(() => null);
|
|
83
|
+
const config = await loadBrainlinkConfig();
|
|
84
|
+
return {
|
|
85
|
+
hasKey: publicKey !== null,
|
|
86
|
+
publicKey,
|
|
87
|
+
remote: config.vaultRemote ?? null,
|
|
88
|
+
autoVersion: config.autoVersion
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
// Accept only a bare SSH git URL (git@host:… or ssh://host/…). https URLs and
|
|
92
|
+
// any URL carrying an inline credential are refused so no secret is ever written
|
|
93
|
+
// to .git/config.
|
|
94
|
+
const validateRemoteUrl = (remoteUrl) => {
|
|
95
|
+
const trimmed = remoteUrl.trim();
|
|
96
|
+
if (trimmed.length === 0) {
|
|
97
|
+
return { ok: false, reason: 'missing-remote' };
|
|
98
|
+
}
|
|
99
|
+
const isSsh = trimmed.startsWith('git@') || trimmed.startsWith('ssh://');
|
|
100
|
+
const hasInlineCredential = /\/\/[^/@]*:[^/@]*@/.test(trimmed) || /@[^/]*:[^/]*@/.test(trimmed);
|
|
101
|
+
if (!isSsh || hasInlineCredential) {
|
|
102
|
+
return { ok: false, reason: 'ssh-url-required' };
|
|
103
|
+
}
|
|
104
|
+
return { ok: true };
|
|
105
|
+
};
|
|
106
|
+
const dirHasMarkdown = async (dir) => {
|
|
107
|
+
const walk = async (current) => {
|
|
108
|
+
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (entry.name === '.git') {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const full = join(current, entry.name);
|
|
114
|
+
if (entry.isDirectory()) {
|
|
115
|
+
if (await walk(full)) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else if (entry.name.toLowerCase().endsWith('.md')) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
};
|
|
125
|
+
return walk(dir);
|
|
126
|
+
};
|
|
127
|
+
const persistVersioningConfig = async (remoteUrl) => {
|
|
128
|
+
const raw = await readFile(getGlobalConfigPath(), 'utf8').catch(() => '{}');
|
|
129
|
+
const parsed = JSON.parse(raw);
|
|
130
|
+
const merged = { ...parsed, vaultRemote: remoteUrl, autoVersion: true };
|
|
131
|
+
await writeRawConfig('global', merged);
|
|
132
|
+
};
|
|
133
|
+
export const connectVaultRemote = async (vaultPath, remoteUrl) => {
|
|
134
|
+
const validation = validateRemoteUrl(remoteUrl);
|
|
135
|
+
if (!validation.ok) {
|
|
136
|
+
return { ok: false, reason: validation.reason };
|
|
137
|
+
}
|
|
138
|
+
const remote = remoteUrl.trim();
|
|
139
|
+
const keyPath = getVaultDeployKeyPath();
|
|
140
|
+
try {
|
|
141
|
+
await ensureDeployKey();
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return { ok: false, reason: 'no-deploy-key' };
|
|
145
|
+
}
|
|
146
|
+
if (!(await fileExists(keyPath))) {
|
|
147
|
+
return { ok: false, reason: 'no-deploy-key' };
|
|
148
|
+
}
|
|
149
|
+
const sshEnv = buildSshEnv(keyPath);
|
|
150
|
+
const sshCommand = sshEnv.GIT_SSH_COMMAND;
|
|
151
|
+
let cloneDir = null;
|
|
152
|
+
try {
|
|
153
|
+
cloneDir = await mkdtemp(join(tmpdir(), 'brainlink-vault-clone-'));
|
|
154
|
+
const clone = await runProcess('git', ['clone', remote, cloneDir], { env: sshEnv });
|
|
155
|
+
if (clone.code !== 0) {
|
|
156
|
+
return { ok: false, reason: 'clone-failed' };
|
|
157
|
+
}
|
|
158
|
+
// CLONE-AND-AGGREGATE, conflict-preserving. Skip when the remote has no
|
|
159
|
+
// markdown (empty/unborn repo) so aggregated stays undefined.
|
|
160
|
+
let aggregated;
|
|
161
|
+
if (await dirHasMarkdown(cloneDir)) {
|
|
162
|
+
const envelope = await buildSnapshotEnvelope(cloneDir);
|
|
163
|
+
const result = await applySnapshotEnvelope(envelope, vaultPath, {});
|
|
164
|
+
aggregated = { imported: result.imported, conflicted: result.conflicted };
|
|
165
|
+
}
|
|
166
|
+
// Ensure the vault is a git repo and wire origin (add or set-url via
|
|
167
|
+
// initVaultGit) + core.sshCommand so future pull/push authenticate with the
|
|
168
|
+
// deploy key.
|
|
169
|
+
const { root } = await initVaultGit(vaultPath, { remote });
|
|
170
|
+
const sshConfig = await runProcess('git', ['config', 'core.sshCommand', sshCommand], { cwd: root, env: sshEnv });
|
|
171
|
+
if (sshConfig.code !== 0) {
|
|
172
|
+
return { ok: false, reason: 'config-failed' };
|
|
173
|
+
}
|
|
174
|
+
await persistVersioningConfig(remote);
|
|
175
|
+
// Best-effort: push the merged state so the remote reflects the vault. A
|
|
176
|
+
// push failure is non-fatal — the connection itself succeeded.
|
|
177
|
+
try {
|
|
178
|
+
await commitVault(vaultPath, 'chore: connect vault remote');
|
|
179
|
+
await pushVault(vaultPath);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// non-fatal
|
|
183
|
+
}
|
|
184
|
+
return aggregated ? { ok: true, aggregated } : { ok: true };
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return { ok: false, reason: 'clone-failed' };
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
if (cloneDir) {
|
|
191
|
+
await rm(cloneDir, { recursive: true, force: true }).catch(() => undefined);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
package/package.json
CHANGED