@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,113 @@
|
|
|
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 { clearLastProvider, readLastProvider, writeLastProvider } from "../expose-last-provider.ts";
|
|
6
|
+
|
|
7
|
+
function makeEnv(): { path: string; cleanup: () => void } {
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-last-provider-"));
|
|
9
|
+
return {
|
|
10
|
+
path: join(dir, "expose-last-provider.json"),
|
|
11
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("expose-last-provider", () => {
|
|
16
|
+
test("returns undefined when file is missing", () => {
|
|
17
|
+
const env = makeEnv();
|
|
18
|
+
try {
|
|
19
|
+
expect(readLastProvider(env.path)).toBeUndefined();
|
|
20
|
+
} finally {
|
|
21
|
+
env.cleanup();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("round-trips a provider", () => {
|
|
26
|
+
const env = makeEnv();
|
|
27
|
+
try {
|
|
28
|
+
writeLastProvider("cloudflare", {
|
|
29
|
+
path: env.path,
|
|
30
|
+
now: () => new Date("2026-04-22T12:34:56Z"),
|
|
31
|
+
});
|
|
32
|
+
const record = readLastProvider(env.path);
|
|
33
|
+
expect(record).toEqual({
|
|
34
|
+
version: 1,
|
|
35
|
+
provider: "cloudflare",
|
|
36
|
+
writtenAt: "2026-04-22T12:34:56.000Z",
|
|
37
|
+
});
|
|
38
|
+
} finally {
|
|
39
|
+
env.cleanup();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("atomic write leaves no tmp file behind on success", () => {
|
|
44
|
+
const env = makeEnv();
|
|
45
|
+
try {
|
|
46
|
+
writeLastProvider("tailscale", { path: env.path });
|
|
47
|
+
const contents = readFileSync(env.path, "utf8");
|
|
48
|
+
// rename (not raw write) is the atomic path — body has to be valid JSON.
|
|
49
|
+
expect(() => JSON.parse(contents)).not.toThrow();
|
|
50
|
+
} finally {
|
|
51
|
+
env.cleanup();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("returns undefined on corrupt JSON rather than throwing", () => {
|
|
56
|
+
const env = makeEnv();
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(env.path, "{ not: json");
|
|
59
|
+
expect(readLastProvider(env.path)).toBeUndefined();
|
|
60
|
+
} finally {
|
|
61
|
+
env.cleanup();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("returns undefined on schema mismatch (wrong provider value)", () => {
|
|
66
|
+
const env = makeEnv();
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(
|
|
69
|
+
env.path,
|
|
70
|
+
JSON.stringify({ version: 1, provider: "aws", writtenAt: "2026-04-22T00:00:00Z" }),
|
|
71
|
+
);
|
|
72
|
+
expect(readLastProvider(env.path)).toBeUndefined();
|
|
73
|
+
} finally {
|
|
74
|
+
env.cleanup();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("returns undefined on unknown version", () => {
|
|
79
|
+
const env = makeEnv();
|
|
80
|
+
try {
|
|
81
|
+
writeFileSync(
|
|
82
|
+
env.path,
|
|
83
|
+
JSON.stringify({ version: 99, provider: "tailscale", writtenAt: "2026-04-22T00:00:00Z" }),
|
|
84
|
+
);
|
|
85
|
+
expect(readLastProvider(env.path)).toBeUndefined();
|
|
86
|
+
} finally {
|
|
87
|
+
env.cleanup();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("clearLastProvider removes the file", () => {
|
|
92
|
+
const env = makeEnv();
|
|
93
|
+
try {
|
|
94
|
+
writeLastProvider("tailscale", { path: env.path });
|
|
95
|
+
expect(readLastProvider(env.path)).toBeDefined();
|
|
96
|
+
clearLastProvider(env.path);
|
|
97
|
+
expect(readLastProvider(env.path)).toBeUndefined();
|
|
98
|
+
} finally {
|
|
99
|
+
env.cleanup();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("overwriting updates the stored provider", () => {
|
|
104
|
+
const env = makeEnv();
|
|
105
|
+
try {
|
|
106
|
+
writeLastProvider("tailscale", { path: env.path });
|
|
107
|
+
writeLastProvider("cloudflare", { path: env.path });
|
|
108
|
+
expect(readLastProvider(env.path)?.provider).toBe("cloudflare");
|
|
109
|
+
} finally {
|
|
110
|
+
env.cleanup();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { CloudflaredState } from "../cloudflare/state.ts";
|
|
3
|
+
import {
|
|
4
|
+
type ExposePublicOffAutoOpts,
|
|
5
|
+
runExposePublicOffAutoDetect,
|
|
6
|
+
} from "../commands/expose-off-auto.ts";
|
|
7
|
+
import type { ExposeState } from "../expose-state.ts";
|
|
8
|
+
|
|
9
|
+
function tailscaleState(overrides: Partial<ExposeState> = {}): ExposeState {
|
|
10
|
+
return {
|
|
11
|
+
version: 1,
|
|
12
|
+
layer: "public",
|
|
13
|
+
mode: "path",
|
|
14
|
+
canonicalFqdn: "box.tail-scale.ts.net",
|
|
15
|
+
port: 8080,
|
|
16
|
+
funnel: true,
|
|
17
|
+
entries: [
|
|
18
|
+
{
|
|
19
|
+
kind: "proxy",
|
|
20
|
+
mount: "/vault/default",
|
|
21
|
+
target: "http://127.0.0.1:8080",
|
|
22
|
+
service: "parachute-vault",
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cloudflaredState(overrides: Partial<CloudflaredState> = {}): CloudflaredState {
|
|
30
|
+
return {
|
|
31
|
+
version: 1,
|
|
32
|
+
pid: 4242,
|
|
33
|
+
tunnelUuid: "11111111-2222-3333-4444-555555555555",
|
|
34
|
+
tunnelName: "vault-tunnel",
|
|
35
|
+
hostname: "vault.example.com",
|
|
36
|
+
startedAt: "2026-04-23T10:00:00.000Z",
|
|
37
|
+
configPath: "/tmp/config.yml",
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Harness {
|
|
43
|
+
logs: string[];
|
|
44
|
+
prompts: string[];
|
|
45
|
+
tailscaleCalls: number;
|
|
46
|
+
cloudflareCalls: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeHarness(
|
|
50
|
+
input: {
|
|
51
|
+
tsState?: ExposeState;
|
|
52
|
+
cfState?: CloudflaredState;
|
|
53
|
+
promptAnswers?: string[];
|
|
54
|
+
isTty?: boolean;
|
|
55
|
+
tsExitCode?: number;
|
|
56
|
+
cfExitCode?: number;
|
|
57
|
+
} = {},
|
|
58
|
+
): {
|
|
59
|
+
harness: Harness;
|
|
60
|
+
opts: ExposePublicOffAutoOpts;
|
|
61
|
+
} {
|
|
62
|
+
const harness: Harness = {
|
|
63
|
+
logs: [],
|
|
64
|
+
prompts: [],
|
|
65
|
+
tailscaleCalls: 0,
|
|
66
|
+
cloudflareCalls: 0,
|
|
67
|
+
};
|
|
68
|
+
const answers = [...(input.promptAnswers ?? [])];
|
|
69
|
+
let i = 0;
|
|
70
|
+
const opts: ExposePublicOffAutoOpts = {
|
|
71
|
+
log: (l) => harness.logs.push(l),
|
|
72
|
+
isTty: input.isTty ?? true,
|
|
73
|
+
readTailscaleState: () => input.tsState,
|
|
74
|
+
readCloudflaredState: () => input.cfState,
|
|
75
|
+
prompt: async (q) => {
|
|
76
|
+
harness.prompts.push(q);
|
|
77
|
+
const a = answers[i++];
|
|
78
|
+
if (a === undefined) throw new Error(`prompt exhausted at: ${q}`);
|
|
79
|
+
return a;
|
|
80
|
+
},
|
|
81
|
+
exposePublicImpl: async (_action) => {
|
|
82
|
+
harness.tailscaleCalls++;
|
|
83
|
+
return input.tsExitCode ?? 0;
|
|
84
|
+
},
|
|
85
|
+
exposeCloudflareOffImpl: async () => {
|
|
86
|
+
harness.cloudflareCalls++;
|
|
87
|
+
return input.cfExitCode ?? 0;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
return { harness, opts };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("runExposePublicOffAutoDetect — neither live", () => {
|
|
94
|
+
test("quiet no-op, exit 0, no teardown called", async () => {
|
|
95
|
+
const { harness, opts } = makeHarness();
|
|
96
|
+
const code = await runExposePublicOffAutoDetect(opts);
|
|
97
|
+
expect(code).toBe(0);
|
|
98
|
+
expect(harness.tailscaleCalls).toBe(0);
|
|
99
|
+
expect(harness.cloudflareCalls).toBe(0);
|
|
100
|
+
expect(harness.prompts).toHaveLength(0);
|
|
101
|
+
expect(harness.logs).toEqual(["No public exposure active. Nothing to tear down."]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("tailnet-layer state is NOT counted as public", async () => {
|
|
105
|
+
// A tailnet exposure has layer==="tailnet" and funnel===false. The auto
|
|
106
|
+
// path is scoped to public. State files co-exist; we must not tear down a
|
|
107
|
+
// tailnet exposure when the user typed `expose public off`.
|
|
108
|
+
const tsState = tailscaleState({ layer: "tailnet", funnel: false });
|
|
109
|
+
const { harness, opts } = makeHarness({ tsState });
|
|
110
|
+
const code = await runExposePublicOffAutoDetect(opts);
|
|
111
|
+
expect(code).toBe(0);
|
|
112
|
+
expect(harness.tailscaleCalls).toBe(0);
|
|
113
|
+
expect(harness.logs).toEqual(["No public exposure active. Nothing to tear down."]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("public state with no entries is not counted as live", async () => {
|
|
117
|
+
const tsState = tailscaleState({ entries: [] });
|
|
118
|
+
const { harness, opts } = makeHarness({ tsState });
|
|
119
|
+
const code = await runExposePublicOffAutoDetect(opts);
|
|
120
|
+
expect(code).toBe(0);
|
|
121
|
+
expect(harness.tailscaleCalls).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("runExposePublicOffAutoDetect — exactly one live", () => {
|
|
126
|
+
test("tailscale-only → tears down tailscale, prints summary", async () => {
|
|
127
|
+
const { harness, opts } = makeHarness({ tsState: tailscaleState() });
|
|
128
|
+
const code = await runExposePublicOffAutoDetect(opts);
|
|
129
|
+
expect(code).toBe(0);
|
|
130
|
+
expect(harness.tailscaleCalls).toBe(1);
|
|
131
|
+
expect(harness.cloudflareCalls).toBe(0);
|
|
132
|
+
expect(harness.logs).toContain(
|
|
133
|
+
"✓ Tore down Tailscale Funnel (was: https://box.tail-scale.ts.net)",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("cloudflare-only → tears down cloudflare, prints summary", async () => {
|
|
138
|
+
const { harness, opts } = makeHarness({ cfState: cloudflaredState() });
|
|
139
|
+
const code = await runExposePublicOffAutoDetect(opts);
|
|
140
|
+
expect(code).toBe(0);
|
|
141
|
+
expect(harness.tailscaleCalls).toBe(0);
|
|
142
|
+
expect(harness.cloudflareCalls).toBe(1);
|
|
143
|
+
expect(harness.logs).toContain(
|
|
144
|
+
"✓ Tore down Cloudflare Tunnel (was: https://vault.example.com)",
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("teardown failure propagates exit code and suppresses summary line", async () => {
|
|
149
|
+
const { harness, opts } = makeHarness({ tsState: tailscaleState(), tsExitCode: 2 });
|
|
150
|
+
const code = await runExposePublicOffAutoDetect(opts);
|
|
151
|
+
expect(code).toBe(2);
|
|
152
|
+
expect(harness.tailscaleCalls).toBe(1);
|
|
153
|
+
// Summary line printed only on success — the inner teardown already
|
|
154
|
+
// explained what went wrong.
|
|
155
|
+
expect(harness.logs.some((l) => l.startsWith("✓ Tore down"))).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("runExposePublicOffAutoDetect — both live (TTY prompt)", () => {
|
|
160
|
+
test("Enter defaults to 'both' — tears down tailscale then cloudflare", async () => {
|
|
161
|
+
const { harness, opts } = makeHarness({
|
|
162
|
+
tsState: tailscaleState(),
|
|
163
|
+
cfState: cloudflaredState(),
|
|
164
|
+
promptAnswers: [""],
|
|
165
|
+
isTty: true,
|
|
166
|
+
});
|
|
167
|
+
const code = await runExposePublicOffAutoDetect(opts);
|
|
168
|
+
expect(code).toBe(0);
|
|
169
|
+
expect(harness.tailscaleCalls).toBe(1);
|
|
170
|
+
expect(harness.cloudflareCalls).toBe(1);
|
|
171
|
+
expect(harness.prompts).toHaveLength(1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("'1' tears down tailscale only", async () => {
|
|
175
|
+
const { harness, opts } = makeHarness({
|
|
176
|
+
tsState: tailscaleState(),
|
|
177
|
+
cfState: cloudflaredState(),
|
|
178
|
+
promptAnswers: ["1"],
|
|
179
|
+
isTty: true,
|
|
180
|
+
});
|
|
181
|
+
const code = await runExposePublicOffAutoDetect(opts);
|
|
182
|
+
expect(code).toBe(0);
|
|
183
|
+
expect(harness.tailscaleCalls).toBe(1);
|
|
184
|
+
expect(harness.cloudflareCalls).toBe(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("'2' tears down cloudflare only", async () => {
|
|
188
|
+
const { harness, opts } = makeHarness({
|
|
189
|
+
tsState: tailscaleState(),
|
|
190
|
+
cfState: cloudflaredState(),
|
|
191
|
+
promptAnswers: ["2"],
|
|
192
|
+
isTty: true,
|
|
193
|
+
});
|
|
194
|
+
const code = await runExposePublicOffAutoDetect(opts);
|
|
195
|
+
expect(code).toBe(0);
|
|
196
|
+
expect(harness.tailscaleCalls).toBe(0);
|
|
197
|
+
expect(harness.cloudflareCalls).toBe(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("'3' explicitly selects both", async () => {
|
|
201
|
+
const { harness, opts } = makeHarness({
|
|
202
|
+
tsState: tailscaleState(),
|
|
203
|
+
cfState: cloudflaredState(),
|
|
204
|
+
promptAnswers: ["3"],
|
|
205
|
+
isTty: true,
|
|
206
|
+
});
|
|
207
|
+
await runExposePublicOffAutoDetect(opts);
|
|
208
|
+
expect(harness.tailscaleCalls).toBe(1);
|
|
209
|
+
expect(harness.cloudflareCalls).toBe(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("'4' cancels — exit 0, no teardown", async () => {
|
|
213
|
+
const { harness, opts } = makeHarness({
|
|
214
|
+
tsState: tailscaleState(),
|
|
215
|
+
cfState: cloudflaredState(),
|
|
216
|
+
promptAnswers: ["4"],
|
|
217
|
+
isTty: true,
|
|
218
|
+
});
|
|
219
|
+
const code = await runExposePublicOffAutoDetect(opts);
|
|
220
|
+
expect(code).toBe(0);
|
|
221
|
+
expect(harness.tailscaleCalls).toBe(0);
|
|
222
|
+
expect(harness.cloudflareCalls).toBe(0);
|
|
223
|
+
expect(harness.logs).toContain("Cancelled — no teardown.");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("unknown input re-prompts", async () => {
|
|
227
|
+
const { harness, opts } = makeHarness({
|
|
228
|
+
tsState: tailscaleState(),
|
|
229
|
+
cfState: cloudflaredState(),
|
|
230
|
+
promptAnswers: ["huh?", "1"],
|
|
231
|
+
isTty: true,
|
|
232
|
+
});
|
|
233
|
+
const code = await runExposePublicOffAutoDetect(opts);
|
|
234
|
+
expect(code).toBe(0);
|
|
235
|
+
expect(harness.prompts).toHaveLength(2);
|
|
236
|
+
expect(harness.tailscaleCalls).toBe(1);
|
|
237
|
+
expect(harness.cloudflareCalls).toBe(0);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("both-teardown: failure from tailscale propagates, cloudflare still runs", async () => {
|
|
241
|
+
const { harness, opts } = makeHarness({
|
|
242
|
+
tsState: tailscaleState(),
|
|
243
|
+
cfState: cloudflaredState(),
|
|
244
|
+
promptAnswers: ["3"],
|
|
245
|
+
isTty: true,
|
|
246
|
+
tsExitCode: 2,
|
|
247
|
+
});
|
|
248
|
+
const code = await runExposePublicOffAutoDetect(opts);
|
|
249
|
+
expect(code).toBe(2);
|
|
250
|
+
expect(harness.tailscaleCalls).toBe(1);
|
|
251
|
+
expect(harness.cloudflareCalls).toBe(1);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("runExposePublicOffAutoDetect — both live (non-TTY)", () => {
|
|
256
|
+
test("tears down both without prompting", async () => {
|
|
257
|
+
const { harness, opts } = makeHarness({
|
|
258
|
+
tsState: tailscaleState(),
|
|
259
|
+
cfState: cloudflaredState(),
|
|
260
|
+
isTty: false,
|
|
261
|
+
});
|
|
262
|
+
const code = await runExposePublicOffAutoDetect(opts);
|
|
263
|
+
expect(code).toBe(0);
|
|
264
|
+
expect(harness.prompts).toHaveLength(0);
|
|
265
|
+
expect(harness.tailscaleCalls).toBe(1);
|
|
266
|
+
expect(harness.cloudflareCalls).toBe(1);
|
|
267
|
+
expect(harness.logs).toContain("(non-TTY: tearing down both.)");
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
type ExposeState,
|
|
7
|
+
ExposeStateError,
|
|
8
|
+
clearExposeState,
|
|
9
|
+
readExposeState,
|
|
10
|
+
writeExposeState,
|
|
11
|
+
} from "../expose-state.ts";
|
|
12
|
+
|
|
13
|
+
function makeTempPath(): { path: string; cleanup: () => void } {
|
|
14
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-state-"));
|
|
15
|
+
return {
|
|
16
|
+
path: join(dir, "expose-state.json"),
|
|
17
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sample: ExposeState = {
|
|
22
|
+
version: 1,
|
|
23
|
+
layer: "tailnet",
|
|
24
|
+
mode: "path",
|
|
25
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
26
|
+
port: 443,
|
|
27
|
+
funnel: false,
|
|
28
|
+
entries: [
|
|
29
|
+
{
|
|
30
|
+
kind: "proxy",
|
|
31
|
+
mount: "/",
|
|
32
|
+
target: "http://127.0.0.1:1940",
|
|
33
|
+
service: "parachute-vault",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
kind: "file",
|
|
37
|
+
mount: "/.well-known/parachute.json",
|
|
38
|
+
target: "/home/x/.parachute/well-known/parachute.json",
|
|
39
|
+
service: "well-known",
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe("expose-state", () => {
|
|
45
|
+
test("readExposeState returns undefined when missing", () => {
|
|
46
|
+
const { path, cleanup } = makeTempPath();
|
|
47
|
+
try {
|
|
48
|
+
expect(readExposeState(path)).toBeUndefined();
|
|
49
|
+
} finally {
|
|
50
|
+
cleanup();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("write + read round-trip", () => {
|
|
55
|
+
const { path, cleanup } = makeTempPath();
|
|
56
|
+
try {
|
|
57
|
+
writeExposeState(sample, path);
|
|
58
|
+
expect(readExposeState(path)).toEqual(sample);
|
|
59
|
+
} finally {
|
|
60
|
+
cleanup();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("clearExposeState removes the file", () => {
|
|
65
|
+
const { path, cleanup } = makeTempPath();
|
|
66
|
+
try {
|
|
67
|
+
writeExposeState(sample, path);
|
|
68
|
+
expect(existsSync(path)).toBe(true);
|
|
69
|
+
clearExposeState(path);
|
|
70
|
+
expect(existsSync(path)).toBe(false);
|
|
71
|
+
} finally {
|
|
72
|
+
cleanup();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("throws on unsupported version", () => {
|
|
77
|
+
const { path, cleanup } = makeTempPath();
|
|
78
|
+
try {
|
|
79
|
+
writeFileSync(path, JSON.stringify({ ...sample, version: 99 }));
|
|
80
|
+
expect(() => readExposeState(path)).toThrow(/unsupported version/);
|
|
81
|
+
} finally {
|
|
82
|
+
cleanup();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("throws on malformed entries", () => {
|
|
87
|
+
const { path, cleanup } = makeTempPath();
|
|
88
|
+
try {
|
|
89
|
+
writeFileSync(
|
|
90
|
+
path,
|
|
91
|
+
JSON.stringify({
|
|
92
|
+
...sample,
|
|
93
|
+
entries: [{ kind: "proxy", mount: "no-slash", target: "http://x", service: "s" }],
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
expect(() => readExposeState(path)).toThrow(ExposeStateError);
|
|
97
|
+
} finally {
|
|
98
|
+
cleanup();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|