@openparachute/hub 0.3.0-rc.1
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/LICENSE +661 -0
- package/README.md +284 -0
- package/package.json +31 -0
- package/src/__tests__/auth.test.ts +101 -0
- package/src/__tests__/auto-wire.test.ts +283 -0
- package/src/__tests__/cli.test.ts +192 -0
- package/src/__tests__/cloudflare-config.test.ts +54 -0
- package/src/__tests__/cloudflare-detect.test.ts +68 -0
- package/src/__tests__/cloudflare-state.test.ts +92 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
- package/src/__tests__/config.test.ts +18 -0
- package/src/__tests__/env-file.test.ts +125 -0
- package/src/__tests__/expose-auth-preflight.test.ts +201 -0
- package/src/__tests__/expose-cloudflare.test.ts +484 -0
- package/src/__tests__/expose-interactive.test.ts +703 -0
- package/src/__tests__/expose-last-provider.test.ts +113 -0
- package/src/__tests__/expose-off-auto.test.ts +269 -0
- package/src/__tests__/expose-state.test.ts +101 -0
- package/src/__tests__/expose.test.ts +1581 -0
- package/src/__tests__/hub-control.test.ts +346 -0
- package/src/__tests__/hub-server.test.ts +157 -0
- package/src/__tests__/hub.test.ts +116 -0
- package/src/__tests__/install.test.ts +1145 -0
- package/src/__tests__/lifecycle.test.ts +608 -0
- package/src/__tests__/migrate.test.ts +422 -0
- package/src/__tests__/notes-serve.test.ts +135 -0
- package/src/__tests__/port-assign.test.ts +178 -0
- package/src/__tests__/process-state.test.ts +140 -0
- package/src/__tests__/scribe-config.test.ts +193 -0
- package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
- package/src/__tests__/services-manifest.test.ts +177 -0
- package/src/__tests__/status.test.ts +347 -0
- package/src/__tests__/tailscale-commands.test.ts +111 -0
- package/src/__tests__/tailscale-detect.test.ts +64 -0
- package/src/__tests__/vault-auth-status.test.ts +164 -0
- package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
- package/src/__tests__/well-known.test.ts +214 -0
- package/src/auto-wire.ts +184 -0
- package/src/cli.ts +482 -0
- package/src/cloudflare/config.ts +58 -0
- package/src/cloudflare/detect.ts +58 -0
- package/src/cloudflare/state.ts +96 -0
- package/src/cloudflare/tunnel.ts +135 -0
- package/src/commands/auth.ts +69 -0
- package/src/commands/expose-auth-preflight.ts +217 -0
- package/src/commands/expose-cloudflare.ts +329 -0
- package/src/commands/expose-interactive.ts +428 -0
- package/src/commands/expose-off-auto.ts +199 -0
- package/src/commands/expose.ts +522 -0
- package/src/commands/install.ts +422 -0
- package/src/commands/lifecycle.ts +324 -0
- package/src/commands/migrate.ts +253 -0
- package/src/commands/scribe-provider-interactive.ts +269 -0
- package/src/commands/status.ts +238 -0
- package/src/commands/vault-tokens-create-interactive.ts +137 -0
- package/src/commands/vault.ts +17 -0
- package/src/config.ts +16 -0
- package/src/env-file.ts +76 -0
- package/src/expose-last-provider.ts +71 -0
- package/src/expose-state.ts +125 -0
- package/src/help.ts +279 -0
- package/src/hub-control.ts +254 -0
- package/src/hub-origin.ts +44 -0
- package/src/hub-server.ts +113 -0
- package/src/hub.ts +674 -0
- package/src/notes-serve.ts +135 -0
- package/src/port-assign.ts +125 -0
- package/src/process-state.ts +111 -0
- package/src/scribe-config.ts +149 -0
- package/src/service-spec.ts +296 -0
- package/src/services-manifest.ts +171 -0
- package/src/tailscale/commands.ts +41 -0
- package/src/tailscale/detect.ts +107 -0
- package/src/tailscale/run.ts +28 -0
- package/src/vault/auth-status.ts +179 -0
- package/src/well-known.ts +127 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
CloudflaredError,
|
|
4
|
+
createTunnel,
|
|
5
|
+
credentialsPath,
|
|
6
|
+
findTunnelByName,
|
|
7
|
+
listTunnels,
|
|
8
|
+
routeDns,
|
|
9
|
+
} from "../cloudflare/tunnel.ts";
|
|
10
|
+
import type { CommandResult, Runner } from "../tailscale/run.ts";
|
|
11
|
+
|
|
12
|
+
function makeRunner(
|
|
13
|
+
expected: string[][],
|
|
14
|
+
results: CommandResult[],
|
|
15
|
+
): {
|
|
16
|
+
runner: Runner;
|
|
17
|
+
seen: string[][];
|
|
18
|
+
} {
|
|
19
|
+
const seen: string[][] = [];
|
|
20
|
+
let i = 0;
|
|
21
|
+
const runner: Runner = async (cmd) => {
|
|
22
|
+
seen.push([...cmd]);
|
|
23
|
+
const exp = expected[i];
|
|
24
|
+
if (exp) expect([...cmd]).toEqual(exp);
|
|
25
|
+
const out = results[i];
|
|
26
|
+
if (!out) throw new Error(`runner called more times than stubs (call #${i + 1})`);
|
|
27
|
+
i++;
|
|
28
|
+
return out;
|
|
29
|
+
};
|
|
30
|
+
return { runner, seen };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("cloudflare tunnel", () => {
|
|
34
|
+
test("listTunnels parses the json array and drops malformed rows", async () => {
|
|
35
|
+
const { runner } = makeRunner(
|
|
36
|
+
[["cloudflared", "tunnel", "list", "--output", "json"]],
|
|
37
|
+
[
|
|
38
|
+
{
|
|
39
|
+
code: 0,
|
|
40
|
+
stdout: JSON.stringify([
|
|
41
|
+
{
|
|
42
|
+
id: "2c1a7c7e-1234-5678-9abc-def012345678",
|
|
43
|
+
name: "parachute",
|
|
44
|
+
created_at: "2026-04-22T00:00:00Z",
|
|
45
|
+
},
|
|
46
|
+
{ id: "other-id-without-name" },
|
|
47
|
+
{ name: "nameonly" },
|
|
48
|
+
"not an object",
|
|
49
|
+
]),
|
|
50
|
+
stderr: "",
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
);
|
|
54
|
+
const tunnels = await listTunnels(runner);
|
|
55
|
+
expect(tunnels).toEqual([
|
|
56
|
+
{
|
|
57
|
+
id: "2c1a7c7e-1234-5678-9abc-def012345678",
|
|
58
|
+
name: "parachute",
|
|
59
|
+
createdAt: "2026-04-22T00:00:00Z",
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("listTunnels throws CloudflaredError on non-zero exit", async () => {
|
|
65
|
+
const { runner } = makeRunner(
|
|
66
|
+
[["cloudflared", "tunnel", "list", "--output", "json"]],
|
|
67
|
+
[{ code: 1, stdout: "", stderr: "Cannot determine default origin certificate path" }],
|
|
68
|
+
);
|
|
69
|
+
try {
|
|
70
|
+
await listTunnels(runner);
|
|
71
|
+
throw new Error("expected throw");
|
|
72
|
+
} catch (err) {
|
|
73
|
+
expect(err).toBeInstanceOf(CloudflaredError);
|
|
74
|
+
expect((err as CloudflaredError).message).toContain("Cannot determine default origin");
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("listTunnels throws on non-array JSON", async () => {
|
|
79
|
+
const { runner } = makeRunner(
|
|
80
|
+
[["cloudflared", "tunnel", "list", "--output", "json"]],
|
|
81
|
+
[{ code: 0, stdout: JSON.stringify({ tunnels: [] }), stderr: "" }],
|
|
82
|
+
);
|
|
83
|
+
await expect(listTunnels(runner)).rejects.toBeInstanceOf(CloudflaredError);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("findTunnelByName returns match", async () => {
|
|
87
|
+
const { runner } = makeRunner(
|
|
88
|
+
[["cloudflared", "tunnel", "list", "--output", "json"]],
|
|
89
|
+
[
|
|
90
|
+
{
|
|
91
|
+
code: 0,
|
|
92
|
+
stdout: JSON.stringify([
|
|
93
|
+
{ id: "aaaaaaaa-0000-0000-0000-000000000001", name: "foo" },
|
|
94
|
+
{ id: "bbbbbbbb-0000-0000-0000-000000000002", name: "parachute" },
|
|
95
|
+
]),
|
|
96
|
+
stderr: "",
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
);
|
|
100
|
+
const t = await findTunnelByName(runner, "parachute");
|
|
101
|
+
expect(t?.id).toBe("bbbbbbbb-0000-0000-0000-000000000002");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("findTunnelByName returns undefined when absent", async () => {
|
|
105
|
+
const { runner } = makeRunner(
|
|
106
|
+
[["cloudflared", "tunnel", "list", "--output", "json"]],
|
|
107
|
+
[{ code: 0, stdout: "[]", stderr: "" }],
|
|
108
|
+
);
|
|
109
|
+
expect(await findTunnelByName(runner, "parachute")).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("createTunnel parses UUID from typical stdout", async () => {
|
|
113
|
+
const { runner } = makeRunner(
|
|
114
|
+
[["cloudflared", "tunnel", "create", "parachute"]],
|
|
115
|
+
[
|
|
116
|
+
{
|
|
117
|
+
code: 0,
|
|
118
|
+
stdout:
|
|
119
|
+
"Tunnel credentials written to /Users/x/.cloudflared/2c1a7c7e-1234-5678-9abc-def012345678.json.\n" +
|
|
120
|
+
"Created tunnel parachute with id 2c1a7c7e-1234-5678-9abc-def012345678\n",
|
|
121
|
+
stderr: "",
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
);
|
|
125
|
+
const t = await createTunnel(runner, "parachute");
|
|
126
|
+
expect(t).toEqual({ id: "2c1a7c7e-1234-5678-9abc-def012345678", name: "parachute" });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("createTunnel throws CloudflaredError when UUID can't be parsed", async () => {
|
|
130
|
+
const { runner } = makeRunner(
|
|
131
|
+
[["cloudflared", "tunnel", "create", "parachute"]],
|
|
132
|
+
[{ code: 0, stdout: "some unexpected output\n", stderr: "" }],
|
|
133
|
+
);
|
|
134
|
+
await expect(createTunnel(runner, "parachute")).rejects.toBeInstanceOf(CloudflaredError);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("createTunnel surfaces cloudflared error output on non-zero exit", async () => {
|
|
138
|
+
const { runner } = makeRunner(
|
|
139
|
+
[["cloudflared", "tunnel", "create", "parachute"]],
|
|
140
|
+
[{ code: 1, stdout: "", stderr: "tunnel with name parachute already exists" }],
|
|
141
|
+
);
|
|
142
|
+
await expect(createTunnel(runner, "parachute")).rejects.toMatchObject({
|
|
143
|
+
message: expect.stringContaining("already exists"),
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("routeDns passes --overwrite-dns and surfaces zone-not-found errors", async () => {
|
|
148
|
+
const { runner, seen } = makeRunner(
|
|
149
|
+
[
|
|
150
|
+
[
|
|
151
|
+
"cloudflared",
|
|
152
|
+
"tunnel",
|
|
153
|
+
"route",
|
|
154
|
+
"dns",
|
|
155
|
+
"--overwrite-dns",
|
|
156
|
+
"parachute",
|
|
157
|
+
"vault.example.com",
|
|
158
|
+
],
|
|
159
|
+
],
|
|
160
|
+
[
|
|
161
|
+
{
|
|
162
|
+
code: 1,
|
|
163
|
+
stdout: "",
|
|
164
|
+
stderr: "Failed to add route: code: 1000, reason: Invalid DNS zone",
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
);
|
|
168
|
+
await expect(routeDns(runner, "parachute", "vault.example.com")).rejects.toMatchObject({
|
|
169
|
+
message: expect.stringContaining("Invalid DNS zone"),
|
|
170
|
+
});
|
|
171
|
+
expect(seen[0]).toEqual([
|
|
172
|
+
"cloudflared",
|
|
173
|
+
"tunnel",
|
|
174
|
+
"route",
|
|
175
|
+
"dns",
|
|
176
|
+
"--overwrite-dns",
|
|
177
|
+
"parachute",
|
|
178
|
+
"vault.example.com",
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("routeDns succeeds on rerun when the CNAME already exists (upsert semantics)", async () => {
|
|
183
|
+
// Without --overwrite-dns this call would exit non-zero with "An A, AAAA,
|
|
184
|
+
// or CNAME record with that host already exists" on the second run. The
|
|
185
|
+
// flag turns it into an idempotent UPSERT, which is what users expect.
|
|
186
|
+
const { runner, seen } = makeRunner(
|
|
187
|
+
[
|
|
188
|
+
[
|
|
189
|
+
"cloudflared",
|
|
190
|
+
"tunnel",
|
|
191
|
+
"route",
|
|
192
|
+
"dns",
|
|
193
|
+
"--overwrite-dns",
|
|
194
|
+
"parachute",
|
|
195
|
+
"vault.example.com",
|
|
196
|
+
],
|
|
197
|
+
],
|
|
198
|
+
[{ code: 0, stdout: "Added CNAME vault.example.com which will route to …", stderr: "" }],
|
|
199
|
+
);
|
|
200
|
+
await routeDns(runner, "parachute", "vault.example.com");
|
|
201
|
+
expect(seen[0]).toContain("--overwrite-dns");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("credentialsPath joins uuid under the cloudflared home", () => {
|
|
205
|
+
expect(credentialsPath("abc", "/Users/x/.cloudflared")).toBe("/Users/x/.cloudflared/abc.json");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { configDir } from "../config.ts";
|
|
5
|
+
|
|
6
|
+
describe("configDir", () => {
|
|
7
|
+
test("honors PARACHUTE_HOME when set", () => {
|
|
8
|
+
expect(configDir({ PARACHUTE_HOME: "/tmp/custom-parachute" })).toBe("/tmp/custom-parachute");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("ignores empty PARACHUTE_HOME", () => {
|
|
12
|
+
expect(configDir({ PARACHUTE_HOME: "" })).toBe(join(homedir(), ".parachute"));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("falls back to ~/.parachute when unset", () => {
|
|
16
|
+
expect(configDir({})).toBe(join(homedir(), ".parachute"));
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
parseEnvFile,
|
|
7
|
+
parseEnvFileText,
|
|
8
|
+
readEnvFileValues,
|
|
9
|
+
upsertEnvLine,
|
|
10
|
+
writeEnvFile,
|
|
11
|
+
} from "../env-file.ts";
|
|
12
|
+
|
|
13
|
+
function makeHarness(): { dir: string; cleanup: () => void } {
|
|
14
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-envfile-"));
|
|
15
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("parseEnvFileText", () => {
|
|
19
|
+
test("parses bare KEY=value lines", () => {
|
|
20
|
+
const parsed = parseEnvFileText("FOO=bar\nBAZ=qux\n");
|
|
21
|
+
expect(parsed.values).toEqual({ FOO: "bar", BAZ: "qux" });
|
|
22
|
+
expect(parsed.lines).toEqual(["FOO=bar", "BAZ=qux"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("strips one level of double quotes", () => {
|
|
26
|
+
const parsed = parseEnvFileText('TOKEN="abc123"\n');
|
|
27
|
+
expect(parsed.values.TOKEN).toBe("abc123");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("strips one level of single quotes", () => {
|
|
31
|
+
const parsed = parseEnvFileText("TOKEN='abc123'\n");
|
|
32
|
+
expect(parsed.values.TOKEN).toBe("abc123");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("preserves embedded equals signs in value", () => {
|
|
36
|
+
const parsed = parseEnvFileText("URL=http://x.example.com/?q=1\n");
|
|
37
|
+
expect(parsed.values.URL).toBe("http://x.example.com/?q=1");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("ignores lines without a key (leading equals or no equals)", () => {
|
|
41
|
+
const parsed = parseEnvFileText("=novalue\nbarewordnoequals\nGOOD=x\n");
|
|
42
|
+
expect(parsed.values).toEqual({ GOOD: "x" });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("empty content yields empty parse", () => {
|
|
46
|
+
const parsed = parseEnvFileText("");
|
|
47
|
+
expect(parsed.lines).toEqual([]);
|
|
48
|
+
expect(parsed.values).toEqual({});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("handles missing trailing newline", () => {
|
|
52
|
+
const parsed = parseEnvFileText("FOO=bar");
|
|
53
|
+
expect(parsed.values.FOO).toBe("bar");
|
|
54
|
+
expect(parsed.lines).toEqual(["FOO=bar"]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("parseEnvFile / readEnvFileValues", () => {
|
|
59
|
+
test("missing file returns empty parse", () => {
|
|
60
|
+
const h = makeHarness();
|
|
61
|
+
try {
|
|
62
|
+
const parsed = parseEnvFile(join(h.dir, "missing.env"));
|
|
63
|
+
expect(parsed.lines).toEqual([]);
|
|
64
|
+
expect(parsed.values).toEqual({});
|
|
65
|
+
expect(readEnvFileValues(join(h.dir, "missing.env"))).toEqual({});
|
|
66
|
+
} finally {
|
|
67
|
+
h.cleanup();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("reads existing file values", () => {
|
|
72
|
+
const h = makeHarness();
|
|
73
|
+
try {
|
|
74
|
+
const path = join(h.dir, ".env");
|
|
75
|
+
writeFileSync(path, "A=1\nB=2\n");
|
|
76
|
+
expect(readEnvFileValues(path)).toEqual({ A: "1", B: "2" });
|
|
77
|
+
} finally {
|
|
78
|
+
h.cleanup();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("upsertEnvLine", () => {
|
|
84
|
+
test("appends when key not present", () => {
|
|
85
|
+
const next = upsertEnvLine(["FOO=1"], "BAR", "2");
|
|
86
|
+
expect(next).toEqual(["FOO=1", "BAR=2"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("replaces in place when key present", () => {
|
|
90
|
+
const next = upsertEnvLine(["FOO=1", "BAR=old", "BAZ=3"], "BAR", "new");
|
|
91
|
+
expect(next).toEqual(["FOO=1", "BAR=new", "BAZ=3"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("does not mutate the input array", () => {
|
|
95
|
+
const input = ["FOO=1"];
|
|
96
|
+
upsertEnvLine(input, "BAR", "2");
|
|
97
|
+
expect(input).toEqual(["FOO=1"]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("writeEnvFile", () => {
|
|
102
|
+
test("creates parent directories and writes content with trailing newline", () => {
|
|
103
|
+
const h = makeHarness();
|
|
104
|
+
try {
|
|
105
|
+
const path = join(h.dir, "nested", "subdir", ".env");
|
|
106
|
+
writeEnvFile(path, ["FOO=1", "BAR=2"]);
|
|
107
|
+
expect(readFileSync(path, "utf8")).toBe("FOO=1\nBAR=2\n");
|
|
108
|
+
} finally {
|
|
109
|
+
h.cleanup();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("round-trips with parseEnvFile", () => {
|
|
114
|
+
const h = makeHarness();
|
|
115
|
+
try {
|
|
116
|
+
const path = join(h.dir, ".env");
|
|
117
|
+
const start = parseEnvFileText("KEEP=ok\nUPDATE=old\n");
|
|
118
|
+
const lines = upsertEnvLine(start.lines, "UPDATE", "new");
|
|
119
|
+
writeEnvFile(path, lines);
|
|
120
|
+
expect(parseEnvFile(path).values).toEqual({ KEEP: "ok", UPDATE: "new" });
|
|
121
|
+
} finally {
|
|
122
|
+
h.cleanup();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { runAuthPreflight } from "../commands/expose-auth-preflight.ts";
|
|
3
|
+
import type { VaultAuthStatus } from "../vault/auth-status.ts";
|
|
4
|
+
|
|
5
|
+
interface Harness {
|
|
6
|
+
logs: string[];
|
|
7
|
+
prompts: string[];
|
|
8
|
+
promptAnswers: string[];
|
|
9
|
+
commands: string[][];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makeHarness(answers: string[] = []): Harness {
|
|
13
|
+
return { logs: [], prompts: [], promptAnswers: answers, commands: [] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function wire(h: Harness) {
|
|
17
|
+
let i = 0;
|
|
18
|
+
return {
|
|
19
|
+
log: (line: string) => h.logs.push(line),
|
|
20
|
+
prompt: async (q: string) => {
|
|
21
|
+
h.prompts.push(q);
|
|
22
|
+
const answer = h.promptAnswers[i++];
|
|
23
|
+
if (answer === undefined) throw new Error(`prompt exhausted at: ${q}`);
|
|
24
|
+
return answer;
|
|
25
|
+
},
|
|
26
|
+
interactiveRunner: async (cmd: readonly string[]) => {
|
|
27
|
+
h.commands.push([...cmd]);
|
|
28
|
+
return 0;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function status(partial: Partial<VaultAuthStatus> = {}): VaultAuthStatus {
|
|
34
|
+
return {
|
|
35
|
+
hasOwnerPassword: false,
|
|
36
|
+
hasTotp: false,
|
|
37
|
+
tokenCount: 0,
|
|
38
|
+
vaultNames: ["default"],
|
|
39
|
+
...partial,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("runAuthPreflight — wide open (no password, no tokens)", () => {
|
|
44
|
+
test("warns loudly and offers password, 2FA, and token creation", async () => {
|
|
45
|
+
const h = makeHarness(["y", "n", "n"]); // password yes, 2fa no, token no
|
|
46
|
+
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
47
|
+
const joined = h.logs.join("\n");
|
|
48
|
+
expect(joined).toContain("No owner password and no API tokens");
|
|
49
|
+
expect(joined).toContain("public internet");
|
|
50
|
+
expect(h.commands).toHaveLength(1);
|
|
51
|
+
expect(h.commands[0]).toEqual(["parachute", "auth", "set-password"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("user declines every prompt → no subprocesses run", async () => {
|
|
55
|
+
const h = makeHarness(["", "", ""]); // all Enter = skip
|
|
56
|
+
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
57
|
+
expect(h.commands).toHaveLength(0);
|
|
58
|
+
// Still prompted on all three lines, even though each was declined.
|
|
59
|
+
expect(h.prompts).toHaveLength(3);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("user accepts all three → all three commands invoked in order", async () => {
|
|
63
|
+
const h = makeHarness(["y", "y", "y"]);
|
|
64
|
+
await runAuthPreflight({ status: status(), ...wire(h) });
|
|
65
|
+
expect(h.commands.map((c) => c.join(" "))).toEqual([
|
|
66
|
+
"parachute auth set-password",
|
|
67
|
+
"parachute auth 2fa enroll",
|
|
68
|
+
"parachute vault tokens create",
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("runAuthPreflight — password set, no 2FA", () => {
|
|
74
|
+
test("short nudge, offers 2FA only", async () => {
|
|
75
|
+
const h = makeHarness(["y"]);
|
|
76
|
+
await runAuthPreflight({
|
|
77
|
+
status: status({ hasOwnerPassword: true, tokenCount: 3 }),
|
|
78
|
+
...wire(h),
|
|
79
|
+
});
|
|
80
|
+
const joined = h.logs.join("\n");
|
|
81
|
+
expect(joined).toContain("Owner password is set");
|
|
82
|
+
expect(joined).toContain("2FA");
|
|
83
|
+
expect(h.prompts).toHaveLength(1);
|
|
84
|
+
expect(h.commands).toEqual([["parachute", "auth", "2fa", "enroll"]]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("user declines → no command runs", async () => {
|
|
88
|
+
const h = makeHarness([""]);
|
|
89
|
+
await runAuthPreflight({
|
|
90
|
+
status: status({ hasOwnerPassword: true, tokenCount: 3 }),
|
|
91
|
+
...wire(h),
|
|
92
|
+
});
|
|
93
|
+
expect(h.commands).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("runAuthPreflight — tokens exist, no password", () => {
|
|
98
|
+
test("notes that OAuth is not set up, offers password", async () => {
|
|
99
|
+
const h = makeHarness(["y"]);
|
|
100
|
+
await runAuthPreflight({
|
|
101
|
+
status: status({ hasOwnerPassword: false, tokenCount: 2 }),
|
|
102
|
+
...wire(h),
|
|
103
|
+
});
|
|
104
|
+
const joined = h.logs.join("\n");
|
|
105
|
+
expect(joined).toContain("API tokens exist");
|
|
106
|
+
expect(joined).toContain("no owner password");
|
|
107
|
+
expect(h.prompts).toHaveLength(1);
|
|
108
|
+
expect(h.commands).toEqual([["parachute", "auth", "set-password"]]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("runAuthPreflight — unknown token count (SQLite failed)", () => {
|
|
113
|
+
test("advises running `tokens list`, no token-dependent prompts", async () => {
|
|
114
|
+
const h = makeHarness([]);
|
|
115
|
+
await runAuthPreflight({
|
|
116
|
+
status: status({ hasOwnerPassword: false, hasTotp: false, tokenCount: null }),
|
|
117
|
+
...wire(h),
|
|
118
|
+
});
|
|
119
|
+
const joined = h.logs.join("\n");
|
|
120
|
+
expect(joined).toContain("Couldn't read vault token state");
|
|
121
|
+
expect(joined).toContain("parachute vault tokens list");
|
|
122
|
+
// No prompts because we don't offer password/token flow when token
|
|
123
|
+
// state is unknown (it'd be ambiguous whether we're dealing with the
|
|
124
|
+
// wide-open or the tokens-only case).
|
|
125
|
+
expect(h.prompts).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("password set + 2FA absent + tokens unknown → still offers 2FA", async () => {
|
|
129
|
+
const h = makeHarness([""]); // decline 2FA
|
|
130
|
+
await runAuthPreflight({
|
|
131
|
+
status: status({ hasOwnerPassword: true, hasTotp: false, tokenCount: null }),
|
|
132
|
+
...wire(h),
|
|
133
|
+
});
|
|
134
|
+
expect(h.prompts).toHaveLength(1);
|
|
135
|
+
expect(h.prompts[0]?.toLowerCase()).toContain("2fa");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("runAuthPreflight — all good", () => {
|
|
140
|
+
test("single positive line, no prompts", async () => {
|
|
141
|
+
const h = makeHarness([]);
|
|
142
|
+
await runAuthPreflight({
|
|
143
|
+
status: status({ hasOwnerPassword: true, hasTotp: true, tokenCount: 1 }),
|
|
144
|
+
...wire(h),
|
|
145
|
+
});
|
|
146
|
+
const joined = h.logs.join("\n");
|
|
147
|
+
expect(joined).toContain("looks good");
|
|
148
|
+
expect(h.prompts).toHaveLength(0);
|
|
149
|
+
expect(h.commands).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("runAuthPreflight — subprocess failure handling", () => {
|
|
154
|
+
test("non-zero exit from auth command doesn't abort the rest of the preflight", async () => {
|
|
155
|
+
const h = makeHarness(["y", "y", "y"]);
|
|
156
|
+
// Override the interactive runner to return non-zero on the first call.
|
|
157
|
+
let first = true;
|
|
158
|
+
const interactiveRunner = async (cmd: readonly string[]) => {
|
|
159
|
+
h.commands.push([...cmd]);
|
|
160
|
+
if (first) {
|
|
161
|
+
first = false;
|
|
162
|
+
return 7;
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
};
|
|
166
|
+
await runAuthPreflight({
|
|
167
|
+
status: status(),
|
|
168
|
+
log: (l) => h.logs.push(l),
|
|
169
|
+
prompt: async (q) => {
|
|
170
|
+
h.prompts.push(q);
|
|
171
|
+
return h.promptAnswers.shift() ?? "";
|
|
172
|
+
},
|
|
173
|
+
interactiveRunner,
|
|
174
|
+
});
|
|
175
|
+
// All three commands still attempted, none aborted the flow.
|
|
176
|
+
expect(h.commands.map((c) => c[0])).toEqual(["parachute", "parachute", "parachute"]);
|
|
177
|
+
const joined = h.logs.join("\n");
|
|
178
|
+
expect(joined).toContain("exited 7");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("runAuthPreflight — case-insensitive yes", () => {
|
|
183
|
+
test('"Y", "YES", and "y" all count as affirmative; anything else is decline', async () => {
|
|
184
|
+
for (const yes of ["y", "Y", "yes", "YES"]) {
|
|
185
|
+
const h = makeHarness([yes]);
|
|
186
|
+
await runAuthPreflight({
|
|
187
|
+
status: status({ hasOwnerPassword: true, tokenCount: 1 }),
|
|
188
|
+
...wire(h),
|
|
189
|
+
});
|
|
190
|
+
expect(h.commands).toHaveLength(1);
|
|
191
|
+
}
|
|
192
|
+
for (const no of ["", "n", "no", "q", "bogus"]) {
|
|
193
|
+
const h = makeHarness([no]);
|
|
194
|
+
await runAuthPreflight({
|
|
195
|
+
status: status({ hasOwnerPassword: true, tokenCount: 1 }),
|
|
196
|
+
...wire(h),
|
|
197
|
+
});
|
|
198
|
+
expect(h.commands).toHaveLength(0);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|