0
0
Fork 0
Codesys-MCP-SP21-plus/src/detect.ts
steel 729ba03593
Some checks failed
CI / build-and-test (18.x) (push) Has been cancelled
CI / build-and-test (20.x) (push) Has been cancelled
CI / build-and-test (22.x) (push) Has been cancelled
fixup msp-con
2026-08-03 17:45:08 +08:00

289 lines
10 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
export interface CodesysInstall {
installDir: string;
exePath: string;
version: string;
major: number;
minor: number;
sp: number;
patch: number;
profileName: string;
serverName: string;
/**
* The AdditionalFolders installation carrying the most registered plugins,
* if any. See LauncherConfig.additionalFolder -- without passing this the
* IDE boots the bare base profile and scripting is unavailable.
*/
additionalFolder?: string;
}
const VERSION_RE = /^CODESYS\s+(\d+)\.(\d+)\.(\d+)\.(\d+)$/i;
/**
* Pick the richest AdditionalFolders installation for an install dir.
*
* The CODESYS Installer writes one folder per "Installation", each with its
* own Profiles\<name>.profile.xml listing that installation's plugins. Every
* one of them reuses the base profile NAME, so the name alone can't tell them
* apart -- we rank by how many plugins each registers and take the fullest.
* Returns undefined when there are no AdditionalFolders (the common case for
* a stock install, where the base profile already has everything).
*/
function findRichestAdditionalFolder(
installDir: string,
fsApi: {
readdirSync: typeof fs.readdirSync;
readFileSync?: typeof fs.readFileSync;
}
): string | undefined {
const readFileSync = fsApi.readFileSync;
if (!readFileSync) return undefined;
const root = path.join(installDir, 'CODESYS', 'AdditionalFolders');
let entries: string[];
try {
entries = fsApi.readdirSync(root);
} catch {
return undefined;
}
let best: { dir: string; plugins: number } | undefined;
for (const entry of entries) {
const dir = path.join(root, entry);
const profilesDir = path.join(dir, 'Profiles');
let profiles: string[];
try {
profiles = fsApi.readdirSync(profilesDir).filter((f) => f.toLowerCase().endsWith('.profile.xml'));
} catch {
continue;
}
let plugins = 0;
for (const profile of profiles) {
try {
const xml = readFileSync(path.join(profilesDir, profile), 'utf-8');
plugins += (xml.match(/<Hint>/g) ?? []).length;
console.log(`Found ${plugins} plugins in ${path.join(profilesDir, profile)}`);
} catch {
/* unreadable profile -- treat as contributing nothing */
}
}
if (plugins > 0 && (!best || plugins > best.plugins)) {
best = { dir, plugins };
}
}
return best?.dir;
}
/**
* Parse a CODESYS profile name like "CODESYS V3.5 SP22 Patch 1" or
* "CODESYS V3.5 SP21" into its SP + patch numbers.
*
* Returns null when the name doesn't match the canonical CODESYS profile
* shape -- callers should treat that as "can't decide" and skip whatever
* behaviour the parsed values would have driven (e.g. open_project's
* pre-flight mismatch check falls through silently).
*/
const PROFILE_NAME_RE = /^CODESYS\s+V\d+\.\d+\s+SP(\d+)(?:\s+Patch\s+(\d+))?\s*$/i;
export function parseProfileName(name: string): { sp: number; patch: number } | null {
if (!name) return null;
const m = PROFILE_NAME_RE.exec(name.trim());
if (!m) return null;
const sp = parseInt(m[1], 10);
const patch = m[2] !== undefined ? parseInt(m[2], 10) : 0;
if (Number.isNaN(sp) || Number.isNaN(patch)) return null;
return { sp, patch };
}
function deriveProfileName(major: number, minor: number, sp: number, patch: number): string {
const head = `CODESYS V${major}.${minor} SP${sp}`;
return patch === 0 ? head : `${head} Patch ${patch}`;
}
function deriveServerName(sp: number, patch: number): string {
const head = `codesys-sp${sp}`;
return patch === 0 ? head : `${head}-patch${patch}`;
}
export function detectInstalls(
searchDirs: string[] = ['C:\\Program Files', 'C:\\Program Files (x86)'],
fsApi: {
readdirSync: typeof fs.readdirSync;
existsSync: typeof fs.existsSync;
// Optional so existing injected fakes keep compiling -- without it we just
// can't rank AdditionalFolders and leave additionalFolder undefined.
readFileSync?: typeof fs.readFileSync;
} = fs
): CodesysInstall[] {
const installs: CodesysInstall[] = [];
const seen = new Set<string>();
for (const base of searchDirs) {
let entries: string[];
try {
entries = fsApi.readdirSync(base);
} catch {
continue;
}
for (const entry of entries) {
const m = VERSION_RE.exec(entry);
if (!m) continue;
const major = parseInt(m[1], 10);
const minor = parseInt(m[2], 10);
const sp = parseInt(m[3], 10);
const rawPatch = parseInt(m[4], 10);
const patch = Math.floor(rawPatch / 10);
const exePath = path.join(base, entry, 'CODESYS', 'Common', 'CODESYS.exe');
if (!fsApi.existsSync(exePath)) continue;
if (seen.has(exePath.toLowerCase())) continue;
seen.add(exePath.toLowerCase());
console.log(`Detected CODESYS install: ${exePath} (V${major}.${minor} SP${sp} Patch ${patch})`);
installs.push({
installDir: path.join(base, entry),
exePath,
version: `${major}.${minor}.${sp}.${rawPatch}`,
major,
minor,
sp,
patch,
profileName: deriveProfileName(major, minor, sp, patch),
serverName: deriveServerName(sp, patch),
additionalFolder: findRichestAdditionalFolder(path.join(base, entry), fsApi),
});
}
}
installs.sort((a, b) => {
if (a.sp !== b.sp) return a.sp - b.sp;
return a.patch - b.patch;
});
return installs;
}
export interface PrintConfigOptions {
sp?: number;
name?: string;
date?: string;
/**
* When set, the caller has already pre-filtered `installs` based on a
* .project file's saved profile (see --for-project in bin.ts). printConfig
* just renders -- the matching logic stays in bin.ts so detect.ts can stay
* independent of inspect.ts. The hint drives the header comment block.
*/
forProjectHint?: {
profileName: string;
profileVersion: string;
matchKind: 'exact' | 'sp-only-fallback';
};
}
export function printConfig(installs: CodesysInstall[], opts: PrintConfigOptions = {}): string {
let filtered = installs;
if (opts.sp !== undefined) {
filtered = installs.filter((i) => i.sp === opts.sp);
}
if (filtered.length === 0) {
if (opts.sp !== undefined) {
throw new Error(
`No CODESYS V3.5 SP${opts.sp} installation detected. Run \`codesys-mcp-sp21-plus --detect\` to see what's available.`
);
}
throw new Error(
`No CODESYS installations detected. Looked under "C:\\Program Files" and "C:\\Program Files (x86)" for "CODESYS X.Y.Z.W"-named directories with a CODESYS\\Common\\CODESYS.exe inside. Run \`codesys-mcp-sp21-plus --detect\` for raw output.`
);
}
if (opts.name !== undefined && filtered.length !== 1) {
throw new Error(
`--name only works when exactly one install is selected. Got ${filtered.length}. Combine with --sp <n> to narrow down.`
);
}
const today = opts.date ?? new Date().toISOString().slice(0, 10);
const lines: string[] = [];
lines.push(`// Auto-generated by \`codesys-mcp-sp21-plus --print-config\` on ${today}.`);
if (filtered.length === 1) {
lines.push(`// Detected 1 CODESYS installation.`);
} else {
lines.push(`// Detected ${filtered.length} CODESYS installations. Add the entries you want; remove the rest.`);
lines.push(`//`);
lines.push(`// Multiple entries can be active at the same time -- different CODESYS`);
lines.push(`// installs (e.g. SP21 + SP22) spawn separate processes and run side by side.`);
lines.push(`// The only hard rule: don't open the SAME .project file from two CODESYS`);
lines.push(`// instances simultaneously -- file-lock contention pops a "project is`);
lines.push(`// currently in use" modal that blocks all script execution.`);
}
if (opts.forProjectHint) {
lines.push(`//`);
if (opts.forProjectHint.matchKind === 'exact') {
lines.push(
`// Filtered by --for-project: matches ${opts.forProjectHint.profileName} (project saved on ${opts.forProjectHint.profileVersion})`
);
} else {
lines.push(
`// No exact match for project's saved profile (${opts.forProjectHint.profileName}, version ${opts.forProjectHint.profileVersion}).`
);
lines.push(
`// Falling back to all installed SP${opts.forProjectHint.profileName.match(/SP(\d+)/)?.[1] ?? '?'} versions. The patch difference will trigger`
);
lines.push(
`// a CODESYS conversion dialog on first open.`
);
}
}
lines.push(`//`);
lines.push(`// Profile names are derived from the install directory version. If CODESYS's`);
lines.push(`// own Profile dialog shows a different name (e.g. localised), edit the`);
lines.push(`// --codesys-profile value to match exactly.`);
lines.push('');
const usedNames = new Set<string>();
const namedEntries = filtered.map((install, idx) => {
let name: string;
if (opts.name !== undefined) {
name = opts.name;
} else if (filtered.length === 1) {
name = 'codesys';
} else {
name = install.serverName;
}
console.log(name);
let unique = name;
let suffix = 2;
while (usedNames.has(unique)) {
unique = `${name}-${suffix++}`;
}
usedNames.add(unique);
return { name: unique, install, isLast: idx === filtered.length - 1 };
});
lines.push('{');
lines.push(' "mcpServers": {');
for (const { name, install, isLast } of namedEntries) {
console.log(name);
lines.push(` ${JSON.stringify(name)}: {`);
lines.push(` "command": "codesys-mcp-sp21-plus",`);
lines.push(` "args": [`);
lines.push(` "--codesys-path", ${JSON.stringify(install.exePath)},`);
lines.push(` "--codesys-profile", ${JSON.stringify(install.profileName)},`);
if (install.additionalFolder) {
lines.push(` "--codesys-additional-folder", ${JSON.stringify(install.additionalFolder)},`);
}
lines.push(` "--mode", "persistent"`);
lines.push(` ]`);
lines.push(` }${isLast ? '' : ','}`);
}
lines.push(' }');
lines.push('}');
return lines.join('\n');
}