@jamestesting888/tum-mcp 0.5.0 → 0.6.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.
@@ -0,0 +1,96 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { delimiter, dirname, join, basename, resolve, relative, isAbsolute, sep } from 'node:path';
5
+
6
+ export const packageName = '@jamestesting888/tum-mcp';
7
+
8
+ function canonical(value) {
9
+ let path = resolve(value.replace(/^~(?=$|[/\\])/, homedir()));
10
+ const tail = [];
11
+ while (!existsSync(path)) { tail.unshift(basename(path)); path = dirname(path); }
12
+ return join(realpathSync(path), ...tail);
13
+ }
14
+
15
+ export function packageStorage(root, dataDirectory) {
16
+ const home = homedir();
17
+ const base = process.platform === 'darwin' ? join(home, 'Library/Application Support')
18
+ : process.platform === 'win32' ? process.env.LOCALAPPDATA || join(home, 'AppData/Local')
19
+ : process.env.XDG_DATA_HOME || join(home, '.local/share');
20
+ const storage = {
21
+ TUM_MCP_DATA_DIR: canonical(dataDirectory),
22
+ TUM_MCP_CONFIG_DIR: canonical(process.env.TUM_MCP_CONFIG_DIR || join(dataDirectory, 'config')),
23
+ TUM_MOODLE_DATA_DIR: canonical(process.env.TUM_MOODLE_DATA_DIR || join(base, 'TUMMoodle')),
24
+ TUM_MOODLE_DOWNLOAD_DIR: canonical(process.env.TUM_MOODLE_DOWNLOAD_DIR || join(home, 'Downloads/TUM Moodle')),
25
+ };
26
+ const config = storage.TUM_MCP_CONFIG_DIR;
27
+ const settings = join(config, 'preferences.json');
28
+ let workspace = join(home, 'University');
29
+ if (existsSync(settings)) {
30
+ try {
31
+ const prefs = JSON.parse(readFileSync(settings, 'utf8'));
32
+ if (prefs.workspace_root !== undefined) {
33
+ if (typeof prefs.workspace_root !== 'string') throw new Error('Invalid storage path');
34
+ workspace = prefs.workspace_root;
35
+ }
36
+ } catch { throw new Error('Storage settings could not be verified; package files and saved data were preserved.'); }
37
+ }
38
+ for (const path of [...Object.values(storage), workspace]) {
39
+ const inside = relative(root, canonical(path));
40
+ if (inside === '' || (inside.split(sep)[0] !== '..' && !isAbsolute(inside))) {
41
+ throw new Error('Package installation overlaps saved data or course work. Move that data outside the package before update/removal.');
42
+ }
43
+ }
44
+ return storage;
45
+ }
46
+
47
+ export function npmCli() {
48
+ const paths = (process.env.PATH || '').split(delimiter);
49
+ const candidates = [process.env.npm_execpath,
50
+ join(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'),
51
+ join(dirname(dirname(process.execPath)), 'lib/node_modules/npm/bin/npm-cli.js'),
52
+ ...paths.flatMap(path => [join(path, 'npm'), join(path, 'node_modules/npm/bin/npm-cli.js')])];
53
+ for (const path of candidates) {
54
+ if (!path || !existsSync(path)) continue;
55
+ const resolved = realpathSync(path);
56
+ if (basename(resolved) !== 'npm-cli.js') continue;
57
+ const manifest = join(dirname(dirname(resolved)), 'package.json');
58
+ if (existsSync(manifest) && JSON.parse(readFileSync(manifest, 'utf8')).name === 'npm') return resolved;
59
+ }
60
+ throw new Error('npm could not be located beside Node or on PATH. Install Node.js with npm, then retry.');
61
+ }
62
+
63
+ export function runNpm(cli, args, capture = false) {
64
+ const result = spawnSync(process.execPath, [cli, ...args, '--ignore-scripts', '--no-audit', '--no-fund', '--no-update-notifier'], {
65
+ shell: false, stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit', encoding: 'utf8', timeout: 600000,
66
+ });
67
+ if (result.error || result.status !== 0) throw new Error('npm operation failed. Saved data and retained client runtimes were preserved; resolve the npm error and retry.');
68
+ return capture ? result.stdout.trim() : '';
69
+ }
70
+
71
+ export function installationTarget(root) {
72
+ const modules = dirname(dirname(root));
73
+ if (basename(modules) !== 'node_modules' || modules.split(/[\\/]/).some(part => ['.pnpm', '_npx'].includes(part))) {
74
+ throw new Error('Package management requires an npm installation. Use update --local for a source/pinned build.');
75
+ }
76
+ if (`${basename(dirname(root))}/${basename(root)}` !== packageName) {
77
+ throw new Error('Automatic package management requires the original package name; aliases were preserved.');
78
+ }
79
+ const parent = dirname(modules);
80
+ const prefix = process.platform === 'win32' ? parent : dirname(parent);
81
+ const shim = process.platform === 'win32' ? join(prefix, 'tum-mcp.cmd') : join(prefix, 'bin/tum-mcp');
82
+ if (existsSync(shim)) {
83
+ const owned = process.platform === 'win32'
84
+ ? readFileSync(shim, 'utf8').replaceAll('\\', '/').includes('node_modules/@jamestesting888/tum-mcp/npm/tum-mcp.mjs')
85
+ : realpathSync(shim) === join(root, 'npm/tum-mcp.mjs');
86
+ if (owned && (process.platform === 'win32' || basename(parent) === 'lib')) return { prefix, global: true };
87
+ }
88
+ const manifest = join(parent, 'package.json');
89
+ if (existsSync(manifest)) {
90
+ const project = JSON.parse(readFileSync(manifest, 'utf8'));
91
+ if (['dependencies', 'devDependencies', 'optionalDependencies'].some(key => project[key]?.[packageName])) {
92
+ return { prefix: parent, global: false };
93
+ }
94
+ }
95
+ throw new Error('The npm installation prefix could not be verified; package files were preserved.');
96
+ }
@@ -0,0 +1,109 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { copyFile, mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { acquireLock } from './lock.mjs';
8
+ import { installationTarget, npmCli, packageName, packageStorage, runNpm } from './npm-command.mjs';
9
+
10
+ function runLauncher(root, args, quiet = false) {
11
+ const result = spawnSync(process.execPath, [join(root, 'npm/tum-mcp.mjs'), ...args], {
12
+ stdio: quiet ? ['ignore', 'pipe', 'pipe'] : 'inherit', encoding: 'utf8', shell: false, timeout: 600000,
13
+ });
14
+ if (result.error || result.status !== 0) {
15
+ if (quiet) process.stderr.write((result.stdout || '') + (result.stderr || ''));
16
+ throw new Error(`${args[0]} did not complete. Saved data and customized client files were preserved; review the details and retry.`);
17
+ }
18
+ }
19
+
20
+ function versionParts(version) {
21
+ const match = typeof version === 'string' && version.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?(?:\+[\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*)?$/);
22
+ if (!match) throw new Error('npm returned an invalid package version.');
23
+ const prerelease = match[4]?.split('.') || [];
24
+ if (prerelease.some(part => /^0\d+$/.test(part))) throw new Error('npm returned an invalid prerelease version.');
25
+ return { core: match.slice(1, 4).map(BigInt), prerelease };
26
+ }
27
+
28
+ export function newerVersion(latest, current) {
29
+ const left = versionParts(latest), right = versionParts(current);
30
+ for (let i = 0; i < left.core.length; i++) if (left.core[i] !== right.core[i]) return left.core[i] > right.core[i];
31
+ const a = left.prerelease, b = right.prerelease;
32
+ if (!a.length || !b.length) return !a.length && Boolean(b.length);
33
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
34
+ if (a[i] === undefined) return false;
35
+ if (b[i] === undefined) return true;
36
+ if (a[i] === b[i]) continue;
37
+ const numericA = /^\d+$/.test(a[i]), numericB = /^\d+$/.test(b[i]);
38
+ if (numericA && numericB) return BigInt(a[i]) > BigInt(b[i]);
39
+ if (numericA !== numericB) return numericB;
40
+ return a[i] > b[i];
41
+ }
42
+ return false;
43
+ }
44
+
45
+ async function operate(job) {
46
+ const release = await acquireLock(job.target.prefix, 'tum-mcp-package');
47
+ try {
48
+ const scope = ['--prefix', job.target.prefix, `--global=${job.target.global}`];
49
+ if (job.action === 'uninstall') {
50
+ const configured = existsSync(join(job.dataDirectory, 'runtime.json')) ||
51
+ existsSync(join(job.configDirectory, 'clients'));
52
+ if (configured || job.args.some(arg => ['--purge-runtime', '--forget-credentials'].includes(arg))) {
53
+ runLauncher(job.root, ['uninstall', '--keep-package', ...job.args]);
54
+ }
55
+ runNpm(job.npm, ['uninstall', ...scope, packageName]);
56
+ console.log('TUM MCP package and managed client setup removed. Saved user data was retained unless explicitly requested.');
57
+ return;
58
+ }
59
+ const current = JSON.parse(readFileSync(join(job.root, 'package.json'), 'utf8')).version;
60
+ const latest = JSON.parse(runNpm(job.npm, ['view', `${packageName}@latest`, 'version', '--json', ...scope], true));
61
+ if (!newerVersion(latest, current)) {
62
+ console.log(`Installed ${current}; no newer npm release. Refreshing configured clients.`);
63
+ runLauncher(job.root, ['update', '--local']);
64
+ return;
65
+ }
66
+ console.log(`Preparing TUM MCP ${latest}.`);
67
+ const packed = JSON.parse(runNpm(job.npm, ['pack', `${packageName}@${latest}`, '--pack-destination', job.temporary, '--json', ...scope], true));
68
+ if (!Array.isArray(packed) || packed.length !== 1 || packed[0].name !== packageName || packed[0].version !== latest || basename(packed[0].filename) !== packed[0].filename) {
69
+ throw new Error('npm package metadata did not match the selected release.');
70
+ }
71
+ const archive = join(job.temporary, packed[0].filename);
72
+ const staging = join(job.temporary, 'prepared');
73
+ runNpm(job.npm, ['install', '--prefix', staging, '--global=false', archive], true);
74
+ const prepared = join(staging, 'node_modules', packageName);
75
+ runLauncher(prepared, ['setup'], true);
76
+ // Only a healthy prepared release may replace the user's npm installation.
77
+ runNpm(job.npm, ['install', ...scope, job.target.global ? archive : `${packageName}@${latest}`]);
78
+ if (JSON.parse(readFileSync(join(job.root, 'package.json'), 'utf8')).version !== latest) throw new Error('npm did not install the selected version; client activation was stopped.');
79
+ runLauncher(job.root, ['update', '--local']);
80
+ console.log(`Updated TUM MCP to ${latest}. Restart clients to load refreshed skills.`);
81
+ } finally { await release(); }
82
+ }
83
+
84
+ export async function managePackage(action, args, dataDirectory) {
85
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
86
+ const inside = relative(root, process.cwd());
87
+ if (inside === '' || (inside.split(sep)[0] !== '..' && !isAbsolute(inside))) throw new Error('Run package update/removal from outside the installed package directory.');
88
+ const target = installationTarget(root);
89
+ const storage = packageStorage(root, dataDirectory);
90
+ const configDirectory = storage.TUM_MCP_CONFIG_DIR;
91
+ const npm = npmCli();
92
+ const temporary = await mkdtemp(join(tmpdir(), 'tum-mcp-package-'));
93
+ try {
94
+ for (const name of ['package-manager.mjs', 'npm-command.mjs', 'lock.mjs']) await copyFile(join(root, 'npm', name), join(temporary, name));
95
+ const job = { action, args, root, dataDirectory, configDirectory, target, npm, temporary };
96
+ const path = join(temporary, 'job.json');
97
+ await writeFile(path, JSON.stringify(job), { mode: 0o600, flag: 'wx' });
98
+ // This worker and its cwd survive Windows replacing/removing the package files.
99
+ const result = spawnSync(process.execPath, [join(temporary, 'package-manager.mjs'), '--job', path], {
100
+ cwd: temporary, stdio: 'inherit', shell: false, env: { ...process.env, ...storage },
101
+ });
102
+ return result.status ?? (result.signal === 'SIGINT' ? 130 : 2);
103
+ } finally { await rm(temporary, { recursive: true, force: true }); }
104
+ }
105
+
106
+ if (process.argv[1] && process.argv[2] === '--job' && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
107
+ try { await operate(JSON.parse(readFileSync(process.argv[3], 'utf8'))); }
108
+ catch (error) { console.error(error.message); process.exitCode = 2; }
109
+ }
package/npm/runtime.mjs CHANGED
@@ -1,96 +1,212 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
- import { existsSync, readFileSync } from 'node:fs';
4
- import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
4
+ import { chmod, mkdir, mkdtemp, rename, rm, rmdir, writeFile } from 'node:fs/promises';
5
5
  import { homedir } from 'node:os';
6
- import { dirname, join } from 'node:path';
6
+ import { basename, dirname, join, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { download } from './download.mjs';
9
+ import { acquireLock } from './lock.mjs';
9
10
 
10
11
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
11
12
  const pins = JSON.parse(readFileSync(join(packageRoot, 'npm/uv-artifacts.json'), 'utf8'));
13
+ const packageVersion = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
12
14
 
13
15
  export function dataRoot() {
14
- if (process.env.TUM_MCP_DATA_DIR) return process.env.TUM_MCP_DATA_DIR;
15
- if (process.platform === 'darwin') return join(homedir(), 'Library/Application Support/TUMMCP');
16
- if (process.platform === 'win32') return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData/Local'), 'TUMMCP');
17
- return join(process.env.XDG_DATA_HOME || join(homedir(), '.local/share'), 'TUMMCP');
16
+ let path = process.env.TUM_MCP_DATA_DIR;
17
+ if (!path) {
18
+ if (process.platform === 'darwin') path = join(homedir(), 'Library/Application Support/TUMMCP');
19
+ else if (process.platform === 'win32') path = join(process.env.LOCALAPPDATA || join(homedir(), 'AppData/Local'), 'TUMMCP');
20
+ else path = join(process.env.XDG_DATA_HOME || join(homedir(), '.local/share'), 'TUMMCP');
21
+ }
22
+ path = resolve(path.replace(/^~(?=$|[/\\])/, homedir()));
23
+ const tail = [];
24
+ while (!existsSync(path)) { tail.unshift(basename(path)); path = dirname(path); }
25
+ return join(realpathSync(path), ...tail);
26
+ }
27
+
28
+ function manifest() {
29
+ const value = JSON.parse(readFileSync(join(packageRoot, 'npm/backend/manifest.json'), 'utf8'));
30
+ if (!/^[a-zA-Z0-9_.-]+\.whl$/.test(value.wheel) ||
31
+ !/^[a-f0-9]{64}$/.test(value.wheelSha256) || !/^[a-f0-9]{64}$/.test(value.requirementsSha256)) {
32
+ throw new Error('Invalid backend manifest; reinstall the npm package.');
33
+ }
34
+ return value;
18
35
  }
19
36
 
20
- function run(executable, args) {
21
- const env = { ...process.env };
22
- delete env.VIRTUAL_ENV; delete env.PYTHONPATH; delete env.PYTHONHOME;
23
- const result = spawnSync(executable, args, { env, stdio: ['ignore', 'inherit', 'inherit'], shell: false, timeout: 600000 });
24
- if (result.error) throw new Error(`Managed runtime subprocess could not start (${result.error.code || 'unknown'}); check executable permissions and mount options.`);
25
- if (result.status !== 0) throw new Error('Managed runtime setup failed; inspect the diagnostics above.');
37
+ export function runtimeIdentity(value = manifest()) {
38
+ return createHash('sha256').update(JSON.stringify({ schema: 2, wheel: value.wheelSha256,
39
+ requirements: value.requirementsSha256, python: pins.python })).digest('hex');
26
40
  }
27
41
 
42
+ function controlDirectory(identity) { return join(dataRoot(), 'runtimes', identity); }
43
+ function pythonPath(runtime) { return join(runtime, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'); }
44
+
28
45
  export function currentPython() {
29
46
  try {
30
- const current = JSON.parse(readFileSync(join(dataRoot(), 'runtime.json'), 'utf8'));
31
- if (!/^[a-f0-9]{64}$/.test(current.identity)) return null;
32
- const manifest = JSON.parse(readFileSync(join(packageRoot, 'npm/backend/manifest.json'), 'utf8'));
33
- if (current.identity !== manifest.wheelSha256) return null;
34
- const runtime = join(dataRoot(), 'runtimes', current.identity);
35
- const expected = join(runtime, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python');
36
- if (current.python !== expected || !existsSync(expected)) return null;
37
- if (readFileSync(join(runtime, '.tum-mcp-ready'), 'utf8') !== current.identity) return null;
47
+ const identity = runtimeIdentity();
48
+ const control = controlDirectory(identity);
49
+ const selected = JSON.parse(readFileSync(join(control, 'current.json'), 'utf8'));
50
+ if (selected.schema !== 2 || selected.identity !== identity || !/^env-[a-zA-Z0-9]+$/.test(selected.generation)) return null;
51
+ const runtime = join(control, selected.generation);
52
+ const expected = pythonPath(runtime);
53
+ if (selected.python !== expected || !existsSync(expected)) return null;
54
+ if ([join(dataRoot(), 'runtimes'), control, runtime, join(runtime, '.tum-mcp-ready')].some(path => lstatSync(path).isSymbolicLink())) return null;
55
+ if (readFileSync(join(runtime, '.tum-mcp-ready'), 'utf8') !== identity) return null;
38
56
  return expected;
39
57
  } catch { return null; }
40
58
  }
41
59
 
60
+ function run(executable, args, { quiet = false, timeout = 600000 } = {}) {
61
+ const env = { ...process.env }; delete env.VIRTUAL_ENV; delete env.PYTHONPATH; delete env.PYTHONHOME;
62
+ const result = spawnSync(executable, args, { env, stdio: quiet ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit'], shell: false, timeout });
63
+ if (result.error) throw new Error(`Managed runtime subprocess could not start (${result.error.code || 'unknown'}); check permissions and retry setup.`);
64
+ if (result.status !== 0) throw new Error('Managed runtime setup failed; the previous runtime was retained.');
65
+ return result;
66
+ }
67
+
68
+ function healthy(python) {
69
+ try {
70
+ const result = run(python, ['-I', '-c', 'from tum_mcp.server import server; from tum_mcp import __version__; print(__version__)'], { quiet: true, timeout: 30000 });
71
+ return result.stdout.toString().trim() === packageVersion;
72
+ } catch { return false; }
73
+ }
74
+
75
+ async function atomicWrite(path, data) {
76
+ const temporary = `${path}.${randomUUID()}.pending`;
77
+ try {
78
+ await writeFile(temporary, data, { mode: 0o600, flag: 'wx' });
79
+ await rename(temporary, path);
80
+ } finally { await rm(temporary, { force: true }); }
81
+ }
82
+
42
83
  function matchingUv(executable) {
43
84
  const found = spawnSync(executable, ['--version'], { encoding: 'utf8', shell: false, timeout: 10000 });
44
85
  return found.status === 0 && found.stdout.trim().split(' ')[1] === pins.version;
45
86
  }
46
87
 
88
+ async function uvExecutable() {
89
+ const cached = join(dataRoot(), process.platform === 'win32' ? 'uv.exe' : 'uv');
90
+ if (matchingUv('uv')) return 'uv';
91
+ if (matchingUv(cached)) {
92
+ await atomicWrite(join(dataRoot(), 'uv-owner.json'), JSON.stringify({ sha256: createHash('sha256').update(readFileSync(cached)).digest('hex') }));
93
+ return cached;
94
+ }
95
+ const systems = { darwin: 'apple-darwin', linux: 'unknown-linux-gnu', win32: 'pc-windows-msvc' };
96
+ const arches = { arm64: 'aarch64', x64: 'x86_64' };
97
+ const target = `${arches[process.arch]}-${systems[process.platform]}`;
98
+ const artifact = pins.artifacts[target];
99
+ if (!artifact) throw new Error('Unsupported platform/architecture; use a documented manual installation.');
100
+ const temporary = await mkdtemp(join(dataRoot(), 'bootstrap-'));
101
+ try {
102
+ const archive = join(temporary, process.platform === 'win32' ? 'uv.zip' : 'uv.tar.gz');
103
+ await download(artifact.url, archive, artifact.sha256);
104
+ run('tar', ['-xf', archive, '-C', temporary]);
105
+ const extracted = process.platform === 'win32' ? join(temporary, 'uv.exe') : join(temporary, `uv-${target}`, 'uv');
106
+ await rename(extracted, cached);
107
+ if (process.platform !== 'win32') await chmod(cached, 0o700);
108
+ } finally { await rm(temporary, { recursive: true, force: true }); }
109
+ await atomicWrite(join(dataRoot(), 'uv-owner.json'), JSON.stringify({ sha256: createHash('sha256').update(readFileSync(cached)).digest('hex') }));
110
+ return cached;
111
+ }
112
+
113
+ export function acquireSetupLock() { return acquireLock(dataRoot()); }
114
+
47
115
  export async function ensureRuntime() {
48
116
  const backend = join(packageRoot, 'npm/backend');
49
- const manifest = JSON.parse(readFileSync(join(backend, 'manifest.json'), 'utf8'));
50
- if (!/^[a-zA-Z0-9_.-]+\.whl$/.test(manifest.wheel)) throw new Error('Invalid backend manifest.');
51
- for (const [name, expected] of [[manifest.wheel, manifest.wheelSha256], ['requirements.txt', manifest.requirementsSha256]]) {
117
+ const value = manifest();
118
+ for (const [name, expected] of [[value.wheel, value.wheelSha256], ['requirements.txt', value.requirementsSha256]]) {
52
119
  if (createHash('sha256').update(readFileSync(join(backend, name))).digest('hex') !== expected) throw new Error('Backend integrity check failed.');
53
120
  }
54
- const identity = manifest.wheelSha256;
55
- const runtime = join(dataRoot(), 'runtimes', identity);
56
- const python = join(runtime, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python');
121
+ const identity = runtimeIdentity(value);
57
122
  await mkdir(dataRoot(), { recursive: true, mode: 0o700 });
58
- // A per-runtime directory lock prevents concurrent setup from corrupting a venv.
59
- const lock = join(dataRoot(), 'setup.lock');
60
- try { await mkdir(lock, { mode: 0o700 }); }
61
- catch (error) { if (error.code === 'EEXIST') throw new Error('Another setup is running; retry when it finishes.'); throw error; }
123
+ const release = await acquireSetupLock();
124
+ let preparing;
62
125
  try {
63
- const ready = join(runtime, '.tum-mcp-ready');
64
- if (!existsSync(python) || !existsSync(ready) || readFileSync(ready, 'utf8') !== identity) {
65
- const cachedUv = join(dataRoot(), process.platform === 'win32' ? 'uv.exe' : 'uv');
66
- let uv = matchingUv('uv') ? 'uv' : matchingUv(cachedUv) ? cachedUv : null;
67
- if (!uv) {
68
- const systems = { darwin: 'apple-darwin', linux: 'unknown-linux-gnu', win32: 'pc-windows-msvc' };
69
- const arches = { arm64: 'aarch64', x64: 'x86_64' };
70
- const target = `${arches[process.arch]}-${systems[process.platform]}`;
71
- const artifact = pins.artifacts[target];
72
- if (!artifact) throw new Error('Unsupported platform/architecture; use a documented manual installation.');
73
- const temporary = await mkdtemp(join(dataRoot(), 'bootstrap-'));
74
- try {
75
- const archive = join(temporary, process.platform === 'win32' ? 'uv.zip' : 'uv.tar.gz');
76
- await download(artifact.url, archive, artifact.sha256);
77
- run('tar', ['-xf', archive, '-C', temporary]);
78
- const extracted = process.platform === 'win32' ? join(temporary, 'uv.exe') : join(temporary, `uv-${target}`, 'uv');
79
- uv = join(dataRoot(), process.platform === 'win32' ? 'uv.exe' : 'uv');
80
- await rename(extracted, uv); if (process.platform !== 'win32') await chmod(uv, 0o700);
81
- } finally { await rm(temporary, { recursive: true, force: true }); }
126
+ let python = currentPython();
127
+ if (!python || !healthy(python)) {
128
+ const uv = await uvExecutable();
129
+ const control = controlDirectory(identity);
130
+ for (const path of [join(dataRoot(), 'runtimes'), control]) {
131
+ if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error('Symbolic runtime directory preserved; review it locally.');
82
132
  }
83
- await mkdir(dirname(runtime), { recursive: true, mode: 0o700 });
84
- if (existsSync(runtime)) await rm(runtime, { recursive: true, force: true });
85
- run(uv, ['venv', '--managed-python', '--python', pins.python, runtime]);
133
+ await mkdir(control, { recursive: true, mode: 0o700 });
134
+ await atomicWrite(join(control, 'runtime-owner.json'), JSON.stringify({ kind: 'tum-mcp-runtime', schema: 2, identity }));
135
+ preparing = await mkdtemp(join(control, 'env-'));
136
+ python = pythonPath(preparing);
137
+ run(uv, ['venv', '--managed-python', '--python', pins.python, preparing]);
86
138
  run(uv, ['pip', 'sync', '--python', python, '--require-hashes', '--only-binary', ':all:', join(backend, 'requirements.txt')]);
87
- run(uv, ['pip', 'install', '--python', python, '--no-deps', '--no-index', join(backend, manifest.wheel)]);
139
+ run(uv, ['pip', 'install', '--python', python, '--no-deps', '--no-index', join(backend, value.wheel)]);
140
+ if (!healthy(python)) throw new Error('New runtime validation failed; the previous runtime was retained.');
141
+ await atomicWrite(join(preparing, '.tum-mcp-ready'), identity);
142
+ const selected = JSON.stringify({ schema: 2, identity, generation: basename(preparing), python });
143
+ await atomicWrite(join(control, 'current.json'), selected);
144
+ // Published generations are never removed: existing clients may still pin one.
145
+ preparing = undefined;
146
+ await atomicWrite(join(dataRoot(), 'runtime.json'), selected);
88
147
  }
89
- try { run(python, ['-I', '-m', 'tum_mcp', 'version']); }
90
- catch (error) { await rm(ready, { force: true }); throw error; }
91
- await writeFile(ready, identity, { mode: 0o600 });
92
- await writeFile(join(dataRoot(), 'runtime.pending.json'), JSON.stringify({ identity, python }), { mode: 0o600 });
93
- await rename(join(dataRoot(), 'runtime.pending.json'), join(dataRoot(), 'runtime.json'));
148
+ const selected = readFileSync(join(controlDirectory(identity), 'current.json'), 'utf8');
149
+ const global = join(dataRoot(), 'runtime.json');
150
+ if (!existsSync(global) || readFileSync(global, 'utf8') !== selected) await atomicWrite(global, selected);
151
+ await rm(join(dataRoot(), 'runtime-removal.pending'), { force: true });
94
152
  return python;
95
- } finally { await rm(lock, { recursive: true, force: true }); }
153
+ } finally {
154
+ try { if (preparing) await rm(preparing, { recursive: true, force: true }); }
155
+ finally { await release(); }
156
+ }
157
+ }
158
+
159
+
160
+ export async function removeRuntimeFiles() {
161
+ const root = dataRoot();
162
+ const directory = join(root, 'runtimes');
163
+ if (!existsSync(join(root, 'runtime-removal.pending'))) throw new Error('Runtime removal must be requested with uninstall --all --purge-runtime.');
164
+ const owned = [];
165
+ if (existsSync(directory)) {
166
+ if (lstatSync(directory).isSymbolicLink()) throw new Error('Symbolic runtime directory preserved for review.');
167
+ for (const name of readdirSync(directory)) {
168
+ const runtime = join(directory, name);
169
+ if (!/^[a-f0-9]{64}$/.test(name) || lstatSync(runtime).isSymbolicLink()) throw new Error('Unrecognized runtime files were preserved for review.');
170
+ let verified = false;
171
+ const owner = join(runtime, 'runtime-owner.json');
172
+ if (existsSync(owner) && !lstatSync(owner).isSymbolicLink()) {
173
+ const record = JSON.parse(readFileSync(owner, 'utf8'));
174
+ verified = record.kind === 'tum-mcp-runtime' && record.schema === 2 && record.identity === name;
175
+ }
176
+ const legacy = join(runtime, '.tum-mcp-ready');
177
+ if (!verified && existsSync(legacy) && !lstatSync(legacy).isSymbolicLink()) verified = readFileSync(legacy, 'utf8') === name;
178
+ if (!verified) throw new Error('Runtime ownership could not be verified; files were preserved.');
179
+ owned.push(runtime);
180
+ }
181
+ }
182
+ const pointer = join(root, 'runtime.json');
183
+ let removePointer = false;
184
+ if (existsSync(pointer)) {
185
+ let current;
186
+ try { current = JSON.parse(readFileSync(pointer, 'utf8')); }
187
+ catch { throw new Error('Runtime selection metadata is unreadable; runtimes were preserved. Run setup to repair it.'); }
188
+ removePointer = /^[a-f0-9]{64}$/.test(current.identity) && owned.includes(join(directory, current.identity));
189
+ }
190
+ // All candidates are checked before deletion; never remove the campus data root.
191
+ for (const runtime of owned) await rm(runtime, { recursive: true });
192
+ if (existsSync(directory) && readdirSync(directory).length === 0) await rmdir(directory);
193
+ if (removePointer) await rm(pointer);
194
+ const uv = join(root, process.platform === 'win32' ? 'uv.exe' : 'uv');
195
+ const uvOwner = join(root, 'uv-owner.json');
196
+ if (existsSync(uvOwner) && existsSync(uv)) {
197
+ const expected = JSON.parse(readFileSync(uvOwner, 'utf8')).sha256;
198
+ if (!lstatSync(uv).isSymbolicLink() && createHash('sha256').update(readFileSync(uv)).digest('hex') === expected) {
199
+ await rm(uv); await rm(uvOwner);
200
+ } else console.error('Modified bootstrap cache was preserved for local review.');
201
+ }
202
+ const leases = join(root, 'runtime-leases');
203
+ if (existsSync(leases) && !lstatSync(leases).isSymbolicLink()) {
204
+ for (const name of readdirSync(leases)) {
205
+ const lock = join(leases, name);
206
+ if (/^[a-f0-9]{32}\.lock$/.test(name) && !lstatSync(lock).isSymbolicLink()) await rm(lock);
207
+ }
208
+ if (readdirSync(leases).length === 0) await rmdir(leases);
209
+ }
210
+ await rm(join(root, 'runtime-removal.pending'), { force: true });
211
+ console.log(`Removed ${owned.length} managed runtime(s). Saved user data was retained.`);
96
212
  }
package/npm/tum-mcp.mjs CHANGED
@@ -1,21 +1,58 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from 'node:child_process';
3
- import { currentPython, ensureRuntime } from './runtime.mjs';
3
+ import { readFileSync } from 'node:fs';
4
+ import { parseArgs } from 'node:util';
5
+ import { acquireSetupLock, currentPython, dataRoot, ensureRuntime, removeRuntimeFiles } from './runtime.mjs';
6
+ import { managePackage } from './package-manager.mjs';
4
7
 
5
- const args = process.argv.slice(2);
8
+ let args = process.argv.slice(2);
9
+ const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
6
10
  try {
7
11
  if (args.length === 0 || (args.length === 1 && ['--help', '-h'].includes(args[0]))) {
8
- console.log('TUM MCP 0.5.0\nCommands: setup [--client codex|claude|cursor|antigravity|generic], connect, scope, discover, status, doctor [--json] [--live], serve, skills, workspace, actions, logout, uninstall, update\nRun connect without arguments to select services and an installed Chromium browser. Run setup explicitly to provision the managed Python backend.');
12
+ console.log(`TUM MCP ${version}\nCommands: setup [--client codex|claude|cursor|antigravity|generic], connect, scope, discover, status, doctor [--json] [--live], serve, skills, study, logout, uninstall, update\nRun connect without arguments to select services and an installed Chromium browser. Run setup explicitly to provision the managed Python backend.`);
9
13
  } else if (['--version', 'version'].includes(args[0])) {
10
- console.log('0.5.0');
14
+ console.log(version);
15
+ } else if (['setup', 'update', 'uninstall'].includes(args[0]) && args.some(arg => ['--help', '-h'].includes(arg))) {
16
+ const help = {
17
+ update: 'Usage: tum-mcp update [--local]\nInstall the latest npm release and refresh configured clients.\n--local refreshes this installed version without contacting npm.',
18
+ setup: 'Usage: tum-mcp setup [--client codex|claude|cursor|antigravity|generic] [--host local|windows]\nProvision the managed runtime and register explicitly selected clients.',
19
+ uninstall: 'Usage: tum-mcp uninstall [--all] [--purge-runtime] [--forget-credentials]\nRemove managed client setup and this npm installation. Saved data is retained by default.\n--client NAME removes one client only. --keep-package keeps npm installed.',
20
+ };
21
+ console.log(help[args[0]]);
11
22
  } else {
12
- const setup = ['setup', 'update'].includes(args[0]);
13
- const python = setup ? await ensureRuntime() : currentPython();
14
- if (!python) throw new Error('SETUP_REQUIRED: Run tum-mcp setup first.');
15
- const forwarded = args[0] === 'update' ? ['status'] : args;
16
- const env = { ...process.env }; delete env.PYTHONPATH; delete env.PYTHONHOME;
17
- const child = spawnSync(python, ['-I', '-m', 'tum_mcp', ...forwarded], { env, stdio: 'inherit', shell: false });
18
- if (child.error) throw new Error('Managed backend could not start; run setup again.');
19
- process.exitCode = child.status ?? 1;
23
+ let packageAction = args[0] === 'update';
24
+ if (args[0] === 'update') {
25
+ if (args.length > 2 || (args.length === 2 && args[1] !== '--local')) throw new Error('Usage: tum-mcp update [--local]. Use --help for details.');
26
+ if (args[1] === '--local') { packageAction = false; args = ['update']; }
27
+ }
28
+ if (args[0] === 'uninstall') {
29
+ const { values } = parseArgs({ args: args.slice(1), options: {
30
+ all: { type: 'boolean' }, client: { type: 'string' }, host: { type: 'string' },
31
+ 'keep-package': { type: 'boolean' }, 'purge-runtime': { type: 'boolean' }, 'forget-credentials': { type: 'boolean' },
32
+ } });
33
+ if (values.host && !['local', 'windows'].includes(values.host)) throw new Error('Choose --host local or windows.');
34
+ if (values.client !== undefined && !['codex', 'claude', 'cursor', 'antigravity', 'generic'].includes(values.client)) throw new Error('Choose a supported --client name.');
35
+ if (values.client && (values.all || values['purge-runtime'] || values['forget-credentials'])) throw new Error('Client-only removal cannot use --all or data/runtime cleanup options.');
36
+ if (!values.client && !values.all) args.push('--all');
37
+ packageAction = !values.client && !values['keep-package'];
38
+ args = args.filter(arg => arg !== '--keep-package');
39
+ }
40
+ if (packageAction) {
41
+ process.exitCode = await managePackage(args[0], args.slice(1), dataRoot());
42
+ } else {
43
+ let python = ['setup', 'update'].includes(args[0]) ? await ensureRuntime() : currentPython();
44
+ if (args[0] === 'uninstall' && !python) python = await ensureRuntime();
45
+ if (!python) throw new Error('SETUP_REQUIRED: Run tum-mcp setup first.');
46
+ const forwarded = args;
47
+ const env = { ...process.env, TUM_MCP_NPM_LAUNCHER: '1' }; delete env.PYTHONPATH; delete env.PYTHONHOME;
48
+ const purge = args[0] === 'uninstall' && args.includes('--purge-runtime');
49
+ const release = purge ? await acquireSetupLock() : null;
50
+ try {
51
+ const child = spawnSync(python, ['-I', '-m', 'tum_mcp', ...forwarded], { env, stdio: 'inherit', shell: false });
52
+ if (child.error) throw new Error('Managed backend could not start; run setup again.');
53
+ process.exitCode = child.status ?? (child.signal === 'SIGINT' ? 130 : 1);
54
+ if (purge && child.status === 0) await removeRuntimeFiles();
55
+ } finally { if (release) await release(); }
56
+ }
20
57
  }
21
58
  } catch (error) { console.error(error.message); process.exitCode = 2; }
package/package.json CHANGED
@@ -1,16 +1,59 @@
1
1
  {
2
2
  "name": "@jamestesting888/tum-mcp",
3
- "version": "0.5.0",
4
- "description": "Local-first TUM Moodle, Artemis, and Zulip integration with campus workflow skills",
3
+ "version": "0.6.1",
4
+ "description": "Read-only TUM Moodle, Artemis and Zulip integration with source-backed study workflows",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "bin": {"tum-mcp": "npm/tum-mcp.mjs"},
8
- "engines": {"node": ">=22.14.0"},
9
- "files": ["npm/tum-mcp.mjs", "npm/runtime.mjs", "npm/download.mjs", "npm/uv-artifacts.json", "npm/backend/*.whl", "npm/backend/requirements.txt", "npm/backend/manifest.json", "LICENSE", "NOTICE", "README.md", "docs/setup.md", "docs/development.md", "docs/tum-mcp.md"],
10
- "scripts": {"prepack": "node npm/build.mjs", "test": "node --test npm/test/*.test.mjs", "test:consumer": "node npm/smoke-consumer.mjs"},
11
- "publishConfig": {"access": "public"},
12
- "repository": {"type": "git", "url": "git+https://github.com/jamestesting0/tum-mcp.git"},
7
+ "bin": {
8
+ "tum-mcp": "npm/tum-mcp.mjs"
9
+ },
10
+ "engines": {
11
+ "node": ">=22.14.0"
12
+ },
13
+ "files": [
14
+ "npm/tum-mcp.mjs",
15
+ "npm/runtime.mjs",
16
+ "npm/download.mjs",
17
+ "npm/lock.mjs",
18
+ "npm/npm-command.mjs",
19
+ "npm/package-manager.mjs",
20
+ "npm/uv-artifacts.json",
21
+ "npm/backend/*.whl",
22
+ "npm/backend/requirements.txt",
23
+ "npm/backend/manifest.json",
24
+ "LICENSE",
25
+ "NOTICE",
26
+ "CHANGELOG.md",
27
+ "README.md",
28
+ "docs/setup.md",
29
+ "docs/development.md",
30
+ "docs/tum-mcp.md",
31
+ "docs/study.md"
32
+ ],
33
+ "scripts": {
34
+ "prepack": "node npm/build.mjs",
35
+ "test": "node --test npm/test/*.test.mjs",
36
+ "test:consumer": "node npm/smoke-consumer.mjs",
37
+ "test:upgrade": "node npm/upgrade-consumer.mjs",
38
+ "test:package-lifecycle": "node npm/package-consumer.mjs"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/jamestesting0/tum-mcp.git"
46
+ },
13
47
  "homepage": "https://github.com/jamestesting0/tum-mcp#readme",
14
- "bugs": {"url": "https://github.com/jamestesting0/tum-mcp/issues"},
15
- "keywords": ["mcp", "tum", "moodle", "artemis", "zulip", "codex"]
48
+ "bugs": {
49
+ "url": "https://github.com/jamestesting0/tum-mcp/issues"
50
+ },
51
+ "keywords": [
52
+ "mcp",
53
+ "tum",
54
+ "moodle",
55
+ "artemis",
56
+ "zulip",
57
+ "codex"
58
+ ]
16
59
  }