Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
## 1.52.0

### Added

- **The dashboard TUI can now manage git credentials.** From a node's detail screen
(Nodes → Enter), press **`c`** to open the node's git-credential view — the same
remote, caller-scoped operations as `omnyshell drive credential --node`:
- list your own credentials on that node (secrets masked),
- **`a`** add a credential via a form (host + PAT, or username/password),
- **`x`** remove the selected credential (with confirmation).
All three go through the existing `DashboardBackend` port (extended with
`listGitCredentials`/`addGitCredential`/`removeGitCredential`) over `ClientRuntime`,
so the TUI stays `dart:io`-free and testable. Credentials remain scoped to the
connected principal and never leave the node — the dashboard is HTTPS-only, matching
the remote CLI.

### Fixed

- **TUIs no longer freeze or crash on an error.** Both the dashboard and the IDE now
catch any exception thrown while handling a keypress and show it in the status bar
instead of hanging or exiting. Dashboard credential loads run in the background so the
UI stays responsive. And **all client Hub/node RPCs are now time-boxed** (list nodes/
sessions/tunnels, peek/kill/detach, tunnel open/close, git credentials, AI proxy), so
a connected-but-silent peer — e.g. an offline or out-of-date node — surfaces an error
instead of leaving a view stuck (previously “Loading credentials…” could hang forever).

## 1.51.0

### Changed
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,10 @@ or drive. A missing or wrong credential fails fast rather than hanging on an
interactive prompt. (SSH keys with a passphrase require an ssh-agent, and can only
be set with `--local` since the key path is node-side.)

The **dashboard TUI** exposes the same remote, caller-scoped management: open a node
(Nodes → Enter) and press `c` to list, `a` to add, or `x` to remove your own git
credentials on that node.

Restrict which sub-paths a directory mount serves with repeatable `--include`
(whitelist) / `--exclude` (wins over include) globs — the filter is baked into the
mount, so every sync keeps applying it. With **neither** flag given, the directory's
Expand Down
57 changes: 56 additions & 1 deletion bin/omnyshell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,8 @@ class DashboardCommand extends Command<void> {

@override
String get description =>
'Open the full-screen TUI dashboard (nodes, sessions, tunnels, drive, AI).';
'Open the full-screen TUI dashboard (nodes, sessions, git credentials, '
'tunnels, drive, AI).';

@override
String? get usageFooter => _usageExamples([
Expand Down Expand Up @@ -1271,6 +1272,60 @@ class _CliDashboardBackend implements DashboardBackend {
}
}

@override
Future<List<DriveCredentialEntry>> listGitCredentials(String nodeId) async {
final res = await _requireClient.driveCredentialList(nodeId: nodeId);
if (!res.ok) throw _CliError(res.message);
return res.entries;
}

@override
Future<DashboardActionResult> addGitCredential(
String nodeId, {
required String host,
String? pat,
String? username,
String? password,
}) async {
final Map<String, dynamic> credential;
if (pat != null && pat.isNotEmpty) {
credential = GitPat(
token: pat,
username: (username == null || username.isEmpty)
? 'x-access-token'
: username,
).toJson();
} else if (username != null &&
username.isNotEmpty &&
password != null &&
password.isNotEmpty) {
credential = GitUserPass(username: username, password: password).toJson();
} else {
return const DashboardActionResult(
ok: false,
message: 'Provide a PAT, or a username and password.',
);
}
final res = await _requireClient.driveCredentialAdd(
nodeId: nodeId,
host: host,
credential: credential,
);
return DashboardActionResult(ok: res.ok, message: res.message);
}

@override
Future<DashboardActionResult> removeGitCredential(
String nodeId, {
required String host,
}) async {
final res = await _requireClient.driveCredentialRemove(
nodeId: nodeId,
host: host,
);
return DashboardActionResult(ok: res.ok, message: res.message);
}

/// Maps the port's [DriveSyncDirection] onto OmnyDrive's [SyncDirection]
/// (`auto` = null = reconcile both sides).
SyncDirection? _syncDirection(DriveSyncDirection d) => switch (d) {
Expand Down
76 changes: 64 additions & 12 deletions lib/src/application/client/client_runtime.dart
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,25 @@ class ClientRuntime {
_connection?.send(ControlFrame(auth));
}

/// Wraps a pending-RPC [future] with a timeout so a peer that never replies
/// surfaces a [TimeoutException] instead of hanging the caller (notably a TUI
/// input loop). [cleanup] drops the orphaned pending entry; [what] names the
/// peer for the message. A dropped connection still fails pending RPCs
/// immediately via the disconnect handler — this guards the "connected but
/// silent" case (e.g. an offline or out-of-date node).
Future<T> _rpcTimeout<T>(
Future<T> future,
void Function() cleanup, {
String what = 'the Hub',
Duration timeout = const Duration(seconds: 20),
}) => future.timeout(
timeout,
onTimeout: () {
cleanup();
throw TimeoutException('No response from $what');
},
);

/// Lists the nodes visible to this client, with an optional label [filter].
Future<List<NodeDescriptor>> listNodes({
Map<String, String> filter = const {},
Expand All @@ -468,7 +487,10 @@ class ClientRuntime {
final completer = Completer<List<NodeDescriptor>>();
_pendingNodeLists.add(completer);
_connection!.send(ControlFrame(NodeListRequest(filter: filter)));
return completer.future;
return _rpcTimeout(
completer.future,
() => _pendingNodeLists.remove(completer),
);
}

/// Measures round-trip latency to the Hub.
Expand All @@ -478,7 +500,7 @@ class ClientRuntime {
final completer = Completer<Duration>();
_pendingPings[id] = completer;
_connection!.send(ControlFrame(Ping(id: id, ts: config.clock.now())));
return completer.future;
return _rpcTimeout(completer.future, () => _pendingPings.remove(id));
}

/// Opens a session on [nodeId].
Expand Down Expand Up @@ -550,7 +572,11 @@ class ClientRuntime {
_connection!.send(
ControlFrame(DetachedSessionsRequest(requestId: id, nodeId: nodeId)),
);
return completer.future;
return _rpcTimeout(
completer.future,
() => _pendingSessionLists.remove(id),
what: 'node "$nodeId"',
);
}

/// Lists only the caller's *detached* sessions on [nodeId].
Expand All @@ -569,7 +595,7 @@ class ClientRuntime {
final completer = Completer<HubAiConfig>();
_pendingAiConfigs[id] = completer;
_connection!.send(ControlFrame(AiConfigRequest(requestId: id)));
return completer.future;
return _rpcTimeout(completer.future, () => _pendingAiConfigs.remove(id));
}

/// Asks the Hub to perform an outbound AI HTTPS request on the caller's behalf
Expand Down Expand Up @@ -605,7 +631,12 @@ class ClientRuntime {
),
),
);
return completer.future;
// Longer bound: this proxies an outbound AI/LLM call which can be slow.
return _rpcTimeout(
completer.future,
() => _pendingHttpProxies.remove(id),
timeout: const Duration(seconds: 120),
);
}

/// Fetches the current screen snapshot of one of the caller's sessions on
Expand All @@ -630,7 +661,11 @@ class ClientRuntime {
),
),
);
return completer.future;
return _rpcTimeout(
completer.future,
() => _pendingSessionScreens.remove(id),
what: 'node "$nodeId"',
);
}

/// Adds/overwrites the caller's own git credential for [host] on [nodeId].
Expand Down Expand Up @@ -677,7 +712,11 @@ class ClientRuntime {
),
),
);
return completer.future;
return _rpcTimeout(
completer.future,
() => _pendingDriveCredentials.remove(id),
what: 'node "$nodeId" for git-credential $op',
);
}

/// Detaches one of the caller's *active* sessions on [nodeId] from this
Expand All @@ -704,7 +743,11 @@ class ClientRuntime {
),
),
);
return completer.future;
return _rpcTimeout(
completer.future,
() => _pendingActiveDetach.remove(id),
what: 'node "$nodeId"',
);
}

/// Terminates one of the caller's sessions on [nodeId] — **running** (attached)
Expand All @@ -728,7 +771,11 @@ class ClientRuntime {
),
),
);
return completer.future;
return _rpcTimeout(
completer.future,
() => _pendingSessionKills.remove(id),
what: 'node "$nodeId"',
);
}

/// Deprecated alias for [killSession] (which now also terminates running
Expand Down Expand Up @@ -932,7 +979,12 @@ class ClientRuntime {
),
),
);
return completer.future;
return _rpcTimeout(
completer.future,
() => _pendingTunnelOpens.remove(id),
what: 'node "$effectiveNode"',
timeout: const Duration(seconds: 30),
);
}

/// Lists the caller's active tunnels held by the Hub.
Expand All @@ -942,7 +994,7 @@ class ClientRuntime {
final completer = Completer<List<TunnelInfo>>();
_pendingTunnelLists[id] = completer;
_connection!.send(ControlFrame(TunnelListRequest(requestId: id)));
return completer.future;
return _rpcTimeout(completer.future, () => _pendingTunnelLists.remove(id));
}

/// Closes the caller's tunnel [tunnelRef] (a full id or unambiguous prefix).
Expand All @@ -954,7 +1006,7 @@ class ClientRuntime {
_connection!.send(
ControlFrame(TunnelCloseRequest(requestId: id, tunnelRef: tunnelRef)),
);
return completer.future;
return _rpcTimeout(completer.future, () => _pendingTunnelCloses.remove(id));
}

/// Serves a Hub-forwarded tunnel connection against this client's own machine
Expand Down
Loading
Loading