@getfootgun/keys 0.1.0 → 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.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @getfootgun/keys
2
2
 
3
- Footgun Keys: guided API key setup for your Mac. Keys go to your Keychain, and only to an env file you ask for.
3
+ Footgun Keys: guided API key setup for your Mac or Windows PC. Keys go to your Mac Keychain or Windows Credential Manager, and only to an env file you ask for.
4
4
 
5
- Mac only. Needs Node 20 or newer.
5
+ Works on macOS and Windows. Needs Node 20 or newer.
6
6
 
7
7
  ## Install
8
8
 
@@ -15,16 +15,27 @@ Setup installs a stable copy of the helper, registers it with Chrome and with a
15
15
  footgun-keys <setup|mcp|native-host|health|protect>
16
16
 
17
17
  - `setup`: one-time install and registration, described above.
18
- - `mcp`: runs the MCP server that Claude Code and Cursor talk to. Connect it with `claude mcp add footgun-keys -- npx -y @getfootgun/keys mcp`, or add the same command under MCP servers in Cursor.
18
+ - `mcp`: runs the MCP server that Claude Code and Cursor talk to. Connect it with `claude mcp add footgun-keys -- npx -y @getfootgun/keys mcp` (on Windows: `claude mcp add footgun-keys -- cmd /c npx -y @getfootgun/keys mcp`), or add the same command under MCP servers in Cursor.
19
19
  - `native-host`: the bridge the Chrome extension uses to hand a key to the helper. Chrome starts it; you do not run it yourself.
20
- - `health`: the daily key check that launchd runs at 9:30. It tells you when a saved key stops working.
20
+ - `health`: the daily key check at 9:30, run by launchd on Mac and by Task Scheduler on Windows. It tells you when a saved key stops working.
21
21
  - `protect`: adds a Claude Code rule to the current project so Claude Code's Read tool will not open your env files (shell commands still can). It asks first.
22
22
 
23
23
  ## What is stored where
24
24
 
25
+ On a Mac:
26
+
25
27
  - Your Mac Keychain, service `footgun-keys`: every key you capture, and your Footgun API key. Key values live nowhere else except an env file you ask for.
26
28
  - The support folder, `~/Library/Application Support/Footgun Keys/`: the installed helper, the two launcher scripts, and session and guide files. It holds names and statuses only, never key values.
27
29
  - Chrome and launchd: a native host manifest in Chrome's NativeMessagingHosts folder, and a launch agent at `~/Library/LaunchAgents/com.getfootgun.keys.health.plist`.
30
+
31
+ On Windows:
32
+
33
+ - Windows Credential Manager, in entries whose names include `footgun-keys`: every key you capture, and your Footgun API key. Key values live nowhere else except an env file you ask for.
34
+ - The support folder, `%LOCALAPPDATA%\Footgun Keys`: the installed helper, the two launcher scripts (`footgun-keys.cmd` and `native-host.cmd`), and session and guide files. It holds names and statuses only, never key values.
35
+ - Chrome and Task Scheduler: a native host manifest in the support folder, registered under `HKCU\Software\Google\Chrome\NativeMessagingHosts\com.getfootgun.keys`, and a Task Scheduler task named "Footgun Keys health" (defined in `health-task.xml` in the support folder). The task runs without a window, on battery too, and catches up after a missed 9:30. Both are per user and need no admin rights.
36
+
37
+ On both:
38
+
28
39
  - Never on Footgun's servers: your keys are never sent to Footgun. The only thing the helper sends is your Footgun API key, to check your plan.
29
40
 
30
41
  ## Release checklist
@@ -1,2 +1,2 @@
1
1
  export const COMMANDS: string[];
2
- export function dispatch(argv: string[], libDir?: string): Promise<number>;
2
+ export function dispatch(argv: string[], libDir?: string, importModule?: (specifier: string) => Promise<{ run(args: string[]): unknown }>): Promise<number>;
package/lib/dispatch.mjs CHANGED
@@ -1,13 +1,14 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { fileURLToPath } from "node:url";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
4
 
5
5
  export const COMMANDS = ["setup", "mcp", "native-host", "health", "protect"];
6
6
  const LIB = fileURLToPath(new URL(".", import.meta.url));
7
7
 
8
8
  // "Not built yet" means the subcommand's own module file is absent. Any error thrown while loading
9
- // an existing module (such as a missing dependency) propagates.
10
- export async function dispatch(argv, libDir = LIB) {
9
+ // an existing module (such as a missing dependency) propagates. The module is imported by file: URL,
10
+ // since Windows rejects a plain absolute path (C:\...) as an ESM specifier.
11
+ export async function dispatch(argv, libDir = LIB, importModule = (specifier) => import(specifier)) {
11
12
  const [cmd, ...args] = argv;
12
13
  if (!COMMANDS.includes(cmd)) {
13
14
  console.error(`Usage: footgun-keys <${COMMANDS.join("|")}> [args]`);
@@ -18,6 +19,6 @@ export async function dispatch(argv, libDir = LIB) {
18
19
  console.error("not built yet");
19
20
  return 2;
20
21
  }
21
- const mod = await import(file);
22
+ const mod = await importModule(pathToFileURL(file).href);
22
23
  return (await mod.run(args)) ?? 0;
23
24
  }
package/lib/envfile.d.mts CHANGED
@@ -2,5 +2,9 @@ export function envFileFor(projectPath: string): Promise<string>;
2
2
  export function writeEnv(
3
3
  projectPath: string,
4
4
  vars: { name: string; value: string }[],
5
- opts?: { git?: (args: string[]) => Promise<string> },
5
+ opts?: {
6
+ git?: (args: string[]) => Promise<string>;
7
+ platform?: NodeJS.Platform;
8
+ chmodFn?: (path: string, mode: number) => Promise<void>;
9
+ },
6
10
  ): Promise<{ file: string; addedGitignore: boolean }>;
package/lib/envfile.mjs CHANGED
@@ -84,7 +84,7 @@ async function ensureIgnored(projectPath, file, git) {
84
84
  return true;
85
85
  }
86
86
 
87
- export async function writeEnv(projectPath, vars, { git = realGit } = {}) {
87
+ export async function writeEnv(projectPath, vars, { git = realGit, platform = process.platform, chmodFn = chmod } = {}) {
88
88
  for (const { value } of vars) formatValue(value); // refuse a bad value before touching any file
89
89
  const file = await envFileFor(projectPath);
90
90
  // Ignore first, so a key never sits in a file git could pick up.
@@ -96,6 +96,9 @@ export async function writeEnv(projectPath, vars, { git = realGit } = {}) {
96
96
  for (const { name, value } of vars) text = upsert(text, name, value);
97
97
  // writeFile on an existing file keeps its mode; a new file gets 0600 (umask can only tighten it).
98
98
  await writeFile(full, text, { mode: 0o600 });
99
- if (!existed) await chmod(full, 0o600);
99
+ if (!existed) {
100
+ // Windows has no POSIX modes, so a chmod failure there is harmless; elsewhere it must surface.
101
+ try { await chmodFn(full, 0o600); } catch (e) { if (platform !== "win32") throw e; }
102
+ }
100
103
  return { file, addedGitignore };
101
104
  }
package/lib/health.mjs CHANGED
@@ -1,15 +1,13 @@
1
- import { execFile } from "node:child_process";
2
1
  import { keychainStore, openVault } from "./vault.mjs";
3
2
  import { makeServer } from "./server.mjs";
4
3
  import { runTest } from "./testcall.mjs";
5
4
  import { groupByProviderLabel, healthRecord } from "./check.mjs";
5
+ import { notify } from "./platform.mjs";
6
6
 
7
7
  const TITLE = "Footgun Keys";
8
- // Title and body travel as argv, never inside the script text.
9
- const SCRIPT = "on run argv\ndisplay notification (item 2 of argv) with title (item 1 of argv)\nend run";
10
-
11
- export function macNotify(title, body, exec = execFile) {
12
- return new Promise((resolve, reject) => exec("osascript", ["-e", SCRIPT, title, body], (e) => (e ? reject(e) : resolve())));
8
+ // Kept for existing callers: the Mac notification, with an optional exec.
9
+ export function macNotify(title, body, exec) {
10
+ return notify(title, body, { platform: "darwin", exec });
13
11
  }
14
12
 
15
13
  // The env var a failed test is about: the one the test call authenticates with.
@@ -92,7 +90,7 @@ export async function runHealth({ vault, server, notify, fetch: fetchFn, now = (
92
90
  export async function run() {
93
91
  const store = keychainStore();
94
92
  try {
95
- await runHealth({ vault: openVault({ store }), server: makeServer({ store }), notify: macNotify });
93
+ await runHealth({ vault: openVault({ store }), server: makeServer({ store }), notify });
96
94
  return 0;
97
95
  } catch {
98
96
  return 1;
package/lib/mcp.d.mts CHANGED
@@ -2,6 +2,7 @@ import type { Vault } from "./vault.mjs";
2
2
  import type { Server } from "./server.mjs";
3
3
 
4
4
  export type ToolResult = { content: { type: "text"; text: string }[]; isError?: boolean };
5
+ export const SERVER_INFO: { name: string; version: string };
5
6
  export const INSTRUCTIONS: string;
6
7
  export const SPECS: Record<"keys_list" | "keys_guides" | "keys_write_env" | "keys_request" | "keys_check", { description: string; inputSchema: import("zod").ZodType }>;
7
8
  export function buildTools(deps: {
package/lib/mcp.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { execFile } from "node:child_process";
1
+ import { readFileSync } from "node:fs";
2
2
  import { z } from "zod";
3
3
  import { McpServer } from "@modelcontextprotocol/server";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
@@ -8,11 +8,15 @@ import { writeEnv } from "./envfile.mjs";
8
8
  import { runTest } from "./testcall.mjs";
9
9
  import { createSession, readSession, isExpired } from "./sessions.mjs";
10
10
  import { SITE } from "./paths.mjs";
11
+ import { openUrl } from "./platform.mjs";
11
12
  import { groupByProviderLabel, healthRecord } from "./check.mjs";
12
13
  import { isProtected } from "./protect.mjs";
13
14
  import { LABEL_RE, FREE_GUIDES } from "../shared/contract.mjs";
14
15
 
15
16
  // Every result here carries names, statuses and messages. A key value is never read into a result.
17
+ const HELPER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
18
+ export const SERVER_INFO = { name: "footgun-keys", version: HELPER_VERSION };
19
+
16
20
  export const INSTRUCTIONS =
17
21
  "When a project needs a third-party API key, call keys_request instead of asking the user to paste it. Tool results never contain key values.";
18
22
 
@@ -237,15 +241,11 @@ export const SPECS = {
237
241
  },
238
242
  };
239
243
 
240
- function openUrl(url) {
241
- return new Promise((resolve, reject) => execFile("open", [url], (e) => (e ? reject(e) : resolve())));
242
- }
243
-
244
- // Off macOS the server still answers initialize and tools/list (so a plugin that starts it does not
245
- // crash), but every tool call says Footgun Keys is Mac only.
244
+ // On other platforms the server still answers initialize and tools/list (so a plugin that starts it does not
245
+ // crash), but every tool call says Footgun Keys runs on macOS and Windows only.
246
246
  export function toolsFor(platform, makeDeps) {
247
- if (platform !== "darwin") {
248
- return Object.fromEntries(Object.keys(SPECS).map((name) => [name, async () => reply("Footgun Keys runs on macOS for now.")]));
247
+ if (platform !== "darwin" && platform !== "win32") {
248
+ return Object.fromEntries(Object.keys(SPECS).map((name) => [name, async () => reply("Footgun Keys runs on macOS and Windows for now.")]));
249
249
  }
250
250
  return buildTools(makeDeps());
251
251
  }
@@ -255,7 +255,7 @@ export async function runMcp() {
255
255
  const store = keychainStore();
256
256
  return { vault: openVault({ store }), server: makeServer({ store }), openUrl };
257
257
  });
258
- const mcp = new McpServer({ name: "footgun-keys", version: "0.1.0" }, { instructions: INSTRUCTIONS });
258
+ const mcp = new McpServer(SERVER_INFO, { instructions: INSTRUCTIONS });
259
259
  for (const [name, spec] of Object.entries(SPECS)) {
260
260
  mcp.registerTool(name, { title: name, ...spec }, async (args) => tools[name](args));
261
261
  }
package/lib/native.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { validateRequest, FREE_GUIDES } from "../shared/contract.mjs";
3
3
  import { EXTENSION_IDS } from "../shared/extension-ids.mjs";
4
- import { openVault } from "./vault.mjs";
4
+ import { openVault, tooLongFor } from "./vault.mjs";
5
5
  import { makeServer } from "./server.mjs";
6
6
  import { writeEnv } from "./envfile.mjs";
7
7
  import { runTest } from "./testcall.mjs";
@@ -92,7 +92,13 @@ async function dispatchRequest(req, deps) {
92
92
  }
93
93
 
94
94
  const label = session ? session.label : req.label;
95
- const vault = openVault({ store, home });
95
+ const platform = deps.platform ?? process.platform;
96
+ // Check every value first, so a too-long one stores nothing (not even earlier values in this request).
97
+ for (const x of req.values) {
98
+ const tooLong = tooLongFor(x.value, platform);
99
+ if (tooLong) return fail(tooLong);
100
+ }
101
+ const vault = openVault({ store, home, platform });
96
102
  for (const x of req.values) await vault.put(req.guide, label, x.envVar, x.value, secretOf.get(x.envVar));
97
103
  if (!req.final) return { ok: true, saved: req.values.map((x) => x.envVar) };
98
104
 
package/lib/paths.d.mts CHANGED
@@ -1,4 +1,4 @@
1
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>;
2
+ export function supportDir(home?: string, platform?: string, env?: NodeJS.ProcessEnv): string;
3
+ export function ensureSupportDir(home?: string, platform?: string, env?: NodeJS.ProcessEnv): Promise<string>;
4
+ export function writePrivate(file: string, data: string | Uint8Array): Promise<void>;
package/lib/paths.mjs CHANGED
@@ -1,18 +1,23 @@
1
1
  import { homedir } from "node:os";
2
- import { join } from "node:path";
2
+ import { join, win32 } from "node:path";
3
3
  import { mkdir, chmod, writeFile, rename, rm } from "node:fs/promises";
4
4
  import { randomBytes } from "node:crypto";
5
5
 
6
6
  export function SITE() { return process.env.FOOTGUN_KEYS_SITE ?? "https://getfootgun.com"; }
7
7
 
8
- export function supportDir(home = homedir()) { return join(home, "Library", "Application Support", "Footgun Keys"); }
8
+ export function supportDir(home = homedir(), platform = process.platform, env = process.env) {
9
+ if (platform === "win32") return win32.join(env.LOCALAPPDATA || win32.join(home, "AppData", "Local"), "Footgun Keys");
10
+ return join(home, "Library", "Application Support", "Footgun Keys");
11
+ }
9
12
 
10
13
  // 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
+ // Windows has no POSIX modes, so the chmod is skipped there.
15
+ export async function ensureSupportDir(home = homedir(), platform = process.platform, env = process.env) {
16
+ const dir = supportDir(home, platform, env);
17
+ const j = platform === "win32" ? win32.join : join;
18
+ for (const d of [dir, j(dir, "sessions"), j(dir, "guides")]) {
14
19
  await mkdir(d, { recursive: true, mode: 0o700 });
15
- await chmod(d, 0o700);
20
+ if (platform !== "win32") await chmod(d, 0o700);
16
21
  }
17
22
  return dir;
18
23
  }
@@ -0,0 +1,5 @@
1
+ type Exec = (file: string, ...rest: any[]) => unknown;
2
+
3
+ export const WIN_NOTIFY_SCRIPT: string;
4
+ export function notify(title: string, body: string, opts?: { platform?: string; exec?: Exec }): Promise<void>;
5
+ export function openUrl(url: string, opts?: { platform?: string; exec?: Exec }): Promise<void>;
@@ -0,0 +1,38 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ // Title and body travel as argv (Mac) or environment variables (Windows), never inside the script text.
4
+ const MAC_SCRIPT = "on run argv\ndisplay notification (item 2 of argv) with title (item 1 of argv)\nend run";
5
+
6
+ // Joined with "; " because a .cmd launcher carries it on one line. It must stay free of newlines, double quotes and %.
7
+ // Not executed on Windows in tests (this runs on a Mac); the tests check how it is called, not that a toast appears.
8
+ // The app id is PowerShell's own: a toast from an unregistered app id shows nothing on Windows.
9
+ export const WIN_NOTIFY_SCRIPT = [
10
+ "[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]",
11
+ "$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
12
+ "$text = $xml.GetElementsByTagName('text')",
13
+ "[void]$text.Item(0).AppendChild($xml.CreateTextNode($env:FOOTGUN_NOTIFY_TITLE))",
14
+ "[void]$text.Item(1).AppendChild($xml.CreateTextNode($env:FOOTGUN_NOTIFY_BODY))",
15
+ "$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
16
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($toast)",
17
+ ].join("; ");
18
+
19
+ export function notify(title, body, { platform = process.platform, exec = execFile } = {}) {
20
+ return new Promise((resolve, reject) => {
21
+ const done = (e) => (e ? reject(e) : resolve());
22
+ if (platform === "win32") {
23
+ exec("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", WIN_NOTIFY_SCRIPT],
24
+ { env: { ...process.env, FOOTGUN_NOTIFY_TITLE: title, FOOTGUN_NOTIFY_BODY: body } }, done);
25
+ } else {
26
+ exec("osascript", ["-e", MAC_SCRIPT, title, body], done);
27
+ }
28
+ });
29
+ }
30
+
31
+ export function openUrl(url, { platform = process.platform, exec = execFile } = {}) {
32
+ return new Promise((resolve, reject) => {
33
+ const done = (e) => (e ? reject(e) : resolve());
34
+ // rundll32 takes the url as one argument, so an & in it needs no shell quoting.
35
+ if (platform === "win32") exec("rundll32", ["url.dll,FileProtocolHandler", url], done);
36
+ else exec("open", [url], done);
37
+ });
38
+ }
@@ -0,0 +1,11 @@
1
+ type Exec = (file: string, args: string[]) => Promise<unknown>;
2
+
3
+ export function registerNativeHost(opts: {
4
+ platform: string;
5
+ exec: Exec;
6
+ home: string;
7
+ support: string;
8
+ manifest: { name: string; [key: string]: unknown };
9
+ }): Promise<void>;
10
+ export function healthTaskXml(launcher: string, now?: Date): string;
11
+ export function scheduleDaily(opts: { platform: string; exec: Exec; home: string; launcher: string; uid?: number; support?: string; now?: () => Date }): Promise<void>;
@@ -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
+ }
package/lib/setup.d.mts CHANGED
@@ -10,10 +10,13 @@ export function setup(deps: {
10
10
  print: (line: string) => void;
11
11
  extensionIds?: string[];
12
12
  uid?: number;
13
+ env?: Record<string, string | undefined>;
14
+ now?: () => Date;
13
15
  }): Promise<number>;
14
16
  export function run(): Promise<number>;
15
- export function launcherScript(nodePath: string, bin: string, subcommand: string, fallbackPath?: string): string;
17
+ export function launcherScript(nodePath: string, bin: string, subcommand: string, options?: { platform?: string; fallbackPath?: string }): string;
16
18
  export function hiddenPrompt(
17
19
  question: string,
18
20
  io?: { input?: NodeJS.ReadableStream; write?: (text: string) => void; exit?: (code: number) => void },
19
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 CHANGED
@@ -1,28 +1,56 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
- import { mkdir, chmod, writeFile } from "node:fs/promises";
3
+ import { chmod, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
- import { join } from "node:path";
5
+ import { join, win32 } from "node:path";
6
6
  import { createInterface } from "node:readline";
7
7
  import { Writable } from "node:stream";
8
8
  import { promisify } from "node:util";
9
9
  import { EXTENSION_IDS } from "../shared/extension-ids.mjs";
10
10
  import { SITE, ensureSupportDir, writePrivate } from "./paths.mjs";
11
- import { launchdPlist } from "./launchd.mjs";
11
+ import { registerNativeHost, scheduleDaily } from "./schedule.mjs";
12
12
  import { keychainStore } from "./vault.mjs";
13
+ import { WIN_NOTIFY_SCRIPT } from "./platform.mjs";
13
14
  import { makeServer } from "./server.mjs";
14
15
 
15
16
  const HOST = "com.getfootgun.keys";
16
- const LABEL = "com.getfootgun.keys.health";
17
17
  const MAX_TRIES = 3;
18
18
 
19
19
  const NO_NODE = "Footgun Keys cannot find Node. Run npx @getfootgun/keys setup again.";
20
20
  const NOTIFY_SCRIPT = ["on run argv", "display notification (item 2 of argv) with title (item 1 of argv)", "end run"];
21
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
+
22
49
  // A launcher survives a Node upgrade: it tries the Node that ran setup, then any node on PATH
23
50
  // (with the Homebrew and /usr/local bins added, since launchd and Chrome start with a bare PATH),
24
51
  // 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") {
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);
26
54
  const sub = subcommand ? ` ${subcommand}` : "";
27
55
  const script = NOTIFY_SCRIPT.map((l) => `-e '${l}'`).join(" ");
28
56
  return [
@@ -42,34 +70,26 @@ export function launcherScript(nodePath, bin, subcommand, fallbackPath = "/opt/h
42
70
  ].join("\n");
43
71
  }
44
72
 
45
- async function writeExecutable(file, text) {
73
+ async function writeExecutable(file, text, platform) {
46
74
  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
- }
75
+ if (platform !== "win32") await chmod(file, 0o700);
58
76
  }
59
77
 
60
78
  // 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.");
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.");
64
82
  return 1;
65
83
  }
66
84
 
67
- const support = await ensureSupportDir(home);
85
+ const win = platform === "win32";
86
+ const j = win ? win32.join : join;
87
+ const support = await ensureSupportDir(home, platform, env);
68
88
  print("Created the Footgun Keys support folder.");
69
89
 
70
90
  let bin;
71
91
  try {
72
- bin = await installApp(join(support, "app"));
92
+ bin = await installApp(j(support, "app"));
73
93
  } catch (e) {
74
94
  const msg = (e instanceof Error && e.message ? e.message : "unknown error").split("\n")[0];
75
95
  print(`Could not install Footgun Keys: ${msg}`);
@@ -77,31 +97,44 @@ export async function setup({ home, platform, prompt, exec, installApp, server,
77
97
  }
78
98
  print("Installed a stable copy of the helper.");
79
99
 
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"));
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);
84
114
  print("Wrote the launchers.");
85
115
 
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");
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
+ }
95
132
  print("Registered the helper with Chrome.");
96
133
  if (extensionIds.length === 0) print("The Chrome extension id is not set yet; guides will not save until it is.");
97
134
 
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
135
  let scheduled = true;
103
136
  try {
104
- await loadAgent(exec, uid, plist);
137
+ await scheduleDaily({ platform, exec, home, launcher, uid, support, now });
105
138
  print("Scheduled the daily key check.");
106
139
  } catch {
107
140
  scheduled = false;
@@ -129,13 +162,13 @@ export async function setup({ home, platform, prompt, exec, installApp, server,
129
162
  print("No API key was saved. Run footgun-keys setup again when you have one.");
130
163
  return finish(1);
131
164
  }
132
- print("Saved your Footgun API key to the Keychain.");
165
+ print(`Saved your Footgun API key to the ${win ? "Windows Credential Manager" : "Keychain"}.`);
133
166
 
134
167
  print("Next steps:");
135
- print("Claude Code: claude mcp add footgun-keys -- npx -y @getfootgun/keys mcp");
168
+ print(`Claude Code: claude mcp add footgun-keys -- ${win ? "cmd /c " : ""}npx -y @getfootgun/keys mcp`);
136
169
  print("Cursor: add the same command under MCP servers.");
137
170
  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).");
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).`);
139
172
  return finish(0);
140
173
  }
141
174
 
@@ -164,11 +197,19 @@ export function hiddenPrompt(question, {
164
197
 
165
198
  const execAsync = promisify(execFile);
166
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
+
167
208
  function realInstallApp(dir) {
168
209
  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
- );
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"));
172
213
  }
173
214
 
174
215
  export async function run() {
package/lib/vault.d.mts CHANGED
@@ -22,4 +22,5 @@ export interface Vault {
22
22
  }
23
23
  export function keychainStore(): KeyStore;
24
24
  export function memoryStore(): KeyStore;
25
- export function openVault(opts: { store: KeyStore; home?: string }): Vault;
25
+ export function openVault(opts: { store: KeyStore; home?: string; platform?: NodeJS.Platform }): Vault;
26
+ export function tooLongFor(value: string, platform?: NodeJS.Platform): string | null;
package/lib/vault.mjs CHANGED
@@ -24,7 +24,17 @@ export function memoryStore() {
24
24
  };
25
25
  }
26
26
 
27
- export function openVault({ store, home }) {
27
+ // Windows Credential Manager holds at most 2,560 bytes per credential, counted as UTF-16.
28
+ const WIN_MAX_BYTES = 2560;
29
+
30
+ // The fixed refusal message when value cannot be stored on this platform, else null. Never contains the value.
31
+ export function tooLongFor(value, platform = process.platform) {
32
+ return platform === "win32" && Buffer.byteLength(value, "utf16le") > WIN_MAX_BYTES
33
+ ? "This value is too long for Windows Credential Manager."
34
+ : null;
35
+ }
36
+
37
+ export function openVault({ store, home, platform = process.platform }) {
28
38
  const file = join(supportDir(home), "index.json");
29
39
  let queue = Promise.resolve();
30
40
 
@@ -47,6 +57,8 @@ export function openVault({ store, home }) {
47
57
 
48
58
  return {
49
59
  async put(provider, label, envVar, value, secret) {
60
+ const tooLong = tooLongFor(value, platform);
61
+ if (tooLong) throw new Error(tooLong);
50
62
  await store.set(account(provider, label, envVar), value);
51
63
  await mutate((entries) => {
52
64
  if (!find(entries, provider, label, envVar)) {
package/package.json CHANGED
@@ -1 +1 @@
1
- { "name": "@getfootgun/keys", "version": "0.1.0", "description": "Footgun Keys: guided API key setup that saves keys to your Mac Keychain.", "bin": { "footgun-keys": "bin/footgun-keys.mjs" }, "type": "module", "license": "MIT", "files": ["bin", "lib", "shared", "README.md"], "dependencies": { "@modelcontextprotocol/server": "2.3.1", "@napi-rs/keyring": "2.1.2", "zod": "4.6.7" }, "engines": { "node": ">=20" } }
1
+ { "name": "@getfootgun/keys", "version": "0.2.0-rc.1", "description": "Footgun Keys: guided API key setup that saves keys to the Mac Keychain or Windows Credential Manager.", "bin": { "footgun-keys": "bin/footgun-keys.mjs" }, "type": "module", "license": "MIT", "files": ["bin", "lib", "shared", "README.md"], "dependencies": { "@modelcontextprotocol/server": "2.3.1", "@napi-rs/keyring": "2.1.2", "zod": "4.6.7" }, "engines": { "node": ">=20" } }