@getfootgun/keys 0.0.0-stage → 0.2.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.
@@ -0,0 +1,97 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join, win32 } from "node:path";
3
+ import { writePrivate } from "./paths.mjs";
4
+ import { launchdPlist } from "./launchd.mjs";
5
+
6
+ const LABEL = "com.getfootgun.keys.health";
7
+ const WIN_HOST_KEY = "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\com.getfootgun.keys";
8
+ const TASK = "Footgun Keys health";
9
+
10
+ async function loadAgent(exec, uid, plist) {
11
+ try {
12
+ await exec("launchctl", ["bootstrap", `gui/${uid}`, plist]);
13
+ } catch {
14
+ // Already loaded: unload it, then load the fresh plist.
15
+ await exec("launchctl", ["bootout", `gui/${uid}/${LABEL}`]).catch(() => {});
16
+ await exec("launchctl", ["bootstrap", `gui/${uid}`, plist]);
17
+ }
18
+ }
19
+
20
+ // Chrome finds the helper through a manifest: a file in Chrome's folder on macOS, a registry
21
+ // pointer to a file in the support folder on Windows (per user, no admin rights).
22
+ export async function registerNativeHost({ platform, exec, home, support, manifest }) {
23
+ const text = JSON.stringify(manifest, null, 2) + "\n";
24
+ if (platform === "win32") {
25
+ const file = win32.join(support, `${manifest.name}.json`);
26
+ await writePrivate(file, text);
27
+ await exec("reg", ["add", WIN_HOST_KEY, "/ve", "/t", "REG_SZ", "/d", file, "/f"]);
28
+ return;
29
+ }
30
+ const hostDir = join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
31
+ await mkdir(hostDir, { recursive: true });
32
+ await writePrivate(join(hostDir, `${manifest.name}.json`), text);
33
+ }
34
+
35
+ const xmlEscape = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]);
36
+ const pad = (n) => String(n).padStart(2, "0");
37
+
38
+ // Task Scheduler definition for the daily check: runs on battery, catches up a missed run, and starts
39
+ // the launcher through a headless console so no window shows. It names the launcher only, never a key.
40
+ export function healthTaskXml(launcher, now = new Date()) {
41
+ const start = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T09:30:00`;
42
+ return [
43
+ '<?xml version="1.0" encoding="UTF-16"?>',
44
+ '<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
45
+ " <RegistrationInfo>",
46
+ " <Description>Footgun Keys daily key check</Description>",
47
+ " </RegistrationInfo>",
48
+ " <Triggers>",
49
+ " <CalendarTrigger>",
50
+ ` <StartBoundary>${start}</StartBoundary>`,
51
+ " <Enabled>true</Enabled>",
52
+ " <ScheduleByDay>",
53
+ " <DaysInterval>1</DaysInterval>",
54
+ " </ScheduleByDay>",
55
+ " </CalendarTrigger>",
56
+ " </Triggers>",
57
+ " <Principals>",
58
+ ' <Principal id="Author">',
59
+ " <LogonType>InteractiveToken</LogonType>",
60
+ " <RunLevel>LeastPrivilege</RunLevel>",
61
+ " </Principal>",
62
+ " </Principals>",
63
+ " <Settings>",
64
+ " <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>",
65
+ " <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>",
66
+ " <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>",
67
+ " <StartWhenAvailable>true</StartWhenAvailable>",
68
+ " <ExecutionTimeLimit>PT10M</ExecutionTimeLimit>",
69
+ " <Enabled>true</Enabled>",
70
+ " </Settings>",
71
+ ' <Actions Context="Author">',
72
+ " <Exec>",
73
+ " <Command>conhost.exe</Command>",
74
+ ` <Arguments>${xmlEscape(`--headless "${launcher}" health`)}</Arguments>`,
75
+ " </Exec>",
76
+ " </Actions>",
77
+ "</Task>",
78
+ "",
79
+ ].join("\r\n");
80
+ }
81
+
82
+ // Daily 9:30 run of "<launcher> health". Throws if the OS scheduler refuses.
83
+ export async function scheduleDaily({ platform, exec, home, launcher, uid, support, now = () => new Date() }) {
84
+ if (platform === "win32") {
85
+ // schtasks reads /XML files as UTF-16LE with a byte order mark.
86
+ const xmlPath = win32.join(support, "health-task.xml");
87
+ const body = Buffer.from(healthTaskXml(launcher, now()), "utf16le");
88
+ await writePrivate(xmlPath, Buffer.concat([Buffer.from([0xff, 0xfe]), body]));
89
+ await exec("schtasks", ["/Create", "/F", "/TN", TASK, "/XML", xmlPath]);
90
+ return;
91
+ }
92
+ const agents = join(home, "Library", "LaunchAgents");
93
+ await mkdir(agents, { recursive: true });
94
+ const plist = join(agents, `${LABEL}.plist`);
95
+ await writePrivate(plist, launchdPlist(launcher));
96
+ await loadAgent(exec, uid, plist);
97
+ }
@@ -0,0 +1,24 @@
1
+ import type { KeyStore } from "./vault.mjs";
2
+ // KeysEntitlement and GuideBody mirror lib/keys/entitlement.ts and lib/keys/guide.ts; defined once in ../shared/types.d.ts.
3
+ import type { KeysEntitlement, GuideBody } from "../shared/types.js";
4
+
5
+ export type GuideSummary = {
6
+ slug: string;
7
+ name: string;
8
+ tier: "free" | "pro";
9
+ version: number;
10
+ status: string;
11
+ lastVerifiedAt: string | null;
12
+ };
13
+ export type GuideResult =
14
+ | { ok: true; guide: GuideBody; version: number; status: string }
15
+ | { ok: false; error: string; upgradeUrl?: string };
16
+
17
+ export interface Server {
18
+ apiKey(): Promise<string | null>;
19
+ setApiKey(key: string): Promise<{ ok: boolean; error?: string }>;
20
+ entitlement(): Promise<KeysEntitlement | null>;
21
+ guide(slug: string): Promise<GuideResult>;
22
+ guides(): Promise<{ ok: true; guides: GuideSummary[] } | { ok: false; error: string }>;
23
+ }
24
+ export function makeServer(opts: { store: KeyStore; home?: string; fetch?: typeof fetch; now?: () => Date }): Server;
package/lib/server.mjs ADDED
@@ -0,0 +1,105 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { SITE, supportDir, ensureSupportDir, writePrivate } from "./paths.mjs";
4
+
5
+ const API_KEY_ACCOUNT = "footgun/api-key";
6
+ const ENTITLEMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
7
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
8
+ const TIMEOUT_MS = 8000;
9
+
10
+ // The only thing this module ever sends to the server is the Footgun API key
11
+ // in the Authorization header. Vault values are never read here.
12
+ export function makeServer({ store, home, fetch: fetchFn = globalThis.fetch, now = () => new Date() }) {
13
+ const dir = () => supportDir(home);
14
+
15
+ async function readJson(file) {
16
+ try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; }
17
+ }
18
+ async function apiKey() { return store.get(API_KEY_ACCOUNT); }
19
+ // A stalled request is aborted, so callers fall back to the cache as for any network error.
20
+ // The timer stays armed through the body read and never holds the process open.
21
+ async function get(path, key) {
22
+ const headers = key ? { Authorization: `Bearer ${key}` } : {};
23
+ const ctrl = new AbortController();
24
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
25
+ timer.unref?.();
26
+ return fetchFn(`${SITE()}${path}`, { method: "GET", headers, signal: ctrl.signal });
27
+ }
28
+ async function bodyOf(res) {
29
+ try { return await res.json(); } catch { return null; }
30
+ }
31
+
32
+ async function setApiKey(key) {
33
+ if (typeof key !== "string" || !key.startsWith("fg_")) {
34
+ return { ok: false, error: `That does not look like a Footgun key. Create one at ${SITE()}/account.` };
35
+ }
36
+ let res;
37
+ try { res = await get("/api/keys/entitlement", key); }
38
+ catch { return { ok: false, error: "Could not reach Footgun to check that key. Check your connection and try again." }; }
39
+ if (res.status === 401) return { ok: false, error: `That key was not accepted. Create one at ${SITE()}/account.` };
40
+ if (res.status !== 200) return { ok: false, error: `Footgun could not check that key (status ${res.status}). Try again in a moment.` };
41
+ await store.set(API_KEY_ACCOUNT, key);
42
+ return { ok: true };
43
+ }
44
+
45
+ async function entitlement() {
46
+ const key = await apiKey();
47
+ if (!key) return null;
48
+ const file = join(dir(), "entitlement.json");
49
+ try {
50
+ const res = await get("/api/keys/entitlement", key);
51
+ if (res.status === 401) {
52
+ // A revoked or invalid key must not keep paid behavior from the cache.
53
+ await rm(file, { force: true });
54
+ return null;
55
+ }
56
+ if (res.status === 200) {
57
+ const ent = await bodyOf(res);
58
+ if (ent) {
59
+ await ensureSupportDir(home);
60
+ await writePrivate(file, JSON.stringify({ fetchedAt: now().toISOString(), entitlement: ent }));
61
+ return ent;
62
+ }
63
+ }
64
+ } catch { /* offline: fall through to the cache */ }
65
+ const cached = await readJson(file);
66
+ if (cached?.entitlement && now().getTime() - Date.parse(cached.fetchedAt) < ENTITLEMENT_TTL_MS) return cached.entitlement;
67
+ return null;
68
+ }
69
+
70
+ async function guide(slug) {
71
+ if (typeof slug !== "string" || !SLUG.test(slug)) return { ok: false, error: "Unknown guide name." };
72
+ const file = join(dir(), "guides", `${slug}.json`);
73
+ const key = await apiKey();
74
+ try {
75
+ const res = await get(`/api/keys/guides/${slug}`, key);
76
+ const body = await bodyOf(res);
77
+ if (res.status === 200 && body?.guide) {
78
+ const out = { guide: body.guide, version: body.version, status: body.status };
79
+ await ensureSupportDir(home);
80
+ await writePrivate(file, JSON.stringify(out));
81
+ return { ok: true, ...out };
82
+ }
83
+ const fail = { ok: false, error: body?.error ?? `Footgun answered with status ${res.status}.` };
84
+ if (body?.upgradeUrl) fail.upgradeUrl = body.upgradeUrl;
85
+ return fail;
86
+ } catch {
87
+ const cached = await readJson(file);
88
+ if (cached?.guide) return { ok: true, guide: cached.guide, version: cached.version, status: cached.status };
89
+ return { ok: false, error: "Could not reach Footgun and there is no saved copy of that guide." };
90
+ }
91
+ }
92
+
93
+ async function guides() {
94
+ try {
95
+ const res = await get("/api/keys/guides", await apiKey());
96
+ const body = await bodyOf(res);
97
+ if (res.status === 200 && Array.isArray(body?.guides)) return { ok: true, guides: body.guides };
98
+ return { ok: false, error: body?.error ?? `Footgun answered with status ${res.status}.` };
99
+ } catch {
100
+ return { ok: false, error: "Could not reach Footgun." };
101
+ }
102
+ }
103
+
104
+ return { apiKey, setApiKey, entitlement, guide, guides };
105
+ }
@@ -0,0 +1,19 @@
1
+ export interface Session {
2
+ id: string;
3
+ provider: string;
4
+ envVars: string[];
5
+ projectPath: string | null;
6
+ label: string;
7
+ createdAt: string;
8
+ status: "pending" | "done" | "failed";
9
+ result?: {
10
+ saved: string[];
11
+ wrote?: { file: string; addedGitignore: boolean };
12
+ test?: { ok: boolean; reason?: string; status?: number };
13
+ error?: string;
14
+ };
15
+ }
16
+ export function createSession(opts: { provider: string; envVars: string[]; projectPath: string | null; label?: string; home?: string }): Promise<Session>;
17
+ export function readSession(id: string, home?: string): Promise<Session | null>;
18
+ export function finishSession(id: string, result: NonNullable<Session["result"]>, home?: string): Promise<Session | null>;
19
+ export function isExpired(s: Session, now?: Date | number): boolean;
@@ -0,0 +1,37 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { SESSION_RE } from "../shared/contract.mjs";
5
+ import { supportDir, ensureSupportDir, writePrivate } from "./paths.mjs";
6
+
7
+ const MAX_AGE_MS = 60 * 60 * 1000;
8
+
9
+ // A session says which provider and env var names a setup run is about. It never holds a value.
10
+ const fileFor = (id, home) => join(supportDir(home), "sessions", `${id}.json`);
11
+
12
+ export async function createSession({ provider, envVars, projectPath, label = "default", home }) {
13
+ const id = randomBytes(32).toString("base64url").slice(0, 43);
14
+ const session = { id, provider, envVars: [...envVars], projectPath: projectPath ?? null, label, createdAt: new Date().toISOString(), status: "pending" };
15
+ await ensureSupportDir(home);
16
+ await writePrivate(fileFor(id, home), JSON.stringify(session));
17
+ return session;
18
+ }
19
+
20
+ export async function readSession(id, home) {
21
+ if (typeof id !== "string" || !SESSION_RE.test(id)) return null;
22
+ try { return JSON.parse(await readFile(fileFor(id, home), "utf8")); } catch { return null; }
23
+ }
24
+
25
+ export async function finishSession(id, result, home) {
26
+ const s = await readSession(id, home);
27
+ if (!s) return null;
28
+ s.status = result.error ? "failed" : "done";
29
+ s.result = result;
30
+ await writePrivate(fileFor(id, home), JSON.stringify(s));
31
+ return s;
32
+ }
33
+
34
+ export function isExpired(s, now = new Date()) {
35
+ const t = now instanceof Date ? now.getTime() : now;
36
+ return !(t - Date.parse(s.createdAt) <= MAX_AGE_MS);
37
+ }
@@ -0,0 +1,22 @@
1
+ import type { Server } from "./server.mjs";
2
+
3
+ export function setup(deps: {
4
+ home: string;
5
+ platform: string;
6
+ prompt: (question: string) => Promise<string>;
7
+ exec: (file: string, args: string[]) => Promise<unknown>;
8
+ installApp: (dir: string) => Promise<string>;
9
+ server: Pick<Server, "setApiKey">;
10
+ print: (line: string) => void;
11
+ extensionIds?: string[];
12
+ uid?: number;
13
+ env?: Record<string, string | undefined>;
14
+ now?: () => Date;
15
+ }): Promise<number>;
16
+ export function run(): Promise<number>;
17
+ export function launcherScript(nodePath: string, bin: string, subcommand: string, options?: { platform?: string; fallbackPath?: string }): string;
18
+ export function hiddenPrompt(
19
+ question: string,
20
+ io?: { input?: NodeJS.ReadableStream; write?: (text: string) => void; exit?: (code: number) => void },
21
+ ): Promise<string>;
22
+ export function npmInstallCommand(platform: string, dir: string, version: string, env?: Record<string, string | undefined>, nodePath?: string): { file: string; args: string[] };
package/lib/setup.mjs ADDED
@@ -0,0 +1,226 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { chmod, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join, win32 } from "node:path";
6
+ import { createInterface } from "node:readline";
7
+ import { Writable } from "node:stream";
8
+ import { promisify } from "node:util";
9
+ import { EXTENSION_IDS } from "../shared/extension-ids.mjs";
10
+ import { SITE, ensureSupportDir, writePrivate } from "./paths.mjs";
11
+ import { registerNativeHost, scheduleDaily } from "./schedule.mjs";
12
+ import { keychainStore } from "./vault.mjs";
13
+ import { WIN_NOTIFY_SCRIPT } from "./platform.mjs";
14
+ import { makeServer } from "./server.mjs";
15
+
16
+ const HOST = "com.getfootgun.keys";
17
+ const MAX_TRIES = 3;
18
+
19
+ const NO_NODE = "Footgun Keys cannot find Node. Run npx @getfootgun/keys setup again.";
20
+ const NOTIFY_SCRIPT = ["on run argv", "display notification (item 2 of argv) with title (item 1 of argv)", "end run"];
21
+
22
+ // The Windows .cmd launcher: same fallback order, but the notification text travels as environment
23
+ // variables read by the fixed PowerShell script, and cmd gets no path it could expand or break on.
24
+ function windowsLauncher(nodePath, bin, subcommand) {
25
+ for (const p of [nodePath, bin]) {
26
+ if (/["%]/.test(p)) throw new Error(`Cannot write a Windows launcher for a path containing " or %: ${p}`);
27
+ }
28
+ const sub = subcommand ? ` ${subcommand}` : "";
29
+ return [
30
+ "@echo off",
31
+ "setlocal",
32
+ `set "NODE=${nodePath}"`,
33
+ 'if not exist "%NODE%" (',
34
+ " set \"NODE=\"",
35
+ " set \"FOOTGUN_FOUND=\"",
36
+ " for /f \"delims=\" %%i in ('where node 2^>nul') do if not defined FOOTGUN_FOUND (set \"NODE=%%i\" & set \"FOOTGUN_FOUND=1\")",
37
+ ")",
38
+ 'if not exist "%NODE%" (',
39
+ ' set "FOOTGUN_NOTIFY_TITLE=Footgun Keys"',
40
+ ` set "FOOTGUN_NOTIFY_BODY=${NO_NODE}"`,
41
+ ` powershell.exe -NoProfile -NonInteractive -Command "${WIN_NOTIFY_SCRIPT}" >nul 2>&1`,
42
+ " exit /b 1",
43
+ ")",
44
+ `"%NODE%" "${bin}"${sub} %*`,
45
+ "",
46
+ ].join("\r\n");
47
+ }
48
+
49
+ // A launcher survives a Node upgrade: it tries the Node that ran setup, then any node on PATH
50
+ // (with the Homebrew and /usr/local bins added, since launchd and Chrome start with a bare PATH),
51
+ // and otherwise shows a notification instead of failing silently. Notification text travels as argv.
52
+ export function launcherScript(nodePath, bin, subcommand, { platform = process.platform, fallbackPath = "/opt/homebrew/bin:/usr/local/bin" } = {}) {
53
+ if (platform === "win32") return windowsLauncher(nodePath, bin, subcommand);
54
+ const sub = subcommand ? ` ${subcommand}` : "";
55
+ const script = NOTIFY_SCRIPT.map((l) => `-e '${l}'`).join(" ");
56
+ return [
57
+ "#!/bin/sh",
58
+ `NODE="${nodePath}"`,
59
+ 'if [ ! -x "$NODE" ]; then',
60
+ ` PATH="$PATH:${fallbackPath}"`,
61
+ " export PATH",
62
+ ' NODE="$(command -v node)"',
63
+ "fi",
64
+ 'if [ -z "$NODE" ] || [ ! -x "$NODE" ]; then',
65
+ ` osascript ${script} "Footgun Keys" "${NO_NODE}" >/dev/null 2>&1`,
66
+ " exit 1",
67
+ "fi",
68
+ `exec "$NODE" "${bin}"${sub} "$@"`,
69
+ "",
70
+ ].join("\n");
71
+ }
72
+
73
+ async function writeExecutable(file, text, platform) {
74
+ await writeFile(file, text, { mode: 0o700 });
75
+ if (platform !== "win32") await chmod(file, 0o700);
76
+ }
77
+
78
+ // Every dependency is injected so tests never touch the real Library, launchctl, npm or Keychain.
79
+ export async function setup({ home, platform, prompt, exec, installApp, server, print, extensionIds = EXTENSION_IDS, uid = process.getuid?.(), env = process.env, now = () => new Date() }) {
80
+ if (platform !== "darwin" && platform !== "win32") {
81
+ print("Footgun Keys runs on macOS and Windows for now.");
82
+ return 1;
83
+ }
84
+
85
+ const win = platform === "win32";
86
+ const j = win ? win32.join : join;
87
+ const support = await ensureSupportDir(home, platform, env);
88
+ print("Created the Footgun Keys support folder.");
89
+
90
+ let bin;
91
+ try {
92
+ bin = await installApp(j(support, "app"));
93
+ } catch (e) {
94
+ const msg = (e instanceof Error && e.message ? e.message : "unknown error").split("\n")[0];
95
+ print(`Could not install Footgun Keys: ${msg}`);
96
+ return 1;
97
+ }
98
+ print("Installed a stable copy of the helper.");
99
+
100
+ const ext = win ? ".cmd" : "";
101
+ const launcher = j(support, `footgun-keys${ext}`);
102
+ const nativeLauncher = j(support, `native-host${ext}`);
103
+ let mainScript, nativeScript;
104
+ try {
105
+ mainScript = launcherScript(process.execPath, bin, "", { platform });
106
+ nativeScript = launcherScript(process.execPath, bin, "native-host", { platform });
107
+ } catch {
108
+ // Only the Windows .cmd launcher refuses a path; cmd would expand or break on " and %.
109
+ print("Footgun Keys can't install into a folder whose path contains \" or %.");
110
+ return 1;
111
+ }
112
+ await writeExecutable(launcher, mainScript, platform);
113
+ await writeExecutable(nativeLauncher, nativeScript, platform);
114
+ print("Wrote the launchers.");
115
+
116
+ try {
117
+ await registerNativeHost({
118
+ platform, exec, home, support,
119
+ manifest: {
120
+ name: HOST,
121
+ description: "Footgun Keys helper",
122
+ path: nativeLauncher,
123
+ type: "stdio",
124
+ allowed_origins: extensionIds.map((id) => `chrome-extension://${id}/`),
125
+ },
126
+ });
127
+ } catch (e) {
128
+ if (!win) throw e;
129
+ print(`Could not register the helper with Chrome: ${(e instanceof Error && e.message ? e.message : "unknown error").split("\n")[0]}`);
130
+ return 1;
131
+ }
132
+ print("Registered the helper with Chrome.");
133
+ if (extensionIds.length === 0) print("The Chrome extension id is not set yet; guides will not save until it is.");
134
+
135
+ let scheduled = true;
136
+ try {
137
+ await scheduleDaily({ platform, exec, home, launcher, uid, support, now });
138
+ print("Scheduled the daily key check.");
139
+ } catch {
140
+ scheduled = false;
141
+ print("Could not schedule the daily key check. Run setup again to retry.");
142
+ }
143
+ // A failed schedule must not end in apparent success, so it is repeated last and fails the run.
144
+ const finish = (code) => {
145
+ if (scheduled) return code;
146
+ print("The daily key check is NOT scheduled. Run npx @getfootgun/keys setup again.");
147
+ return 1;
148
+ };
149
+
150
+ let stored = false;
151
+ let lastError = "";
152
+ for (let i = 0; i < MAX_TRIES && !stored; i++) {
153
+ const key = (await prompt(`Paste your Footgun API key (create one free at ${SITE()}/account):`)).trim();
154
+ const res = await server.setApiKey(key);
155
+ if (res.ok) stored = true;
156
+ else {
157
+ lastError = res.error ?? "That key was not accepted.";
158
+ print(lastError);
159
+ }
160
+ }
161
+ if (!stored) {
162
+ print("No API key was saved. Run footgun-keys setup again when you have one.");
163
+ return finish(1);
164
+ }
165
+ print(`Saved your Footgun API key to the ${win ? "Windows Credential Manager" : "Keychain"}.`);
166
+
167
+ print("Next steps:");
168
+ print(`Claude Code: claude mcp add footgun-keys -- ${win ? "cmd /c " : ""}npx -y @getfootgun/keys mcp`);
169
+ print("Cursor: add the same command under MCP servers.");
170
+ print("Chrome: install the Footgun Keys extension.");
171
+ print(`Your keys live in ${win ? "the Windows Credential Manager" : "your Mac's Keychain"} and in the env files you choose. AI tools can still read those env files; run npx @getfootgun/keys protect in a project so Claude Code's Read tool will not open them (shell commands still can).`);
172
+ return finish(0);
173
+ }
174
+
175
+ // Reads one line without echoing what is typed. Ctrl-C closes the prompt and exits 130.
176
+ export function hiddenPrompt(question, {
177
+ input = process.stdin,
178
+ write = (t) => process.stdout.write(t),
179
+ exit = (code) => process.exit(code),
180
+ } = {}) {
181
+ const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } });
182
+ const rl = createInterface({ input, output: muted, terminal: true });
183
+ write(`${question} `);
184
+ return new Promise((resolve) => {
185
+ rl.on("SIGINT", () => {
186
+ rl.close();
187
+ write("\n");
188
+ exit(130);
189
+ });
190
+ rl.question("", (answer) => {
191
+ rl.close();
192
+ write("\n");
193
+ resolve(answer);
194
+ });
195
+ });
196
+ }
197
+
198
+ const execAsync = promisify(execFile);
199
+
200
+ // Node 20.12+ will not execFile a .cmd without a shell, so on Windows npm runs through node itself.
201
+ export function npmInstallCommand(platform, dir, version, env = process.env, nodePath = process.execPath) {
202
+ const args = ["install", "--prefix", dir, `@getfootgun/keys@${version}`];
203
+ if (platform !== "win32") return { file: "npm", args };
204
+ const cli = env.npm_execpath?.endsWith(".js") ? env.npm_execpath : win32.join(win32.dirname(nodePath), "node_modules", "npm", "bin", "npm-cli.js");
205
+ return { file: nodePath, args: [cli, ...args] };
206
+ }
207
+
208
+ function realInstallApp(dir) {
209
+ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
210
+ const { file, args } = npmInstallCommand(process.platform, dir, version);
211
+ const j = process.platform === "win32" ? win32.join : join;
212
+ return execAsync(file, args).then(() => j(dir, "node_modules", "@getfootgun", "keys", "bin", "footgun-keys.mjs"));
213
+ }
214
+
215
+ export async function run() {
216
+ const home = homedir();
217
+ return setup({
218
+ home,
219
+ platform: process.platform,
220
+ prompt: hiddenPrompt,
221
+ exec: (file, args) => execAsync(file, args),
222
+ installApp: realInstallApp,
223
+ server: makeServer({ store: keychainStore(), home }),
224
+ print: (line) => console.log(line),
225
+ });
226
+ }
@@ -0,0 +1,11 @@
1
+ import type { GuideBody } from "../shared/types";
2
+
3
+ export function runTest(
4
+ guide: GuideBody,
5
+ values: Record<string, string>,
6
+ opts?: { fetch?: typeof fetch; timeoutMs?: number },
7
+ ): Promise<{
8
+ ok: boolean;
9
+ reason?: "revoked" | "out_of_credit" | "rate_limited" | "unreachable" | "unexpected";
10
+ status?: number;
11
+ }>;
@@ -0,0 +1,103 @@
1
+ // The one request that may carry a key value off the machine, and only to the provider's own host.
2
+ // Results and errors never include a key value.
3
+
4
+ // Same rule as lib/keys/guide.ts: last two labels.
5
+ const registrable = (host) => host.split(".").slice(-2).join(".");
6
+
7
+ const ownHost = (guide, url) => {
8
+ try {
9
+ const own = new Set([...guide.domains, new URL(guide.docsUrl).hostname].map(registrable));
10
+ return own.has(registrable(new URL(url).hostname));
11
+ } catch {
12
+ return false;
13
+ }
14
+ };
15
+
16
+ const BAD = { ok: false, reason: "unexpected" };
17
+
18
+ // Same list as lib/keys/guide.ts; checked again here in case a guide skipped validation.
19
+ const RESERVED = new Set(["authorization", "cookie", "host", "content-type", "content-length", "proxy-authorization"]);
20
+ const CONTENT_TYPES = { json: "application/json", form: "application/x-www-form-urlencoded" };
21
+
22
+ export async function runTest(guide, values, opts = {}) {
23
+ const t = guide.test;
24
+ if (!t) return { ok: true };
25
+ if (!ownHost(guide, t.url)) return { ...BAD };
26
+
27
+ const a = t.auth;
28
+ const headers = {};
29
+ let url;
30
+ try {
31
+ url = new URL(t.url);
32
+ } catch {
33
+ return { ...BAD };
34
+ }
35
+ if (url.protocol !== "https:") return { ...BAD };
36
+ const need = (name) => (typeof values[name] === "string" && values[name] !== "" ? values[name] : null);
37
+
38
+ if (a.type === "bearer") {
39
+ const v = need(a.envVar);
40
+ if (v === null) return { ...BAD };
41
+ headers.Authorization = `Bearer ${v}`;
42
+ } else if (a.type === "header") {
43
+ const v = need(a.envVar);
44
+ if (v === null) return { ...BAD };
45
+ headers[a.name] = v;
46
+ } else if (a.type === "basic") {
47
+ const u = need(a.userEnvVar);
48
+ const p = need(a.passEnvVar);
49
+ if (u === null || p === null) return { ...BAD };
50
+ headers.Authorization = "Basic " + Buffer.from(`${u}:${p}`).toString("base64");
51
+ } else if (a.type === "query") {
52
+ const v = need(a.envVar);
53
+ if (v === null) return { ...BAD };
54
+ url.searchParams.set(a.name, v);
55
+ } else if (a.type !== "none") {
56
+ return { ...BAD };
57
+ }
58
+
59
+ // Fixed extra headers never replace auth or the ones set here.
60
+ const taken = new Set(Object.keys(headers).map((h) => h.toLowerCase()));
61
+ for (const [name, value] of Object.entries(t.headers ?? {})) {
62
+ const n = name.toLowerCase();
63
+ if (taken.has(n) || RESERVED.has(n)) continue;
64
+ headers[name] = value;
65
+ }
66
+ let body;
67
+ if (t.body !== undefined) {
68
+ if (t.method !== "POST" || !CONTENT_TYPES[t.contentType]) return { ...BAD };
69
+ headers["Content-Type"] = CONTENT_TYPES[t.contentType];
70
+ body = t.body;
71
+ }
72
+
73
+ const doFetch = opts.fetch ?? globalThis.fetch;
74
+ const ctl = new AbortController();
75
+ const timer = setTimeout(() => ctl.abort(), opts.timeoutMs ?? 10000);
76
+ let status;
77
+ let reply = null;
78
+ try {
79
+ const res = await doFetch(url.toString(), { method: t.method, headers, body, signal: ctl.signal, redirect: "manual" });
80
+ status = res.status;
81
+ if (status === t.expectStatus && t.failIfJsonHas) reply = await res.text();
82
+ } catch {
83
+ return { ok: false, reason: "unreachable" };
84
+ } finally {
85
+ clearTimeout(timer);
86
+ }
87
+
88
+ if (status === t.expectStatus) {
89
+ if (!t.failIfJsonHas) return { ok: true };
90
+ // The reply is read only to look for the named keys; nothing from it is returned.
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(reply);
94
+ } catch {
95
+ return { ok: false, reason: "unexpected", status };
96
+ }
97
+ if (parsed === null || typeof parsed !== "object") return { ok: false, reason: "unexpected", status };
98
+ const hit = t.failIfJsonHas.some((k) => parsed[k] !== undefined && parsed[k] !== null);
99
+ return hit ? { ok: false, reason: "revoked", status } : { ok: true };
100
+ }
101
+ const reason = t.failures[String(status)];
102
+ return { ok: false, reason: reason ?? "unexpected", status };
103
+ }