@mycadet/cli 0.12.8 → 0.12.9
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/@mycadet/assistant-api/events/show-cloud-login.ts +2 -2
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/node_modules/@mycadet/service-contracts/package.json +1 -1
- package/package.json +2 -2
- package/src/__tests__/cloud-releases.test.ts +23 -13
- package/src/__tests__/ensure-binary.test.ts +182 -0
- package/src/__tests__/nginx-ingress.test.ts +3 -2
- package/src/__tests__/ngrok-spawn.test.ts +49 -0
- package/src/__tests__/ps-table.test.ts +79 -0
- package/src/__tests__/tailscale.test.ts +1 -1
- package/src/__tests__/teleport.test.ts +1 -1
- package/src/__tests__/tunnel.test.ts +21 -14
- package/src/__tests__/uninstall.test.ts +1 -1
- package/src/__tests__/wake.test.ts +2 -2
- package/src/commands/backup.ts +3 -3
- package/src/commands/completions.test.ts +104 -0
- package/src/commands/completions.ts +87 -0
- package/src/commands/exec.ts +7 -7
- package/src/commands/flags.ts +2 -2
- package/src/commands/hatch.ts +1 -1
- package/src/commands/logs.ts +7 -7
- package/src/commands/ps.ts +57 -8
- package/src/commands/restore.ts +2 -2
- package/src/commands/retire.ts +2 -2
- package/src/commands/rollback.ts +2 -2
- package/src/commands/sleep.ts +2 -2
- package/src/commands/teleport.ts +16 -16
- package/src/commands/terminal.ts +1 -1
- package/src/commands/tunnel.ts +78 -55
- package/src/commands/uninstall.ts +4 -4
- package/src/commands/upgrade.ts +8 -8
- package/src/commands/use.ts +1 -1
- package/src/commands/wake.ts +6 -6
- package/src/index.ts +15 -0
- package/src/lib/__tests__/docker.test.ts +168 -20
- package/src/lib/__tests__/job-polling.test.ts +1 -1
- package/src/lib/cloud-releases.ts +6 -4
- package/src/lib/docker.ts +150 -44
- package/src/lib/ensure-binary.ts +171 -0
- package/src/lib/flag-args.test.ts +1 -1
- package/src/lib/flag-args.ts +1 -1
- package/src/lib/guardian-token.ts +1 -1
- package/src/lib/hatch-local.ts +1 -1
- package/src/lib/job-polling.ts +1 -1
- package/src/lib/local.ts +1 -1
- package/src/lib/nginx-ingress.ts +2 -2
- package/src/lib/pair.ts +1 -1
- package/src/lib/sync-cloud-assistants.ts +2 -2
- package/src/lib/tunnels/cloudflare.ts +21 -11
- package/src/lib/tunnels/ngrok.ts +198 -108
- package/src/lib/tunnels/tailscale.ts +34 -4
- package/src/lib/upgrade-lifecycle.ts +5 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
2
|
+
* `show_cloud_login` SSE event.
|
|
3
3
|
*
|
|
4
4
|
* Emitted by the cloud-connect route when no cloud credentials are
|
|
5
5
|
* stored yet, signalling connected clients to surface the cloud login UI.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
|
|
14
14
|
export const ShowCloudLoginEventSchema = z.object({
|
|
15
|
-
type: z.literal("
|
|
15
|
+
type: z.literal("show_cloud_login"),
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
export type ShowCloudLoginEvent = z.infer<typeof ShowCloudLoginEventSchema>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mycadet/cli",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.9",
|
|
4
4
|
"description": "CLI tools for mycadet",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"format": "prettier --write .",
|
|
22
22
|
"format:check": "prettier --check .",
|
|
23
|
-
"lint": "eslint --max-warnings
|
|
23
|
+
"lint": "eslint --max-warnings 322",
|
|
24
24
|
"lint:unused": "knip --include files,dependencies,unlisted",
|
|
25
25
|
"compile:check": "bun build --compile src/index.ts --outfile /dev/null",
|
|
26
26
|
"test": "bun run scripts/run-tests.ts",
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
resolveOwnImageRefs,
|
|
8
8
|
} from "../lib/cloud-releases.js";
|
|
9
9
|
import { dockerhubImages } from "../lib/docker.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
isDevVersion,
|
|
12
|
+
isLocalVersion,
|
|
13
|
+
isStagingVersion,
|
|
14
|
+
} from "../lib/version-compat.js";
|
|
11
15
|
|
|
12
16
|
const originalFetch = globalThis.fetch;
|
|
13
17
|
|
|
@@ -79,12 +83,12 @@ describe("fetchReleases", () => {
|
|
|
79
83
|
});
|
|
80
84
|
|
|
81
85
|
describe("resolveImageRefs", () => {
|
|
82
|
-
test("released CLI resolves un-suffixed repos tagged with the
|
|
86
|
+
test("released CLI resolves un-suffixed repos tagged with the bare version", () => {
|
|
83
87
|
const tags = resolveImageRefs("v0.12.6", "0.12.6");
|
|
84
|
-
expect(tags.assistant).toBe("mycadet/mycadet-assistant:
|
|
85
|
-
expect(tags.gateway).toBe("mycadet/mycadet-gateway:
|
|
88
|
+
expect(tags.assistant).toBe("mycadet/mycadet-assistant:0.12.6");
|
|
89
|
+
expect(tags.gateway).toBe("mycadet/mycadet-gateway:0.12.6");
|
|
86
90
|
expect(tags["credential-executor"]).toBe(
|
|
87
|
-
"mycadet/mycadet-credential-executor:
|
|
91
|
+
"mycadet/mycadet-credential-executor:0.12.6",
|
|
88
92
|
);
|
|
89
93
|
});
|
|
90
94
|
|
|
@@ -94,31 +98,32 @@ describe("resolveImageRefs", () => {
|
|
|
94
98
|
"0.12.6-dev.202610081023.3a27b77",
|
|
95
99
|
);
|
|
96
100
|
expect(tags.assistant).toBe(
|
|
97
|
-
"mycadet/mycadet-assistant-dev:
|
|
101
|
+
"mycadet/mycadet-assistant-dev:0.12.6-dev.202610081023.3a27b77",
|
|
98
102
|
);
|
|
99
103
|
expect(tags.gateway).toBe(
|
|
100
|
-
"mycadet/mycadet-gateway-dev:
|
|
104
|
+
"mycadet/mycadet-gateway-dev:0.12.6-dev.202610081023.3a27b77",
|
|
101
105
|
);
|
|
102
106
|
expect(tags["credential-executor"]).toBe(
|
|
103
|
-
"mycadet/mycadet-credential-executor-dev:
|
|
107
|
+
"mycadet/mycadet-credential-executor-dev:0.12.6-dev.202610081023.3a27b77",
|
|
104
108
|
);
|
|
105
109
|
});
|
|
106
110
|
|
|
107
111
|
test("repo suffix follows the CLI version, tag follows the target", () => {
|
|
108
112
|
const tags = resolveImageRefs("v0.12.5", "0.12.6-dev.202610081023.3a27b77");
|
|
109
|
-
expect(tags.assistant).toBe("mycadet/mycadet-assistant-dev:
|
|
113
|
+
expect(tags.assistant).toBe("mycadet/mycadet-assistant-dev:0.12.5");
|
|
110
114
|
});
|
|
111
115
|
|
|
112
116
|
test("does not consult the network", async () => {
|
|
113
117
|
mockFetchError();
|
|
114
118
|
const tags = resolveImageRefs("v0.12.6", "0.12.6");
|
|
115
|
-
expect(tags.assistant).toContain(":
|
|
119
|
+
expect(tags.assistant).toContain(":0.12.6");
|
|
116
120
|
});
|
|
117
121
|
|
|
118
|
-
test("normalizes the v-prefix
|
|
122
|
+
test("normalizes the v-prefix off the tag", () => {
|
|
119
123
|
const withoutPrefix = resolveImageRefs("0.12.6", "0.12.6");
|
|
120
124
|
const withPrefix = resolveImageRefs("v0.12.6", "0.12.6");
|
|
121
|
-
expect(withoutPrefix.assistant).toBe(
|
|
125
|
+
expect(withoutPrefix.assistant).toBe("mycadet/mycadet-assistant:0.12.6");
|
|
126
|
+
expect(withPrefix.assistant).toBe(withoutPrefix.assistant);
|
|
122
127
|
});
|
|
123
128
|
});
|
|
124
129
|
|
|
@@ -126,9 +131,14 @@ describe("resolveOwnImageRefs", () => {
|
|
|
126
131
|
test("uses the CLI's own version for both tag and repo suffix", () => {
|
|
127
132
|
const tags = resolveOwnImageRefs("0.12.6-dev.202610081023.3a27b77");
|
|
128
133
|
expect(tags.assistant).toBe(
|
|
129
|
-
"mycadet/mycadet-assistant-dev:
|
|
134
|
+
"mycadet/mycadet-assistant-dev:0.12.6-dev.202610081023.3a27b77",
|
|
130
135
|
);
|
|
131
136
|
});
|
|
137
|
+
|
|
138
|
+
test("falls back to the literal latest tag when the CLI version is unknown", () => {
|
|
139
|
+
const tags = resolveOwnImageRefs("");
|
|
140
|
+
expect(tags.assistant).toBe("mycadet/mycadet-assistant:latest");
|
|
141
|
+
});
|
|
132
142
|
});
|
|
133
143
|
|
|
134
144
|
describe("dockerhubImages", () => {
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Readable, Writable } from "node:stream";
|
|
3
|
+
|
|
4
|
+
import { ensureBinaryInstalled, readLineText } from "../lib/ensure-binary.js";
|
|
5
|
+
|
|
6
|
+
/** A real Writable capturing everything written, as a test output stream. */
|
|
7
|
+
function makeOutput(): { writable: Writable; text(): string } {
|
|
8
|
+
const captured: string[] = [];
|
|
9
|
+
const writable = new Writable({
|
|
10
|
+
write(chunk, _encoding, callback) {
|
|
11
|
+
captured.push(chunk.toString());
|
|
12
|
+
callback();
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
return { writable, text: () => captured.join("") };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("ensureBinaryInstalled", () => {
|
|
19
|
+
test("returns the existing probe result without prompting", async () => {
|
|
20
|
+
const output = makeOutput();
|
|
21
|
+
const version = await ensureBinaryInstalled({
|
|
22
|
+
binary: "nginx",
|
|
23
|
+
purpose: "the tunnel edge",
|
|
24
|
+
packages: { brew: ["brew", "install", "nginx"] },
|
|
25
|
+
manualHint: "install nginx",
|
|
26
|
+
probe: () => "nginx version 1.2",
|
|
27
|
+
confirmInstall: () => {
|
|
28
|
+
throw new Error("must not prompt for a present binary");
|
|
29
|
+
},
|
|
30
|
+
streams: { output: output.writable },
|
|
31
|
+
});
|
|
32
|
+
expect(version).toBe("nginx version 1.2");
|
|
33
|
+
expect(output.text()).toBe("");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("offers to install and runs the first available package manager", async () => {
|
|
37
|
+
const output = makeOutput();
|
|
38
|
+
const installed: string[][] = [];
|
|
39
|
+
let probeCalls = 0;
|
|
40
|
+
const version = await ensureBinaryInstalled({
|
|
41
|
+
binary: "nginx",
|
|
42
|
+
purpose: "the tunnel edge",
|
|
43
|
+
packages: {
|
|
44
|
+
brew: ["brew", "install", "nginx"],
|
|
45
|
+
"apt-get": ["sudo", "apt-get", "install", "-y", "nginx"],
|
|
46
|
+
},
|
|
47
|
+
manualHint: "install nginx yourself",
|
|
48
|
+
probe: () => (++probeCalls > 1 ? "nginx version 1.2" : null),
|
|
49
|
+
pmAvailable: (pm) => pm === "apt-get",
|
|
50
|
+
runInstall: (argv) => {
|
|
51
|
+
installed.push(argv);
|
|
52
|
+
return true;
|
|
53
|
+
},
|
|
54
|
+
confirmInstall: async () => true,
|
|
55
|
+
streams: { output: output.writable },
|
|
56
|
+
});
|
|
57
|
+
expect(version).toBe("nginx version 1.2");
|
|
58
|
+
expect(installed).toEqual([["sudo", "apt-get", "install", "-y", "nginx"]]);
|
|
59
|
+
expect(output.text()).toContain("isn't installed");
|
|
60
|
+
expect(output.text()).not.toContain("apt-get install");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("the default confirm takes Enter as yes", async () => {
|
|
64
|
+
const output = makeOutput();
|
|
65
|
+
const input = Object.assign(new Readable({ read() {} }), {
|
|
66
|
+
isTTY: true,
|
|
67
|
+
});
|
|
68
|
+
const installed: string[][] = [];
|
|
69
|
+
let probeCalls = 0;
|
|
70
|
+
const pending = ensureBinaryInstalled({
|
|
71
|
+
binary: "nginx",
|
|
72
|
+
purpose: "the tunnel edge",
|
|
73
|
+
packages: { brew: ["brew", "install", "nginx"] },
|
|
74
|
+
manualHint: "install nginx yourself",
|
|
75
|
+
probe: () => (++probeCalls > 1 ? "nginx version 1.2" : null),
|
|
76
|
+
pmAvailable: () => true,
|
|
77
|
+
runInstall: (argv) => {
|
|
78
|
+
installed.push(argv);
|
|
79
|
+
return true;
|
|
80
|
+
},
|
|
81
|
+
streams: { input, output: output.writable },
|
|
82
|
+
});
|
|
83
|
+
input.emit("data", "\n");
|
|
84
|
+
expect(await pending).toBe("nginx version 1.2");
|
|
85
|
+
expect(installed).toEqual([["brew", "install", "nginx"]]);
|
|
86
|
+
expect(output.text()).toContain("Install nginx now?");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a declined offer prints the manual hint and installs nothing", async () => {
|
|
90
|
+
const output = makeOutput();
|
|
91
|
+
let ran = false;
|
|
92
|
+
const version = await ensureBinaryInstalled({
|
|
93
|
+
binary: "ngrok",
|
|
94
|
+
purpose: "the ngrok tunnel",
|
|
95
|
+
packages: { brew: ["brew", "install", "ngrok/ngrok/ngrok"] },
|
|
96
|
+
manualHint: "get ngrok from ngrok.com",
|
|
97
|
+
probe: () => null,
|
|
98
|
+
runInstall: () => {
|
|
99
|
+
ran = true;
|
|
100
|
+
return true;
|
|
101
|
+
},
|
|
102
|
+
confirmInstall: async () => false,
|
|
103
|
+
streams: { output: output.writable },
|
|
104
|
+
});
|
|
105
|
+
expect(version).toBe(null);
|
|
106
|
+
expect(ran).toBe(false);
|
|
107
|
+
expect(output.text()).toContain("get ngrok from ngrok.com");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a failed install falls back to the manual hint", async () => {
|
|
111
|
+
const output = makeOutput();
|
|
112
|
+
const version = await ensureBinaryInstalled({
|
|
113
|
+
binary: "nginx",
|
|
114
|
+
purpose: "the tunnel edge",
|
|
115
|
+
packages: { brew: ["brew", "install", "nginx"] },
|
|
116
|
+
manualHint: "install nginx yourself",
|
|
117
|
+
probe: () => null,
|
|
118
|
+
runInstall: () => false,
|
|
119
|
+
confirmInstall: async () => true,
|
|
120
|
+
streams: { output: output.writable },
|
|
121
|
+
});
|
|
122
|
+
expect(version).toBe(null);
|
|
123
|
+
expect(output.text()).toContain("install nginx yourself");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("no matching package manager prints the hint", async () => {
|
|
127
|
+
const output = makeOutput();
|
|
128
|
+
let ran = false;
|
|
129
|
+
const version = await ensureBinaryInstalled({
|
|
130
|
+
binary: "cloudflared",
|
|
131
|
+
purpose: "the cloudflare tunnel",
|
|
132
|
+
packages: { brew: ["brew", "install", "cloudflared"] },
|
|
133
|
+
manualHint: "cloudflared install docs",
|
|
134
|
+
probe: () => null,
|
|
135
|
+
pmAvailable: () => false,
|
|
136
|
+
runInstall: () => {
|
|
137
|
+
ran = true;
|
|
138
|
+
return true;
|
|
139
|
+
},
|
|
140
|
+
confirmInstall: async () => true,
|
|
141
|
+
streams: { output: output.writable },
|
|
142
|
+
});
|
|
143
|
+
expect(version).toBe(null);
|
|
144
|
+
expect(ran).toBe(false);
|
|
145
|
+
expect(output.text()).toContain("cloudflared install docs");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a non-interactive terminal declines instead of hanging", async () => {
|
|
149
|
+
const version = await ensureBinaryInstalled({
|
|
150
|
+
binary: "nginx",
|
|
151
|
+
purpose: "the tunnel edge",
|
|
152
|
+
packages: { brew: ["brew", "install", "nginx"] },
|
|
153
|
+
manualHint: "install nginx yourself",
|
|
154
|
+
probe: () => null,
|
|
155
|
+
confirmInstall: async () => {
|
|
156
|
+
throw new Error("Install confirm requires an interactive terminal.");
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
expect(version).toBe(null);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("readLineText", () => {
|
|
164
|
+
test("resolves the first line and ignores the rest", async () => {
|
|
165
|
+
const output = makeOutput();
|
|
166
|
+
const input = new Readable({ read() {} });
|
|
167
|
+
const pending = readLineText("token: ", {
|
|
168
|
+
input,
|
|
169
|
+
output: output.writable,
|
|
170
|
+
});
|
|
171
|
+
input.emit("data", "abc123\nsecond line\n");
|
|
172
|
+
expect(await pending).toBe("abc123");
|
|
173
|
+
expect(output.text()).toBe("token: ");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("resolves an empty line on an immediate newline", async () => {
|
|
177
|
+
const input = new Readable({ read() {} });
|
|
178
|
+
const pending = readLineText("[Y/n] ", { input });
|
|
179
|
+
input.emit("data", "\n");
|
|
180
|
+
expect(await pending).toBe("");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -2095,8 +2095,9 @@ describe("ensureTunnelEdge", () => {
|
|
|
2095
2095
|
gatewayPort: 7830,
|
|
2096
2096
|
});
|
|
2097
2097
|
|
|
2098
|
-
await expect(promise).rejects.toThrow(
|
|
2099
|
-
|
|
2098
|
+
await expect(promise).rejects.toThrow(
|
|
2099
|
+
"See https://nginx.org/en/docs/install.html for how to install",
|
|
2100
|
+
);
|
|
2100
2101
|
await expect(promise).rejects.toThrow("NGINX_BIN");
|
|
2101
2102
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
2102
2103
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { buildNgrokSpawnArgs, isNgrokAuthError } from "../lib/tunnels/ngrok.js";
|
|
4
|
+
|
|
5
|
+
describe("buildNgrokSpawnArgs", () => {
|
|
6
|
+
test("spawns a plain http agent against the loopback edge", () => {
|
|
7
|
+
expect(buildNgrokSpawnArgs(7830)).toEqual([
|
|
8
|
+
"http",
|
|
9
|
+
"127.0.0.1:7830",
|
|
10
|
+
"--log=stdout",
|
|
11
|
+
]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("never passes --config so the default (authtoken-bearing) config loads", () => {
|
|
15
|
+
const args = buildNgrokSpawnArgs(7830, "me.ngrok.app");
|
|
16
|
+
expect(args.some((arg) => arg.startsWith("--config"))).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("binds a reserved domain when given", () => {
|
|
20
|
+
expect(buildNgrokSpawnArgs(7830, "my-assistant.ngrok.app")).toEqual([
|
|
21
|
+
"http",
|
|
22
|
+
"127.0.0.1:7830",
|
|
23
|
+
"--log=stdout",
|
|
24
|
+
"--domain=my-assistant.ngrok.app",
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("isNgrokAuthError", () => {
|
|
30
|
+
test("recognizes the free-account authtoken requirement", () => {
|
|
31
|
+
expect(
|
|
32
|
+
isNgrokAuthError(
|
|
33
|
+
"FATAL: authentication failed: Usage of ngrok requires a free authtoken. ERR_NGROK_105",
|
|
34
|
+
),
|
|
35
|
+
).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("recognizes an unauthorized token", () => {
|
|
39
|
+
expect(isNgrokAuthError("unauthorized authtoken ERR_NGROK_401")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("does not claim other startup failures are auth errors", () => {
|
|
43
|
+
expect(
|
|
44
|
+
isNgrokAuthError(
|
|
45
|
+
"creating tunnel session failed: dial tcp 3.219.x.x:443: connection refused",
|
|
46
|
+
),
|
|
47
|
+
).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { displayWidth, formatRow } from "../commands/ps.js";
|
|
4
|
+
|
|
5
|
+
describe("displayWidth", () => {
|
|
6
|
+
test("ascii strings count one cell per character", () => {
|
|
7
|
+
expect(displayWidth("healthy")).toBe(7);
|
|
8
|
+
expect(displayWidth("")).toBe(0);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("emoji count two cells", () => {
|
|
12
|
+
expect(displayWidth("🟢")).toBe(2);
|
|
13
|
+
expect(displayWidth("🚀")).toBe(2);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("the ZWJ astronaut sequence counts two cells, not its UTF-16 length", () => {
|
|
17
|
+
// "🧑🚀" is 5 UTF-16 units but renders 2 cells wide.
|
|
18
|
+
expect(displayWidth("🧑🚀 Assistant")).toBe(12);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a variation selector widens a narrow symbol to two cells", () => {
|
|
22
|
+
expect(displayWidth("⚠️ warn")).toBe(7);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("ps table alignment", () => {
|
|
27
|
+
const colWidths = { name: 15, type: 14, status: 10, info: 0 };
|
|
28
|
+
|
|
29
|
+
/** The terminal column where `marker` starts (string indices shift under
|
|
30
|
+
* emoji, which render 2 cells but span 2 UTF-16 units or more). */
|
|
31
|
+
const columnOf = (s: string, marker: string): number =>
|
|
32
|
+
displayWidth(s.slice(0, s.indexOf(marker)));
|
|
33
|
+
|
|
34
|
+
test("emoji cells keep every later column aligned", () => {
|
|
35
|
+
const emojiRow = formatRow(
|
|
36
|
+
{
|
|
37
|
+
name: "slim-yak",
|
|
38
|
+
type: "🧑🚀 Assistant",
|
|
39
|
+
status: "🟢 healthy",
|
|
40
|
+
info: "http://x",
|
|
41
|
+
},
|
|
42
|
+
colWidths,
|
|
43
|
+
);
|
|
44
|
+
const asciiRow = formatRow(
|
|
45
|
+
{
|
|
46
|
+
name: "plain",
|
|
47
|
+
type: "Assistant",
|
|
48
|
+
status: "healthy",
|
|
49
|
+
info: "http://y",
|
|
50
|
+
},
|
|
51
|
+
colWidths,
|
|
52
|
+
);
|
|
53
|
+
expect(columnOf(emojiRow, "http://x")).toBe(columnOf(asciiRow, "http://y"));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("the header separator matches the emoji row's column starts", () => {
|
|
57
|
+
const widths = {
|
|
58
|
+
name: displayWidth("slim-yak-yhpdq6"),
|
|
59
|
+
type: displayWidth("🧑🚀 Assistant"),
|
|
60
|
+
status: displayWidth("🟢 healthy"),
|
|
61
|
+
info: 0,
|
|
62
|
+
};
|
|
63
|
+
const header = formatRow(
|
|
64
|
+
{ name: "NAME", type: "TYPE", status: "STATUS", info: "INFO" },
|
|
65
|
+
widths,
|
|
66
|
+
);
|
|
67
|
+
const row = formatRow(
|
|
68
|
+
{
|
|
69
|
+
name: "slim-yak-yhpdq6",
|
|
70
|
+
type: "🧑🚀 Assistant",
|
|
71
|
+
status: "🟢 healthy",
|
|
72
|
+
info: "http://127.0.0.1:7830",
|
|
73
|
+
},
|
|
74
|
+
widths,
|
|
75
|
+
);
|
|
76
|
+
expect(columnOf(header, "TYPE")).toBe(columnOf(row, "🧑🚀"));
|
|
77
|
+
expect(columnOf(header, "STATUS")).toBe(columnOf(row, "🟢"));
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -79,7 +79,7 @@ function makeDeps(opts: {
|
|
|
79
79
|
const calls: string[][] = [];
|
|
80
80
|
const binary = opts.binary === undefined ? "tailscale" : opts.binary;
|
|
81
81
|
const deps: TailscaleDeps = {
|
|
82
|
-
findBinary: () => binary,
|
|
82
|
+
findBinary: async () => binary,
|
|
83
83
|
run: (_bin, args) => {
|
|
84
84
|
calls.push(args);
|
|
85
85
|
return opts.responses?.[args.join(" ")] ?? ok();
|
|
@@ -2821,7 +2821,7 @@ describe("auth + transient-error resilience", () => {
|
|
|
2821
2821
|
await teleport();
|
|
2822
2822
|
expect(pollCalls).toBeGreaterThanOrEqual(2);
|
|
2823
2823
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
2824
|
-
expect.stringContaining("polling failed
|
|
2824
|
+
expect.stringContaining("polling failed:"),
|
|
2825
2825
|
);
|
|
2826
2826
|
} finally {
|
|
2827
2827
|
restoreFetch();
|
|
@@ -84,9 +84,13 @@ const EDGE_PORT = 18080;
|
|
|
84
84
|
const ensureTunnelEdgeMock = mock<typeof nginxIngress.ensureTunnelEdge>(
|
|
85
85
|
async () => ({ port: EDGE_PORT, started: true, includesWebApp: true }),
|
|
86
86
|
);
|
|
87
|
+
const getNginxVersionMock = mock<typeof nginxIngress.getNginxVersion>(
|
|
88
|
+
() => "nginx version (test)",
|
|
89
|
+
);
|
|
87
90
|
mock.module("../lib/nginx-ingress.js", () => ({
|
|
88
91
|
...realNginxIngress,
|
|
89
92
|
ensureTunnelEdge: ensureTunnelEdgeMock,
|
|
93
|
+
getNginxVersion: getNginxVersionMock,
|
|
90
94
|
}));
|
|
91
95
|
|
|
92
96
|
const { tunnel } = await import("../commands/tunnel.js");
|
|
@@ -324,7 +328,6 @@ describe("tunnel edge targeting", () => {
|
|
|
324
328
|
});
|
|
325
329
|
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
326
330
|
expect(logs).toContain(`Started the nginx edge on 127.0.0.1:${EDGE_PORT}`);
|
|
327
|
-
expect(logs).toContain("serves remote web + webhooks");
|
|
328
331
|
});
|
|
329
332
|
|
|
330
333
|
test("the tunnel level notifies the gateway of the provider's public URL", async () => {
|
|
@@ -673,7 +676,6 @@ describe("tunnel edge targeting", () => {
|
|
|
673
676
|
workspaceDir: join(entry.resources!.instanceDir, "workspace"),
|
|
674
677
|
});
|
|
675
678
|
expect(logs).toContain(`Reusing the nginx edge on 127.0.0.1:${EDGE_PORT}`);
|
|
676
|
-
expect(logs).toContain("serves webhooks only");
|
|
677
679
|
});
|
|
678
680
|
|
|
679
681
|
test("a bare invocation tunnels the default provider", async () => {
|
|
@@ -782,7 +784,7 @@ describe("tunnel edge targeting", () => {
|
|
|
782
784
|
ensureTunnelEdgeMock.mockRejectedValue(
|
|
783
785
|
new Error(
|
|
784
786
|
"nginx is not installed, so the tunnel edge cannot start. " +
|
|
785
|
-
"
|
|
787
|
+
"See https://nginx.org/en/docs/install.html for how to install, " +
|
|
786
788
|
"or point NGINX_BIN at an existing binary.",
|
|
787
789
|
),
|
|
788
790
|
);
|
|
@@ -790,7 +792,9 @@ describe("tunnel edge targeting", () => {
|
|
|
790
792
|
const { exited, errors } = await runTunnelExpectingExit1();
|
|
791
793
|
|
|
792
794
|
expect(exited).toBe(true);
|
|
793
|
-
expect(errors).toContain(
|
|
795
|
+
expect(errors).toContain(
|
|
796
|
+
"See https://nginx.org/en/docs/install.html for how to install",
|
|
797
|
+
);
|
|
794
798
|
expect(runNgrokTunnelMock).not.toHaveBeenCalled();
|
|
795
799
|
expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
|
|
796
800
|
expect(runTailscaleTunnelMock).not.toHaveBeenCalled();
|
|
@@ -1139,13 +1143,14 @@ describe("ngrok --domain spawn args", () => {
|
|
|
1139
1143
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
1140
1144
|
const [cmd, args] = spawnMock.mock.calls[0];
|
|
1141
1145
|
expect(cmd).toBe("ngrok");
|
|
1146
|
+
// Plain `ngrok http` so the user's default config — where their
|
|
1147
|
+
// authtoken lives — is what the agent loads.
|
|
1142
1148
|
expect(args).toEqual([
|
|
1143
|
-
"
|
|
1144
|
-
"
|
|
1145
|
-
`--config=${realNgrok.ngrokConfigPath()}`,
|
|
1149
|
+
"http",
|
|
1150
|
+
"127.0.0.1:7831",
|
|
1146
1151
|
"--log=stdout",
|
|
1152
|
+
"--domain=foo.ngrok.app",
|
|
1147
1153
|
]);
|
|
1148
|
-
// The reserved domain rides the XDG config the agent loads, not a flag.
|
|
1149
1154
|
expect(realNgrok.loadNgrokDomain()).toBe("foo.ngrok.app");
|
|
1150
1155
|
|
|
1151
1156
|
const config = IngressTestConfigSchema.parse(
|
|
@@ -1430,11 +1435,13 @@ describe("ngrok --domain spawn args", () => {
|
|
|
1430
1435
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
1431
1436
|
const [cmd, args] = spawnMock.mock.calls[0];
|
|
1432
1437
|
expect(cmd).toBe("ngrok");
|
|
1438
|
+
// Plain `ngrok http` so the user's default config — where their
|
|
1439
|
+
// authtoken lives — is what the agent loads.
|
|
1433
1440
|
expect(args).toEqual([
|
|
1434
|
-
"
|
|
1435
|
-
"
|
|
1436
|
-
`--config=${realNgrok.ngrokConfigPath()}`,
|
|
1441
|
+
"http",
|
|
1442
|
+
"127.0.0.1:7830",
|
|
1437
1443
|
"--log=stdout",
|
|
1444
|
+
"--domain=foo.ngrok.app",
|
|
1438
1445
|
]);
|
|
1439
1446
|
|
|
1440
1447
|
const config = IngressTestConfigSchema.parse(
|
|
@@ -1469,10 +1476,10 @@ describe("ngrok --domain spawn args", () => {
|
|
|
1469
1476
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
1470
1477
|
const [, args] = spawnMock.mock.calls[0];
|
|
1471
1478
|
expect(args).toEqual([
|
|
1472
|
-
"
|
|
1473
|
-
"
|
|
1474
|
-
`--config=${realNgrok.ngrokConfigPath()}`,
|
|
1479
|
+
"http",
|
|
1480
|
+
"127.0.0.1:7831",
|
|
1475
1481
|
"--log=stdout",
|
|
1482
|
+
"--domain=foo.ngrok.app",
|
|
1476
1483
|
]);
|
|
1477
1484
|
|
|
1478
1485
|
const config = IngressTestConfigSchema.parse(
|
|
@@ -102,7 +102,7 @@ describe("cadet uninstall", () => {
|
|
|
102
102
|
// not an assistant guard.
|
|
103
103
|
await expect(uninstall()).rejects.toThrow("process.exit:1");
|
|
104
104
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
105
|
-
expect.stringContaining("1 assistant
|
|
105
|
+
expect.stringContaining("1 assistant still exists"),
|
|
106
106
|
);
|
|
107
107
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
108
108
|
expect.stringContaining("will be retired now"),
|
|
@@ -1503,14 +1503,14 @@ describe("cadet wake — tunnel edge restore", () => {
|
|
|
1503
1503
|
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
1504
1504
|
ensureTunnelEdgeMock.mockRejectedValue(
|
|
1505
1505
|
new Error(
|
|
1506
|
-
"nginx is not installed, so the tunnel edge cannot start.
|
|
1506
|
+
"nginx is not installed, so the tunnel edge cannot start. See https://nginx.org/en/docs/install.html for how to install, or point NGINX_BIN at an existing binary.",
|
|
1507
1507
|
),
|
|
1508
1508
|
);
|
|
1509
1509
|
|
|
1510
1510
|
await wake();
|
|
1511
1511
|
|
|
1512
1512
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
1513
|
-
expect.stringContaining("
|
|
1513
|
+
expect.stringContaining("https://nginx.org/en/docs/install.html"),
|
|
1514
1514
|
);
|
|
1515
1515
|
// No tunnel is recorded here, so no provider can be named and the advice
|
|
1516
1516
|
// points at the help that lists them.
|
package/src/commands/backup.ts
CHANGED
|
@@ -42,10 +42,10 @@ export const backup = Object.assign(
|
|
|
42
42
|
console.log("Options:");
|
|
43
43
|
console.log(" --output <path> Path to save the .vbundle file");
|
|
44
44
|
console.log(
|
|
45
|
-
"
|
|
45
|
+
" defaults to ~/.local/share/cadet/backups/<name>-<timestamp>.vbundle",
|
|
46
46
|
);
|
|
47
47
|
console.log(
|
|
48
|
-
` --export-timeout <secs> Export request timeout in seconds
|
|
48
|
+
` --export-timeout <secs> Export request timeout in seconds, defaults to ${DEFAULT_EXPORT_TIMEOUT_MS / 1000}`,
|
|
49
49
|
);
|
|
50
50
|
console.log("");
|
|
51
51
|
console.log("Examples:");
|
|
@@ -254,7 +254,7 @@ async function backupPlatform(
|
|
|
254
254
|
const cloudToken = readCloudToken();
|
|
255
255
|
if (!cloudToken) {
|
|
256
256
|
console.error(
|
|
257
|
-
"Not logged in. Run 'cadet login' first
|
|
257
|
+
"Not logged in. Run 'cadet login' first.",
|
|
258
258
|
);
|
|
259
259
|
process.exit(1);
|
|
260
260
|
}
|