@getfootgun/keys 0.2.0-rc.1 → 0.2.0-rc.3

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.
@@ -1,2 +1,4 @@
1
1
  export const COMMANDS: string[];
2
- export function dispatch(argv: string[], libDir?: string, importModule?: (specifier: string) => Promise<{ run(args: string[]): unknown }>): Promise<number>;
2
+ type RuntimeOpts = { platform?: string; arch?: string; env?: Record<string, string | undefined>; exists?: (path: string) => boolean };
3
+ export function missingRuntimeMessage(e: unknown, opts?: RuntimeOpts): string | null;
4
+ export function dispatch(argv: string[], libDir?: string, importModule?: (specifier: string) => Promise<{ run(args: string[]): unknown }>, opts?: RuntimeOpts & { print?: (line: string) => void }): Promise<number>;
package/lib/dispatch.mjs CHANGED
@@ -8,7 +8,20 @@ const LIB = fileURLToPath(new URL(".", import.meta.url));
8
8
  // "Not built yet" means the subcommand's own module file is absent. Any error thrown while loading
9
9
  // an existing module (such as a missing dependency) propagates. The module is imported by file: URL,
10
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
+ // The Windows key-store binding needs Microsoft's Visual C++ Runtime (vcruntime140.dll), which a fresh
12
+ // Windows may not have. Then loading fails with "Cannot find native binding"; say what to install instead.
13
+ export function missingRuntimeMessage(e, { platform = process.platform, arch = process.arch, env = process.env, exists = existsSync } = {}) {
14
+ if (platform !== "win32") return null;
15
+ if (!/native binding/i.test(String(e?.message ?? ""))) return null;
16
+ if (exists(join(env.SystemRoot ?? "C:\\Windows", "System32", "vcruntime140.dll"))) return null;
17
+ const a = arch === "arm64" ? "arm64" : arch === "ia32" ? "x86" : "x64";
18
+ return [
19
+ "Footgun Keys needs Microsoft's Visual C++ Runtime, which this PC does not have yet.",
20
+ `Install it from https://aka.ms/vs/17/release/vc_redist.${a}.exe, then run npx.cmd @getfootgun/keys setup again.`,
21
+ ].join("\n");
22
+ }
23
+
24
+ export async function dispatch(argv, libDir = LIB, importModule = (specifier) => import(specifier), opts = {}) {
12
25
  const [cmd, ...args] = argv;
13
26
  if (!COMMANDS.includes(cmd)) {
14
27
  console.error(`Usage: footgun-keys <${COMMANDS.join("|")}> [args]`);
@@ -19,6 +32,14 @@ export async function dispatch(argv, libDir = LIB, importModule = (specifier) =>
19
32
  console.error("not built yet");
20
33
  return 2;
21
34
  }
22
- const mod = await importModule(pathToFileURL(file).href);
35
+ let mod;
36
+ try {
37
+ mod = await importModule(pathToFileURL(file).href);
38
+ } catch (e) {
39
+ const msg = missingRuntimeMessage(e, opts);
40
+ if (!msg) throw e;
41
+ (opts.print ?? console.error)(msg);
42
+ return 1;
43
+ }
23
44
  return (await mod.run(args)) ?? 0;
24
45
  }
@@ -0,0 +1,7 @@
1
+ export const ENV_FILES: string[];
2
+ export const PUBLIC_PREFIX: RegExp;
3
+ export function isManaged(name: string): boolean;
4
+ export function isLocalValue(value: string): boolean;
5
+ export function codeNames(projectPath: string): Promise<string[]>;
6
+ export function parseEnv(text: string): Map<string, string>;
7
+ export function envFileValues(projectPath: string): Promise<Map<string, { value: string; file: string }>>;
@@ -0,0 +1,88 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ // Which env var names a project uses, and which values its local env files hold. Only names are read from
5
+ // code; values are read from env files and stay in the caller (never in a result).
6
+
7
+ const SKIP_DIRS = new Set(["node_modules", "dist", "build", "out", "coverage", "vendor", "venv", "__pycache__", "target"]);
8
+ const EXTENSIONS = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|py|prisma)$/;
9
+ const MAX_FILES = 5000;
10
+ const MAX_BYTES = 1024 * 1024;
11
+ const NAME = "[A-Z_][A-Z0-9_]*";
12
+ const PATTERNS = [
13
+ new RegExp(`process\\.env\\.(${NAME})\\b`, "g"),
14
+ new RegExp(`process\\.env\\[\\s*["'\`](${NAME})["'\`]\\s*\\]`, "g"),
15
+ new RegExp(`import\\.meta\\.env\\.(${NAME})\\b`, "g"),
16
+ new RegExp(`Deno\\.env\\.get\\(\\s*["'](${NAME})["']`, "g"),
17
+ new RegExp(`os\\.environ\\[\\s*["'](${NAME})["']\\s*\\]`, "g"),
18
+ new RegExp(`os\\.(?:environ\\.get|getenv)\\(\\s*["'](${NAME})["']`, "g"),
19
+ new RegExp(`\\benv\\(\\s*["'](${NAME})["']\\s*\\)`, "g"), // prisma schema and env() helpers
20
+ ];
21
+ const DESTRUCTURE = /\{([^{}]*)\}\s*=\s*(?:process\.env|import\.meta\.env)\b/g;
22
+
23
+ // Read in this order; the first file that sets a name wins, so production values beat development ones.
24
+ export const ENV_FILES = [".env.production.local", ".env.production", ".env.local", ".env"];
25
+ // Names the platform or the framework sets itself.
26
+ const MANAGED = /^(?:VERCEL|VERCEL_.*|RAILWAY_.*|NODE_ENV|PORT|CI|MODE|DEV|PROD|SSR|BASE_URL|NEXT_RUNTIME|HOSTNAME|PWD|HOME)$/;
27
+ export const PUBLIC_PREFIX = /^(?:NEXT_PUBLIC_|VITE_|PUBLIC_|EXPO_PUBLIC_|NUXT_PUBLIC_|REACT_APP_)/;
28
+ // A value that only works on the developer's machine must never reach a deployed app.
29
+ const LOCAL = /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0|host\.docker\.internal|\[::1\])\b/i;
30
+
31
+ export const isManaged = (name) => MANAGED.test(name);
32
+ export const isLocalValue = (value) => LOCAL.test(value);
33
+
34
+ /** Env var names referenced in source, sorted. */
35
+ export async function codeNames(projectPath) {
36
+ const names = new Set();
37
+ let files = 0;
38
+ async function walk(dir) {
39
+ let entries;
40
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
41
+ for (const e of entries) {
42
+ if (files >= MAX_FILES) return;
43
+ if (e.name.startsWith(".") || SKIP_DIRS.has(e.name)) continue;
44
+ const p = join(dir, e.name);
45
+ if (e.isDirectory()) { await walk(p); continue; }
46
+ if (!e.isFile() || !EXTENSIONS.test(e.name)) continue;
47
+ files++;
48
+ try { if ((await stat(p)).size > MAX_BYTES) continue; } catch { continue; }
49
+ let text;
50
+ try { text = await readFile(p, "utf8"); } catch { continue; }
51
+ for (const re of PATTERNS) for (const m of text.matchAll(re)) names.add(m[1]);
52
+ for (const m of text.matchAll(DESTRUCTURE)) {
53
+ for (const part of m[1].split(",")) {
54
+ const n = part.trim().split(/[\s:=]/)[0];
55
+ if (new RegExp(`^${NAME}$`).test(n)) names.add(n);
56
+ }
57
+ }
58
+ }
59
+ }
60
+ await walk(projectPath);
61
+ return [...names].filter((n) => !isManaged(n)).sort();
62
+ }
63
+
64
+ // KEY=value lines as dotenv reads them: optional "export ", quotes stripped, inline comments after an unquoted value dropped.
65
+ export function parseEnv(text) {
66
+ const out = new Map();
67
+ for (const raw of text.split(/\r?\n/)) {
68
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
69
+ if (!m) continue;
70
+ let v = m[2].trim();
71
+ const q = v[0];
72
+ if ((q === '"' || q === "'" || q === "`") && v.lastIndexOf(q) > 0) v = v.slice(1, v.lastIndexOf(q));
73
+ else v = v.replace(/\s+#.*$/, "");
74
+ if (!out.has(m[1])) out.set(m[1], v);
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** name -> { value, file } from the project's local env files, in ENV_FILES order. */
80
+ export async function envFileValues(projectPath) {
81
+ const out = new Map();
82
+ for (const file of ENV_FILES) {
83
+ let text;
84
+ try { text = await readFile(join(projectPath, file), "utf8"); } catch { continue; }
85
+ for (const [name, value] of parseEnv(text)) if (!out.has(name) && value !== "") out.set(name, { value, file });
86
+ }
87
+ return out;
88
+ }
package/lib/mcp.d.mts CHANGED
@@ -4,7 +4,7 @@ import type { Server } from "./server.mjs";
4
4
  export type ToolResult = { content: { type: "text"; text: string }[]; isError?: boolean };
5
5
  export const SERVER_INFO: { name: string; version: string };
6
6
  export const INSTRUCTIONS: string;
7
- export const SPECS: Record<"keys_list" | "keys_guides" | "keys_write_env" | "keys_request" | "keys_check", { description: string; inputSchema: import("zod").ZodType }>;
7
+ export const SPECS: Record<"keys_list" | "keys_guides" | "keys_write_env" | "keys_request" | "keys_check" | "keys_sync_env", { description: string; inputSchema: import("zod").ZodType }>;
8
8
  export function buildTools(deps: {
9
9
  vault: Vault;
10
10
  server: Server;
@@ -19,6 +19,7 @@ export function buildTools(deps: {
19
19
  keys_write_env(args: { project_path: string; env_vars: string[] }): Promise<ToolResult>;
20
20
  keys_request(args: { provider: string; project_path: string; env_vars?: string[]; label?: string; session?: string }): Promise<ToolResult>;
21
21
  keys_check(args?: { env_var?: string }): Promise<ToolResult>;
22
+ keys_sync_env(args: { project_path: string; platform: "vercel" | "railway"; apply?: boolean; overwrite?: string[]; targets?: ("production" | "preview" | "development")[] }): Promise<ToolResult>;
22
23
  };
23
24
  export function toolsFor(platform: string, makeDeps: () => Parameters<typeof buildTools>[0]): ReturnType<typeof buildTools>;
24
25
  export function runMcp(): Promise<void>;
package/lib/mcp.mjs CHANGED
@@ -11,6 +11,7 @@ import { SITE } from "./paths.mjs";
11
11
  import { openUrl } from "./platform.mjs";
12
12
  import { groupByProviderLabel, healthRecord } from "./check.mjs";
13
13
  import { isProtected } from "./protect.mjs";
14
+ import { syncEnv, VERCEL_TARGETS } from "./sync.mjs";
14
15
  import { LABEL_RE, FREE_GUIDES } from "../shared/contract.mjs";
15
16
 
16
17
  // Every result here carries names, statuses and messages. A key value is never read into a result.
@@ -18,7 +19,7 @@ const HELPER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import
18
19
  export const SERVER_INFO = { name: "footgun-keys", version: HELPER_VERSION };
19
20
 
20
21
  export const INSTRUCTIONS =
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.";
22
+ "When a project needs a third-party API key, call keys_request instead of asking the user to paste it. Before or after deploying to Vercel or Railway, call keys_sync_env to find env vars the deployed app is missing. Tool results never contain key values.";
22
23
 
23
24
  const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
24
25
  const PROVIDER_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; // the server's guide slug rule
@@ -189,6 +190,14 @@ export function buildTools({ vault, server, openUrl, home, now = () => new Date(
189
190
  }
190
191
  return reply(lines.join("\n"));
191
192
  },
193
+
194
+ async keys_sync_env({ project_path, platform, apply = false, overwrite = [], targets }) {
195
+ const blocked = await planBlock("keys_sync_env");
196
+ if (blocked) return reply(blocked);
197
+ if (overwrite.some((n) => !NAME_RE.test(n))) return reply("One of the names is not a valid env var name.", true);
198
+ const r = await syncEnv({ projectPath: project_path, platform, apply, overwrite, targets, vault, fetch: fetchFn, home });
199
+ return reply(r.text, r.isError);
200
+ },
192
201
  };
193
202
 
194
203
  // Poll the session file once a second, up to waitMs.
@@ -239,6 +248,16 @@ export const SPECS = {
239
248
  description: "Test saved keys against their provider and record whether each still works.",
240
249
  inputSchema: z.object({ env_var: z.string().optional().describe("Check just this env var. Defaults to all saved keys.") }),
241
250
  },
251
+ keys_sync_env: {
252
+ description: "Compare the env vars this project uses with what its Vercel or Railway project has, and push the missing ones from the Keychain or the project's env file. Without apply it only reports; show the report to the user and get their yes before apply. Existing variables are never changed unless named in overwrite. Never shows values.",
253
+ inputSchema: z.object({
254
+ project_path: z.string().describe("Absolute path of the project folder (linked with vercel link or railway link)."),
255
+ platform: z.enum(["vercel", "railway"]).describe("Where the app is deployed."),
256
+ apply: z.boolean().optional().describe("Push the missing variables. Defaults to false: report only."),
257
+ overwrite: names.optional().describe("Variables already set on the platform to replace with the local value."),
258
+ targets: z.array(z.enum(VERCEL_TARGETS)).optional().describe("Vercel only. Defaults to production and preview."),
259
+ }),
260
+ },
242
261
  };
243
262
 
244
263
  // On other platforms the server still answers initialize and tools/list (so a plugin that starts it does not
@@ -8,4 +8,5 @@ export function registerNativeHost(opts: {
8
8
  manifest: { name: string; [key: string]: unknown };
9
9
  }): Promise<void>;
10
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>;
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 CHANGED
@@ -6,6 +6,8 @@ import { launchdPlist } from "./launchd.mjs";
6
6
  const LABEL = "com.getfootgun.keys.health";
7
7
  const WIN_HOST_KEY = "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\com.getfootgun.keys";
8
8
  const TASK = "Footgun Keys health";
9
+ const WIN_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
10
+ const WIN_RUN_VALUE = "FootgunKeysHealth";
9
11
 
10
12
  async function loadAgent(exec, uid, plist) {
11
13
  try {
@@ -79,19 +81,35 @@ export function healthTaskXml(launcher, now = new Date()) {
79
81
  ].join("\r\n");
80
82
  }
81
83
 
82
- // Daily 9:30 run of "<launcher> health". Throws if the OS scheduler refuses.
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.
83
87
  export async function scheduleDaily({ platform, exec, home, launcher, uid, support, now = () => new Date() }) {
84
88
  if (platform === "win32") {
85
89
  // schtasks reads /XML files as UTF-16LE with a byte order mark.
86
90
  const xmlPath = win32.join(support, "health-task.xml");
87
91
  const body = Buffer.from(healthTaskXml(launcher, now()), "utf16le");
88
92
  await writePrivate(xmlPath, Buffer.concat([Buffer.from([0xff, 0xfe]), body]));
89
- await exec("schtasks", ["/Create", "/F", "/TN", TASK, "/XML", xmlPath]);
90
- return;
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" };
91
103
  }
92
104
  const agents = join(home, "Library", "LaunchAgents");
93
105
  await mkdir(agents, { recursive: true });
94
106
  const plist = join(agents, `${LABEL}.plist`);
95
107
  await writePrivate(plist, launchdPlist(launcher));
96
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];
97
115
  }
package/lib/setup.mjs CHANGED
@@ -8,7 +8,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 { registerNativeHost, scheduleDaily } from "./schedule.mjs";
11
+ import { firstLine, registerNativeHost, scheduleDaily } from "./schedule.mjs";
12
12
  import { keychainStore } from "./vault.mjs";
13
13
  import { WIN_NOTIFY_SCRIPT } from "./platform.mjs";
14
14
  import { makeServer } from "./server.mjs";
@@ -17,6 +17,8 @@ const HOST = "com.getfootgun.keys";
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
+ // PowerShell blocks npx (npx.ps1) by default, so Windows text names npx.cmd, which runs in both shells.
21
+ const WIN_NO_NODE = "Footgun Keys cannot find Node. Run npx.cmd @getfootgun/keys setup again.";
20
22
  const NOTIFY_SCRIPT = ["on run argv", "display notification (item 2 of argv) with title (item 1 of argv)", "end run"];
21
23
 
22
24
  // The Windows .cmd launcher: same fallback order, but the notification text travels as environment
@@ -37,7 +39,7 @@ function windowsLauncher(nodePath, bin, subcommand) {
37
39
  ")",
38
40
  'if not exist "%NODE%" (',
39
41
  ' set "FOOTGUN_NOTIFY_TITLE=Footgun Keys"',
40
- ` set "FOOTGUN_NOTIFY_BODY=${NO_NODE}"`,
42
+ ` set "FOOTGUN_NOTIFY_BODY=${WIN_NO_NODE}"`,
41
43
  ` powershell.exe -NoProfile -NonInteractive -Command "${WIN_NOTIFY_SCRIPT}" >nul 2>&1`,
42
44
  " exit /b 1",
43
45
  ")",
@@ -83,6 +85,7 @@ export async function setup({ home, platform, prompt, exec, installApp, server,
83
85
  }
84
86
 
85
87
  const win = platform === "win32";
88
+ const npx = win ? "npx.cmd" : "npx";
86
89
  const j = win ? win32.join : join;
87
90
  const support = await ensureSupportDir(home, platform, env);
88
91
  print("Created the Footgun Keys support folder.");
@@ -134,16 +137,23 @@ export async function setup({ home, platform, prompt, exec, installApp, server,
134
137
 
135
138
  let scheduled = true;
136
139
  try {
137
- await scheduleDaily({ platform, exec, home, launcher, uid, support, now });
138
- print("Scheduled the daily key check.");
139
- } catch {
140
+ const result = await scheduleDaily({ platform, exec, home, launcher, uid, support, now });
141
+ if (result?.when === "signin") {
142
+ print(`Windows would not create a scheduled task on this PC${result.reason ? ` (${result.reason})` : ""}.`);
143
+ print("Footgun will check your keys each time you sign in to Windows instead.");
144
+ } else {
145
+ print("Scheduled the daily key check.");
146
+ }
147
+ } catch (e) {
140
148
  scheduled = false;
141
149
  print("Could not schedule the daily key check. Run setup again to retry.");
150
+ const reason = firstLine(e);
151
+ if (reason) print(`The scheduler said: ${reason}`);
142
152
  }
143
153
  // A failed schedule must not end in apparent success, so it is repeated last and fails the run.
144
154
  const finish = (code) => {
145
155
  if (scheduled) return code;
146
- print("The daily key check is NOT scheduled. Run npx @getfootgun/keys setup again.");
156
+ print(`The daily key check is NOT scheduled. Run ${npx} @getfootgun/keys setup again.`);
147
157
  return 1;
148
158
  };
149
159
 
@@ -168,7 +178,7 @@ export async function setup({ home, platform, prompt, exec, installApp, server,
168
178
  print(`Claude Code: claude mcp add footgun-keys -- ${win ? "cmd /c " : ""}npx -y @getfootgun/keys mcp`);
169
179
  print("Cursor: add the same command under MCP servers.");
170
180
  print("Chrome: install the Footgun Keys extension.");
171
- print(`Your keys live in ${win ? "the Windows Credential Manager" : "your Mac's Keychain"} and in the env files you choose. AI tools can still read those env files; run npx @getfootgun/keys protect in a project so Claude Code's Read tool will not open them (shell commands still can).`);
181
+ print(`Your keys live in ${win ? "the Windows Credential Manager" : "your Mac's Keychain"} and in the env files you choose. AI tools can still read those env files; run ${npx} @getfootgun/keys protect in a project so Claude Code's Read tool will not open them (shell commands still can).`);
172
182
  return finish(0);
173
183
  }
174
184
 
package/lib/sync.d.mts ADDED
@@ -0,0 +1,16 @@
1
+ import type { Vault } from "./vault.mjs";
2
+
3
+ export const VERCEL_TARGETS: ("production" | "preview" | "development")[];
4
+ export const PLATFORMS: Record<"vercel" | "railway", unknown>;
5
+ export function syncEnv(opts: {
6
+ projectPath: string;
7
+ platform: string;
8
+ apply?: boolean;
9
+ overwrite?: string[];
10
+ targets?: string[];
11
+ vault: Vault;
12
+ fetch?: typeof fetch;
13
+ home?: string;
14
+ }): Promise<{ text: string; isError: boolean }>;
15
+
16
+ export function samePath(a: unknown, b: unknown, platform?: string): boolean;
package/lib/sync.mjs ADDED
@@ -0,0 +1,229 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { codeNames, envFileValues, isLocalValue, isManaged, PUBLIC_PREFIX } from "./envrefs.mjs";
5
+
6
+ // keys_sync_env: compare the env vars a project needs with what its hosting platform has, and push the missing
7
+ // ones. Values go from the Keychain (or the project's env file) only to the platform's own API host, fixed here.
8
+ // Results and errors carry names, never values. Endpoints checked against the official docs on 2026-11-04:
9
+ // https://vercel.com/docs/rest-api/reference/endpoints/projects/retrieve-the-environment-variables-of-a-project-by-id-or-name
10
+ // https://vercel.com/docs/rest-api/reference/endpoints/projects/create-one-or-more-environment-variables
11
+ // https://vercel.com/docs/rest-api/reference/endpoints/projects/edit-an-environment-variable
12
+ // https://vercel.com/docs/cli/project-linking (.vercel/project.json holds orgId and projectId)
13
+ // https://docs.railway.com/integrations/api and https://docs.railway.com/integrations/api/manage-variables
14
+ // https://github.com/railwayapp/cli/blob/master/src/config.rs (~/.railway/config.json, projects keyed by path)
15
+
16
+ const VERCEL_API = "https://api.vercel.com";
17
+ const RAILWAY_API = "https://backboard.railway.com/graphql/v2";
18
+ const TIMEOUT_MS = 15000;
19
+ export const VERCEL_TARGETS = ["production", "preview", "development"];
20
+
21
+ async function readJson(file) {
22
+ try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; }
23
+ }
24
+ const timed = () => AbortSignal.timeout(TIMEOUT_MS);
25
+ const refused = (name, status) => (status === 401 || status === 403
26
+ ? `${name} refused the saved token (status ${status}). Run keys_check, or keys_request to replace it.`
27
+ : `${name} answered with status ${status}.`);
28
+
29
+ const vercel = {
30
+ name: "Vercel",
31
+ tokenVar: "VERCEL_TOKEN",
32
+ async link(projectPath) {
33
+ const p = await readJson(join(projectPath, ".vercel", "project.json"));
34
+ if (typeof p?.projectId !== "string") return { ok: false, error: "This folder is not linked to a Vercel project. Run vercel link in it first." };
35
+ // a team project needs teamId on every call; a personal one has a user id here instead
36
+ const teamId = typeof p.orgId === "string" && p.orgId.startsWith("team_") ? p.orgId : null;
37
+ return { ok: true, link: { projectId: p.projectId, teamId } };
38
+ },
39
+ url(link, path, extra = "") {
40
+ const q = new URLSearchParams(link.teamId ? { teamId: link.teamId } : {});
41
+ const qs = [q.toString(), extra].filter(Boolean).join("&");
42
+ return `${VERCEL_API}${path}${qs ? `?${qs}` : ""}`;
43
+ },
44
+ // name -> [{ id, targets }] for variables that apply to whole targets (branch-specific ones do not count)
45
+ async list(token, link, fetchFn) {
46
+ const res = await fetchFn(this.url(link, `/v10/projects/${encodeURIComponent(link.projectId)}/env`), { headers: { Authorization: `Bearer ${token}` }, signal: timed() });
47
+ if (!res.ok) return { ok: false, error: refused("Vercel", res.status) };
48
+ const body = await res.json().catch(() => null);
49
+ const envs = Array.isArray(body?.envs) ? body.envs : body && typeof body.key === "string" ? [body] : [];
50
+ const records = new Map();
51
+ for (const e of envs) {
52
+ if (typeof e?.key !== "string" || e.gitBranch) continue;
53
+ const targets = (Array.isArray(e.target) ? e.target : [e.target]).filter((t) => VERCEL_TARGETS.includes(t));
54
+ if (!records.has(e.key)) records.set(e.key, []);
55
+ records.get(e.key).push({ id: e.id, targets });
56
+ }
57
+ return { ok: true, records };
58
+ },
59
+ async push(token, link, plan, fetchFn) {
60
+ const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
61
+ const done = [];
62
+ const failed = [];
63
+ for (const { name, value, id } of plan.edits) {
64
+ const res = await fetchFn(this.url(link, `/v9/projects/${encodeURIComponent(link.projectId)}/env/${encodeURIComponent(id)}`), { method: "PATCH", headers, body: JSON.stringify({ value }), signal: timed() });
65
+ (res.ok ? done : failed).push(name);
66
+ }
67
+ if (plan.creates.length) {
68
+ const body = plan.creates.map(({ name, value, targets }) => ({ key: name, value, type: "encrypted", target: targets }));
69
+ const res = await fetchFn(this.url(link, `/v10/projects/${encodeURIComponent(link.projectId)}/env`), { method: "POST", headers, body: JSON.stringify(body), signal: timed() });
70
+ if (!res.ok) failed.push(...plan.creates.map((c) => c.name));
71
+ else {
72
+ // one bad item comes back in failed[] inside a 201
73
+ const r = await res.json().catch(() => null);
74
+ const bad = new Set((Array.isArray(r?.failed) ? r.failed : []).map((f) => f?.error?.key ?? f?.error?.envVarKey).filter(Boolean));
75
+ for (const c of plan.creates) (bad.has(c.name) ? failed : done).push(c.name);
76
+ }
77
+ }
78
+ return { done: [...new Set(done)].filter((n) => !failed.includes(n)), failed: [...new Set(failed)] };
79
+ },
80
+ };
81
+
82
+ async function railwayCall(token, query, variables, fetchFn) {
83
+ const res = await fetchFn(RAILWAY_API, {
84
+ method: "POST",
85
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
86
+ body: JSON.stringify({ query, variables }),
87
+ signal: timed(),
88
+ });
89
+ if (!res.ok) return { ok: false, error: refused("Railway", res.status) };
90
+ const body = await res.json().catch(() => null);
91
+ if (!body || Array.isArray(body.errors) && body.errors.length) {
92
+ const msg = body?.errors?.[0]?.message;
93
+ // Railway's own message names a field or a permission, never a value we sent
94
+ return { ok: false, error: `Railway refused the request${typeof msg === "string" ? `: ${msg.slice(0, 200)}` : "."}` };
95
+ }
96
+ return { ok: true, data: body.data };
97
+ }
98
+
99
+ // Windows paths ignore case and may use either slash ("c:\\app" and "C:/app" are one folder).
100
+ export function samePath(a, b, platform = process.platform) {
101
+ if (typeof a !== "string" || typeof b !== "string") return false;
102
+ if (platform !== "win32") return a === b;
103
+ const norm = (p) => p.replace(/\//g, "\\").replace(/\\+$/, "").toLowerCase();
104
+ return norm(a) === norm(b);
105
+ }
106
+
107
+ const railway = {
108
+ name: "Railway",
109
+ tokenVar: "RAILWAY_API_TOKEN",
110
+ async link(projectPath, home = homedir()) {
111
+ const config = await readJson(join(home, ".railway", "config.json"));
112
+ const projects = config?.projects && typeof config.projects === "object" ? config.projects : {};
113
+ let dir;
114
+ try { dir = await realpath(projectPath); } catch { dir = resolve(projectPath); }
115
+ // the CLI links the folder it ran in; a parent's link covers this folder too
116
+ for (let d = dir; ; d = dirname(d)) {
117
+ const hit = Object.entries(projects).find(([k, v]) => samePath(k, d) || samePath(v?.projectPath, d))?.[1];
118
+ if (hit) {
119
+ if (typeof hit.project !== "string" || typeof hit.environment !== "string") break;
120
+ if (typeof hit.service !== "string") return { ok: false, error: "This folder is linked to a Railway project but not to a service. Run railway service in it first." };
121
+ return { ok: true, link: { projectId: hit.project, environmentId: hit.environment, serviceId: hit.service } };
122
+ }
123
+ if (dirname(d) === d) break;
124
+ }
125
+ return { ok: false, error: "This folder is not linked to a Railway project. Run railway link in it first." };
126
+ },
127
+ async list(token, link, fetchFn) {
128
+ const r = await railwayCall(token,
129
+ "query vars($projectId: String!, $environmentId: String!, $serviceId: String) { variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId, unrendered: true) }",
130
+ link, fetchFn);
131
+ if (!r.ok) return r;
132
+ const names = r.data?.variables && typeof r.data.variables === "object" ? Object.keys(r.data.variables) : [];
133
+ return { ok: true, records: new Map(names.map((n) => [n, [{ id: null, targets: ["service"] }]])) };
134
+ },
135
+ async push(token, link, plan, fetchFn) {
136
+ const all = [...plan.edits, ...plan.creates];
137
+ const variables = Object.fromEntries(all.map(({ name, value }) => [name, value]));
138
+ // replace: false keeps every other variable; skipDeploys: the user redeploys when ready
139
+ const r = await railwayCall(token,
140
+ "mutation set($input: VariableCollectionUpsertInput!) { variableCollectionUpsert(input: $input) }",
141
+ { input: { ...link, variables, replace: false, skipDeploys: true } }, fetchFn);
142
+ const names = all.map((v) => v.name);
143
+ return r.ok ? { done: names, failed: [] } : { done: [], failed: names, error: r.error };
144
+ },
145
+ };
146
+
147
+ export const PLATFORMS = { vercel, railway };
148
+
149
+ /**
150
+ * Builds the report, and pushes when apply is true. Returns { text, isError }. Values never enter text.
151
+ * vault: the Keys vault. targets: Vercel targets (Railway has one service environment).
152
+ */
153
+ export async function syncEnv({ projectPath, platform, apply = false, overwrite = [], targets, vault, fetch: fetchFn = globalThis.fetch, home }) {
154
+ const p = PLATFORMS[platform];
155
+ const err = (text) => ({ text, isError: true });
156
+ if (!p) return err("Env sync supports vercel and railway.");
157
+ const wanted = platform === "vercel" ? (targets?.length ? targets : ["production", "preview"]) : ["service"];
158
+ if (platform === "vercel" && wanted.some((t) => !VERCEL_TARGETS.includes(t))) return err(`Vercel targets are ${VERCEL_TARGETS.join(", ")}.`);
159
+
160
+ const linked = await p.link(projectPath, home);
161
+ if (!linked.ok) return err(linked.error);
162
+
163
+ const entries = await vault.list();
164
+ const newest = (name) => entries.filter((e) => e.envVar === name).sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
165
+ const tokenEntry = newest(p.tokenVar);
166
+ const token = tokenEntry ? await vault.get(tokenEntry.provider, tokenEntry.label, p.tokenVar) : null;
167
+ if (!token) return err(`No ${p.name} token saved. Call keys_request with provider ${platform} first.`);
168
+
169
+ const listed = await p.list(token, linked.link, fetchFn);
170
+ if (!listed.ok) return err(listed.error);
171
+
172
+ const files = await envFileValues(projectPath);
173
+ const needed = [...new Set([...(await codeNames(projectPath)), ...[...files.keys()].filter((n) => !isManaged(n))])].sort();
174
+ const valueOf = async (name) => {
175
+ const e = newest(name);
176
+ const v = e ? await vault.get(e.provider, e.label, name) : null;
177
+ if (v !== null && v !== undefined) return { value: v, source: "Keychain" };
178
+ const f = files.get(name);
179
+ return f ? { value: f.value, source: f.file } : null;
180
+ };
181
+
182
+ const set = [];
183
+ const local = [];
184
+ const missing = [];
185
+ const creates = [];
186
+ const edits = [];
187
+ const notes = [];
188
+ for (const name of needed) {
189
+ const records = listed.records.get(name) ?? [];
190
+ const covered = new Set(records.flatMap((r) => r.targets));
191
+ const uncovered = platform === "vercel" ? wanted.filter((t) => !covered.has(t)) : covered.size ? [] : ["service"];
192
+ const replace = overwrite.includes(name) && records.length > 0;
193
+ if (!uncovered.length && !replace) { set.push(name); continue; }
194
+ const v = await valueOf(name);
195
+ if (!v) { missing.push(name); continue; }
196
+ if (v.source !== "Keychain" && isLocalValue(v.value)) { local.push(`${name} (${v.source})`); continue; }
197
+ const label = `${name} (from ${v.source}${PUBLIC_PREFIX.test(name) ? ", public in the browser" : ""})`;
198
+ if (replace) {
199
+ for (const r of records) if (platform === "railway" || r.targets.some((t) => wanted.includes(t))) edits.push({ name, value: v.value, id: r.id, label });
200
+ }
201
+ if (uncovered.length && !(platform === "railway" && replace)) creates.push({ name, value: v.value, targets: uncovered, label });
202
+ }
203
+ for (const name of overwrite) if (!needed.includes(name)) notes.push(`${name}: not used by this project, so not overwritten.`);
204
+
205
+ const where = platform === "vercel" ? `${p.name} (${wanted.join(", ")})` : p.name;
206
+ const lines = [`${where}:`];
207
+ const labels = [...new Map([...edits, ...creates].map((x) => [x.name, x.label])).values()];
208
+ if (set.length) lines.push(`Already set, not changed: ${set.join(", ")}`);
209
+ if (labels.length) lines.push(`${apply ? "Pushing" : "Can push"}: ${labels.join("; ")}`);
210
+ if (local.length) lines.push(`Not pushed, the value points at this computer: ${local.join(", ")}`);
211
+ if (missing.length) lines.push(`Missing everywhere: ${missing.join(", ")}. Get these with keys_request, or add them on ${p.name} yourself.`);
212
+ lines.push(...notes);
213
+ if (!labels.length) { lines.push("Nothing to push."); return { text: lines.join("\n"), isError: false }; }
214
+ if (!apply) {
215
+ lines.push(`Nothing was sent. Show this to the user, and with their yes call keys_sync_env again with apply: true to push ${labels.length}.`);
216
+ if (set.length) lines.push("Names already set are only replaced when listed in overwrite.");
217
+ return { text: lines.join("\n"), isError: false };
218
+ }
219
+
220
+ let pushed;
221
+ try {
222
+ pushed = await p.push(token, linked.link, { creates, edits }, fetchFn);
223
+ } catch {
224
+ return err(`Could not reach ${p.name}. Nothing was confirmed as pushed; run keys_sync_env again to see what is set.`);
225
+ }
226
+ if (pushed.done.length) lines.push(`Pushed: ${pushed.done.join(", ")}. Redeploy on ${p.name} for the new values to take effect.`);
227
+ if (pushed.failed.length) lines.push(`Failed: ${pushed.failed.join(", ")}.${pushed.error ? ` ${pushed.error}` : ""}`);
228
+ return { text: lines.join("\n"), isError: pushed.done.length === 0 };
229
+ }
package/package.json CHANGED
@@ -1 +1 @@
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" } }
1
+ { "name": "@getfootgun/keys", "version": "0.2.0-rc.3", "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" } }