@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,183 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { runVaultTokensCreateInteractive } from "../commands/vault-tokens-create-interactive.ts";
|
|
3
|
+
|
|
4
|
+
interface Harness {
|
|
5
|
+
logs: string[];
|
|
6
|
+
prompts: string[];
|
|
7
|
+
promptAnswers: string[];
|
|
8
|
+
commands: string[][];
|
|
9
|
+
exitCode: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makeHarness(
|
|
13
|
+
answers: string[],
|
|
14
|
+
opts: { exitCode?: number } = {},
|
|
15
|
+
): {
|
|
16
|
+
harness: Harness;
|
|
17
|
+
wire: {
|
|
18
|
+
log: (l: string) => void;
|
|
19
|
+
prompt: (q: string) => Promise<string>;
|
|
20
|
+
interactiveRunner: (cmd: readonly string[]) => Promise<number>;
|
|
21
|
+
};
|
|
22
|
+
} {
|
|
23
|
+
const harness: Harness = {
|
|
24
|
+
logs: [],
|
|
25
|
+
prompts: [],
|
|
26
|
+
promptAnswers: [...answers],
|
|
27
|
+
commands: [],
|
|
28
|
+
exitCode: opts.exitCode ?? 0,
|
|
29
|
+
};
|
|
30
|
+
let i = 0;
|
|
31
|
+
return {
|
|
32
|
+
harness,
|
|
33
|
+
wire: {
|
|
34
|
+
log: (line) => harness.logs.push(line),
|
|
35
|
+
prompt: async (q) => {
|
|
36
|
+
harness.prompts.push(q);
|
|
37
|
+
const a = harness.promptAnswers[i++];
|
|
38
|
+
if (a === undefined) throw new Error(`prompt exhausted at: ${q}`);
|
|
39
|
+
return a;
|
|
40
|
+
},
|
|
41
|
+
interactiveRunner: async (cmd) => {
|
|
42
|
+
harness.commands.push([...cmd]);
|
|
43
|
+
return harness.exitCode;
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("runVaultTokensCreateInteractive — scope picker", () => {
|
|
50
|
+
test("Enter defaults to read (safer choice)", async () => {
|
|
51
|
+
const { harness, wire } = makeHarness(["", ""]); // scope=default, label=skip
|
|
52
|
+
const code = await runVaultTokensCreateInteractive({ args: [], ...wire });
|
|
53
|
+
expect(code).toBe(0);
|
|
54
|
+
expect(harness.commands).toHaveLength(1);
|
|
55
|
+
const cmd = harness.commands[0]!;
|
|
56
|
+
expect(cmd).toEqual(["parachute-vault", "tokens", "create", "--read"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("'1' also selects read", async () => {
|
|
60
|
+
const { harness, wire } = makeHarness(["1", ""]);
|
|
61
|
+
await runVaultTokensCreateInteractive({ args: [], ...wire });
|
|
62
|
+
expect(harness.commands[0]).toContain("--read");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("'2' selects write (vault:write scope)", async () => {
|
|
66
|
+
const { harness, wire } = makeHarness(["2", ""]);
|
|
67
|
+
await runVaultTokensCreateInteractive({ args: [], ...wire });
|
|
68
|
+
const cmd = harness.commands[0]!;
|
|
69
|
+
expect(cmd).toEqual(["parachute-vault", "tokens", "create", "--scope", "vault:write"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("'3' selects admin (vault:admin scope)", async () => {
|
|
73
|
+
const { harness, wire } = makeHarness(["3", ""]);
|
|
74
|
+
await runVaultTokensCreateInteractive({ args: [], ...wire });
|
|
75
|
+
const cmd = harness.commands[0]!;
|
|
76
|
+
expect(cmd).toEqual(["parachute-vault", "tokens", "create", "--scope", "vault:admin"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("'4' cancels with exit 0 and no subprocess", async () => {
|
|
80
|
+
const { harness, wire } = makeHarness(["4"]);
|
|
81
|
+
const code = await runVaultTokensCreateInteractive({ args: [], ...wire });
|
|
82
|
+
expect(code).toBe(0);
|
|
83
|
+
expect(harness.commands).toHaveLength(0);
|
|
84
|
+
expect(harness.logs.join("\n")).toContain("Cancelled");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("'q' also cancels", async () => {
|
|
88
|
+
const { harness, wire } = makeHarness(["q"]);
|
|
89
|
+
const code = await runVaultTokensCreateInteractive({ args: [], ...wire });
|
|
90
|
+
expect(code).toBe(0);
|
|
91
|
+
expect(harness.commands).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("word aliases accepted case-insensitively", async () => {
|
|
95
|
+
for (const [answer, expected] of [
|
|
96
|
+
["READ", "--read"],
|
|
97
|
+
["Write", "vault:write"],
|
|
98
|
+
["admin", "vault:admin"],
|
|
99
|
+
] as const) {
|
|
100
|
+
const { harness, wire } = makeHarness([answer, ""]);
|
|
101
|
+
await runVaultTokensCreateInteractive({ args: [], ...wire });
|
|
102
|
+
expect(harness.commands[0]!.join(" ")).toContain(expected);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("garbage input reprompts, keeping the scope picker tight", async () => {
|
|
107
|
+
const { harness, wire } = makeHarness(["bogus", "5", "2", ""]);
|
|
108
|
+
await runVaultTokensCreateInteractive({ args: [], ...wire });
|
|
109
|
+
expect(harness.commands).toHaveLength(1);
|
|
110
|
+
expect(harness.commands[0]).toContain("vault:write");
|
|
111
|
+
// Three scope prompts were asked (bogus, 5, then the valid 2); one label.
|
|
112
|
+
expect(harness.prompts.filter((p) => p.startsWith("Choice"))).toHaveLength(3);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("runVaultTokensCreateInteractive — label prompt", () => {
|
|
117
|
+
test("blank label = no --label flag forwarded", async () => {
|
|
118
|
+
const { harness, wire } = makeHarness(["1", ""]);
|
|
119
|
+
await runVaultTokensCreateInteractive({ args: [], ...wire });
|
|
120
|
+
expect(harness.commands[0]!.includes("--label")).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("non-blank label is appended verbatim", async () => {
|
|
124
|
+
const { harness, wire } = makeHarness(["1", "n8n-sync"]);
|
|
125
|
+
await runVaultTokensCreateInteractive({ args: [], ...wire });
|
|
126
|
+
const cmd = harness.commands[0]!;
|
|
127
|
+
expect(cmd).toContain("--label");
|
|
128
|
+
expect(cmd[cmd.indexOf("--label") + 1]).toBe("n8n-sync");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("label with spaces is passed as a single arg (not re-split)", async () => {
|
|
132
|
+
const { harness, wire } = makeHarness(["1", "pendant prototype"]);
|
|
133
|
+
await runVaultTokensCreateInteractive({ args: [], ...wire });
|
|
134
|
+
const cmd = harness.commands[0]!;
|
|
135
|
+
expect(cmd[cmd.indexOf("--label") + 1]).toBe("pendant prototype");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("pre-supplied --label skips the label prompt entirely", async () => {
|
|
139
|
+
const { harness, wire } = makeHarness(["1"]); // ONLY the scope prompt
|
|
140
|
+
await runVaultTokensCreateInteractive({
|
|
141
|
+
args: ["--label", "existing"],
|
|
142
|
+
...wire,
|
|
143
|
+
});
|
|
144
|
+
// Prompts only include the scope picker, not a label prompt.
|
|
145
|
+
expect(harness.prompts.some((p) => p.toLowerCase().includes("label"))).toBe(false);
|
|
146
|
+
// The user-supplied --label stays in place; we didn't double-append.
|
|
147
|
+
const cmd = harness.commands[0]!;
|
|
148
|
+
const labelIdxs: number[] = [];
|
|
149
|
+
cmd.forEach((a, idx) => {
|
|
150
|
+
if (a === "--label") labelIdxs.push(idx);
|
|
151
|
+
});
|
|
152
|
+
expect(labelIdxs).toHaveLength(1);
|
|
153
|
+
expect(cmd[labelIdxs[0]! + 1]).toBe("existing");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("runVaultTokensCreateInteractive — passthrough of pre-supplied args", () => {
|
|
158
|
+
test("--vault / --expires forwarded verbatim, scope appended", async () => {
|
|
159
|
+
const { harness, wire } = makeHarness(["1", ""]);
|
|
160
|
+
await runVaultTokensCreateInteractive({
|
|
161
|
+
args: ["--vault", "work", "--expires", "30d"],
|
|
162
|
+
...wire,
|
|
163
|
+
});
|
|
164
|
+
const cmd = harness.commands[0]!;
|
|
165
|
+
// Original argv stays in order, scope flag appended after.
|
|
166
|
+
expect(cmd).toEqual([
|
|
167
|
+
"parachute-vault",
|
|
168
|
+
"tokens",
|
|
169
|
+
"create",
|
|
170
|
+
"--vault",
|
|
171
|
+
"work",
|
|
172
|
+
"--expires",
|
|
173
|
+
"30d",
|
|
174
|
+
"--read",
|
|
175
|
+
]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("subprocess exit code is returned to the caller", async () => {
|
|
179
|
+
const { wire } = makeHarness(["1", ""], { exitCode: 5 });
|
|
180
|
+
const code = await runVaultTokensCreateInteractive({ args: [], ...wire });
|
|
181
|
+
expect(code).toBe(5);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { ServiceEntry } from "../services-manifest.ts";
|
|
6
|
+
import {
|
|
7
|
+
buildWellKnown,
|
|
8
|
+
isVaultEntry,
|
|
9
|
+
shortName,
|
|
10
|
+
vaultInstanceName,
|
|
11
|
+
writeWellKnownFile,
|
|
12
|
+
} from "../well-known.ts";
|
|
13
|
+
|
|
14
|
+
const vault: ServiceEntry = {
|
|
15
|
+
name: "parachute-vault",
|
|
16
|
+
port: 1940,
|
|
17
|
+
paths: ["/vault/default"],
|
|
18
|
+
health: "/vault/default/health",
|
|
19
|
+
version: "0.2.4",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const notes: ServiceEntry = {
|
|
23
|
+
name: "parachute-notes",
|
|
24
|
+
port: 5173,
|
|
25
|
+
paths: ["/notes"],
|
|
26
|
+
health: "/notes/health",
|
|
27
|
+
version: "0.0.1",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const scribe: ServiceEntry = {
|
|
31
|
+
name: "parachute-scribe",
|
|
32
|
+
port: 3200,
|
|
33
|
+
paths: ["/scribe"],
|
|
34
|
+
health: "/scribe/health",
|
|
35
|
+
version: "0.1.0",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe("shortName", () => {
|
|
39
|
+
test("strips parachute- prefix", () => {
|
|
40
|
+
expect(shortName("parachute-vault")).toBe("vault");
|
|
41
|
+
expect(shortName("parachute-notes")).toBe("notes");
|
|
42
|
+
expect(shortName("custom-service")).toBe("custom-service");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("isVaultEntry", () => {
|
|
47
|
+
test("matches bare parachute-vault", () => {
|
|
48
|
+
expect(isVaultEntry(vault)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("matches prefixed vault instances", () => {
|
|
52
|
+
expect(isVaultEntry({ ...vault, name: "parachute-vault-work" })).toBe(true);
|
|
53
|
+
expect(isVaultEntry({ ...vault, name: "parachute-vault-personal" })).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("rejects non-vault services", () => {
|
|
57
|
+
expect(isVaultEntry(notes)).toBe(false);
|
|
58
|
+
expect(isVaultEntry(scribe)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("does not match an unrelated name that merely starts with parachute-vaultish", () => {
|
|
62
|
+
expect(isVaultEntry({ ...vault, name: "parachute-vaultkeeper" })).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("vaultInstanceName", () => {
|
|
67
|
+
test("prefers /vault/<name> path segment", () => {
|
|
68
|
+
expect(vaultInstanceName({ ...vault, paths: ["/vault/work"] })).toBe("work");
|
|
69
|
+
expect(vaultInstanceName({ ...vault, paths: ["/vault/default"] })).toBe("default");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("falls back to manifest-name suffix when path is non-vault", () => {
|
|
73
|
+
expect(vaultInstanceName({ ...vault, name: "parachute-vault-personal", paths: ["/"] })).toBe(
|
|
74
|
+
"personal",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("defaults to 'default' when nothing else matches", () => {
|
|
79
|
+
expect(vaultInstanceName({ ...vault, paths: ["/"] })).toBe("default");
|
|
80
|
+
expect(vaultInstanceName({ ...vault, paths: [] })).toBe("default");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("path wins over name suffix", () => {
|
|
84
|
+
expect(
|
|
85
|
+
vaultInstanceName({
|
|
86
|
+
...vault,
|
|
87
|
+
name: "parachute-vault-work",
|
|
88
|
+
paths: ["/vault/override"],
|
|
89
|
+
}),
|
|
90
|
+
).toBe("override");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("buildWellKnown", () => {
|
|
95
|
+
test("vaults is always an array, other services are flat entries, services[] includes all", () => {
|
|
96
|
+
const doc = buildWellKnown({
|
|
97
|
+
services: [vault, notes, scribe],
|
|
98
|
+
canonicalOrigin: "https://parachute.taildf9ce2.ts.net",
|
|
99
|
+
});
|
|
100
|
+
expect(doc.vaults).toEqual([
|
|
101
|
+
{
|
|
102
|
+
name: "default",
|
|
103
|
+
url: "https://parachute.taildf9ce2.ts.net/vault/default",
|
|
104
|
+
version: "0.2.4",
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
expect(doc.notes).toEqual({
|
|
108
|
+
url: "https://parachute.taildf9ce2.ts.net/notes",
|
|
109
|
+
version: "0.0.1",
|
|
110
|
+
});
|
|
111
|
+
expect(doc.scribe).toEqual({
|
|
112
|
+
url: "https://parachute.taildf9ce2.ts.net/scribe",
|
|
113
|
+
version: "0.1.0",
|
|
114
|
+
});
|
|
115
|
+
expect(doc.services.map((s) => s.name)).toEqual([
|
|
116
|
+
"parachute-vault",
|
|
117
|
+
"parachute-notes",
|
|
118
|
+
"parachute-scribe",
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("services[] entries include infoUrl pointing at /.parachute/info", () => {
|
|
123
|
+
const doc = buildWellKnown({
|
|
124
|
+
services: [vault, notes],
|
|
125
|
+
canonicalOrigin: "https://x.example",
|
|
126
|
+
});
|
|
127
|
+
expect(doc.services).toEqual([
|
|
128
|
+
{
|
|
129
|
+
name: "parachute-vault",
|
|
130
|
+
url: "https://x.example/vault/default",
|
|
131
|
+
path: "/vault/default",
|
|
132
|
+
version: "0.2.4",
|
|
133
|
+
infoUrl: "https://x.example/vault/default/.parachute/info",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "parachute-notes",
|
|
137
|
+
url: "https://x.example/notes",
|
|
138
|
+
path: "/notes",
|
|
139
|
+
version: "0.0.1",
|
|
140
|
+
infoUrl: "https://x.example/notes/.parachute/info",
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("infoUrl for root-mounted service has no double slash", () => {
|
|
146
|
+
const rootSvc: ServiceEntry = { ...notes, paths: ["/"] };
|
|
147
|
+
const doc = buildWellKnown({
|
|
148
|
+
services: [rootSvc],
|
|
149
|
+
canonicalOrigin: "https://x.example",
|
|
150
|
+
});
|
|
151
|
+
expect(doc.services[0]?.infoUrl).toBe("https://x.example/.parachute/info");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("vaults array is present even when no vault is installed", () => {
|
|
155
|
+
const doc = buildWellKnown({
|
|
156
|
+
services: [notes],
|
|
157
|
+
canonicalOrigin: "https://x.example",
|
|
158
|
+
});
|
|
159
|
+
expect(doc.vaults).toEqual([]);
|
|
160
|
+
expect(doc.services).toHaveLength(1);
|
|
161
|
+
expect(doc.notes).toEqual({ url: "https://x.example/notes", version: "0.0.1" });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("multiple vault instances all land in the vaults array", () => {
|
|
165
|
+
const work: ServiceEntry = {
|
|
166
|
+
...vault,
|
|
167
|
+
name: "parachute-vault-work",
|
|
168
|
+
paths: ["/vault/work"],
|
|
169
|
+
port: 1941,
|
|
170
|
+
version: "0.2.4",
|
|
171
|
+
};
|
|
172
|
+
const doc = buildWellKnown({
|
|
173
|
+
services: [vault, work],
|
|
174
|
+
canonicalOrigin: "https://x.example",
|
|
175
|
+
});
|
|
176
|
+
expect(doc.vaults).toHaveLength(2);
|
|
177
|
+
expect(doc.vaults.map((v) => v.name).sort()).toEqual(["default", "work"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("handles canonicalOrigin with trailing slash", () => {
|
|
181
|
+
const doc = buildWellKnown({
|
|
182
|
+
services: [vault],
|
|
183
|
+
canonicalOrigin: "https://parachute.taildf9ce2.ts.net/",
|
|
184
|
+
});
|
|
185
|
+
expect(doc.vaults[0]?.url).toBe("https://parachute.taildf9ce2.ts.net/vault/default");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("falls back to / for empty paths", () => {
|
|
189
|
+
const entry: ServiceEntry = { ...vault, paths: [] };
|
|
190
|
+
const doc = buildWellKnown({
|
|
191
|
+
services: [entry],
|
|
192
|
+
canonicalOrigin: "https://x.example",
|
|
193
|
+
});
|
|
194
|
+
expect(doc.vaults[0]?.url).toBe("https://x.example/");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("writeWellKnownFile", () => {
|
|
199
|
+
test("writes pretty JSON and creates nested directories", () => {
|
|
200
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-wk-"));
|
|
201
|
+
try {
|
|
202
|
+
const path = join(dir, "nested", "parachute.json");
|
|
203
|
+
const doc = buildWellKnown({
|
|
204
|
+
services: [vault],
|
|
205
|
+
canonicalOrigin: "https://x.example",
|
|
206
|
+
});
|
|
207
|
+
writeWellKnownFile(doc, path);
|
|
208
|
+
const round = JSON.parse(readFileSync(path, "utf8"));
|
|
209
|
+
expect(round).toEqual(doc);
|
|
210
|
+
} finally {
|
|
211
|
+
rmSync(dir, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
package/src/auto-wire.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { restart as lifecycleRestart } from "./commands/lifecycle.ts";
|
|
5
|
+
import { parseEnvFile, upsertEnvLine, writeEnvFile } from "./env-file.ts";
|
|
6
|
+
import { type AliveFn, defaultAlive, processState } from "./process-state.ts";
|
|
7
|
+
import { PORT_RESERVATIONS } from "./service-spec.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Cross-service auto-wiring for shared secrets.
|
|
11
|
+
*
|
|
12
|
+
* Vault's transcription worker authenticates to scribe over loopback using a
|
|
13
|
+
* shared bearer token, and reaches scribe at SCRIBE_URL. On install, when both
|
|
14
|
+
* services are present, we mint the secret and pin the URL on vault's side so
|
|
15
|
+
* the operator never has to. Missing either service → no-op; values already
|
|
16
|
+
* present in vault's .env → preserved.
|
|
17
|
+
*
|
|
18
|
+
* Storage locations (convention, matches what each service reads at boot):
|
|
19
|
+
* ~/.parachute/vault/.env SCRIBE_AUTH_TOKEN=<value>
|
|
20
|
+
* SCRIBE_URL=http://127.0.0.1:1943
|
|
21
|
+
* ~/.parachute/scribe/config.json { "auth": { "required_token": "<value>" } }
|
|
22
|
+
*
|
|
23
|
+
* Idempotency rule: we don't regenerate the token if vault's .env already
|
|
24
|
+
* carries it, and we don't overwrite SCRIBE_URL if already set. This preserves
|
|
25
|
+
* operator-set overrides and keeps repeat installs from churning state in a
|
|
26
|
+
* way that would break an already-running vault worker.
|
|
27
|
+
*
|
|
28
|
+
* After writing, if vault is running, restart it so the worker re-reads the
|
|
29
|
+
* .env. Without the restart vault keeps the old (or empty) values in process
|
|
30
|
+
* env and voice memos sit with `_Transcript pending._` forever — exactly the
|
|
31
|
+
* launch-day footgun this auto-wire exists to prevent.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const SCRIBE_AUTH_ENV_KEY = "SCRIBE_AUTH_TOKEN";
|
|
35
|
+
export const SCRIBE_URL_ENV_KEY = "SCRIBE_URL";
|
|
36
|
+
|
|
37
|
+
export interface AutoWireOpts {
|
|
38
|
+
configDir: string;
|
|
39
|
+
/** Override for tests; must return a hex string of any reasonable length. */
|
|
40
|
+
randomToken?: () => string;
|
|
41
|
+
log?: (line: string) => void;
|
|
42
|
+
/** Test seam: liveness check used to decide whether to restart vault. */
|
|
43
|
+
alive?: AliveFn;
|
|
44
|
+
/**
|
|
45
|
+
* Test seam: restart hook for vault. Defaults to `lifecycle.restart("vault")`.
|
|
46
|
+
* Tests inject a fake to assert the call without spawning a real child.
|
|
47
|
+
*/
|
|
48
|
+
restartService?: (short: string) => Promise<number>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AutoWireResult {
|
|
52
|
+
/** True when a token was written this call (vs. preserved from a prior wire). */
|
|
53
|
+
generated: boolean;
|
|
54
|
+
/** The token value, whether newly minted or pre-existing. */
|
|
55
|
+
token: string;
|
|
56
|
+
/** The SCRIBE_URL value present in vault .env after this call. */
|
|
57
|
+
scribeUrl: string;
|
|
58
|
+
vaultEnvPath: string;
|
|
59
|
+
scribeConfigPath: string;
|
|
60
|
+
/** True when vault was running and we issued a restart. */
|
|
61
|
+
restartedVault: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function defaultRandomToken(): string {
|
|
65
|
+
return randomBytes(32).toString("hex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function defaultScribeUrl(): string {
|
|
69
|
+
// Pull scribe's canonical port from the single source of truth so a future
|
|
70
|
+
// port change doesn't drift between auto-wire and the rest of the CLI.
|
|
71
|
+
const port = PORT_RESERVATIONS.find((p) => p.name === "parachute-scribe")?.port ?? 1943;
|
|
72
|
+
return `http://127.0.0.1:${port}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeScribeConfig(path: string, token: string): void {
|
|
76
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
77
|
+
let current: Record<string, unknown> = {};
|
|
78
|
+
if (existsSync(path)) {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
81
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
82
|
+
current = parsed as Record<string, unknown>;
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Malformed config — overwrite. Auto-wire owns this file's auth block;
|
|
86
|
+
// repairing a user-broken JSON is not our job.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const existingAuth =
|
|
90
|
+
typeof current.auth === "object" && current.auth !== null && !Array.isArray(current.auth)
|
|
91
|
+
? (current.auth as Record<string, unknown>)
|
|
92
|
+
: {};
|
|
93
|
+
const next = {
|
|
94
|
+
...current,
|
|
95
|
+
auth: { ...existingAuth, required_token: token },
|
|
96
|
+
};
|
|
97
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
98
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
|
|
99
|
+
renameSync(tmp, path);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Mint (or preserve) a shared secret and persist it to vault and scribe, plus
|
|
104
|
+
* pin SCRIBE_URL on vault's side. Caller has already confirmed both services
|
|
105
|
+
* are installed. Restarts vault if it's running so the worker re-reads .env.
|
|
106
|
+
*/
|
|
107
|
+
export async function autoWireScribeAuth(opts: AutoWireOpts): Promise<AutoWireResult> {
|
|
108
|
+
const random = opts.randomToken ?? defaultRandomToken;
|
|
109
|
+
const log = opts.log ?? (() => {});
|
|
110
|
+
const alive = opts.alive ?? defaultAlive;
|
|
111
|
+
const restartService =
|
|
112
|
+
opts.restartService ??
|
|
113
|
+
((short: string) =>
|
|
114
|
+
lifecycleRestart(short, {
|
|
115
|
+
configDir: opts.configDir,
|
|
116
|
+
log,
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
const vaultEnvPath = join(opts.configDir, "vault", ".env");
|
|
120
|
+
const scribeConfigPath = join(opts.configDir, "scribe", "config.json");
|
|
121
|
+
|
|
122
|
+
const parsed = parseEnvFile(vaultEnvPath);
|
|
123
|
+
let lines = parsed.lines;
|
|
124
|
+
let didWriteEnv = false;
|
|
125
|
+
|
|
126
|
+
const existingToken = parsed.values[SCRIBE_AUTH_ENV_KEY];
|
|
127
|
+
const tokenAlreadySet = existingToken !== undefined && existingToken.length > 0;
|
|
128
|
+
const token = tokenAlreadySet ? existingToken : random();
|
|
129
|
+
if (!tokenAlreadySet) {
|
|
130
|
+
lines = upsertEnvLine(lines, SCRIBE_AUTH_ENV_KEY, token);
|
|
131
|
+
didWriteEnv = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const existingUrl = parsed.values[SCRIBE_URL_ENV_KEY];
|
|
135
|
+
const urlAlreadySet = existingUrl !== undefined && existingUrl.length > 0;
|
|
136
|
+
const scribeUrl = urlAlreadySet ? existingUrl : defaultScribeUrl();
|
|
137
|
+
if (!urlAlreadySet) {
|
|
138
|
+
lines = upsertEnvLine(lines, SCRIBE_URL_ENV_KEY, scribeUrl);
|
|
139
|
+
didWriteEnv = true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (didWriteEnv) writeEnvFile(vaultEnvPath, lines);
|
|
143
|
+
writeScribeConfig(scribeConfigPath, token);
|
|
144
|
+
|
|
145
|
+
if (tokenAlreadySet && urlAlreadySet) {
|
|
146
|
+
log(
|
|
147
|
+
`${SCRIBE_AUTH_ENV_KEY} and ${SCRIBE_URL_ENV_KEY} already set in vault .env — preserved. Synced scribe config.json.`,
|
|
148
|
+
);
|
|
149
|
+
} else if (tokenAlreadySet) {
|
|
150
|
+
log(
|
|
151
|
+
`${SCRIBE_AUTH_ENV_KEY} already set in vault .env — preserved. Wired ${SCRIBE_URL_ENV_KEY}=${scribeUrl}. Synced scribe config.json.`,
|
|
152
|
+
);
|
|
153
|
+
} else {
|
|
154
|
+
log(
|
|
155
|
+
`Auto-wired shared secret + ${SCRIBE_URL_ENV_KEY} for vault → scribe transcription. Stored in ${vaultEnvPath} and ${scribeConfigPath}.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Vault caches .env on process start; without a restart the worker keeps
|
|
160
|
+
// running with stale (or absent) SCRIBE_URL/SCRIBE_AUTH_TOKEN and voice
|
|
161
|
+
// memos never transcribe. Mirrors the auto-restart-on-expose pattern from
|
|
162
|
+
// PR #39 — skip silently if vault isn't running.
|
|
163
|
+
let restartedVault = false;
|
|
164
|
+
if (didWriteEnv && processState("vault", opts.configDir, alive).status === "running") {
|
|
165
|
+
log("Restarting vault to pick up new transcription wiring…");
|
|
166
|
+
const code = await restartService("vault");
|
|
167
|
+
if (code === 0) {
|
|
168
|
+
restartedVault = true;
|
|
169
|
+
} else {
|
|
170
|
+
log(
|
|
171
|
+
"⚠ vault restart failed. Run manually once the issue is resolved: parachute restart vault",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
generated: !tokenAlreadySet,
|
|
178
|
+
token,
|
|
179
|
+
scribeUrl,
|
|
180
|
+
vaultEnvPath,
|
|
181
|
+
scribeConfigPath,
|
|
182
|
+
restartedVault,
|
|
183
|
+
};
|
|
184
|
+
}
|