frely-cli 0.3.0

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.
Files changed (44) hide show
  1. package/README.md +157 -0
  2. package/dist/auth.d.ts +27 -0
  3. package/dist/auth.js +208 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/device/control.d.ts +10 -0
  6. package/dist/device/control.js +164 -0
  7. package/dist/device/control.js.map +1 -0
  8. package/dist/device/identity.d.ts +16 -0
  9. package/dist/device/identity.js +49 -0
  10. package/dist/device/identity.js.map +1 -0
  11. package/dist/device/protocol.d.ts +34 -0
  12. package/dist/device/protocol.js +76 -0
  13. package/dist/device/protocol.js.map +1 -0
  14. package/dist/device/relay-client.d.ts +6 -0
  15. package/dist/device/relay-client.js +159 -0
  16. package/dist/device/relay-client.js.map +1 -0
  17. package/dist/device/state.d.ts +14 -0
  18. package/dist/device/state.js +34 -0
  19. package/dist/device/state.js.map +1 -0
  20. package/dist/diagnostics.d.ts +25 -0
  21. package/dist/diagnostics.js +64 -0
  22. package/dist/diagnostics.js.map +1 -0
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +258 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/runtime/mcp.d.ts +3 -0
  27. package/dist/runtime/mcp.js +117 -0
  28. package/dist/runtime/mcp.js.map +1 -0
  29. package/dist/runtime/process-manager.d.ts +29 -0
  30. package/dist/runtime/process-manager.js +118 -0
  31. package/dist/runtime/process-manager.js.map +1 -0
  32. package/dist/runtime/relay-mcp.d.ts +9 -0
  33. package/dist/runtime/relay-mcp.js +101 -0
  34. package/dist/runtime/relay-mcp.js.map +1 -0
  35. package/dist/runtime/scheduler.d.ts +11 -0
  36. package/dist/runtime/scheduler.js +51 -0
  37. package/dist/runtime/scheduler.js.map +1 -0
  38. package/dist/runtime/workspace.d.ts +72 -0
  39. package/dist/runtime/workspace.js +318 -0
  40. package/dist/runtime/workspace.js.map +1 -0
  41. package/dist/service.d.ts +12 -0
  42. package/dist/service.js +142 -0
  43. package/dist/service.js.map +1 -0
  44. package/package.json +47 -0
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # frely-cli
2
+
3
+ 设计与 ChatGPT MCP 用户流程见 [`docs/chatgpt-mcp.md`](docs/chatgpt-mcp.md)。
4
+
5
+ `frely-cli` is Frely's local command-line client and MCP runtime. The target flow is:
6
+
7
+ ```text
8
+ install -> frely login -> frely mcp setup -> add MCP URL to ChatGPT
9
+ ```
10
+
11
+ There is no separate `friday-local` project. Local MCP execution belongs to `frely-cli`.
12
+
13
+ ## Quick install
14
+
15
+ After `frely-cli` is published, Frely can serve the repository `install.sh` as:
16
+
17
+ ```sh
18
+ curl -fsSL https://app.frely.cloud/install.sh | sh
19
+ ```
20
+
21
+ The installer requires Node.js 22 or newer and installs `frely-cli@latest` through npm.
22
+
23
+ ## Install from a local checkout
24
+
25
+ The package requires Node.js 22 or newer. To install the current source checkout with Bun:
26
+
27
+ ```sh
28
+ cd /path/to/frely-cli
29
+ bun install
30
+ bun run build
31
+ bun install --global "$PWD"
32
+ ```
33
+
34
+ Use the absolute `$PWD` path in the global install command. Bun installs the `frely` executable in its global bin directory. If the command is not found, add that directory to your `PATH`:
35
+
36
+ ```sh
37
+ export PATH="$(bun pm bin -g):$PATH"
38
+ ```
39
+
40
+ If Bun reports that the `keytar` lifecycle script is blocked, trust the native dependency and reinstall:
41
+
42
+ ```sh
43
+ bun pm trust keytar
44
+ bun install
45
+ ```
46
+
47
+ The repository currently has an npm lockfile rather than a committed Bun lockfile. The first `bun install` may create `bun.lock`; handle that file according to the repository's lockfile policy.
48
+
49
+ ## First use
50
+
51
+ ```sh
52
+ frely login
53
+ frely mcp setup --workspace /path/to/project
54
+ ```
55
+
56
+ `mcp setup` performs three client-side steps:
57
+
58
+ 1. enrolls this machine with the logged-in Frely account;
59
+ 2. obtains the stable public MCP URL;
60
+ 3. installs and starts the user-level background Device Relay service.
61
+
62
+ It prints the MCP URL to add to ChatGPT. The background service keeps the outbound connection alive, so no terminal window or inbound port is required.
63
+
64
+ You can print the URL again with:
65
+
66
+ ```sh
67
+ frely mcp url
68
+ ```
69
+
70
+ Or show ChatGPT-oriented instructions with:
71
+
72
+ ```sh
73
+ frely mcp chatgpt
74
+ ```
75
+
76
+ ## Commands
77
+
78
+ ```text
79
+ frely login [--relay <https-url>]
80
+ frely logout
81
+ frely whoami
82
+ frely status [--json]
83
+ frely doctor [--json]
84
+ frely mcp setup [--workspace <path>]
85
+ frely mcp url [--json]
86
+ frely mcp status [--json]
87
+ frely mcp chatgpt
88
+ frely mcp serve [--workspace <path>]
89
+ frely mcp service status|start|stop|uninstall [--json]
90
+ frely mcp revoke
91
+ frely mcp stdio [--workspace <path>]
92
+ ```
93
+
94
+ `frely mcp serve` is the foreground/debug form of the same Device Relay client. `frely mcp setup` uses a LaunchAgent on macOS and a systemd user service on Linux.
95
+
96
+ `frely logout` stops the background service before removing the account session. `frely mcp revoke` removes the background service, revokes the server-side device binding, and deletes the local device private key.
97
+
98
+ ## MCP provisioning contract
99
+
100
+ The CLI expects Frely Relay to expose these authenticated user endpoints:
101
+
102
+ ```text
103
+ POST /api/user/device-relay/enroll
104
+ POST /api/user/device-relay/connect
105
+ POST /api/user/device-relay/revoke
106
+ ```
107
+
108
+ Enrollment returns:
109
+
110
+ ```json
111
+ {
112
+ "deviceId": "...",
113
+ "mcpUrl": "https://.../mcp/..."
114
+ }
115
+ ```
116
+
117
+ The public MCP URL is a private bearer URL of the form `https://app.frely.cloud/mcp/<device-id>/<private-secret>`. The secret is high entropy, is only stored as a SHA-256 hash by the Relay, and must be treated as a credential. ChatGPT uses this URL with MCP Authentication set to `None`; no separate ChatGPT MCP OAuth flow is required.
118
+
119
+ A Device Relay connection request uses the enrolled Ed25519 device key and returns a short-lived connection grant:
120
+
121
+ ```json
122
+ {
123
+ "websocketUrl": "wss://...",
124
+ "accessToken": "short-lived-token",
125
+ "expiresAt": "..."
126
+ }
127
+ ```
128
+
129
+ The access token is sent only in the WebSocket `Authorization` header. It is not printed and is not persisted in the MCP URL.
130
+
131
+ ## Device Relay
132
+
133
+ The WebSocket subprotocol is `frely.device-relay.v1`. Requests use independent request IDs and a 64-request inflight window. Read-only local operations may overlap; writes and shell operations use the local fair scheduler. This avoids LocalMCP's device-wide `busy -> 429` behavior.
134
+
135
+ The client requests a fresh short-lived connection grant for every connection attempt and reconnects with bounded exponential backoff. A renewed Frely login is picked up by the next reconnect without reinstalling the service.
136
+
137
+ ## Local MCP capabilities
138
+
139
+ The local MCP server exposes workspace inspection, file search/read/write/patch, directory create/delete/move, shell commands, and persistent process management.
140
+
141
+ Filesystem tools are constrained to the selected workspace, reject symlink escapes, cap normal file reads/writes at 1 MiB, use no-follow reads, and use atomic replacement for writes. `run_command` and persistent process tools execute with the current OS user's permissions; the workspace only constrains their working directory and is not a shell sandbox.
142
+
143
+ ## Authentication and secrets
144
+
145
+ - Password input requires an interactive TTY and is not stored.
146
+ - Frely account session credentials are stored through Keychain / Secret Service via `keytar`.
147
+ - The device Ed25519 private key is stored in the OS credential store.
148
+ - Device binding metadata includes the private MCP bearer URL and therefore uses owner-only file permissions; the Relay stores only the MCP secret hash.
149
+ - Device connection grants are short lived and stay in memory.
150
+ - The MCP URL does not contain the device private key, account session, or connection grant, but the URL's private secret is itself a credential.
151
+ - `doctor` never prints passwords, sessions, private keys, or connection grants.
152
+
153
+ ## Current server dependency
154
+
155
+ The CLI side of installation, account login, device enrollment, MCP URL discovery, background service lifecycle, Device Relay WebSocket transport, multiplexing, reconnection, and local MCP execution is implemented here.
156
+
157
+ A Frely Relay deployment must implement the three provisioning endpoints, Device Relay WebSocket host, and public private-URL MCP ingress before `frely mcp setup` can produce a working ChatGPT connection. The private MCP URL is the ChatGPT-side bearer credential, so no separate ChatGPT MCP authentication service is required.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ export interface PublicUser {
2
+ id: string;
3
+ email: string;
4
+ name?: string | null;
5
+ }
6
+ export interface AuthSnapshot {
7
+ configured: boolean;
8
+ relayUrl?: string;
9
+ user?: PublicUser;
10
+ credentialStored: boolean;
11
+ configPath: string;
12
+ configMode?: number;
13
+ }
14
+ export declare function authConfigPath(): string;
15
+ export declare function normalizeRelayUrl(value?: string): string;
16
+ export declare function login(email: string, password: string, relayInput?: string): Promise<PublicUser>;
17
+ export declare function whoami(): Promise<PublicUser>;
18
+ export declare function requireLogin(): Promise<{
19
+ config: {
20
+ relayUrl: string;
21
+ };
22
+ user: PublicUser;
23
+ cookie: string;
24
+ }>;
25
+ export declare function logout(): Promise<void>;
26
+ export declare function inspectAuth(): Promise<AuthSnapshot>;
27
+ export declare function probeCredentialStore(): Promise<void>;
package/dist/auth.js ADDED
@@ -0,0 +1,208 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import keytar from "keytar";
6
+ const SERVICE = "frely-cli";
7
+ const SESSION_COOKIE_NAME = "friday_session_token";
8
+ const CONFIG_VERSION = 1;
9
+ const DEFAULT_RELAY = "https://app.frely.cloud";
10
+ const REQUEST_TIMEOUT_MS = 15_000;
11
+ export function authConfigPath() {
12
+ const root = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
13
+ return join(root, "frely", "config.json");
14
+ }
15
+ function accountKey(relayUrl) {
16
+ return new URL(relayUrl).origin;
17
+ }
18
+ export function normalizeRelayUrl(value) {
19
+ const url = new URL(value || process.env.FRELY_RELAY_URL || DEFAULT_RELAY);
20
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "::1"].includes(url.hostname))) {
21
+ throw new Error("Frely Relay must use HTTPS outside loopback development.");
22
+ }
23
+ url.pathname = "";
24
+ url.search = "";
25
+ url.hash = "";
26
+ return url.origin;
27
+ }
28
+ export async function login(email, password, relayInput) {
29
+ if (!email.trim() || !password)
30
+ throw new Error("Email and password are required.");
31
+ const relayUrl = normalizeRelayUrl(relayInput);
32
+ const response = await fetchWithTimeout(`${relayUrl}/api/auth/login`, {
33
+ method: "POST",
34
+ headers: {
35
+ "content-type": "application/json",
36
+ "origin": relayUrl,
37
+ "accept": "application/json",
38
+ },
39
+ body: JSON.stringify({ email: email.trim(), password }),
40
+ redirect: "error",
41
+ });
42
+ const payload = await safeJson(response);
43
+ if (!response.ok)
44
+ throw new Error(publicError(payload, response.status));
45
+ const user = parseUser(payload);
46
+ const cookie = sessionCookie(response.headers);
47
+ if (!cookie)
48
+ throw new Error("Frely login succeeded without a usable session cookie.");
49
+ const key = accountKey(relayUrl);
50
+ await keytar.setPassword(SERVICE, key, cookie);
51
+ try {
52
+ await writeConfig({ version: CONFIG_VERSION, relayUrl, user });
53
+ }
54
+ catch (error) {
55
+ await keytar.deletePassword(SERVICE, key).catch(() => false);
56
+ throw error;
57
+ }
58
+ return user;
59
+ }
60
+ export async function whoami() {
61
+ const config = await readConfig();
62
+ const cookie = await keytar.getPassword(SERVICE, accountKey(config.relayUrl));
63
+ if (!cookie)
64
+ throw new Error("No Frely login is stored. Run `frely login`.");
65
+ const response = await fetchWithTimeout(`${config.relayUrl}/api/auth/me`, {
66
+ headers: { "cookie": cookie, "accept": "application/json" },
67
+ redirect: "error",
68
+ });
69
+ const payload = await safeJson(response);
70
+ if (!response.ok) {
71
+ if (response.status === 401)
72
+ throw new Error("Frely login expired. Run `frely login`.");
73
+ throw new Error(publicError(payload, response.status));
74
+ }
75
+ const user = parseUser(payload);
76
+ if (user.id !== config.user.id || user.email !== config.user.email || user.name !== config.user.name) {
77
+ await writeConfig({ ...config, user });
78
+ }
79
+ return user;
80
+ }
81
+ export async function requireLogin() {
82
+ const config = await readConfig();
83
+ const cookie = await keytar.getPassword(SERVICE, accountKey(config.relayUrl));
84
+ if (!cookie)
85
+ throw new Error("No Frely login is stored. Run `frely login`.");
86
+ const user = await whoami();
87
+ return { config: { relayUrl: config.relayUrl }, user, cookie };
88
+ }
89
+ export async function logout() {
90
+ const config = await readConfig().catch(() => null);
91
+ if (!config) {
92
+ await unlink(authConfigPath()).catch(() => undefined);
93
+ return;
94
+ }
95
+ const key = accountKey(config.relayUrl);
96
+ const cookie = await keytar.getPassword(SERVICE, key);
97
+ if (cookie) {
98
+ await fetchWithTimeout(`${config.relayUrl}/api/auth/logout`, {
99
+ method: "POST",
100
+ headers: { "cookie": cookie, "origin": config.relayUrl, "content-type": "application/json" },
101
+ body: "{}",
102
+ redirect: "error",
103
+ }).catch(() => undefined);
104
+ }
105
+ await keytar.deletePassword(SERVICE, key).catch(() => false);
106
+ await unlink(authConfigPath()).catch(() => undefined);
107
+ }
108
+ export async function inspectAuth() {
109
+ const path = authConfigPath();
110
+ const config = await readConfig().catch(() => null);
111
+ if (!config)
112
+ return { configured: false, credentialStored: false, configPath: path };
113
+ const credentialStored = Boolean(await keytar.getPassword(SERVICE, accountKey(config.relayUrl)));
114
+ const fileStat = await stat(path).catch(() => null);
115
+ return {
116
+ configured: true,
117
+ relayUrl: config.relayUrl,
118
+ user: config.user,
119
+ credentialStored,
120
+ configPath: path,
121
+ ...(fileStat ? { configMode: fileStat.mode & 0o777 } : {}),
122
+ };
123
+ }
124
+ export async function probeCredentialStore() {
125
+ const account = `doctor:${randomUUID()}`;
126
+ const value = randomUUID();
127
+ await keytar.setPassword(SERVICE, account, value);
128
+ try {
129
+ if (await keytar.getPassword(SERVICE, account) !== value)
130
+ throw new Error("Credential store readback failed.");
131
+ }
132
+ finally {
133
+ await keytar.deletePassword(SERVICE, account).catch(() => false);
134
+ }
135
+ }
136
+ async function readConfig() {
137
+ const raw = await readFile(authConfigPath(), "utf8").catch(() => null);
138
+ if (!raw)
139
+ throw new Error("No Frely login is configured. Run `frely login`.");
140
+ let value;
141
+ try {
142
+ value = JSON.parse(raw);
143
+ }
144
+ catch {
145
+ throw new Error("Frely CLI configuration is invalid.");
146
+ }
147
+ if (value.version !== CONFIG_VERSION || typeof value.relayUrl !== "string" || !value.user || typeof value.user.id !== "string" || typeof value.user.email !== "string") {
148
+ throw new Error("Frely CLI configuration is invalid.");
149
+ }
150
+ return value;
151
+ }
152
+ async function writeConfig(config) {
153
+ const path = authConfigPath();
154
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
155
+ await chmod(dirname(path), 0o700).catch(() => undefined);
156
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
157
+ await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: "wx" });
158
+ await rename(tmp, path);
159
+ await chmod(path, 0o600).catch(() => undefined);
160
+ }
161
+ async function fetchWithTimeout(url, init) {
162
+ const controller = new AbortController();
163
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
164
+ timer.unref?.();
165
+ try {
166
+ return await fetch(url, { ...init, signal: controller.signal });
167
+ }
168
+ catch (error) {
169
+ if (controller.signal.aborted)
170
+ throw new Error("Frely request timed out.");
171
+ throw error;
172
+ }
173
+ finally {
174
+ clearTimeout(timer);
175
+ }
176
+ }
177
+ async function safeJson(response) {
178
+ try {
179
+ return await response.json();
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ }
185
+ function parseUser(payload) {
186
+ const root = payload && typeof payload === "object" ? payload : {};
187
+ const candidate = root.user && typeof root.user === "object" ? root.user : root;
188
+ if (typeof candidate.id !== "string" || typeof candidate.email !== "string")
189
+ throw new Error("Frely returned an invalid user profile.");
190
+ return { id: candidate.id, email: candidate.email, ...(typeof candidate.name === "string" ? { name: candidate.name } : {}) };
191
+ }
192
+ function sessionCookie(headers) {
193
+ const extended = headers;
194
+ const setCookies = extended.getSetCookie?.() ?? (headers.get("set-cookie") ? [headers.get("set-cookie")] : []);
195
+ for (const value of setCookies) {
196
+ const pair = value.split(";", 1)[0]?.trim();
197
+ if (pair?.startsWith(`${SESSION_COOKIE_NAME}=`))
198
+ return pair;
199
+ }
200
+ return null;
201
+ }
202
+ function publicError(payload, status) {
203
+ const root = payload && typeof payload === "object" ? payload : {};
204
+ const error = root.error && typeof root.error === "object" ? root.error : root;
205
+ const message = typeof error.message === "string" ? error.message : null;
206
+ return message || `Frely request failed with HTTP ${status}.`;
207
+ }
208
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ import { type DeviceBinding } from "./state.js";
2
+ export interface ConnectionGrant {
3
+ websocketUrl: string;
4
+ accessToken: string;
5
+ expiresAt: string;
6
+ }
7
+ export declare function ensureDevice(): Promise<DeviceBinding>;
8
+ export declare function currentDevice(): Promise<DeviceBinding | null>;
9
+ export declare function connectionGrant(binding?: DeviceBinding): Promise<ConnectionGrant>;
10
+ export declare function revokeDevice(): Promise<void>;
@@ -0,0 +1,164 @@
1
+ import { hostname, platform } from "node:os";
2
+ import { requireLogin } from "../auth.js";
3
+ import { createConnectionProof, deleteDeviceIdentity, loadOrCreateDeviceIdentity } from "./identity.js";
4
+ import { clearDeviceBinding, readDeviceBinding, writeDeviceBinding } from "./state.js";
5
+ const CLIENT_VERSION = "0.3.0";
6
+ const TIMEOUT_MS = 15_000;
7
+ export async function ensureDevice() {
8
+ const auth = await requireLogin();
9
+ const existing = await readDeviceBinding();
10
+ if (existing && existing.relayUrl === auth.config.relayUrl && existing.userId === auth.user.id)
11
+ return existing;
12
+ const identity = await loadOrCreateDeviceIdentity(auth.config.relayUrl, auth.user.id);
13
+ const response = await relayFetch(auth.config.relayUrl, auth.cookie, "/api/user/device-relay/enroll", {
14
+ method: "POST",
15
+ body: JSON.stringify({
16
+ publicKeySpki: identity.publicKeySpki,
17
+ keyThumbprint: identity.keyThumbprint,
18
+ deviceName: hostname(),
19
+ platform: platform(),
20
+ cliVersion: CLIENT_VERSION,
21
+ }),
22
+ });
23
+ if (response.status === 404 || response.status === 405)
24
+ throw new Error("This Frely Relay does not provide MCP device enrollment yet.");
25
+ const payload = await responseJson(response);
26
+ if (!response.ok)
27
+ throw new Error(publicError(payload, response.status));
28
+ const record = object(payload);
29
+ const deviceId = requiredString(record, "deviceId", 256);
30
+ const mcpUrl = validateMcpUrl(requiredString(record, "mcpUrl", 4096));
31
+ const binding = {
32
+ version: 1,
33
+ relayUrl: auth.config.relayUrl,
34
+ userId: auth.user.id,
35
+ deviceId,
36
+ publicKeySpki: identity.publicKeySpki,
37
+ keyThumbprint: identity.keyThumbprint,
38
+ mcpUrl,
39
+ updatedAt: new Date().toISOString(),
40
+ };
41
+ await writeDeviceBinding(binding);
42
+ return binding;
43
+ }
44
+ export async function currentDevice() {
45
+ const auth = await requireLogin();
46
+ const binding = await readDeviceBinding();
47
+ if (!binding || binding.relayUrl !== auth.config.relayUrl || binding.userId !== auth.user.id)
48
+ return null;
49
+ return binding;
50
+ }
51
+ export async function connectionGrant(binding) {
52
+ const auth = await requireLogin();
53
+ const device = binding ?? await ensureDevice();
54
+ if (device.relayUrl !== auth.config.relayUrl || device.userId !== auth.user.id)
55
+ throw new Error("Device binding does not belong to the current Frely login.");
56
+ const identity = await loadOrCreateDeviceIdentity(auth.config.relayUrl, auth.user.id);
57
+ if (identity.keyThumbprint !== device.keyThumbprint)
58
+ throw new Error("Device key does not match the enrolled device.");
59
+ const proof = createConnectionProof(identity, device.deviceId);
60
+ const response = await relayFetch(auth.config.relayUrl, auth.cookie, "/api/user/device-relay/connect", {
61
+ method: "POST",
62
+ body: JSON.stringify(proof),
63
+ });
64
+ if (response.status === 404 || response.status === 405)
65
+ throw new Error("This Frely Relay does not provide Device Relay connections yet.");
66
+ const payload = await responseJson(response);
67
+ if (!response.ok)
68
+ throw new Error(publicError(payload, response.status));
69
+ const record = object(payload);
70
+ return {
71
+ websocketUrl: validateWebSocketUrl(requiredString(record, "websocketUrl", 4096)),
72
+ accessToken: requiredString(record, "accessToken", 8192),
73
+ expiresAt: requiredString(record, "expiresAt", 128),
74
+ };
75
+ }
76
+ export async function revokeDevice() {
77
+ const auth = await requireLogin();
78
+ const binding = await currentDevice();
79
+ if (!binding) {
80
+ await clearDeviceBinding();
81
+ await deleteDeviceIdentity(auth.config.relayUrl, auth.user.id);
82
+ return;
83
+ }
84
+ const response = await relayFetch(auth.config.relayUrl, auth.cookie, "/api/user/device-relay/revoke", {
85
+ method: "POST",
86
+ body: JSON.stringify({ deviceId: binding.deviceId }),
87
+ });
88
+ if (response.status === 404 || response.status === 405)
89
+ throw new Error("This Frely Relay does not provide Device Relay revocation yet.");
90
+ if (!response.ok) {
91
+ const payload = await responseJson(response);
92
+ throw new Error(publicError(payload, response.status));
93
+ }
94
+ await clearDeviceBinding();
95
+ await deleteDeviceIdentity(auth.config.relayUrl, auth.user.id);
96
+ }
97
+ async function relayFetch(relayUrl, cookie, path, init) {
98
+ const controller = new AbortController();
99
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
100
+ timer.unref?.();
101
+ try {
102
+ return await fetch(`${relayUrl}${path}`, {
103
+ ...init,
104
+ redirect: "error",
105
+ signal: controller.signal,
106
+ headers: {
107
+ "accept": "application/json",
108
+ "content-type": "application/json",
109
+ "cookie": cookie,
110
+ "origin": relayUrl,
111
+ ...(init.headers ?? {}),
112
+ },
113
+ });
114
+ }
115
+ catch (error) {
116
+ if (controller.signal.aborted)
117
+ throw new Error("Frely Device Relay request timed out.");
118
+ throw error;
119
+ }
120
+ finally {
121
+ clearTimeout(timer);
122
+ }
123
+ }
124
+ function validateMcpUrl(value) {
125
+ const url = new URL(value);
126
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "::1"].includes(url.hostname)))
127
+ throw new Error("Relay returned an invalid MCP URL.");
128
+ if (url.username || url.password || url.search || url.hash)
129
+ throw new Error("MCP URL must not contain URL userinfo, query parameters, or fragments.");
130
+ return url.toString();
131
+ }
132
+ function validateWebSocketUrl(value) {
133
+ const url = new URL(value);
134
+ if (url.protocol !== "wss:" && !(url.protocol === "ws:" && ["127.0.0.1", "localhost", "::1"].includes(url.hostname)))
135
+ throw new Error("Relay returned an invalid Device Relay WebSocket URL.");
136
+ if (url.username || url.password)
137
+ throw new Error("Device Relay WebSocket URL must not contain credentials.");
138
+ return url.toString();
139
+ }
140
+ async function responseJson(response) {
141
+ try {
142
+ return await response.json();
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ }
148
+ function object(value) {
149
+ if (!value || typeof value !== "object" || Array.isArray(value))
150
+ throw new Error("Frely Relay returned an invalid Device Relay response.");
151
+ return value;
152
+ }
153
+ function requiredString(record, key, max) {
154
+ const value = record[key];
155
+ if (typeof value !== "string" || value.length < 1 || value.length > max)
156
+ throw new Error(`Frely Relay returned an invalid ${key}.`);
157
+ return value;
158
+ }
159
+ function publicError(payload, status) {
160
+ const record = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
161
+ const error = record.error && typeof record.error === "object" && !Array.isArray(record.error) ? record.error : record;
162
+ return typeof error.message === "string" ? error.message : `Frely Device Relay request failed with HTTP ${status}.`;
163
+ }
164
+ //# sourceMappingURL=control.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"control.js","sourceRoot":"","sources":["../../src/device/control.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,16 @@
1
+ export interface DeviceIdentity {
2
+ publicKeySpki: string;
3
+ keyThumbprint: string;
4
+ signMessage(message: string): string;
5
+ }
6
+ export declare function loadOrCreateDeviceIdentity(relayUrl: string, userId: string): Promise<DeviceIdentity>;
7
+ export declare function deleteDeviceIdentity(relayUrl: string, userId: string): Promise<void>;
8
+ export declare function connectionProofMessage(deviceId: string, issuedAt: string, nonce: string): string;
9
+ export declare function createConnectionProof(identity: DeviceIdentity, deviceId: string): {
10
+ deviceId: string;
11
+ publicKeySpki: string;
12
+ keyThumbprint: string;
13
+ issuedAt: string;
14
+ nonce: string;
15
+ signature: string;
16
+ };
@@ -0,0 +1,49 @@
1
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign } from "node:crypto";
2
+ import keytar from "keytar";
3
+ const SERVICE = "frely-cli-device";
4
+ function account(relayUrl, userId) {
5
+ return `${new URL(relayUrl).origin}|${userId}`;
6
+ }
7
+ export async function loadOrCreateDeviceIdentity(relayUrl, userId) {
8
+ const key = account(relayUrl, userId);
9
+ let privateKeyPem = await keytar.getPassword(SERVICE, key);
10
+ if (!privateKeyPem) {
11
+ const pair = generateKeyPairSync("ed25519");
12
+ privateKeyPem = pair.privateKey.export({ type: "pkcs8", format: "pem" }).toString();
13
+ await keytar.setPassword(SERVICE, key, privateKeyPem);
14
+ }
15
+ return identityFromPrivateKey(privateKeyPem);
16
+ }
17
+ export async function deleteDeviceIdentity(relayUrl, userId) {
18
+ await keytar.deletePassword(SERVICE, account(relayUrl, userId)).catch(() => false);
19
+ }
20
+ export function connectionProofMessage(deviceId, issuedAt, nonce) {
21
+ return `frely.device-relay.connect.v1\n${deviceId}\n${issuedAt}\n${nonce}`;
22
+ }
23
+ export function createConnectionProof(identity, deviceId) {
24
+ const issuedAt = new Date().toISOString();
25
+ const nonce = randomBytes(24).toString("base64url");
26
+ const message = connectionProofMessage(deviceId, issuedAt, nonce);
27
+ return {
28
+ deviceId,
29
+ publicKeySpki: identity.publicKeySpki,
30
+ keyThumbprint: identity.keyThumbprint,
31
+ issuedAt,
32
+ nonce,
33
+ signature: identity.signMessage(message),
34
+ };
35
+ }
36
+ function identityFromPrivateKey(privateKeyPem) {
37
+ const privateKey = createPrivateKey(privateKeyPem);
38
+ const publicDer = createPublicKey(privateKey).export({ type: "spki", format: "der" });
39
+ const publicKeySpki = Buffer.from(publicDer).toString("base64url");
40
+ const keyThumbprint = createHash("sha256").update(publicDer).digest("base64url");
41
+ return {
42
+ publicKeySpki,
43
+ keyThumbprint,
44
+ signMessage(message) {
45
+ return sign(null, Buffer.from(message, "utf8"), privateKey).toString("base64url");
46
+ },
47
+ };
48
+ }
49
+ //# sourceMappingURL=identity.js.map