@paradigma-inc/flywheel 0.1.0
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 +58 -0
- package/bin/flywheel.js +9 -0
- package/package.json +16 -0
- package/src/agents.mjs +97 -0
- package/src/cli.mjs +422 -0
- package/src/mcp-writer.mjs +199 -0
- package/src/setup-auth.mjs +157 -0
- package/tests/mcp-writer.test.mjs +117 -0
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# flywheel setup CLI
|
|
2
|
+
|
|
3
|
+
This package provides a Context7-style setup workflow for Flywheel MCP.
|
|
4
|
+
|
|
5
|
+
Design intentionally mirrors Context7's setup implementation patterns from:
|
|
6
|
+
|
|
7
|
+
- `packages/cli/src/commands/setup.ts`
|
|
8
|
+
- `packages/cli/src/setup/agents.ts`
|
|
9
|
+
- `packages/cli/src/setup/mcp-writer.ts`
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @paradigma-inc/flywheel setup
|
|
15
|
+
npx @paradigma-inc/flywheel uninstall
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Production vs staging
|
|
19
|
+
|
|
20
|
+
- `npx @paradigma-inc/flywheel setup` defaults to production
|
|
21
|
+
(`https://flywheel.paradigma.inc`).
|
|
22
|
+
- To target staging explicitly, pass a staging base URL:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx @paradigma-inc/flywheel setup --base-url https://flywheel-staging.paradigma.inc
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- Staging remains protected by the existing staging password gate in the WebUI.
|
|
29
|
+
The browser auth step must pass that gate before key creation can complete.
|
|
30
|
+
- Setup writes one Flywheel server entry per host+scope. Re-running setup for a
|
|
31
|
+
different base URL in the same host+scope updates that same entry.
|
|
32
|
+
|
|
33
|
+
## Commands
|
|
34
|
+
|
|
35
|
+
- `setup`: interview + browser auth bridge + idempotent host config writes.
|
|
36
|
+
- `uninstall`: remove Flywheel MCP entries from selected host configs.
|
|
37
|
+
|
|
38
|
+
## Release channels
|
|
39
|
+
|
|
40
|
+
- Public production installs use npm (`latest`):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx @paradigma-inc/flywheel setup
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- Internal staging builds are published to GitHub Packages (`staging` tag) and
|
|
47
|
+
require npm auth for `npm.pkg.github.com`.
|
|
48
|
+
- Example internal install:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx --registry=https://npm.pkg.github.com @paradigma-inc/flywheel@staging setup
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Supported hosts
|
|
55
|
+
|
|
56
|
+
- Codex (`~/.codex/config.toml` or `.codex/config.toml`)
|
|
57
|
+
- Claude Code (`~/.claude.json` or `.mcp.json`)
|
|
58
|
+
- OpenCode (`~/.config/opencode/opencode.json` or `opencode.json`)
|
package/bin/flywheel.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@paradigma-inc/flywheel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One-command setup for Flywheel MCP on Codex, Claude Code, and OpenCode",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"flywheel": "bin/flywheel.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test tests/*.test.mjs"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"license": "UNLICENSED"
|
|
16
|
+
}
|
package/src/agents.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const SERVER_NAME = "flywheel";
|
|
5
|
+
export const HOSTS = ["codex", "claude", "opencode"];
|
|
6
|
+
|
|
7
|
+
export const HOST_LABELS = {
|
|
8
|
+
codex: "Codex",
|
|
9
|
+
claude: "Claude Code",
|
|
10
|
+
opencode: "OpenCode",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const HOME = os.homedir();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Patterns mirrored from Context7's `setup/agents.ts`.
|
|
17
|
+
*/
|
|
18
|
+
export const AGENT_CONFIG = {
|
|
19
|
+
codex: {
|
|
20
|
+
configType: "toml",
|
|
21
|
+
projectPath: path.join(".codex", "config.toml"),
|
|
22
|
+
globalPath: path.join(HOME, ".codex", "config.toml"),
|
|
23
|
+
buildEntry: ({ serverUrl, apiKey }) => ({
|
|
24
|
+
type: "http",
|
|
25
|
+
url: serverUrl,
|
|
26
|
+
headers: {
|
|
27
|
+
Authorization: `Bearer ${apiKey}`,
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
claude: {
|
|
32
|
+
configType: "json",
|
|
33
|
+
projectPath: ".mcp.json",
|
|
34
|
+
globalPath: path.join(HOME, ".claude.json"),
|
|
35
|
+
configKey: "mcpServers",
|
|
36
|
+
buildEntry: ({ serverUrl, apiKey }) => ({
|
|
37
|
+
type: "http",
|
|
38
|
+
url: serverUrl,
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: `Bearer ${apiKey}`,
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
opencode: {
|
|
45
|
+
configType: "json",
|
|
46
|
+
projectPath: "opencode.json",
|
|
47
|
+
globalPath: path.join(HOME, ".config", "opencode", "opencode.json"),
|
|
48
|
+
configKey: "mcp",
|
|
49
|
+
buildEntry: ({ serverUrl, apiKey }) => ({
|
|
50
|
+
type: "remote",
|
|
51
|
+
url: serverUrl,
|
|
52
|
+
enabled: true,
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${apiKey}`,
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function normalizeScope(scope) {
|
|
61
|
+
if (scope === "global" || scope === "project") return scope;
|
|
62
|
+
return "global";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveHostConfigPath(host, scope) {
|
|
66
|
+
const hostConfig = AGENT_CONFIG[host];
|
|
67
|
+
if (!hostConfig) {
|
|
68
|
+
throw new Error(`Unsupported host: ${host}`);
|
|
69
|
+
}
|
|
70
|
+
if (scope === "project") {
|
|
71
|
+
return path.join(process.cwd(), hostConfig.projectPath);
|
|
72
|
+
}
|
|
73
|
+
return hostConfig.globalPath;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function normalizeHosts(rawHosts) {
|
|
77
|
+
if (!Array.isArray(rawHosts) || rawHosts.length === 0) {
|
|
78
|
+
return [...HOSTS];
|
|
79
|
+
}
|
|
80
|
+
const normalized = [];
|
|
81
|
+
for (const host of rawHosts) {
|
|
82
|
+
const candidate = String(host || "").trim().toLowerCase();
|
|
83
|
+
if (!candidate) continue;
|
|
84
|
+
if (!HOSTS.includes(candidate)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Unsupported host '${candidate}'. Supported: ${HOSTS.join(", ")}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (!normalized.includes(candidate)) {
|
|
90
|
+
normalized.push(candidate);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (normalized.length === 0) {
|
|
94
|
+
return [...HOSTS];
|
|
95
|
+
}
|
|
96
|
+
return normalized;
|
|
97
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline/promises";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
AGENT_CONFIG,
|
|
9
|
+
HOST_LABELS,
|
|
10
|
+
HOSTS,
|
|
11
|
+
SERVER_NAME,
|
|
12
|
+
normalizeHosts,
|
|
13
|
+
normalizeScope,
|
|
14
|
+
resolveHostConfigPath,
|
|
15
|
+
} from "./agents.mjs";
|
|
16
|
+
import {
|
|
17
|
+
readJsonConfig,
|
|
18
|
+
removeCodexTomlServer,
|
|
19
|
+
removeJsonServerEntry,
|
|
20
|
+
upsertCodexTomlServer,
|
|
21
|
+
upsertJsonServerEntry,
|
|
22
|
+
writeJsonConfig,
|
|
23
|
+
} from "./mcp-writer.mjs";
|
|
24
|
+
import { acquireApiKeyViaBrowserBridge } from "./setup-auth.mjs";
|
|
25
|
+
|
|
26
|
+
// This setup flow intentionally follows Context7's structure:
|
|
27
|
+
// - packages/cli/src/commands/setup.ts
|
|
28
|
+
// - packages/cli/src/setup/agents.ts
|
|
29
|
+
// - packages/cli/src/setup/mcp-writer.ts
|
|
30
|
+
const DEFAULT_BASE_URL =
|
|
31
|
+
process.env.FLYWHEEL_PUBLIC_BASE_URL || "https://flywheel.paradigma.inc";
|
|
32
|
+
const DEFAULT_SCOPE = "global";
|
|
33
|
+
const SHARED_KEY_PATH = path.join(
|
|
34
|
+
os.homedir(),
|
|
35
|
+
".config",
|
|
36
|
+
"flywheel",
|
|
37
|
+
"mcp-api-key",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
function printHelp() {
|
|
41
|
+
console.log(`flywheel
|
|
42
|
+
|
|
43
|
+
Commands:
|
|
44
|
+
setup Configure Flywheel MCP for codex/claude/opencode
|
|
45
|
+
uninstall Remove Flywheel MCP entries from host configs
|
|
46
|
+
|
|
47
|
+
Options:
|
|
48
|
+
--hosts <list> Comma-separated: codex,claude,opencode
|
|
49
|
+
--scope <scope> global | project | all (uninstall only)
|
|
50
|
+
--base-url <url> Flywheel base URL (default: ${DEFAULT_BASE_URL})
|
|
51
|
+
--server-url <url> MCP server URL (default: <base-url>/mcp-server)
|
|
52
|
+
--api-key <key> Use API key directly (skip browser auth)
|
|
53
|
+
--name <name> API key name to create during browser setup
|
|
54
|
+
--yes Non-interactive defaults
|
|
55
|
+
--delete-key Uninstall: also remove ~/.config/flywheel/mcp-api-key
|
|
56
|
+
-h, --help Show this help
|
|
57
|
+
`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseArgs(argv) {
|
|
61
|
+
const args = argv.slice(2);
|
|
62
|
+
const command = args[0];
|
|
63
|
+
const options = {
|
|
64
|
+
hosts: null,
|
|
65
|
+
scope: null,
|
|
66
|
+
baseUrl: null,
|
|
67
|
+
serverUrl: null,
|
|
68
|
+
apiKey: null,
|
|
69
|
+
name: null,
|
|
70
|
+
yes: false,
|
|
71
|
+
deleteKey: false,
|
|
72
|
+
help: false,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
for (let i = 1; i < args.length; i += 1) {
|
|
76
|
+
const arg = args[i];
|
|
77
|
+
const next = args[i + 1];
|
|
78
|
+
switch (arg) {
|
|
79
|
+
case "--hosts":
|
|
80
|
+
options.hosts = next || "";
|
|
81
|
+
i += 1;
|
|
82
|
+
break;
|
|
83
|
+
case "--scope":
|
|
84
|
+
options.scope = next || "";
|
|
85
|
+
i += 1;
|
|
86
|
+
break;
|
|
87
|
+
case "--base-url":
|
|
88
|
+
options.baseUrl = next || "";
|
|
89
|
+
i += 1;
|
|
90
|
+
break;
|
|
91
|
+
case "--server-url":
|
|
92
|
+
options.serverUrl = next || "";
|
|
93
|
+
i += 1;
|
|
94
|
+
break;
|
|
95
|
+
case "--api-key":
|
|
96
|
+
options.apiKey = next || "";
|
|
97
|
+
i += 1;
|
|
98
|
+
break;
|
|
99
|
+
case "--name":
|
|
100
|
+
options.name = next || "";
|
|
101
|
+
i += 1;
|
|
102
|
+
break;
|
|
103
|
+
case "--yes":
|
|
104
|
+
case "-y":
|
|
105
|
+
options.yes = true;
|
|
106
|
+
break;
|
|
107
|
+
case "--delete-key":
|
|
108
|
+
options.deleteKey = true;
|
|
109
|
+
break;
|
|
110
|
+
case "--help":
|
|
111
|
+
case "-h":
|
|
112
|
+
options.help = true;
|
|
113
|
+
break;
|
|
114
|
+
default:
|
|
115
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { command, options };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseHostsValue(value) {
|
|
123
|
+
if (!value) return [...HOSTS];
|
|
124
|
+
const pieces = String(value)
|
|
125
|
+
.split(",")
|
|
126
|
+
.map((v) => v.trim())
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
return normalizeHosts(pieces);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function promptLine(rl, message, defaultValue = "") {
|
|
132
|
+
const suffix = defaultValue ? ` (${defaultValue})` : "";
|
|
133
|
+
const answer = await rl.question(`${message}${suffix}: `);
|
|
134
|
+
const trimmed = answer.trim();
|
|
135
|
+
return trimmed || defaultValue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function resolveSetupInputs(options) {
|
|
139
|
+
let scope = normalizeScope(options.scope || DEFAULT_SCOPE);
|
|
140
|
+
let hosts = parseHostsValue(options.hosts);
|
|
141
|
+
let baseUrl = (options.baseUrl || DEFAULT_BASE_URL).trim().replace(/\/$/, "");
|
|
142
|
+
|
|
143
|
+
if (!options.yes) {
|
|
144
|
+
const rl = readline.createInterface({
|
|
145
|
+
input: process.stdin,
|
|
146
|
+
output: process.stdout,
|
|
147
|
+
});
|
|
148
|
+
try {
|
|
149
|
+
const promptedScope = await promptLine(
|
|
150
|
+
rl,
|
|
151
|
+
"Scope [global/project]",
|
|
152
|
+
scope,
|
|
153
|
+
);
|
|
154
|
+
scope = normalizeScope(promptedScope);
|
|
155
|
+
|
|
156
|
+
const promptedHosts = await promptLine(
|
|
157
|
+
rl,
|
|
158
|
+
"Hosts (comma-separated codex,claude,opencode)",
|
|
159
|
+
hosts.join(","),
|
|
160
|
+
);
|
|
161
|
+
hosts = parseHostsValue(promptedHosts);
|
|
162
|
+
|
|
163
|
+
baseUrl = (await promptLine(rl, "Flywheel base URL", baseUrl)).replace(
|
|
164
|
+
/\/$/,
|
|
165
|
+
"",
|
|
166
|
+
);
|
|
167
|
+
} finally {
|
|
168
|
+
rl.close();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const serverUrl = (options.serverUrl || `${baseUrl}/mcp-server`)
|
|
173
|
+
.trim()
|
|
174
|
+
.replace(/\/$/, "");
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
scope,
|
|
178
|
+
hosts,
|
|
179
|
+
baseUrl,
|
|
180
|
+
serverUrl,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function resolveUninstallInputs(options) {
|
|
185
|
+
let scope = (options.scope || "all").trim().toLowerCase();
|
|
186
|
+
if (!["all", "global", "project"].includes(scope)) {
|
|
187
|
+
scope = "all";
|
|
188
|
+
}
|
|
189
|
+
let hosts = parseHostsValue(options.hosts);
|
|
190
|
+
|
|
191
|
+
if (!options.yes) {
|
|
192
|
+
const rl = readline.createInterface({
|
|
193
|
+
input: process.stdin,
|
|
194
|
+
output: process.stdout,
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
const promptedScope = await promptLine(
|
|
198
|
+
rl,
|
|
199
|
+
"Scope [all/global/project]",
|
|
200
|
+
scope,
|
|
201
|
+
);
|
|
202
|
+
scope = ["all", "global", "project"].includes(promptedScope)
|
|
203
|
+
? promptedScope
|
|
204
|
+
: "all";
|
|
205
|
+
const promptedHosts = await promptLine(
|
|
206
|
+
rl,
|
|
207
|
+
"Hosts (comma-separated codex,claude,opencode)",
|
|
208
|
+
hosts.join(","),
|
|
209
|
+
);
|
|
210
|
+
hosts = parseHostsValue(promptedHosts);
|
|
211
|
+
} finally {
|
|
212
|
+
rl.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { scope, hosts };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function writeSharedApiKey(apiKey) {
|
|
220
|
+
await mkdir(path.dirname(SHARED_KEY_PATH), { recursive: true });
|
|
221
|
+
await writeFile(SHARED_KEY_PATH, `${apiKey.trim()}\n`, {
|
|
222
|
+
encoding: "utf8",
|
|
223
|
+
mode: 0o600,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function configureHost({ host, scope, serverUrl, apiKey }) {
|
|
228
|
+
const config = AGENT_CONFIG[host];
|
|
229
|
+
const filePath = resolveHostConfigPath(host, scope);
|
|
230
|
+
const entry = config.buildEntry({ serverUrl, apiKey });
|
|
231
|
+
|
|
232
|
+
if (config.configType === "toml") {
|
|
233
|
+
const result = await upsertCodexTomlServer({
|
|
234
|
+
filePath,
|
|
235
|
+
serverName: SERVER_NAME,
|
|
236
|
+
entry,
|
|
237
|
+
});
|
|
238
|
+
return {
|
|
239
|
+
host,
|
|
240
|
+
filePath,
|
|
241
|
+
status: result.changed
|
|
242
|
+
? result.hadExisting
|
|
243
|
+
? "updated"
|
|
244
|
+
: "installed"
|
|
245
|
+
: "already configured",
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const current = await readJsonConfig(filePath);
|
|
250
|
+
const next = upsertJsonServerEntry({
|
|
251
|
+
config: current,
|
|
252
|
+
configKey: config.configKey,
|
|
253
|
+
serverName: SERVER_NAME,
|
|
254
|
+
entry,
|
|
255
|
+
});
|
|
256
|
+
if (next.changed) {
|
|
257
|
+
await writeJsonConfig(filePath, next.config);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
host,
|
|
261
|
+
filePath,
|
|
262
|
+
status: next.changed
|
|
263
|
+
? next.hadExisting
|
|
264
|
+
? "updated"
|
|
265
|
+
: "installed"
|
|
266
|
+
: "already configured",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function removeHostConfig({ host, scope }) {
|
|
271
|
+
const config = AGENT_CONFIG[host];
|
|
272
|
+
const filePath = resolveHostConfigPath(host, scope);
|
|
273
|
+
|
|
274
|
+
if (config.configType === "toml") {
|
|
275
|
+
const result = await removeCodexTomlServer({
|
|
276
|
+
filePath,
|
|
277
|
+
serverName: SERVER_NAME,
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
host,
|
|
281
|
+
scope,
|
|
282
|
+
filePath,
|
|
283
|
+
status: result.changed ? "removed" : "not present",
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const current = await readJsonConfig(filePath);
|
|
288
|
+
const next = removeJsonServerEntry({
|
|
289
|
+
config: current,
|
|
290
|
+
configKey: config.configKey,
|
|
291
|
+
serverName: SERVER_NAME,
|
|
292
|
+
});
|
|
293
|
+
if (next.changed) {
|
|
294
|
+
await writeJsonConfig(filePath, next.config);
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
host,
|
|
298
|
+
scope,
|
|
299
|
+
filePath,
|
|
300
|
+
status: next.changed ? "removed" : "not present",
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function printSetupSummary({ scope, serverUrl, results }) {
|
|
305
|
+
console.log("\nFlywheel MCP setup complete\n");
|
|
306
|
+
console.log(`Scope: ${scope}`);
|
|
307
|
+
console.log(`Server URL: ${serverUrl}`);
|
|
308
|
+
for (const result of results) {
|
|
309
|
+
console.log(
|
|
310
|
+
`- ${HOST_LABELS[result.host]}: ${result.status}\n ${result.filePath}`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
console.log(`\nStored API key: ${SHARED_KEY_PATH}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function printUninstallSummary(results, deletedKey) {
|
|
317
|
+
console.log("\nFlywheel MCP uninstall complete\n");
|
|
318
|
+
for (const result of results) {
|
|
319
|
+
console.log(
|
|
320
|
+
`- ${HOST_LABELS[result.host]} (${result.scope}): ${result.status}\n ${result.filePath}`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if (deletedKey) {
|
|
324
|
+
console.log(`\nDeleted shared API key: ${SHARED_KEY_PATH}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function runSetup(options) {
|
|
329
|
+
const inputs = await resolveSetupInputs(options);
|
|
330
|
+
const keyName =
|
|
331
|
+
(options.name || `flywheel-setup-${cryptoRandomHex(3)}`).trim() ||
|
|
332
|
+
`flywheel-setup-${cryptoRandomHex(3)}`;
|
|
333
|
+
|
|
334
|
+
const apiKey =
|
|
335
|
+
(options.apiKey && options.apiKey.trim()) ||
|
|
336
|
+
(
|
|
337
|
+
await acquireApiKeyViaBrowserBridge({
|
|
338
|
+
baseUrl: inputs.baseUrl,
|
|
339
|
+
keyName,
|
|
340
|
+
})
|
|
341
|
+
).apiKey;
|
|
342
|
+
|
|
343
|
+
if (!apiKey || !apiKey.trim()) {
|
|
344
|
+
throw new Error("No API key available for setup.");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
await writeSharedApiKey(apiKey);
|
|
348
|
+
|
|
349
|
+
const results = [];
|
|
350
|
+
for (const host of inputs.hosts) {
|
|
351
|
+
results.push(
|
|
352
|
+
await configureHost({
|
|
353
|
+
host,
|
|
354
|
+
scope: inputs.scope,
|
|
355
|
+
serverUrl: inputs.serverUrl,
|
|
356
|
+
apiKey,
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
printSetupSummary({
|
|
362
|
+
scope: inputs.scope,
|
|
363
|
+
serverUrl: inputs.serverUrl,
|
|
364
|
+
results,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function runUninstall(options) {
|
|
369
|
+
const inputs = await resolveUninstallInputs(options);
|
|
370
|
+
const scopes =
|
|
371
|
+
inputs.scope === "all" ? ["global", "project"] : [inputs.scope];
|
|
372
|
+
|
|
373
|
+
const results = [];
|
|
374
|
+
for (const scope of scopes) {
|
|
375
|
+
for (const host of inputs.hosts) {
|
|
376
|
+
results.push(await removeHostConfig({ host, scope }));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let deletedKey = false;
|
|
381
|
+
if (options.deleteKey) {
|
|
382
|
+
try {
|
|
383
|
+
await rm(SHARED_KEY_PATH, { force: true });
|
|
384
|
+
deletedKey = true;
|
|
385
|
+
} catch {
|
|
386
|
+
deletedKey = false;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
printUninstallSummary(results, deletedKey);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function cryptoRandomHex(bytes) {
|
|
394
|
+
return randomBytes(bytes).toString("hex");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function runCli(argv = process.argv) {
|
|
398
|
+
const { command, options } = parseArgs(argv);
|
|
399
|
+
|
|
400
|
+
if (
|
|
401
|
+
!command ||
|
|
402
|
+
options.help ||
|
|
403
|
+
command === "help" ||
|
|
404
|
+
command === "--help" ||
|
|
405
|
+
command === "-h"
|
|
406
|
+
) {
|
|
407
|
+
printHelp();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (command === "setup") {
|
|
412
|
+
await runSetup(options);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (command === "uninstall") {
|
|
417
|
+
await runUninstall(options);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
throw new Error(`Unknown command '${command}'. Use --help for usage.`);
|
|
422
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function stripJsonComments(text) {
|
|
5
|
+
let result = "";
|
|
6
|
+
let i = 0;
|
|
7
|
+
while (i < text.length) {
|
|
8
|
+
if (text[i] === '"') {
|
|
9
|
+
const start = i++;
|
|
10
|
+
while (i < text.length && text[i] !== '"') {
|
|
11
|
+
if (text[i] === "\\") i++;
|
|
12
|
+
i++;
|
|
13
|
+
}
|
|
14
|
+
result += text.slice(start, ++i);
|
|
15
|
+
} else if (text[i] === "/" && text[i + 1] === "/") {
|
|
16
|
+
i += 2;
|
|
17
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
18
|
+
} else if (text[i] === "/" && text[i + 1] === "*") {
|
|
19
|
+
i += 2;
|
|
20
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
|
|
21
|
+
i += 2;
|
|
22
|
+
} else {
|
|
23
|
+
result += text[i++];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readJsonConfig(filePath) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = (await readFile(filePath, "utf8")).trim();
|
|
32
|
+
if (!raw) return {};
|
|
33
|
+
return JSON.parse(stripJsonComments(raw));
|
|
34
|
+
} catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function writeJsonConfig(filePath, config) {
|
|
40
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
41
|
+
await writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, {
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
mode: 0o600,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stableJson(value) {
|
|
48
|
+
return JSON.stringify(value, Object.keys(value || {}).sort());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function upsertJsonServerEntry({ config, configKey, serverName, entry }) {
|
|
52
|
+
const section =
|
|
53
|
+
config && typeof config[configKey] === "object" && config[configKey] !== null
|
|
54
|
+
? { ...config[configKey] }
|
|
55
|
+
: {};
|
|
56
|
+
const previous = section[serverName] ?? null;
|
|
57
|
+
section[serverName] = entry;
|
|
58
|
+
const nextConfig = {
|
|
59
|
+
...(config || {}),
|
|
60
|
+
[configKey]: section,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const changed = stableJson(previous) !== stableJson(entry);
|
|
64
|
+
return {
|
|
65
|
+
config: nextConfig,
|
|
66
|
+
changed,
|
|
67
|
+
hadExisting: previous !== null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function removeJsonServerEntry({ config, configKey, serverName }) {
|
|
72
|
+
const section =
|
|
73
|
+
config && typeof config[configKey] === "object" && config[configKey] !== null
|
|
74
|
+
? { ...config[configKey] }
|
|
75
|
+
: {};
|
|
76
|
+
if (!(serverName in section)) {
|
|
77
|
+
return {
|
|
78
|
+
config: config || {},
|
|
79
|
+
changed: false,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
delete section[serverName];
|
|
83
|
+
const nextConfig = {
|
|
84
|
+
...(config || {}),
|
|
85
|
+
[configKey]: section,
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
config: nextConfig,
|
|
89
|
+
changed: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeLineEndings(text) {
|
|
94
|
+
return text.replace(/\r\n/g, "\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function buildCodexTomlServerBlock({ serverName, entry }) {
|
|
98
|
+
const lines = [`[mcp_servers.${serverName}]`];
|
|
99
|
+
lines.push(`type = ${JSON.stringify(entry.type)}`);
|
|
100
|
+
lines.push(`url = ${JSON.stringify(entry.url)}`);
|
|
101
|
+
|
|
102
|
+
const headers = entry.headers && typeof entry.headers === "object" ? entry.headers : {};
|
|
103
|
+
const headerEntries = Object.entries(headers);
|
|
104
|
+
if (headerEntries.length > 0) {
|
|
105
|
+
lines.push("");
|
|
106
|
+
lines.push(`[mcp_servers.${serverName}.http_headers]`);
|
|
107
|
+
for (const [key, value] of headerEntries) {
|
|
108
|
+
lines.push(`${key} = ${JSON.stringify(String(value))}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return `${lines.join("\n")}\n`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stripCodexTomlServerBlock({ tomlText, serverName }) {
|
|
116
|
+
const normalized = normalizeLineEndings(tomlText || "");
|
|
117
|
+
const lines = normalized.split("\n");
|
|
118
|
+
const output = [];
|
|
119
|
+
|
|
120
|
+
let skipping = false;
|
|
121
|
+
let removed = false;
|
|
122
|
+
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
const trimmed = line.trim();
|
|
125
|
+
const sectionMatch = trimmed.match(/^\[(.+)\]$/);
|
|
126
|
+
|
|
127
|
+
if (sectionMatch) {
|
|
128
|
+
const sectionName = sectionMatch[1];
|
|
129
|
+
const isTargetSection =
|
|
130
|
+
sectionName === `mcp_servers.${serverName}` ||
|
|
131
|
+
sectionName.startsWith(`mcp_servers.${serverName}.`);
|
|
132
|
+
if (isTargetSection) {
|
|
133
|
+
skipping = true;
|
|
134
|
+
removed = true;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (skipping) {
|
|
138
|
+
skipping = false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!skipping) {
|
|
143
|
+
output.push(line);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let stripped = output.join("\n");
|
|
148
|
+
stripped = stripped.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
149
|
+
if (stripped.length > 0) {
|
|
150
|
+
stripped += "\n";
|
|
151
|
+
}
|
|
152
|
+
return { stripped, removed };
|
|
153
|
+
}
|
|
154
|
+
|
|
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
|
+
export async function removeCodexTomlServer({ filePath, serverName }) {
|
|
181
|
+
let existing = "";
|
|
182
|
+
try {
|
|
183
|
+
existing = await readFile(filePath, "utf8");
|
|
184
|
+
} catch {
|
|
185
|
+
return { changed: false };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { stripped, removed } = stripCodexTomlServerBlock({
|
|
189
|
+
tomlText: existing,
|
|
190
|
+
serverName,
|
|
191
|
+
});
|
|
192
|
+
if (!removed) {
|
|
193
|
+
return { changed: false };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
197
|
+
await writeFile(filePath, stripped, { encoding: "utf8", mode: 0o600 });
|
|
198
|
+
return { changed: true };
|
|
199
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_AUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
function openUrlInBrowser(url) {
|
|
8
|
+
const platform = process.platform;
|
|
9
|
+
if (platform === "darwin") {
|
|
10
|
+
return spawn("open", [url], { stdio: "ignore", detached: true });
|
|
11
|
+
}
|
|
12
|
+
if (platform === "win32") {
|
|
13
|
+
return spawn("cmd", ["/c", "start", "", url], {
|
|
14
|
+
stdio: "ignore",
|
|
15
|
+
detached: true,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return spawn("xdg-open", [url], { stdio: "ignore", detached: true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function renderCallbackPage({ ok, message }) {
|
|
22
|
+
const title = ok ? "Flywheel Setup Complete" : "Flywheel Setup Failed";
|
|
23
|
+
const color = ok ? "#16a34a" : "#dc2626";
|
|
24
|
+
return `<!doctype html>
|
|
25
|
+
<html>
|
|
26
|
+
<head><meta charset=\"utf-8\"><title>${title}</title></head>
|
|
27
|
+
<body style=\"font-family: system-ui; margin: 0; padding: 40px; background: #fafafa; color: #111;\">
|
|
28
|
+
<h1 style=\"margin-top: 0; color: ${color};\">${title}</h1>
|
|
29
|
+
<p>${message}</p>
|
|
30
|
+
<p>You can close this window and return to the terminal.</p>
|
|
31
|
+
</body>
|
|
32
|
+
</html>`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function acquireApiKeyViaBrowserBridge({
|
|
36
|
+
baseUrl,
|
|
37
|
+
keyName,
|
|
38
|
+
timeoutMs = DEFAULT_AUTH_TIMEOUT_MS,
|
|
39
|
+
}) {
|
|
40
|
+
const state = crypto.randomUUID();
|
|
41
|
+
|
|
42
|
+
return await new Promise((resolve, reject) => {
|
|
43
|
+
let settled = false;
|
|
44
|
+
let timeout = null;
|
|
45
|
+
let server = null;
|
|
46
|
+
|
|
47
|
+
const cleanup = () => {
|
|
48
|
+
if (timeout) {
|
|
49
|
+
clearTimeout(timeout);
|
|
50
|
+
timeout = null;
|
|
51
|
+
}
|
|
52
|
+
if (server) {
|
|
53
|
+
server.close();
|
|
54
|
+
server = null;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const fail = (error) => {
|
|
59
|
+
if (settled) return;
|
|
60
|
+
settled = true;
|
|
61
|
+
cleanup();
|
|
62
|
+
reject(error);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const succeed = (result) => {
|
|
66
|
+
if (settled) return;
|
|
67
|
+
settled = true;
|
|
68
|
+
cleanup();
|
|
69
|
+
resolve(result);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
server = http.createServer((req, res) => {
|
|
73
|
+
const reqUrl = new URL(req.url || "/", "http://127.0.0.1");
|
|
74
|
+
if (reqUrl.pathname !== "/callback") {
|
|
75
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
76
|
+
res.end("Not Found");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const callbackState = (reqUrl.searchParams.get("state") || "").trim();
|
|
81
|
+
const key = (reqUrl.searchParams.get("key") || "").trim();
|
|
82
|
+
const error = (reqUrl.searchParams.get("error") || "").trim();
|
|
83
|
+
|
|
84
|
+
if (callbackState !== state) {
|
|
85
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
86
|
+
res.end(
|
|
87
|
+
renderCallbackPage({
|
|
88
|
+
ok: false,
|
|
89
|
+
message: "State mismatch. Please retry setup.",
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
fail(new Error("Setup callback state mismatch."));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (error) {
|
|
97
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
98
|
+
res.end(renderCallbackPage({ ok: false, message: error }));
|
|
99
|
+
fail(new Error(error));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!key) {
|
|
104
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
105
|
+
res.end(
|
|
106
|
+
renderCallbackPage({
|
|
107
|
+
ok: false,
|
|
108
|
+
message: "No API key was returned.",
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
fail(new Error("Setup callback missing API key."));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
116
|
+
res.end(
|
|
117
|
+
renderCallbackPage({
|
|
118
|
+
ok: true,
|
|
119
|
+
message: "Flywheel MCP was authorized successfully.",
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
succeed({ apiKey: key });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
server.once("error", (error) => {
|
|
126
|
+
fail(error);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
server.listen(0, "127.0.0.1", () => {
|
|
130
|
+
const address = server.address();
|
|
131
|
+
if (!address || typeof address === "string") {
|
|
132
|
+
fail(new Error("Failed to allocate local callback port."));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const callbackUrl = `http://127.0.0.1:${address.port}/callback`;
|
|
137
|
+
const setupUrl = new URL("/auth/mcp/setup", baseUrl);
|
|
138
|
+
setupUrl.searchParams.set("state", state);
|
|
139
|
+
setupUrl.searchParams.set("redirect_uri", callbackUrl);
|
|
140
|
+
setupUrl.searchParams.set("name", keyName);
|
|
141
|
+
|
|
142
|
+
console.log("Opening browser for Flywheel login and key creation...");
|
|
143
|
+
console.log(`If it does not open, use this URL:\n${setupUrl.toString()}\n`);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const child = openUrlInBrowser(setupUrl.toString());
|
|
147
|
+
child.unref();
|
|
148
|
+
} catch {
|
|
149
|
+
// User can still open URL manually.
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
timeout = setTimeout(() => {
|
|
154
|
+
fail(new Error("Timed out waiting for browser authorization."));
|
|
155
|
+
}, timeoutMs);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
});
|