@remcp/remcp 0.2.150 → 0.2.152

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/remcp",
3
- "version": "0.2.150",
3
+ "version": "0.2.152",
4
4
  "description": "ReMCP device client: pair a computer with ReMCP and run the outbound-only agent that hosts the local MCP runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  "README.md"
19
19
  ],
20
20
  "scripts": {
21
- "check": "node --check bin/remcp.mjs && node --check src/agent-update.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
21
+ "check": "node --check bin/remcp.mjs && node --check src/agent-update.mjs && node --check src/agent.mjs && node --check src/cli.mjs && node --check src/cli/config.mjs && node --check src/cli/connect.mjs && node --check src/cli/doctor.mjs && node --check src/cli/env.mjs && node --check src/cli/npm-global.mjs && node --check src/cli/service-inventory.mjs && node --check src/cli/service.mjs && node --check src/cli/shell.mjs && node --check src/cli/update.mjs && node --check src/fs-access.mjs && node --check src/npm.mjs && node --check src/runtime.mjs && node --check src/version.mjs",
22
22
  "test": "node --test test/*.test.mjs"
23
23
  },
24
24
  "dependencies": {
@@ -0,0 +1,212 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { resolveNpm } from '../npm.mjs';
5
+ import { PACKAGE_NAME } from '../version.mjs';
6
+ import { home, npm } from './env.mjs';
7
+ import { output, run } from './shell.mjs';
8
+
9
+ export function servicePlatform() {
10
+ return process.env.NODE_ENV === 'test' && process.env.REMCP_TEST_PLATFORM ? process.env.REMCP_TEST_PLATFORM : process.platform;
11
+ }
12
+
13
+ export function globalPrefix() {
14
+ return output(npm.command, [...npm.args, 'prefix', '--global']);
15
+ }
16
+
17
+ export function globalCliPath() {
18
+ const prefix = globalPrefix();
19
+ return servicePlatform() === 'win32' ? path.join(prefix, 'remcp.cmd') : path.join(prefix, 'bin', 'remcp');
20
+ }
21
+
22
+ function npmGlobalInstallArgs(specs, { preferOnline = false } = {}) {
23
+ return [
24
+ 'install',
25
+ '--global',
26
+ ...specs,
27
+ ...(preferOnline ? ['--prefer-online'] : []),
28
+ '--no-audit',
29
+ '--no-fund',
30
+ '--ignore-scripts',
31
+ '--loglevel=error',
32
+ ];
33
+ }
34
+
35
+ export function npmGlobalInstall(...specs) {
36
+ run(npm.command, [...npm.args, ...npmGlobalInstallArgs(specs)]);
37
+ }
38
+
39
+ function pathEntryExists(file) {
40
+ try {
41
+ fs.lstatSync(file);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ function globalPackagePath(prefix, packageName, platform = servicePlatform()) {
49
+ const name = String(packageName || '').trim();
50
+ const parts = name.split('/');
51
+ const validUnscoped = parts.length === 1 && parts[0] && !parts[0].startsWith('@') && parts[0] !== '.' && parts[0] !== '..';
52
+ const validScoped = parts.length === 2 && /^@[^/]+$/.test(parts[0]) && parts[1] && parts[1] !== '.' && parts[1] !== '..';
53
+ if (!validUnscoped && !validScoped) throw new Error(`Invalid npm package name for update: ${name || '(empty)'}`);
54
+ const modulesRoot = platform === 'win32'
55
+ ? path.join(prefix, 'node_modules')
56
+ : path.join(prefix, 'lib', 'node_modules');
57
+ return path.join(modulesRoot, ...parts);
58
+ }
59
+
60
+ function managedWindowsRuntimePrefix() {
61
+ return path.resolve(
62
+ process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'),
63
+ 'ReMCP',
64
+ 'runtime',
65
+ );
66
+ }
67
+
68
+ function isManagedWindowsRuntimePrefix(prefix) {
69
+ const actual = path.resolve(String(prefix || ''));
70
+ const expected = managedWindowsRuntimePrefix();
71
+ return actual.toLowerCase() === expected.toLowerCase();
72
+ }
73
+
74
+ function transactionalMacGlobalInstall({ resolved, prefix, packageNames, specs, preferOnline = false }) {
75
+ prefix = String(prefix || '').trim();
76
+ if (!path.isAbsolute(prefix)) throw new Error(`npm global prefix must be absolute for a macOS update: ${prefix || '(empty)'}`);
77
+ const backupRoot = path.join(prefix, `.remcp-update-backup-${process.pid}-${Date.now()}`);
78
+ const moved = [];
79
+ const moveAside = (source, label) => {
80
+ if (!pathEntryExists(source)) return;
81
+ const backup = path.join(backupRoot, label);
82
+ fs.renameSync(source, backup);
83
+ moved.push({ source, backup });
84
+ };
85
+ const restore = originalError => {
86
+ let rollbackError = null;
87
+ for (const item of [...moved].reverse()) {
88
+ try {
89
+ if (pathEntryExists(item.source)) fs.rmSync(item.source, { recursive:true, force:true });
90
+ fs.mkdirSync(path.dirname(item.source), { recursive:true });
91
+ fs.renameSync(item.backup, item.source);
92
+ } catch (error) {
93
+ rollbackError ??= error;
94
+ }
95
+ }
96
+ try { fs.rmSync(backupRoot, { recursive:true, force:true }); } catch (error) { rollbackError ??= error; }
97
+ if (rollbackError) {
98
+ throw new Error(
99
+ `ReMCP update failed and the previous macOS installation could not be fully restored: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
100
+ { cause:originalError },
101
+ );
102
+ }
103
+ };
104
+
105
+ fs.mkdirSync(prefix, { recursive:true });
106
+ fs.mkdirSync(backupRoot, { recursive:false, mode:0o700 });
107
+ try {
108
+ const uniqueNames = [...new Set(packageNames.map(name => String(name || '').trim()).filter(Boolean))];
109
+ uniqueNames.forEach((name, index) => moveAside(globalPackagePath(prefix, name), `package-${index}`));
110
+ if (uniqueNames.includes(PACKAGE_NAME)) moveAside(path.join(prefix, 'bin', 'remcp'), 'bin-remcp');
111
+
112
+ run(resolved.command, [...resolved.args, ...npmGlobalInstallArgs(specs, { preferOnline })]);
113
+ } catch (error) {
114
+ restore(error);
115
+ throw error;
116
+ }
117
+
118
+ try {
119
+ fs.rmSync(backupRoot, { recursive:true, force:true });
120
+ } catch (error) {
121
+ console.error(`ReMCP update: could not remove macOS rollback backup ${backupRoot}: ${error instanceof Error ? error.message : String(error)}`);
122
+ }
123
+ }
124
+
125
+ function transactionalWindowsGlobalInstall({ resolved, prefix, packageNames, specs, preferOnline = false }) {
126
+ prefix = String(prefix || '').trim();
127
+ if (!path.isAbsolute(prefix)) throw new Error(`npm global prefix must be absolute for a Windows update: ${prefix || '(empty)'}`);
128
+ if (!isManagedWindowsRuntimePrefix(prefix)) {
129
+ run(resolved.command, [...resolved.args, ...npmGlobalInstallArgs(specs, { preferOnline })]);
130
+ return;
131
+ }
132
+
133
+ const backupRoot = path.join(prefix, `.remcp-update-backup-${process.pid}-${Date.now()}`);
134
+ const moved = [];
135
+ const moveAside = (source, label) => {
136
+ if (!pathEntryExists(source)) return;
137
+ const backup = path.join(backupRoot, label);
138
+ fs.renameSync(source, backup);
139
+ moved.push({ source, backup });
140
+ };
141
+ const restore = originalError => {
142
+ let rollbackError = null;
143
+ for (const item of [...moved].reverse()) {
144
+ try {
145
+ if (pathEntryExists(item.source)) fs.rmSync(item.source, { recursive:true, force:true });
146
+ fs.mkdirSync(path.dirname(item.source), { recursive:true });
147
+ fs.renameSync(item.backup, item.source);
148
+ } catch (error) {
149
+ rollbackError ??= error;
150
+ }
151
+ }
152
+ try { fs.rmSync(backupRoot, { recursive:true, force:true }); } catch (error) { rollbackError ??= error; }
153
+ if (rollbackError) {
154
+ throw new Error(
155
+ `ReMCP update failed and the previous Windows installation could not be fully restored: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
156
+ { cause:originalError },
157
+ );
158
+ }
159
+ };
160
+
161
+ fs.mkdirSync(prefix, { recursive:true });
162
+ fs.mkdirSync(backupRoot, { recursive:false, mode:0o700 });
163
+ try {
164
+ const uniqueNames = [...new Set(packageNames.map(name => String(name || '').trim()).filter(Boolean))];
165
+ uniqueNames.forEach((name, index) => moveAside(globalPackagePath(prefix, name, 'win32'), `package-${index}`));
166
+ if (uniqueNames.includes(PACKAGE_NAME)) {
167
+ for (const shim of ['remcp', 'remcp.cmd', 'remcp.ps1']) moveAside(path.join(prefix, shim), `bin-${shim}`);
168
+ }
169
+ run(resolved.command, [...resolved.args, ...npmGlobalInstallArgs(specs, { preferOnline })]);
170
+ } catch (error) {
171
+ restore(error);
172
+ throw error;
173
+ }
174
+
175
+ try {
176
+ fs.rmSync(backupRoot, { recursive:true, force:true });
177
+ } catch (error) {
178
+ console.error(`ReMCP update: could not remove Windows rollback backup ${backupRoot}: ${error instanceof Error ? error.message : String(error)}`);
179
+ }
180
+ }
181
+
182
+ // Release updates use exact versions that may have been published only moments ago. npm can keep a
183
+ // cached pre-publication packument long enough to answer ETARGET after the registry already serves the
184
+ // version, so updates revalidate metadata while retaining npm's cache for package bytes.
185
+ export function npmGlobalUpdate(packageNames, ...specs) {
186
+ const platform = servicePlatform();
187
+ const prefix = globalPrefix();
188
+ if (platform === 'darwin') {
189
+ transactionalMacGlobalInstall({ resolved:npm, prefix, packageNames, specs, preferOnline:true });
190
+ return;
191
+ }
192
+ if (platform === 'win32') {
193
+ transactionalWindowsGlobalInstall({ resolved:npm, prefix, packageNames, specs, preferOnline:true });
194
+ return;
195
+ }
196
+ run(npm.command, [...npm.args, ...npmGlobalInstallArgs(specs, { preferOnline:true })]);
197
+ }
198
+
199
+ export function npmGlobalUpdateForNode(nodePath, packageNames, ...specs) {
200
+ const platform = servicePlatform();
201
+ const resolved = resolveNpm({ nodePath, home, platform });
202
+ const prefix = output(resolved.command, [...resolved.args, 'prefix', '--global']);
203
+ if (platform === 'darwin') {
204
+ transactionalMacGlobalInstall({ resolved, prefix, packageNames, specs, preferOnline:true });
205
+ return;
206
+ }
207
+ if (platform === 'win32') {
208
+ transactionalWindowsGlobalInstall({ resolved, prefix, packageNames, specs, preferOnline:true });
209
+ return;
210
+ }
211
+ run(resolved.command, [...resolved.args, ...npmGlobalInstallArgs(specs, { preferOnline:true })]);
212
+ }
@@ -0,0 +1,217 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { PACKAGE_NAME, VERSION } from '../version.mjs';
6
+ import { saveConfig } from './config.mjs';
7
+ import { linuxServiceFile, linuxServiceLauncherFile, macServiceFile, macServiceLabel, windowsTaskName } from './env.mjs';
8
+ import { servicePlatform, globalCliPath, npmGlobalUpdateForNode } from './npm-global.mjs';
9
+
10
+ export function persistentServiceExpected(config) {
11
+ if (config?.serviceInstalled === false) return false;
12
+ if (config?.serviceInstalled === true) return true;
13
+ const platform = servicePlatform();
14
+ if (platform === 'linux') return fs.existsSync(linuxServiceFile);
15
+ if (platform === 'darwin') return fs.existsSync(macServiceFile);
16
+ if (platform === 'win32') {
17
+ return spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName], { stdio: 'ignore' }).status === 0;
18
+ }
19
+ return false;
20
+ }
21
+
22
+ export function persistentServiceState(config) {
23
+ const platform = servicePlatform();
24
+ const expected = persistentServiceExpected(config);
25
+ if (platform === 'linux') {
26
+ const installed = fs.existsSync(linuxServiceFile);
27
+ const active = installed && spawnSync('systemctl', ['--user', 'is-active', '--quiet', 'remcp-agent.service'], { stdio: 'ignore' }).status === 0;
28
+ return { expected, installed, active, manager:'systemd-user', name:'remcp-agent.service' };
29
+ }
30
+ if (platform === 'darwin') {
31
+ const installed = fs.existsSync(macServiceFile);
32
+ const target = typeof process.getuid === 'function' ? 'gui/' + process.getuid() + '/' + macServiceLabel : macServiceLabel;
33
+ const active = installed && spawnSync('launchctl', ['print', target], { stdio: 'ignore' }).status === 0;
34
+ return { expected, installed, active, manager:'launchd', name:macServiceLabel };
35
+ }
36
+ if (platform === 'win32') {
37
+ const query = spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName, '/FO', 'LIST'], { encoding:'utf8' });
38
+ const installed = query.status === 0;
39
+ const outputText = String(query.stdout || '');
40
+ const active = installed && /(?:Status|Состояние):\s*Running/i.test(outputText);
41
+ return { expected, installed, active, manager:'schtasks', name:windowsTaskName };
42
+ }
43
+ return { expected:false, installed:false, active:false, manager:null, name:null };
44
+ }
45
+
46
+ function inferServiceIdentity() {
47
+ const platform = servicePlatform();
48
+ if (platform === 'linux' && fs.existsSync(linuxServiceLauncherFile)) {
49
+ try {
50
+ const body = fs.readFileSync(linuxServiceLauncherFile, 'utf8');
51
+ const match = body.match(/^exec\s+'([^']+)'\s+'([^']+)'/m);
52
+ if (match) return { nodePath:match[1], cliPath:match[2] };
53
+ } catch {}
54
+ }
55
+ if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
56
+ try {
57
+ const body = fs.readFileSync(macServiceFile, 'utf8');
58
+ const args = [...body.matchAll(/<string>([^<]+)<\/string>/g)].map(match => match[1]);
59
+ const startIndex = args.indexOf('start');
60
+ if (startIndex >= 2) return { nodePath:args[startIndex - 2], cliPath:args[startIndex - 1] };
61
+ } catch {}
62
+ }
63
+ return {};
64
+ }
65
+
66
+ export function canonicalServiceCliPath(config) {
67
+ const configured = String(config?.serviceCliPath || '').trim();
68
+ if (configured && fs.existsSync(configured)) return configured;
69
+ const inferred = inferServiceIdentity().cliPath;
70
+ if (inferred && fs.existsSync(inferred)) return inferred;
71
+ return globalCliPath();
72
+ }
73
+
74
+ export function canonicalServiceNodePath(config) {
75
+ const configured = String(config?.serviceNodePath || '').trim();
76
+ if (configured && fs.existsSync(configured)) return configured;
77
+ const inferred = inferServiceIdentity().nodePath;
78
+ if (inferred && fs.existsSync(inferred)) return inferred;
79
+ return process.execPath;
80
+ }
81
+
82
+ function versionFromPackageJson(file) {
83
+ try {
84
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
85
+ return typeof parsed.version === 'string' ? parsed.version : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function packageVersionBesideCli(cliPath, packageName) {
92
+ if (!cliPath || !packageName) return null;
93
+ const marker = path.sep + 'node_modules' + path.sep;
94
+ const index = cliPath.lastIndexOf(marker);
95
+ if (index < 0) return null;
96
+ const nodeModules = cliPath.slice(0, index + (path.sep + 'node_modules').length);
97
+ return versionFromPackageJson(path.join(nodeModules, ...String(packageName).split('/'), 'package.json'));
98
+ }
99
+
100
+ function packageVersionForCli(cliPath, packageName) {
101
+ const beside = packageVersionBesideCli(cliPath, packageName);
102
+ if (beside || servicePlatform() !== 'win32' || !cliPath || !packageName) return beside;
103
+ // npm's Windows global executable is a .cmd shim in the prefix root, not a symlink into
104
+ // node_modules. realpath() therefore cannot reveal the package directory like it does on Unix.
105
+ // Resolve the package from the same prefix layout npm uses on Windows.
106
+ if (path.basename(cliPath).toLowerCase() !== 'remcp.cmd') return null;
107
+ const prefix = path.dirname(cliPath);
108
+ return versionFromPackageJson(path.join(prefix, 'node_modules', ...String(packageName).split('/'), 'package.json'));
109
+ }
110
+
111
+ function exactPackageSpecVersion(packageName, packageSpec) {
112
+ const prefix = `${String(packageName || '')}@`;
113
+ const spec = String(packageSpec || '');
114
+ return prefix !== '@' && spec.startsWith(prefix) ? spec.slice(prefix.length) : '';
115
+ }
116
+
117
+ // A PowerShell bootstrap already installs the exact client/runtime pair before it starts pairing.
118
+ // Reusing that verified pair avoids a second registry mutation during `connect --install` while
119
+ // preserving the normal npm path for partial, stale, custom-runtime and non-bootstrap installs.
120
+ export function releasePairPresentAtCli(cliPath, config) {
121
+ const runtimePackageName = String(config?.runtime?.packageName || '');
122
+ const expectedRuntimeVersion = exactPackageSpecVersion(runtimePackageName, config?.runtime?.packageSpec);
123
+ if (!cliPath || !runtimePackageName || !expectedRuntimeVersion) return false;
124
+ return packageVersionForCli(cliPath, PACKAGE_NAME) === VERSION
125
+ && packageVersionForCli(cliPath, runtimePackageName) === expectedRuntimeVersion;
126
+ }
127
+
128
+ export function installationVersionsAtCliPath(cliPath, runtimePackageName) {
129
+ if (!cliPath) return { cliVersion:null, runtimeVersion:null, cliPath:null };
130
+ let resolvedCliPath = cliPath;
131
+ try { if (fs.existsSync(cliPath)) resolvedCliPath = fs.realpathSync(cliPath); } catch {}
132
+ return {
133
+ cliVersion:packageVersionForCli(resolvedCliPath, PACKAGE_NAME),
134
+ runtimeVersion:packageVersionForCli(resolvedCliPath, runtimePackageName),
135
+ cliPath:resolvedCliPath,
136
+ };
137
+ }
138
+
139
+ export function serviceInstallationInfo(config) {
140
+ if (!persistentServiceExpected(config)) return { cliVersion:null, runtimeVersion:null, cliPath:null, nodePath:null };
141
+ const cliPath = canonicalServiceCliPath(config);
142
+ const nodePath = canonicalServiceNodePath(config);
143
+ let resolvedCliPath = cliPath;
144
+ try { if (cliPath && fs.existsSync(cliPath)) resolvedCliPath = fs.realpathSync(cliPath); } catch {}
145
+ return {
146
+ cliVersion:packageVersionForCli(resolvedCliPath, PACKAGE_NAME),
147
+ runtimeVersion:packageVersionForCli(resolvedCliPath, config?.runtime?.packageName),
148
+ cliPath:resolvedCliPath || cliPath,
149
+ nodePath,
150
+ };
151
+ }
152
+
153
+ export function currentInstallationInfo(config) {
154
+ let cliPath = process.argv[1] || '';
155
+ try { if (cliPath && fs.existsSync(cliPath)) cliPath = fs.realpathSync(cliPath); } catch {}
156
+ return {
157
+ cliVersion:VERSION,
158
+ runtimeVersion:config?.runtime?.packageName ? packageVersionForCli(cliPath, config.runtime.packageName) : null,
159
+ cliPath:cliPath || null,
160
+ nodePath:process.execPath,
161
+ };
162
+ }
163
+
164
+ export function rememberCurrentInstallation(config, { service = false } = {}) {
165
+ if (!config || typeof config !== 'object') return config;
166
+ const info = currentInstallationInfo(config);
167
+ if (!info.cliPath || !info.nodePath) return config;
168
+ const key = info.nodePath + '\u0000' + info.cliPath;
169
+ const existing = Array.isArray(config.installations) ? config.installations.filter(item => item && typeof item === 'object') : [];
170
+ const filtered = existing.filter(item => ((item.nodePath || '') + '\u0000' + (item.cliPath || '')) !== key);
171
+ const record = {
172
+ nodePath:info.nodePath,
173
+ cliPath:info.cliPath,
174
+ cliVersion:info.cliVersion,
175
+ runtimeVersion:info.runtimeVersion,
176
+ service:Boolean(service),
177
+ lastSeenAt:new Date().toISOString(),
178
+ };
179
+ const installations = [...filtered, record].slice(-8);
180
+ const next = {
181
+ ...config,
182
+ installations,
183
+ ...(service ? { serviceInstalled:true, serviceCliPath:info.cliPath, serviceNodePath:info.nodePath } : {}),
184
+ };
185
+ saveConfig(next);
186
+ return next;
187
+ }
188
+
189
+ export function syncKnownInstallations(config, ...specs) {
190
+ const serviceInfo = serviceInstallationInfo(config);
191
+ const current = currentInstallationInfo(config);
192
+ const candidates = [
193
+ ...(Array.isArray(config?.installations) ? config.installations : []),
194
+ ...(serviceInfo.nodePath ? [{ nodePath:serviceInfo.nodePath, cliPath:serviceInfo.cliPath, service:true }] : []),
195
+ ];
196
+ const currentNode = (() => { try { return fs.realpathSync(current.nodePath); } catch { return current.nodePath; } })();
197
+ const seen = new Set();
198
+ const results = [];
199
+ for (const candidate of candidates) {
200
+ const nodePath = String(candidate?.nodePath || '').trim();
201
+ if (!nodePath || !fs.existsSync(nodePath)) continue;
202
+ let resolvedNode = nodePath;
203
+ try { resolvedNode = fs.realpathSync(nodePath); } catch {}
204
+ if (resolvedNode === currentNode || seen.has(resolvedNode)) continue;
205
+ seen.add(resolvedNode);
206
+ const required = Boolean(candidate?.service) || resolvedNode === serviceInfo.nodePath;
207
+ try {
208
+ npmGlobalUpdateForNode(resolvedNode, [PACKAGE_NAME, config.runtime.packageName], ...specs);
209
+ results.push({ nodePath:resolvedNode, ok:true, required });
210
+ } catch (error) {
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ results.push({ nodePath:resolvedNode, ok:false, required, error:message });
213
+ if (required) throw new Error('Could not update canonical ReMCP service installation at ' + resolvedNode + ': ' + message);
214
+ }
215
+ }
216
+ return results;
217
+ }
@@ -1,3 +1,7 @@
1
+ import { servicePlatform, globalPrefix, globalCliPath, npmGlobalInstall, npmGlobalUpdate, npmGlobalUpdateForNode } from './npm-global.mjs';
2
+ import { persistentServiceExpected, persistentServiceState, releasePairPresentAtCli, installationVersionsAtCliPath, serviceInstallationInfo, currentInstallationInfo, rememberCurrentInstallation, syncKnownInstallations, canonicalServiceCliPath, canonicalServiceNodePath } from './service-inventory.mjs';
3
+ export { servicePlatform, globalPrefix, globalCliPath, npmGlobalInstall, npmGlobalUpdate, npmGlobalUpdateForNode };
4
+ export { persistentServiceExpected, persistentServiceState, releasePairPresentAtCli, installationVersionsAtCliPath, serviceInstallationInfo, currentInstallationInfo, rememberCurrentInstallation, syncKnownInstallations };
1
5
  // Installing, repairing and removing the background agent: one supervisor per platform, plus the
2
6
  // write-access tweaks a fresh install needs. Nothing here is reachable from a model or an MCP tool.
3
7
  import fs from 'node:fs';
@@ -12,211 +16,6 @@ import { saveConfig } from './config.mjs';
12
16
  import { configDir, home, linuxServiceFile, linuxServiceLauncherFile, macLogFile, macServiceFile, macServiceLabel, npm, windowsServiceLauncherFile, windowsTaskName } from './env.mjs';
13
17
  import { output, run } from './shell.mjs';
14
18
 
15
- export function servicePlatform() {
16
- return process.env.NODE_ENV === 'test' && process.env.REMCP_TEST_PLATFORM ? process.env.REMCP_TEST_PLATFORM : process.platform;
17
- }
18
-
19
- export function globalPrefix() {
20
- return output(npm.command, [...npm.args, 'prefix', '--global']);
21
- }
22
-
23
- export function globalCliPath() {
24
- const prefix = globalPrefix();
25
- return servicePlatform() === 'win32' ? path.join(prefix, 'remcp.cmd') : path.join(prefix, 'bin', 'remcp');
26
- }
27
-
28
- function npmGlobalInstallArgs(specs, { preferOnline = false } = {}) {
29
- return [
30
- 'install',
31
- '--global',
32
- ...specs,
33
- ...(preferOnline ? ['--prefer-online'] : []),
34
- '--no-audit',
35
- '--no-fund',
36
- '--ignore-scripts',
37
- '--loglevel=error',
38
- ];
39
- }
40
-
41
- export function npmGlobalInstall(...specs) {
42
- run(npm.command, [...npm.args, ...npmGlobalInstallArgs(specs)]);
43
- }
44
-
45
- function pathEntryExists(file) {
46
- try {
47
- fs.lstatSync(file);
48
- return true;
49
- } catch {
50
- return false;
51
- }
52
- }
53
-
54
- function globalPackagePath(prefix, packageName, platform = servicePlatform()) {
55
- const name = String(packageName || '').trim();
56
- const parts = name.split('/');
57
- const validUnscoped = parts.length === 1 && parts[0] && !parts[0].startsWith('@') && parts[0] !== '.' && parts[0] !== '..';
58
- const validScoped = parts.length === 2 && /^@[^/]+$/.test(parts[0]) && parts[1] && parts[1] !== '.' && parts[1] !== '..';
59
- if (!validUnscoped && !validScoped) throw new Error(`Invalid npm package name for update: ${name || '(empty)'}`);
60
- const modulesRoot = platform === 'win32'
61
- ? path.join(prefix, 'node_modules')
62
- : path.join(prefix, 'lib', 'node_modules');
63
- return path.join(modulesRoot, ...parts);
64
- }
65
-
66
- function managedWindowsRuntimePrefix() {
67
- return path.resolve(
68
- process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'),
69
- 'ReMCP',
70
- 'runtime',
71
- );
72
- }
73
-
74
- function isManagedWindowsRuntimePrefix(prefix) {
75
- const actual = path.resolve(String(prefix || ''));
76
- const expected = managedWindowsRuntimePrefix();
77
- return actual.toLowerCase() === expected.toLowerCase();
78
- }
79
-
80
- function transactionalMacGlobalInstall({ resolved, prefix, packageNames, specs, preferOnline = false }) {
81
- prefix = String(prefix || '').trim();
82
- if (!path.isAbsolute(prefix)) throw new Error(`npm global prefix must be absolute for a macOS update: ${prefix || '(empty)'}`);
83
- const backupRoot = path.join(prefix, `.remcp-update-backup-${process.pid}-${Date.now()}`);
84
- const moved = [];
85
- const moveAside = (source, label) => {
86
- if (!pathEntryExists(source)) return;
87
- const backup = path.join(backupRoot, label);
88
- fs.renameSync(source, backup);
89
- moved.push({ source, backup });
90
- };
91
- const restore = originalError => {
92
- let rollbackError = null;
93
- for (const item of [...moved].reverse()) {
94
- try {
95
- if (pathEntryExists(item.source)) fs.rmSync(item.source, { recursive:true, force:true });
96
- fs.mkdirSync(path.dirname(item.source), { recursive:true });
97
- fs.renameSync(item.backup, item.source);
98
- } catch (error) {
99
- rollbackError ??= error;
100
- }
101
- }
102
- try { fs.rmSync(backupRoot, { recursive:true, force:true }); } catch (error) { rollbackError ??= error; }
103
- if (rollbackError) {
104
- throw new Error(
105
- `ReMCP update failed and the previous macOS installation could not be fully restored: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
106
- { cause:originalError },
107
- );
108
- }
109
- };
110
-
111
- fs.mkdirSync(prefix, { recursive:true });
112
- fs.mkdirSync(backupRoot, { recursive:false, mode:0o700 });
113
- try {
114
- const uniqueNames = [...new Set(packageNames.map(name => String(name || '').trim()).filter(Boolean))];
115
- uniqueNames.forEach((name, index) => moveAside(globalPackagePath(prefix, name), `package-${index}`));
116
- if (uniqueNames.includes(PACKAGE_NAME)) moveAside(path.join(prefix, 'bin', 'remcp'), 'bin-remcp');
117
-
118
- run(resolved.command, [...resolved.args, ...npmGlobalInstallArgs(specs, { preferOnline })]);
119
- } catch (error) {
120
- restore(error);
121
- throw error;
122
- }
123
-
124
- try {
125
- fs.rmSync(backupRoot, { recursive:true, force:true });
126
- } catch (error) {
127
- console.error(`ReMCP update: could not remove macOS rollback backup ${backupRoot}: ${error instanceof Error ? error.message : String(error)}`);
128
- }
129
- }
130
-
131
- function transactionalWindowsGlobalInstall({ resolved, prefix, packageNames, specs, preferOnline = false }) {
132
- prefix = String(prefix || '').trim();
133
- if (!path.isAbsolute(prefix)) throw new Error(`npm global prefix must be absolute for a Windows update: ${prefix || '(empty)'}`);
134
- if (!isManagedWindowsRuntimePrefix(prefix)) {
135
- run(resolved.command, [...resolved.args, ...npmGlobalInstallArgs(specs, { preferOnline })]);
136
- return;
137
- }
138
-
139
- const backupRoot = path.join(prefix, `.remcp-update-backup-${process.pid}-${Date.now()}`);
140
- const moved = [];
141
- const moveAside = (source, label) => {
142
- if (!pathEntryExists(source)) return;
143
- const backup = path.join(backupRoot, label);
144
- fs.renameSync(source, backup);
145
- moved.push({ source, backup });
146
- };
147
- const restore = originalError => {
148
- let rollbackError = null;
149
- for (const item of [...moved].reverse()) {
150
- try {
151
- if (pathEntryExists(item.source)) fs.rmSync(item.source, { recursive:true, force:true });
152
- fs.mkdirSync(path.dirname(item.source), { recursive:true });
153
- fs.renameSync(item.backup, item.source);
154
- } catch (error) {
155
- rollbackError ??= error;
156
- }
157
- }
158
- try { fs.rmSync(backupRoot, { recursive:true, force:true }); } catch (error) { rollbackError ??= error; }
159
- if (rollbackError) {
160
- throw new Error(
161
- `ReMCP update failed and the previous Windows installation could not be fully restored: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
162
- { cause:originalError },
163
- );
164
- }
165
- };
166
-
167
- fs.mkdirSync(prefix, { recursive:true });
168
- fs.mkdirSync(backupRoot, { recursive:false, mode:0o700 });
169
- try {
170
- const uniqueNames = [...new Set(packageNames.map(name => String(name || '').trim()).filter(Boolean))];
171
- uniqueNames.forEach((name, index) => moveAside(globalPackagePath(prefix, name, 'win32'), `package-${index}`));
172
- if (uniqueNames.includes(PACKAGE_NAME)) {
173
- for (const shim of ['remcp', 'remcp.cmd', 'remcp.ps1']) moveAside(path.join(prefix, shim), `bin-${shim}`);
174
- }
175
- run(resolved.command, [...resolved.args, ...npmGlobalInstallArgs(specs, { preferOnline })]);
176
- } catch (error) {
177
- restore(error);
178
- throw error;
179
- }
180
-
181
- try {
182
- fs.rmSync(backupRoot, { recursive:true, force:true });
183
- } catch (error) {
184
- console.error(`ReMCP update: could not remove Windows rollback backup ${backupRoot}: ${error instanceof Error ? error.message : String(error)}`);
185
- }
186
- }
187
-
188
- // Release updates use exact versions that may have been published only moments ago. npm can keep a
189
- // cached pre-publication packument long enough to answer ETARGET after the registry already serves the
190
- // version, so updates revalidate metadata while retaining npm's cache for package bytes.
191
- export function npmGlobalUpdate(packageNames, ...specs) {
192
- const platform = servicePlatform();
193
- const prefix = globalPrefix();
194
- if (platform === 'darwin') {
195
- transactionalMacGlobalInstall({ resolved:npm, prefix, packageNames, specs, preferOnline:true });
196
- return;
197
- }
198
- if (platform === 'win32') {
199
- transactionalWindowsGlobalInstall({ resolved:npm, prefix, packageNames, specs, preferOnline:true });
200
- return;
201
- }
202
- run(npm.command, [...npm.args, ...npmGlobalInstallArgs(specs, { preferOnline:true })]);
203
- }
204
-
205
- export function npmGlobalUpdateForNode(nodePath, packageNames, ...specs) {
206
- const platform = servicePlatform();
207
- const resolved = resolveNpm({ nodePath, home, platform });
208
- const prefix = output(resolved.command, [...resolved.args, 'prefix', '--global']);
209
- if (platform === 'darwin') {
210
- transactionalMacGlobalInstall({ resolved, prefix, packageNames, specs, preferOnline:true });
211
- return;
212
- }
213
- if (platform === 'win32') {
214
- transactionalWindowsGlobalInstall({ resolved, prefix, packageNames, specs, preferOnline:true });
215
- return;
216
- }
217
- run(resolved.command, [...resolved.args, ...npmGlobalInstallArgs(specs, { preferOnline:true })]);
218
- }
219
-
220
19
  export function quoteSystemd(value) {
221
20
  return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
222
21
  }
@@ -471,215 +270,6 @@ export function installWindowsService(cliPath = globalCliPath(), { nodePath = pr
471
270
  // explicit false as the user's opt-out, an explicit true as the current marker, and only infer the
472
271
  // old intent from an OS service artifact when the marker is absent. This keeps legacy installs
473
272
  // repairable without resurrecting a service that a newer client explicitly disabled.
474
- export function persistentServiceExpected(config) {
475
- if (config?.serviceInstalled === false) return false;
476
- if (config?.serviceInstalled === true) return true;
477
- const platform = servicePlatform();
478
- if (platform === 'linux') return fs.existsSync(linuxServiceFile);
479
- if (platform === 'darwin') return fs.existsSync(macServiceFile);
480
- if (platform === 'win32') {
481
- return spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName], { stdio: 'ignore' }).status === 0;
482
- }
483
- return false;
484
- }
485
-
486
- export function persistentServiceState(config) {
487
- const platform = servicePlatform();
488
- const expected = persistentServiceExpected(config);
489
- if (platform === 'linux') {
490
- const installed = fs.existsSync(linuxServiceFile);
491
- const active = installed && spawnSync('systemctl', ['--user', 'is-active', '--quiet', 'remcp-agent.service'], { stdio: 'ignore' }).status === 0;
492
- return { expected, installed, active, manager:'systemd-user', name:'remcp-agent.service' };
493
- }
494
- if (platform === 'darwin') {
495
- const installed = fs.existsSync(macServiceFile);
496
- const target = typeof process.getuid === 'function' ? 'gui/' + process.getuid() + '/' + macServiceLabel : macServiceLabel;
497
- const active = installed && spawnSync('launchctl', ['print', target], { stdio: 'ignore' }).status === 0;
498
- return { expected, installed, active, manager:'launchd', name:macServiceLabel };
499
- }
500
- if (platform === 'win32') {
501
- const query = spawnSync('schtasks.exe', ['/Query', '/TN', windowsTaskName, '/FO', 'LIST'], { encoding:'utf8' });
502
- const installed = query.status === 0;
503
- const outputText = String(query.stdout || '');
504
- const active = installed && /(?:Status|Состояние):\s*Running/i.test(outputText);
505
- return { expected, installed, active, manager:'schtasks', name:windowsTaskName };
506
- }
507
- return { expected:false, installed:false, active:false, manager:null, name:null };
508
- }
509
-
510
- function inferServiceIdentity() {
511
- const platform = servicePlatform();
512
- if (platform === 'linux' && fs.existsSync(linuxServiceLauncherFile)) {
513
- try {
514
- const body = fs.readFileSync(linuxServiceLauncherFile, 'utf8');
515
- const match = body.match(/^exec\s+'([^']+)'\s+'([^']+)'/m);
516
- if (match) return { nodePath:match[1], cliPath:match[2] };
517
- } catch {}
518
- }
519
- if (platform === 'darwin' && fs.existsSync(macServiceFile)) {
520
- try {
521
- const body = fs.readFileSync(macServiceFile, 'utf8');
522
- const args = [...body.matchAll(/<string>([^<]+)<\/string>/g)].map(match => match[1]);
523
- const startIndex = args.indexOf('start');
524
- if (startIndex >= 2) return { nodePath:args[startIndex - 2], cliPath:args[startIndex - 1] };
525
- } catch {}
526
- }
527
- return {};
528
- }
529
-
530
- function canonicalServiceCliPath(config) {
531
- const configured = String(config?.serviceCliPath || '').trim();
532
- if (configured && fs.existsSync(configured)) return configured;
533
- const inferred = inferServiceIdentity().cliPath;
534
- if (inferred && fs.existsSync(inferred)) return inferred;
535
- return globalCliPath();
536
- }
537
-
538
- function canonicalServiceNodePath(config) {
539
- const configured = String(config?.serviceNodePath || '').trim();
540
- if (configured && fs.existsSync(configured)) return configured;
541
- const inferred = inferServiceIdentity().nodePath;
542
- if (inferred && fs.existsSync(inferred)) return inferred;
543
- return process.execPath;
544
- }
545
-
546
- function versionFromPackageJson(file) {
547
- try {
548
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
549
- return typeof parsed.version === 'string' ? parsed.version : null;
550
- } catch {
551
- return null;
552
- }
553
- }
554
-
555
- function packageVersionBesideCli(cliPath, packageName) {
556
- if (!cliPath || !packageName) return null;
557
- const marker = path.sep + 'node_modules' + path.sep;
558
- const index = cliPath.lastIndexOf(marker);
559
- if (index < 0) return null;
560
- const nodeModules = cliPath.slice(0, index + (path.sep + 'node_modules').length);
561
- return versionFromPackageJson(path.join(nodeModules, ...String(packageName).split('/'), 'package.json'));
562
- }
563
-
564
- function packageVersionForCli(cliPath, packageName) {
565
- const beside = packageVersionBesideCli(cliPath, packageName);
566
- if (beside || servicePlatform() !== 'win32' || !cliPath || !packageName) return beside;
567
- // npm's Windows global executable is a .cmd shim in the prefix root, not a symlink into
568
- // node_modules. realpath() therefore cannot reveal the package directory like it does on Unix.
569
- // Resolve the package from the same prefix layout npm uses on Windows.
570
- if (path.basename(cliPath).toLowerCase() !== 'remcp.cmd') return null;
571
- const prefix = path.dirname(cliPath);
572
- return versionFromPackageJson(path.join(prefix, 'node_modules', ...String(packageName).split('/'), 'package.json'));
573
- }
574
-
575
- function exactPackageSpecVersion(packageName, packageSpec) {
576
- const prefix = `${String(packageName || '')}@`;
577
- const spec = String(packageSpec || '');
578
- return prefix !== '@' && spec.startsWith(prefix) ? spec.slice(prefix.length) : '';
579
- }
580
-
581
- // A PowerShell bootstrap already installs the exact client/runtime pair before it starts pairing.
582
- // Reusing that verified pair avoids a second registry mutation during `connect --install` while
583
- // preserving the normal npm path for partial, stale, custom-runtime and non-bootstrap installs.
584
- export function releasePairPresentAtCli(cliPath, config) {
585
- const runtimePackageName = String(config?.runtime?.packageName || '');
586
- const expectedRuntimeVersion = exactPackageSpecVersion(runtimePackageName, config?.runtime?.packageSpec);
587
- if (!cliPath || !runtimePackageName || !expectedRuntimeVersion) return false;
588
- return packageVersionForCli(cliPath, PACKAGE_NAME) === VERSION
589
- && packageVersionForCli(cliPath, runtimePackageName) === expectedRuntimeVersion;
590
- }
591
-
592
- export function installationVersionsAtCliPath(cliPath, runtimePackageName) {
593
- if (!cliPath) return { cliVersion:null, runtimeVersion:null, cliPath:null };
594
- let resolvedCliPath = cliPath;
595
- try { if (fs.existsSync(cliPath)) resolvedCliPath = fs.realpathSync(cliPath); } catch {}
596
- return {
597
- cliVersion:packageVersionForCli(resolvedCliPath, PACKAGE_NAME),
598
- runtimeVersion:packageVersionForCli(resolvedCliPath, runtimePackageName),
599
- cliPath:resolvedCliPath,
600
- };
601
- }
602
-
603
- export function serviceInstallationInfo(config) {
604
- if (!persistentServiceExpected(config)) return { cliVersion:null, runtimeVersion:null, cliPath:null, nodePath:null };
605
- const cliPath = canonicalServiceCliPath(config);
606
- const nodePath = canonicalServiceNodePath(config);
607
- let resolvedCliPath = cliPath;
608
- try { if (cliPath && fs.existsSync(cliPath)) resolvedCliPath = fs.realpathSync(cliPath); } catch {}
609
- return {
610
- cliVersion:packageVersionForCli(resolvedCliPath, PACKAGE_NAME),
611
- runtimeVersion:packageVersionForCli(resolvedCliPath, config?.runtime?.packageName),
612
- cliPath:resolvedCliPath || cliPath,
613
- nodePath,
614
- };
615
- }
616
-
617
- export function currentInstallationInfo(config) {
618
- let cliPath = process.argv[1] || '';
619
- try { if (cliPath && fs.existsSync(cliPath)) cliPath = fs.realpathSync(cliPath); } catch {}
620
- return {
621
- cliVersion:VERSION,
622
- runtimeVersion:config?.runtime?.packageName ? packageVersionForCli(cliPath, config.runtime.packageName) : null,
623
- cliPath:cliPath || null,
624
- nodePath:process.execPath,
625
- };
626
- }
627
-
628
- export function rememberCurrentInstallation(config, { service = false } = {}) {
629
- if (!config || typeof config !== 'object') return config;
630
- const info = currentInstallationInfo(config);
631
- if (!info.cliPath || !info.nodePath) return config;
632
- const key = info.nodePath + '\u0000' + info.cliPath;
633
- const existing = Array.isArray(config.installations) ? config.installations.filter(item => item && typeof item === 'object') : [];
634
- const filtered = existing.filter(item => ((item.nodePath || '') + '\u0000' + (item.cliPath || '')) !== key);
635
- const record = {
636
- nodePath:info.nodePath,
637
- cliPath:info.cliPath,
638
- cliVersion:info.cliVersion,
639
- runtimeVersion:info.runtimeVersion,
640
- service:Boolean(service),
641
- lastSeenAt:new Date().toISOString(),
642
- };
643
- const installations = [...filtered, record].slice(-8);
644
- const next = {
645
- ...config,
646
- installations,
647
- ...(service ? { serviceInstalled:true, serviceCliPath:info.cliPath, serviceNodePath:info.nodePath } : {}),
648
- };
649
- saveConfig(next);
650
- return next;
651
- }
652
-
653
- export function syncKnownInstallations(config, ...specs) {
654
- const serviceInfo = serviceInstallationInfo(config);
655
- const current = currentInstallationInfo(config);
656
- const candidates = [
657
- ...(Array.isArray(config?.installations) ? config.installations : []),
658
- ...(serviceInfo.nodePath ? [{ nodePath:serviceInfo.nodePath, cliPath:serviceInfo.cliPath, service:true }] : []),
659
- ];
660
- const currentNode = (() => { try { return fs.realpathSync(current.nodePath); } catch { return current.nodePath; } })();
661
- const seen = new Set();
662
- const results = [];
663
- for (const candidate of candidates) {
664
- const nodePath = String(candidate?.nodePath || '').trim();
665
- if (!nodePath || !fs.existsSync(nodePath)) continue;
666
- let resolvedNode = nodePath;
667
- try { resolvedNode = fs.realpathSync(nodePath); } catch {}
668
- if (resolvedNode === currentNode || seen.has(resolvedNode)) continue;
669
- seen.add(resolvedNode);
670
- const required = Boolean(candidate?.service) || resolvedNode === serviceInfo.nodePath;
671
- try {
672
- npmGlobalUpdateForNode(resolvedNode, [PACKAGE_NAME, config.runtime.packageName], ...specs);
673
- results.push({ nodePath:resolvedNode, ok:true, required });
674
- } catch (error) {
675
- const message = error instanceof Error ? error.message : String(error);
676
- results.push({ nodePath:resolvedNode, ok:false, required, error:message });
677
- if (required) throw new Error('Could not update canonical ReMCP service installation at ' + resolvedNode + ': ' + message);
678
- }
679
- }
680
- return results;
681
- }
682
-
683
273
  export function configurePostInstallAccess() {
684
274
  const platform = servicePlatform();
685
275
  try {