uplink-cli 0.1.3 → 0.1.6
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.
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { apiRequest } from "../http";
|
|
2
|
+
|
|
3
|
+
export type Tunnel = {
|
|
4
|
+
id: string;
|
|
5
|
+
url?: string;
|
|
6
|
+
ingressHttpUrl?: string;
|
|
7
|
+
token?: string;
|
|
8
|
+
alias?: string | null;
|
|
9
|
+
status?: string;
|
|
10
|
+
createdAt?: string;
|
|
11
|
+
updatedAt?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class TunnelsClient {
|
|
15
|
+
async create(port: number, opts?: { project?: string; alias?: string }) {
|
|
16
|
+
const tunnel = await apiRequest("POST", "/v1/tunnels", {
|
|
17
|
+
port,
|
|
18
|
+
project: opts?.project,
|
|
19
|
+
}) as Tunnel;
|
|
20
|
+
|
|
21
|
+
let aliasError: string | null = null;
|
|
22
|
+
if (opts?.alias) {
|
|
23
|
+
try {
|
|
24
|
+
const aliasResult = await apiRequest("POST", `/v1/tunnels/${tunnel.id}/alias`, {
|
|
25
|
+
alias: opts.alias,
|
|
26
|
+
}) as Tunnel;
|
|
27
|
+
tunnel.alias = aliasResult.alias ?? tunnel.alias;
|
|
28
|
+
} catch (err: any) {
|
|
29
|
+
aliasError = err?.message || String(err);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { tunnel, aliasError };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async list() {
|
|
37
|
+
return apiRequest("GET", "/v1/tunnels") as Promise<{ tunnels: Tunnel[]; count: number }>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async setAlias(id: string, alias: string) {
|
|
41
|
+
return apiRequest("POST", `/v1/tunnels/${id}/alias`, { alias }) as Promise<Tunnel>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async deleteAlias(id: string) {
|
|
45
|
+
return apiRequest("DELETE", `/v1/tunnels/${id}/alias`) as Promise<Tunnel>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async stats(id: string) {
|
|
49
|
+
return apiRequest("GET", `/v1/tunnels/${id}/stats`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async stop(id: string) {
|
|
53
|
+
return apiRequest("DELETE", `/v1/tunnels/${id}`) as Promise<{ id: string; status: string }>;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/cli/src/index.ts
CHANGED
|
@@ -4,19 +4,54 @@ import { dbCommand } from "./subcommands/db";
|
|
|
4
4
|
import { devCommand } from "./subcommands/dev";
|
|
5
5
|
import { adminCommand } from "./subcommands/admin";
|
|
6
6
|
import { menuCommand } from "./subcommands/menu";
|
|
7
|
+
import { tunnelCommand } from "./subcommands/tunnel";
|
|
8
|
+
import { signupCommand } from "./subcommands/signup";
|
|
7
9
|
|
|
8
10
|
const program = new Command();
|
|
9
11
|
|
|
10
12
|
program
|
|
11
13
|
.name("uplink")
|
|
12
14
|
.description("Agent-friendly cloud CLI")
|
|
13
|
-
.version("0.1.0")
|
|
15
|
+
.version("0.1.0")
|
|
16
|
+
.option("--api-base <url>", "Override API base URL (default env AGENTCLOUD_API_BASE)")
|
|
17
|
+
.option("--token-stdin", "Read AGENTCLOUD_TOKEN from stdin once");
|
|
14
18
|
|
|
15
19
|
program.addCommand(dbCommand);
|
|
16
20
|
program.addCommand(devCommand);
|
|
17
21
|
program.addCommand(adminCommand);
|
|
22
|
+
program.addCommand(tunnelCommand);
|
|
23
|
+
program.addCommand(signupCommand);
|
|
18
24
|
program.addCommand(menuCommand);
|
|
19
25
|
|
|
26
|
+
// Global pre-action hook to apply shared options
|
|
27
|
+
let cachedTokenStdin: string | null = null;
|
|
28
|
+
program.hook("preAction", async (thisCommand) => {
|
|
29
|
+
// Collect global options
|
|
30
|
+
const opts =
|
|
31
|
+
typeof thisCommand.optsWithGlobals === "function"
|
|
32
|
+
? thisCommand.optsWithGlobals()
|
|
33
|
+
: thisCommand.opts();
|
|
34
|
+
|
|
35
|
+
if (opts.apiBase) {
|
|
36
|
+
process.env.AGENTCLOUD_API_BASE = String(opts.apiBase);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (opts.tokenStdin) {
|
|
40
|
+
if (!cachedTokenStdin) {
|
|
41
|
+
cachedTokenStdin = await new Promise<string>((resolve, reject) => {
|
|
42
|
+
let data = "";
|
|
43
|
+
process.stdin.setEncoding("utf8");
|
|
44
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
45
|
+
process.stdin.on("end", () => resolve(data.trim()));
|
|
46
|
+
process.stdin.on("error", (err) => reject(err));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (cachedTokenStdin) {
|
|
50
|
+
process.env.AGENTCLOUD_TOKEN = cachedTokenStdin;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
20
55
|
// If no command provided and not a flag, default to menu
|
|
21
56
|
if (process.argv.length === 2) {
|
|
22
57
|
process.argv.push("menu");
|
|
@@ -8,6 +8,11 @@ import { homedir } from "os";
|
|
|
8
8
|
import { join } from "path";
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
10
10
|
|
|
11
|
+
// Check if running from project directory (smoke tests require local scripts)
|
|
12
|
+
function isInProjectDir(): boolean {
|
|
13
|
+
return existsSync("scripts/test-comprehensive.sh") && existsSync("package.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
type MenuChoice = {
|
|
12
17
|
label: string;
|
|
13
18
|
action?: () => Promise<string>;
|
|
@@ -471,33 +476,36 @@ export const menuCommand = new Command("menu")
|
|
|
471
476
|
// Only show other menu items if authentication succeeded
|
|
472
477
|
|
|
473
478
|
if (isAdmin) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
{
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
health = "unreachable";
|
|
487
|
-
}
|
|
479
|
+
const systemStatusItems: MenuChoice[] = [
|
|
480
|
+
{
|
|
481
|
+
label: "View Status",
|
|
482
|
+
action: async () => {
|
|
483
|
+
let health = "unknown";
|
|
484
|
+
try {
|
|
485
|
+
const res = await fetch(`${apiBase}/health`);
|
|
486
|
+
const json = await res.json().catch(() => ({}));
|
|
487
|
+
health = json.status || res.statusText || "unknown";
|
|
488
|
+
} catch {
|
|
489
|
+
health = "unreachable";
|
|
490
|
+
}
|
|
488
491
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
},
|
|
492
|
+
const stats = await apiRequest("GET", "/v1/admin/stats");
|
|
493
|
+
return [
|
|
494
|
+
`API health: ${health}`,
|
|
495
|
+
"Tunnels:",
|
|
496
|
+
` Active ${stats.tunnels.active} | Inactive ${stats.tunnels.inactive} | Deleted ${stats.tunnels.deleted} | Total ${stats.tunnels.total}`,
|
|
497
|
+
` Created last 24h: ${stats.tunnels.createdLast24h}`,
|
|
498
|
+
"Databases:",
|
|
499
|
+
` Ready ${stats.databases.ready} | Provisioning ${stats.databases.provisioning} | Failed ${stats.databases.failed} | Deleted ${stats.databases.deleted} | Total ${stats.databases.total}`,
|
|
500
|
+
` Created last 24h: ${stats.databases.createdLast24h}`,
|
|
501
|
+
].join("\n");
|
|
500
502
|
},
|
|
503
|
+
},
|
|
504
|
+
];
|
|
505
|
+
|
|
506
|
+
// Smoke tests only available when running from project directory
|
|
507
|
+
if (isInProjectDir()) {
|
|
508
|
+
systemStatusItems.push(
|
|
501
509
|
{
|
|
502
510
|
label: "Test: Tunnel",
|
|
503
511
|
action: async () => {
|
|
@@ -525,72 +533,80 @@ export const menuCommand = new Command("menu")
|
|
|
525
533
|
await runSmoke("test:comprehensive");
|
|
526
534
|
return "test:comprehensive completed";
|
|
527
535
|
},
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
action: async () => {
|
|
532
|
-
try {
|
|
533
|
-
const data = await apiRequest("GET", "/v1/admin/relay-status") as {
|
|
534
|
-
connectedTunnels?: number;
|
|
535
|
-
tunnels?: Array<{ token: string; clientIp: string; targetPort: number; connectedAt: string; connectedFor: string }>;
|
|
536
|
-
timestamp?: string;
|
|
537
|
-
error?: string;
|
|
538
|
-
message?: string;
|
|
539
|
-
};
|
|
540
|
-
|
|
541
|
-
if (data.error) {
|
|
542
|
-
return `Error: ${data.error}${data.message ? ` - ${data.message}` : ""}`;
|
|
543
|
-
}
|
|
544
|
-
if (!data.tunnels || data.tunnels.length === 0) {
|
|
545
|
-
return "No tunnels currently connected to the relay.";
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
const lines = data.tunnels.map((t) =>
|
|
549
|
-
`${truncate(t.token, 12).padEnd(14)} ${t.clientIp.padEnd(16)} ${String(t.targetPort).padEnd(6)} ${t.connectedFor.padEnd(10)} ${truncate(t.connectedAt, 19)}`
|
|
550
|
-
);
|
|
536
|
+
}
|
|
537
|
+
);
|
|
538
|
+
}
|
|
551
539
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
},
|
|
564
|
-
{
|
|
565
|
-
label: "View Traffic Stats",
|
|
566
|
-
action: async () => {
|
|
567
|
-
const data = await apiRequest("GET", "/v1/admin/traffic-stats?sync=true") as {
|
|
568
|
-
count?: number;
|
|
569
|
-
aliases?: Array<{ alias: string; requests: number; bytesIn: number; bytesOut: number; lastSeenAt: string | null; lastStatus: number | null }>;
|
|
540
|
+
systemStatusItems.push(
|
|
541
|
+
{
|
|
542
|
+
label: "View Connected Tunnels",
|
|
543
|
+
action: async () => {
|
|
544
|
+
try {
|
|
545
|
+
const data = await apiRequest("GET", "/v1/admin/relay-status") as {
|
|
546
|
+
connectedTunnels?: number;
|
|
547
|
+
tunnels?: Array<{ token: string; clientIp: string; targetPort: number; connectedAt: string; connectedFor: string }>;
|
|
548
|
+
timestamp?: string;
|
|
549
|
+
error?: string;
|
|
550
|
+
message?: string;
|
|
570
551
|
};
|
|
571
552
|
|
|
572
|
-
|
|
573
|
-
|
|
553
|
+
if (data.error) {
|
|
554
|
+
return `Error: ${data.error}${data.message ? ` - ${data.message}` : ""}`;
|
|
555
|
+
}
|
|
556
|
+
if (!data.tunnels || data.tunnels.length === 0) {
|
|
557
|
+
return "No tunnels currently connected to the relay.";
|
|
558
|
+
}
|
|
574
559
|
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
return `${truncate(a.alias, 22).padEnd(24)} ${String(a.requests || 0).padStart(8)} ${formatBytes(a.bytesIn || 0).padStart(9)} ${formatBytes(a.bytesOut || 0).padStart(9)} ${String(a.lastStatus ?? "-").padStart(3)} ${last}`;
|
|
579
|
-
});
|
|
560
|
+
const lines = data.tunnels.map((t) =>
|
|
561
|
+
`${truncate(t.token, 12).padEnd(14)} ${t.clientIp.padEnd(16)} ${String(t.targetPort).padEnd(6)} ${t.connectedFor.padEnd(10)} ${truncate(t.connectedAt, 19)}`
|
|
562
|
+
);
|
|
580
563
|
|
|
581
564
|
return [
|
|
582
|
-
`
|
|
565
|
+
`Connected Tunnels: ${data.connectedTunnels}`,
|
|
583
566
|
"",
|
|
584
|
-
"
|
|
567
|
+
"Token Client IP Port Uptime Connected At",
|
|
585
568
|
"-".repeat(75),
|
|
586
569
|
...lines,
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
},
|
|
570
|
+
].join("\n");
|
|
571
|
+
} catch (err: any) {
|
|
572
|
+
return `Error: Failed to get relay status - ${err.message}`;
|
|
573
|
+
}
|
|
592
574
|
},
|
|
593
|
-
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
label: "View Traffic Stats",
|
|
578
|
+
action: async () => {
|
|
579
|
+
const data = await apiRequest("GET", "/v1/admin/traffic-stats?sync=true") as {
|
|
580
|
+
count?: number;
|
|
581
|
+
aliases?: Array<{ alias: string; requests: number; bytesIn: number; bytesOut: number; lastSeenAt: string | null; lastStatus: number | null }>;
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const aliases = data.aliases || [];
|
|
585
|
+
if (aliases.length === 0) return "No persisted alias stats yet.";
|
|
586
|
+
|
|
587
|
+
const top = aliases.slice(0, 25);
|
|
588
|
+
const lines = top.map((a) => {
|
|
589
|
+
const last = a.lastSeenAt ? truncate(a.lastSeenAt, 19) : "-";
|
|
590
|
+
return `${truncate(a.alias, 22).padEnd(24)} ${String(a.requests || 0).padStart(8)} ${formatBytes(a.bytesIn || 0).padStart(9)} ${formatBytes(a.bytesOut || 0).padStart(9)} ${String(a.lastStatus ?? "-").padStart(3)} ${last}`;
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
return [
|
|
594
|
+
`Aliases tracked: ${aliases.length}`,
|
|
595
|
+
"",
|
|
596
|
+
"Alias Requests In Out Sts Last Seen",
|
|
597
|
+
"-".repeat(75),
|
|
598
|
+
...lines,
|
|
599
|
+
aliases.length > top.length ? `\nShowing top ${top.length} by requests.` : "",
|
|
600
|
+
]
|
|
601
|
+
.filter(Boolean)
|
|
602
|
+
.join("\n");
|
|
603
|
+
},
|
|
604
|
+
}
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
mainMenu.push({
|
|
608
|
+
label: "System Status",
|
|
609
|
+
subMenu: systemStatusItems,
|
|
594
610
|
});
|
|
595
611
|
}
|
|
596
612
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { printJson, handleError } from "../utils/machine";
|
|
3
|
+
|
|
4
|
+
type SignupResponse = {
|
|
5
|
+
id: string;
|
|
6
|
+
token: string;
|
|
7
|
+
tokenPrefix: string;
|
|
8
|
+
role: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
label: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
expiresAt: string | null;
|
|
13
|
+
message: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Unauthenticated request for signup (no token required)
|
|
18
|
+
*/
|
|
19
|
+
async function signupRequest(body: Record<string, unknown>): Promise<SignupResponse> {
|
|
20
|
+
const apiBase = process.env.AGENTCLOUD_API_BASE || "https://api.uplink.spot";
|
|
21
|
+
const url = `${apiBase}/v1/signup`;
|
|
22
|
+
|
|
23
|
+
const res = await fetch(url, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const json = await res.json();
|
|
30
|
+
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const code = json?.error?.code || "SIGNUP_ERROR";
|
|
33
|
+
const msg = json?.error?.message || json?.message || res.statusText;
|
|
34
|
+
throw new Error(`${code}: ${msg}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return json as SignupResponse;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const signupCommand = new Command("signup")
|
|
41
|
+
.description("Create a new user account and token (no auth required)")
|
|
42
|
+
.option("--label <label>", "Optional label for the token")
|
|
43
|
+
.option("--expires-days <days>", "Token expiration in days (optional)")
|
|
44
|
+
.option("--json", "Output JSON", false)
|
|
45
|
+
.action(async (opts) => {
|
|
46
|
+
try {
|
|
47
|
+
const body: Record<string, unknown> = {};
|
|
48
|
+
if (opts.label) body.label = opts.label;
|
|
49
|
+
if (opts.expiresDays) {
|
|
50
|
+
const days = Number(opts.expiresDays);
|
|
51
|
+
if (!Number.isFinite(days) || days <= 0) {
|
|
52
|
+
console.error("Invalid --expires-days. Provide a positive number.");
|
|
53
|
+
process.exit(2);
|
|
54
|
+
}
|
|
55
|
+
body.expiresInDays = days;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = await signupRequest(body);
|
|
59
|
+
|
|
60
|
+
if (opts.json) {
|
|
61
|
+
printJson(result);
|
|
62
|
+
} else {
|
|
63
|
+
console.log("Account created successfully!");
|
|
64
|
+
console.log("");
|
|
65
|
+
console.log(` Token: ${result.token}`);
|
|
66
|
+
console.log(` User ID: ${result.userId}`);
|
|
67
|
+
console.log(` Role: ${result.role}`);
|
|
68
|
+
console.log(` Label: ${result.label}`);
|
|
69
|
+
console.log(` Expires: ${result.expiresAt ?? "never"}`);
|
|
70
|
+
console.log("");
|
|
71
|
+
console.log("Save this token securely - it will not be shown again.");
|
|
72
|
+
console.log("");
|
|
73
|
+
console.log("To use this token, set the environment variable:");
|
|
74
|
+
console.log(` export AGENTCLOUD_TOKEN="${result.token}"`);
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
handleError(error, { json: opts.json });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { apiRequest } from "../http";
|
|
3
|
+
import { handleError, printJson } from "../utils/machine";
|
|
4
|
+
|
|
5
|
+
type TunnelResponse = {
|
|
6
|
+
id: string;
|
|
7
|
+
url?: string;
|
|
8
|
+
host?: string;
|
|
9
|
+
port?: number;
|
|
10
|
+
token?: string;
|
|
11
|
+
alias?: string | null;
|
|
12
|
+
status?: string;
|
|
13
|
+
createdAt?: string;
|
|
14
|
+
updatedAt?: string;
|
|
15
|
+
ingressHttpUrl?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type TunnelListResponse = {
|
|
19
|
+
tunnels: TunnelResponse[];
|
|
20
|
+
count: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type TunnelStatsResponse = any;
|
|
24
|
+
|
|
25
|
+
export const tunnelCommand = new Command("tunnel")
|
|
26
|
+
.description("Manage tunnels non-interactively (agent-friendly)");
|
|
27
|
+
|
|
28
|
+
// Create tunnel
|
|
29
|
+
tunnelCommand
|
|
30
|
+
.command("create")
|
|
31
|
+
.description("Create a tunnel")
|
|
32
|
+
.requiredOption("--port <port>", "Local port to expose")
|
|
33
|
+
.option("--alias <alias>", "Optional permanent alias (if enabled on account)")
|
|
34
|
+
.option("--project <project>", "Optional project id")
|
|
35
|
+
.option("--json", "Output JSON", false)
|
|
36
|
+
.action(async (opts) => {
|
|
37
|
+
const port = Number(opts.port);
|
|
38
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
39
|
+
console.error("Invalid port. Provide a positive integer.");
|
|
40
|
+
process.exit(2);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const body: Record<string, unknown> = { port };
|
|
45
|
+
if (opts.project) body.project = opts.project;
|
|
46
|
+
|
|
47
|
+
const tunnel = await apiRequest("POST", "/v1/tunnels", body) as TunnelResponse;
|
|
48
|
+
let aliasResult: TunnelResponse | null = null;
|
|
49
|
+
let aliasError: string | null = null;
|
|
50
|
+
|
|
51
|
+
if (opts.alias) {
|
|
52
|
+
try {
|
|
53
|
+
aliasResult = await apiRequest("POST", `/v1/tunnels/${tunnel.id}/alias`, {
|
|
54
|
+
alias: opts.alias,
|
|
55
|
+
}) as TunnelResponse;
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
aliasError = err?.message || String(err);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (opts.json) {
|
|
62
|
+
printJson({
|
|
63
|
+
tunnel,
|
|
64
|
+
alias: aliasResult?.alias ?? null,
|
|
65
|
+
aliasError,
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
console.log(`Created tunnel ${tunnel.id}`);
|
|
69
|
+
console.log(` url: ${tunnel.url ?? tunnel.ingressHttpUrl ?? "-"}`);
|
|
70
|
+
console.log(` token: ${tunnel.token ?? "-"}`);
|
|
71
|
+
if (opts.alias) {
|
|
72
|
+
if (aliasResult?.alias) {
|
|
73
|
+
console.log(` alias: ${aliasResult.alias}`);
|
|
74
|
+
} else if (aliasError) {
|
|
75
|
+
console.log(` alias: failed - ${aliasError}`);
|
|
76
|
+
}
|
|
77
|
+
} else if (tunnel.alias) {
|
|
78
|
+
console.log(` alias: ${tunnel.alias}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
handleError(error, { json: opts.json });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// List tunnels
|
|
87
|
+
tunnelCommand
|
|
88
|
+
.command("list")
|
|
89
|
+
.description("List your tunnels")
|
|
90
|
+
.option("--json", "Output JSON", false)
|
|
91
|
+
.action(async (opts) => {
|
|
92
|
+
try {
|
|
93
|
+
const result = await apiRequest("GET", "/v1/tunnels") as TunnelListResponse;
|
|
94
|
+
if (opts.json) {
|
|
95
|
+
printJson(result);
|
|
96
|
+
} else {
|
|
97
|
+
if (!result.tunnels || result.tunnels.length === 0) {
|
|
98
|
+
console.log("No tunnels found.");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
console.log(`Tunnels (${result.count}):`);
|
|
102
|
+
for (const t of result.tunnels) {
|
|
103
|
+
console.log(
|
|
104
|
+
`${t.id} ${t.url ?? t.ingressHttpUrl ?? "-"} token=${t.token ?? "-"} alias=${t.alias ?? "-"} status=${t.status ?? "-"}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
handleError(error, { json: opts.json });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Set alias
|
|
114
|
+
tunnelCommand
|
|
115
|
+
.command("alias-set")
|
|
116
|
+
.description("Set or update an alias for a tunnel")
|
|
117
|
+
.requiredOption("--id <id>", "Tunnel id")
|
|
118
|
+
.requiredOption("--alias <alias>", "Alias to set")
|
|
119
|
+
.option("--json", "Output JSON", false)
|
|
120
|
+
.action(async (opts) => {
|
|
121
|
+
try {
|
|
122
|
+
const result = await apiRequest("POST", `/v1/tunnels/${opts.id}/alias`, {
|
|
123
|
+
alias: opts.alias,
|
|
124
|
+
}) as TunnelResponse;
|
|
125
|
+
if (opts.json) {
|
|
126
|
+
printJson(result);
|
|
127
|
+
} else {
|
|
128
|
+
console.log(`Alias set: ${result.alias ?? opts.alias} -> ${result.url ?? "-"}`);
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
handleError(error, { json: opts.json });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Delete alias
|
|
136
|
+
tunnelCommand
|
|
137
|
+
.command("alias-delete")
|
|
138
|
+
.description("Remove alias from a tunnel")
|
|
139
|
+
.requiredOption("--id <id>", "Tunnel id")
|
|
140
|
+
.option("--json", "Output JSON", false)
|
|
141
|
+
.action(async (opts) => {
|
|
142
|
+
try {
|
|
143
|
+
const result = await apiRequest("DELETE", `/v1/tunnels/${opts.id}/alias`) as TunnelResponse;
|
|
144
|
+
if (opts.json) {
|
|
145
|
+
printJson(result);
|
|
146
|
+
} else {
|
|
147
|
+
console.log(`Alias removed for tunnel ${result.id}`);
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
handleError(error, { json: opts.json });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Stats
|
|
155
|
+
tunnelCommand
|
|
156
|
+
.command("stats")
|
|
157
|
+
.description("Get tunnel stats (in-memory or alias totals)")
|
|
158
|
+
.requiredOption("--id <id>", "Tunnel id")
|
|
159
|
+
.option("--json", "Output JSON", false)
|
|
160
|
+
.action(async (opts) => {
|
|
161
|
+
try {
|
|
162
|
+
const result = await apiRequest("GET", `/v1/tunnels/${opts.id}/stats`) as TunnelStatsResponse;
|
|
163
|
+
if (opts.json) {
|
|
164
|
+
printJson(result);
|
|
165
|
+
} else {
|
|
166
|
+
console.log(`Stats for tunnel ${opts.id}`);
|
|
167
|
+
console.log(JSON.stringify(result, null, 2));
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
handleError(error, { json: opts.json });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Stop (delete) tunnel
|
|
175
|
+
tunnelCommand
|
|
176
|
+
.command("stop")
|
|
177
|
+
.description("Stop (delete) a tunnel")
|
|
178
|
+
.requiredOption("--id <id>", "Tunnel id")
|
|
179
|
+
.option("--json", "Output JSON", false)
|
|
180
|
+
.action(async (opts) => {
|
|
181
|
+
try {
|
|
182
|
+
const result = await apiRequest("DELETE", `/v1/tunnels/${opts.id}`) as { id: string; status: string };
|
|
183
|
+
if (opts.json) {
|
|
184
|
+
printJson(result);
|
|
185
|
+
} else {
|
|
186
|
+
console.log(`Stopped tunnel ${result.id} (status=${result.status})`);
|
|
187
|
+
}
|
|
188
|
+
} catch (error: any) {
|
|
189
|
+
console.error(error?.message || String(error));
|
|
190
|
+
process.exit(30);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-mode helpers for CLI commands.
|
|
3
|
+
* Ensures JSON-only stdout in --json mode and consistent exit codes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type MachineOptions = { json?: boolean };
|
|
7
|
+
|
|
8
|
+
export function printJson(data: unknown) {
|
|
9
|
+
process.stdout.write(JSON.stringify(data, null, 2));
|
|
10
|
+
process.stdout.write("\n");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function selectExitCode(message: string): number {
|
|
14
|
+
const msg = message.toLowerCase();
|
|
15
|
+
if (
|
|
16
|
+
msg.includes("missing agentcloud_token") ||
|
|
17
|
+
msg.includes("unauthorized") ||
|
|
18
|
+
msg.includes("forbidden") ||
|
|
19
|
+
msg.includes("401") ||
|
|
20
|
+
msg.includes("403")
|
|
21
|
+
) {
|
|
22
|
+
return 10; // auth missing/invalid
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
msg.includes("econnrefused") ||
|
|
26
|
+
msg.includes("enotfound") ||
|
|
27
|
+
msg.includes("etimedout") ||
|
|
28
|
+
msg.includes("econnreset") ||
|
|
29
|
+
msg.includes("socket hang up") ||
|
|
30
|
+
msg.includes("network")
|
|
31
|
+
) {
|
|
32
|
+
return 20; // network
|
|
33
|
+
}
|
|
34
|
+
return 30; // server/unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function handleError(error: unknown, opts: MachineOptions = {}) {
|
|
38
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
39
|
+
if (opts.json) {
|
|
40
|
+
printJson({ error: message });
|
|
41
|
+
} else {
|
|
42
|
+
console.error(message);
|
|
43
|
+
}
|
|
44
|
+
process.exit(selectExitCode(message));
|
|
45
|
+
}
|
package/package.json
CHANGED