@omercnet/paseo-connected-hosts 0.0.0-stage → 0.1.0-next.303.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/LICENSE +21 -0
- package/OVERVIEW.md +17 -0
- package/README.md +33 -2
- package/client/hosts.ts +154 -0
- package/client/screen.tsx +563 -0
- package/client/sidebar.tsx +14 -0
- package/index.client.tsx +31 -0
- package/package.json +56 -4
- package/paseo-plugin.json +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Omer Cohen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/OVERVIEW.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
15
|
+
Both daemons need the connected hosts feature, and the link between them needs host multiplexing.
|
|
16
|
+
|
|
17
|
+
Requires Paseo ^0.13.0.
|
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Connected hosts
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Paseo plugin that manages the hosts saved on a daemon from the app: add, edit, disable, enable and remove, with live connection status. See [OVERVIEW.md](OVERVIEW.md) for behavior.
|
|
4
|
+
|
|
5
|
+
## Screenshots
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
It is client only. Everything goes through `usePaseo().hosts` (`list`, `observe`, `add`, `update`, `remove`), so there is no server entry and no plugin RPC.
|
|
12
|
+
|
|
13
|
+
## Develop
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bun install
|
|
17
|
+
cd connected-hosts
|
|
18
|
+
npm run check && npm run typecheck && npm test
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Verify against an isolated daemon, never the default one:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
.agents/skills/deliver/scripts/dev-daemon.sh up connected-hosts
|
|
25
|
+
.agents/skills/deliver/scripts/dev-daemon.sh down connected-hosts
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Layout
|
|
29
|
+
|
|
30
|
+
- `index.client.tsx`: registers the screen, the sidebar row and the Command Center action.
|
|
31
|
+
- `client/hosts.ts`: metadata parsing, add and update builders (an update sends only changed fields), and the live directory hook.
|
|
32
|
+
- `client/screen.tsx`: host cards, the add and edit form, and the remove confirmation.
|
|
33
|
+
- `client/sidebar.tsx`: the sidebar row.
|
|
34
|
+
- `tests/`: unit tests for the builders, the directory message handling and the entry.
|
package/client/hosts.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { HostAdd, HostDirectory, HostSummary, HostUpdate, PaseoApi } from "@getpaseo/client";
|
|
2
|
+
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
export type Built<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
6
|
+
|
|
7
|
+
/** Prefers the daemon's `userMessage` over the transport message, which appends request details. */
|
|
8
|
+
export function errorMessage(error: unknown): string {
|
|
9
|
+
if (error instanceof Error) {
|
|
10
|
+
const { userMessage } = error as Error & { userMessage?: unknown };
|
|
11
|
+
return typeof userMessage === "string" && userMessage ? userMessage : error.message;
|
|
12
|
+
}
|
|
13
|
+
return String(error);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One `key=value` per line. Blank lines are skipped; the value may contain `=`. */
|
|
17
|
+
export function parseMetadata(text: string): Built<Record<string, string>> {
|
|
18
|
+
const metadata: Record<string, string> = {};
|
|
19
|
+
for (const [index, raw] of text.split("\n").entries()) {
|
|
20
|
+
const line = raw.trim();
|
|
21
|
+
if (!line) continue;
|
|
22
|
+
const at = line.indexOf("=");
|
|
23
|
+
const key = at < 0 ? "" : line.slice(0, at).trim();
|
|
24
|
+
if (!key) return { ok: false, error: `Metadata line ${index + 1} needs the form key=value.` };
|
|
25
|
+
metadata[key] = line.slice(at + 1).trim();
|
|
26
|
+
}
|
|
27
|
+
return { ok: true, value: metadata };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatMetadata(metadata: Record<string, string>): string {
|
|
31
|
+
return Object.keys(metadata)
|
|
32
|
+
.sort()
|
|
33
|
+
.map((key) => `${key}=${metadata[key]}`)
|
|
34
|
+
.join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AddForm {
|
|
38
|
+
connection: string;
|
|
39
|
+
label: string;
|
|
40
|
+
metadata: string;
|
|
41
|
+
sourceKey: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildAddInput(form: AddForm): Built<HostAdd> {
|
|
45
|
+
const connection = form.connection.trim();
|
|
46
|
+
if (!connection)
|
|
47
|
+
return { ok: false, error: "Enter a connection, such as host:6767 or ssh://user@host." };
|
|
48
|
+
const metadata = parseMetadata(form.metadata);
|
|
49
|
+
if (!metadata.ok) return metadata;
|
|
50
|
+
const input: HostAdd = { connection };
|
|
51
|
+
const label = form.label.trim();
|
|
52
|
+
if (label) input.label = label;
|
|
53
|
+
if (Object.keys(metadata.value).length > 0) input.metadata = metadata.value;
|
|
54
|
+
const sourceKey = form.sourceKey.trim();
|
|
55
|
+
if (sourceKey) input.sourceKey = sourceKey;
|
|
56
|
+
return { ok: true, value: input };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface EditForm {
|
|
60
|
+
label: string;
|
|
61
|
+
metadata: string;
|
|
62
|
+
/** Write-only: blank leaves the saved connection untouched. */
|
|
63
|
+
connection: string;
|
|
64
|
+
replaceIdentity: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Sends only what the user changed. A field still equal to its prefilled text is never parsed or
|
|
69
|
+
* sent, so values the form cannot round-trip (padding, `=` in a key) are not rewritten and a
|
|
70
|
+
* label-only edit never asks for metadata permission.
|
|
71
|
+
*/
|
|
72
|
+
export function buildUpdatePatch(host: HostSummary, form: EditForm): Built<HostUpdate> {
|
|
73
|
+
const patch: HostUpdate = {};
|
|
74
|
+
if (form.label !== (host.label ?? "")) {
|
|
75
|
+
const label = form.label.trim() || null;
|
|
76
|
+
if (label !== host.label) patch.label = label;
|
|
77
|
+
}
|
|
78
|
+
if (form.metadata !== formatMetadata(host.metadata)) {
|
|
79
|
+
const metadata = parseMetadata(form.metadata);
|
|
80
|
+
if (!metadata.ok) return metadata;
|
|
81
|
+
if (formatMetadata(metadata.value) !== formatMetadata(host.metadata)) {
|
|
82
|
+
patch.metadata = metadata.value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const connection = form.connection.trim();
|
|
86
|
+
if (connection) {
|
|
87
|
+
patch.connection = connection;
|
|
88
|
+
if (form.replaceIdentity) patch.replaceIdentity = true;
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, value: patch };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function directoryFromMessage(message: SessionOutboundMessage): HostDirectory | null {
|
|
94
|
+
return message.type === "hosts.changed" ? message.payload.directory : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface DirectoryHandlers {
|
|
98
|
+
onDirectory(directory: HostDirectory): void;
|
|
99
|
+
onError(message: string): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Follows the selected daemon's saved hosts and returns the cleanup. `observe()` throws
|
|
104
|
+
* synchronously when the daemon is offline or too old, so that is reported like any other failure.
|
|
105
|
+
*/
|
|
106
|
+
export function observeDirectory(paseo: PaseoApi, handlers: DirectoryHandlers): () => void {
|
|
107
|
+
try {
|
|
108
|
+
const subscription = paseo.hosts.observe();
|
|
109
|
+
const stop = subscription.subscribe({
|
|
110
|
+
snapshot: handlers.onDirectory,
|
|
111
|
+
update: (message) => {
|
|
112
|
+
const directory = directoryFromMessage(message);
|
|
113
|
+
if (directory) handlers.onDirectory(directory);
|
|
114
|
+
},
|
|
115
|
+
error: (cause) => handlers.onError(errorMessage(cause)),
|
|
116
|
+
});
|
|
117
|
+
return () => {
|
|
118
|
+
stop();
|
|
119
|
+
void subscription.release();
|
|
120
|
+
};
|
|
121
|
+
} catch (cause) {
|
|
122
|
+
handlers.onError(errorMessage(cause));
|
|
123
|
+
return () => {};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface HostDirectoryState {
|
|
128
|
+
directory: HostDirectory | null;
|
|
129
|
+
error: string | null;
|
|
130
|
+
/** Subscribes again after a failure. */
|
|
131
|
+
retry(): void;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Live view of the selected daemon's saved hosts. Resets when the selected host changes. */
|
|
135
|
+
export function useHostDirectory(paseo: PaseoApi): HostDirectoryState {
|
|
136
|
+
const [directory, setDirectory] = useState<HostDirectory | null>(null);
|
|
137
|
+
const [error, setError] = useState<string | null>(null);
|
|
138
|
+
const [attempt, setAttempt] = useState(0);
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
setDirectory(null);
|
|
142
|
+
setError(null);
|
|
143
|
+
return observeDirectory(paseo, {
|
|
144
|
+
onDirectory: (next) => {
|
|
145
|
+
setDirectory(next);
|
|
146
|
+
setError(null);
|
|
147
|
+
},
|
|
148
|
+
onError: setError,
|
|
149
|
+
});
|
|
150
|
+
}, [paseo, attempt]);
|
|
151
|
+
|
|
152
|
+
const retry = useCallback(() => setAttempt((count) => count + 1), []);
|
|
153
|
+
return { directory, error, retry };
|
|
154
|
+
}
|
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import type { HostSummary } from "@getpaseo/client";
|
|
2
|
+
import type { PluginTheme } from "@getpaseo/plugin";
|
|
3
|
+
import { type PluginScreenProps, usePaseo } from "@getpaseo/plugin/client";
|
|
4
|
+
import { Icon, Modal, ScrollView, TextInput, useToast } from "@getpaseo/plugin/client/ui";
|
|
5
|
+
import { useMemo, useState } from "react";
|
|
6
|
+
import { Pressable, Text, type TextStyle, View, type ViewStyle } from "react-native";
|
|
7
|
+
import {
|
|
8
|
+
buildAddInput,
|
|
9
|
+
buildUpdatePatch,
|
|
10
|
+
errorMessage,
|
|
11
|
+
formatMetadata,
|
|
12
|
+
useHostDirectory,
|
|
13
|
+
} from "./hosts";
|
|
14
|
+
|
|
15
|
+
type FormTarget = { kind: "add" } | { kind: "edit"; host: HostSummary };
|
|
16
|
+
|
|
17
|
+
const STATUS_LABEL: Record<HostSummary["status"], string> = {
|
|
18
|
+
online: "Online",
|
|
19
|
+
connecting: "Connecting",
|
|
20
|
+
offline: "Offline, retrying",
|
|
21
|
+
error: "Error",
|
|
22
|
+
disabled: "Disabled",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function statusColor(theme: PluginTheme, status: HostSummary["status"]) {
|
|
26
|
+
const colors = theme.colors;
|
|
27
|
+
if (status === "online") return colors.statusSuccess;
|
|
28
|
+
if (status === "error") return colors.statusDanger;
|
|
29
|
+
if (status === "disabled") return colors.foregroundMuted;
|
|
30
|
+
return colors.statusWarning;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type StyleKey =
|
|
34
|
+
| "screen"
|
|
35
|
+
| "content"
|
|
36
|
+
| "title"
|
|
37
|
+
| "muted"
|
|
38
|
+
| "card"
|
|
39
|
+
| "row"
|
|
40
|
+
| "name"
|
|
41
|
+
| "body"
|
|
42
|
+
| "mono"
|
|
43
|
+
| "error"
|
|
44
|
+
| "button"
|
|
45
|
+
| "primary"
|
|
46
|
+
| "danger"
|
|
47
|
+
| "buttonText"
|
|
48
|
+
| "primaryText"
|
|
49
|
+
| "dangerText"
|
|
50
|
+
| "disabled"
|
|
51
|
+
| "input"
|
|
52
|
+
| "chip"
|
|
53
|
+
| "field"
|
|
54
|
+
| "label";
|
|
55
|
+
type Styles = Record<StyleKey, ViewStyle & TextStyle>;
|
|
56
|
+
|
|
57
|
+
function makeStyles(theme: PluginTheme, compact: boolean): Styles {
|
|
58
|
+
const c = theme.colors;
|
|
59
|
+
const button = {
|
|
60
|
+
minHeight: compact ? 44 : 40,
|
|
61
|
+
paddingHorizontal: 14,
|
|
62
|
+
borderRadius: 10,
|
|
63
|
+
alignItems: "center" as const,
|
|
64
|
+
justifyContent: "center" as const,
|
|
65
|
+
borderWidth: 1,
|
|
66
|
+
borderColor: c.border,
|
|
67
|
+
backgroundColor: c.surface2,
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
screen: { flex: 1, backgroundColor: c.surface0 },
|
|
71
|
+
content: { padding: compact ? 16 : 24, gap: 12, maxWidth: 880, width: "100%" as const },
|
|
72
|
+
title: { color: c.foreground, fontSize: compact ? 20 : 24, fontWeight: "600" as const },
|
|
73
|
+
muted: { color: c.foregroundMuted, fontSize: 13, lineHeight: 18 },
|
|
74
|
+
card: {
|
|
75
|
+
gap: 8,
|
|
76
|
+
padding: 14,
|
|
77
|
+
borderRadius: 12,
|
|
78
|
+
borderWidth: 1,
|
|
79
|
+
borderColor: c.border,
|
|
80
|
+
backgroundColor: c.surface1,
|
|
81
|
+
},
|
|
82
|
+
row: {
|
|
83
|
+
flexDirection: "row" as const,
|
|
84
|
+
alignItems: "center" as const,
|
|
85
|
+
gap: 8,
|
|
86
|
+
flexWrap: "wrap" as const,
|
|
87
|
+
},
|
|
88
|
+
name: { color: c.foreground, fontSize: 16, fontWeight: "600" as const, flexShrink: 1 },
|
|
89
|
+
body: { color: c.foreground, fontSize: 13 },
|
|
90
|
+
mono: { color: c.foregroundMuted, fontSize: 12 },
|
|
91
|
+
error: { color: c.statusDanger, fontSize: 13 },
|
|
92
|
+
button,
|
|
93
|
+
primary: { ...button, backgroundColor: c.accent, borderColor: c.accent },
|
|
94
|
+
danger: { ...button, borderColor: c.statusDanger },
|
|
95
|
+
buttonText: { color: c.foreground, fontSize: 14 },
|
|
96
|
+
primaryText: { color: c.accentForeground, fontSize: 14, fontWeight: "600" as const },
|
|
97
|
+
dangerText: { color: c.statusDanger, fontSize: 14 },
|
|
98
|
+
disabled: { opacity: 0.5 },
|
|
99
|
+
input: {
|
|
100
|
+
minHeight: 40,
|
|
101
|
+
borderRadius: 10,
|
|
102
|
+
borderWidth: 1,
|
|
103
|
+
borderColor: c.border,
|
|
104
|
+
backgroundColor: c.surface0,
|
|
105
|
+
color: c.foreground,
|
|
106
|
+
paddingHorizontal: 12,
|
|
107
|
+
paddingVertical: 8,
|
|
108
|
+
fontSize: 14,
|
|
109
|
+
},
|
|
110
|
+
chip: {
|
|
111
|
+
paddingHorizontal: 8,
|
|
112
|
+
paddingVertical: 2,
|
|
113
|
+
borderRadius: 999,
|
|
114
|
+
borderWidth: 1,
|
|
115
|
+
borderColor: c.border,
|
|
116
|
+
},
|
|
117
|
+
field: { gap: 4 },
|
|
118
|
+
label: { color: c.foreground, fontSize: 13, fontWeight: "600" as const },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function Action({
|
|
123
|
+
label,
|
|
124
|
+
onPress,
|
|
125
|
+
style,
|
|
126
|
+
textStyle,
|
|
127
|
+
disabled,
|
|
128
|
+
accessibilityLabel,
|
|
129
|
+
}: {
|
|
130
|
+
label: string;
|
|
131
|
+
onPress(): void;
|
|
132
|
+
style: object;
|
|
133
|
+
textStyle: object;
|
|
134
|
+
disabled?: boolean;
|
|
135
|
+
/** Defaults to `label`; set it when several buttons share a label. */
|
|
136
|
+
accessibilityLabel?: string;
|
|
137
|
+
}) {
|
|
138
|
+
return (
|
|
139
|
+
<Pressable
|
|
140
|
+
accessibilityRole="button"
|
|
141
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
142
|
+
accessibilityState={{ disabled: !!disabled }}
|
|
143
|
+
disabled={disabled}
|
|
144
|
+
onPress={onPress}
|
|
145
|
+
style={[style, disabled && { opacity: 0.5 }]}
|
|
146
|
+
>
|
|
147
|
+
<Text style={textStyle}>{label}</Text>
|
|
148
|
+
</Pressable>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function Field({
|
|
153
|
+
styles,
|
|
154
|
+
theme,
|
|
155
|
+
label,
|
|
156
|
+
hint,
|
|
157
|
+
...input
|
|
158
|
+
}: {
|
|
159
|
+
styles: Styles;
|
|
160
|
+
theme: PluginTheme;
|
|
161
|
+
label: string;
|
|
162
|
+
hint?: string;
|
|
163
|
+
value: string;
|
|
164
|
+
onChangeText(text: string): void;
|
|
165
|
+
placeholder?: string;
|
|
166
|
+
multiline?: boolean;
|
|
167
|
+
}) {
|
|
168
|
+
return (
|
|
169
|
+
<View style={styles.field}>
|
|
170
|
+
<Text style={styles.label}>{label}</Text>
|
|
171
|
+
<TextInput
|
|
172
|
+
accessibilityLabel={label}
|
|
173
|
+
autoCapitalize="none"
|
|
174
|
+
autoCorrect={false}
|
|
175
|
+
placeholderTextColor={theme.colors.foregroundMuted}
|
|
176
|
+
style={[styles.input, input.multiline && { minHeight: 72, textAlignVertical: "top" }]}
|
|
177
|
+
{...input}
|
|
178
|
+
/>
|
|
179
|
+
{hint ? <Text style={styles.muted}>{hint}</Text> : null}
|
|
180
|
+
</View>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function HostForm({
|
|
185
|
+
target,
|
|
186
|
+
styles,
|
|
187
|
+
theme,
|
|
188
|
+
onCancel,
|
|
189
|
+
onDone,
|
|
190
|
+
}: {
|
|
191
|
+
target: FormTarget;
|
|
192
|
+
styles: Styles;
|
|
193
|
+
theme: PluginTheme;
|
|
194
|
+
onCancel(): void;
|
|
195
|
+
onDone(message: string): void;
|
|
196
|
+
}) {
|
|
197
|
+
const paseo = usePaseo();
|
|
198
|
+
const host = target.kind === "edit" ? target.host : null;
|
|
199
|
+
const [connection, setConnection] = useState("");
|
|
200
|
+
const [label, setLabel] = useState(host?.label ?? "");
|
|
201
|
+
const [metadata, setMetadata] = useState(host ? formatMetadata(host.metadata) : "");
|
|
202
|
+
const [sourceKey, setSourceKey] = useState("");
|
|
203
|
+
const mismatch = host?.error?.code === "server_id_mismatch";
|
|
204
|
+
const [replaceIdentity, setReplaceIdentity] = useState(false);
|
|
205
|
+
const [error, setError] = useState<string | null>(null);
|
|
206
|
+
const [pending, setPending] = useState(false);
|
|
207
|
+
|
|
208
|
+
async function submit() {
|
|
209
|
+
const add = host ? null : buildAddInput({ connection, label, metadata, sourceKey });
|
|
210
|
+
const update = host
|
|
211
|
+
? buildUpdatePatch(host, { label, metadata, connection, replaceIdentity })
|
|
212
|
+
: null;
|
|
213
|
+
const invalid = [add, update].find((built) => built && !built.ok);
|
|
214
|
+
if (invalid && !invalid.ok) {
|
|
215
|
+
setError(invalid.error);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
setError(null);
|
|
219
|
+
setPending(true);
|
|
220
|
+
try {
|
|
221
|
+
if (add?.ok) {
|
|
222
|
+
await paseo.hosts.add(add.value);
|
|
223
|
+
onDone("Host added");
|
|
224
|
+
} else if (host && update?.ok && Object.keys(update.value).length > 0) {
|
|
225
|
+
await paseo.hosts.update(host.registrationId, update.value);
|
|
226
|
+
onDone("Host updated");
|
|
227
|
+
} else {
|
|
228
|
+
onCancel();
|
|
229
|
+
}
|
|
230
|
+
} catch (cause) {
|
|
231
|
+
setError(errorMessage(cause));
|
|
232
|
+
setPending(false);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<View style={{ gap: 12 }}>
|
|
238
|
+
<Field
|
|
239
|
+
styles={styles}
|
|
240
|
+
theme={theme}
|
|
241
|
+
label={host ? "New connection" : "Connection"}
|
|
242
|
+
value={connection}
|
|
243
|
+
onChangeText={setConnection}
|
|
244
|
+
placeholder={
|
|
245
|
+
host ? "Leave blank to keep the saved connection" : "host:6767 or ssh://user@host"
|
|
246
|
+
}
|
|
247
|
+
hint={
|
|
248
|
+
host
|
|
249
|
+
? `Currently ${host.targetDescription}. Saved credentials are never shown.${
|
|
250
|
+
mismatch
|
|
251
|
+
? " This host reached a different daemon. Enter the connection again and tick the box below to accept the new identity."
|
|
252
|
+
: ""
|
|
253
|
+
}`
|
|
254
|
+
: "Saved on this daemon. Unreachable hosts are kept and retried."
|
|
255
|
+
}
|
|
256
|
+
/>
|
|
257
|
+
{host && connection.trim() ? (
|
|
258
|
+
<Pressable
|
|
259
|
+
accessibilityRole="checkbox"
|
|
260
|
+
accessibilityLabel="Accept a different server identity"
|
|
261
|
+
accessibilityState={{ checked: replaceIdentity }}
|
|
262
|
+
onPress={() => setReplaceIdentity((value) => !value)}
|
|
263
|
+
style={styles.row}
|
|
264
|
+
>
|
|
265
|
+
<Icon
|
|
266
|
+
name={replaceIdentity ? "SquareCheck" : "Square"}
|
|
267
|
+
size={18}
|
|
268
|
+
color={theme.colors.foreground}
|
|
269
|
+
/>
|
|
270
|
+
<Text style={[styles.body, { flex: 1 }]}>
|
|
271
|
+
Accept a different server identity. Required when the new connection reaches another
|
|
272
|
+
daemon than the one pinned for this host.
|
|
273
|
+
</Text>
|
|
274
|
+
</Pressable>
|
|
275
|
+
) : null}
|
|
276
|
+
<Field
|
|
277
|
+
styles={styles}
|
|
278
|
+
theme={theme}
|
|
279
|
+
label="Label"
|
|
280
|
+
value={label}
|
|
281
|
+
onChangeText={setLabel}
|
|
282
|
+
placeholder="Build box"
|
|
283
|
+
/>
|
|
284
|
+
<Field
|
|
285
|
+
styles={styles}
|
|
286
|
+
theme={theme}
|
|
287
|
+
label="Metadata"
|
|
288
|
+
value={metadata}
|
|
289
|
+
onChangeText={setMetadata}
|
|
290
|
+
placeholder="team=infra"
|
|
291
|
+
hint="One key=value per line. Saving replaces the whole map."
|
|
292
|
+
multiline
|
|
293
|
+
/>
|
|
294
|
+
{host ? null : (
|
|
295
|
+
<Field
|
|
296
|
+
styles={styles}
|
|
297
|
+
theme={theme}
|
|
298
|
+
label="Source key"
|
|
299
|
+
value={sourceKey}
|
|
300
|
+
onChangeText={setSourceKey}
|
|
301
|
+
placeholder="Optional"
|
|
302
|
+
hint="Names this registration. Repeating an add with the same connection, label and metadata returns the saved entry."
|
|
303
|
+
/>
|
|
304
|
+
)}
|
|
305
|
+
{error ? (
|
|
306
|
+
<Text accessibilityRole="alert" style={styles.error}>
|
|
307
|
+
{error}
|
|
308
|
+
</Text>
|
|
309
|
+
) : null}
|
|
310
|
+
<View style={[styles.row, { justifyContent: "flex-end" }]}>
|
|
311
|
+
<Action
|
|
312
|
+
label="Cancel"
|
|
313
|
+
onPress={onCancel}
|
|
314
|
+
disabled={pending}
|
|
315
|
+
style={styles.button}
|
|
316
|
+
textStyle={styles.buttonText}
|
|
317
|
+
/>
|
|
318
|
+
<Action
|
|
319
|
+
label={pending ? "Saving..." : host ? "Save changes" : "Add host"}
|
|
320
|
+
onPress={submit}
|
|
321
|
+
disabled={pending}
|
|
322
|
+
style={styles.primary}
|
|
323
|
+
textStyle={styles.primaryText}
|
|
324
|
+
/>
|
|
325
|
+
</View>
|
|
326
|
+
</View>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function HostCard({
|
|
331
|
+
host,
|
|
332
|
+
styles,
|
|
333
|
+
theme,
|
|
334
|
+
busy,
|
|
335
|
+
onToggle,
|
|
336
|
+
onEdit,
|
|
337
|
+
onRemove,
|
|
338
|
+
}: {
|
|
339
|
+
host: HostSummary;
|
|
340
|
+
styles: Styles;
|
|
341
|
+
theme: PluginTheme;
|
|
342
|
+
busy: boolean;
|
|
343
|
+
onToggle(): void;
|
|
344
|
+
onEdit(): void;
|
|
345
|
+
onRemove(): void;
|
|
346
|
+
}) {
|
|
347
|
+
const name = host.label ?? host.targetDescription;
|
|
348
|
+
const color = statusColor(theme, host.status);
|
|
349
|
+
const metadata = Object.entries(host.metadata);
|
|
350
|
+
return (
|
|
351
|
+
<View style={styles.card} accessibilityLabel={`Connected host ${name}`}>
|
|
352
|
+
<View style={styles.row}>
|
|
353
|
+
<Text style={styles.name}>{name}</Text>
|
|
354
|
+
<View style={[styles.chip, { borderColor: color }]}>
|
|
355
|
+
<Text style={{ color, fontSize: 12 }}>{STATUS_LABEL[host.status]}</Text>
|
|
356
|
+
</View>
|
|
357
|
+
</View>
|
|
358
|
+
<Text style={styles.mono}>{host.targetDescription}</Text>
|
|
359
|
+
<Text style={styles.mono}>
|
|
360
|
+
Server {host.serverId ?? "not learned yet"} | Source {host.source.id}
|
|
361
|
+
</Text>
|
|
362
|
+
{host.lastOnlineAt ? (
|
|
363
|
+
<Text style={styles.muted}>Last online {new Date(host.lastOnlineAt).toLocaleString()}</Text>
|
|
364
|
+
) : null}
|
|
365
|
+
{host.nextRetryAt && host.status !== "online" ? (
|
|
366
|
+
<Text style={styles.muted}>Next retry {new Date(host.nextRetryAt).toLocaleString()}</Text>
|
|
367
|
+
) : null}
|
|
368
|
+
{host.error ? (
|
|
369
|
+
<Text style={styles.error}>
|
|
370
|
+
{host.error.message} ({host.error.code})
|
|
371
|
+
</Text>
|
|
372
|
+
) : null}
|
|
373
|
+
{metadata.length > 0 ? (
|
|
374
|
+
<View style={styles.row}>
|
|
375
|
+
{metadata.map(([key, value]) => (
|
|
376
|
+
<View key={key} style={styles.chip}>
|
|
377
|
+
<Text style={styles.mono}>
|
|
378
|
+
{key}={value}
|
|
379
|
+
</Text>
|
|
380
|
+
</View>
|
|
381
|
+
))}
|
|
382
|
+
</View>
|
|
383
|
+
) : null}
|
|
384
|
+
<View style={styles.row}>
|
|
385
|
+
<Action
|
|
386
|
+
label={host.enabled ? "Disable" : "Enable"}
|
|
387
|
+
accessibilityLabel={`${host.enabled ? "Disable" : "Enable"} ${name}`}
|
|
388
|
+
onPress={onToggle}
|
|
389
|
+
disabled={busy}
|
|
390
|
+
style={styles.button}
|
|
391
|
+
textStyle={styles.buttonText}
|
|
392
|
+
/>
|
|
393
|
+
<Action
|
|
394
|
+
label="Edit"
|
|
395
|
+
accessibilityLabel={`Edit ${name}`}
|
|
396
|
+
onPress={onEdit}
|
|
397
|
+
disabled={busy}
|
|
398
|
+
style={styles.button}
|
|
399
|
+
textStyle={styles.buttonText}
|
|
400
|
+
/>
|
|
401
|
+
<Action
|
|
402
|
+
label="Remove"
|
|
403
|
+
accessibilityLabel={`Remove ${name}`}
|
|
404
|
+
onPress={onRemove}
|
|
405
|
+
disabled={busy}
|
|
406
|
+
style={styles.danger}
|
|
407
|
+
textStyle={styles.dangerText}
|
|
408
|
+
/>
|
|
409
|
+
</View>
|
|
410
|
+
</View>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function ConnectedHostsScreen({ theme, layout, host: selected }: PluginScreenProps) {
|
|
415
|
+
const paseo = usePaseo();
|
|
416
|
+
const toast = useToast();
|
|
417
|
+
const { directory, error, retry } = useHostDirectory(paseo);
|
|
418
|
+
const styles = useMemo(() => makeStyles(theme, layout.compact), [theme, layout.compact]);
|
|
419
|
+
const [form, setForm] = useState<FormTarget | null>(null);
|
|
420
|
+
const [removing, setRemoving] = useState<HostSummary | null>(null);
|
|
421
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
422
|
+
|
|
423
|
+
async function run(registrationId: string, action: () => Promise<unknown>, done: string) {
|
|
424
|
+
setBusyId(registrationId);
|
|
425
|
+
try {
|
|
426
|
+
await action();
|
|
427
|
+
toast.show(done, { variant: "success" });
|
|
428
|
+
} catch (cause) {
|
|
429
|
+
toast.error(errorMessage(cause));
|
|
430
|
+
} finally {
|
|
431
|
+
setBusyId(null);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function finishForm(message: string) {
|
|
436
|
+
toast.show(message, { variant: "success" });
|
|
437
|
+
setForm(null);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const hosts = directory?.hosts ?? [];
|
|
441
|
+
return (
|
|
442
|
+
<ScrollView style={styles.screen} contentContainerStyle={styles.content}>
|
|
443
|
+
<View style={styles.row}>
|
|
444
|
+
<Text style={[styles.title, { flex: 1 }]}>Connected hosts</Text>
|
|
445
|
+
<Action
|
|
446
|
+
label="Add host"
|
|
447
|
+
onPress={() => setForm({ kind: "add" })}
|
|
448
|
+
disabled={!directory}
|
|
449
|
+
style={styles.primary}
|
|
450
|
+
textStyle={styles.primaryText}
|
|
451
|
+
/>
|
|
452
|
+
</View>
|
|
453
|
+
<Text style={styles.muted}>
|
|
454
|
+
Hosts saved on {directory?.self.label ?? selected.label}. Agents here can delegate to them.
|
|
455
|
+
This list is separate from the connections saved in the app.
|
|
456
|
+
</Text>
|
|
457
|
+
{error ? (
|
|
458
|
+
<View style={styles.card}>
|
|
459
|
+
<Text accessibilityRole="alert" style={styles.error}>
|
|
460
|
+
{error}
|
|
461
|
+
</Text>
|
|
462
|
+
<Action
|
|
463
|
+
label="Retry"
|
|
464
|
+
onPress={retry}
|
|
465
|
+
style={styles.button}
|
|
466
|
+
textStyle={styles.buttonText}
|
|
467
|
+
/>
|
|
468
|
+
</View>
|
|
469
|
+
) : null}
|
|
470
|
+
{!directory && !error ? <Text style={styles.muted}>Loading connected hosts...</Text> : null}
|
|
471
|
+
{directory && hosts.length === 0 ? (
|
|
472
|
+
<View style={styles.card}>
|
|
473
|
+
<Text style={styles.name}>No connected hosts yet</Text>
|
|
474
|
+
<Text style={styles.muted}>
|
|
475
|
+
Add another daemon to start agents and workspaces on it from this one.
|
|
476
|
+
</Text>
|
|
477
|
+
</View>
|
|
478
|
+
) : null}
|
|
479
|
+
{hosts.map((host) => (
|
|
480
|
+
<HostCard
|
|
481
|
+
key={host.registrationId}
|
|
482
|
+
host={host}
|
|
483
|
+
styles={styles}
|
|
484
|
+
theme={theme}
|
|
485
|
+
busy={busyId === host.registrationId}
|
|
486
|
+
onToggle={() =>
|
|
487
|
+
run(
|
|
488
|
+
host.registrationId,
|
|
489
|
+
() => paseo.hosts.update(host.registrationId, { enabled: !host.enabled }),
|
|
490
|
+
host.enabled ? "Host disabled" : "Host enabled",
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
onEdit={() => setForm({ kind: "edit", host })}
|
|
494
|
+
onRemove={() => setRemoving(host)}
|
|
495
|
+
/>
|
|
496
|
+
))}
|
|
497
|
+
|
|
498
|
+
<Modal
|
|
499
|
+
title={form?.kind === "edit" ? "Edit host" : "Add host"}
|
|
500
|
+
open={form !== null}
|
|
501
|
+
onOpenChange={(open) => {
|
|
502
|
+
if (!open) setForm(null);
|
|
503
|
+
}}
|
|
504
|
+
>
|
|
505
|
+
<Modal.Content>
|
|
506
|
+
{form ? (
|
|
507
|
+
<HostForm
|
|
508
|
+
key={form.kind === "edit" ? form.host.registrationId : "add"}
|
|
509
|
+
target={form}
|
|
510
|
+
styles={styles}
|
|
511
|
+
theme={theme}
|
|
512
|
+
onCancel={() => setForm(null)}
|
|
513
|
+
onDone={finishForm}
|
|
514
|
+
/>
|
|
515
|
+
) : null}
|
|
516
|
+
</Modal.Content>
|
|
517
|
+
</Modal>
|
|
518
|
+
|
|
519
|
+
<Modal
|
|
520
|
+
title="Remove host"
|
|
521
|
+
open={removing !== null}
|
|
522
|
+
onOpenChange={(open) => {
|
|
523
|
+
if (!open) setRemoving(null);
|
|
524
|
+
}}
|
|
525
|
+
>
|
|
526
|
+
<Modal.Content>
|
|
527
|
+
<View style={{ gap: 12 }}>
|
|
528
|
+
<Text style={styles.body}>
|
|
529
|
+
Remove {removing?.label ?? removing?.targetDescription} from this daemon? Agents
|
|
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.
|
|
533
|
+
</Text>
|
|
534
|
+
<View style={[styles.row, { justifyContent: "flex-end" }]}>
|
|
535
|
+
<Action
|
|
536
|
+
label="Cancel"
|
|
537
|
+
onPress={() => setRemoving(null)}
|
|
538
|
+
style={styles.button}
|
|
539
|
+
textStyle={styles.buttonText}
|
|
540
|
+
/>
|
|
541
|
+
<Action
|
|
542
|
+
label="Remove host"
|
|
543
|
+
onPress={() => {
|
|
544
|
+
const target = removing;
|
|
545
|
+
setRemoving(null);
|
|
546
|
+
if (target) {
|
|
547
|
+
void run(
|
|
548
|
+
target.registrationId,
|
|
549
|
+
() => paseo.hosts.remove(target.registrationId),
|
|
550
|
+
"Host removed",
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
}}
|
|
554
|
+
style={styles.danger}
|
|
555
|
+
textStyle={styles.dangerText}
|
|
556
|
+
/>
|
|
557
|
+
</View>
|
|
558
|
+
</View>
|
|
559
|
+
</Modal.Content>
|
|
560
|
+
</Modal>
|
|
561
|
+
</ScrollView>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
|
3
|
+
|
|
4
|
+
export const SCREEN_ID = "connected-hosts";
|
|
5
|
+
|
|
6
|
+
export function ConnectedHostsSidebarItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
|
|
7
|
+
return (
|
|
8
|
+
<SidebarRow
|
|
9
|
+
icon="Network"
|
|
10
|
+
active={currentScreen?.screenId === SCREEN_ID}
|
|
11
|
+
onPress={() => openScreen({ screenId: SCREEN_ID })}
|
|
12
|
+
/>
|
|
13
|
+
);
|
|
14
|
+
}
|
package/index.client.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import { ConnectedHostsScreen } from "./client/screen";
|
|
3
|
+
import { ConnectedHostsSidebarItem, SCREEN_ID } from "./client/sidebar";
|
|
4
|
+
|
|
5
|
+
export default function contribute(client: PluginClientContext) {
|
|
6
|
+
const removeScreen = client.addScreen({
|
|
7
|
+
id: SCREEN_ID,
|
|
8
|
+
title: "Connected hosts",
|
|
9
|
+
Component: ConnectedHostsScreen,
|
|
10
|
+
});
|
|
11
|
+
const removeSidebarItem = client.addSidebarHeaderItem({
|
|
12
|
+
id: SCREEN_ID,
|
|
13
|
+
title: "Connected hosts",
|
|
14
|
+
Component: ConnectedHostsSidebarItem,
|
|
15
|
+
});
|
|
16
|
+
const removeCommand = client.addCommandCenterItem({
|
|
17
|
+
id: "manage-connected-hosts",
|
|
18
|
+
title: "Manage connected hosts",
|
|
19
|
+
icon: "Network",
|
|
20
|
+
keywords: ["hosts", "daemon", "remote", "connect", "ssh"],
|
|
21
|
+
context: "global",
|
|
22
|
+
onSelect({ openScreen }) {
|
|
23
|
+
openScreen({ screenId: SCREEN_ID });
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
return () => {
|
|
27
|
+
removeCommand();
|
|
28
|
+
removeSidebarItem();
|
|
29
|
+
removeScreen();
|
|
30
|
+
};
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omercnet/paseo-connected-hosts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-next.303.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A Paseo plugin that manages the daemon's connected hosts from the UI.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Omer Cohen",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/omercnet/paseo-plugins.git",
|
|
11
|
+
"directory": "connected-hosts"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/omercnet/paseo-plugins/tree/main/connected-hosts#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/omercnet/paseo-plugins/issues"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"packageManager": "bun@1.4.3",
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=24"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"paseo",
|
|
26
|
+
"paseo-plugin",
|
|
27
|
+
"connected-hosts",
|
|
28
|
+
"hosts"
|
|
29
|
+
],
|
|
30
|
+
"files": [
|
|
31
|
+
"index.client.tsx",
|
|
32
|
+
"client",
|
|
33
|
+
"paseo-plugin.json",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"OVERVIEW.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"check": "biome check .",
|
|
40
|
+
"check:write": "biome check --write .",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"typecheck": "tsc --noEmit"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
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
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "connected-hosts",
|
|
3
|
+
"description": "Add, edit, disable and remove the hosts connected to this daemon.",
|
|
4
|
+
"media": [
|
|
5
|
+
"https://raw.githubusercontent.com/omercnet/paseo-plugins/pr-assets-connected-hosts/connected-hosts-screen.jpg",
|
|
6
|
+
"https://raw.githubusercontent.com/omercnet/paseo-plugins/pr-assets-connected-hosts/connected-hosts-edit.jpg"
|
|
7
|
+
],
|
|
8
|
+
"requirements": { "paseo": "^0.13.0" }
|
|
9
|
+
}
|