@getfootgun/keys 0.0.0-stage → 0.1.0

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,7 @@
1
+ import { keychainStore } from "./vault.mjs";
2
+ import { runNativeHost } from "./native.mjs";
3
+
4
+ // Chrome passes the caller origin first (chrome-extension://<id>/), and on some platforms a window id after it.
5
+ export async function run(args) {
6
+ return runNativeHost(process.stdin, process.stdout, { store: keychainStore(), origin: args[0] });
7
+ }
@@ -0,0 +1,15 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ import type { KeyStore } from "./vault.mjs";
3
+
4
+ export interface HostDeps {
5
+ store: KeyStore;
6
+ home?: string;
7
+ fetch?: typeof fetch;
8
+ now?: () => Date;
9
+ origin?: string;
10
+ extensionIds?: string[];
11
+ }
12
+ export function readMessage(stream: Readable): Promise<unknown | null>;
13
+ export function writeMessage(stream: Writable, obj: unknown): Promise<void>;
14
+ export function handle(req: unknown, deps: HostDeps): Promise<Record<string, unknown>>;
15
+ export function runNativeHost(stdin: Readable, stdout: Writable, deps: HostDeps): Promise<number>;
package/lib/native.mjs ADDED
@@ -0,0 +1,144 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { validateRequest, FREE_GUIDES } from "../shared/contract.mjs";
3
+ import { EXTENSION_IDS } from "../shared/extension-ids.mjs";
4
+ import { openVault } from "./vault.mjs";
5
+ import { makeServer } from "./server.mjs";
6
+ import { writeEnv } from "./envfile.mjs";
7
+ import { runTest } from "./testcall.mjs";
8
+ import { readSession, finishSession, isExpired } from "./sessions.mjs";
9
+
10
+ // Nothing here writes a key value to stdout: responses carry env var names and statuses only.
11
+ const MAX_FRAME = 1048576;
12
+ const BAD = { __bad: true };
13
+ const HELPER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
14
+
15
+ async function readBytes(stream, n) {
16
+ for (;;) {
17
+ const c = stream.read(n);
18
+ if (c) return c;
19
+ if (stream.readableEnded || stream.destroyed) return null;
20
+ await new Promise((resolve) => {
21
+ const done = () => {
22
+ stream.off("readable", done); stream.off("end", done); stream.off("close", done); stream.off("error", done);
23
+ resolve();
24
+ };
25
+ stream.on("readable", done); stream.on("end", done); stream.on("close", done); stream.on("error", done);
26
+ });
27
+ }
28
+ }
29
+
30
+ export async function readMessage(stream) {
31
+ const head = await readBytes(stream, 4);
32
+ if (!head || head.length < 4) return null;
33
+ const len = head.readUInt32LE(0);
34
+ if (len === 0 || len > MAX_FRAME) return { ...BAD };
35
+ const body = await readBytes(stream, len);
36
+ if (!body || body.length < len) return { ...BAD };
37
+ try { return JSON.parse(body.toString("utf8")); } catch { return { ...BAD }; }
38
+ }
39
+
40
+ export function writeMessage(stream, obj) {
41
+ const body = Buffer.from(JSON.stringify(obj), "utf8");
42
+ const head = Buffer.alloc(4);
43
+ head.writeUInt32LE(body.length, 0);
44
+ return new Promise((resolve, reject) => stream.write(Buffer.concat([head, body]), (e) => (e ? reject(e) : resolve())));
45
+ }
46
+
47
+ const fail = (error) => ({ ok: false, error });
48
+
49
+ export async function handle(req, deps) {
50
+ const v = validateRequest(req);
51
+ if (!v.ok) return fail("bad message");
52
+ try {
53
+ return await dispatchRequest(v.req, deps);
54
+ } catch {
55
+ return fail("Something went wrong in the Footgun Keys helper.");
56
+ }
57
+ }
58
+
59
+ async function dispatchRequest(req, deps) {
60
+ const { store, home, fetch: fetchFn } = deps;
61
+ const now = deps.now ?? (() => new Date());
62
+ const server = makeServer({ store, home, fetch: fetchFn, now });
63
+
64
+ if (req.type === "hello") {
65
+ return { ok: true, helperVersion: HELPER_VERSION, signedIn: (await server.apiKey()) !== null, entitlement: await server.entitlement() };
66
+ }
67
+ if (req.type === "guide") return server.guide(req.slug);
68
+ if (req.type === "session") {
69
+ const s = await readSession(req.id, home);
70
+ if (!s) return fail("unknown session");
71
+ if (isExpired(s, now())) return fail("expired");
72
+ return { ok: true, provider: s.provider, envVars: s.envVars };
73
+ }
74
+
75
+ // save
76
+ if (!FREE_GUIDES.includes(req.guide)) {
77
+ const ent = await server.entitlement();
78
+ if (ent?.allGuides !== true) return fail("This guide is part of Footgun Pro.");
79
+ }
80
+ const g = await server.guide(req.guide);
81
+ if (!g.ok) return fail(g.error);
82
+ const guide = g.guide;
83
+ const secretOf = new Map(guide.captures.map((c) => [c.envVar, c.secret]));
84
+ if (req.values.some((x) => !secretOf.has(x.envVar))) return fail("unknown value");
85
+
86
+ let session = null;
87
+ if (req.session !== undefined) {
88
+ session = await readSession(req.session, home);
89
+ if (!session) return fail("unknown session");
90
+ if (isExpired(session, now())) return fail("expired");
91
+ if (session.provider !== req.guide) return fail("unknown session");
92
+ }
93
+
94
+ const label = session ? session.label : req.label;
95
+ const vault = openVault({ store, home });
96
+ for (const x of req.values) await vault.put(req.guide, label, x.envVar, x.value, secretOf.get(x.envVar));
97
+ if (!req.final) return { ok: true, saved: req.values.map((x) => x.envVar) };
98
+
99
+ // With a session, only the env vars it asked for are written and reported; without one, every capture.
100
+ const wanted = session ? session.envVars : guide.captures.map((c) => c.envVar);
101
+ if (wanted.some((n) => !secretOf.has(n))) return fail("unknown value");
102
+ const all = {};
103
+ for (const c of guide.captures) {
104
+ const val = await vault.get(req.guide, label, c.envVar);
105
+ if (val !== null) all[c.envVar] = val;
106
+ }
107
+ const chosen = Object.fromEntries(Object.entries(all).filter(([name]) => wanted.includes(name)));
108
+ // A final save reports every value gathered for this request, not just this message's.
109
+ const saved = Object.keys(chosen);
110
+ // The test call may need a capture the session did not ask for, so it sees every saved one.
111
+ const test = await runTest(guide, all, { fetch: fetchFn });
112
+ const result = { saved, test };
113
+ if (session?.projectPath) {
114
+ try {
115
+ result.wrote = await writeEnv(session.projectPath, Object.entries(chosen).map(([name, value]) => ({ name, value })));
116
+ } catch (e) {
117
+ const error = e instanceof Error ? e.message : "Could not write the env file.";
118
+ await finishSession(session.id, { saved, error }, home);
119
+ return fail(error);
120
+ }
121
+ for (const envVar of saved) await vault.addProject(req.guide, label, envVar, session.projectPath);
122
+ }
123
+ if (session) await finishSession(session.id, result, home);
124
+ return { ok: true, ...result };
125
+ }
126
+
127
+ export async function runNativeHost(stdin, stdout, deps) {
128
+ const ids = deps.extensionIds ?? EXTENSION_IDS;
129
+ const m = /^chrome-extension:\/\/([A-Za-z0-9]{1,64})\/?$/.exec(deps.origin ?? "");
130
+ if (!m || !ids.includes(m[1])) {
131
+ await writeMessage(stdout, fail("unknown extension"));
132
+ return 1;
133
+ }
134
+ for (;;) {
135
+ const msg = await readMessage(stdin);
136
+ if (msg === null) return 0;
137
+ if (msg.__bad) {
138
+ // The frame is unreadable, so the stream can no longer be trusted: answer once and stop.
139
+ await writeMessage(stdout, fail("bad message"));
140
+ return 1;
141
+ }
142
+ await writeMessage(stdout, await handle(msg, deps));
143
+ }
144
+ }
@@ -0,0 +1,4 @@
1
+ export function SITE(): string;
2
+ export function supportDir(home?: string): string;
3
+ export function ensureSupportDir(home?: string): Promise<string>;
4
+ export function writePrivate(file: string, data: string): Promise<void>;
package/lib/paths.mjs ADDED
@@ -0,0 +1,31 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { mkdir, chmod, writeFile, rename, rm } from "node:fs/promises";
4
+ import { randomBytes } from "node:crypto";
5
+
6
+ export function SITE() { return process.env.FOOTGUN_KEYS_SITE ?? "https://getfootgun.com"; }
7
+
8
+ export function supportDir(home = homedir()) { return join(home, "Library", "Application Support", "Footgun Keys"); }
9
+
10
+ // mkdir's mode is masked by the umask, so chmod each folder explicitly.
11
+ export async function ensureSupportDir(home = homedir()) {
12
+ const dir = supportDir(home);
13
+ for (const d of [dir, join(dir, "sessions"), join(dir, "guides")]) {
14
+ await mkdir(d, { recursive: true, mode: 0o700 });
15
+ await chmod(d, 0o700);
16
+ }
17
+ return dir;
18
+ }
19
+
20
+ // Atomic: write a sibling temp file with mode 0600, then rename over the target.
21
+ export async function writePrivate(file, data) {
22
+ const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
23
+ try {
24
+ await writeFile(tmp, data, { mode: 0o600 });
25
+ await chmod(tmp, 0o600);
26
+ await rename(tmp, file);
27
+ } catch (e) {
28
+ await rm(tmp, { force: true });
29
+ throw e;
30
+ }
31
+ }
@@ -0,0 +1,6 @@
1
+ export const DENY_RULES: string[];
2
+ export type ProtectResult = { changed: boolean; file: string; added: string[] };
3
+ export function protect(projectPath: string, confirm: () => Promise<boolean>): Promise<ProtectResult>;
4
+ export function protectSummary(r: ProtectResult): string;
5
+ export function run(args: string[]): Promise<number>;
6
+ export function isProtected(projectPath: string): Promise<boolean>;
@@ -0,0 +1,63 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+
5
+ // Syntax per https://code.claude.com/docs/en/settings (permissions.deny, "Read(./.env)").
6
+ export const DENY_RULES = ["Read(./.env)", "Read(./.env.local)", "Read(./.env.*)"];
7
+
8
+ // True when the project's Claude Code settings already deny reading every env file pattern.
9
+ export async function isProtected(projectPath) {
10
+ try {
11
+ const deny = JSON.parse(await readFile(join(projectPath, ".claude", "settings.json"), "utf8"))?.permissions?.deny;
12
+ return Array.isArray(deny) && DENY_RULES.every((r) => deny.includes(r));
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export async function protect(projectPath, confirm) {
19
+ const file = join(projectPath, ".claude", "settings.json");
20
+ let settings = {};
21
+ try {
22
+ const text = await readFile(file, "utf8");
23
+ try { settings = JSON.parse(text); } catch { throw new Error(`${file} is not valid JSON; fix it first. Nothing was changed.`); }
24
+ if (settings === null || typeof settings !== "object" || Array.isArray(settings)) throw new Error(`${file} is not a JSON object. Nothing was changed.`);
25
+ } catch (e) {
26
+ if (e.code !== "ENOENT") throw e;
27
+ }
28
+ const deny = Array.isArray(settings.permissions?.deny) ? settings.permissions.deny : [];
29
+ const missing = DENY_RULES.filter((r) => !deny.includes(r));
30
+ if (missing.length === 0) return { changed: false, file, added: [] };
31
+ if (!(await confirm())) return { changed: false, file, added: [] };
32
+ settings.permissions = { ...settings.permissions, deny: [...deny, ...missing] };
33
+ await mkdir(join(projectPath, ".claude"), { recursive: true });
34
+ await writeFile(file, JSON.stringify(settings, null, 2) + "\n");
35
+ return { changed: true, file, added: missing };
36
+ }
37
+
38
+ // The deny rules only stop Claude Code's Read tool, so the message says that and no more.
39
+ export function protectSummary(r) {
40
+ if (!r.changed) return `No changes made to ${r.file}.`;
41
+ return [
42
+ "Added read-deny rules for env files to .claude/settings.json. Claude Code's Read tool will not open them; shell commands can still read them.",
43
+ ...r.added.map((rule) => ` ${rule}`),
44
+ ].join("\n");
45
+ }
46
+
47
+ export async function run(args) {
48
+ const project = resolve(args[0] ?? process.cwd());
49
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
50
+ try {
51
+ const r = await protect(project, async () => {
52
+ const a = (await rl.question("Stop Claude Code's Read tool from opening env files in this project? (y/n) ")).trim().toLowerCase();
53
+ return a === "y" || a === "yes";
54
+ });
55
+ console.log(protectSummary(r));
56
+ return 0;
57
+ } catch (e) {
58
+ console.error(e.message);
59
+ return 1;
60
+ } finally {
61
+ rl.close();
62
+ }
63
+ }
@@ -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,19 @@
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
+ }): Promise<number>;
14
+ export function run(): Promise<number>;
15
+ export function launcherScript(nodePath: string, bin: string, subcommand: string, fallbackPath?: string): string;
16
+ export function hiddenPrompt(
17
+ question: string,
18
+ io?: { input?: NodeJS.ReadableStream; write?: (text: string) => void; exit?: (code: number) => void },
19
+ ): Promise<string>;
package/lib/setup.mjs ADDED
@@ -0,0 +1,185 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { mkdir, chmod, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } 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 { launchdPlist } from "./launchd.mjs";
12
+ import { keychainStore } from "./vault.mjs";
13
+ import { makeServer } from "./server.mjs";
14
+
15
+ const HOST = "com.getfootgun.keys";
16
+ const LABEL = "com.getfootgun.keys.health";
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
+ // A launcher survives a Node upgrade: it tries the Node that ran setup, then any node on PATH
23
+ // (with the Homebrew and /usr/local bins added, since launchd and Chrome start with a bare PATH),
24
+ // and otherwise shows a notification instead of failing silently. Notification text travels as argv.
25
+ export function launcherScript(nodePath, bin, subcommand, fallbackPath = "/opt/homebrew/bin:/usr/local/bin") {
26
+ const sub = subcommand ? ` ${subcommand}` : "";
27
+ const script = NOTIFY_SCRIPT.map((l) => `-e '${l}'`).join(" ");
28
+ return [
29
+ "#!/bin/sh",
30
+ `NODE="${nodePath}"`,
31
+ 'if [ ! -x "$NODE" ]; then',
32
+ ` PATH="$PATH:${fallbackPath}"`,
33
+ " export PATH",
34
+ ' NODE="$(command -v node)"',
35
+ "fi",
36
+ 'if [ -z "$NODE" ] || [ ! -x "$NODE" ]; then',
37
+ ` osascript ${script} "Footgun Keys" "${NO_NODE}" >/dev/null 2>&1`,
38
+ " exit 1",
39
+ "fi",
40
+ `exec "$NODE" "${bin}"${sub} "$@"`,
41
+ "",
42
+ ].join("\n");
43
+ }
44
+
45
+ async function writeExecutable(file, text) {
46
+ await writeFile(file, text, { mode: 0o700 });
47
+ await chmod(file, 0o700);
48
+ }
49
+
50
+ async function loadAgent(exec, uid, plist) {
51
+ try {
52
+ await exec("launchctl", ["bootstrap", `gui/${uid}`, plist]);
53
+ } catch {
54
+ // Already loaded: unload it, then load the fresh plist.
55
+ await exec("launchctl", ["bootout", `gui/${uid}/${LABEL}`]).catch(() => {});
56
+ await exec("launchctl", ["bootstrap", `gui/${uid}`, plist]);
57
+ }
58
+ }
59
+
60
+ // Every dependency is injected so tests never touch the real Library, launchctl, npm or Keychain.
61
+ export async function setup({ home, platform, prompt, exec, installApp, server, print, extensionIds = EXTENSION_IDS, uid = process.getuid?.() }) {
62
+ if (platform !== "darwin") {
63
+ print("Footgun Keys runs on macOS for now.");
64
+ return 1;
65
+ }
66
+
67
+ const support = await ensureSupportDir(home);
68
+ print("Created the Footgun Keys support folder.");
69
+
70
+ let bin;
71
+ try {
72
+ bin = await installApp(join(support, "app"));
73
+ } catch (e) {
74
+ const msg = (e instanceof Error && e.message ? e.message : "unknown error").split("\n")[0];
75
+ print(`Could not install Footgun Keys: ${msg}`);
76
+ return 1;
77
+ }
78
+ print("Installed a stable copy of the helper.");
79
+
80
+ const launcher = join(support, "footgun-keys");
81
+ const nativeLauncher = join(support, "native-host");
82
+ await writeExecutable(launcher, launcherScript(process.execPath, bin, ""));
83
+ await writeExecutable(nativeLauncher, launcherScript(process.execPath, bin, "native-host"));
84
+ print("Wrote the launchers.");
85
+
86
+ const hostDir = join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
87
+ await mkdir(hostDir, { recursive: true });
88
+ await writePrivate(join(hostDir, `${HOST}.json`), JSON.stringify({
89
+ name: HOST,
90
+ description: "Footgun Keys helper",
91
+ path: nativeLauncher,
92
+ type: "stdio",
93
+ allowed_origins: extensionIds.map((id) => `chrome-extension://${id}/`),
94
+ }, null, 2) + "\n");
95
+ print("Registered the helper with Chrome.");
96
+ if (extensionIds.length === 0) print("The Chrome extension id is not set yet; guides will not save until it is.");
97
+
98
+ const agents = join(home, "Library", "LaunchAgents");
99
+ await mkdir(agents, { recursive: true });
100
+ const plist = join(agents, `${LABEL}.plist`);
101
+ await writePrivate(plist, launchdPlist(launcher));
102
+ let scheduled = true;
103
+ try {
104
+ await loadAgent(exec, uid, plist);
105
+ print("Scheduled the daily key check.");
106
+ } catch {
107
+ scheduled = false;
108
+ print("Could not schedule the daily key check. Run setup again to retry.");
109
+ }
110
+ // A failed schedule must not end in apparent success, so it is repeated last and fails the run.
111
+ const finish = (code) => {
112
+ if (scheduled) return code;
113
+ print("The daily key check is NOT scheduled. Run npx @getfootgun/keys setup again.");
114
+ return 1;
115
+ };
116
+
117
+ let stored = false;
118
+ let lastError = "";
119
+ for (let i = 0; i < MAX_TRIES && !stored; i++) {
120
+ const key = (await prompt(`Paste your Footgun API key (create one free at ${SITE()}/account):`)).trim();
121
+ const res = await server.setApiKey(key);
122
+ if (res.ok) stored = true;
123
+ else {
124
+ lastError = res.error ?? "That key was not accepted.";
125
+ print(lastError);
126
+ }
127
+ }
128
+ if (!stored) {
129
+ print("No API key was saved. Run footgun-keys setup again when you have one.");
130
+ return finish(1);
131
+ }
132
+ print("Saved your Footgun API key to the Keychain.");
133
+
134
+ print("Next steps:");
135
+ print("Claude Code: claude mcp add footgun-keys -- npx -y @getfootgun/keys mcp");
136
+ print("Cursor: add the same command under MCP servers.");
137
+ print("Chrome: install the Footgun Keys extension.");
138
+ print("Your keys live in 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).");
139
+ return finish(0);
140
+ }
141
+
142
+ // Reads one line without echoing what is typed. Ctrl-C closes the prompt and exits 130.
143
+ export function hiddenPrompt(question, {
144
+ input = process.stdin,
145
+ write = (t) => process.stdout.write(t),
146
+ exit = (code) => process.exit(code),
147
+ } = {}) {
148
+ const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } });
149
+ const rl = createInterface({ input, output: muted, terminal: true });
150
+ write(`${question} `);
151
+ return new Promise((resolve) => {
152
+ rl.on("SIGINT", () => {
153
+ rl.close();
154
+ write("\n");
155
+ exit(130);
156
+ });
157
+ rl.question("", (answer) => {
158
+ rl.close();
159
+ write("\n");
160
+ resolve(answer);
161
+ });
162
+ });
163
+ }
164
+
165
+ const execAsync = promisify(execFile);
166
+
167
+ function realInstallApp(dir) {
168
+ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
169
+ return execAsync("npm", ["install", "--prefix", dir, `@getfootgun/keys@${version}`]).then(
170
+ () => join(dir, "node_modules", "@getfootgun", "keys", "bin", "footgun-keys.mjs"),
171
+ );
172
+ }
173
+
174
+ export async function run() {
175
+ const home = homedir();
176
+ return setup({
177
+ home,
178
+ platform: process.platform,
179
+ prompt: hiddenPrompt,
180
+ exec: (file, args) => execAsync(file, args),
181
+ installApp: realInstallApp,
182
+ server: makeServer({ store: keychainStore(), home }),
183
+ print: (line) => console.log(line),
184
+ });
185
+ }