mcp-for-i 0.1.13 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/config/store.js +229 -109
- package/dist/controlplane/auth.js +128 -0
- package/dist/controlplane/clientIntegrationService.js +83 -4
- package/dist/controlplane/commands.js +14 -9
- package/dist/controlplane/connectionService.js +110 -5
- package/dist/controlplane/connectionTest.js +329 -0
- package/dist/controlplane/errors.js +12 -0
- package/dist/controlplane/runtimeService.js +98 -26
- package/dist/controlplane/server.js +122 -11
- package/dist/controlplane/serverShared.js +25 -6
- package/dist/controlplane/serviceManager.js +113 -100
- package/dist/controlplane/ui.js +1742 -920
- package/dist/ibmi/CompileTools.js +29 -1
- package/dist/ibmi/IBMiContent.js +435 -63
- package/dist/ibmi/IbmDocs.js +685 -0
- package/dist/ibmi/LocalLanguageActions.js +129 -3
- package/dist/ibmi/Search.js +158 -52
- package/dist/ibmi/Tools.js +7 -10
- package/dist/ibmi/client.js +220 -31
- package/dist/ibmi/evfevent.js +256 -0
- package/dist/ibmi/knownHosts.js +190 -0
- package/dist/ibmi/libraryList.js +93 -0
- package/dist/ibmi/sourceInspect.js +815 -0
- package/dist/ibmi/sqlJob.js +888 -0
- package/dist/ibmi/sqlParse.js +317 -0
- package/dist/ibmi/tn5250/certTrust.js +59 -0
- package/dist/ibmi/tn5250/client.js +310 -0
- package/dist/ibmi/tn5250/datastream.js +109 -0
- package/dist/ibmi/tn5250/ebcdic.js +74 -0
- package/dist/ibmi/tn5250/index.js +6 -0
- package/dist/ibmi/tn5250/screen.js +827 -0
- package/dist/ibmi/tn5250/telnet.js +186 -0
- package/dist/ibmi/variables.js +18 -7
- package/dist/index.js +43 -22
- package/dist/mcp/annotations.js +155 -0
- package/dist/mcp/audit.js +490 -133
- package/dist/mcp/confirm.js +48 -0
- package/dist/mcp/context.js +43 -5
- package/dist/mcp/deployMethods.js +192 -0
- package/dist/mcp/logger.js +5 -10
- package/dist/mcp/modules/bulkMembers.js +476 -0
- package/dist/mcp/modules/changedLines.js +189 -0
- package/dist/mcp/modules/compare.js +198 -0
- package/dist/mcp/modules/compile.js +515 -0
- package/dist/mcp/modules/ifsOps.js +123 -0
- package/dist/mcp/modules/index.js +38 -0
- package/dist/mcp/modules/inspect.js +811 -0
- package/dist/mcp/modules/libl.js +246 -0
- package/dist/mcp/modules/members.js +171 -0
- package/dist/mcp/modules/objects.js +339 -0
- package/dist/mcp/modules/system.js +266 -0
- package/dist/mcp/modules/tn5250.js +515 -0
- package/dist/mcp/policy.js +412 -0
- package/dist/mcp/tools.js +1700 -698
- package/dist/security/credentialStore.js +62 -13
- package/dist/security/localPaths.js +138 -0
- package/dist/security/redact.js +79 -0
- package/dist/util/atomicWrite.js +101 -0
- package/dist/util/fileLock.js +206 -0
- package/docs/capabilities.md +50 -7
- package/docs/configuration.md +189 -60
- package/docs/examples.md +109 -73
- package/docs/overview.md +15 -15
- package/docs/security.md +101 -16
- package/docs/tools.md +363 -188
- package/package.json +56 -53
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { CompileTools } from "../../ibmi/CompileTools.js";
|
|
4
|
+
import { Variables } from "../../ibmi/variables.js";
|
|
5
|
+
import { Tools } from "../../ibmi/Tools.js";
|
|
6
|
+
import { parseEvfevent } from "../../ibmi/evfevent.js";
|
|
7
|
+
import { actionCommandGuard, clampNumber, enforceWritable, guardLocalPath, handleTool, json, jsonError, mapActionExecution, normalizeSpoolEntry, normalizeSpoolLine, requireQsysName, resolveAction } from "../tools.js";
|
|
8
|
+
/** Variables an action may use that this server can leave unset for a target. */
|
|
9
|
+
const TARGET_VARIABLES = [
|
|
10
|
+
"RELATIVEPATH", "FULLPATH", "FILEDIR", "PARENT", "BASENAME", "NAME", "NAMEL", "EXT", "EXTL",
|
|
11
|
+
"OPENLIB", "OPENLIBL", "OPENSPF", "OPENSPFL", "OPENMBR", "OPENMBRL", "SRCFILE", "SRCLIB", "SRCPF",
|
|
12
|
+
"LIBRARY", "CURLIB", "BUILDLIB", "LIBLS", "WORKDIR", "HOME", "BRANCH", "USERNAME", "HOST"
|
|
13
|
+
];
|
|
14
|
+
/** Object parameters that name the object a compile creates. */
|
|
15
|
+
const OBJECT_PARAMETERS = ["PGM", "MODULE", "OBJ", "SRVPGM", "CMD", "FILE", "MENU", "PNLGRP", "QRY", "TBL"];
|
|
16
|
+
/** Split a member target such as LIB/FILE/MBR, LIB/FILE/MBR.TYPE or /QSYS.LIB/LIB.LIB/FILE.FILE/MBR.MBR. */
|
|
17
|
+
export function parseMemberTargetPath(value) {
|
|
18
|
+
const text = String(value ?? "").trim();
|
|
19
|
+
if (!text)
|
|
20
|
+
return {};
|
|
21
|
+
const qsys = text.match(/^\/QSYS\.LIB\/([^/]+)\.LIB\/([^/]+)\.FILE\/([^/]+)\.MBR$/i);
|
|
22
|
+
if (qsys)
|
|
23
|
+
return { library: qsys[1], sourceFile: qsys[2], member: qsys[3] };
|
|
24
|
+
const parts = text.replace(/^\/+/, "").split("/");
|
|
25
|
+
if (parts.length === 3) {
|
|
26
|
+
const [library, sourceFile, last] = parts;
|
|
27
|
+
const dot = last.lastIndexOf(".");
|
|
28
|
+
return dot > 0
|
|
29
|
+
? { library, sourceFile, member: last.substring(0, dot), extension: last.substring(dot + 1) }
|
|
30
|
+
: { library, sourceFile, member: last };
|
|
31
|
+
}
|
|
32
|
+
if (parts.length === 1) {
|
|
33
|
+
const dot = text.lastIndexOf(".");
|
|
34
|
+
return dot > 0 ? { member: text.substring(0, dot), extension: text.substring(dot + 1) } : { member: text };
|
|
35
|
+
}
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
/** Build the variables for a member or streamfile target, as Code for IBM i does. */
|
|
39
|
+
export async function buildActionTarget(conn, args, remoteWorkspace) {
|
|
40
|
+
const config = conn.getConfig();
|
|
41
|
+
const curlib = String(config.currentLibrary || "QGPL").toUpperCase();
|
|
42
|
+
const targetType = String(args?.targetType || "");
|
|
43
|
+
if (targetType === "member") {
|
|
44
|
+
const parsed = parseMemberTargetPath(args.targetPath);
|
|
45
|
+
const lib = requireQsysName(conn, args.library || parsed.library, "library");
|
|
46
|
+
const src = requireQsysName(conn, args.sourceFile || parsed.sourceFile, "sourceFile");
|
|
47
|
+
const mbr = requireQsysName(conn, args.member || parsed.member, "member");
|
|
48
|
+
let ext = String(args.extension || parsed.extension || "").trim();
|
|
49
|
+
if (!ext && conn.content?.getMemberInfo) {
|
|
50
|
+
try {
|
|
51
|
+
ext = String((await conn.content.getMemberInfo(lib, src, mbr))?.extension || "");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
ext = "";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (ext && !/^[A-Za-z0-9#@$_]{1,10}$/.test(ext))
|
|
58
|
+
throw new Error(`Invalid extension: ${ext}`);
|
|
59
|
+
const env = {
|
|
60
|
+
"&OPENLIB": lib,
|
|
61
|
+
"&OPENLIBL": lib.toLowerCase(),
|
|
62
|
+
"&OPENSPF": src,
|
|
63
|
+
"&OPENSPFL": src.toLowerCase(),
|
|
64
|
+
"&OPENMBR": mbr,
|
|
65
|
+
"&OPENMBRL": mbr.toLowerCase(),
|
|
66
|
+
"&EXT": ext.toUpperCase(),
|
|
67
|
+
"&EXTL": ext.toLowerCase(),
|
|
68
|
+
"&NAME": mbr,
|
|
69
|
+
"&NAMEL": mbr.toLowerCase(),
|
|
70
|
+
"&BASENAME": ext ? `${mbr}.${ext.toUpperCase()}` : mbr,
|
|
71
|
+
"&LIBRARY": lib,
|
|
72
|
+
"&SRCLIB": lib,
|
|
73
|
+
"&SRCPF": src,
|
|
74
|
+
"&SRCFILE": `${lib}/${src}`,
|
|
75
|
+
"&FULLPATH": `/QSYS.LIB/${lib}.LIB/${src}.FILE/${mbr}.MBR`
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
targetType: "member",
|
|
79
|
+
displayPath: `${lib}/${src}/${mbr}${ext ? `.${ext.toUpperCase()}` : ""}`,
|
|
80
|
+
env,
|
|
81
|
+
evfLibrary: lib,
|
|
82
|
+
evfMember: mbr,
|
|
83
|
+
library: lib,
|
|
84
|
+
sourceFile: src,
|
|
85
|
+
member: mbr
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (targetType === "streamfile") {
|
|
89
|
+
const target = CompileTools.validateStreamfilePath(args.targetPath);
|
|
90
|
+
const cwd = args.cwd
|
|
91
|
+
? CompileTools.validateStreamfilePath(args.cwd).replace(/(.)\/+$/, "$1")
|
|
92
|
+
: remoteWorkspace || String(config.homeDirectory || "").replace(/(.)\/+$/, "$1") || undefined;
|
|
93
|
+
const base = path.posix.basename(target);
|
|
94
|
+
const dot = base.lastIndexOf(".");
|
|
95
|
+
const name = dot > 0 ? base.substring(0, dot) : base;
|
|
96
|
+
const ext = dot > 0 ? base.substring(dot + 1) : "";
|
|
97
|
+
const dir = path.posix.dirname(target);
|
|
98
|
+
const relative = cwd && (target === cwd || target.startsWith(`${cwd === "/" ? "" : cwd}/`))
|
|
99
|
+
? path.posix.relative(cwd, target)
|
|
100
|
+
: target;
|
|
101
|
+
const env = {
|
|
102
|
+
"&FULLPATH": target,
|
|
103
|
+
"&FILEDIR": dir,
|
|
104
|
+
"&PARENT": path.posix.basename(dir),
|
|
105
|
+
"&RELATIVEPATH": relative || target,
|
|
106
|
+
"&BASENAME": base,
|
|
107
|
+
"{filename}": base,
|
|
108
|
+
"&NAME": name,
|
|
109
|
+
"&NAMEL": name.toLowerCase(),
|
|
110
|
+
"&EXT": ext,
|
|
111
|
+
"&EXTL": ext.toLowerCase(),
|
|
112
|
+
"&SRCLIB": curlib,
|
|
113
|
+
"&SRCPF": "QTMPSRC",
|
|
114
|
+
"&SRCFILE": `${curlib}/QTMPSRC`
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
targetType: "streamfile",
|
|
118
|
+
displayPath: target,
|
|
119
|
+
env,
|
|
120
|
+
cwd,
|
|
121
|
+
evfLibrary: curlib,
|
|
122
|
+
evfMember: name.toUpperCase()
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (targetType === "file") {
|
|
126
|
+
throw new Error("Local file actions are not supported in this MCP server (use streamfile or member).");
|
|
127
|
+
}
|
|
128
|
+
throw new Error("targetType must be member or streamfile");
|
|
129
|
+
}
|
|
130
|
+
/** Refuse a command that still holds a target variable this target did not set. */
|
|
131
|
+
export function assertNoUnresolvedVariables(command, targetType) {
|
|
132
|
+
const left = new Set();
|
|
133
|
+
for (const match of command.matchAll(/&([A-Za-z]+)(?![A-Za-z0-9_#@$])/g)) {
|
|
134
|
+
const name = match[1].toUpperCase();
|
|
135
|
+
if (TARGET_VARIABLES.includes(name))
|
|
136
|
+
left.add(`&${name}`);
|
|
137
|
+
}
|
|
138
|
+
if (left.size > 0) {
|
|
139
|
+
throw new Error(`Action uses ${Array.from(left).join(", ")}, which is not set for ${targetType} targets`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Find the object a compile command creates, for the EVFEVENT lookup. */
|
|
143
|
+
export function findCompiledObject(command, currentLibrary) {
|
|
144
|
+
const firstLine = String(command || "").split(/\r?\n/).find(line => /\*EVENTF/i.test(line)) || String(command || "");
|
|
145
|
+
// The first object parameter in the command is the object it creates
|
|
146
|
+
// (CRTCMD CMD(...) PGM(...) creates the command, not the program).
|
|
147
|
+
const match = firstLine.match(new RegExp(`\\b(?:${OBJECT_PARAMETERS.join("|")})\\(\\s*(?:([^\\s/()]+)\\/)?([^\\s/()]+)\\s*\\)`, "i"));
|
|
148
|
+
if (match) {
|
|
149
|
+
let library = (match[1] || "*LIBL").toUpperCase();
|
|
150
|
+
if (library === "*CURLIB" || library === "*LIBL")
|
|
151
|
+
library = currentLibrary.toUpperCase();
|
|
152
|
+
return { library, object: match[2].toUpperCase() };
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
/** Read LIB/EVFEVENT(member) through a QTEMP alias and parse it. */
|
|
157
|
+
export async function readEvfeventMember(conn, library, member) {
|
|
158
|
+
const lib = requireQsysName(conn, library, "EVFEVENT library");
|
|
159
|
+
const mbr = requireQsysName(conn, member, "EVFEVENT member");
|
|
160
|
+
try {
|
|
161
|
+
const rows = await conn.runSQL(`create or replace alias QTEMP.MCP_EVFEVENT for "${lib}"."EVFEVENT"("${mbr}");\n` +
|
|
162
|
+
// EVFEVENT is CCSID 65535, so the bytes come back raw unless cast to EBCDIC text.
|
|
163
|
+
`select rtrim(cast(E.EVFEVENT as varchar(400) ccsid 37)) as EVFEVENT from QTEMP.MCP_EVFEVENT as E order by rrn(E)`);
|
|
164
|
+
const lines = (rows || []).map(row => {
|
|
165
|
+
const value = row.EVFEVENT ?? Object.values(row)[0];
|
|
166
|
+
return String(value ?? "");
|
|
167
|
+
});
|
|
168
|
+
return { library: lib, member: mbr, found: true, records: lines.length, result: parseEvfevent(lines) };
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
return { library: lib, member: mbr, found: false, error: err?.message || String(err) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}[- T]\d{2}[.:]\d{2}[.:]\d{2}(?:\.\d{1,12})?$/;
|
|
175
|
+
/**
|
|
176
|
+
* Fetch the most recent compile listing: a spooled file named after the
|
|
177
|
+
* object, else QSYSPRT, else QPJOBLOG, owned by the user and created at or
|
|
178
|
+
* after `since`.
|
|
179
|
+
*/
|
|
180
|
+
export async function fetchCompileListing(conn, options) {
|
|
181
|
+
const object = requireQsysName(conn, options.objectName, "objectName");
|
|
182
|
+
const user = requireQsysName(conn, options.userName || conn.currentUser, "userName");
|
|
183
|
+
const since = options.since ? String(options.since).trim() : "";
|
|
184
|
+
if (since && !TIMESTAMP_PATTERN.test(since))
|
|
185
|
+
throw new Error(`Invalid since timestamp: ${options.since}`);
|
|
186
|
+
const maxLines = clampNumber(options.maxLines, 2000, 1, 20000);
|
|
187
|
+
const names = Array.from(new Set([object, "QSYSPRT", "QPJOBLOG"]));
|
|
188
|
+
const where = [
|
|
189
|
+
`user_name = ${Tools.sqlString(user)}`,
|
|
190
|
+
`spooled_file_name in (${names.map(n => Tools.sqlString(n)).join(", ")})`,
|
|
191
|
+
...(since ? [`create_timestamp >= timestamp(${Tools.sqlString(since)})`] : [])
|
|
192
|
+
].join(" and ");
|
|
193
|
+
const order = `order by case spooled_file_name ${names.map((n, i) => `when ${Tools.sqlString(n)} then ${i}`).join(" ")} else 9 end, create_timestamp desc fetch first 1 rows only`;
|
|
194
|
+
let entry;
|
|
195
|
+
let lastError;
|
|
196
|
+
for (const view of ["qsys2.output_queue_entries_basic", "qsys2.output_queue_entries"]) {
|
|
197
|
+
try {
|
|
198
|
+
const rows = await conn.runSQL(`select * from ${view} where ${where} ${order}`);
|
|
199
|
+
entry = rows?.[0] ? normalizeSpoolEntry(rows[0]) : undefined;
|
|
200
|
+
lastError = undefined;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
lastError = err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (lastError)
|
|
208
|
+
return { found: false, error: lastError?.message || String(lastError) };
|
|
209
|
+
if (!entry || !entry.jobName || !entry.spooledFileName)
|
|
210
|
+
return { found: false };
|
|
211
|
+
const number = entry.spooledFileNumber ?? 1;
|
|
212
|
+
const rows = await conn.runSQL(`select ordinal_position, spooled_data from table(qsys2.display_spooled_file_data(` +
|
|
213
|
+
`${Tools.sqlString(entry.jobName)}, ${Tools.sqlString(entry.spooledFileName)}, ${Number(number)})) ` +
|
|
214
|
+
`order by ordinal_position fetch first ${maxLines + 1} rows only`);
|
|
215
|
+
const lines = (rows || []).map((row) => normalizeSpoolLine(row).text);
|
|
216
|
+
return {
|
|
217
|
+
found: true,
|
|
218
|
+
jobName: entry.jobName,
|
|
219
|
+
spooledFileName: entry.spooledFileName,
|
|
220
|
+
spooledFileNumber: entry.spooledFileNumber,
|
|
221
|
+
createdAt: entry.createdAt,
|
|
222
|
+
lines: lines.slice(0, maxLines),
|
|
223
|
+
truncated: lines.length > maxLines
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
async function remoteTimestamp(conn) {
|
|
227
|
+
try {
|
|
228
|
+
const rows = await conn.runSQL(`select varchar(current timestamp) as TS from sysibm.sysdummy1`);
|
|
229
|
+
const value = String(rows?.[0]?.TS ?? "").trim();
|
|
230
|
+
return TIMESTAMP_PATTERN.test(value) ? value : undefined;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function mergeEvfResults(results) {
|
|
237
|
+
if (results.length === 0)
|
|
238
|
+
return undefined;
|
|
239
|
+
if (results.length === 1)
|
|
240
|
+
return results[0];
|
|
241
|
+
const diagnostics = results.flatMap(r => r.diagnostics);
|
|
242
|
+
const files = new Map();
|
|
243
|
+
for (const group of results.flatMap(r => r.files)) {
|
|
244
|
+
const existing = files.get(group.file);
|
|
245
|
+
if (!existing) {
|
|
246
|
+
files.set(group.file, { ...group, errors: [...group.errors], counts: { ...group.counts } });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
existing.errors.push(...group.errors);
|
|
250
|
+
existing.counts.errors += group.counts.errors;
|
|
251
|
+
existing.counts.warnings += group.counts.warnings;
|
|
252
|
+
existing.counts.infos += group.counts.infos;
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
timestamp: results.find(r => r.timestamp)?.timestamp,
|
|
256
|
+
processors: results.reduce((sum, r) => sum + r.processors, 0),
|
|
257
|
+
files: Array.from(files.values()),
|
|
258
|
+
diagnostics,
|
|
259
|
+
summary: {
|
|
260
|
+
total: diagnostics.length,
|
|
261
|
+
errors: diagnostics.filter(d => d.severity === "ERROR").length,
|
|
262
|
+
warnings: diagnostics.filter(d => d.severity === "WARN").length,
|
|
263
|
+
infos: diagnostics.filter(d => d.severity === "INFO").length,
|
|
264
|
+
highestSeverity: diagnostics.reduce((max, d) => Math.max(max, d.severityLevel), 0)
|
|
265
|
+
},
|
|
266
|
+
unparsedLines: results.reduce((sum, r) => sum + r.unparsedLines, 0)
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
async function listLocalFilesRecursive(root) {
|
|
270
|
+
const stat = await fs.stat(root).catch(() => undefined);
|
|
271
|
+
if (!stat)
|
|
272
|
+
return [];
|
|
273
|
+
if (stat.isFile())
|
|
274
|
+
return [root];
|
|
275
|
+
const out = [];
|
|
276
|
+
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
|
277
|
+
const full = path.join(root, entry.name);
|
|
278
|
+
if (entry.isDirectory())
|
|
279
|
+
out.push(...await listLocalFilesRecursive(full));
|
|
280
|
+
else if (entry.isFile())
|
|
281
|
+
out.push(full);
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
async function postDownload(ctx, conn, entries, remoteBase, localBase) {
|
|
286
|
+
const outcomes = [];
|
|
287
|
+
for (const rawEntry of entries) {
|
|
288
|
+
const entry = String(rawEntry || "").trim();
|
|
289
|
+
if (!entry)
|
|
290
|
+
continue;
|
|
291
|
+
let remotePath = "";
|
|
292
|
+
try {
|
|
293
|
+
const relative = !entry.startsWith("/");
|
|
294
|
+
if (relative && !remoteBase)
|
|
295
|
+
throw new Error("a relative postDownload path needs cwd or remoteWorkspace");
|
|
296
|
+
remotePath = relative ? path.posix.join(remoteBase, entry) : entry;
|
|
297
|
+
remotePath = CompileTools.validateStreamfilePath(remotePath.replace(/(.)\/+$/, "$1"));
|
|
298
|
+
const localRelative = relative ? entry.replace(/\/+$/, "") : path.posix.basename(remotePath);
|
|
299
|
+
if (localRelative.split("/").some(part => part === ".."))
|
|
300
|
+
throw new Error("postDownload path must not contain '..'");
|
|
301
|
+
const localPath = await guardLocalPath(ctx, path.join(localBase, ...localRelative.split("/")), "write");
|
|
302
|
+
const isDirectory = (await conn.sendCommand({ command: `test -d ${Tools.escapePath(remotePath)}` }))?.code === 0;
|
|
303
|
+
if (isDirectory) {
|
|
304
|
+
await fs.mkdir(localPath, { recursive: true });
|
|
305
|
+
await conn.client.getDirectory(localPath, remotePath, { recursive: true, concurrency: 5 });
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
await fs.mkdir(path.dirname(localPath), { recursive: true });
|
|
309
|
+
await conn.client.getFile(localPath, remotePath);
|
|
310
|
+
}
|
|
311
|
+
outcomes.push({ remotePath, localPath, kind: isDirectory ? "directory" : "file", ok: true });
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
outcomes.push({ remotePath: remotePath || entry, ok: false, error: err?.message || String(err) });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return outcomes;
|
|
318
|
+
}
|
|
319
|
+
function formatCommandOutput(command, result) {
|
|
320
|
+
return [
|
|
321
|
+
`Command: ${command}`,
|
|
322
|
+
`Exit code: ${result?.code}`,
|
|
323
|
+
"",
|
|
324
|
+
"STDOUT:",
|
|
325
|
+
String(result?.stdout || ""),
|
|
326
|
+
"",
|
|
327
|
+
"STDERR:",
|
|
328
|
+
String(result?.stderr || ""),
|
|
329
|
+
""
|
|
330
|
+
].join("\n");
|
|
331
|
+
}
|
|
332
|
+
/** ibmi.actions.run: run an action against a member or streamfile and collect its errors. */
|
|
333
|
+
export async function runActionTool(ctx, args) {
|
|
334
|
+
const conn = await ctx.ensureActive(args?.connectionName);
|
|
335
|
+
await enforceWritable(ctx, conn, "qsys.write", args);
|
|
336
|
+
const action = await resolveAction(ctx, args);
|
|
337
|
+
if (!action)
|
|
338
|
+
throw new Error("Action not found");
|
|
339
|
+
const targetType = String(args?.targetType || "");
|
|
340
|
+
if (action.type && action.type !== targetType && targetType !== "file") {
|
|
341
|
+
throw new Error(`Action "${action.name}" runs against ${action.type} targets, not ${targetType} targets`);
|
|
342
|
+
}
|
|
343
|
+
const remoteWorkspace = args?.remoteWorkspace
|
|
344
|
+
? CompileTools.validateStreamfilePath(args.remoteWorkspace).replace(/(.)\/+$/, "$1")
|
|
345
|
+
: undefined;
|
|
346
|
+
const target = await buildActionTarget(conn, args, remoteWorkspace);
|
|
347
|
+
const config = conn.getConfig();
|
|
348
|
+
const curlib = String(config.currentLibrary || "QGPL").toUpperCase();
|
|
349
|
+
// deployFirst: push the local workspace before running (streamfile targets).
|
|
350
|
+
let deploy;
|
|
351
|
+
if (action.deployFirst && target.targetType === "streamfile") {
|
|
352
|
+
if (args?.localWorkspace) {
|
|
353
|
+
if (!remoteWorkspace)
|
|
354
|
+
throw new Error("remoteWorkspace is required with localWorkspace");
|
|
355
|
+
const deployResult = await handleTool(ctx, "ibmi.deploy.sync", {
|
|
356
|
+
connectionName: args.connectionName,
|
|
357
|
+
approve: args.approve,
|
|
358
|
+
localPath: args.localWorkspace,
|
|
359
|
+
remotePath: remoteWorkspace,
|
|
360
|
+
overwrite: true
|
|
361
|
+
});
|
|
362
|
+
deploy = deployResult?.structuredContent?.data;
|
|
363
|
+
if (deployResult?.isError) {
|
|
364
|
+
return jsonError(`Deploy before "${action.name}" failed; the action was not run`, { action: action.name, deploy });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
deploy = { skipped: true, reason: "deployFirst is set but no localWorkspace was given" };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const listingSince = args?.includeListing ? await remoteTimestamp(conn) : undefined;
|
|
372
|
+
const guard = actionCommandGuard(ctx, action.environment, args);
|
|
373
|
+
const commandResult = await CompileTools.runCommand(conn, {
|
|
374
|
+
command: action.command,
|
|
375
|
+
environment: action.environment,
|
|
376
|
+
env: target.env,
|
|
377
|
+
cwd: target.cwd
|
|
378
|
+
}, {
|
|
379
|
+
commandConfirm: async (expanded) => {
|
|
380
|
+
assertNoUnresolvedVariables(expanded, target.targetType);
|
|
381
|
+
return guard(expanded);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
const expandedCommand = String(commandResult.command || action.command);
|
|
385
|
+
const payload = mapActionExecution(action, target.targetType, target.displayPath, commandResult);
|
|
386
|
+
if (target.cwd)
|
|
387
|
+
payload.cwd = target.cwd;
|
|
388
|
+
if (deploy !== undefined)
|
|
389
|
+
payload.deploy = deploy;
|
|
390
|
+
// EVFEVENT from the object's library, as written by OPTION(*EVENTF).
|
|
391
|
+
const evfResults = [];
|
|
392
|
+
const environment = action.environment || "ile";
|
|
393
|
+
if (environment === "ile" && /\*EVENTF\b/i.test(expandedCommand)) {
|
|
394
|
+
const object = findCompiledObject(expandedCommand, curlib);
|
|
395
|
+
const library = String(args?.evfeventLibrary || object?.library || target.evfLibrary).toUpperCase();
|
|
396
|
+
const member = String(args?.evfeventMember || object?.object || target.evfMember).toUpperCase();
|
|
397
|
+
if (conn.validQsysName(library) && conn.validQsysName(member)) {
|
|
398
|
+
const read = await readEvfeventMember(conn, library, member);
|
|
399
|
+
payload.evfevent = { library: read.library, member: read.member, found: read.found, records: read.records, error: read.error };
|
|
400
|
+
if (read.result)
|
|
401
|
+
evfResults.push(read.result);
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
payload.evfevent = { library, member, found: false, error: "EVFEVENT library or member is not a valid object name" };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// postDownload: copy build outputs back to the local workspace.
|
|
408
|
+
const downloads = (action.postDownload || []).filter(Boolean);
|
|
409
|
+
if (downloads.length > 0) {
|
|
410
|
+
const localBase = args?.downloadTo || args?.localWorkspace;
|
|
411
|
+
if (!localBase) {
|
|
412
|
+
payload.postDownload = { skipped: true, reason: "postDownload is set but neither downloadTo nor localWorkspace was given" };
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
const base = await guardLocalPath(ctx, localBase, "write");
|
|
416
|
+
const outcomes = await postDownload(ctx, conn, downloads, target.cwd || remoteWorkspace, base);
|
|
417
|
+
payload.postDownload = outcomes;
|
|
418
|
+
for (const outcome of outcomes) {
|
|
419
|
+
if (!outcome.ok || !outcome.localPath || !/\.evfevent/i.test(outcome.remotePath))
|
|
420
|
+
continue;
|
|
421
|
+
for (const file of await listLocalFilesRecursive(outcome.localPath)) {
|
|
422
|
+
try {
|
|
423
|
+
evfResults.push(parseEvfevent(await fs.readFile(file, "utf8")));
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// Not readable as text: skip it.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const evf = mergeEvfResults(evfResults);
|
|
433
|
+
if (evf) {
|
|
434
|
+
payload.errors = evf.files;
|
|
435
|
+
payload.errorSummary = evf.summary;
|
|
436
|
+
if (payload.ok && evf.summary.errors > 0)
|
|
437
|
+
payload.status = "warning";
|
|
438
|
+
}
|
|
439
|
+
if (args?.includeListing) {
|
|
440
|
+
const object = findCompiledObject(expandedCommand, curlib);
|
|
441
|
+
const objectName = object?.object || target.evfMember;
|
|
442
|
+
try {
|
|
443
|
+
payload.listing = conn.validQsysName(objectName)
|
|
444
|
+
? await fetchCompileListing(conn, { objectName, since: listingSince, maxLines: args?.listingMaxLines })
|
|
445
|
+
: { found: false, error: `No valid object name for the listing: ${objectName}` };
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
payload.listing = { found: false, error: err?.message || String(err) };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// outputToFile: save the command output locally.
|
|
452
|
+
if (action.outputToFile) {
|
|
453
|
+
try {
|
|
454
|
+
let outputPath = new Variables(undefined, target.env).expand(action.outputToFile);
|
|
455
|
+
if (/^&LOCAL\b/i.test(outputPath) || !path.isAbsolute(outputPath)) {
|
|
456
|
+
if (!args?.localWorkspace)
|
|
457
|
+
throw new Error("a relative outputToFile path needs localWorkspace");
|
|
458
|
+
outputPath = path.join(args.localWorkspace, outputPath.replace(/^&LOCAL[\\/]?/i, ""));
|
|
459
|
+
}
|
|
460
|
+
const resolved = await guardLocalPath(ctx, outputPath, "write");
|
|
461
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
462
|
+
await fs.writeFile(resolved, formatCommandOutput(expandedCommand, commandResult), "utf8");
|
|
463
|
+
payload.outputFile = resolved;
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
payload.outputFile = { ok: false, error: err?.message || String(err) };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (!payload.ok) {
|
|
470
|
+
const errorCount = evf?.summary.errors || 0;
|
|
471
|
+
const firstError = evf?.diagnostics.find(d => d.severity === "ERROR");
|
|
472
|
+
const detail = firstError
|
|
473
|
+
? `${errorCount} error(s); first: ${firstError.msgid} line ${firstError.line}: ${firstError.text}`
|
|
474
|
+
: (Array.isArray(payload.diagnostics) ? payload.diagnostics.find((d) => d.severity === "ERROR")?.message : undefined)
|
|
475
|
+
|| String(commandResult.stderr || commandResult.stdout || "").trim().split(/\r?\n/)[0] || `exit ${commandResult.code}`;
|
|
476
|
+
return jsonError(`Action "${action.name}" failed (${detail})`, payload);
|
|
477
|
+
}
|
|
478
|
+
return json(payload);
|
|
479
|
+
}
|
|
480
|
+
export const compileModule = {
|
|
481
|
+
tools: [
|
|
482
|
+
{
|
|
483
|
+
name: "ibmi.compile.listing",
|
|
484
|
+
description: "Read the most recent compile listing for an object: the newest spooled file named after the object " +
|
|
485
|
+
"(falling back to QSYSPRT, then QPJOBLOG) owned by the user, optionally created at or after a timestamp.",
|
|
486
|
+
inputSchema: {
|
|
487
|
+
type: "object",
|
|
488
|
+
properties: {
|
|
489
|
+
connectionName: { type: "string", description: "Saved IBM i connection name." },
|
|
490
|
+
objectName: { type: "string", description: "Object (program, module, file) the compile created." },
|
|
491
|
+
userName: { type: "string", description: "Spooled file owner. Defaults to the connection user." },
|
|
492
|
+
since: { type: "string", description: "Only listings created at or after this IBM i timestamp (YYYY-MM-DD-HH.MM.SS[.ffffff])." },
|
|
493
|
+
maxLines: { type: "number", description: "Maximum listing lines to return (default 2000)." }
|
|
494
|
+
},
|
|
495
|
+
required: ["objectName"],
|
|
496
|
+
additionalProperties: false
|
|
497
|
+
},
|
|
498
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
499
|
+
}
|
|
500
|
+
],
|
|
501
|
+
async handle(ctx, name, args) {
|
|
502
|
+
if (name !== "ibmi.compile.listing")
|
|
503
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
504
|
+
const conn = await ctx.ensureActive(args?.connectionName);
|
|
505
|
+
const listing = await fetchCompileListing(conn, {
|
|
506
|
+
objectName: args?.objectName,
|
|
507
|
+
userName: args?.userName,
|
|
508
|
+
since: args?.since,
|
|
509
|
+
maxLines: args?.maxLines
|
|
510
|
+
});
|
|
511
|
+
if (listing.error)
|
|
512
|
+
return jsonError(`Could not read the compile listing: ${listing.error}`, listing);
|
|
513
|
+
return json(listing);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Tools } from "../../ibmi/Tools.js";
|
|
2
|
+
import { assertSafeIfsTargetPath, checkResourcePolicy, normalizeIfsPath } from "../policy.js";
|
|
3
|
+
import { commandOutcome, enforceWritable, resolveIfsWriteOperation, toolError } from "../tools.js";
|
|
4
|
+
/**
|
|
5
|
+
* IFS copy and move. Paths are checked like ibmi.ifs.delete (no root,
|
|
6
|
+
* top-level or system directories, no '..' or wildcards), against the path
|
|
7
|
+
* policy, and are single-quoted for the shell. An existing destination is
|
|
8
|
+
* only overwritten when overwrite=true.
|
|
9
|
+
*/
|
|
10
|
+
const CONNECTION_NAME = { type: "string", description: "Saved IBM i connection name." };
|
|
11
|
+
const APPROVE = { type: "boolean", description: "Set true to approve guarded-policy operations." };
|
|
12
|
+
const ANNOTATIONS = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
|
|
13
|
+
const tools = [
|
|
14
|
+
{
|
|
15
|
+
name: "ibmi.ifs.copy",
|
|
16
|
+
description: "Copy an IFS file with cp, or a directory tree with recursive=true (cp -R). " +
|
|
17
|
+
"Refuses an existing destination unless overwrite=true (an existing directory then receives the copy inside it).",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
sourcePath: { type: "string", description: "Absolute IFS path to copy." },
|
|
22
|
+
destinationPath: { type: "string", description: "Absolute IFS path to create." },
|
|
23
|
+
recursive: { type: "boolean", description: "Copy a directory and everything under it." },
|
|
24
|
+
overwrite: { type: "boolean", description: "Allow an existing destination. Default false." },
|
|
25
|
+
connectionName: CONNECTION_NAME,
|
|
26
|
+
approve: APPROVE
|
|
27
|
+
},
|
|
28
|
+
required: ["sourcePath", "destinationPath"],
|
|
29
|
+
additionalProperties: false
|
|
30
|
+
},
|
|
31
|
+
annotations: ANNOTATIONS
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "ibmi.ifs.move",
|
|
35
|
+
description: "Move or rename an IFS file or directory with mv. " +
|
|
36
|
+
"Refuses an existing destination unless overwrite=true (an existing directory then receives the entry inside it).",
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
sourcePath: { type: "string", description: "Absolute IFS path to move." },
|
|
41
|
+
destinationPath: { type: "string", description: "Absolute IFS path to move it to." },
|
|
42
|
+
overwrite: { type: "boolean", description: "Allow an existing destination. Default false." },
|
|
43
|
+
connectionName: CONNECTION_NAME,
|
|
44
|
+
approve: APPROVE
|
|
45
|
+
},
|
|
46
|
+
required: ["sourcePath", "destinationPath"],
|
|
47
|
+
additionalProperties: false
|
|
48
|
+
},
|
|
49
|
+
annotations: ANNOTATIONS
|
|
50
|
+
}
|
|
51
|
+
];
|
|
52
|
+
/** Basic checks for a path that is only read (a non-recursive copy source). */
|
|
53
|
+
function requireReadablePath(value, operation) {
|
|
54
|
+
const raw = String(value ?? "").trim();
|
|
55
|
+
if (!raw)
|
|
56
|
+
throw new Error(`${operation} refused: sourcePath is empty`);
|
|
57
|
+
if (/[\0\r\n]/.test(raw))
|
|
58
|
+
throw new Error(`${operation} refused: path contains control characters`);
|
|
59
|
+
if (!raw.startsWith("/"))
|
|
60
|
+
throw new Error(`${operation} refused: path must be absolute`);
|
|
61
|
+
if (raw.split(/[\\/]/).some(segment => segment === ".."))
|
|
62
|
+
throw new Error(`${operation} refused: path must not contain '..'`);
|
|
63
|
+
if (/[*?[\]]/.test(raw))
|
|
64
|
+
throw new Error(`${operation} refused: wildcards are not allowed`);
|
|
65
|
+
const normalized = normalizeIfsPath(raw);
|
|
66
|
+
if (normalized === "/")
|
|
67
|
+
throw new Error(`${operation} refused: cannot copy the root directory`);
|
|
68
|
+
return normalized;
|
|
69
|
+
}
|
|
70
|
+
function enforcePathPolicy(ctx, toolName, args, paths) {
|
|
71
|
+
const policy = ctx.getPolicy(args?.connectionName);
|
|
72
|
+
for (const ifsPath of paths) {
|
|
73
|
+
const reason = checkResourcePolicy(policy, toolName, { path: ifsPath });
|
|
74
|
+
if (reason)
|
|
75
|
+
throw new Error(`Blocked by policy: ${toolName} (${reason})`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function refuseSameOrNested(source, destination, operation) {
|
|
79
|
+
const src = source.toLowerCase();
|
|
80
|
+
const dest = destination.toLowerCase();
|
|
81
|
+
if (src === dest)
|
|
82
|
+
throw new Error(`${operation} refused: sourcePath and destinationPath are the same`);
|
|
83
|
+
if (dest.startsWith(`${src}/`))
|
|
84
|
+
throw new Error(`${operation} refused: destinationPath is inside sourcePath`);
|
|
85
|
+
}
|
|
86
|
+
async function pathExists(conn, ifsPath) {
|
|
87
|
+
const res = await conn.sendCommand({ command: `test -e ${Tools.shellQuote(ifsPath)}` });
|
|
88
|
+
return res?.code === 0;
|
|
89
|
+
}
|
|
90
|
+
async function handle(ctx, name, args) {
|
|
91
|
+
args = args || {};
|
|
92
|
+
const conn = await ctx.ensureActive(args.connectionName);
|
|
93
|
+
const operation = name.replace(/^ibmi\./, "");
|
|
94
|
+
const destination = assertSafeIfsTargetPath(args.destinationPath, operation, "write to");
|
|
95
|
+
const source = name === "ibmi.ifs.move" || args.recursive
|
|
96
|
+
? assertSafeIfsTargetPath(args.sourcePath, operation, name === "ibmi.ifs.move" ? "move" : "recursively copy")
|
|
97
|
+
: requireReadablePath(args.sourcePath, operation);
|
|
98
|
+
refuseSameOrNested(source, destination, operation);
|
|
99
|
+
await enforceWritable(ctx, conn, resolveIfsWriteOperation(destination), args);
|
|
100
|
+
if (name === "ibmi.ifs.move")
|
|
101
|
+
await enforceWritable(ctx, conn, "ifs.delete", args);
|
|
102
|
+
enforcePathPolicy(ctx, name, args, [source, destination]);
|
|
103
|
+
if (args.overwrite !== true && await pathExists(conn, destination)) {
|
|
104
|
+
return toolError(`${destination} already exists. Pass overwrite=true to replace it or copy into it.`, {
|
|
105
|
+
sourcePath: source,
|
|
106
|
+
destinationPath: destination
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
let command;
|
|
110
|
+
switch (name) {
|
|
111
|
+
case "ibmi.ifs.copy":
|
|
112
|
+
command = `cp ${args.recursive ? "-R " : ""}${Tools.shellQuote(source)} ${Tools.shellQuote(destination)}`;
|
|
113
|
+
break;
|
|
114
|
+
case "ibmi.ifs.move":
|
|
115
|
+
command = `mv ${Tools.shellQuote(source)} ${Tools.shellQuote(destination)}`;
|
|
116
|
+
break;
|
|
117
|
+
default:
|
|
118
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
119
|
+
}
|
|
120
|
+
const res = await conn.sendCommand({ command });
|
|
121
|
+
return commandOutcome({ ...res, ok: res?.code === 0, command, sourcePath: source, destinationPath: destination });
|
|
122
|
+
}
|
|
123
|
+
export const ifsOpsModule = { tools, handle };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { objectsModule } from "./objects.js";
|
|
2
|
+
import { membersModule } from "./members.js";
|
|
3
|
+
import { ifsOpsModule } from "./ifsOps.js";
|
|
4
|
+
import { bulkMembersModule } from "./bulkMembers.js";
|
|
5
|
+
import { compareModule } from "./compare.js";
|
|
6
|
+
import { inspectModule } from "./inspect.js";
|
|
7
|
+
import { compileModule } from "./compile.js";
|
|
8
|
+
import { changedLinesModule } from "./changedLines.js";
|
|
9
|
+
import { liblModule } from "./libl.js";
|
|
10
|
+
import { systemModule } from "./system.js";
|
|
11
|
+
import { tn5250Module } from "./tn5250.js";
|
|
12
|
+
// Feature modules register here, one import and one array entry each.
|
|
13
|
+
export const toolModules = [objectsModule, membersModule, ifsOpsModule, bulkMembersModule, compareModule, inspectModule, compileModule, systemModule, changedLinesModule, liblModule, tn5250Module];
|
|
14
|
+
export function findToolModule(name) {
|
|
15
|
+
return toolModules.find(module => module.tools.some(tool => tool.name === name));
|
|
16
|
+
}
|
|
17
|
+
/** Arguments with module-specific secrets removed, ready for generic redaction. */
|
|
18
|
+
export function redactModuleArgs(ctx, name, args) {
|
|
19
|
+
const module = findToolModule(name);
|
|
20
|
+
if (!module?.redactArgs)
|
|
21
|
+
return args;
|
|
22
|
+
try {
|
|
23
|
+
return module.redactArgs(ctx, name, args);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return args;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function notifyModulesOfDisconnect(connectionName) {
|
|
30
|
+
for (const module of toolModules) {
|
|
31
|
+
try {
|
|
32
|
+
module.onDisconnect?.(connectionName);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// a module's cleanup must not break disconnecting
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|