@omercnet/paseo-connected-hosts 0.0.0 → 0.1.0-next.305.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/OVERVIEW.md +5 -5
- package/client/hosts.ts +71 -35
- package/client/screen.tsx +24 -18
- package/package.json +13 -13
package/OVERVIEW.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
Connected hosts manages the hosts saved on the daemon you are working in, from a **Connected hosts** screen. It adds a sidebar row and a Command Center action, "Manage connected hosts".
|
|
2
2
|
|
|
3
|
-
Each saved host shows its label, connection status, the safe description of where it connects, the server ID once learned,
|
|
3
|
+
Each saved host shows its label, connection status, the safe description of where it connects, the server ID once learned, what registered it, its metadata, when it was last online, when the next retry happens, and the last connection error. Status updates live. From the screen you can add a host, edit its label, metadata and connection, disable and re-enable it, and remove it.
|
|
4
4
|
|
|
5
5
|
The list is the daemon's own registry, the same one `paseo host` and agent tools such as `list_hosts` use. It is separate from the connections saved in the app, and it always describes the host selected in the screen's host picker. Changing the picker shows that daemon's registry.
|
|
6
6
|
|
|
7
|
-
Connections are written as the CLI accepts them, for example `host:6767` or `ssh://user@host`. A host that cannot be reached is still saved and retried. A connection is write-only: the daemon never returns saved credentials, so the edit form shows the safe description and leaves the connection untouched unless you type a new one.
|
|
7
|
+
Connections are written as the CLI accepts them, for example `host:6767` or `ssh://user@host`. A host that cannot be reached is still saved and retried automatically. A host that is reachable but rejected (failed authentication, a different daemon identity, this daemon itself, a duplicate, or a daemon without the feature) stays in an error state until you disable and re-enable it, or save a new connection. A connection is write-only: the daemon never returns saved credentials, so the edit form shows the safe description and leaves the connection untouched unless you type a new one.
|
|
8
8
|
|
|
9
9
|
Replacing a connection keeps the host's pinned identity. If the new connection reaches a different daemon, the new connection is saved but the host shows an identity error and stays disconnected. To accept the new daemon, edit the host, enter the connection again and tick **Accept a different server identity**. The daemon refuses an identity change without a connection, so the field cannot be left blank.
|
|
10
10
|
|
|
11
|
-
Metadata is a set of `key=value` lines and saving replaces the whole map.
|
|
11
|
+
Metadata is a set of `key=value` lines and saving replaces the whole map. Only fields you change are sent, so editing a label never rewrites metadata.
|
|
12
12
|
|
|
13
|
-
Who may change what follows the daemon's own rules.
|
|
13
|
+
Who may change what follows the daemon's own rules. Adding, editing and removing need tunnel management. Changing a connection or metadata, and removing, also need the registering producer or access management. The screen cannot see your grants, so every action is shown, and a refused change shows the daemon's message and leaves the host as it was. Removing a host that another process registered lets that process add it again; disabling keeps the host saved and switched off. Removing a host does not stop agents already running on it, but this daemon can no longer reach them.
|
|
14
14
|
|
|
15
|
-
Both daemons need the connected hosts feature.
|
|
15
|
+
Both daemons need the connected hosts feature, and the link between them needs host multiplexing.
|
|
16
16
|
|
|
17
17
|
Requires Paseo ^0.13.0.
|
package/client/hosts.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
import type { HostAdd, HostDirectory, HostSummary, HostUpdate
|
|
1
|
+
import type { HostAdd, HostDirectory, HostSummary, HostUpdate } from "@getpaseo/protocol/hosts";
|
|
2
2
|
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
|
3
3
|
import { useCallback, useEffect, useState } from "react";
|
|
4
4
|
|
|
5
|
+
/** The slice of the plugin's `usePaseo()` client that follows the saved hosts. */
|
|
6
|
+
export interface HostsApi {
|
|
7
|
+
hosts: {
|
|
8
|
+
observe(): {
|
|
9
|
+
subscribe(observer: {
|
|
10
|
+
snapshot(directory: HostDirectory): void;
|
|
11
|
+
update(message: SessionOutboundMessage): void;
|
|
12
|
+
error(cause: unknown): void;
|
|
13
|
+
}): () => void;
|
|
14
|
+
release(): Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
5
19
|
export type Built<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
6
20
|
|
|
7
21
|
/** Prefers the daemon's `userMessage` over the transport message, which appends request details. */
|
|
@@ -64,15 +78,23 @@ export interface EditForm {
|
|
|
64
78
|
replaceIdentity: boolean;
|
|
65
79
|
}
|
|
66
80
|
|
|
67
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Sends only what the user changed. A field still equal to its prefilled text is never parsed or
|
|
83
|
+
* sent, so values the form cannot round-trip (padding, `=` in a key) are not rewritten and a
|
|
84
|
+
* label-only edit never asks for metadata permission.
|
|
85
|
+
*/
|
|
68
86
|
export function buildUpdatePatch(host: HostSummary, form: EditForm): Built<HostUpdate> {
|
|
69
87
|
const patch: HostUpdate = {};
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
|
|
88
|
+
if (form.label !== (host.label ?? "")) {
|
|
89
|
+
const label = form.label.trim() || null;
|
|
90
|
+
if (label !== host.label) patch.label = label;
|
|
91
|
+
}
|
|
92
|
+
if (form.metadata !== formatMetadata(host.metadata)) {
|
|
93
|
+
const metadata = parseMetadata(form.metadata);
|
|
94
|
+
if (!metadata.ok) return metadata;
|
|
95
|
+
if (formatMetadata(metadata.value) !== formatMetadata(host.metadata)) {
|
|
96
|
+
patch.metadata = metadata.value;
|
|
97
|
+
}
|
|
76
98
|
}
|
|
77
99
|
const connection = form.connection.trim();
|
|
78
100
|
if (connection) {
|
|
@@ -86,47 +108,61 @@ export function directoryFromMessage(message: SessionOutboundMessage): HostDirec
|
|
|
86
108
|
return message.type === "hosts.changed" ? message.payload.directory : null;
|
|
87
109
|
}
|
|
88
110
|
|
|
111
|
+
export interface DirectoryHandlers {
|
|
112
|
+
onDirectory(directory: HostDirectory): void;
|
|
113
|
+
onError(message: string): void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Follows the selected daemon's saved hosts and returns the cleanup. `observe()` throws
|
|
118
|
+
* synchronously when the daemon is offline or too old, so that is reported like any other failure.
|
|
119
|
+
*/
|
|
120
|
+
export function observeDirectory(paseo: HostsApi, handlers: DirectoryHandlers): () => void {
|
|
121
|
+
try {
|
|
122
|
+
const subscription = paseo.hosts.observe();
|
|
123
|
+
const stop = subscription.subscribe({
|
|
124
|
+
snapshot: handlers.onDirectory,
|
|
125
|
+
update: (message) => {
|
|
126
|
+
const directory = directoryFromMessage(message);
|
|
127
|
+
if (directory) handlers.onDirectory(directory);
|
|
128
|
+
},
|
|
129
|
+
error: (cause) => handlers.onError(errorMessage(cause)),
|
|
130
|
+
});
|
|
131
|
+
return () => {
|
|
132
|
+
stop();
|
|
133
|
+
void subscription.release();
|
|
134
|
+
};
|
|
135
|
+
} catch (cause) {
|
|
136
|
+
handlers.onError(errorMessage(cause));
|
|
137
|
+
return () => {};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
89
141
|
export interface HostDirectoryState {
|
|
90
142
|
directory: HostDirectory | null;
|
|
91
143
|
error: string | null;
|
|
92
|
-
|
|
144
|
+
/** Subscribes again after a failure. */
|
|
145
|
+
retry(): void;
|
|
93
146
|
}
|
|
94
147
|
|
|
95
148
|
/** Live view of the selected daemon's saved hosts. Resets when the selected host changes. */
|
|
96
|
-
export function useHostDirectory(paseo:
|
|
149
|
+
export function useHostDirectory(paseo: HostsApi): HostDirectoryState {
|
|
97
150
|
const [directory, setDirectory] = useState<HostDirectory | null>(null);
|
|
98
151
|
const [error, setError] = useState<string | null>(null);
|
|
152
|
+
const [attempt, setAttempt] = useState(0);
|
|
99
153
|
|
|
100
154
|
useEffect(() => {
|
|
101
155
|
setDirectory(null);
|
|
102
156
|
setError(null);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
setDirectory(snapshot);
|
|
157
|
+
return observeDirectory(paseo, {
|
|
158
|
+
onDirectory: (next) => {
|
|
159
|
+
setDirectory(next);
|
|
107
160
|
setError(null);
|
|
108
161
|
},
|
|
109
|
-
|
|
110
|
-
const next = directoryFromMessage(message);
|
|
111
|
-
if (next) setDirectory(next);
|
|
112
|
-
},
|
|
113
|
-
error: (cause) => setError(errorMessage(cause)),
|
|
162
|
+
onError: setError,
|
|
114
163
|
});
|
|
115
|
-
|
|
116
|
-
return () => {
|
|
117
|
-
stop();
|
|
118
|
-
void subscription.release();
|
|
119
|
-
};
|
|
120
|
-
}, [paseo]);
|
|
121
|
-
|
|
122
|
-
const refresh = useCallback(async () => {
|
|
123
|
-
try {
|
|
124
|
-
setDirectory(await paseo.hosts.list());
|
|
125
|
-
setError(null);
|
|
126
|
-
} catch (cause) {
|
|
127
|
-
setError(errorMessage(cause));
|
|
128
|
-
}
|
|
129
|
-
}, [paseo]);
|
|
164
|
+
}, [paseo, attempt]);
|
|
130
165
|
|
|
131
|
-
|
|
166
|
+
const retry = useCallback(() => setAttempt((count) => count + 1), []);
|
|
167
|
+
return { directory, error, retry };
|
|
132
168
|
}
|
package/client/screen.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { HostSummary } from "@getpaseo/client";
|
|
2
1
|
import type { PluginTheme } from "@getpaseo/plugin";
|
|
3
2
|
import { type PluginScreenProps, usePaseo } from "@getpaseo/plugin/client";
|
|
4
3
|
import { Icon, Modal, ScrollView, TextInput, useToast } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import type { HostSummary } from "@getpaseo/protocol/hosts";
|
|
5
5
|
import { useMemo, useState } from "react";
|
|
6
6
|
import { Pressable, Text, type TextStyle, View, type ViewStyle } from "react-native";
|
|
7
7
|
import {
|
|
@@ -57,7 +57,7 @@ type Styles = Record<StyleKey, ViewStyle & TextStyle>;
|
|
|
57
57
|
function makeStyles(theme: PluginTheme, compact: boolean): Styles {
|
|
58
58
|
const c = theme.colors;
|
|
59
59
|
const button = {
|
|
60
|
-
minHeight: 40,
|
|
60
|
+
minHeight: compact ? 44 : 40,
|
|
61
61
|
paddingHorizontal: 14,
|
|
62
62
|
borderRadius: 10,
|
|
63
63
|
alignItems: "center" as const,
|
|
@@ -125,17 +125,20 @@ function Action({
|
|
|
125
125
|
style,
|
|
126
126
|
textStyle,
|
|
127
127
|
disabled,
|
|
128
|
+
accessibilityLabel,
|
|
128
129
|
}: {
|
|
129
130
|
label: string;
|
|
130
131
|
onPress(): void;
|
|
131
132
|
style: object;
|
|
132
133
|
textStyle: object;
|
|
133
134
|
disabled?: boolean;
|
|
135
|
+
/** Defaults to `label`; set it when several buttons share a label. */
|
|
136
|
+
accessibilityLabel?: string;
|
|
134
137
|
}) {
|
|
135
138
|
return (
|
|
136
139
|
<Pressable
|
|
137
140
|
accessibilityRole="button"
|
|
138
|
-
accessibilityLabel={label}
|
|
141
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
139
142
|
accessibilityState={{ disabled: !!disabled }}
|
|
140
143
|
disabled={disabled}
|
|
141
144
|
onPress={onPress}
|
|
@@ -189,7 +192,7 @@ function HostForm({
|
|
|
189
192
|
styles: Styles;
|
|
190
193
|
theme: PluginTheme;
|
|
191
194
|
onCancel(): void;
|
|
192
|
-
onDone(message: string):
|
|
195
|
+
onDone(message: string): void;
|
|
193
196
|
}) {
|
|
194
197
|
const paseo = usePaseo();
|
|
195
198
|
const host = target.kind === "edit" ? target.host : null;
|
|
@@ -198,7 +201,7 @@ function HostForm({
|
|
|
198
201
|
const [metadata, setMetadata] = useState(host ? formatMetadata(host.metadata) : "");
|
|
199
202
|
const [sourceKey, setSourceKey] = useState("");
|
|
200
203
|
const mismatch = host?.error?.code === "server_id_mismatch";
|
|
201
|
-
const [replaceIdentity, setReplaceIdentity] = useState(
|
|
204
|
+
const [replaceIdentity, setReplaceIdentity] = useState(false);
|
|
202
205
|
const [error, setError] = useState<string | null>(null);
|
|
203
206
|
const [pending, setPending] = useState(false);
|
|
204
207
|
|
|
@@ -217,12 +220,12 @@ function HostForm({
|
|
|
217
220
|
try {
|
|
218
221
|
if (add?.ok) {
|
|
219
222
|
await paseo.hosts.add(add.value);
|
|
220
|
-
|
|
223
|
+
onDone("Host added");
|
|
221
224
|
} else if (host && update?.ok && Object.keys(update.value).length > 0) {
|
|
222
225
|
await paseo.hosts.update(host.registrationId, update.value);
|
|
223
|
-
|
|
226
|
+
onDone("Host updated");
|
|
224
227
|
} else {
|
|
225
|
-
|
|
228
|
+
onCancel();
|
|
226
229
|
}
|
|
227
230
|
} catch (cause) {
|
|
228
231
|
setError(errorMessage(cause));
|
|
@@ -245,7 +248,7 @@ function HostForm({
|
|
|
245
248
|
host
|
|
246
249
|
? `Currently ${host.targetDescription}. Saved credentials are never shown.${
|
|
247
250
|
mismatch
|
|
248
|
-
? " This host reached a different daemon. Enter the connection again and
|
|
251
|
+
? " This host reached a different daemon. Enter the connection again and tick the box below to accept the new identity."
|
|
249
252
|
: ""
|
|
250
253
|
}`
|
|
251
254
|
: "Saved on this daemon. Unreachable hosts are kept and retried."
|
|
@@ -254,6 +257,7 @@ function HostForm({
|
|
|
254
257
|
{host && connection.trim() ? (
|
|
255
258
|
<Pressable
|
|
256
259
|
accessibilityRole="checkbox"
|
|
260
|
+
accessibilityLabel="Accept a different server identity"
|
|
257
261
|
accessibilityState={{ checked: replaceIdentity }}
|
|
258
262
|
onPress={() => setReplaceIdentity((value) => !value)}
|
|
259
263
|
style={styles.row}
|
|
@@ -295,7 +299,7 @@ function HostForm({
|
|
|
295
299
|
value={sourceKey}
|
|
296
300
|
onChangeText={setSourceKey}
|
|
297
301
|
placeholder="Optional"
|
|
298
|
-
hint="Names this registration
|
|
302
|
+
hint="Names this registration. Repeating an add with the same connection, label and metadata returns the saved entry."
|
|
299
303
|
/>
|
|
300
304
|
)}
|
|
301
305
|
{error ? (
|
|
@@ -353,7 +357,7 @@ function HostCard({
|
|
|
353
357
|
</View>
|
|
354
358
|
<Text style={styles.mono}>{host.targetDescription}</Text>
|
|
355
359
|
<Text style={styles.mono}>
|
|
356
|
-
Server {host.serverId ?? "not learned yet"} | Source {host.source.
|
|
360
|
+
Server {host.serverId ?? "not learned yet"} | Source {host.source.id}
|
|
357
361
|
</Text>
|
|
358
362
|
{host.lastOnlineAt ? (
|
|
359
363
|
<Text style={styles.muted}>Last online {new Date(host.lastOnlineAt).toLocaleString()}</Text>
|
|
@@ -380,6 +384,7 @@ function HostCard({
|
|
|
380
384
|
<View style={styles.row}>
|
|
381
385
|
<Action
|
|
382
386
|
label={host.enabled ? "Disable" : "Enable"}
|
|
387
|
+
accessibilityLabel={`${host.enabled ? "Disable" : "Enable"} ${name}`}
|
|
383
388
|
onPress={onToggle}
|
|
384
389
|
disabled={busy}
|
|
385
390
|
style={styles.button}
|
|
@@ -387,6 +392,7 @@ function HostCard({
|
|
|
387
392
|
/>
|
|
388
393
|
<Action
|
|
389
394
|
label="Edit"
|
|
395
|
+
accessibilityLabel={`Edit ${name}`}
|
|
390
396
|
onPress={onEdit}
|
|
391
397
|
disabled={busy}
|
|
392
398
|
style={styles.button}
|
|
@@ -394,6 +400,7 @@ function HostCard({
|
|
|
394
400
|
/>
|
|
395
401
|
<Action
|
|
396
402
|
label="Remove"
|
|
403
|
+
accessibilityLabel={`Remove ${name}`}
|
|
397
404
|
onPress={onRemove}
|
|
398
405
|
disabled={busy}
|
|
399
406
|
style={styles.danger}
|
|
@@ -407,7 +414,7 @@ function HostCard({
|
|
|
407
414
|
export function ConnectedHostsScreen({ theme, layout, host: selected }: PluginScreenProps) {
|
|
408
415
|
const paseo = usePaseo();
|
|
409
416
|
const toast = useToast();
|
|
410
|
-
const { directory, error,
|
|
417
|
+
const { directory, error, retry } = useHostDirectory(paseo);
|
|
411
418
|
const styles = useMemo(() => makeStyles(theme, layout.compact), [theme, layout.compact]);
|
|
412
419
|
const [form, setForm] = useState<FormTarget | null>(null);
|
|
413
420
|
const [removing, setRemoving] = useState<HostSummary | null>(null);
|
|
@@ -418,7 +425,6 @@ export function ConnectedHostsScreen({ theme, layout, host: selected }: PluginSc
|
|
|
418
425
|
try {
|
|
419
426
|
await action();
|
|
420
427
|
toast.show(done, { variant: "success" });
|
|
421
|
-
await refresh();
|
|
422
428
|
} catch (cause) {
|
|
423
429
|
toast.error(errorMessage(cause));
|
|
424
430
|
} finally {
|
|
@@ -426,10 +432,9 @@ export function ConnectedHostsScreen({ theme, layout, host: selected }: PluginSc
|
|
|
426
432
|
}
|
|
427
433
|
}
|
|
428
434
|
|
|
429
|
-
|
|
435
|
+
function finishForm(message: string) {
|
|
430
436
|
toast.show(message, { variant: "success" });
|
|
431
437
|
setForm(null);
|
|
432
|
-
await refresh();
|
|
433
438
|
}
|
|
434
439
|
|
|
435
440
|
const hosts = directory?.hosts ?? [];
|
|
@@ -456,7 +461,7 @@ export function ConnectedHostsScreen({ theme, layout, host: selected }: PluginSc
|
|
|
456
461
|
</Text>
|
|
457
462
|
<Action
|
|
458
463
|
label="Retry"
|
|
459
|
-
onPress={
|
|
464
|
+
onPress={retry}
|
|
460
465
|
style={styles.button}
|
|
461
466
|
textStyle={styles.buttonText}
|
|
462
467
|
/>
|
|
@@ -522,8 +527,9 @@ export function ConnectedHostsScreen({ theme, layout, host: selected }: PluginSc
|
|
|
522
527
|
<View style={{ gap: 12 }}>
|
|
523
528
|
<Text style={styles.body}>
|
|
524
529
|
Remove {removing?.label ?? removing?.targetDescription} from this daemon? Agents
|
|
525
|
-
already running on it keep running
|
|
526
|
-
|
|
530
|
+
already running on it keep running on their own daemon, but this daemon can no longer
|
|
531
|
+
reach them. If another process registered this host, it can add it again. Disable the
|
|
532
|
+
host instead to keep it saved and switched off.
|
|
527
533
|
</Text>
|
|
528
534
|
<View style={[styles.row, { justifyContent: "flex-end" }]}>
|
|
529
535
|
<Action
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omercnet/paseo-connected-hosts",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.1.0-next.305.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A Paseo plugin that manages the daemon's connected hosts from the UI.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,17 +42,17 @@
|
|
|
42
42
|
"typecheck": "tsc --noEmit"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@biomejs/biome": "
|
|
46
|
-
"@getpaseo/cli": "
|
|
47
|
-
"@getpaseo/client": "
|
|
48
|
-
"@getpaseo/plugin": "
|
|
49
|
-
"@getpaseo/protocol": "
|
|
50
|
-
"@tanstack/react-query": "
|
|
51
|
-
"@types/node": "
|
|
52
|
-
"@types/react": "
|
|
53
|
-
"react": "
|
|
54
|
-
"react-native": "
|
|
55
|
-
"typescript": "
|
|
56
|
-
"vitest": "
|
|
45
|
+
"@biomejs/biome": "^2.5.10",
|
|
46
|
+
"@getpaseo/cli": "0.13.0",
|
|
47
|
+
"@getpaseo/client": "0.13.0",
|
|
48
|
+
"@getpaseo/plugin": "0.13.0",
|
|
49
|
+
"@getpaseo/protocol": "0.13.0",
|
|
50
|
+
"@tanstack/react-query": "^5.102.3",
|
|
51
|
+
"@types/node": "^24.10.1",
|
|
52
|
+
"@types/react": "^19.2.18",
|
|
53
|
+
"react": "19.1.0",
|
|
54
|
+
"react-native": "0.81.5",
|
|
55
|
+
"typescript": "^7.0.2",
|
|
56
|
+
"vitest": "^5.0.0"
|
|
57
57
|
}
|
|
58
58
|
}
|