@paradigma-inc/flywheel 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
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
3
 
4
4
  function stripJsonComments(text) {
@@ -8,32 +8,40 @@ function stripJsonComments(text) {
8
8
  if (text[i] === '"') {
9
9
  const start = i++;
10
10
  while (i < text.length && text[i] !== '"') {
11
- if (text[i] === "\\") i++;
12
- i++;
11
+ if (text[i] === "\\") i += 1;
12
+ i += 1;
13
13
  }
14
14
  result += text.slice(start, ++i);
15
15
  } else if (text[i] === "/" && text[i + 1] === "/") {
16
16
  i += 2;
17
- while (i < text.length && text[i] !== "\n") i++;
17
+ while (i < text.length && text[i] !== "\n") i += 1;
18
18
  } else if (text[i] === "/" && text[i + 1] === "*") {
19
19
  i += 2;
20
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
20
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
21
+ i += 1;
21
22
  i += 2;
22
23
  } else {
23
- result += text[i++];
24
+ result += text[i];
25
+ i += 1;
24
26
  }
25
27
  }
26
28
  return result;
27
29
  }
28
30
 
31
+ function normalizeLineEndings(text) {
32
+ return text.replace(/\r\n/g, "\n");
33
+ }
34
+
29
35
  export async function readJsonConfig(filePath) {
36
+ let raw;
30
37
  try {
31
- const raw = (await readFile(filePath, "utf8")).trim();
32
- if (!raw) return {};
33
- return JSON.parse(stripJsonComments(raw));
38
+ raw = await readFile(filePath, "utf8");
34
39
  } catch {
35
40
  return {};
36
41
  }
42
+ const trimmed = raw.trim();
43
+ if (!trimmed) return {};
44
+ return JSON.parse(stripJsonComments(trimmed));
37
45
  }
38
46
 
39
47
  export async function writeJsonConfig(filePath, config) {
@@ -44,79 +52,129 @@ export async function writeJsonConfig(filePath, config) {
44
52
  });
45
53
  }
46
54
 
47
- function stableJson(value) {
48
- return JSON.stringify(value, Object.keys(value || {}).sort());
55
+ export async function resolveMcpPath(candidates) {
56
+ for (const candidate of candidates) {
57
+ try {
58
+ // eslint-disable-next-line no-await-in-loop
59
+ await access(candidate);
60
+ return candidate;
61
+ } catch {
62
+ // keep searching
63
+ }
64
+ }
65
+ return candidates[0];
49
66
  }
50
67
 
51
- export function upsertJsonServerEntry({ config, configKey, serverName, entry }) {
68
+ export function mergeServerEntry(existing, configKey, serverName, entry) {
52
69
  const section =
53
- config && typeof config[configKey] === "object" && config[configKey] !== null
54
- ? { ...config[configKey] }
70
+ existing &&
71
+ typeof existing[configKey] === "object" &&
72
+ existing[configKey] !== null
73
+ ? existing[configKey]
55
74
  : {};
56
- const previous = section[serverName] ?? null;
57
- section[serverName] = entry;
58
- const nextConfig = {
59
- ...(config || {}),
60
- [configKey]: section,
61
- };
62
75
 
63
- const changed = stableJson(previous) !== stableJson(entry);
76
+ if (serverName in section) {
77
+ return { config: existing, alreadyExists: true };
78
+ }
79
+
64
80
  return {
65
- config: nextConfig,
66
- changed,
67
- hadExisting: previous !== null,
81
+ config: {
82
+ ...(existing || {}),
83
+ [configKey]: {
84
+ ...section,
85
+ [serverName]: entry,
86
+ },
87
+ },
88
+ alreadyExists: false,
68
89
  };
69
90
  }
70
91
 
71
92
  export function removeJsonServerEntry({ config, configKey, serverName }) {
72
93
  const section =
73
- config && typeof config[configKey] === "object" && config[configKey] !== null
94
+ config &&
95
+ typeof config[configKey] === "object" &&
96
+ config[configKey] !== null
74
97
  ? { ...config[configKey] }
75
98
  : {};
99
+
76
100
  if (!(serverName in section)) {
77
- return {
78
- config: config || {},
79
- changed: false,
80
- };
101
+ return { config: config || {}, changed: false };
81
102
  }
103
+
82
104
  delete section[serverName];
83
- const nextConfig = {
84
- ...(config || {}),
85
- [configKey]: section,
86
- };
87
105
  return {
88
- config: nextConfig,
106
+ config: {
107
+ ...(config || {}),
108
+ [configKey]: section,
109
+ },
89
110
  changed: true,
90
111
  };
91
112
  }
92
113
 
93
- function normalizeLineEndings(text) {
94
- return text.replace(/\r\n/g, "\n");
114
+ export async function readTomlServerExists(filePath, serverName) {
115
+ try {
116
+ const raw = await readFile(filePath, "utf8");
117
+ return raw.includes(`[mcp_servers.${serverName}]`);
118
+ } catch {
119
+ return false;
120
+ }
95
121
  }
96
122
 
97
- export function buildCodexTomlServerBlock({ serverName, entry }) {
123
+ export function buildTomlServerBlock(serverName, entry) {
98
124
  const lines = [`[mcp_servers.${serverName}]`];
99
- lines.push(`type = ${JSON.stringify(entry.type)}`);
100
- lines.push(`url = ${JSON.stringify(entry.url)}`);
125
+ const headers =
126
+ entry && typeof entry.headers === "object" && entry.headers !== null
127
+ ? entry.headers
128
+ : null;
129
+
130
+ for (const [key, value] of Object.entries(entry || {})) {
131
+ if (key === "headers") continue;
132
+ lines.push(`${key} = ${JSON.stringify(value)}`);
133
+ }
101
134
 
102
- const headers = entry.headers && typeof entry.headers === "object" ? entry.headers : {};
103
- const headerEntries = Object.entries(headers);
104
- if (headerEntries.length > 0) {
135
+ if (headers && Object.keys(headers).length > 0) {
105
136
  lines.push("");
106
137
  lines.push(`[mcp_servers.${serverName}.http_headers]`);
107
- for (const [key, value] of headerEntries) {
108
- lines.push(`${key} = ${JSON.stringify(String(value))}`);
138
+ for (const [headerKey, headerValue] of Object.entries(headers)) {
139
+ lines.push(`${headerKey} = ${JSON.stringify(String(headerValue))}`);
109
140
  }
110
141
  }
111
142
 
112
143
  return `${lines.join("\n")}\n`;
113
144
  }
114
145
 
146
+ export async function appendTomlServer(filePath, serverName, entry) {
147
+ if (await readTomlServerExists(filePath, serverName)) {
148
+ return { alreadyExists: true };
149
+ }
150
+
151
+ const block = buildTomlServerBlock(serverName, entry);
152
+
153
+ let existing = "";
154
+ try {
155
+ existing = await readFile(filePath, "utf8");
156
+ } catch {
157
+ existing = "";
158
+ }
159
+
160
+ const separator =
161
+ existing.length > 0 && !existing.endsWith("\n")
162
+ ? "\n\n"
163
+ : existing.length > 0
164
+ ? "\n"
165
+ : "";
166
+ await mkdir(path.dirname(filePath), { recursive: true });
167
+ await writeFile(filePath, `${existing}${separator}${block}`, {
168
+ encoding: "utf8",
169
+ mode: 0o600,
170
+ });
171
+ return { alreadyExists: false };
172
+ }
173
+
115
174
  function stripCodexTomlServerBlock({ tomlText, serverName }) {
116
175
  const normalized = normalizeLineEndings(tomlText || "");
117
176
  const lines = normalized.split("\n");
118
177
  const output = [];
119
-
120
178
  let skipping = false;
121
179
  let removed = false;
122
180
 
@@ -146,37 +204,10 @@ function stripCodexTomlServerBlock({ tomlText, serverName }) {
146
204
 
147
205
  let stripped = output.join("\n");
148
206
  stripped = stripped.replace(/\n{3,}/g, "\n\n").trimEnd();
149
- if (stripped.length > 0) {
150
- stripped += "\n";
151
- }
207
+ if (stripped.length > 0) stripped += "\n";
152
208
  return { stripped, removed };
153
209
  }
154
210
 
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
211
  export async function removeCodexTomlServer({ filePath, serverName }) {
181
212
  let existing = "";
182
213
  try {
@@ -189,9 +220,7 @@ export async function removeCodexTomlServer({ filePath, serverName }) {
189
220
  tomlText: existing,
190
221
  serverName,
191
222
  });
192
- if (!removed) {
193
- return { changed: false };
194
- }
223
+ if (!removed) return { changed: false };
195
224
 
196
225
  await mkdir(path.dirname(filePath), { recursive: true });
197
226
  await writeFile(filePath, stripped, { encoding: "utf8", mode: 0o600 });
@@ -36,6 +36,7 @@ export async function acquireApiKeyViaBrowserBridge({
36
36
  baseUrl,
37
37
  keyName,
38
38
  timeoutMs = DEFAULT_AUTH_TIMEOUT_MS,
39
+ openUrl = openUrlInBrowser,
39
40
  }) {
40
41
  const state = crypto.randomUUID();
41
42
 
@@ -51,6 +52,8 @@ export async function acquireApiKeyViaBrowserBridge({
51
52
  }
52
53
  if (server) {
53
54
  server.close();
55
+ server.closeAllConnections?.();
56
+ server.closeIdleConnections?.();
54
57
  server = null;
55
58
  }
56
59
  };
@@ -143,8 +146,10 @@ export async function acquireApiKeyViaBrowserBridge({
143
146
  console.log(`If it does not open, use this URL:\n${setupUrl.toString()}\n`);
144
147
 
145
148
  try {
146
- const child = openUrlInBrowser(setupUrl.toString());
147
- child.unref();
149
+ const child = openUrl(setupUrl.toString());
150
+ if (child && typeof child.unref === "function") {
151
+ child.unref();
152
+ }
148
153
  } catch {
149
154
  // User can still open URL manually.
150
155
  }
@@ -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
- });