@mycadet/cli 0.12.10 → 0.12.11

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 (41) hide show
  1. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  3. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  4. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  5. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  6. package/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/cloud-login-poll.test.ts +69 -0
  9. package/src/__tests__/devices.test.ts +62 -0
  10. package/src/__tests__/ensure-binary.test.ts +52 -0
  11. package/src/__tests__/hatch-next-steps.test.ts +38 -0
  12. package/src/__tests__/multi-local.test.ts +3 -3
  13. package/src/__tests__/nginx-ingress.test.ts +55 -17
  14. package/src/__tests__/ps-cloud-status.test.ts +2 -1
  15. package/src/__tests__/setup.test.ts +14 -4
  16. package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
  17. package/src/__tests__/tunnel-stop.test.ts +226 -0
  18. package/src/__tests__/tunnel.test.ts +12 -2
  19. package/src/commands/devices.ts +66 -30
  20. package/src/commands/hatch.ts +10 -52
  21. package/src/commands/pair.ts +7 -1
  22. package/src/commands/ps.ts +4 -2
  23. package/src/commands/tunnel.ts +43 -4
  24. package/src/commands/upgrade.ts +6 -1
  25. package/src/lib/assistant-config.ts +20 -5
  26. package/src/lib/cloud-login-poll.ts +75 -0
  27. package/src/lib/cloud-login.ts +3 -1
  28. package/src/lib/docker.ts +6 -12
  29. package/src/lib/ensure-binary.ts +65 -6
  30. package/src/lib/hatch-local.ts +1 -51
  31. package/src/lib/hatch-next-steps.ts +15 -2
  32. package/src/lib/live-voice/tunnel.ts +2 -2
  33. package/src/lib/local.ts +6 -6
  34. package/src/lib/nginx-ingress.ts +123 -21
  35. package/src/lib/tunnel.ts +86 -34
  36. package/src/lib/tunnels/cadet.ts +12 -1
  37. package/src/lib/tunnels/cloudflare.ts +36 -1
  38. package/src/lib/tunnels/ngrok.ts +16 -2
  39. package/src/lib/tunnels/process-lifecycle.ts +26 -0
  40. package/src/lib/tunnels/tailscale.ts +79 -1
  41. package/src/lib/xdg-log.ts +11 -0
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createServer } from "node:net";
3
+
4
+ import { findAvailableIngressPort } from "../lib/nginx-ingress";
5
+ import { edgeServedRemote } from "../lib/nginx-ingress";
6
+ import { summarizeStartupFailure } from "../lib/tunnel";
7
+
8
+ function portOf(server: ReturnType<typeof createServer>): number {
9
+ const addr = server.address();
10
+ return typeof addr === "object" && addr !== null ? addr.port : -1;
11
+ }
12
+
13
+ describe("summarizeStartupFailure", () => {
14
+ test("names an nginx emerg line over generic log noise", () => {
15
+ const log = [
16
+ "Starting ngrok tunnel to localhost:7840...",
17
+ "nginx: [emerg] bind() to 0.0.0.0:7840 failed (98: Address already in use)",
18
+ ].join("\n");
19
+ expect(summarizeStartupFailure(log)).toContain("[emerg]");
20
+ });
21
+
22
+ test("names an ngrok error code", () => {
23
+ expect(
24
+ summarizeStartupFailure(
25
+ "agent started\nERR_NGROK_4018: Authentication failed",
26
+ ),
27
+ ).toContain("ERR_NGROK_4018");
28
+ });
29
+
30
+ test("the last matching line wins", () => {
31
+ const log = [
32
+ "nginx: [emerg] bind() to 0.0.0.0:7840 failed (98: Address already in use)",
33
+ "nginx: [emerg] bind() to 0.0.0.0:7841 failed (98: Address already in use)",
34
+ ].join("\n");
35
+ expect(summarizeStartupFailure(log)).toContain(":7841");
36
+ });
37
+
38
+ test("falls back to the last non-empty line when nothing error-shaped appears", () => {
39
+ expect(summarizeStartupFailure("first line\nsome plain output")).toBe(
40
+ "some plain output",
41
+ );
42
+ });
43
+
44
+ test("an empty log summarizes to null", () => {
45
+ expect(summarizeStartupFailure("")).toBeNull();
46
+ expect(summarizeStartupFailure("\n \n")).toBeNull();
47
+ });
48
+
49
+ test("long lines are capped", () => {
50
+ const long = `nginx: [emerg] ${"x".repeat(400)}`;
51
+ const summary = summarizeStartupFailure(long) ?? "";
52
+ expect(summary.length).toBeLessThanOrEqual(201);
53
+ expect(summary.endsWith("…")).toBe(true);
54
+ });
55
+ });
56
+
57
+ describe("findAvailableIngressPort", () => {
58
+ test("skips a port a foreign listener already holds", async () => {
59
+ const blocker = createServer();
60
+ await new Promise<void>((resolve) => {
61
+ blocker.listen(0, "127.0.0.1", resolve);
62
+ });
63
+ const occupied = portOf(blocker);
64
+ try {
65
+ const port = await findAvailableIngressPort(occupied);
66
+ expect(port).toBe(occupied + 1);
67
+ } finally {
68
+ await new Promise<void>((resolve) => blocker.close(() => resolve()));
69
+ }
70
+ });
71
+
72
+ test("returns null when the whole band is busy", async () => {
73
+ const scout = createServer();
74
+ await new Promise<void>((resolve) => scout.listen(0, "127.0.0.1", resolve));
75
+ const base = portOf(scout);
76
+ await new Promise<void>((resolve) => scout.close(() => resolve()));
77
+ // Hold both ports of a 2-port band with fresh listeners.
78
+ const first = createServer();
79
+ const second = createServer();
80
+ await new Promise<void>((resolve) =>
81
+ first.listen(base, "127.0.0.1", resolve),
82
+ );
83
+ await new Promise<void>((resolve) =>
84
+ second.listen(base + 1, "127.0.0.1", resolve),
85
+ );
86
+ try {
87
+ expect(await findAvailableIngressPort(base, 2)).toBeNull();
88
+ } finally {
89
+ await new Promise<void>((resolve) => first.close(() => resolve()));
90
+ await new Promise<void>((resolve) => second.close(() => resolve()));
91
+ }
92
+ });
93
+ });
94
+
95
+ describe("edgeServedRemote", () => {
96
+ test("a docker cadet is stamped truthfully", () => {
97
+ expect(edgeServedRemote("docker")).toBe("docker");
98
+ });
99
+
100
+ test("a custom cadet keeps its kind", () => {
101
+ expect(edgeServedRemote("custom")).toBe("custom");
102
+ });
103
+
104
+ test("local, paired, cloud, and unknown entries read as custom so the remote-gateway machinery always runs", () => {
105
+ expect(edgeServedRemote("local")).toBe("custom");
106
+ expect(edgeServedRemote("paired")).toBe("custom");
107
+ expect(edgeServedRemote("cloud")).toBe("custom");
108
+ expect(edgeServedRemote(undefined)).toBe("custom");
109
+ });
110
+ });
@@ -0,0 +1,226 @@
1
+ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
2
+ import {
3
+ mkdtempSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import * as processes from "../lib/process.js";
12
+ import {
13
+ TAILSCALE_PROVIDER,
14
+ stopTailscaleTunnel,
15
+ type TailscaleDeps,
16
+ } from "../lib/tunnels/tailscale.js";
17
+ import * as ngrok from "../lib/tunnels/ngrok.js";
18
+ import { tunnel } from "../commands/tunnel.js";
19
+ import {
20
+ readRootProcesses,
21
+ saveAssistantEntry,
22
+ stampRootProcess,
23
+ findAssistantByName,
24
+ } from "../lib/assistant-config.js";
25
+
26
+ const originalArgv = process.argv;
27
+ const originalLock = process.env.CADET_LOCKFILE_DIR;
28
+ const originalConfig = process.env.XDG_CONFIG_HOME;
29
+ let root = "";
30
+ let workspace = "";
31
+ let stop: ReturnType<typeof spyOn<typeof processes, "stopProcess">>;
32
+ let identity: ReturnType<
33
+ typeof spyOn<typeof processes, "readProcessCommandLine">
34
+ >;
35
+ let logs: ReturnType<typeof spyOn<typeof console, "log">>;
36
+
37
+ beforeEach(() => {
38
+ root = mkdtempSync(join(tmpdir(), "cadet-tunnel-stop-"));
39
+ workspace = join(root, "instance", "workspace");
40
+ mkdirSync(workspace, { recursive: true });
41
+ process.env.CADET_LOCKFILE_DIR = root;
42
+ process.env.XDG_CONFIG_HOME = root;
43
+ saveAssistantEntry({
44
+ assistantId: "fixture",
45
+ name: "fixture",
46
+ cloud: "local",
47
+ runtimeUrl: "http://127.0.0.1:7830",
48
+ ingressUrl: "https://old.ngrok.app",
49
+ resources: {
50
+ instanceDir: join(root, "instance"),
51
+ gatewayPort: 7830,
52
+ daemonPort: 7821,
53
+ cesPort: 8090,
54
+ qdrantPort: 6333,
55
+ },
56
+ });
57
+ writeFileSync(
58
+ join(workspace, "config.json"),
59
+ JSON.stringify({
60
+ ingress: {
61
+ publicBaseUrl: "https://old.ngrok.app",
62
+ lastTunnel: {
63
+ provider: "ngrok",
64
+ publicBaseUrl: "https://old.ngrok.app",
65
+ },
66
+ },
67
+ }),
68
+ );
69
+ stampRootProcess("tunnel", {
70
+ pid: 424242,
71
+ provider: "ngrok",
72
+ assistantId: "fixture",
73
+ startedAt: new Date().toISOString(),
74
+ });
75
+ stampRootProcess("app", { pid: 424243, startedAt: new Date().toISOString() });
76
+ ngrok.saveNgrokDomain("old.ngrok.app");
77
+ stop = spyOn(processes, "stopProcess").mockResolvedValue(true);
78
+ identity = spyOn(processes, "readProcessCommandLine").mockReturnValue(
79
+ "ngrok http 7840",
80
+ );
81
+ logs = spyOn(console, "log").mockImplementation(() => {});
82
+ process.argv = ["bun", "cadet", "tunnel", "stop", "fixture"];
83
+ });
84
+ afterEach(() => {
85
+ stop.mockRestore();
86
+ identity.mockRestore();
87
+ logs.mockRestore();
88
+ process.argv = originalArgv;
89
+ if (originalLock === undefined) delete process.env.CADET_LOCKFILE_DIR;
90
+ else process.env.CADET_LOCKFILE_DIR = originalLock;
91
+ if (originalConfig === undefined) delete process.env.XDG_CONFIG_HOME;
92
+ else process.env.XDG_CONFIG_HOME = originalConfig;
93
+ rmSync(root, { recursive: true, force: true });
94
+ });
95
+
96
+ test("stop terminates the owned agent and clears ingress, retaining nginx and domain; repeated stop is harmless", async () => {
97
+ await tunnel();
98
+ expect(stop).toHaveBeenCalledWith(424242, "tunnel");
99
+ expect(readRootProcesses().tunnel).toBeUndefined();
100
+ expect(readRootProcesses().app?.pid).toBe(424243);
101
+ expect(findAssistantByName("fixture")?.ingressUrl).toBeUndefined();
102
+ expect(
103
+ JSON.parse(readFileSync(join(workspace, "config.json"), "utf8")).ingress
104
+ .publicBaseUrl,
105
+ ).toBeUndefined();
106
+ expect(ngrok.loadNgrokDomain()).toBe("old.ngrok.app");
107
+ await tunnel();
108
+ expect(stop).toHaveBeenCalledTimes(1);
109
+ });
110
+ test("a recycled PID is not signaled and its stale record is cleared", async () => {
111
+ identity.mockReturnValue("unrelated-process");
112
+ await tunnel();
113
+ expect(stop).not.toHaveBeenCalled();
114
+ expect(readRootProcesses().tunnel).toBeUndefined();
115
+ });
116
+ test("another assistant's tunnel is left intact", async () => {
117
+ stampRootProcess("tunnel", {
118
+ pid: 424242,
119
+ provider: "ngrok",
120
+ assistantId: "another",
121
+ startedAt: new Date().toISOString(),
122
+ });
123
+ await expect(tunnel()).rejects.toThrow("belongs to 'another'");
124
+ expect(stop).not.toHaveBeenCalled();
125
+ expect(readRootProcesses().tunnel?.assistantId).toBe("another");
126
+ });
127
+ test("failure to stop retains process and ingress state", async () => {
128
+ stop.mockResolvedValue(false);
129
+ await expect(tunnel()).rejects.toThrow("Could not stop ngrok");
130
+ expect(readRootProcesses().tunnel?.pid).toBe(424242);
131
+ expect(findAssistantByName("fixture")?.ingressUrl).toBe(
132
+ "https://old.ngrok.app",
133
+ );
134
+ });
135
+
136
+ test("Cloudflare dispatch stops cloudflared and clears its ingress", async () => {
137
+ stampRootProcess("tunnel", {
138
+ pid: 424242,
139
+ provider: "cloudflare",
140
+ assistantId: "fixture",
141
+ startedAt: new Date().toISOString(),
142
+ });
143
+ identity.mockReturnValue(
144
+ "/usr/local/bin/cloudflared tunnel --url http://localhost:7840",
145
+ );
146
+ await tunnel();
147
+ expect(stop).toHaveBeenCalledWith(424242, "tunnel");
148
+ expect(readRootProcesses().tunnel).toBeUndefined();
149
+ expect(findAssistantByName("fixture")?.ingressUrl).toBeUndefined();
150
+ });
151
+
152
+ test("Tailscale dispatch disables serve and preserves a separate webhook ingress", async () => {
153
+ stampRootProcess("tunnel", {
154
+ pid: 424242,
155
+ provider: "tailscale",
156
+ previous: {
157
+ pid: 424240,
158
+ provider: "ngrok",
159
+ assistantId: "fixture",
160
+ startedAt: new Date().toISOString(),
161
+ },
162
+ assistantId: "fixture",
163
+ startedAt: new Date().toISOString(),
164
+ });
165
+ writeFileSync(
166
+ join(workspace, "config.json"),
167
+ JSON.stringify({
168
+ ingress: {
169
+ publicBaseUrl: "https://old.ngrok.app",
170
+ pairingTunnel: { provider: "tailscale" },
171
+ },
172
+ }),
173
+ );
174
+ const calls: string[][] = [];
175
+ const deps: TailscaleDeps = {
176
+ findBinary: async () => "tailscale",
177
+ run: (_bin, args) => {
178
+ calls.push(args);
179
+ return { status: 0, stdout: "", stderr: "" };
180
+ },
181
+ };
182
+ const provider = spyOn(TAILSCALE_PROVIDER, "stop").mockImplementation(
183
+ (opts) => stopTailscaleTunnel(opts, deps),
184
+ );
185
+ try {
186
+ await tunnel();
187
+ expect(calls).toEqual([["serve", "--https=443", "off"]]);
188
+ expect(stop).not.toHaveBeenCalled();
189
+ expect(readRootProcesses().tunnel?.pid).toBe(424240);
190
+ expect(findAssistantByName("fixture")?.ingressUrl).toBe(
191
+ "https://old.ngrok.app",
192
+ );
193
+ const state = JSON.parse(
194
+ readFileSync(join(workspace, "config.json"), "utf8"),
195
+ );
196
+ expect(state.ingress.publicBaseUrl).toBe("https://old.ngrok.app");
197
+ expect(state.ingress.pairingTunnel).toBeUndefined();
198
+ } finally {
199
+ provider.mockRestore();
200
+ }
201
+ });
202
+
203
+ test("Tailscale shutdown failure preserves the recorded tunnel and ingress", async () => {
204
+ stampRootProcess("tunnel", {
205
+ pid: 424242,
206
+ provider: "tailscale",
207
+ assistantId: "fixture",
208
+ startedAt: new Date().toISOString(),
209
+ });
210
+ const deps: TailscaleDeps = {
211
+ findBinary: async () => "tailscale",
212
+ run: () => ({ status: 1, stdout: "", stderr: "permission denied" }),
213
+ };
214
+ const provider = spyOn(TAILSCALE_PROVIDER, "stop").mockImplementation(
215
+ (opts) => stopTailscaleTunnel(opts, deps),
216
+ );
217
+ try {
218
+ await expect(tunnel()).rejects.toThrow("Could not stop tailscale serve");
219
+ expect(readRootProcesses().tunnel?.provider).toBe("tailscale");
220
+ expect(findAssistantByName("fixture")?.ingressUrl).toBe(
221
+ "https://old.ngrok.app",
222
+ );
223
+ } finally {
224
+ provider.mockRestore();
225
+ }
226
+ });
@@ -53,6 +53,10 @@ const runCloudflareTunnelMock = mock<
53
53
  mock.module("../lib/tunnels/cloudflare.js", () => ({
54
54
  ...realCloudflareTunnel,
55
55
  runCloudflareTunnel: runCloudflareTunnelMock,
56
+ CLOUDFLARE_PROVIDER: {
57
+ ...realCloudflareTunnel.CLOUDFLARE_PROVIDER,
58
+ run: runCloudflareTunnelMock,
59
+ },
56
60
  }));
57
61
 
58
62
  const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () =>
@@ -61,6 +65,7 @@ const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () =>
61
65
  mock.module("../lib/tunnels/ngrok", () => ({
62
66
  ...realNgrok,
63
67
  runNgrokTunnel: runNgrokTunnelMock,
68
+ NGROK_PROVIDER: { ...realNgrok.NGROK_PROVIDER, run: runNgrokTunnelMock },
64
69
  }));
65
70
 
66
71
  const runTailscaleTunnelMock = mock<typeof tailscaleTunnel.runTailscaleTunnel>(
@@ -69,6 +74,10 @@ const runTailscaleTunnelMock = mock<typeof tailscaleTunnel.runTailscaleTunnel>(
69
74
  mock.module("../lib/tunnels/tailscale.js", () => ({
70
75
  ...realTailscaleTunnel,
71
76
  runTailscaleTunnel: runTailscaleTunnelMock,
77
+ TAILSCALE_PROVIDER: {
78
+ ...realTailscaleTunnel.TAILSCALE_PROVIDER,
79
+ run: runTailscaleTunnelMock,
80
+ },
72
81
  }));
73
82
 
74
83
  const notifyGatewayOfPublicUrlMock = mock<
@@ -1705,6 +1714,7 @@ describe("tunnel background mode", () => {
1705
1714
  mock.module("../lib/tunnels/ngrok", () => ({
1706
1715
  ...realNgrok,
1707
1716
  runNgrokTunnel: runNgrokTunnelMock,
1717
+ NGROK_PROVIDER: { ...realNgrok.NGROK_PROVIDER, run: runNgrokTunnelMock },
1708
1718
  }));
1709
1719
  mock.module("../lib/nginx-ingress.js", () => ({
1710
1720
  ...realNginxIngress,
@@ -1780,7 +1790,7 @@ describe("tunnel background mode", () => {
1780
1790
 
1781
1791
  expect(logs).toContain("Tunnel established: https://foo.ngrok.app");
1782
1792
  expect(logs).toContain("Running in background (pid 9999)");
1783
- expect(logs).toContain("Stop with: kill 9999");
1793
+ expect(logs).toContain("Stop with: cadet tunnel stop");
1784
1794
 
1785
1795
  // The parent hands the actual tunnel workflow off to the child: it never
1786
1796
  // runs the provider or edge setup itself.
@@ -1818,7 +1828,7 @@ describe("tunnel background mode", () => {
1818
1828
  const { exited, errors } = await runTunnelExpectingExit1();
1819
1829
 
1820
1830
  expect(exited).toBe(true);
1821
- expect(errors).toContain("exited during startup (exit code 1)");
1831
+ expect(errors).toContain("Error (1): tunnel process exited during startup");
1822
1832
  expect(runNgrokTunnelMock).not.toHaveBeenCalled();
1823
1833
  });
1824
1834
 
@@ -13,6 +13,7 @@
13
13
  * that runs the assistant (server side).
14
14
  */
15
15
 
16
+ import { z } from "zod";
16
17
  import { createHash } from "node:crypto";
17
18
 
18
19
  import type { DeviceRecord } from "@mycadet/local-mode";
@@ -33,8 +34,16 @@ import {
33
34
  canPromptForConfirmation,
34
35
  confirmAction,
35
36
  } from "../lib/confirm-action.js";
37
+ import { GATEWAY_INTERNAL_PORT } from "../lib/environments/paths.js";
38
+ import { execOutput } from "../lib/step-runner.js";
39
+ import { dockerResourceNames } from "../lib/docker.js";
36
40
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
37
41
 
42
+ const dockerGatewayResponseSchema = z.object({
43
+ status: z.number().int().min(200).max(599),
44
+ body: z.string(),
45
+ });
46
+
38
47
  function printUsage(): void {
39
48
  console.log(`cadet devices [beta] - List and revoke devices paired to a local assistant
40
49
 
@@ -81,6 +90,9 @@ function resolveLoopbackBase(entry: AssistantEntry): string {
81
90
  process.exit(1);
82
91
  }
83
92
 
93
+ if (entry.cloud === "docker")
94
+ return `http://127.0.0.1:${GATEWAY_INTERNAL_PORT}`;
95
+
84
96
  const base =
85
97
  entry.localUrl ||
86
98
  (entry.resources?.gatewayPort
@@ -96,6 +108,31 @@ function resolveLoopbackBase(entry: AssistantEntry): string {
96
108
  return base.replace(/\/+$/, "");
97
109
  }
98
110
 
111
+ /** Docker host management enters the gateway network namespace before calling loopback-only routes. */
112
+ async function deviceRequest(
113
+ entry: AssistantEntry,
114
+ url: string,
115
+ init: RequestInit,
116
+ ): Promise<Response> {
117
+ if (entry.cloud !== "docker") return loopbackSafeFetch(url, init);
118
+ const script = `const r = await fetch(process.argv[1], {...JSON.parse(process.argv[2]), signal: AbortSignal.timeout(10000)}); console.log(JSON.stringify({status:r.status, body:await r.text()}));`;
119
+ const output = await execOutput(
120
+ "docker",
121
+ [
122
+ "exec",
123
+ dockerResourceNames(entry.assistantId).gatewayContainer,
124
+ "bun",
125
+ "-e",
126
+ script,
127
+ url,
128
+ JSON.stringify(init),
129
+ ],
130
+ { timeoutMs: 15000 },
131
+ );
132
+ const result = dockerGatewayResponseSchema.parse(JSON.parse(output));
133
+ return new Response(result.body, { status: result.status });
134
+ }
135
+
99
136
  /**
100
137
  * The hashed device id the gateway stores for THIS machine's own guardian
101
138
  * credential (`leaseGuardianToken` registers it device-bound as
@@ -122,7 +159,7 @@ async function listDevices(
122
159
 
123
160
  let response: Response;
124
161
  try {
125
- response = await loopbackSafeFetch(`${base}/v1/devices`, {
162
+ response = await deviceRequest(entry, `${base}/v1/devices`, {
126
163
  method: "GET",
127
164
  headers: getClientRegistrationHeaders(CLI_INTERFACE_ID),
128
165
  });
@@ -239,7 +276,7 @@ async function revokeDevice(
239
276
 
240
277
  let response: Response;
241
278
  try {
242
- response = await loopbackSafeFetch(`${base}/v1/devices/revoke`, {
279
+ response = await deviceRequest(entry, `${base}/v1/devices/revoke`, {
243
280
  method: "POST",
244
281
  headers: {
245
282
  "Content-Type": "application/json",
@@ -281,38 +318,37 @@ async function revokeDevice(
281
318
 
282
319
  export const devices = Object.assign(
283
320
  async function devices(): Promise<void> {
284
- const rawArgs = process.argv.slice(3);
285
-
286
- if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
287
- printUsage();
288
- return;
289
- }
321
+ const rawArgs = process.argv.slice(3);
290
322
 
291
- const jsonOutput = rawArgs.includes("--json");
292
- const args = rawArgs.filter((a) => a !== "--json");
293
-
294
- if (args[0] === "revoke") {
295
- const rest = args.slice(1);
296
- const yes = rest.includes("--yes");
297
- const positionals = rest.filter((a) => !a.startsWith("-"));
298
- const hashedDeviceId = positionals[0];
299
- if (!hashedDeviceId) {
300
- console.error("Error: a hashedDeviceId is required to revoke.");
323
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
301
324
  printUsage();
302
- process.exit(1);
325
+ return;
303
326
  }
304
- const nameArg = positionals.slice(1).join(" ") || undefined;
327
+
328
+ const jsonOutput = rawArgs.includes("--json");
329
+ const args = rawArgs.filter((a) => a !== "--json");
330
+
331
+ if (args[0] === "revoke") {
332
+ const rest = args.slice(1);
333
+ const yes = rest.includes("--yes");
334
+ const positionals = rest.filter((a) => !a.startsWith("-"));
335
+ const hashedDeviceId = positionals[0];
336
+ if (!hashedDeviceId) {
337
+ console.error("Error: a hashedDeviceId is required to revoke.");
338
+ printUsage();
339
+ process.exit(1);
340
+ }
341
+ const nameArg = positionals.slice(1).join(" ") || undefined;
342
+ const entry = resolveTargetAssistant(nameArg);
343
+ const base = resolveLoopbackBase(entry);
344
+ await revokeDevice(entry, base, hashedDeviceId, yes, jsonOutput);
345
+ return;
346
+ }
347
+
348
+ const nameArg = parseAssistantTargetArg(args, []);
305
349
  const entry = resolveTargetAssistant(nameArg);
306
350
  const base = resolveLoopbackBase(entry);
307
- await revokeDevice(entry, base, hashedDeviceId, yes, jsonOutput);
308
- return;
309
- }
310
-
311
- const nameArg = parseAssistantTargetArg(args, []);
312
- const entry = resolveTargetAssistant(nameArg);
313
- const base = resolveLoopbackBase(entry);
314
- await listDevices(entry, base, jsonOutput);
315
- },
351
+ await listDevices(entry, base, jsonOutput);
352
+ },
316
353
  { description: "List or revoke devices paired to a local assistant [beta]" },
317
354
  );
318
-