@warmio/mcp 9.1.1 → 11.0.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/dist/install.js CHANGED
@@ -1,533 +1,303 @@
1
- import { createInterface } from 'readline';
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
- import { dirname, join, resolve } from 'path';
4
- import { homedir, platform } from 'os';
5
- import { WARM_MCP_CLIENTS, WARM_MCP_CREDENTIALS, WARM_MCP_PROJECT_CONFIGS, WARM_MCP_SERVER_CONFIGS, } from '@warmio/contracts/mcp';
6
- import { verifyWarmApiKey, WarmApiError } from './server.js';
7
- import { getWarmApiKeyPath, readConfigFile } from './config-paths.js';
8
- const HOME = homedir();
9
- const CWD = process.cwd();
1
+ import { randomUUID } from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { createInterface } from 'node:readline';
6
+ import { API_ORIGIN, WARM_API_KEY_PREFIX } from '@warmio/contracts/api';
7
+ import { WARM_MCP_CLIENTS, WARM_MCP_PROJECT_CONFIGS, WARM_MCP_SERVER_CONFIG, } from '@warmio/contracts/mcp';
8
+ import { getWarmApiKeyPath } from './config-paths.js';
10
9
  function isRecord(value) {
11
10
  return typeof value === 'object' && value !== null && !Array.isArray(value);
12
11
  }
13
- function resolveClientConfigPath(configPath) {
14
- const root = configPath.root === 'appData' ? process.env.APPDATA || join(HOME, 'AppData', 'Roaming') : HOME;
15
- return join(root, ...configPath.segments);
16
- }
17
- function getClientConfigPath(clientId) {
18
- const client = WARM_MCP_CLIENTS.find((candidate) => candidate.id === clientId);
19
- if (!client) {
20
- throw new Error(`Unsupported MCP client: ${clientId}`);
21
- }
22
- const currentPlatform = platform();
23
- const configPath = currentPlatform === 'darwin'
24
- ? client.configPaths.darwin
25
- : currentPlatform === 'win32'
26
- ? client.configPaths.win32
27
- : client.configPaths.linux;
28
- return resolveClientConfigPath(configPath);
29
- }
30
- const GLOBAL_CLIENTS = WARM_MCP_CLIENTS.map((client) => ({
31
- name: client.name,
32
- format: client.format,
33
- configPath: getClientConfigPath(client.id),
34
- alwaysInclude: 'alwaysInclude' in client && client.alwaysInclude,
35
- }));
36
- function getServerName(mode) {
37
- return WARM_MCP_SERVER_CONFIGS[mode].serverName;
38
- }
39
- function getCredential(mode) {
40
- return WARM_MCP_CREDENTIALS[mode];
41
- }
42
- function getOppositeCredential(mode) {
43
- return WARM_MCP_CREDENTIALS[mode === 'automation' ? 'context' : 'automation'];
44
- }
45
- function getMcpConfig(mode) {
46
- const serverConfig = WARM_MCP_SERVER_CONFIGS[mode];
47
- return platform() === 'win32'
48
- ? { command: 'cmd', args: ['/c', serverConfig.command, ...serverConfig.args] }
49
- : { command: serverConfig.command, args: [...serverConfig.args] };
50
- }
51
- function formatTomlStringArray(values) {
52
- return `[${values.map((value) => JSON.stringify(value)).join(', ')}]`;
53
- }
54
- function detectProjectClients() {
55
- const found = [];
56
- for (const name of WARM_MCP_PROJECT_CONFIGS) {
57
- const configPath = resolve(CWD, name);
58
- if (existsSync(configPath)) {
59
- found.push({
60
- name: `Project (${name})`,
61
- configPath,
62
- format: 'json',
63
- isProjectLevel: true,
64
- });
65
- }
66
- }
67
- return found;
68
- }
69
- function isDetected(client) {
70
- if (client.alwaysInclude) {
12
+ const API_KEY_PATTERN = new RegExp(`^${WARM_API_KEY_PREFIX}[A-Za-z0-9_-]{43}$`);
13
+ function configPath(config, homeDir) {
14
+ const root = config.root === 'appData'
15
+ ? process.env.APPDATA?.trim() || path.join(homeDir, 'AppData', 'Roaming')
16
+ : homeDir;
17
+ return path.join(root, ...config.segments);
18
+ }
19
+ function supportedClients(homeDir, workingDirectory) {
20
+ const platform = process.platform === 'win32' ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux';
21
+ const globalClients = WARM_MCP_CLIENTS.map((client) => ({
22
+ alwaysInclude: 'alwaysInclude' in client && client.alwaysInclude,
23
+ configDialect: client.configDialect,
24
+ configPath: configPath(client.configPaths[platform], homeDir),
25
+ format: client.format,
26
+ name: client.name,
27
+ })).filter((client) => client.alwaysInclude || fs.existsSync(path.dirname(client.configPath)));
28
+ const projectClients = WARM_MCP_PROJECT_CONFIGS.map((relativePath) => path.resolve(workingDirectory, relativePath))
29
+ .filter((candidate) => fs.existsSync(candidate))
30
+ .map((candidate) => ({
31
+ configPath: candidate,
32
+ configDialect: 'mcpServers',
33
+ format: 'json',
34
+ name: `Project (${path.relative(workingDirectory, candidate)})`,
35
+ }));
36
+ const configPaths = new Set();
37
+ return [...globalClients, ...projectClients].filter((client) => {
38
+ if (configPaths.has(client.configPath))
39
+ return false;
40
+ configPaths.add(client.configPath);
71
41
  return true;
72
- }
73
- return existsSync(dirname(client.configPath));
74
- }
75
- function getStringArray(value) {
76
- if (!Array.isArray(value)) {
77
- return [];
78
- }
79
- return value.filter((entry) => typeof entry === 'string');
80
- }
81
- function isWarmPackageSpecifier(value) {
82
- return /^@warmio\/mcp(?:@.+)?$/.test(value);
42
+ });
83
43
  }
84
- function isSupportedWarmInvocation(command, args, mode) {
85
- if (command !== 'npx' && command !== 'cmd') {
44
+ function isCurrentWarmConfig(client, server) {
45
+ if (!isRecord(server))
86
46
  return false;
47
+ if (client.configDialect === 'openCode') {
48
+ return (server.type === 'local' &&
49
+ Array.isArray(server.command) &&
50
+ server.command.length === WARM_MCP_SERVER_CONFIG.args.length + 1 &&
51
+ server.command[0] === WARM_MCP_SERVER_CONFIG.command &&
52
+ server.command.slice(1).every((argument, index) => argument === WARM_MCP_SERVER_CONFIG.args[index]) &&
53
+ Object.keys(server).length === 2);
54
+ }
55
+ return (server.command === WARM_MCP_SERVER_CONFIG.command &&
56
+ Array.isArray(server.args) &&
57
+ server.args.length === WARM_MCP_SERVER_CONFIG.args.length &&
58
+ server.args.every((argument, index) => argument === WARM_MCP_SERVER_CONFIG.args[index]) &&
59
+ Object.keys(server).length === 2);
60
+ }
61
+ function parseJsonConfig(client, content) {
62
+ if (!content)
63
+ return {};
64
+ const parsed = JSON.parse(content);
65
+ if (!isRecord(parsed))
66
+ throw new Error(`${client.name} config is not a JSON object.`);
67
+ return parsed;
68
+ }
69
+ function readJsonConfig(client) {
70
+ return parseJsonConfig(client, fs.existsSync(client.configPath) ? fs.readFileSync(client.configPath, 'utf8') : '');
71
+ }
72
+ const WARM_TOML_BLOCK = /\n?\[mcp_servers\.warm\][\s\S]*?(?=\n\[[^\n]+\]|\s*$)/g;
73
+ const WARM_TOML_ENV_BLOCK = /\n?\[mcp_servers\.warm\.env\][\s\S]*?(?=\n\[[^\n]+\]|\s*$)/g;
74
+ function existingWarmConfig(client) {
75
+ if (client.format === 'toml') {
76
+ const content = fs.existsSync(client.configPath)
77
+ ? fs.readFileSync(client.configPath, 'utf8')
78
+ : '';
79
+ const section = content.match(WARM_TOML_BLOCK)?.[0];
80
+ return {
81
+ current: Boolean(section) &&
82
+ section.trim() ===
83
+ [
84
+ '[mcp_servers.warm]',
85
+ `command = "${WARM_MCP_SERVER_CONFIG.command}"`,
86
+ `args = ${JSON.stringify(WARM_MCP_SERVER_CONFIG.args)}`,
87
+ ].join('\n'),
88
+ exists: Boolean(section),
89
+ };
87
90
  }
88
- return (args.some((value) => isWarmPackageSpecifier(value)) &&
89
- args.includes('mcp') &&
90
- args.includes('--mode') &&
91
- args.includes(mode));
92
- }
93
- function getJsonEnvStatus(server, mode) {
94
- const env = isRecord(server?.env) ? server.env : undefined;
95
- const credential = getCredential(mode);
96
- const oppositeCredential = getOppositeCredential(mode);
97
- const apiKeyName = credential.apiKeyEnv;
98
- const apiKeyFileName = credential.apiKeyFileEnv;
99
- const inlineApiKey = typeof env?.[apiKeyName] === 'string' && env[apiKeyName].trim() ? env[apiKeyName].trim() : null;
91
+ const config = readJsonConfig(client);
92
+ const servers = client.configDialect === 'openCode'
93
+ ? isRecord(config.mcp)
94
+ ? config.mcp
95
+ : {}
96
+ : isRecord(config.mcpServers)
97
+ ? config.mcpServers
98
+ : {};
100
99
  return {
101
- apiKeyFileOverride: typeof env?.[apiKeyFileName] === 'string' && env[apiKeyFileName].trim().length > 0
102
- ? env[apiKeyFileName].trim()
103
- : null,
104
- hasIrrelevantCredential: [
105
- 'WARM_API_KEY',
106
- 'WARM_API_KEY_FILE',
107
- oppositeCredential.apiKeyEnv,
108
- oppositeCredential.apiKeyFileEnv,
109
- ].some((name) => typeof env?.[name] === 'string' && env[name].trim().length > 0),
110
- inlineApiKey,
100
+ current: isCurrentWarmConfig(client, servers.warm),
101
+ exists: 'warm' in servers,
111
102
  };
112
103
  }
113
- function resolveApiKeyFileOverride(filePath) {
114
- if (filePath === '~') {
115
- return HOME;
116
- }
117
- if (filePath.startsWith('~/')) {
118
- return join(HOME, filePath.slice(2));
119
- }
120
- return resolve(filePath);
121
- }
122
- function createClientStatus(client, invocationState, envStatus, mode) {
123
- const credentialApiKey = envStatus.inlineApiKey ||
124
- (envStatus.apiKeyFileOverride
125
- ? readConfigFile(resolveApiKeyFileOverride(envStatus.apiKeyFileOverride))
126
- : readConfigFile(getWarmApiKeyPath(mode)));
127
- const state = invocationState === 'configured' && credentialApiKey && !envStatus.hasIrrelevantCredential
128
- ? 'configured'
129
- : invocationState === 'missing'
130
- ? 'missing'
131
- : 'needs_setup';
132
- return { client, credentialApiKey, state, ...envStatus };
133
- }
134
- function getJsonStatus(client, content, mode) {
104
+ function prepareJsonConfig(client, current) {
105
+ const config = parseJsonConfig(client, current);
106
+ if (client.configDialect === 'openCode') {
107
+ const servers = isRecord(config.mcp) ? config.mcp : {};
108
+ servers.warm = {
109
+ type: 'local',
110
+ command: [WARM_MCP_SERVER_CONFIG.command, ...WARM_MCP_SERVER_CONFIG.args],
111
+ };
112
+ config.mcp = servers;
113
+ delete config.mcpServers;
114
+ return `${JSON.stringify(config, null, 2)}\n`;
115
+ }
116
+ const servers = isRecord(config.mcpServers) ? config.mcpServers : {};
117
+ servers.warm = {
118
+ command: WARM_MCP_SERVER_CONFIG.command,
119
+ args: [...WARM_MCP_SERVER_CONFIG.args],
120
+ };
121
+ config.mcpServers = servers;
122
+ return `${JSON.stringify(config, null, 2)}\n`;
123
+ }
124
+ function prepareTomlConfig(current) {
125
+ const remaining = current.replace(WARM_TOML_ENV_BLOCK, '').replace(WARM_TOML_BLOCK, '').trimEnd();
126
+ const next = [
127
+ remaining,
128
+ '[mcp_servers.warm]',
129
+ `command = "${WARM_MCP_SERVER_CONFIG.command}"`,
130
+ `args = ${JSON.stringify(WARM_MCP_SERVER_CONFIG.args)}`,
131
+ ]
132
+ .filter(Boolean)
133
+ .join('\n\n');
134
+ return `${next}\n`;
135
+ }
136
+ function snapshotFile(target) {
135
137
  try {
136
- const parsed = JSON.parse(content);
137
- const servers = isRecord(parsed.mcpServers) ? parsed.mcpServers : undefined;
138
- const candidate = servers?.[getServerName(mode)];
139
- const server = isRecord(candidate) ? candidate : undefined;
140
- const envStatus = getJsonEnvStatus(server, mode);
141
- if (!server) {
142
- return createClientStatus(client, 'missing', envStatus, mode);
143
- }
144
- const command = typeof server.command === 'string' ? server.command : undefined;
145
- const args = getStringArray(server.args);
146
- if (isSupportedWarmInvocation(command, args, mode)) {
147
- return createClientStatus(client, 'configured', envStatus, mode);
148
- }
149
- return createClientStatus(client, 'needs_setup', envStatus, mode);
150
- }
151
- catch {
152
- return createClientStatus(client, 'missing', { apiKeyFileOverride: null, hasIrrelevantCredential: false, inlineApiKey: null }, mode);
138
+ const stat = fs.statSync(target);
139
+ return {
140
+ content: fs.readFileSync(target, 'utf8'),
141
+ exists: true,
142
+ mode: stat.mode & 0o777,
143
+ };
153
144
  }
154
- }
155
- function escapeRegExp(value) {
156
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
157
- }
158
- function getTomlSection(content, sectionName) {
159
- const pattern = new RegExp(`(?:^|\\r?\\n)\\[${escapeRegExp(sectionName)}\\]\\r?\\n([\\s\\S]*?)(?=\\r?\\n\\[[^\\r\\n]+\\]|\\s*$)`);
160
- const match = content.match(pattern);
161
- return match ? match[0].replace(/^\r?\n/, '') : null;
162
- }
163
- function getTomlStringValue(block, key) {
164
- if (!block) {
165
- return undefined;
145
+ catch (error) {
146
+ if (error.code !== 'ENOENT')
147
+ throw error;
148
+ return { content: '', exists: false, mode: 0o666 };
166
149
  }
167
- const pattern = new RegExp(`^${escapeRegExp(key)}\\s*=\\s*(?:"([^"]*)"|'([^']*)')\\s*$`, 'm');
168
- const match = block.match(pattern);
169
- return match?.[1] ?? match?.[2];
170
150
  }
171
- function getTomlStringArrayValue(block, key) {
172
- if (!block) {
173
- return [];
174
- }
175
- const pattern = new RegExp(`^${escapeRegExp(key)}\\s*=\\s*\\[([\\s\\S]*?)\\]\\s*$`, 'm');
176
- const match = block.match(pattern);
177
- if (!match) {
178
- return [];
179
- }
180
- const values = [];
181
- const valuePattern = /"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'/g;
182
- for (const item of match[1].matchAll(valuePattern)) {
183
- values.push(item[1] ?? item[2] ?? '');
184
- }
185
- return values;
151
+ function prepareClientFile(client) {
152
+ const snapshot = snapshotFile(client.configPath);
153
+ return {
154
+ content: client.format === 'toml'
155
+ ? prepareTomlConfig(snapshot.content)
156
+ : prepareJsonConfig(client, snapshot.content),
157
+ mode: snapshot.mode,
158
+ snapshot,
159
+ target: client.configPath,
160
+ };
186
161
  }
187
- function getTomlEnvStatus(content, mode) {
188
- const envBlock = getTomlSection(content, `mcp_servers.${getServerName(mode)}.env`);
189
- if (!envBlock) {
190
- return {
191
- apiKeyFileOverride: null,
192
- envLines: [],
193
- hasIrrelevantCredential: false,
194
- inlineApiKey: null,
195
- };
196
- }
197
- const envLines = envBlock
198
- .split(/\r?\n/)
199
- .slice(1)
200
- .filter((line) => line.trim().length > 0);
201
- const credential = getCredential(mode);
202
- const oppositeCredential = getOppositeCredential(mode);
203
- const apiKeyName = credential.apiKeyEnv;
204
- const inlineApiKey = getTomlStringValue(envBlock, apiKeyName)?.trim() || null;
205
- const apiKeyFile = getTomlStringValue(envBlock, credential.apiKeyFileEnv);
162
+ function prepareCredentialFile(target, apiKey) {
206
163
  return {
207
- apiKeyFileOverride: typeof apiKeyFile === 'string' && apiKeyFile.trim() ? apiKeyFile.trim() : null,
208
- envLines,
209
- hasIrrelevantCredential: [
210
- 'WARM_API_KEY',
211
- 'WARM_API_KEY_FILE',
212
- oppositeCredential.apiKeyEnv,
213
- oppositeCredential.apiKeyFileEnv,
214
- ].some((name) => Boolean(getTomlStringValue(envBlock, name)?.trim())),
215
- inlineApiKey,
164
+ content: `${apiKey}\n`,
165
+ mode: 0o600,
166
+ snapshot: snapshotFile(target),
167
+ target,
216
168
  };
217
169
  }
218
- function getTomlStatus(client, content, mode) {
219
- const warmBlock = getTomlSection(content, `mcp_servers.${getServerName(mode)}`);
220
- const envStatus = getTomlEnvStatus(content, mode);
221
- if (!warmBlock) {
222
- return createClientStatus(client, 'missing', envStatus, mode);
223
- }
224
- const command = getTomlStringValue(warmBlock, 'command');
225
- const args = getTomlStringArrayValue(warmBlock, 'args');
226
- if (isSupportedWarmInvocation(command, args, mode)) {
227
- return createClientStatus(client, 'configured', envStatus, mode);
170
+ function atomicReplaceFile(target, content, mode) {
171
+ const directory = path.dirname(target);
172
+ const temporaryPath = path.join(directory, `.${path.basename(target)}.${randomUUID()}.tmp`);
173
+ let descriptor;
174
+ try {
175
+ fs.mkdirSync(directory, { recursive: true });
176
+ descriptor = fs.openSync(temporaryPath, 'wx', mode);
177
+ fs.writeFileSync(descriptor, content, 'utf8');
178
+ fs.fsyncSync(descriptor);
179
+ fs.closeSync(descriptor);
180
+ descriptor = undefined;
181
+ fs.renameSync(temporaryPath, target);
182
+ }
183
+ finally {
184
+ if (descriptor !== undefined)
185
+ fs.closeSync(descriptor);
186
+ if (fs.existsSync(temporaryPath))
187
+ fs.unlinkSync(temporaryPath);
188
+ }
189
+ }
190
+ function restorePreparedFile(prepared, replaceFile) {
191
+ if (prepared.snapshot.exists) {
192
+ replaceFile(prepared.target, prepared.snapshot.content, prepared.snapshot.mode);
193
+ return;
228
194
  }
229
- return createClientStatus(client, 'needs_setup', envStatus, mode);
195
+ fs.rmSync(prepared.target, { force: true });
230
196
  }
231
- function getClientStatus(client, mode) {
232
- if (!existsSync(client.configPath)) {
233
- return createClientStatus(client, 'missing', { apiKeyFileOverride: null, hasIrrelevantCredential: false, inlineApiKey: null }, mode);
234
- }
235
- const content = readFileSync(client.configPath, 'utf-8');
236
- if (client.format === 'toml') {
237
- return getTomlStatus(client, content, mode);
197
+ function commitPreparedFiles(files, replaceFile) {
198
+ const replaced = [];
199
+ try {
200
+ for (const file of files) {
201
+ replaceFile(file.target, file.content, file.mode);
202
+ replaced.push(file);
203
+ }
238
204
  }
239
- return getJsonStatus(client, content, mode);
240
- }
241
- function configureJson(client, mode, preserveSelectedKeyFile) {
242
- let config = {};
243
- if (existsSync(client.configPath)) {
244
- const content = readFileSync(client.configPath, 'utf-8');
245
- let parsed;
205
+ catch (error) {
246
206
  try {
247
- parsed = JSON.parse(content);
207
+ for (const file of [...replaced].reverse())
208
+ restorePreparedFile(file, replaceFile);
248
209
  }
249
- catch (error) {
250
- throw new Error(`Refusing to overwrite unparsable JSON config ${client.configPath}: ${error instanceof Error ? error.message : String(error)}`);
210
+ catch {
211
+ throw new Error('Warm MCP installation failed and rollback could not complete.');
251
212
  }
252
- if (!isRecord(parsed)) {
253
- throw new Error(`Refusing to overwrite JSON config ${client.configPath}: root must be an object.`);
254
- }
255
- config = parsed;
256
- }
257
- if (config.mcpServers === undefined) {
258
- config.mcpServers = {};
259
- }
260
- const servers = config.mcpServers;
261
- if (!isRecord(servers)) {
262
- throw new Error(`Refusing to overwrite JSON config ${client.configPath}: mcpServers must be an object.`);
213
+ throw error;
263
214
  }
264
- const serverName = getServerName(mode);
265
- const existing = isRecord(servers[serverName]) ? servers[serverName] : undefined;
266
- const existingEnv = isRecord(existing?.env) ? existing.env : {};
267
- const nextEnv = { ...existingEnv };
268
- const existingCommand = typeof existing?.command === 'string' ? existing.command : undefined;
269
- const existingArgs = getStringArray(existing?.args);
270
- const preserveProjectCommand = client.isProjectLevel &&
271
- !!existingCommand &&
272
- isSupportedWarmInvocation(existingCommand, existingArgs, mode);
273
- const credential = getCredential(mode);
274
- const oppositeCredential = getOppositeCredential(mode);
275
- const selectedKeyFile = credential.apiKeyFileEnv;
276
- for (const name of [
277
- 'WARM_API_KEY',
278
- 'WARM_API_KEY_FILE',
279
- credential.apiKeyEnv,
280
- oppositeCredential.apiKeyEnv,
281
- oppositeCredential.apiKeyFileEnv,
282
- ]) {
283
- delete nextEnv[name];
284
- }
285
- if (!preserveSelectedKeyFile) {
286
- delete nextEnv[selectedKeyFile];
287
- }
288
- if (preserveProjectCommand) {
289
- servers[serverName] = {
290
- ...existing,
291
- env: Object.keys(nextEnv).length > 0 ? nextEnv : undefined,
292
- };
293
- }
294
- else {
295
- servers[serverName] = {
296
- ...existing,
297
- ...getMcpConfig(mode),
298
- env: Object.keys(nextEnv).length > 0 ? nextEnv : undefined,
299
- };
300
- }
301
- mkdirSync(dirname(client.configPath), { recursive: true });
302
- writeFileSync(client.configPath, JSON.stringify(config, null, 2) + '\n');
303
215
  }
304
- function configureToml(client, mode, preserveSelectedKeyFile) {
305
- let content = '';
306
- let lineEnding = '\n';
307
- if (existsSync(client.configPath)) {
308
- content = readFileSync(client.configPath, 'utf-8');
309
- lineEnding = content.includes('\r\n') ? '\r\n' : '\n';
310
- if (!content.endsWith('\n')) {
311
- content += lineEnding;
312
- }
313
- }
314
- const serverName = getServerName(mode);
315
- const serverConfig = WARM_MCP_SERVER_CONFIGS[mode];
316
- const tomlCommand = platform() === 'win32' ? 'cmd' : serverConfig.command;
317
- const posixArgs = formatTomlStringArray(serverConfig.args);
318
- const windowsArgs = formatTomlStringArray(['/c', serverConfig.command, ...serverConfig.args]);
319
- const tomlArgs = platform() === 'win32' ? windowsArgs : posixArgs;
320
- const warmBlock = `[mcp_servers.${serverName}]${lineEnding}command = "${tomlCommand}"${lineEnding}args = ${tomlArgs}${lineEnding}`;
321
- const credential = getCredential(mode);
322
- const oppositeCredential = getOppositeCredential(mode);
323
- const selectedKeyFile = credential.apiKeyFileEnv;
324
- const removedEnvNames = new Set([
325
- 'WARM_API_KEY',
326
- 'WARM_API_KEY_FILE',
327
- credential.apiKeyEnv,
328
- oppositeCredential.apiKeyEnv,
329
- oppositeCredential.apiKeyFileEnv,
330
- ...(!preserveSelectedKeyFile ? [selectedKeyFile] : []),
331
- ]);
332
- const preservedEnvLines = getTomlEnvStatus(content, mode).envLines.filter((line) => {
333
- const name = line.match(/^\s*([A-Z0-9_]+)\s*=/)?.[1];
334
- return !name || !removedEnvNames.has(name);
216
+ function readlinePrompt(question) {
217
+ return new Promise((resolve) => {
218
+ const readline = createInterface({
219
+ input: process.stdin,
220
+ output: process.stdout,
221
+ });
222
+ readline.question(question, (answer) => {
223
+ readline.close();
224
+ resolve(answer.trim());
225
+ });
335
226
  });
336
- const escapedServerName = escapeRegExp(serverName);
337
- const tomlBlockPattern = new RegExp(`(?:\\r?\\n)?\\[mcp_servers\\.${escapedServerName}\\]\\r?\\n[\\s\\S]*?(?=\\r?\\n\\[[^\\r\\n]+\\]|\\s*$)`, 'g');
338
- const tomlEnvBlockPattern = new RegExp(`(?:\\r?\\n)?\\[mcp_servers\\.${escapedServerName}\\.env\\]\\r?\\n[\\s\\S]*?(?=\\r?\\n\\[[^\\r\\n]+\\]|\\s*$)`, 'g');
339
- let nextContent = content
340
- .replace(tomlEnvBlockPattern, '')
341
- .replace(tomlBlockPattern, '')
342
- .trimEnd();
343
- if (nextContent.length > 0) {
344
- nextContent += `${lineEnding}${lineEnding}`;
345
- }
346
- nextContent += warmBlock;
347
- if (preservedEnvLines.length > 0) {
348
- nextContent += `${lineEnding}[mcp_servers.${serverName}.env]${lineEnding}${preservedEnvLines.join(lineEnding)}${lineEnding}`;
349
- }
350
- mkdirSync(dirname(client.configPath), { recursive: true });
351
- writeFileSync(client.configPath, nextContent.endsWith('\n') ? nextContent : `${nextContent}${lineEnding}`);
352
227
  }
353
- function configure(client, mode, preserveSelectedKeyFile) {
354
- if (client.format === 'json') {
355
- configureJson(client, mode, preserveSelectedKeyFile);
356
- return;
228
+ async function validateApiKey(apiKey, options) {
229
+ if (!API_KEY_PATTERN.test(apiKey)) {
230
+ throw new Error('Warm API key validation failed. Check Warm Settings and try again.');
357
231
  }
358
- configureToml(client, mode, preserveSelectedKeyFile);
359
- }
360
- function shortPath(filePath) {
361
- return filePath.replace(HOME, '~').replace(CWD, '.');
362
- }
363
- function prompt(question) {
364
- return new Promise((resolvePrompt) => {
365
- const rl = createInterface({ input: process.stdin, output: process.stdout });
366
- rl.question(question, (answer) => {
367
- rl.close();
368
- resolvePrompt(answer.trim());
369
- });
232
+ const response = await (options.fetchImplementation || fetch)(new URL('/api/verify', options.apiUrl || process.env.WARM_API_URL || API_ORIGIN), {
233
+ headers: {
234
+ Accept: 'application/json',
235
+ Authorization: `Bearer ${apiKey}`,
236
+ },
370
237
  });
371
- }
372
- async function validateApiKey(apiKey, mode) {
373
- console.log(' Validating API key...');
238
+ const text = await response.text();
239
+ let body = null;
374
240
  try {
375
- const result = await verifyWarmApiKey(apiKey, mode);
376
- if (!result.valid) {
377
- console.log(` Validation failed: ${result.status}`);
378
- console.log(' Check https://warm.io/settings and try again.');
379
- console.log('');
380
- return false;
381
- }
382
- if (result.audience !== mode) {
383
- console.log(` Validation failed: this installer mode requires a ${mode} API key.`);
384
- console.log(` Create a ${mode} key in https://warm.io/settings and try again.`);
385
- console.log('');
386
- return false;
387
- }
388
- console.log(' API key verified.');
389
- console.log('');
390
- return true;
241
+ body = JSON.parse(text);
391
242
  }
392
- catch (error) {
393
- const message = error instanceof Error ? error.message : String(error);
394
- if (error instanceof WarmApiError && (error.status === 401 || error.status === 403)) {
395
- console.log(` Validation failed: ${message}`);
396
- console.log('');
397
- return false;
398
- }
399
- console.log(` Could not validate right now: ${message}`);
400
- console.log(' Continuing setup with the provided key.');
401
- console.log('');
402
- return true;
243
+ catch {
244
+ /* An invalid response is not a valid key. */
403
245
  }
404
- }
405
- async function validateEffectiveApiKeys(apiKeys, mode) {
406
- const uniqueApiKeys = [...new Set(apiKeys.filter((apiKey) => Boolean(apiKey)))];
407
- for (const apiKey of uniqueApiKeys) {
408
- if (!(await validateApiKey(apiKey, mode))) {
409
- throw new Error(`The ${mode} API key could not be validated.`);
410
- }
246
+ if (!response.ok || !isRecord(body) || body.valid !== true || Object.keys(body).length !== 1) {
247
+ throw new Error('Warm API key validation failed. Check Warm Settings and try again.');
411
248
  }
412
249
  }
413
- function storeApiKey(apiKey, mode) {
414
- const apiKeyPath = getWarmApiKeyPath(mode);
415
- mkdirSync(dirname(apiKeyPath), { recursive: true });
416
- writeFileSync(apiKeyPath, `${apiKey}\n`, { mode: 0o600 });
417
- chmodSync(apiKeyPath, 0o600);
250
+ async function requestedApiKey(ask, options) {
251
+ const apiKey = (await ask('Warm API key: ')).trim();
252
+ if (!apiKey)
253
+ throw new Error('A Warm API key is required to configure MCP clients.');
254
+ await validateApiKey(apiKey, options);
255
+ return apiKey;
418
256
  }
419
- function getStatusLabel(status, force) {
420
- if (force) {
421
- return 'not configured';
422
- }
423
- if (status === 'configured') {
424
- return 'configured';
425
- }
426
- if (status === 'needs_setup') {
427
- return 'needs setup';
428
- }
429
- return 'not configured';
430
- }
431
- export async function install(options = {}) {
432
- const force = options.force ?? false;
433
- const mode = options.mode ?? 'context';
434
- const shouldValidateApiKey = options.validateApiKey ?? true;
435
- console.log('');
436
- console.log(' Warmio Installer');
437
- console.log(' ----------------');
438
- console.log('');
439
- const globalClients = GLOBAL_CLIENTS.filter(isDetected);
440
- const projectClients = detectProjectClients();
441
- const allClients = [...globalClients, ...projectClients];
442
- const clientStatuses = allClients.map((client) => getClientStatus(client, mode));
443
- const needsSetup = clientStatuses.filter((status) => force || status.state !== 'configured');
444
- console.log(' MCP clients found:');
445
- clientStatuses.forEach((status) => {
446
- console.log(` ${status.client.name.padEnd(22)} ${shortPath(status.client.configPath).padEnd(55)} ${getStatusLabel(status.state, force)}`);
447
- });
448
- console.log('');
449
- if (needsSetup.length === 0) {
450
- if (shouldValidateApiKey) {
451
- await validateEffectiveApiKeys(clientStatuses.map((status) => status.credentialApiKey), mode);
452
- }
453
- console.log(' All clients already configured!');
454
- console.log(' Run with --force to update the API key.');
455
- console.log('');
456
- return;
457
- }
458
- const apiKeyPath = getWarmApiKeyPath(mode);
459
- const storedApiKey = readConfigFile(apiKeyPath);
460
- const migratedApiKey = needsSetup.find((status) => typeof status.inlineApiKey === 'string' && status.inlineApiKey.length > 0)?.inlineApiKey ?? null;
461
- const usableApiKeyFileOverride = needsSetup.find((status) => status.apiKeyFileOverride && !status.inlineApiKey && status.credentialApiKey);
462
- const targetMissingCredential = needsSetup.some((status) => !status.credentialApiKey);
463
- let apiKeyToStore = null;
464
- let shouldStoreApiKey = false;
465
- let shouldPromptForApiKey = force;
466
- if (!shouldPromptForApiKey) {
467
- if (storedApiKey) {
468
- console.log(' Using stored Warm API key.');
469
- console.log('');
470
- apiKeyToStore = storedApiKey;
471
- }
472
- else if (migratedApiKey) {
473
- console.log(' Reusing Warm API key from existing client config.');
474
- console.log('');
475
- apiKeyToStore = migratedApiKey;
476
- shouldStoreApiKey = true;
477
- }
478
- else if (usableApiKeyFileOverride && !targetMissingCredential) {
479
- console.log(` Reusing existing ${getCredential(mode).apiKeyFileEnv} override.`);
480
- console.log('');
481
- }
482
- else {
483
- shouldPromptForApiKey = true;
484
- }
485
- }
486
- if (shouldPromptForApiKey) {
487
- const apiKey = await prompt(' Warm API key: ');
488
- if (!apiKey) {
489
- console.log('');
490
- console.log(' No key provided. Get one at https://warm.io/settings');
491
- console.log('');
492
- throw new Error(`A ${mode} API key is required to complete setup.`);
493
- }
494
- apiKeyToStore = apiKey;
495
- shouldStoreApiKey = true;
496
- }
497
- if (shouldValidateApiKey) {
498
- const retainedClientApiKeys = force
499
- ? []
500
- : clientStatuses
501
- .filter((status) => status.state === 'configured' ||
502
- Boolean(status.apiKeyFileOverride && status.credentialApiKey))
503
- .map((status) => status.credentialApiKey);
504
- await validateEffectiveApiKeys([apiKeyToStore, ...retainedClientApiKeys], mode);
257
+ async function resolveApiKey(target, ask, confirm, options) {
258
+ const storedApiKey = fs.existsSync(target) ? fs.readFileSync(target, 'utf8').trim() : '';
259
+ if (!storedApiKey || options.force) {
260
+ return {
261
+ apiKey: await requestedApiKey(ask, options),
262
+ replaceStoredKey: true,
263
+ };
505
264
  }
506
- if (apiKeyToStore && shouldStoreApiKey) {
507
- storeApiKey(apiKeyToStore, mode);
265
+ try {
266
+ await validateApiKey(storedApiKey, options);
267
+ return { apiKey: storedApiKey, replaceStoredKey: false };
508
268
  }
509
- console.log(' Configuring...');
510
- console.log('');
511
- const configurationFailures = [];
512
- needsSetup.forEach((status) => {
513
- try {
514
- configure(status.client, mode, !force && Boolean(status.apiKeyFileOverride && status.credentialApiKey));
515
- console.log(` ${status.client.name.padEnd(22)} done`);
516
- }
517
- catch (error) {
518
- const message = error instanceof Error ? error.message : String(error);
519
- console.log(` ${status.client.name.padEnd(22)} failed: ${message}`);
520
- configurationFailures.push(`${status.client.name}: ${message}`);
269
+ catch {
270
+ if (!(await confirm('Replace the existing stored Warm API key?'))) {
271
+ throw new Error('Warm API key replacement cancelled.');
521
272
  }
522
- });
523
- console.log('');
524
- if (configurationFailures.length > 0) {
525
- throw new Error(`Failed to configure MCP clients: ${configurationFailures.join('; ')}`);
526
- }
527
- if (apiKeyToStore) {
528
- console.log(` Stored ${mode} API key at ${shortPath(apiKeyPath)}`);
273
+ return {
274
+ apiKey: await requestedApiKey(ask, options),
275
+ replaceStoredKey: true,
276
+ };
529
277
  }
530
- console.log(' All set! Restart your MCP clients and try:');
531
- console.log(' "What\'s my net worth?"');
532
- console.log('');
533
278
  }
279
+ /** Configure detected supported MCP clients for the local stdio server. */
280
+ export async function install(options = {}) {
281
+ const homeDir = options.homeDir || os.homedir();
282
+ const workingDirectory = options.workingDirectory || process.cwd();
283
+ const ask = options.prompt || readlinePrompt;
284
+ const confirm = options.confirm ||
285
+ (async (question) => /^(y|yes)$/i.test(await ask(`${question} [y/N] `)));
286
+ const targets = [];
287
+ for (const client of supportedClients(homeDir, workingDirectory)) {
288
+ const existing = existingWarmConfig(client);
289
+ if (existing.current)
290
+ continue;
291
+ if (existing.exists &&
292
+ !options.force &&
293
+ !(await confirm(`Replace the existing Warm MCP configuration in ${client.name}?`)))
294
+ continue;
295
+ targets.push(client);
296
+ }
297
+ const target = getWarmApiKeyPath();
298
+ const { apiKey, replaceStoredKey } = await resolveApiKey(target, ask, confirm, options);
299
+ const clientFiles = targets.map(prepareClientFile);
300
+ const files = replaceStoredKey ? [...clientFiles, prepareCredentialFile(target, apiKey)] : clientFiles;
301
+ commitPreparedFiles(files, options.atomicReplaceFile || atomicReplaceFile);
302
+ }
303
+ //# sourceMappingURL=install.js.map