@vellumai/cli 0.8.7 → 0.8.8-dev.202606060043.60454ad
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/node_modules/@vellumai/local-mode/package.json +2 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +15 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +15 -8
- package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +42 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +22 -4
- package/node_modules/@vellumai/local-mode/src/index.ts +26 -4
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -7
- package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-client-refresh.test.ts +182 -0
- package/src/__tests__/clean.test.ts +179 -0
- package/src/__tests__/client-token.test.ts +87 -0
- package/src/__tests__/client-tui-refresh.test.ts +170 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
- package/src/__tests__/connect-import.test.ts +317 -0
- package/src/__tests__/devices.test.ts +272 -0
- package/src/__tests__/guardian-token.test.ts +126 -2
- package/src/__tests__/pair.test.ts +271 -0
- package/src/__tests__/paired-lifecycle.test.ts +116 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
- package/src/__tests__/unpair.test.ts +163 -0
- package/src/commands/client.ts +115 -26
- package/src/commands/connect/import.ts +217 -0
- package/src/commands/connect.ts +31 -0
- package/src/commands/devices.ts +247 -0
- package/src/commands/pair.ts +222 -0
- package/src/commands/ps.ts +16 -0
- package/src/commands/retire.ts +20 -47
- package/src/commands/sleep.ts +7 -0
- package/src/commands/tunnel.ts +46 -2
- package/src/commands/unpair.ts +118 -0
- package/src/commands/wake.ts +7 -0
- package/src/components/DefaultMainScreen.tsx +84 -13
- package/src/index.ts +16 -0
- package/src/lib/assistant-client.ts +58 -37
- package/src/lib/assistant-config.ts +12 -0
- package/src/lib/cloudflare-tunnel.ts +276 -0
- package/src/lib/confirm-action.ts +57 -0
- package/src/lib/docker.ts +25 -1
- package/src/lib/environments/resolve.ts +9 -30
- package/src/lib/guardian-token.ts +120 -4
- package/src/lib/local.ts +20 -6
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vellum connect import <blob> [--name <localname>]`
|
|
3
|
+
*
|
|
4
|
+
* Import a pairing bundle printed by `vellum pair` on another machine and
|
|
5
|
+
* register it locally so `vellum client`/`message`/`events <name>` work against
|
|
6
|
+
* the remote assistant.
|
|
7
|
+
*
|
|
8
|
+
* The bundle is base64(JSON.stringify({ gatewayUrl, assistantId, token,
|
|
9
|
+
* deviceId })). We store the entry under a UNIQUE LOCAL id (not the bundle's
|
|
10
|
+
* assistantId, which is typically "self" and would collide across hosts). This
|
|
11
|
+
* is safe because the gateway's runtime proxy strips the `/v1/assistants/<id>/`
|
|
12
|
+
* segment before forwarding, so the local id never has to match the remote one
|
|
13
|
+
* — the token (validated by signature/audience) is what authorizes requests.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { nanoid } from "nanoid";
|
|
17
|
+
|
|
18
|
+
import { extractFlag } from "../../lib/arg-utils.js";
|
|
19
|
+
import {
|
|
20
|
+
findAssistantByName,
|
|
21
|
+
saveAssistantEntry,
|
|
22
|
+
} from "../../lib/assistant-config.js";
|
|
23
|
+
import { saveGuardianToken } from "../../lib/guardian-token.js";
|
|
24
|
+
|
|
25
|
+
function printUsage(): void {
|
|
26
|
+
console.log(`vellum connect import - Register an assistant paired from another machine
|
|
27
|
+
|
|
28
|
+
USAGE:
|
|
29
|
+
vellum connect import <bundle> [options]
|
|
30
|
+
|
|
31
|
+
ARGUMENTS:
|
|
32
|
+
<bundle> The base64 bundle printed by 'vellum pair' on the host machine
|
|
33
|
+
|
|
34
|
+
OPTIONS:
|
|
35
|
+
--name <name> Local name to register the assistant under
|
|
36
|
+
(default: paired-<deviceId>)
|
|
37
|
+
|
|
38
|
+
EXAMPLES:
|
|
39
|
+
vellum connect import eyJnYXRld2F5...
|
|
40
|
+
vellum connect import eyJnYXRld2F5... --name desk
|
|
41
|
+
`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PairBundle {
|
|
45
|
+
gatewayUrl: string;
|
|
46
|
+
token: string;
|
|
47
|
+
assistantId?: string;
|
|
48
|
+
deviceId?: string;
|
|
49
|
+
// Optional refresh credential. Present when the host's gateway issued a
|
|
50
|
+
// device-bound token pair; absent for older access-only bundles (which remain
|
|
51
|
+
// importable, just without auto-renewal). `refreshTokenExpiresAt` mirrors
|
|
52
|
+
// GuardianTokenData (ISO string OR epoch-ms number) so a numeric expiry isn't
|
|
53
|
+
// silently dropped on import.
|
|
54
|
+
refreshToken?: string;
|
|
55
|
+
refreshTokenExpiresAt?: string | number;
|
|
56
|
+
refreshAfter?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Decode the base64 bundle, returning null if malformed or missing fields. */
|
|
60
|
+
function decodeBundle(blob: string): PairBundle | null {
|
|
61
|
+
let json: unknown;
|
|
62
|
+
try {
|
|
63
|
+
json = JSON.parse(Buffer.from(blob, "base64").toString("utf8"));
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (typeof json !== "object" || json === null) return null;
|
|
68
|
+
const b = json as Record<string, unknown>;
|
|
69
|
+
if (typeof b.gatewayUrl !== "string" || typeof b.token !== "string") {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
// The gatewayUrl is persisted as runtimeUrl and used to build fetch URLs, so
|
|
73
|
+
// require an absolute http(s) URL here rather than letting an invalid string
|
|
74
|
+
// through (which would crash `new URL(...)` or break later client calls).
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(b.gatewayUrl);
|
|
77
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
gatewayUrl: b.gatewayUrl,
|
|
85
|
+
token: b.token,
|
|
86
|
+
assistantId: typeof b.assistantId === "string" ? b.assistantId : undefined,
|
|
87
|
+
deviceId: typeof b.deviceId === "string" ? b.deviceId : undefined,
|
|
88
|
+
refreshToken:
|
|
89
|
+
typeof b.refreshToken === "string" ? b.refreshToken : undefined,
|
|
90
|
+
refreshTokenExpiresAt:
|
|
91
|
+
typeof b.refreshTokenExpiresAt === "string" ||
|
|
92
|
+
typeof b.refreshTokenExpiresAt === "number"
|
|
93
|
+
? b.refreshTokenExpiresAt
|
|
94
|
+
: undefined,
|
|
95
|
+
refreshAfter:
|
|
96
|
+
typeof b.refreshAfter === "string" ? b.refreshAfter : undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Lowercase, collapse non-alphanumerics to single dashes, trim dashes. */
|
|
101
|
+
function slugify(name: string): string {
|
|
102
|
+
return name
|
|
103
|
+
.toLowerCase()
|
|
104
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
105
|
+
.replace(/^-+|-+$/g, "");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Best-effort JWT `exp` (epoch seconds) → epoch ms; null if undecodable. */
|
|
109
|
+
function jwtExpiryMs(token: string): number | null {
|
|
110
|
+
const parts = token.split(".");
|
|
111
|
+
if (parts.length !== 3) return null;
|
|
112
|
+
try {
|
|
113
|
+
const payload = JSON.parse(
|
|
114
|
+
Buffer.from(parts[1], "base64").toString("utf8"),
|
|
115
|
+
);
|
|
116
|
+
if (typeof payload.exp === "number") return payload.exp * 1000;
|
|
117
|
+
} catch {
|
|
118
|
+
/* fall through */
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function connectImport(): Promise<void> {
|
|
124
|
+
const rawArgs = process.argv.slice(4);
|
|
125
|
+
|
|
126
|
+
if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
|
127
|
+
printUsage();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const [nameFlag, args] = extractFlag(rawArgs, "--name");
|
|
132
|
+
const blob = args[0];
|
|
133
|
+
if (!blob) {
|
|
134
|
+
console.error("Error: missing pairing bundle.");
|
|
135
|
+
printUsage();
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const bundle = decodeBundle(blob);
|
|
140
|
+
if (!bundle) {
|
|
141
|
+
console.error(
|
|
142
|
+
"Error: invalid pairing bundle. Paste the full base64 string printed by `vellum pair`.",
|
|
143
|
+
);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Unique local id: a --name slug, or paired-<deviceId> (deviceId is unique
|
|
148
|
+
// per pairing). Never the bundle's "self" assistantId — that would collide.
|
|
149
|
+
// The deviceId comes from an untrusted bundle and is used as a path component
|
|
150
|
+
// by saveGuardianToken, so it MUST be slugified (no `../` traversal); fall
|
|
151
|
+
// back to a random id if it sanitizes to empty.
|
|
152
|
+
const localId = nameFlag
|
|
153
|
+
? slugify(nameFlag)
|
|
154
|
+
: `paired-${slugify(bundle.deviceId ?? "") || nanoid()}`;
|
|
155
|
+
if (!localId) {
|
|
156
|
+
console.error(
|
|
157
|
+
"Error: --name must contain at least one alphanumeric character.",
|
|
158
|
+
);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Don't clobber an existing assistant. Only update in place when the prior
|
|
163
|
+
// entry is itself a paired import (marked `paired: true`); otherwise the id
|
|
164
|
+
// collides with a real local/remote assistant and overwriting would drop its
|
|
165
|
+
// resources/runtime metadata. Reject and let the user pick a fresh --name.
|
|
166
|
+
const existing = findAssistantByName(localId);
|
|
167
|
+
if (existing && existing.paired !== true) {
|
|
168
|
+
console.error(
|
|
169
|
+
`Error: an assistant named '${localId}' already exists locally. ` +
|
|
170
|
+
"Choose a different --name to avoid overwriting it.",
|
|
171
|
+
);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
const existed = existing !== null;
|
|
175
|
+
|
|
176
|
+
saveAssistantEntry({
|
|
177
|
+
assistantId: localId,
|
|
178
|
+
name: nameFlag ?? `paired (${new URL(bundle.gatewayUrl).host})`,
|
|
179
|
+
runtimeUrl: bundle.gatewayUrl,
|
|
180
|
+
// Paired entries are reached by bearer token at the remote runtimeUrl
|
|
181
|
+
// (a non-"vellum" cloud selects the bearer-token auth path in client.ts).
|
|
182
|
+
// The "paired" topology lets lifecycle/status commands (ps/wake/sleep)
|
|
183
|
+
// recognize this as a remote pairing rather than an on-machine process.
|
|
184
|
+
cloud: "paired",
|
|
185
|
+
// Marks this entry as a connect-import so re-imports update in place while
|
|
186
|
+
// imports never silently overwrite a non-paired assistant (see guard above).
|
|
187
|
+
paired: true,
|
|
188
|
+
species: "vellum",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
const hasRefresh = Boolean(bundle.refreshToken);
|
|
193
|
+
saveGuardianToken(localId, {
|
|
194
|
+
guardianPrincipalId: "imported",
|
|
195
|
+
accessToken: bundle.token,
|
|
196
|
+
accessTokenExpiresAt:
|
|
197
|
+
jwtExpiryMs(bundle.token) ?? now + 24 * 60 * 60 * 1000,
|
|
198
|
+
refreshToken: bundle.refreshToken ?? "",
|
|
199
|
+
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt ?? 0,
|
|
200
|
+
refreshAfter: bundle.refreshAfter ?? "",
|
|
201
|
+
isNew: false,
|
|
202
|
+
deviceId: bundle.deviceId ?? "",
|
|
203
|
+
leasedAt: new Date(now).toISOString(),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
console.log(
|
|
207
|
+
`${existed ? "Updated" : "Imported"} paired assistant '${localId}'.`,
|
|
208
|
+
);
|
|
209
|
+
console.log("");
|
|
210
|
+
console.log(` Connect with: vellum client ${localId}`);
|
|
211
|
+
console.log("");
|
|
212
|
+
console.log(
|
|
213
|
+
hasRefresh
|
|
214
|
+
? "Note: this connection includes a refresh credential, so it can renew itself — re-pair only if it's revoked or the refresh credential expires."
|
|
215
|
+
: "Note: the token is access-only and will expire — re-run `vellum pair` and import again when it does.",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { connectImport } from "./connect/import.js";
|
|
2
|
+
|
|
3
|
+
function printUsage(): void {
|
|
4
|
+
console.log("Usage: vellum connect <subcommand>");
|
|
5
|
+
console.log("");
|
|
6
|
+
console.log("Connect to an assistant paired from another machine.");
|
|
7
|
+
console.log("");
|
|
8
|
+
console.log("Subcommands:");
|
|
9
|
+
console.log(
|
|
10
|
+
" import Import a pairing bundle from `vellum pair` and register it",
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function connect(): Promise<void> {
|
|
15
|
+
const args = process.argv.slice(3);
|
|
16
|
+
const subcommand = args[0];
|
|
17
|
+
|
|
18
|
+
if (subcommand === "--help" || subcommand === "-h" || !subcommand) {
|
|
19
|
+
printUsage();
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (subcommand === "import") {
|
|
24
|
+
await connectImport();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
29
|
+
printUsage();
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vellum devices [name]` and `vellum devices revoke <hashedDeviceId> [name] [--yes]`
|
|
3
|
+
*
|
|
4
|
+
* Host-side of pairing lifecycle: list and revoke the devices paired to a LOCAL
|
|
5
|
+
* self-hosted assistant. Calls the loopback-only gateway endpoints
|
|
6
|
+
* `GET /v1/devices` and `POST /v1/devices/revoke` (added in the gateway slice),
|
|
7
|
+
* which self-guard loopback + reject any browser/WebView Origin. The gateway
|
|
8
|
+
* only ever stores the HASHED device id, so list returns and revoke accepts the
|
|
9
|
+
* same `hashedDeviceId` (the raw device id is never persisted anywhere).
|
|
10
|
+
*
|
|
11
|
+
* This is the counterpart to `vellum unpair`: `unpair` forgets a connection on
|
|
12
|
+
* the *paired* machine (client side); `devices` revokes a device from the *host*
|
|
13
|
+
* that runs the assistant (server side).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type AssistantEntry,
|
|
18
|
+
formatAssistantReference,
|
|
19
|
+
getAssistantDisplayName,
|
|
20
|
+
resolveTargetAssistant,
|
|
21
|
+
} from "../lib/assistant-config";
|
|
22
|
+
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
23
|
+
import {
|
|
24
|
+
CLI_INTERFACE_ID,
|
|
25
|
+
getClientRegistrationHeaders,
|
|
26
|
+
} from "../lib/client-identity.js";
|
|
27
|
+
import {
|
|
28
|
+
canPromptForConfirmation,
|
|
29
|
+
confirmAction,
|
|
30
|
+
} from "../lib/confirm-action.js";
|
|
31
|
+
|
|
32
|
+
interface DeviceRecord {
|
|
33
|
+
hashedDeviceId: string;
|
|
34
|
+
platform: string;
|
|
35
|
+
issuedAt: number | null;
|
|
36
|
+
expiresAt: number | null;
|
|
37
|
+
lastUsedAt: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function printUsage(): void {
|
|
41
|
+
console.log(`vellum devices - List and revoke devices paired to a local assistant
|
|
42
|
+
|
|
43
|
+
USAGE:
|
|
44
|
+
vellum devices [name]
|
|
45
|
+
vellum devices revoke <hashedDeviceId> [name] [--yes]
|
|
46
|
+
|
|
47
|
+
ARGUMENTS:
|
|
48
|
+
[name] Name or id of the local assistant (defaults to the active/sole one)
|
|
49
|
+
<hashedDeviceId> The device's hashed id (copy it from the list output)
|
|
50
|
+
|
|
51
|
+
OPTIONS:
|
|
52
|
+
--yes Skip the interactive confirmation prompt when revoking (for automation)
|
|
53
|
+
|
|
54
|
+
Lists the devices paired to a local (host-side) assistant, or revokes one by its
|
|
55
|
+
hashed id. Runs on the machine that hosts the assistant — paired connections
|
|
56
|
+
imported from another machine are managed with 'vellum unpair' instead.
|
|
57
|
+
|
|
58
|
+
EXAMPLES:
|
|
59
|
+
vellum devices
|
|
60
|
+
vellum devices my-desk
|
|
61
|
+
vellum devices revoke 3f9a1c...
|
|
62
|
+
vellum devices revoke 3f9a1c... my-desk --yes
|
|
63
|
+
`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the LOOPBACK gateway base URL for a host-side assistant, or exit with
|
|
68
|
+
* a helpful error. Refuses paired connections (they have no local gateway here)
|
|
69
|
+
* and never falls back to a non-loopback URL.
|
|
70
|
+
*/
|
|
71
|
+
function resolveLoopbackBase(entry: AssistantEntry): string {
|
|
72
|
+
const displayName = getAssistantDisplayName(entry);
|
|
73
|
+
|
|
74
|
+
if (entry.cloud === "paired") {
|
|
75
|
+
console.error(
|
|
76
|
+
`Error: '${displayName}' is a paired connection imported from another machine.`,
|
|
77
|
+
);
|
|
78
|
+
console.error(
|
|
79
|
+
"Run `vellum devices` on the host that runs the assistant to manage its devices.",
|
|
80
|
+
);
|
|
81
|
+
console.error("To forget this connection here, use `vellum unpair`.");
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const base =
|
|
86
|
+
entry.localUrl ||
|
|
87
|
+
(entry.resources?.gatewayPort
|
|
88
|
+
? `http://127.0.0.1:${entry.resources.gatewayPort}`
|
|
89
|
+
: undefined);
|
|
90
|
+
if (!base) {
|
|
91
|
+
console.error(
|
|
92
|
+
`Error: no local gateway found for '${displayName}'. \`vellum devices\` runs on the machine hosting the assistant.`,
|
|
93
|
+
);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return base.replace(/\/+$/, "");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Format an epoch-ms timestamp as ISO, or a placeholder when absent. */
|
|
101
|
+
function formatTimestamp(ms: number | null, absent: string): string {
|
|
102
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) return absent;
|
|
103
|
+
return new Date(ms).toISOString();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function listDevices(entry: AssistantEntry, base: string): Promise<void> {
|
|
107
|
+
const displayName = getAssistantDisplayName(entry);
|
|
108
|
+
|
|
109
|
+
let response: Response;
|
|
110
|
+
try {
|
|
111
|
+
response = await fetch(`${base}/v1/devices`, {
|
|
112
|
+
method: "GET",
|
|
113
|
+
headers: getClientRegistrationHeaders(CLI_INTERFACE_ID),
|
|
114
|
+
});
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(
|
|
117
|
+
`Error: could not reach the gateway for '${displayName}' at ${base}: ${
|
|
118
|
+
(err as Error).message
|
|
119
|
+
}`,
|
|
120
|
+
);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
console.error(
|
|
126
|
+
`Error: gateway returned ${response.status} listing devices for '${displayName}'.`,
|
|
127
|
+
);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const body = (await response.json()) as { devices?: DeviceRecord[] };
|
|
132
|
+
const devices = body.devices ?? [];
|
|
133
|
+
|
|
134
|
+
if (devices.length === 0) {
|
|
135
|
+
console.log(`No devices are paired to ${displayName}.`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(`Devices paired to ${formatAssistantReference(entry)}:`);
|
|
140
|
+
console.log("");
|
|
141
|
+
for (const device of devices) {
|
|
142
|
+
console.log(` ${device.hashedDeviceId}`);
|
|
143
|
+
console.log(` platform: ${device.platform}`);
|
|
144
|
+
console.log(` issued: ${formatTimestamp(device.issuedAt, "—")}`);
|
|
145
|
+
console.log(` expires: ${formatTimestamp(device.expiresAt, "—")}`);
|
|
146
|
+
console.log(
|
|
147
|
+
` last used: ${formatTimestamp(device.lastUsedAt, "never")}`,
|
|
148
|
+
);
|
|
149
|
+
console.log("");
|
|
150
|
+
}
|
|
151
|
+
console.log(
|
|
152
|
+
`${devices.length} device(s). Revoke one with: vellum devices revoke <hashedDeviceId>`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function revokeDevice(
|
|
157
|
+
entry: AssistantEntry,
|
|
158
|
+
base: string,
|
|
159
|
+
hashedDeviceId: string,
|
|
160
|
+
yes: boolean,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const displayName = getAssistantDisplayName(entry);
|
|
163
|
+
|
|
164
|
+
// Print the resolved identity before acting (cli/AGENTS.md).
|
|
165
|
+
console.log("Device to revoke:");
|
|
166
|
+
console.log(` Assistant: ${formatAssistantReference(entry)}`);
|
|
167
|
+
console.log(` Device: ${hashedDeviceId}`);
|
|
168
|
+
console.log("");
|
|
169
|
+
|
|
170
|
+
if (!yes) {
|
|
171
|
+
if (!canPromptForConfirmation()) {
|
|
172
|
+
console.error(
|
|
173
|
+
"Error: Refusing to revoke without confirmation in a non-interactive terminal.",
|
|
174
|
+
);
|
|
175
|
+
console.error("Re-run with --yes to confirm from automation.");
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
const confirmed = await confirmAction(
|
|
179
|
+
"Press Enter to revoke, or Esc/q to cancel: ",
|
|
180
|
+
);
|
|
181
|
+
if (!confirmed) {
|
|
182
|
+
console.log("Revoke cancelled.");
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let response: Response;
|
|
188
|
+
try {
|
|
189
|
+
response = await fetch(`${base}/v1/devices/revoke`, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: {
|
|
192
|
+
"Content-Type": "application/json",
|
|
193
|
+
...getClientRegistrationHeaders(CLI_INTERFACE_ID),
|
|
194
|
+
},
|
|
195
|
+
body: JSON.stringify({ hashedDeviceId }),
|
|
196
|
+
});
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.error(
|
|
199
|
+
`Error: could not reach the gateway for '${displayName}' at ${base}: ${
|
|
200
|
+
(err as Error).message
|
|
201
|
+
}`,
|
|
202
|
+
);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
console.error(
|
|
208
|
+
`Error: gateway returned ${response.status} revoking device for '${displayName}'.`,
|
|
209
|
+
);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
console.log(
|
|
214
|
+
`Revoked device ${hashedDeviceId} from ${displayName}. Its tokens are invalidated; that machine must re-pair to reconnect.`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function devices(): Promise<void> {
|
|
219
|
+
const args = process.argv.slice(3);
|
|
220
|
+
|
|
221
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
222
|
+
printUsage();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (args[0] === "revoke") {
|
|
227
|
+
const rest = args.slice(1);
|
|
228
|
+
const yes = rest.includes("--yes");
|
|
229
|
+
const positionals = rest.filter((a) => !a.startsWith("-"));
|
|
230
|
+
const hashedDeviceId = positionals[0];
|
|
231
|
+
if (!hashedDeviceId) {
|
|
232
|
+
console.error("Error: a hashedDeviceId is required to revoke.");
|
|
233
|
+
printUsage();
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
const nameArg = positionals.slice(1).join(" ") || undefined;
|
|
237
|
+
const entry = resolveTargetAssistant(nameArg);
|
|
238
|
+
const base = resolveLoopbackBase(entry);
|
|
239
|
+
await revokeDevice(entry, base, hashedDeviceId, yes);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const nameArg = parseAssistantTargetArg(args, []);
|
|
244
|
+
const entry = resolveTargetAssistant(nameArg);
|
|
245
|
+
const base = resolveLoopbackBase(entry);
|
|
246
|
+
await listDevices(entry, base);
|
|
247
|
+
}
|