@rayrun/cli 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/LICENSE +9 -0
- package/README.md +24 -0
- package/index.js +10 -0
- package/package.json +40 -0
- package/src/clients.js +220 -0
- package/src/config.js +237 -0
- package/src/main.js +198 -0
- package/src/setup.js +467 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rayrun
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @rayrun/cli
|
|
2
|
+
|
|
3
|
+
Connect supported MCP clients to a Rayrun workspace endpoint without copying configuration by hand.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pnpm --package @rayrun/cli@latest dlx rayrun setup https://copy-the-endpoint-from-rayrun.example/mcp
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The command detects Claude Code, Codex/ChatGPT desktop, Cursor, VS Code, Windsurf/Devin Local, and OpenCode v2. It shows the detected clients before changing anything. Pass `--client cursor` more than once for automation, or `--all --yes` to configure every detected client.
|
|
10
|
+
|
|
11
|
+
It writes only the public Rayrun endpoint. OAuth stays inside each client; no bearer token or API key is put in a config file or shell history. Before changing configuration it stores byte-for-byte backups under `~/.rayrun/backups/setup`, writes JSON atomically, and restores every target if a configuration write fails. A later client-login failure leaves the valid configuration in place and prints the exact login command to retry.
|
|
12
|
+
|
|
13
|
+
Backups are private to your user account (`0700` directories and `0600` files) and are retained until you remove old backup directories after their rollback window is no longer useful.
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
rayrun setup <endpoint> --dry-run --all
|
|
17
|
+
rayrun setup <endpoint> --client cursor --client opencode --yes
|
|
18
|
+
rayrun setup <endpoint> --client codex --yes --login
|
|
19
|
+
rayrun setup rollback <backup-id>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Use `--project` for project-local configuration in Claude Code, Codex, Cursor, VS Code, Devin, and OpenCode. Legacy Windsurf remains user-scoped. Claude Code asks you to approve a project MCP server when that project opens. Codex loads `.codex/config.toml` only after you trust the project; restart Codex after doing so.
|
|
23
|
+
|
|
24
|
+
Interactive setup runs supported clients' OAuth login commands. `--yes` and non-interactive setup never launch login unless you explicitly pass `--login`; the command prints the exact login command when it skips one. Pass `--no-login` to make the choice explicit in scripts.
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rayrun/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Safely connect local MCP clients to Rayrun",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/rayrundev/rayrun.git",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"rayrun": "index.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"src/clients.js",
|
|
17
|
+
"src/config.js",
|
|
18
|
+
"src/main.js",
|
|
19
|
+
"src/setup.js",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "vitest run"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"jsonc-parser": "^3.3.1",
|
|
32
|
+
"smol-toml": "^1.8.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"vitest": "^4.1.10"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/clients.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/* eslint-disable node/no-process-env -- the installer intentionally inspects the invoking user's environment */
|
|
2
|
+
// cspell:ignore codemode
|
|
3
|
+
import { access } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const executableExists = async (name, environment = process.env) => {
|
|
8
|
+
const pathValue = environment.PATH ?? '';
|
|
9
|
+
const extensions = process.platform === 'win32' ? ['', '.cmd', '.exe'] : [''];
|
|
10
|
+
|
|
11
|
+
for (const directory of pathValue.split(path.delimiter).filter(Boolean)) {
|
|
12
|
+
for (const extension of extensions) {
|
|
13
|
+
try {
|
|
14
|
+
await access(path.join(directory, `${name}${extension}`));
|
|
15
|
+
return true;
|
|
16
|
+
} catch {
|
|
17
|
+
// Try the next PATH entry.
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return false;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const fileExists = async (filePath) => {
|
|
26
|
+
try {
|
|
27
|
+
await access(filePath);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const vscodeUserPath = ({ environment, homeDirectory, platform }) => {
|
|
35
|
+
if (platform === 'darwin') {
|
|
36
|
+
return path.join(homeDirectory, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (platform === 'win32') {
|
|
40
|
+
return path.join(
|
|
41
|
+
environment.APPDATA ?? path.join(homeDirectory, 'AppData', 'Roaming'),
|
|
42
|
+
'Code',
|
|
43
|
+
'User',
|
|
44
|
+
'mcp.json',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return path.join(
|
|
49
|
+
environment.XDG_CONFIG_HOME ?? path.join(homeDirectory, '.config'),
|
|
50
|
+
'Code',
|
|
51
|
+
'User',
|
|
52
|
+
'mcp.json',
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const clientDefinitions = ({
|
|
57
|
+
cwd = process.cwd(),
|
|
58
|
+
environment = process.env,
|
|
59
|
+
homeDirectory = os.homedir(),
|
|
60
|
+
platform = process.platform,
|
|
61
|
+
project = false,
|
|
62
|
+
} = {}) => {
|
|
63
|
+
const configHome = environment.XDG_CONFIG_HOME ?? path.join(homeDirectory, '.config');
|
|
64
|
+
const devinConfigHome =
|
|
65
|
+
platform === 'win32'
|
|
66
|
+
? (environment.APPDATA ?? path.join(homeDirectory, 'AppData', 'Roaming'))
|
|
67
|
+
: configHome;
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
detectCommand: 'claude',
|
|
72
|
+
detectPaths: [path.join(homeDirectory, '.claude')],
|
|
73
|
+
id: 'claude-code',
|
|
74
|
+
label: 'Claude Code',
|
|
75
|
+
loginCommand: {
|
|
76
|
+
args: ['mcp', 'login', 'rayrun'],
|
|
77
|
+
command: 'claude',
|
|
78
|
+
cwd: project ? cwd : homeDirectory,
|
|
79
|
+
},
|
|
80
|
+
path: project ? path.join(cwd, '.mcp.json') : path.join(homeDirectory, '.claude.json'),
|
|
81
|
+
propertyPath: ['mcpServers', 'rayrun'],
|
|
82
|
+
projectInstruction: project
|
|
83
|
+
? 'Open Claude Code in this project and approve the rayrun server when prompted.'
|
|
84
|
+
: null,
|
|
85
|
+
type: 'json',
|
|
86
|
+
value: (url) => ({ type: 'http', url }),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
detectCommand: 'codex',
|
|
90
|
+
detectPaths: [path.join(homeDirectory, '.codex')],
|
|
91
|
+
id: 'codex',
|
|
92
|
+
label: 'Codex and ChatGPT desktop',
|
|
93
|
+
loginCommand: {
|
|
94
|
+
args: ['mcp', 'login', 'rayrun'],
|
|
95
|
+
command: 'codex',
|
|
96
|
+
cwd: project ? cwd : homeDirectory,
|
|
97
|
+
},
|
|
98
|
+
path: project
|
|
99
|
+
? path.join(cwd, '.codex', 'config.toml')
|
|
100
|
+
: path.join(homeDirectory, '.codex', 'config.toml'),
|
|
101
|
+
projectInstruction: project
|
|
102
|
+
? 'Trust this project in Codex, then restart Codex so it loads .codex/config.toml.'
|
|
103
|
+
: null,
|
|
104
|
+
section: 'mcp_servers.rayrun',
|
|
105
|
+
type: 'toml',
|
|
106
|
+
value: (url) => ({ url }),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'cursor',
|
|
110
|
+
label: 'Cursor',
|
|
111
|
+
detectCommand: 'cursor',
|
|
112
|
+
detectPaths: [path.join(homeDirectory, '.cursor')],
|
|
113
|
+
path: project
|
|
114
|
+
? path.join(cwd, '.cursor', 'mcp.json')
|
|
115
|
+
: path.join(homeDirectory, '.cursor', 'mcp.json'),
|
|
116
|
+
propertyPath: ['mcpServers', 'rayrun'],
|
|
117
|
+
type: 'json',
|
|
118
|
+
value: (url) => ({ url }),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'vscode',
|
|
122
|
+
label: 'VS Code',
|
|
123
|
+
detectCommand: 'code',
|
|
124
|
+
detectPaths: [path.dirname(vscodeUserPath({ environment, homeDirectory, platform }))],
|
|
125
|
+
path: project
|
|
126
|
+
? path.join(cwd, '.vscode', 'mcp.json')
|
|
127
|
+
: vscodeUserPath({ environment, homeDirectory, platform }),
|
|
128
|
+
propertyPath: ['servers', 'rayrun'],
|
|
129
|
+
type: 'json',
|
|
130
|
+
value: (url) => ({ type: 'http', url }),
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: 'windsurf',
|
|
134
|
+
label: 'Windsurf (legacy Cascade)',
|
|
135
|
+
detectCommand: 'windsurf',
|
|
136
|
+
detectPaths: [path.join(homeDirectory, '.codeium', 'windsurf')],
|
|
137
|
+
path: path.join(homeDirectory, '.codeium', 'windsurf', 'mcp_config.json'),
|
|
138
|
+
propertyPath: ['mcpServers', 'rayrun'],
|
|
139
|
+
projectUnsupported: true,
|
|
140
|
+
type: 'json',
|
|
141
|
+
value: (url) => ({ serverUrl: url }),
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
detectCommand: 'devin',
|
|
145
|
+
id: 'devin',
|
|
146
|
+
label: 'Windsurf / Devin Local',
|
|
147
|
+
loginCommand: {
|
|
148
|
+
args: ['mcp', 'login', 'rayrun'],
|
|
149
|
+
command: 'devin',
|
|
150
|
+
cwd: project ? cwd : homeDirectory,
|
|
151
|
+
},
|
|
152
|
+
path: project
|
|
153
|
+
? path.join(cwd, '.devin', 'mcp_config.json')
|
|
154
|
+
: path.join(devinConfigHome, 'devin', 'mcp_config.json'),
|
|
155
|
+
propertyPath: ['mcpServers', 'rayrun'],
|
|
156
|
+
type: 'json',
|
|
157
|
+
value: (url) => ({ transport: 'http', url }),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'opencode',
|
|
161
|
+
label: 'OpenCode v2',
|
|
162
|
+
alternativePaths: project
|
|
163
|
+
? [path.join(cwd, 'opencode.jsonc')]
|
|
164
|
+
: [path.join(configHome, 'opencode', 'opencode.jsonc')],
|
|
165
|
+
detectCommand: 'opencode2',
|
|
166
|
+
path: project
|
|
167
|
+
? path.join(cwd, 'opencode.json')
|
|
168
|
+
: path.join(configHome, 'opencode', 'opencode.json'),
|
|
169
|
+
preferredExistingPaths: project
|
|
170
|
+
? [
|
|
171
|
+
path.join(cwd, '.opencode', 'opencode.json'),
|
|
172
|
+
path.join(cwd, '.opencode', 'opencode.jsonc'),
|
|
173
|
+
]
|
|
174
|
+
: [],
|
|
175
|
+
propertyPath: ['mcp', 'servers', 'rayrun'],
|
|
176
|
+
requireCommandForDetection: true,
|
|
177
|
+
type: 'json',
|
|
178
|
+
value: (url) => ({ codemode: false, type: 'remote', url }),
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const detectClients = async (options = {}) => {
|
|
184
|
+
const definitions = clientDefinitions(options);
|
|
185
|
+
const environment = options.environment ?? process.env;
|
|
186
|
+
const detected = [];
|
|
187
|
+
|
|
188
|
+
for (const definition of definitions) {
|
|
189
|
+
if (options.project && definition.projectUnsupported) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const candidatePaths = [
|
|
193
|
+
...(definition.preferredExistingPaths ?? []),
|
|
194
|
+
definition.path,
|
|
195
|
+
...(definition.alternativePaths ?? []),
|
|
196
|
+
].filter(Boolean);
|
|
197
|
+
const existingPath = (
|
|
198
|
+
await Promise.all(
|
|
199
|
+
candidatePaths.map(async (candidate) => ((await fileExists(candidate)) ? candidate : null)),
|
|
200
|
+
)
|
|
201
|
+
).find(Boolean);
|
|
202
|
+
const commandExists = definition.detectCommand
|
|
203
|
+
? await executableExists(definition.detectCommand, environment)
|
|
204
|
+
: false;
|
|
205
|
+
const exists =
|
|
206
|
+
definition.type === 'native'
|
|
207
|
+
? await executableExists(definition.command, environment)
|
|
208
|
+
: definition.requireCommandForDetection
|
|
209
|
+
? commandExists
|
|
210
|
+
: existingPath !== undefined ||
|
|
211
|
+
commandExists ||
|
|
212
|
+
(await Promise.all((definition.detectPaths ?? []).map(fileExists))).some(Boolean);
|
|
213
|
+
|
|
214
|
+
if (exists) {
|
|
215
|
+
detected.push(existingPath ? { ...definition, path: existingPath } : definition);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return detected;
|
|
220
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { applyEdits, modify, parse, parseTree, printParseErrorCode } from 'jsonc-parser';
|
|
2
|
+
import { parse as parseToml } from 'smol-toml';
|
|
3
|
+
|
|
4
|
+
// cspell:ignore accesstoken apikey bearertoken clientsecret codemode httpheaders
|
|
5
|
+
|
|
6
|
+
const hasCredentialMaterial = (value) => {
|
|
7
|
+
if (!value || typeof value !== 'object') return false;
|
|
8
|
+
|
|
9
|
+
return Object.entries(value).some(([key, child]) => {
|
|
10
|
+
const normalized = key.replaceAll(/[-_]/gu, '').toLowerCase();
|
|
11
|
+
const credentialKey =
|
|
12
|
+
normalized === 'authorization' ||
|
|
13
|
+
normalized === 'headers' ||
|
|
14
|
+
normalized.endsWith('apikey') ||
|
|
15
|
+
normalized.includes('bearertoken') ||
|
|
16
|
+
normalized.includes('clientsecret') ||
|
|
17
|
+
normalized.includes('credential') ||
|
|
18
|
+
normalized.includes('httpheaders') ||
|
|
19
|
+
normalized.includes('oauth') ||
|
|
20
|
+
normalized.includes('accesstoken') ||
|
|
21
|
+
normalized.endsWith('token') ||
|
|
22
|
+
normalized.endsWith('password') ||
|
|
23
|
+
normalized.endsWith('secret');
|
|
24
|
+
|
|
25
|
+
return credentialKey || hasCredentialMaterial(child);
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const endpointResource = (value) => {
|
|
30
|
+
if (typeof value !== 'string') return null;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const endpoint = new URL(value);
|
|
34
|
+
endpoint.pathname = endpoint.pathname.replace(/\/direct\/?$/u, '');
|
|
35
|
+
return endpoint.href;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const assertCredentialsStayOnResource = ({ existing, value }) => {
|
|
42
|
+
if (!hasCredentialMaterial(existing)) return;
|
|
43
|
+
|
|
44
|
+
const existingResource = endpointResource(existing.url ?? existing.serverUrl);
|
|
45
|
+
const nextResource = endpointResource(value.url ?? value.serverUrl);
|
|
46
|
+
|
|
47
|
+
if (existingResource === null || nextResource === null || existingResource !== nextResource) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
'Refusing to move a configuration that contains credentials to another server. Remove its credential fields after inspecting them, then run setup again.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const assertNoDuplicateJsonKeys = (node, path = []) => {
|
|
55
|
+
if (!node) return;
|
|
56
|
+
|
|
57
|
+
if (node.type === 'object') {
|
|
58
|
+
const keys = new Set();
|
|
59
|
+
for (const property of node.children ?? []) {
|
|
60
|
+
const [keyNode, valueNode] = property.children ?? [];
|
|
61
|
+
const key = keyNode?.value;
|
|
62
|
+
if (typeof key !== 'string') continue;
|
|
63
|
+
if (keys.has(key)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Cannot safely update JSON configuration with duplicate key ${[...path, key].join('.')}.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
keys.add(key);
|
|
69
|
+
assertNoDuplicateJsonKeys(valueNode, [...path, key]);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const child of node.children ?? []) assertNoDuplicateJsonKeys(child, path);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const updateJsonConfig = ({ source, propertyPath, value }) => {
|
|
78
|
+
const errors = [];
|
|
79
|
+
const parsed = parse(source === '' ? '{}' : source, errors, { allowTrailingComma: true });
|
|
80
|
+
|
|
81
|
+
if (errors.length > 0 || parsed === undefined) {
|
|
82
|
+
const first = errors[0];
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Cannot safely update invalid JSON configuration${first ? `: ${printParseErrorCode(first.error)} at offset ${String(first.offset)}` : ''}.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
assertNoDuplicateJsonKeys(parseTree(source === '' ? '{}' : source));
|
|
88
|
+
|
|
89
|
+
const existing = propertyPath.reduce((candidate, key) => {
|
|
90
|
+
return candidate && typeof candidate === 'object' ? candidate[key] : undefined;
|
|
91
|
+
}, parsed);
|
|
92
|
+
assertCredentialsStayOnResource({ existing, value });
|
|
93
|
+
const enabledValue = { ...value };
|
|
94
|
+
|
|
95
|
+
if (existing && typeof existing.disabled === 'boolean') enabledValue.disabled = false;
|
|
96
|
+
if (existing && typeof existing.enabled === 'boolean') enabledValue.enabled = true;
|
|
97
|
+
let result = source === '' ? '{}' : source;
|
|
98
|
+
const formattingOptions = { insertSpaces: true, tabSize: 2 };
|
|
99
|
+
|
|
100
|
+
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
|
|
101
|
+
result = applyEdits(result, modify(result, propertyPath, {}, { formattingOptions }));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// These keys select mutually exclusive MCP transports across the supported clients. Remove a
|
|
105
|
+
// stale transport field while retaining unrelated client-specific settings and their comments.
|
|
106
|
+
for (const key of [
|
|
107
|
+
'args',
|
|
108
|
+
'codemode',
|
|
109
|
+
'command',
|
|
110
|
+
'cwd',
|
|
111
|
+
'env',
|
|
112
|
+
'environment',
|
|
113
|
+
'serverUrl',
|
|
114
|
+
'transport',
|
|
115
|
+
'type',
|
|
116
|
+
'url',
|
|
117
|
+
]) {
|
|
118
|
+
if (existing && Object.hasOwn(existing, key) && !Object.hasOwn(enabledValue, key)) {
|
|
119
|
+
result = applyEdits(
|
|
120
|
+
result,
|
|
121
|
+
modify(result, [...propertyPath, key], undefined, { formattingOptions }),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const [key, nextValue] of Object.entries(enabledValue)) {
|
|
127
|
+
result = applyEdits(
|
|
128
|
+
result,
|
|
129
|
+
modify(result, [...propertyPath, key], nextValue, { formattingOptions }),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return result;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const updateTomlConfig = ({ section, source, value }) => {
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = parseToml(source === '' ? '' : source);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Cannot safely update invalid TOML configuration: ${error instanceof Error ? error.message : String(error)}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const lines = source.replace(/\r\n/gu, '\n').split('\n');
|
|
147
|
+
const escapePattern = (part) => part.replaceAll(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
148
|
+
const sectionPatternSource = section
|
|
149
|
+
.split('.')
|
|
150
|
+
.map((part) => `(?:${escapePattern(part)}|"${escapePattern(part)}")`)
|
|
151
|
+
.join('\\s*\\.\\s*');
|
|
152
|
+
const sectionPattern = new RegExp(`^\\s*\\[\\s*${sectionPatternSource}\\s*\\]\\s*(?:#.*)?$`, 'u');
|
|
153
|
+
const nestedSectionPattern = new RegExp(
|
|
154
|
+
`^\\s*\\[\\s*${sectionPatternSource}\\s*\\.\\s*(.+?)\\s*\\]\\s*(?:#.*)?$`,
|
|
155
|
+
'u',
|
|
156
|
+
);
|
|
157
|
+
const sectionIndexes = lines
|
|
158
|
+
.map((line, index) => (sectionPattern.test(line) ? index : -1))
|
|
159
|
+
.filter((index) => index >= 0);
|
|
160
|
+
|
|
161
|
+
if (sectionIndexes.length > 1) {
|
|
162
|
+
throw new Error(`Cannot safely update duplicate TOML section [${section}].`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const existing = section.split('.').reduce((candidate, key) => {
|
|
166
|
+
return candidate && typeof candidate === 'object' ? candidate[key] : undefined;
|
|
167
|
+
}, parsed);
|
|
168
|
+
assertCredentialsStayOnResource({ existing, value });
|
|
169
|
+
const enabledValue = { ...value };
|
|
170
|
+
|
|
171
|
+
if (existing && typeof existing.disabled === 'boolean') enabledValue.disabled = false;
|
|
172
|
+
if (existing && typeof existing.enabled === 'boolean') enabledValue.enabled = true;
|
|
173
|
+
|
|
174
|
+
if (existing !== undefined && sectionIndexes.length === 0) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Cannot safely update [${section}] because it uses an inline or dotted TOML representation. Convert it to a table first.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const nestedSections = lines
|
|
181
|
+
.map((line) => nestedSectionPattern.exec(line)?.[1])
|
|
182
|
+
.filter((nested) => nested !== undefined);
|
|
183
|
+
|
|
184
|
+
if (nestedSections.some((nested) => /^(?:env|"env"|'env')(?:\s*\.|$)/u.test(nested))) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Cannot safely replace [${section}] while [${section}.env] contains transport-specific settings. Move or remove that table first.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (sectionIndexes.length === 0) {
|
|
191
|
+
const prefix = source.trimEnd();
|
|
192
|
+
const body = Object.entries(enabledValue)
|
|
193
|
+
.map(([key, item]) => `${key} = ${JSON.stringify(item)}`)
|
|
194
|
+
.join('\n');
|
|
195
|
+
const result = `${prefix}${prefix === '' ? '' : '\n\n'}[${section}]\n${body}\n`;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
parseToml(result);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Rayrun refused to write invalid TOML: ${error instanceof Error ? error.message : String(error)}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const start = sectionIndexes[0];
|
|
209
|
+
const nextSectionOffset = lines.slice(start + 1).findIndex((line) => /^\s*\[/u.test(line));
|
|
210
|
+
const end = nextSectionOffset === -1 ? lines.length : start + 1 + nextSectionOffset;
|
|
211
|
+
const ownedKeys = new Set(['args', 'command', 'disabled', 'enabled', 'env', 'url']);
|
|
212
|
+
const seen = new Set();
|
|
213
|
+
const updated = lines.slice(start + 1, end).flatMap((line) => {
|
|
214
|
+
const match = /^\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\s*=/u.exec(line);
|
|
215
|
+
const key = match?.[1] ?? match?.[2] ?? match?.[3];
|
|
216
|
+
if (!key || !ownedKeys.has(key)) return [line];
|
|
217
|
+
if (!Object.hasOwn(enabledValue, key)) return [];
|
|
218
|
+
seen.add(key);
|
|
219
|
+
return [`${key} = ${JSON.stringify(enabledValue[key])}`];
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
for (const [key, item] of Object.entries(enabledValue)) {
|
|
223
|
+
if (!seen.has(key)) updated.push(`${key} = ${JSON.stringify(item)}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const result = [...lines.slice(0, start + 1), ...updated, ...lines.slice(end)].join('\n');
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
parseToml(result);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`Rayrun refused to write invalid TOML: ${error instanceof Error ? error.message : String(error)}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return result;
|
|
237
|
+
};
|
package/src/main.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { clientDefinitions, detectClients } from './clients.js';
|
|
2
|
+
import { applySetup, rollbackSetup } from './setup.js';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
|
|
6
|
+
const packageVersion = createRequire(import.meta.url)('../package.json').version;
|
|
7
|
+
|
|
8
|
+
const usage = `Usage:
|
|
9
|
+
rayrun setup <endpoint> [--client <id> ... | --all] [--project] [--yes] [--dry-run] [--login | --no-login]
|
|
10
|
+
rayrun setup rollback <backup-id>
|
|
11
|
+
|
|
12
|
+
Clients: claude-code, codex, cursor, vscode, windsurf, devin, opencode`;
|
|
13
|
+
|
|
14
|
+
export const validateEndpoint = (value) => {
|
|
15
|
+
let endpoint;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
endpoint = new URL(value);
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Error('Endpoint must be an absolute HTTP or HTTPS URL.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!['http:', 'https:'].includes(endpoint.protocol)) {
|
|
24
|
+
throw new Error('Endpoint must use HTTP or HTTPS.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (
|
|
28
|
+
endpoint.protocol === 'http:' &&
|
|
29
|
+
!['127.0.0.1', '::1', 'localhost'].includes(endpoint.hostname)
|
|
30
|
+
) {
|
|
31
|
+
throw new Error('HTTP endpoints are only allowed on localhost. Use HTTPS for remote gateways.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
|
|
35
|
+
throw new Error('Endpoint cannot contain credentials, a query string, or a fragment.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const pathname = endpoint.pathname.replace(/\/$/u, '');
|
|
39
|
+
|
|
40
|
+
if (!pathname.endsWith('/mcp') && !pathname.endsWith('/mcp/direct')) {
|
|
41
|
+
throw new Error('Endpoint path must end in /mcp or /mcp/direct.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
endpoint.pathname = pathname;
|
|
45
|
+
return endpoint.href;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const parseArguments = (arguments_) => {
|
|
49
|
+
const options = {
|
|
50
|
+
all: false,
|
|
51
|
+
clientIds: [],
|
|
52
|
+
dryRun: false,
|
|
53
|
+
login: null,
|
|
54
|
+
project: false,
|
|
55
|
+
yes: false,
|
|
56
|
+
};
|
|
57
|
+
const positional = [];
|
|
58
|
+
|
|
59
|
+
for (let index = 0; index < arguments_.length; index += 1) {
|
|
60
|
+
const argument = arguments_[index];
|
|
61
|
+
|
|
62
|
+
if (argument === '--client') {
|
|
63
|
+
const value = arguments_[index + 1];
|
|
64
|
+
if (!value) throw new Error('--client requires an id.');
|
|
65
|
+
options.clientIds.push(value);
|
|
66
|
+
index += 1;
|
|
67
|
+
} else if (argument === '--all') options.all = true;
|
|
68
|
+
else if (argument === '--project') options.project = true;
|
|
69
|
+
else if (argument === '--yes') options.yes = true;
|
|
70
|
+
else if (argument === '--dry-run') options.dryRun = true;
|
|
71
|
+
else if (argument === '--login') options.login = true;
|
|
72
|
+
else if (argument === '--no-login') options.login = false;
|
|
73
|
+
else if (argument?.startsWith('--')) throw new Error(`Unknown option: ${argument}`);
|
|
74
|
+
else if (argument) positional.push(argument);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { options, positional };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const resolveLogin = ({ isInteractive, login, yes }) => {
|
|
81
|
+
return login ?? (!yes && isInteractive);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const assertInteractiveSelectionAllowed = (isInteractive) => {
|
|
85
|
+
if (!isInteractive) {
|
|
86
|
+
throw new Error('Non-interactive setup requires --client <id> or --all --yes.');
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const selectInteractively = async (clients) => {
|
|
91
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
92
|
+
const answer = await terminal.question(
|
|
93
|
+
`${clients.map((client, index) => `${String(index + 1)}. ${client.label}`).join('\n')}\nSelect clients (comma-separated): `,
|
|
94
|
+
);
|
|
95
|
+
terminal.close();
|
|
96
|
+
const indexes = new Set(answer.split(',').map((part) => Number(part.trim()) - 1));
|
|
97
|
+
return clients.filter((_client, index) => indexes.has(index));
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const main = async (arguments_) => {
|
|
101
|
+
if (arguments_.includes('--help') || arguments_.includes('-h')) {
|
|
102
|
+
process.stdout.write(`${usage}\n`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (arguments_.includes('--version') || arguments_.includes('-v')) {
|
|
106
|
+
process.stdout.write(`${String(packageVersion)}\n`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const { options, positional } = parseArguments(arguments_);
|
|
110
|
+
|
|
111
|
+
if (positional[0] !== 'setup') {
|
|
112
|
+
throw new Error(usage);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (positional[1] === 'rollback') {
|
|
116
|
+
if (!positional[2]) throw new Error(usage);
|
|
117
|
+
const restored = await rollbackSetup({ id: positional[2] });
|
|
118
|
+
process.stdout.write(`Restored ${String(restored.length)} configuration files.\n`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!positional[1]) throw new Error(usage);
|
|
123
|
+
const endpoint = validateEndpoint(positional[1]);
|
|
124
|
+
const allDefinitions = clientDefinitions({ project: options.project });
|
|
125
|
+
const detected = await detectClients({ project: options.project });
|
|
126
|
+
const byId = new Map([...allDefinitions, ...detected].map((client) => [client.id, client]));
|
|
127
|
+
let clients;
|
|
128
|
+
|
|
129
|
+
if (options.clientIds.length > 0) {
|
|
130
|
+
clients = [...new Set(options.clientIds)].map((id) => {
|
|
131
|
+
const client = byId.get(id);
|
|
132
|
+
if (!client) throw new Error(`Unknown client: ${String(id)}`);
|
|
133
|
+
if (options.project && client.projectUnsupported) {
|
|
134
|
+
throw new Error(`${client.label} does not support project-scoped MCP configuration.`);
|
|
135
|
+
}
|
|
136
|
+
return client;
|
|
137
|
+
});
|
|
138
|
+
} else if (options.all) {
|
|
139
|
+
clients = detected;
|
|
140
|
+
} else if (options.yes) {
|
|
141
|
+
if (detected.length === 0) {
|
|
142
|
+
throw new Error('No supported clients were detected. Pass --client explicitly.');
|
|
143
|
+
}
|
|
144
|
+
clients = detected;
|
|
145
|
+
} else {
|
|
146
|
+
assertInteractiveSelectionAllowed(process.stdin.isTTY === true);
|
|
147
|
+
clients = await selectInteractively(detected);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (clients.length === 0) {
|
|
151
|
+
throw new Error('No clients selected.');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const result = await applySetup({
|
|
155
|
+
clients,
|
|
156
|
+
dryRun: options.dryRun,
|
|
157
|
+
endpoint,
|
|
158
|
+
login: resolveLogin({
|
|
159
|
+
isInteractive: process.stdin.isTTY === true,
|
|
160
|
+
login: options.login,
|
|
161
|
+
yes: options.yes,
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (options.dryRun) {
|
|
166
|
+
process.stdout.write(
|
|
167
|
+
`Would update:\n${result.changedTargets.map((target) => `- ${target}`).join('\n')}\n`,
|
|
168
|
+
);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
process.stdout.write(
|
|
173
|
+
result.changedTargets.length === 0
|
|
174
|
+
? 'Rayrun is already configured. No files changed.\n'
|
|
175
|
+
: `Configured ${String(clients.length)} client(s).${result.backupId ? ` Roll back with: rayrun setup rollback ${result.backupId}` : ''}\n`,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
for (const client of clients) {
|
|
179
|
+
if (client.projectInstruction)
|
|
180
|
+
process.stdout.write(`${client.label}: ${client.projectInstruction}\n`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const failure of result.loginFailures) {
|
|
184
|
+
process.stderr.write(
|
|
185
|
+
`${failure.client} was configured but still needs login: ${failure.message}\n`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const skipped of result.loginSkipped) {
|
|
190
|
+
process.stdout.write(`${skipped.client}: ${skipped.message}\n`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (result.loginFailures.length > 0) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`${String(result.loginFailures.length)} client login${result.loginFailures.length === 1 ? '' : 's'} failed. Configuration was kept; run the client's MCP login command after fixing the error.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
};
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { executableExists } from './clients.js';
|
|
2
|
+
import { updateJsonConfig, updateTomlConfig } from './config.js';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
/* eslint-disable node/no-process-env -- the installer intentionally writes under the invoking user's config home */
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const checksum = (value) => createHash('sha256').update(value).digest('hex');
|
|
11
|
+
|
|
12
|
+
const assertNoSymbolicLinkComponents = async (target) => {
|
|
13
|
+
const absoluteTarget = path.resolve(target);
|
|
14
|
+
const root = path.parse(absoluteTarget).root;
|
|
15
|
+
const components = path.relative(root, absoluteTarget).split(path.sep).filter(Boolean);
|
|
16
|
+
let current = root;
|
|
17
|
+
|
|
18
|
+
for (const component of components) {
|
|
19
|
+
current = path.join(current, component);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const metadata = await lstat(current);
|
|
23
|
+
|
|
24
|
+
if (metadata.isSymbolicLink()) {
|
|
25
|
+
throw new Error(`Refusing to use symbolic-link configuration path: ${current}`);
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const readSnapshot = async (target) => {
|
|
35
|
+
await assertNoSymbolicLinkComponents(target);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const metadata = await lstat(target);
|
|
39
|
+
|
|
40
|
+
if (metadata.isSymbolicLink()) {
|
|
41
|
+
throw new Error(`Refusing to replace symbolic-link configuration: ${target}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const contents = await readFile(target);
|
|
45
|
+
return { contents, existed: true, mode: metadata.mode & 0o777, target };
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
48
|
+
return { contents: Buffer.alloc(0), existed: false, mode: 0o600, target };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const atomicWrite = async ({ contents, mode, target }) => {
|
|
56
|
+
await assertNoSymbolicLinkComponents(target);
|
|
57
|
+
await mkdir(path.dirname(target), { mode: 0o700, recursive: true });
|
|
58
|
+
await assertNoSymbolicLinkComponents(path.dirname(target));
|
|
59
|
+
const temporary = path.join(
|
|
60
|
+
path.dirname(target),
|
|
61
|
+
`.${path.basename(target)}.rayrun-${randomUUID()}`,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await writeFile(temporary, contents, { mode });
|
|
66
|
+
await rename(temporary, target);
|
|
67
|
+
await chmod(target, mode);
|
|
68
|
+
} finally {
|
|
69
|
+
await rm(temporary, { force: true });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const snapshotsEqual = (left, right) => {
|
|
74
|
+
return (
|
|
75
|
+
left.existed === right.existed &&
|
|
76
|
+
left.mode === right.mode &&
|
|
77
|
+
checksum(left.contents) === checksum(right.contents)
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const restoreSnapshots = async (mutations) => {
|
|
82
|
+
const ordered = mutations.toReversed();
|
|
83
|
+
for (const { after, before } of ordered) {
|
|
84
|
+
const current = await readSnapshot(before.target);
|
|
85
|
+
|
|
86
|
+
if (!snapshotsEqual(current, after)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Refusing to overwrite configuration changed after Rayrun setup: ${before.target}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const restored = [];
|
|
94
|
+
try {
|
|
95
|
+
for (const mutation of ordered) {
|
|
96
|
+
if (mutation.before.existed) {
|
|
97
|
+
await atomicWrite(mutation.before);
|
|
98
|
+
} else {
|
|
99
|
+
await rm(mutation.before.target, { force: true });
|
|
100
|
+
}
|
|
101
|
+
restored.push(mutation);
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
for (const mutation of restored.toReversed()) {
|
|
105
|
+
if (mutation.after.existed) {
|
|
106
|
+
await atomicWrite(mutation.after);
|
|
107
|
+
} else {
|
|
108
|
+
await rm(mutation.after.target, { force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const run = async ({ args, command, cwd }) => {
|
|
116
|
+
await new Promise((resolve, reject) => {
|
|
117
|
+
const child = spawn(command, args, { cwd, stdio: 'inherit' });
|
|
118
|
+
|
|
119
|
+
child.once('error', reject);
|
|
120
|
+
child.once('exit', (code) => {
|
|
121
|
+
if (code === 0) {
|
|
122
|
+
resolve();
|
|
123
|
+
} else {
|
|
124
|
+
reject(new Error(`${command} exited with status ${String(code)}`));
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const capture = async ({ args, command, cwd }) => {
|
|
131
|
+
return await new Promise((resolve) => {
|
|
132
|
+
const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
133
|
+
let output = '';
|
|
134
|
+
|
|
135
|
+
child.stdout.on('data', (chunk) => {
|
|
136
|
+
output += String(chunk);
|
|
137
|
+
});
|
|
138
|
+
child.once('error', () => resolve(null));
|
|
139
|
+
child.once('exit', (code) => resolve(code === 0 ? output : null));
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const outputContainsEndpoint = ({ endpoint, output }) => {
|
|
144
|
+
let candidates = [];
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(output);
|
|
148
|
+
const visit = (value) => {
|
|
149
|
+
if (!value || typeof value !== 'object') return;
|
|
150
|
+
for (const [key, child] of Object.entries(value)) {
|
|
151
|
+
if ((key === 'url' || key === 'serverUrl') && typeof child === 'string') {
|
|
152
|
+
candidates.push(child);
|
|
153
|
+
} else {
|
|
154
|
+
visit(child);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
visit(parsed);
|
|
159
|
+
} catch {
|
|
160
|
+
candidates = [
|
|
161
|
+
...output.matchAll(/^\s*(?:server\s+)?url\s*[:=]\s*(https?:\/\/\S+)\s*$/gimu),
|
|
162
|
+
].map((match) => match[1]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return candidates.some((candidate) => {
|
|
166
|
+
try {
|
|
167
|
+
return new URL(candidate.replace(/[),}\]]+$/u, '')).href === endpoint;
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const assertUnchanged = async (snapshot) => {
|
|
175
|
+
const current = await readSnapshot(snapshot.target);
|
|
176
|
+
|
|
177
|
+
if (!snapshotsEqual(current, snapshot)) {
|
|
178
|
+
throw new Error(`Configuration changed while setup was running: ${snapshot.target}`);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const backupRoot = ({ environment = process.env, homeDirectory = os.homedir() } = {}) => {
|
|
183
|
+
return path.join(
|
|
184
|
+
environment.RAYRUN_CONFIG_HOME ?? path.join(homeDirectory, '.rayrun'),
|
|
185
|
+
'backups',
|
|
186
|
+
'setup',
|
|
187
|
+
);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const writeBackup = async ({ environment, homeDirectory, snapshots }) => {
|
|
191
|
+
const id = `${new Date().toISOString().replaceAll(/[:.]/gu, '-')}-${randomUUID().slice(0, 8)}`;
|
|
192
|
+
const directory = path.join(backupRoot({ environment, homeDirectory }), id);
|
|
193
|
+
|
|
194
|
+
await mkdir(directory, { mode: 0o700, recursive: true });
|
|
195
|
+
await chmod(directory, 0o700);
|
|
196
|
+
const entries = [];
|
|
197
|
+
|
|
198
|
+
for (const [index, snapshot] of snapshots.entries()) {
|
|
199
|
+
const backupName = `${String(index)}.backup`;
|
|
200
|
+
|
|
201
|
+
if (snapshot.existed) {
|
|
202
|
+
await writeFile(path.join(directory, backupName), snapshot.contents, { mode: 0o600 });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
entries.push({
|
|
206
|
+
backupName: snapshot.existed ? backupName : null,
|
|
207
|
+
checksum: checksum(snapshot.contents),
|
|
208
|
+
existed: snapshot.existed,
|
|
209
|
+
mode: snapshot.mode,
|
|
210
|
+
installedChecksum: null,
|
|
211
|
+
installedExisted: null,
|
|
212
|
+
installedMode: null,
|
|
213
|
+
target: snapshot.target,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await writeFile(
|
|
218
|
+
path.join(directory, 'manifest.json'),
|
|
219
|
+
`${JSON.stringify({ createdAt: new Date().toISOString(), entries, id, version: 1 }, null, 2)}\n`,
|
|
220
|
+
{ mode: 0o600 },
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return { directory, entries, id };
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const completeBackup = async ({ backup, mutations }) => {
|
|
227
|
+
const afterByTarget = new Map(
|
|
228
|
+
mutations.map((mutation) => [mutation.after.target, mutation.after]),
|
|
229
|
+
);
|
|
230
|
+
const entries = backup.entries.map((entry) => {
|
|
231
|
+
const after = afterByTarget.get(entry.target);
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
...entry,
|
|
235
|
+
installedChecksum: after ? checksum(after.contents) : null,
|
|
236
|
+
installedExisted: after?.existed ?? null,
|
|
237
|
+
installedMode: after?.mode ?? null,
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await atomicWrite({
|
|
242
|
+
contents: Buffer.from(
|
|
243
|
+
`${JSON.stringify({ completedAt: new Date().toISOString(), entries, id: backup.id, version: 1 }, null, 2)}\n`,
|
|
244
|
+
),
|
|
245
|
+
mode: 0o600,
|
|
246
|
+
target: path.join(backup.directory, 'manifest.json'),
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export const rollbackSetup = async ({ environment, homeDirectory, id }) => {
|
|
251
|
+
if (!/^[\w:-]+$/u.test(id) || path.basename(id) !== id) {
|
|
252
|
+
throw new Error('Invalid backup id.');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const directory = path.join(backupRoot({ environment, homeDirectory }), id);
|
|
256
|
+
const manifest = JSON.parse(await readFile(path.join(directory, 'manifest.json'), 'utf8'));
|
|
257
|
+
if (
|
|
258
|
+
!manifest ||
|
|
259
|
+
typeof manifest !== 'object' ||
|
|
260
|
+
manifest.version !== 1 ||
|
|
261
|
+
manifest.id !== id ||
|
|
262
|
+
typeof manifest.completedAt !== 'string' ||
|
|
263
|
+
!Array.isArray(manifest.entries)
|
|
264
|
+
) {
|
|
265
|
+
throw new Error('Backup manifest is incomplete or unsupported.');
|
|
266
|
+
}
|
|
267
|
+
const snapshots = [];
|
|
268
|
+
const mutations = [];
|
|
269
|
+
|
|
270
|
+
for (const entry of manifest.entries) {
|
|
271
|
+
if (
|
|
272
|
+
!entry ||
|
|
273
|
+
typeof entry !== 'object' ||
|
|
274
|
+
typeof entry.target !== 'string' ||
|
|
275
|
+
!path.isAbsolute(entry.target) ||
|
|
276
|
+
entry.target === path.parse(entry.target).root ||
|
|
277
|
+
typeof entry.existed !== 'boolean' ||
|
|
278
|
+
!Number.isInteger(entry.mode) ||
|
|
279
|
+
entry.mode < 0 ||
|
|
280
|
+
entry.mode > 0o777 ||
|
|
281
|
+
(entry.backupName !== null &&
|
|
282
|
+
(typeof entry.backupName !== 'string' ||
|
|
283
|
+
path.basename(entry.backupName) !== entry.backupName ||
|
|
284
|
+
!/^\d+\.backup$/u.test(entry.backupName))) ||
|
|
285
|
+
typeof entry.checksum !== 'string' ||
|
|
286
|
+
typeof entry.installedExisted !== 'boolean' ||
|
|
287
|
+
!Number.isInteger(entry.installedMode) ||
|
|
288
|
+
entry.installedMode < 0 ||
|
|
289
|
+
entry.installedMode > 0o777 ||
|
|
290
|
+
typeof entry.installedChecksum !== 'string'
|
|
291
|
+
) {
|
|
292
|
+
throw new Error('Backup manifest contains an invalid entry.');
|
|
293
|
+
}
|
|
294
|
+
const contents = entry.backupName
|
|
295
|
+
? await readFile(path.join(directory, entry.backupName))
|
|
296
|
+
: Buffer.alloc(0);
|
|
297
|
+
|
|
298
|
+
if (checksum(contents) !== entry.checksum) {
|
|
299
|
+
throw new Error(`Backup checksum failed for ${String(entry.target)}.`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const before = { contents, existed: entry.existed, mode: entry.mode, target: entry.target };
|
|
303
|
+
const current = await readSnapshot(entry.target);
|
|
304
|
+
const installed = {
|
|
305
|
+
contents: current.contents,
|
|
306
|
+
existed: entry.installedExisted,
|
|
307
|
+
mode: entry.installedMode,
|
|
308
|
+
target: entry.target,
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
if (
|
|
312
|
+
current.existed !== entry.installedExisted ||
|
|
313
|
+
current.mode !== entry.installedMode ||
|
|
314
|
+
checksum(current.contents) !== entry.installedChecksum
|
|
315
|
+
) {
|
|
316
|
+
throw new Error(`Refusing to overwrite configuration changed after setup: ${entry.target}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
snapshots.push(before);
|
|
320
|
+
mutations.push({ after: installed, before });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await restoreSnapshots(mutations);
|
|
324
|
+
return snapshots.map((snapshot) => snapshot.target);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
export const applySetup = async ({
|
|
328
|
+
clients,
|
|
329
|
+
dryRun = false,
|
|
330
|
+
endpoint,
|
|
331
|
+
environment = process.env,
|
|
332
|
+
homeDirectory = os.homedir(),
|
|
333
|
+
login = true,
|
|
334
|
+
}) => {
|
|
335
|
+
const targets = [
|
|
336
|
+
...new Set(clients.map((client) => (client.type === 'native' ? client.target : client.path))),
|
|
337
|
+
];
|
|
338
|
+
const snapshots = await Promise.all(targets.map(readSnapshot));
|
|
339
|
+
const directWrites = [];
|
|
340
|
+
|
|
341
|
+
for (const client of clients) {
|
|
342
|
+
if (client.type !== 'json' && client.type !== 'toml') {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const snapshot = snapshots.find((candidate) => candidate.target === client.path);
|
|
347
|
+
const source = snapshot?.contents.toString('utf8') ?? '';
|
|
348
|
+
const updated =
|
|
349
|
+
client.type === 'json'
|
|
350
|
+
? updateJsonConfig({
|
|
351
|
+
propertyPath: client.propertyPath,
|
|
352
|
+
source,
|
|
353
|
+
value: client.value(endpoint),
|
|
354
|
+
})
|
|
355
|
+
: updateTomlConfig({ section: client.section, source, value: client.value(endpoint) });
|
|
356
|
+
const contents = Buffer.from(`${updated.trimEnd()}\n`);
|
|
357
|
+
|
|
358
|
+
if (!snapshot || !contents.equals(snapshot.contents)) {
|
|
359
|
+
directWrites.push({ contents, mode: snapshot?.mode ?? 0o600, target: client.path });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const nativeClients = [];
|
|
364
|
+
|
|
365
|
+
for (const client of clients.filter((candidate) => candidate.type === 'native')) {
|
|
366
|
+
const current = client.getArgs
|
|
367
|
+
? await capture({ args: client.getArgs, command: client.command, cwd: client.cwd })
|
|
368
|
+
: null;
|
|
369
|
+
|
|
370
|
+
if (current === null || !outputContainsEndpoint({ endpoint, output: current })) {
|
|
371
|
+
nativeClients.push({ ...client, replaceExisting: current !== null });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const changedTargets = new Set(directWrites.map((write) => write.target));
|
|
375
|
+
|
|
376
|
+
for (const client of nativeClients) {
|
|
377
|
+
changedTargets.add(client.target);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (dryRun) {
|
|
381
|
+
return { backupId: null, changedTargets: [...changedTargets], loginFailures: [] };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const changedSnapshots = snapshots.filter((snapshot) => changedTargets.has(snapshot.target));
|
|
385
|
+
const backup =
|
|
386
|
+
changedSnapshots.length === 0
|
|
387
|
+
? null
|
|
388
|
+
: await writeBackup({ environment, homeDirectory, snapshots: changedSnapshots });
|
|
389
|
+
const mutations = [];
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
for (const client of nativeClients) {
|
|
393
|
+
const snapshot = snapshots.find((candidate) => candidate.target === client.target);
|
|
394
|
+
if (snapshot) await assertUnchanged(snapshot);
|
|
395
|
+
try {
|
|
396
|
+
if (client.replaceExisting && client.removeArgs) {
|
|
397
|
+
await run({ args: client.removeArgs, command: client.command, cwd: client.cwd });
|
|
398
|
+
}
|
|
399
|
+
await run({ args: client.nativeArgs(endpoint), command: client.command, cwd: client.cwd });
|
|
400
|
+
} finally {
|
|
401
|
+
if (snapshot) {
|
|
402
|
+
const after = await readSnapshot(snapshot.target);
|
|
403
|
+
if (!snapshotsEqual(snapshot, after)) mutations.push({ after, before: snapshot });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
for (const write of directWrites) {
|
|
409
|
+
const snapshot = snapshots.find((candidate) => candidate.target === write.target);
|
|
410
|
+
if (snapshot) await assertUnchanged(snapshot);
|
|
411
|
+
try {
|
|
412
|
+
await atomicWrite(write);
|
|
413
|
+
} finally {
|
|
414
|
+
if (snapshot) {
|
|
415
|
+
const after = await readSnapshot(write.target);
|
|
416
|
+
if (!snapshotsEqual(snapshot, after)) mutations.push({ after, before: snapshot });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (backup) {
|
|
422
|
+
await completeBackup({ backup, mutations });
|
|
423
|
+
}
|
|
424
|
+
} catch (error) {
|
|
425
|
+
await restoreSnapshots(mutations);
|
|
426
|
+
throw new Error(
|
|
427
|
+
`Setup failed and every changed configuration was restored${backup ? ` from ${backup.id}` : ''}: ${error instanceof Error ? error.message : String(error)}`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const loginFailures = [];
|
|
432
|
+
const loginSkipped = login
|
|
433
|
+
? []
|
|
434
|
+
: clients
|
|
435
|
+
.filter((candidate) => candidate.loginCommand)
|
|
436
|
+
.map((client) => ({
|
|
437
|
+
client: client.label,
|
|
438
|
+
message: `Run ${client.loginCommand.command} ${client.loginCommand.args.join(' ')} to authenticate.`,
|
|
439
|
+
}));
|
|
440
|
+
|
|
441
|
+
if (login) {
|
|
442
|
+
for (const client of clients.filter((candidate) => candidate.loginCommand)) {
|
|
443
|
+
if (!(await executableExists(client.loginCommand.command, environment))) {
|
|
444
|
+
loginSkipped.push({
|
|
445
|
+
client: client.label,
|
|
446
|
+
message: `${client.loginCommand.command} is not installed; authenticate Rayrun from the client's MCP settings.`,
|
|
447
|
+
});
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
await run(client.loginCommand);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
loginFailures.push({
|
|
454
|
+
client: client.label,
|
|
455
|
+
message: error instanceof Error ? error.message : String(error),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
backupId: backup?.id ?? null,
|
|
463
|
+
changedTargets: [...changedTargets],
|
|
464
|
+
loginFailures,
|
|
465
|
+
loginSkipped,
|
|
466
|
+
};
|
|
467
|
+
};
|