@warmio/mcp 10.0.0 → 11.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/install.js CHANGED
@@ -1,533 +1,319 @@
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
53
+ .slice(1)
54
+ .every((argument, index) => argument === WARM_MCP_SERVER_CONFIG.args[index]) &&
55
+ Object.keys(server).length === 2);
56
+ }
57
+ return (server.command === WARM_MCP_SERVER_CONFIG.command &&
58
+ Array.isArray(server.args) &&
59
+ server.args.length === WARM_MCP_SERVER_CONFIG.args.length &&
60
+ server.args.every((argument, index) => argument === WARM_MCP_SERVER_CONFIG.args[index]) &&
61
+ Object.keys(server).length === 2);
62
+ }
63
+ function parseJsonConfig(client, content) {
64
+ if (!content)
65
+ return {};
66
+ const parsed = JSON.parse(content);
67
+ if (!isRecord(parsed))
68
+ throw new Error(`${client.name} config is not a JSON object.`);
69
+ return parsed;
70
+ }
71
+ function readJsonConfig(client) {
72
+ return parseJsonConfig(client, fs.existsSync(client.configPath) ? fs.readFileSync(client.configPath, 'utf8') : '');
73
+ }
74
+ const WARM_TOML_BLOCK = /\n?\[mcp_servers\.warm\][\s\S]*?(?=\n\[[^\n]+\]|\s*$)/g;
75
+ const WARM_TOML_ENV_BLOCK = /\n?\[mcp_servers\.warm\.env\][\s\S]*?(?=\n\[[^\n]+\]|\s*$)/g;
76
+ function existingWarmConfig(client) {
77
+ if (client.format === 'toml') {
78
+ const content = fs.existsSync(client.configPath)
79
+ ? fs.readFileSync(client.configPath, 'utf8')
80
+ : '';
81
+ const section = content.match(WARM_TOML_BLOCK)?.[0];
82
+ return {
83
+ current: Boolean(section) &&
84
+ !content.includes('[mcp_servers.warm.env]') &&
85
+ section.trim() ===
86
+ [
87
+ '[mcp_servers.warm]',
88
+ `command = "${WARM_MCP_SERVER_CONFIG.command}"`,
89
+ `args = ${JSON.stringify(WARM_MCP_SERVER_CONFIG.args)}`,
90
+ ].join('\n'),
91
+ exists: Boolean(section),
92
+ };
87
93
  }
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;
94
+ const config = readJsonConfig(client);
95
+ if (client.configDialect === 'openCode') {
96
+ const servers = isRecord(config.mcp) ? config.mcp : {};
97
+ const legacyServers = isRecord(config.mcpServers) ? config.mcpServers : {};
98
+ const hasLegacyWarmConfig = 'warm' in legacyServers;
99
+ return {
100
+ current: isCurrentWarmConfig(client, servers.warm) && !hasLegacyWarmConfig,
101
+ exists: 'warm' in servers || hasLegacyWarmConfig,
102
+ };
103
+ }
104
+ const servers = isRecord(config.mcpServers) ? config.mcpServers : {};
100
105
  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,
106
+ current: isCurrentWarmConfig(client, servers.warm),
107
+ exists: 'warm' in servers,
111
108
  };
112
109
  }
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) {
135
- 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);
110
+ function prepareJsonConfig(client, current) {
111
+ const config = parseJsonConfig(client, current);
112
+ if (client.configDialect === 'openCode') {
113
+ const servers = isRecord(config.mcp) ? config.mcp : {};
114
+ servers.warm = {
115
+ type: 'local',
116
+ command: [WARM_MCP_SERVER_CONFIG.command, ...WARM_MCP_SERVER_CONFIG.args],
117
+ };
118
+ config.mcp = servers;
119
+ if (isRecord(config.mcpServers) && 'warm' in config.mcpServers) {
120
+ delete config.mcpServers.warm;
121
+ if (Object.keys(config.mcpServers).length === 0)
122
+ delete config.mcpServers;
148
123
  }
149
- return createClientStatus(client, 'needs_setup', envStatus, mode);
124
+ return `${JSON.stringify(config, null, 2)}\n`;
150
125
  }
151
- catch {
152
- return createClientStatus(client, 'missing', { apiKeyFileOverride: null, hasIrrelevantCredential: false, inlineApiKey: null }, mode);
126
+ const servers = isRecord(config.mcpServers) ? config.mcpServers : {};
127
+ servers.warm = {
128
+ command: WARM_MCP_SERVER_CONFIG.command,
129
+ args: [...WARM_MCP_SERVER_CONFIG.args],
130
+ };
131
+ config.mcpServers = servers;
132
+ return `${JSON.stringify(config, null, 2)}\n`;
133
+ }
134
+ function prepareTomlConfig(current) {
135
+ const remaining = current.replace(WARM_TOML_ENV_BLOCK, '').replace(WARM_TOML_BLOCK, '').trimEnd();
136
+ const next = [
137
+ remaining,
138
+ '[mcp_servers.warm]',
139
+ `command = "${WARM_MCP_SERVER_CONFIG.command}"`,
140
+ `args = ${JSON.stringify(WARM_MCP_SERVER_CONFIG.args)}`,
141
+ ]
142
+ .filter(Boolean)
143
+ .join('\n\n');
144
+ return `${next}\n`;
145
+ }
146
+ function snapshotFile(target) {
147
+ try {
148
+ const stat = fs.statSync(target);
149
+ return {
150
+ content: fs.readFileSync(target, 'utf8'),
151
+ exists: true,
152
+ mode: stat.mode & 0o777,
153
+ };
153
154
  }
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;
155
+ catch (error) {
156
+ if (error.code !== 'ENOENT')
157
+ throw error;
158
+ return { content: '', exists: false, mode: 0o666 };
166
159
  }
167
- const pattern = new RegExp(`^${escapeRegExp(key)}\\s*=\\s*(?:"([^"]*)"|'([^']*)')\\s*$`, 'm');
168
- const match = block.match(pattern);
169
- return match?.[1] ?? match?.[2];
170
160
  }
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;
161
+ function prepareClientFile(client) {
162
+ const snapshot = snapshotFile(client.configPath);
163
+ return {
164
+ content: client.format === 'toml'
165
+ ? prepareTomlConfig(snapshot.content)
166
+ : prepareJsonConfig(client, snapshot.content),
167
+ mode: snapshot.mode,
168
+ snapshot,
169
+ target: client.configPath,
170
+ };
186
171
  }
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);
172
+ function prepareCredentialFile(target, apiKey) {
206
173
  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,
174
+ content: `${apiKey}\n`,
175
+ mode: 0o600,
176
+ snapshot: snapshotFile(target),
177
+ target,
216
178
  };
217
179
  }
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);
180
+ function atomicReplaceFile(target, content, mode) {
181
+ const directory = path.dirname(target);
182
+ const temporaryPath = path.join(directory, `.${path.basename(target)}.${randomUUID()}.tmp`);
183
+ let descriptor;
184
+ try {
185
+ fs.mkdirSync(directory, { recursive: true });
186
+ descriptor = fs.openSync(temporaryPath, 'wx', mode);
187
+ fs.writeFileSync(descriptor, content, 'utf8');
188
+ fs.fsyncSync(descriptor);
189
+ fs.closeSync(descriptor);
190
+ descriptor = undefined;
191
+ fs.renameSync(temporaryPath, target);
192
+ }
193
+ finally {
194
+ if (descriptor !== undefined)
195
+ fs.closeSync(descriptor);
196
+ if (fs.existsSync(temporaryPath))
197
+ fs.unlinkSync(temporaryPath);
198
+ }
199
+ }
200
+ function restorePreparedFile(prepared, replaceFile) {
201
+ if (prepared.snapshot.exists) {
202
+ replaceFile(prepared.target, prepared.snapshot.content, prepared.snapshot.mode);
203
+ return;
228
204
  }
229
- return createClientStatus(client, 'needs_setup', envStatus, mode);
205
+ fs.rmSync(prepared.target, { force: true });
230
206
  }
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);
207
+ function commitPreparedFiles(files, replaceFile) {
208
+ const replaced = [];
209
+ try {
210
+ for (const file of files) {
211
+ replaceFile(file.target, file.content, file.mode);
212
+ replaced.push(file);
213
+ }
238
214
  }
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;
215
+ catch (error) {
246
216
  try {
247
- parsed = JSON.parse(content);
248
- }
249
- catch (error) {
250
- throw new Error(`Refusing to overwrite unparsable JSON config ${client.configPath}: ${error instanceof Error ? error.message : String(error)}`);
217
+ for (const file of [...replaced].reverse())
218
+ restorePreparedFile(file, replaceFile);
251
219
  }
252
- if (!isRecord(parsed)) {
253
- throw new Error(`Refusing to overwrite JSON config ${client.configPath}: root must be an object.`);
220
+ catch {
221
+ throw new Error('Warm MCP installation failed and rollback could not complete.');
254
222
  }
255
- config = parsed;
223
+ throw error;
256
224
  }
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.`);
263
- }
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
225
  }
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;
226
+ function readlinePrompt(question, options) {
227
+ return new Promise((resolve) => {
228
+ const readline = createInterface({
229
+ input: process.stdin,
230
+ output: process.stdout,
231
+ });
232
+ const maskedReadline = readline;
233
+ const writeToOutput = maskedReadline._writeToOutput?.bind(readline);
234
+ if (options.hidden && writeToOutput && process.stdin.isTTY && process.stdout.isTTY) {
235
+ writeToOutput(question);
236
+ maskedReadline._writeToOutput = (value) => writeToOutput(value.replace(/[^\r\n]/g, '•'));
312
237
  }
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);
238
+ readline.question(options.hidden ? '' : question, (answer) => {
239
+ readline.close();
240
+ resolve(answer.trim());
241
+ });
335
242
  });
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
243
  }
353
- function configure(client, mode, preserveSelectedKeyFile) {
354
- if (client.format === 'json') {
355
- configureJson(client, mode, preserveSelectedKeyFile);
356
- return;
244
+ async function validateApiKey(apiKey, options) {
245
+ if (!API_KEY_PATTERN.test(apiKey)) {
246
+ throw new Error('Warm API key validation failed. Check Warm Settings and try again.');
357
247
  }
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
- });
248
+ const response = await (options.fetchImplementation || fetch)(new URL('/api/verify', options.apiUrl || process.env.WARM_API_URL || API_ORIGIN), {
249
+ headers: {
250
+ Accept: 'application/json',
251
+ Authorization: `Bearer ${apiKey}`,
252
+ },
370
253
  });
371
- }
372
- async function validateApiKey(apiKey, mode) {
373
- console.log(' Validating API key...');
254
+ const text = await response.text();
255
+ let body = null;
374
256
  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;
257
+ body = JSON.parse(text);
391
258
  }
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;
259
+ catch {
260
+ /* An invalid response is not a valid key. */
403
261
  }
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
- }
262
+ if (!response.ok || !isRecord(body) || body.valid !== true || Object.keys(body).length !== 1) {
263
+ throw new Error('Warm API key validation failed. Check Warm Settings and try again.');
411
264
  }
412
265
  }
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);
266
+ async function requestedApiKey(ask, options) {
267
+ const apiKey = (await ask('Warm API key: ', { hidden: true })).trim();
268
+ if (!apiKey)
269
+ throw new Error('A Warm API key is required to configure MCP clients.');
270
+ await validateApiKey(apiKey, options);
271
+ return apiKey;
418
272
  }
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);
273
+ async function resolveApiKey(target, ask, confirm, options) {
274
+ const storedApiKey = fs.existsSync(target) ? fs.readFileSync(target, 'utf8').trim() : '';
275
+ if (!storedApiKey || options.force) {
276
+ return {
277
+ apiKey: await requestedApiKey(ask, options),
278
+ replaceStoredKey: true,
279
+ };
505
280
  }
506
- if (apiKeyToStore && shouldStoreApiKey) {
507
- storeApiKey(apiKeyToStore, mode);
281
+ try {
282
+ await validateApiKey(storedApiKey, options);
283
+ return { apiKey: storedApiKey, replaceStoredKey: false };
508
284
  }
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}`);
285
+ catch {
286
+ if (!(await confirm('Replace the existing stored Warm API key?'))) {
287
+ throw new Error('Warm API key replacement cancelled.');
521
288
  }
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)}`);
289
+ return {
290
+ apiKey: await requestedApiKey(ask, options),
291
+ replaceStoredKey: true,
292
+ };
529
293
  }
530
- console.log(' All set! Restart your MCP clients and try:');
531
- console.log(' "What\'s my net worth?"');
532
- console.log('');
533
294
  }
295
+ /** Configure detected supported MCP clients for the local stdio server. */
296
+ export async function install(options = {}) {
297
+ const homeDir = options.homeDir || os.homedir();
298
+ const workingDirectory = options.workingDirectory || process.cwd();
299
+ const ask = options.prompt || readlinePrompt;
300
+ const confirm = options.confirm ||
301
+ (async (question) => /^(y|yes)$/i.test(await ask(`${question} [y/N] `, { hidden: false })));
302
+ const targets = [];
303
+ for (const client of supportedClients(homeDir, workingDirectory)) {
304
+ const existing = existingWarmConfig(client);
305
+ if (existing.current)
306
+ continue;
307
+ if (existing.exists &&
308
+ !options.force &&
309
+ !(await confirm(`Replace the existing Warm MCP configuration in ${client.name}?`)))
310
+ continue;
311
+ targets.push(client);
312
+ }
313
+ const target = getWarmApiKeyPath();
314
+ const { apiKey } = await resolveApiKey(target, ask, confirm, options);
315
+ const clientFiles = targets.map(prepareClientFile);
316
+ const files = [...clientFiles, prepareCredentialFile(target, apiKey)];
317
+ commitPreparedFiles(files, options.atomicReplaceFile || atomicReplaceFile);
318
+ }
319
+ //# sourceMappingURL=install.js.map