@paradigma-inc/flywheel 0.1.0 → 0.1.4

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,5 +1,6 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { load as loadYaml, dump as dumpYaml } from "js-yaml";
3
4
 
4
5
  function stripJsonComments(text) {
5
6
  let result = "";
@@ -8,32 +9,40 @@ function stripJsonComments(text) {
8
9
  if (text[i] === '"') {
9
10
  const start = i++;
10
11
  while (i < text.length && text[i] !== '"') {
11
- if (text[i] === "\\") i++;
12
- i++;
12
+ if (text[i] === "\\") i += 1;
13
+ i += 1;
13
14
  }
14
15
  result += text.slice(start, ++i);
15
16
  } else if (text[i] === "/" && text[i + 1] === "/") {
16
17
  i += 2;
17
- while (i < text.length && text[i] !== "\n") i++;
18
+ while (i < text.length && text[i] !== "\n") i += 1;
18
19
  } else if (text[i] === "/" && text[i + 1] === "*") {
19
20
  i += 2;
20
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
21
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
22
+ i += 1;
21
23
  i += 2;
22
24
  } else {
23
- result += text[i++];
25
+ result += text[i];
26
+ i += 1;
24
27
  }
25
28
  }
26
29
  return result;
27
30
  }
28
31
 
32
+ function normalizeLineEndings(text) {
33
+ return text.replace(/\r\n/g, "\n");
34
+ }
35
+
29
36
  export async function readJsonConfig(filePath) {
37
+ let raw;
30
38
  try {
31
- const raw = (await readFile(filePath, "utf8")).trim();
32
- if (!raw) return {};
33
- return JSON.parse(stripJsonComments(raw));
39
+ raw = await readFile(filePath, "utf8");
34
40
  } catch {
35
41
  return {};
36
42
  }
43
+ const trimmed = raw.trim();
44
+ if (!trimmed) return {};
45
+ return JSON.parse(stripJsonComments(trimmed));
37
46
  }
38
47
 
39
48
  export async function writeJsonConfig(filePath, config) {
@@ -44,79 +53,190 @@ export async function writeJsonConfig(filePath, config) {
44
53
  });
45
54
  }
46
55
 
47
- function stableJson(value) {
48
- return JSON.stringify(value, Object.keys(value || {}).sort());
56
+ export async function readYamlConfig(filePath) {
57
+ let raw;
58
+ try {
59
+ raw = await readFile(filePath, "utf8");
60
+ } catch {
61
+ return {};
62
+ }
63
+ const trimmed = raw.trim();
64
+ if (!trimmed) return {};
65
+
66
+ try {
67
+ const parsed = loadYaml(trimmed);
68
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
69
+ return parsed;
70
+ }
71
+ return {};
72
+ } catch {
73
+ return {};
74
+ }
49
75
  }
50
76
 
51
- export function upsertJsonServerEntry({ config, configKey, serverName, entry }) {
52
- const section =
53
- config && typeof config[configKey] === "object" && config[configKey] !== null
54
- ? { ...config[configKey] }
77
+ export async function writeYamlConfig(filePath, config) {
78
+ await mkdir(path.dirname(filePath), { recursive: true });
79
+ await writeFile(filePath, dumpYaml(config, { noRefs: true }), {
80
+ encoding: "utf8",
81
+ mode: 0o600,
82
+ });
83
+ }
84
+
85
+ export async function resolveMcpPath(candidates) {
86
+ for (const candidate of candidates) {
87
+ try {
88
+ // eslint-disable-next-line no-await-in-loop
89
+ await access(candidate);
90
+ return candidate;
91
+ } catch {
92
+ // keep searching
93
+ }
94
+ }
95
+ return candidates[0];
96
+ }
97
+
98
+ function keyPath(configKey) {
99
+ return Array.isArray(configKey) ? configKey : [configKey];
100
+ }
101
+
102
+ function readConfigSection(config, configKey) {
103
+ const pathParts = keyPath(configKey);
104
+ let current = config && typeof config === "object" ? config : {};
105
+ for (const part of pathParts) {
106
+ if (
107
+ !current ||
108
+ typeof current !== "object" ||
109
+ Array.isArray(current) ||
110
+ !(part in current)
111
+ ) {
112
+ return {};
113
+ }
114
+ const next = current[part];
115
+ if (!next || typeof next !== "object" || Array.isArray(next)) {
116
+ return {};
117
+ }
118
+ current = next;
119
+ }
120
+ return current;
121
+ }
122
+
123
+ function writeConfigSection(config, configKey, section) {
124
+ const pathParts = keyPath(configKey);
125
+ if (pathParts.length === 0) return config || {};
126
+
127
+ const root =
128
+ config && typeof config === "object" && !Array.isArray(config)
129
+ ? { ...config }
55
130
  : {};
56
- const previous = section[serverName] ?? null;
57
- section[serverName] = entry;
58
- const nextConfig = {
59
- ...(config || {}),
60
- [configKey]: section,
61
- };
131
+ let cursor = root;
132
+
133
+ for (let index = 0; index < pathParts.length - 1; index += 1) {
134
+ const part = pathParts[index];
135
+ const existing = cursor[part];
136
+ cursor[part] =
137
+ existing && typeof existing === "object" && !Array.isArray(existing)
138
+ ? { ...existing }
139
+ : {};
140
+ cursor = cursor[part];
141
+ }
142
+ cursor[pathParts[pathParts.length - 1]] = section;
143
+ return root;
144
+ }
145
+
146
+ export function mergeServerEntry(existing, configKey, serverName, entry) {
147
+ const section = readConfigSection(existing, configKey);
148
+
149
+ if (serverName in section) {
150
+ return { config: existing, alreadyExists: true };
151
+ }
62
152
 
63
- const changed = stableJson(previous) !== stableJson(entry);
64
153
  return {
65
- config: nextConfig,
66
- changed,
67
- hadExisting: previous !== null,
154
+ config: writeConfigSection(existing, configKey, {
155
+ ...section,
156
+ [serverName]: entry,
157
+ }),
158
+ alreadyExists: false,
68
159
  };
69
160
  }
70
161
 
71
162
  export function removeJsonServerEntry({ config, configKey, serverName }) {
72
- const section =
73
- config && typeof config[configKey] === "object" && config[configKey] !== null
74
- ? { ...config[configKey] }
75
- : {};
163
+ const section = { ...readConfigSection(config, configKey) };
164
+
76
165
  if (!(serverName in section)) {
77
- return {
78
- config: config || {},
79
- changed: false,
80
- };
166
+ return { config: config || {}, changed: false };
81
167
  }
168
+
82
169
  delete section[serverName];
83
- const nextConfig = {
84
- ...(config || {}),
85
- [configKey]: section,
86
- };
87
170
  return {
88
- config: nextConfig,
171
+ config: writeConfigSection(config, configKey, section),
89
172
  changed: true,
90
173
  };
91
174
  }
92
175
 
93
- function normalizeLineEndings(text) {
94
- return text.replace(/\r\n/g, "\n");
176
+ export async function readTomlServerExists(filePath, serverName) {
177
+ try {
178
+ const raw = await readFile(filePath, "utf8");
179
+ return raw.includes(`[mcp_servers.${serverName}]`);
180
+ } catch {
181
+ return false;
182
+ }
95
183
  }
96
184
 
97
- export function buildCodexTomlServerBlock({ serverName, entry }) {
185
+ export function buildTomlServerBlock(serverName, entry) {
98
186
  const lines = [`[mcp_servers.${serverName}]`];
99
- lines.push(`type = ${JSON.stringify(entry.type)}`);
100
- lines.push(`url = ${JSON.stringify(entry.url)}`);
187
+ const headers =
188
+ entry && typeof entry.headers === "object" && entry.headers !== null
189
+ ? entry.headers
190
+ : null;
191
+
192
+ for (const [key, value] of Object.entries(entry || {})) {
193
+ if (key === "headers") continue;
194
+ lines.push(`${key} = ${JSON.stringify(value)}`);
195
+ }
101
196
 
102
- const headers = entry.headers && typeof entry.headers === "object" ? entry.headers : {};
103
- const headerEntries = Object.entries(headers);
104
- if (headerEntries.length > 0) {
197
+ if (headers && Object.keys(headers).length > 0) {
105
198
  lines.push("");
106
199
  lines.push(`[mcp_servers.${serverName}.http_headers]`);
107
- for (const [key, value] of headerEntries) {
108
- lines.push(`${key} = ${JSON.stringify(String(value))}`);
200
+ for (const [headerKey, headerValue] of Object.entries(headers)) {
201
+ lines.push(`${headerKey} = ${JSON.stringify(String(headerValue))}`);
109
202
  }
110
203
  }
111
204
 
112
205
  return `${lines.join("\n")}\n`;
113
206
  }
114
207
 
208
+ export async function appendTomlServer(filePath, serverName, entry) {
209
+ if (await readTomlServerExists(filePath, serverName)) {
210
+ return { alreadyExists: true };
211
+ }
212
+
213
+ const block = buildTomlServerBlock(serverName, entry);
214
+
215
+ let existing = "";
216
+ try {
217
+ existing = await readFile(filePath, "utf8");
218
+ } catch {
219
+ existing = "";
220
+ }
221
+
222
+ const separator =
223
+ existing.length > 0 && !existing.endsWith("\n")
224
+ ? "\n\n"
225
+ : existing.length > 0
226
+ ? "\n"
227
+ : "";
228
+ await mkdir(path.dirname(filePath), { recursive: true });
229
+ await writeFile(filePath, `${existing}${separator}${block}`, {
230
+ encoding: "utf8",
231
+ mode: 0o600,
232
+ });
233
+ return { alreadyExists: false };
234
+ }
235
+
115
236
  function stripCodexTomlServerBlock({ tomlText, serverName }) {
116
237
  const normalized = normalizeLineEndings(tomlText || "");
117
238
  const lines = normalized.split("\n");
118
239
  const output = [];
119
-
120
240
  let skipping = false;
121
241
  let removed = false;
122
242
 
@@ -146,37 +266,10 @@ function stripCodexTomlServerBlock({ tomlText, serverName }) {
146
266
 
147
267
  let stripped = output.join("\n");
148
268
  stripped = stripped.replace(/\n{3,}/g, "\n\n").trimEnd();
149
- if (stripped.length > 0) {
150
- stripped += "\n";
151
- }
269
+ if (stripped.length > 0) stripped += "\n";
152
270
  return { stripped, removed };
153
271
  }
154
272
 
155
- export async function upsertCodexTomlServer({ filePath, serverName, entry }) {
156
- let existing = "";
157
- try {
158
- existing = await readFile(filePath, "utf8");
159
- } catch {
160
- existing = "";
161
- }
162
-
163
- const { stripped, removed } = stripCodexTomlServerBlock({
164
- tomlText: existing,
165
- serverName,
166
- });
167
- const block = buildCodexTomlServerBlock({ serverName, entry });
168
- const separator = stripped.length > 0 ? "\n" : "";
169
- const next = `${stripped}${separator}${block}`;
170
-
171
- await mkdir(path.dirname(filePath), { recursive: true });
172
- await writeFile(filePath, next, { encoding: "utf8", mode: 0o600 });
173
-
174
- return {
175
- changed: normalizeLineEndings(existing) !== normalizeLineEndings(next),
176
- hadExisting: removed,
177
- };
178
- }
179
-
180
273
  export async function removeCodexTomlServer({ filePath, serverName }) {
181
274
  let existing = "";
182
275
  try {
@@ -189,9 +282,7 @@ export async function removeCodexTomlServer({ filePath, serverName }) {
189
282
  tomlText: existing,
190
283
  serverName,
191
284
  });
192
- if (!removed) {
193
- return { changed: false };
194
- }
285
+ if (!removed) return { changed: false };
195
286
 
196
287
  await mkdir(path.dirname(filePath), { recursive: true });
197
288
  await writeFile(filePath, stripped, { encoding: "utf8", mode: 0o600 });
@@ -32,10 +32,67 @@ function renderCallbackPage({ ok, message }) {
32
32
  </html>`;
33
33
  }
34
34
 
35
+ async function redeemSetupExchangeToken({
36
+ baseUrl,
37
+ exchangeToken,
38
+ state,
39
+ redirectUri,
40
+ }) {
41
+ const redeemUrl = new URL(
42
+ "/api/auth/mcp-api-keys/setup-exchange/redeem",
43
+ baseUrl,
44
+ );
45
+ const response = await fetch(redeemUrl, {
46
+ method: "POST",
47
+ headers: {
48
+ "content-type": "application/json",
49
+ "Idempotency-Key": `setup-redeem:${state}`,
50
+ },
51
+ body: JSON.stringify({
52
+ exchange_token: exchangeToken,
53
+ state,
54
+ redirect_uri: redirectUri,
55
+ }),
56
+ });
57
+ let payload = {};
58
+ try {
59
+ payload = await response.json();
60
+ } catch {
61
+ payload = {};
62
+ }
63
+ if (!response.ok) {
64
+ const detail = resolveRedeemErrorMessage(payload);
65
+ throw new Error(detail);
66
+ }
67
+ const key = (payload?.key || "").trim();
68
+ if (!key) {
69
+ throw new Error("Setup exchange response missing API key.");
70
+ }
71
+ return { apiKey: key };
72
+ }
73
+
74
+ function resolveRedeemErrorMessage(payload) {
75
+ if (!payload || typeof payload !== "object") {
76
+ return "Setup exchange redemption failed.";
77
+ }
78
+ const detail = payload.detail;
79
+ if (typeof detail === "string" && detail.trim()) {
80
+ return detail.trim();
81
+ }
82
+ if (detail && typeof detail === "object") {
83
+ const message = detail.message;
84
+ if (typeof message === "string" && message.trim()) {
85
+ return message.trim();
86
+ }
87
+ }
88
+ return "Setup exchange redemption failed.";
89
+ }
90
+
35
91
  export async function acquireApiKeyViaBrowserBridge({
36
92
  baseUrl,
37
93
  keyName,
38
94
  timeoutMs = DEFAULT_AUTH_TIMEOUT_MS,
95
+ openUrl = openUrlInBrowser,
39
96
  }) {
40
97
  const state = crypto.randomUUID();
41
98
 
@@ -43,6 +100,7 @@ export async function acquireApiKeyViaBrowserBridge({
43
100
  let settled = false;
44
101
  let timeout = null;
45
102
  let server = null;
103
+ let callbackUrl = "";
46
104
 
47
105
  const cleanup = () => {
48
106
  if (timeout) {
@@ -51,6 +109,8 @@ export async function acquireApiKeyViaBrowserBridge({
51
109
  }
52
110
  if (server) {
53
111
  server.close();
112
+ server.closeAllConnections?.();
113
+ server.closeIdleConnections?.();
54
114
  server = null;
55
115
  }
56
116
  };
@@ -69,7 +129,7 @@ export async function acquireApiKeyViaBrowserBridge({
69
129
  resolve(result);
70
130
  };
71
131
 
72
- server = http.createServer((req, res) => {
132
+ server = http.createServer(async (req, res) => {
73
133
  const reqUrl = new URL(req.url || "/", "http://127.0.0.1");
74
134
  if (reqUrl.pathname !== "/callback") {
75
135
  res.writeHead(404, { "Content-Type": "text/plain" });
@@ -78,7 +138,9 @@ export async function acquireApiKeyViaBrowserBridge({
78
138
  }
79
139
 
80
140
  const callbackState = (reqUrl.searchParams.get("state") || "").trim();
81
- const key = (reqUrl.searchParams.get("key") || "").trim();
141
+ const exchangeToken = (
142
+ reqUrl.searchParams.get("exchange_token") || ""
143
+ ).trim();
82
144
  const error = (reqUrl.searchParams.get("error") || "").trim();
83
145
 
84
146
  if (callbackState !== state) {
@@ -100,15 +162,32 @@ export async function acquireApiKeyViaBrowserBridge({
100
162
  return;
101
163
  }
102
164
 
103
- if (!key) {
165
+ if (!exchangeToken) {
104
166
  res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
105
167
  res.end(
106
168
  renderCallbackPage({
107
169
  ok: false,
108
- message: "No API key was returned.",
170
+ message: "No setup exchange token was returned.",
109
171
  }),
110
172
  );
111
- fail(new Error("Setup callback missing API key."));
173
+ fail(new Error("Setup callback missing exchange token."));
174
+ return;
175
+ }
176
+
177
+ let redeemed;
178
+ try {
179
+ redeemed = await redeemSetupExchangeToken({
180
+ baseUrl,
181
+ exchangeToken,
182
+ state,
183
+ redirectUri: callbackUrl,
184
+ });
185
+ } catch (err) {
186
+ const message =
187
+ err instanceof Error ? err.message : "Failed to redeem setup exchange.";
188
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
189
+ res.end(renderCallbackPage({ ok: false, message }));
190
+ fail(new Error(message));
112
191
  return;
113
192
  }
114
193
 
@@ -119,7 +198,7 @@ export async function acquireApiKeyViaBrowserBridge({
119
198
  message: "Flywheel MCP was authorized successfully.",
120
199
  }),
121
200
  );
122
- succeed({ apiKey: key });
201
+ succeed({ apiKey: redeemed.apiKey });
123
202
  });
124
203
 
125
204
  server.once("error", (error) => {
@@ -133,7 +212,7 @@ export async function acquireApiKeyViaBrowserBridge({
133
212
  return;
134
213
  }
135
214
 
136
- const callbackUrl = `http://127.0.0.1:${address.port}/callback`;
215
+ callbackUrl = `http://127.0.0.1:${address.port}/callback`;
137
216
  const setupUrl = new URL("/auth/mcp/setup", baseUrl);
138
217
  setupUrl.searchParams.set("state", state);
139
218
  setupUrl.searchParams.set("redirect_uri", callbackUrl);
@@ -143,8 +222,10 @@ export async function acquireApiKeyViaBrowserBridge({
143
222
  console.log(`If it does not open, use this URL:\n${setupUrl.toString()}\n`);
144
223
 
145
224
  try {
146
- const child = openUrlInBrowser(setupUrl.toString());
147
- child.unref();
225
+ const child = openUrl(setupUrl.toString());
226
+ if (child && typeof child.unref === "function") {
227
+ child.unref();
228
+ }
148
229
  } catch {
149
230
  // User can still open URL manually.
150
231
  }
@@ -1,117 +0,0 @@
1
- import { mkdtemp, readFile } from "node:fs/promises";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import assert from "node:assert/strict";
5
- import test from "node:test";
6
-
7
- import {
8
- removeCodexTomlServer,
9
- removeJsonServerEntry,
10
- upsertCodexTomlServer,
11
- upsertJsonServerEntry,
12
- writeJsonConfig,
13
- readJsonConfig,
14
- } from "../src/mcp-writer.mjs";
15
-
16
- test("upsertJsonServerEntry installs and removes server entries", async () => {
17
- const initial = {
18
- mcpServers: {
19
- other: {
20
- url: "http://localhost/other",
21
- },
22
- },
23
- };
24
-
25
- const inserted = upsertJsonServerEntry({
26
- config: initial,
27
- configKey: "mcpServers",
28
- serverName: "flywheel",
29
- entry: {
30
- type: "http",
31
- url: "https://flywheel.paradigma.inc/mcp-server",
32
- },
33
- });
34
-
35
- assert.equal(inserted.changed, true);
36
- assert.equal(inserted.hadExisting, false);
37
- assert.equal(inserted.config.mcpServers.flywheel.type, "http");
38
-
39
- const removed = removeJsonServerEntry({
40
- config: inserted.config,
41
- configKey: "mcpServers",
42
- serverName: "flywheel",
43
- });
44
- assert.equal(removed.changed, true);
45
- assert.equal("flywheel" in removed.config.mcpServers, false);
46
- assert.equal("other" in removed.config.mcpServers, true);
47
- });
48
-
49
- test("codex toml writer replaces flywheel block idempotently", async () => {
50
- const tempDir = await mkdtemp(path.join(os.tmpdir(), "flywheel-mcp-writer-"));
51
- const filePath = path.join(tempDir, "config.toml");
52
-
53
- const first = await upsertCodexTomlServer({
54
- filePath,
55
- serverName: "flywheel",
56
- entry: {
57
- type: "http",
58
- url: "https://flywheel.paradigma.inc/mcp-server",
59
- headers: {
60
- Authorization: "Bearer one",
61
- },
62
- },
63
- });
64
- assert.equal(first.changed, true);
65
-
66
- const second = await upsertCodexTomlServer({
67
- filePath,
68
- serverName: "flywheel",
69
- entry: {
70
- type: "http",
71
- url: "https://flywheel.paradigma.inc/mcp-server",
72
- headers: {
73
- Authorization: "Bearer two",
74
- },
75
- },
76
- });
77
- assert.equal(second.changed, true);
78
- assert.equal(second.hadExisting, true);
79
-
80
- const text = await readFile(filePath, "utf8");
81
- assert.equal(
82
- text.includes("Authorization = \"Bearer two\""),
83
- true,
84
- "updated token should be present",
85
- );
86
- assert.equal(
87
- text.includes("Authorization = \"Bearer one\""),
88
- false,
89
- "old token should not survive repeated setup",
90
- );
91
-
92
- const removed = await removeCodexTomlServer({
93
- filePath,
94
- serverName: "flywheel",
95
- });
96
- assert.equal(removed.changed, true);
97
- const afterRemove = await readFile(filePath, "utf8");
98
- assert.equal(afterRemove.includes("[mcp_servers.flywheel]"), false);
99
- });
100
-
101
- test("readJsonConfig/writeJsonConfig round-trip", async () => {
102
- const tempDir = await mkdtemp(path.join(os.tmpdir(), "flywheel-mcp-json-"));
103
- const filePath = path.join(tempDir, "mcp.json");
104
-
105
- const config = {
106
- mcpServers: {
107
- flywheel: {
108
- type: "http",
109
- url: "https://flywheel.paradigma.inc/mcp-server",
110
- },
111
- },
112
- };
113
-
114
- await writeJsonConfig(filePath, config);
115
- const loaded = await readJsonConfig(filePath);
116
- assert.deepEqual(loaded, config);
117
- });