Skip to content

Commit 9190dde

Browse files
Wire reciprocal invites, fix first-run gate, repair Dreamer auto-link
Three fixes toward rc.22: #83 — Reciprocal invite now wires the peer remote. cloneFromGitHub's "already exists" branch used to bare-return 'skipped', bypassing the sovereignty handover. New ensurePeerRemote() helper adds the inviting peer's outbox (https://github.com/<owner>/<repo>) as a fetch remote — skipping only if it's your own outbox or already present. This is the reciprocal half of the handover: both peers end up tracking each other's outbox. #80 — First-run gate keys off vault_count == 0, not gh auth. A user who already has gh authenticated for unrelated reasons must still see the setup screen; gh auth is a step within first-run, not its gate. #76 — findNodeByIdentifier matches GitHub identifiers by node.repoPath === <repoName> (cloneFromGitHub names the dir exactly that). We stopped writing .udd.githubRepoUrl on clone, so the old match path was always null — the cloned DreamNode never linked to its sender Dreamer. Legacy githubRepoUrl fallback retained. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 31c2953 commit 9190dde

2 files changed

Lines changed: 120 additions & 15 deletions

File tree

desktop/src-tauri/src/lib.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -215,21 +215,25 @@ pub fn run() {
215215
tauri::async_runtime::spawn(async move {
216216
// Tiny yield so the platform event loop starts processing.
217217
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
218-
// Identity is "is gh CLI authenticated?" Not the legacy
219-
// ed25519 keychain entry (which is what `has_unlocked_identity`
220-
// would have asked, and which would have triggered a macOS
221-
// consent prompt at every relaunch).
218+
// First-run gate keys off InterBrain's OWN setup state —
219+
// specifically: has the plugin been installed into at least
220+
// one vault? That's the whole purpose of the first-run
221+
// flow. gh authentication is just a *step* within first-run,
222+
// not the gate for it: a user who already has gh
223+
// authenticated for unrelated reasons must still see the
224+
// setup screen.
222225
let gh = github::gh_status();
223-
let has_identity = gh.authenticated;
224226
let vault_count = state_for_setup.settings.lock().unwrap().vault_registry.len();
227+
let needs_first_run = vault_count == 0;
225228
tracing::info!(
226229
target: "startup",
227-
gh_authenticated = has_identity,
230+
gh_authenticated = gh.authenticated,
228231
gh_username = gh.username.as_deref().unwrap_or(""),
229232
registered_vaults = vault_count,
233+
needs_first_run = needs_first_run,
230234
"startup decision"
231235
);
232-
if !has_identity {
236+
if needs_first_run {
233237
// Allow headless launches (e.g. SSH-driven testing) to
234238
// skip rendering the first-run window — WebView2 fails
235239
// outside an interactive logon session on Windows, and

src/features/uri-handler/uri-handler-service.ts

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,77 @@ export class URIHandlerService {
432432
}
433433
}
434434

435+
/**
436+
* Ensure the inviting peer's GitHub outbox is registered as a peer
437+
* remote on an already-cloned DreamNode. This is the reciprocal half
438+
* of the sovereignty handover: when both peers have invited each
439+
* other, each one's repo tracks the other's outbox as a fetch source.
440+
*
441+
* Peer remotes use the native `https://github.com/<owner>/<repo>` URL
442+
* (NOT `interbrain://` — that namespace is for `.gitmodules`, where
443+
* UUID indirection is needed; a peer remote is already a concrete,
444+
* known location). The remote is named after the owner.
445+
*
446+
* Returns true if a new peer remote was added, false if it was
447+
* already present (or the owner is the current user — you don't peer
448+
* with yourself).
449+
*/
450+
private async ensurePeerRemote(repoFullPath: string, owner: string, repoName: string): Promise<boolean> {
451+
try {
452+
const { exec } = require('child_process');
453+
const { promisify } = require('util');
454+
const execAsync = promisify(exec);
455+
456+
// Don't add a peer remote pointing at our own outbox.
457+
try {
458+
const { getSovereigntyService } = await import('../social-resonance-filter/services/sovereignty-service');
459+
const me = await getSovereigntyService().getCurrentUser();
460+
if (me && me.toLowerCase() === owner.toLowerCase()) {
461+
return false;
462+
}
463+
} catch {
464+
// Couldn't determine current user — proceed; worst case we
465+
// add a redundant remote, which is harmless.
466+
}
467+
468+
const peerUrl = `https://github.com/${owner}/${repoName}`;
469+
const { stdout: remotesOut } = await execAsync('git remote -v', { cwd: repoFullPath });
470+
471+
// Already have a remote pointing at this exact URL?
472+
if (remotesOut.includes(peerUrl)) {
473+
return false;
474+
}
475+
476+
// Pick a remote name. Prefer the owner login; if that name is
477+
// taken by a *different* URL, suffix it.
478+
let remoteName = owner;
479+
const existingNames = new Set(
480+
remotesOut.split('\n')
481+
.map((l: string) => l.split('\t')[0]?.trim())
482+
.filter(Boolean)
483+
);
484+
if (existingNames.has(remoteName)) {
485+
let n = 2;
486+
while (existingNames.has(`${remoteName}-${n}`)) n++;
487+
remoteName = `${remoteName}-${n}`;
488+
}
489+
490+
await execAsync(`git remote add ${remoteName} ${peerUrl}`, { cwd: repoFullPath });
491+
// Best-effort fetch so the peer's commits are immediately
492+
// visible to Check for Updates.
493+
try {
494+
await execAsync(`git fetch ${remoteName}`, { cwd: repoFullPath });
495+
} catch (fetchErr) {
496+
console.warn(`[URIHandler] peer remote ${remoteName} added but fetch failed (non-fatal):`, fetchErr);
497+
}
498+
console.log(`[URIHandler] added peer remote "${remoteName}" -> ${peerUrl}`);
499+
return true;
500+
} catch (err) {
501+
console.warn('[URIHandler] ensurePeerRemote failed (non-fatal):', err);
502+
return false;
503+
}
504+
}
505+
435506
/**
436507
* Index a newly cloned node for semantic search
437508
*/
@@ -650,11 +721,26 @@ export class URIHandlerService {
650721

651722
// Check if already exists - use Obsidian vault API
652723
if (await this.app.vault.adapter.exists(repoName)) {
724+
// The DreamNode is already in this vault — but the invite is
725+
// still meaningful: it's the *reciprocal* half of the
726+
// sovereignty handover. If Alice accepted Bob's invite, then
727+
// Bob sends his link back, accepting it should register
728+
// Alice's outbox (`github.com/<owner>/<repo>`) as a peer
729+
// remote so Bob can follow Alice's updates too.
730+
//
731+
// Only truly skip if that peer remote is already present.
732+
const peerAdded = await this.ensurePeerRemote(destinationPath, owner, repoName);
653733
if (!silent) {
654-
new Notice(`DreamNode "${repoName}" already cloned!`);
734+
if (peerAdded) {
735+
new Notice(`Now following ${owner}'s "${repoName}"`);
736+
} else {
737+
new Notice(`Already following ${owner}'s "${repoName}"`);
738+
}
655739
await this.autoFocusNode(repoName, silent);
656740
}
657-
return 'skipped';
741+
// 'success' when we wired a new peer, 'skipped' when nothing
742+
// changed — keeps the caller's accounting honest.
743+
return peerAdded ? 'success' : 'skipped';
658744
}
659745

660746
if (!silent) {
@@ -852,10 +938,18 @@ export class URIHandlerService {
852938
const isRadicleId = identifier.startsWith('rad:');
853939
const isGitHubUrl = identifier.includes('github.com/');
854940

855-
// Normalize GitHub URL if needed (remove protocol, .git suffix)
856-
const normalizedGitHubUrl = isGitHubUrl
857-
? identifier.replace(/^https?:\/\//, '').replace(/\.git$/, '')
858-
: null;
941+
// For a GitHub identifier, the cloned DreamNode lives at vault path
942+
// `<repo>` — that's literally how cloneFromGitHub names the
943+
// destination dir. So match on repoPath, NOT githubRepoUrl: under
944+
// rc.21 we deliberately stopped writing .udd.githubRepoUrl on clone
945+
// (it would be the sender's URL, and the sovereignty handover
946+
// repoints origin to the recipient's own outbox anyway), so
947+
// githubRepoUrl is undefined on freshly-cloned nodes.
948+
let githubRepoName: string | null = null;
949+
if (isGitHubUrl) {
950+
const m = identifier.match(/github\.com\/[^/]+\/([^/\s]+)/);
951+
if (m) githubRepoName = m[1].replace(/\.git$/, '');
952+
}
859953

860954
for (const node of allNodes) {
861955
// Check Radicle ID (available on DreamNode from vault scan)
@@ -864,10 +958,17 @@ export class URIHandlerService {
864958
return node;
865959
}
866960

867-
// Check GitHub URL (available on DreamNode from vault scan)
961+
// Check GitHub identifier: cloned node's repoPath === <repo>.
962+
if (githubRepoName && node.repoPath === githubRepoName) {
963+
(node as any).uuid = node.id;
964+
return node;
965+
}
966+
967+
// Fallback: legacy nodes that still carry githubRepoUrl in .udd.
868968
if (isGitHubUrl && node.githubRepoUrl) {
869969
const normalizedUddUrl = node.githubRepoUrl.replace(/^https?:\/\//, '').replace(/\.git$/, '');
870-
if (normalizedUddUrl === normalizedGitHubUrl) {
970+
const normalizedIdentifier = identifier.replace(/^https?:\/\//, '').replace(/\.git$/, '');
971+
if (normalizedUddUrl === normalizedIdentifier) {
871972
(node as any).uuid = node.id;
872973
return node;
873974
}

0 commit comments

Comments
 (0)