@getfootgun/keys 0.1.0 → 0.2.0-rc.2
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 +15 -4
- package/lib/dispatch.d.mts +1 -1
- package/lib/dispatch.mjs +5 -4
- package/lib/envfile.d.mts +5 -1
- package/lib/envfile.mjs +5 -2
- package/lib/health.mjs +5 -7
- package/lib/mcp.d.mts +1 -0
- package/lib/mcp.mjs +10 -10
- package/lib/native.mjs +8 -2
- package/lib/paths.d.mts +3 -3
- package/lib/paths.mjs +11 -6
- package/lib/platform.d.mts +5 -0
- package/lib/platform.mjs +38 -0
- package/lib/schedule.d.mts +12 -0
- package/lib/schedule.mjs +115 -0
- package/lib/setup.d.mts +4 -1
- package/lib/setup.mjs +96 -48
- package/lib/vault.d.mts +2 -1
- package/lib/vault.mjs +13 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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
|
package/lib/dispatch.d.mts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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?: {
|
|
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)
|
|
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
|
-
//
|
|
9
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
241
|
-
|
|
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(
|
|
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
|
|
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()
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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>;
|
package/lib/platform.mjs
ADDED
|
@@ -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,12 @@
|
|
|
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<{ when: "daily" } | { when: "signin"; reason: string }>;
|
|
12
|
+
export function firstLine(e: unknown): string;
|
package/lib/schedule.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
const WIN_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
|
10
|
+
const WIN_RUN_VALUE = "FootgunKeysHealth";
|
|
11
|
+
|
|
12
|
+
async function loadAgent(exec, uid, plist) {
|
|
13
|
+
try {
|
|
14
|
+
await exec("launchctl", ["bootstrap", `gui/${uid}`, plist]);
|
|
15
|
+
} catch {
|
|
16
|
+
// Already loaded: unload it, then load the fresh plist.
|
|
17
|
+
await exec("launchctl", ["bootout", `gui/${uid}/${LABEL}`]).catch(() => {});
|
|
18
|
+
await exec("launchctl", ["bootstrap", `gui/${uid}`, plist]);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Chrome finds the helper through a manifest: a file in Chrome's folder on macOS, a registry
|
|
23
|
+
// pointer to a file in the support folder on Windows (per user, no admin rights).
|
|
24
|
+
export async function registerNativeHost({ platform, exec, home, support, manifest }) {
|
|
25
|
+
const text = JSON.stringify(manifest, null, 2) + "\n";
|
|
26
|
+
if (platform === "win32") {
|
|
27
|
+
const file = win32.join(support, `${manifest.name}.json`);
|
|
28
|
+
await writePrivate(file, text);
|
|
29
|
+
await exec("reg", ["add", WIN_HOST_KEY, "/ve", "/t", "REG_SZ", "/d", file, "/f"]);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const hostDir = join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
|
|
33
|
+
await mkdir(hostDir, { recursive: true });
|
|
34
|
+
await writePrivate(join(hostDir, `${manifest.name}.json`), text);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const xmlEscape = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
38
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
39
|
+
|
|
40
|
+
// Task Scheduler definition for the daily check: runs on battery, catches up a missed run, and starts
|
|
41
|
+
// the launcher through a headless console so no window shows. It names the launcher only, never a key.
|
|
42
|
+
export function healthTaskXml(launcher, now = new Date()) {
|
|
43
|
+
const start = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T09:30:00`;
|
|
44
|
+
return [
|
|
45
|
+
'<?xml version="1.0" encoding="UTF-16"?>',
|
|
46
|
+
'<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
|
|
47
|
+
" <RegistrationInfo>",
|
|
48
|
+
" <Description>Footgun Keys daily key check</Description>",
|
|
49
|
+
" </RegistrationInfo>",
|
|
50
|
+
" <Triggers>",
|
|
51
|
+
" <CalendarTrigger>",
|
|
52
|
+
` <StartBoundary>${start}</StartBoundary>`,
|
|
53
|
+
" <Enabled>true</Enabled>",
|
|
54
|
+
" <ScheduleByDay>",
|
|
55
|
+
" <DaysInterval>1</DaysInterval>",
|
|
56
|
+
" </ScheduleByDay>",
|
|
57
|
+
" </CalendarTrigger>",
|
|
58
|
+
" </Triggers>",
|
|
59
|
+
" <Principals>",
|
|
60
|
+
' <Principal id="Author">',
|
|
61
|
+
" <LogonType>InteractiveToken</LogonType>",
|
|
62
|
+
" <RunLevel>LeastPrivilege</RunLevel>",
|
|
63
|
+
" </Principal>",
|
|
64
|
+
" </Principals>",
|
|
65
|
+
" <Settings>",
|
|
66
|
+
" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>",
|
|
67
|
+
" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>",
|
|
68
|
+
" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>",
|
|
69
|
+
" <StartWhenAvailable>true</StartWhenAvailable>",
|
|
70
|
+
" <ExecutionTimeLimit>PT10M</ExecutionTimeLimit>",
|
|
71
|
+
" <Enabled>true</Enabled>",
|
|
72
|
+
" </Settings>",
|
|
73
|
+
' <Actions Context="Author">',
|
|
74
|
+
" <Exec>",
|
|
75
|
+
" <Command>conhost.exe</Command>",
|
|
76
|
+
` <Arguments>${xmlEscape(`--headless "${launcher}" health`)}</Arguments>`,
|
|
77
|
+
" </Exec>",
|
|
78
|
+
" </Actions>",
|
|
79
|
+
"</Task>",
|
|
80
|
+
"",
|
|
81
|
+
].join("\r\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Daily 9:30 run of "<launcher> health". Resolves { when: "daily" }, or on a Windows PC that blocks
|
|
85
|
+
// Task Scheduler { when: "signin", reason } after falling back to a per-user Run entry that checks
|
|
86
|
+
// at each sign-in. Throws if neither works.
|
|
87
|
+
export async function scheduleDaily({ platform, exec, home, launcher, uid, support, now = () => new Date() }) {
|
|
88
|
+
if (platform === "win32") {
|
|
89
|
+
// schtasks reads /XML files as UTF-16LE with a byte order mark.
|
|
90
|
+
const xmlPath = win32.join(support, "health-task.xml");
|
|
91
|
+
const body = Buffer.from(healthTaskXml(launcher, now()), "utf16le");
|
|
92
|
+
await writePrivate(xmlPath, Buffer.concat([Buffer.from([0xff, 0xfe]), body]));
|
|
93
|
+
try {
|
|
94
|
+
await exec("schtasks", ["/Create", "/F", "/TN", TASK, "/XML", xmlPath]);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
// Some PCs (security software, policy) refuse every new task, even for an administrator.
|
|
97
|
+
await exec("reg", ["add", WIN_RUN_KEY, "/v", WIN_RUN_VALUE, "/t", "REG_SZ", "/d", `conhost.exe --headless "${launcher}" health`, "/f"]).catch(() => { throw e; });
|
|
98
|
+
return { when: "signin", reason: firstLine(e) };
|
|
99
|
+
}
|
|
100
|
+
// A sign-in entry from an earlier blocked run would now double the checks.
|
|
101
|
+
await exec("reg", ["delete", WIN_RUN_KEY, "/v", WIN_RUN_VALUE, "/f"]).catch(() => {});
|
|
102
|
+
return { when: "daily" };
|
|
103
|
+
}
|
|
104
|
+
const agents = join(home, "Library", "LaunchAgents");
|
|
105
|
+
await mkdir(agents, { recursive: true });
|
|
106
|
+
const plist = join(agents, `${LABEL}.plist`);
|
|
107
|
+
await writePrivate(plist, launchdPlist(launcher));
|
|
108
|
+
await loadAgent(exec, uid, plist);
|
|
109
|
+
return { when: "daily" };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The OS tool's own message (schtasks/launchctl stderr), which names no key.
|
|
113
|
+
export function firstLine(e) {
|
|
114
|
+
return String(e?.stderr || (e instanceof Error ? e.message : "") || "").trim().split(/\r?\n/)[0];
|
|
115
|
+
}
|
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 {
|
|
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 {
|
|
11
|
+
import { firstLine, 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
|
|
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(
|
|
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,35 +97,55 @@ 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
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
137
|
+
const result = await scheduleDaily({ platform, exec, home, launcher, uid, support, now });
|
|
138
|
+
if (result?.when === "signin") {
|
|
139
|
+
print(`Windows would not create a scheduled task on this PC${result.reason ? ` (${result.reason})` : ""}.`);
|
|
140
|
+
print("Footgun will check your keys each time you sign in to Windows instead.");
|
|
141
|
+
} else {
|
|
142
|
+
print("Scheduled the daily key check.");
|
|
143
|
+
}
|
|
144
|
+
} catch (e) {
|
|
107
145
|
scheduled = false;
|
|
108
146
|
print("Could not schedule the daily key check. Run setup again to retry.");
|
|
147
|
+
const reason = firstLine(e);
|
|
148
|
+
if (reason) print(`The scheduler said: ${reason}`);
|
|
109
149
|
}
|
|
110
150
|
// A failed schedule must not end in apparent success, so it is repeated last and fails the run.
|
|
111
151
|
const finish = (code) => {
|
|
@@ -129,13 +169,13 @@ export async function setup({ home, platform, prompt, exec, installApp, server,
|
|
|
129
169
|
print("No API key was saved. Run footgun-keys setup again when you have one.");
|
|
130
170
|
return finish(1);
|
|
131
171
|
}
|
|
132
|
-
print(
|
|
172
|
+
print(`Saved your Footgun API key to the ${win ? "Windows Credential Manager" : "Keychain"}.`);
|
|
133
173
|
|
|
134
174
|
print("Next steps:");
|
|
135
|
-
print(
|
|
175
|
+
print(`Claude Code: claude mcp add footgun-keys -- ${win ? "cmd /c " : ""}npx -y @getfootgun/keys mcp`);
|
|
136
176
|
print("Cursor: add the same command under MCP servers.");
|
|
137
177
|
print("Chrome: install the Footgun Keys extension.");
|
|
138
|
-
print(
|
|
178
|
+
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
179
|
return finish(0);
|
|
140
180
|
}
|
|
141
181
|
|
|
@@ -164,11 +204,19 @@ export function hiddenPrompt(question, {
|
|
|
164
204
|
|
|
165
205
|
const execAsync = promisify(execFile);
|
|
166
206
|
|
|
207
|
+
// Node 20.12+ will not execFile a .cmd without a shell, so on Windows npm runs through node itself.
|
|
208
|
+
export function npmInstallCommand(platform, dir, version, env = process.env, nodePath = process.execPath) {
|
|
209
|
+
const args = ["install", "--prefix", dir, `@getfootgun/keys@${version}`];
|
|
210
|
+
if (platform !== "win32") return { file: "npm", args };
|
|
211
|
+
const cli = env.npm_execpath?.endsWith(".js") ? env.npm_execpath : win32.join(win32.dirname(nodePath), "node_modules", "npm", "bin", "npm-cli.js");
|
|
212
|
+
return { file: nodePath, args: [cli, ...args] };
|
|
213
|
+
}
|
|
214
|
+
|
|
167
215
|
function realInstallApp(dir) {
|
|
168
216
|
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
);
|
|
217
|
+
const { file, args } = npmInstallCommand(process.platform, dir, version);
|
|
218
|
+
const j = process.platform === "win32" ? win32.join : join;
|
|
219
|
+
return execAsync(file, args).then(() => j(dir, "node_modules", "@getfootgun", "keys", "bin", "footgun-keys.mjs"));
|
|
172
220
|
}
|
|
173
221
|
|
|
174
222
|
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
|
-
|
|
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
|
+
{ "name": "@getfootgun/keys", "version": "0.2.0-rc.2", "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" } }
|