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 { clampNumber, enforceWritable, getConnectionName, json } from "../tools.js";
|
|
2
|
+
import { getPassword } from "../../security/credentialStore.js";
|
|
3
|
+
import { REDACTED } from "../../security/redact.js";
|
|
4
|
+
import { Tn5250Client, aidForKey, checkTlsCertificate } from "../../ibmi/tn5250/index.js";
|
|
5
|
+
import { AID_WITHOUT_DATA } from "../../ibmi/tn5250/datastream.js";
|
|
6
|
+
const sessions = new Map();
|
|
7
|
+
/** Replaceable dependencies (tests swap these). */
|
|
8
|
+
export const tn5250Deps = {
|
|
9
|
+
getPassword: (connectionName) => getPassword(connectionName),
|
|
10
|
+
checkCertificate: (info) => checkTlsCertificate({ host: info.host, port: info.port, certificate: info.certificate, authorized: info.authorized })
|
|
11
|
+
};
|
|
12
|
+
const LOCAL_KEYS = new Set(["TAB", "BACKTAB", "HOME", "RESET", "ERASEEOF", "FIELDEXIT"]);
|
|
13
|
+
const CONNECTION_NAME = { type: "string", description: "Saved IBM i connection name (defaults to the active connection)." };
|
|
14
|
+
const TIMEOUT_MS = { type: "number", description: "How long to wait for the host, in milliseconds." };
|
|
15
|
+
const FIELD_ID = {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "Field id from readScreen (for example \"r6c53\"), its 1-based index, or \"command\" for a recognised command line (===>)."
|
|
18
|
+
};
|
|
19
|
+
const tools = [
|
|
20
|
+
{
|
|
21
|
+
name: "ibmi.tn5250.connect",
|
|
22
|
+
description: "Open a real TN5250 (5250 green-screen) session to the saved connection's host and return the first screen. " +
|
|
23
|
+
"Port and TLS come from the connection settings tn5250Port / tn5250Tls (default 23, or 992 with TLS). " +
|
|
24
|
+
"signOn=true signs on with the connection's stored user and password (never passed as arguments); it counts as sending keys, so read-only connections refuse it.",
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
connectionName: CONNECTION_NAME,
|
|
29
|
+
port: { type: "number", description: "Override the telnet port (1-65535)." },
|
|
30
|
+
tls: { type: "boolean", description: "Override TLS (default: tn5250Tls setting, else true for port 992)." },
|
|
31
|
+
signOn: { type: "boolean", default: false, description: "Sign on with the stored credentials." },
|
|
32
|
+
deviceName: { type: "string", description: "Virtual device name to request (1-10 characters)." },
|
|
33
|
+
screenSize: { type: "string", enum: ["24x80", "27x132"], description: "27x132 announces an IBM-3477-FC display." },
|
|
34
|
+
timeoutMs: TIMEOUT_MS,
|
|
35
|
+
approve: { type: "boolean", description: "Set true to approve guarded-policy operations (signOn)." }
|
|
36
|
+
},
|
|
37
|
+
additionalProperties: false
|
|
38
|
+
},
|
|
39
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "ibmi.tn5250.readScreen",
|
|
43
|
+
description: "Read the current 5250 screen: text rows, cursor, keyboard state and the input fields (id, position, length, label, protected/hidden flags, value). " +
|
|
44
|
+
"Hidden (non-display, password-style) field contents are never returned.",
|
|
45
|
+
inputSchema: { type: "object", properties: { connectionName: CONNECTION_NAME }, additionalProperties: false },
|
|
46
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "ibmi.tn5250.setField",
|
|
50
|
+
description: "Fill an input field on the local screen (nothing is sent until sendKeys presses an AID key such as Enter). " +
|
|
51
|
+
"Identify the field by fieldId or by row/col. useStoredPassword=true fills a hidden field with the connection's stored password.",
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
connectionName: CONNECTION_NAME,
|
|
56
|
+
fieldId: FIELD_ID,
|
|
57
|
+
row: { type: "number", description: "1-based row of any position in the field." },
|
|
58
|
+
col: { type: "number", description: "1-based column of any position in the field." },
|
|
59
|
+
value: { type: "string" },
|
|
60
|
+
useStoredPassword: { type: "boolean", description: "Fill a hidden field with the stored password instead of value." }
|
|
61
|
+
},
|
|
62
|
+
additionalProperties: false
|
|
63
|
+
},
|
|
64
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "ibmi.tn5250.sendKeys",
|
|
68
|
+
description: "Type text at the cursor and/or press keys, then wait for the host to answer. keys is a space-separated list: local keys " +
|
|
69
|
+
"(Tab, BackTab, Home, Reset, EraseEOF, FieldExit) and at most one AID key at the end (Enter, F1-F24, PageUp, PageDown, Clear, Help, Print, Attn, SysReq). " +
|
|
70
|
+
"Pressing an AID key sends the screen to the IBM i: read-only connections refuse it, and text on a recognised command line (===>) is checked against the command policy as cl.run.",
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: "object",
|
|
73
|
+
properties: {
|
|
74
|
+
connectionName: CONNECTION_NAME,
|
|
75
|
+
text: { type: "string", description: "Text typed at the cursor before the keys." },
|
|
76
|
+
keys: { type: "string", description: "Keys to press, for example \"Enter\", \"F3\" or \"Tab Tab Enter\"." },
|
|
77
|
+
cursorRow: { type: "number", description: "Move the cursor here (1-based) before typing." },
|
|
78
|
+
cursorCol: { type: "number", description: "Move the cursor here (1-based) before typing." },
|
|
79
|
+
timeoutMs: TIMEOUT_MS,
|
|
80
|
+
approve: { type: "boolean", description: "Set true to approve guarded-policy operations." }
|
|
81
|
+
},
|
|
82
|
+
additionalProperties: false
|
|
83
|
+
},
|
|
84
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "ibmi.tn5250.waitFor",
|
|
88
|
+
description: "Wait until text appears on the screen and/or the keyboard unlocks (the host is waiting for input). With no text it waits for the keyboard.",
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: "object",
|
|
91
|
+
properties: {
|
|
92
|
+
connectionName: CONNECTION_NAME,
|
|
93
|
+
text: { type: "string", description: "Case-insensitive text to look for on any row." },
|
|
94
|
+
keyboardUnlocked: { type: "boolean", description: "Also require the keyboard to be unlocked (default true when text is empty)." },
|
|
95
|
+
timeoutMs: TIMEOUT_MS
|
|
96
|
+
},
|
|
97
|
+
additionalProperties: false
|
|
98
|
+
},
|
|
99
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "ibmi.tn5250.snapshot",
|
|
103
|
+
description: "Capture the screen as plain text plus the structured screen (hidden fields are blanked).",
|
|
104
|
+
inputSchema: { type: "object", properties: { connectionName: CONNECTION_NAME }, additionalProperties: false },
|
|
105
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "ibmi.tn5250.disconnect",
|
|
109
|
+
description: "Close the TN5250 session (the IBM i ends or disconnects the interactive job as configured for the device).",
|
|
110
|
+
inputSchema: { type: "object", properties: { connectionName: CONNECTION_NAME }, additionalProperties: false },
|
|
111
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }
|
|
112
|
+
}
|
|
113
|
+
];
|
|
114
|
+
async function handle(ctx, name, args) {
|
|
115
|
+
switch (name) {
|
|
116
|
+
case "ibmi.tn5250.connect":
|
|
117
|
+
return connect(ctx, args || {});
|
|
118
|
+
case "ibmi.tn5250.readScreen":
|
|
119
|
+
return json(sessionView(requireSession(ctx, args)));
|
|
120
|
+
case "ibmi.tn5250.setField":
|
|
121
|
+
return setField(ctx, args || {});
|
|
122
|
+
case "ibmi.tn5250.sendKeys":
|
|
123
|
+
return sendKeys(ctx, args || {});
|
|
124
|
+
case "ibmi.tn5250.waitFor":
|
|
125
|
+
return waitFor(ctx, args || {});
|
|
126
|
+
case "ibmi.tn5250.snapshot": {
|
|
127
|
+
const session = requireSession(ctx, args);
|
|
128
|
+
const view = sessionView(session);
|
|
129
|
+
return json({ capturedAt: new Date().toISOString(), connectionName: session.connectionName, plainText: view.text.join("\n"), screen: view });
|
|
130
|
+
}
|
|
131
|
+
case "ibmi.tn5250.disconnect": {
|
|
132
|
+
const connectionName = getConnectionName(ctx, args);
|
|
133
|
+
const session = sessions.get(connectionName);
|
|
134
|
+
const existed = closeSession(connectionName);
|
|
135
|
+
const endedJob = session?.signedOn && session.device ? await endDisconnectedJob(ctx, connectionName, session.device) : undefined;
|
|
136
|
+
return json({ disconnected: existed, connectionName, ...(endedJob ? { endedJob } : {}) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
140
|
+
}
|
|
141
|
+
async function connect(ctx, args) {
|
|
142
|
+
const connectionName = getConnectionName(ctx, args);
|
|
143
|
+
const existing = sessions.get(connectionName);
|
|
144
|
+
if (existing && !existing.client.closed) {
|
|
145
|
+
return json({ reused: true, ...sessionView(existing) });
|
|
146
|
+
}
|
|
147
|
+
if (existing)
|
|
148
|
+
sessions.delete(connectionName);
|
|
149
|
+
const stored = await ctx.store.getConnection(connectionName);
|
|
150
|
+
if (!stored)
|
|
151
|
+
throw new Error(`Unknown connection: ${connectionName}`);
|
|
152
|
+
const globalSettings = typeof ctx.store.getSettings === "function" ? await ctx.store.getSettings() : {};
|
|
153
|
+
const settings = { ...(globalSettings || {}), ...(stored.settings || {}) };
|
|
154
|
+
const tlsSetting = typeof args.tls === "boolean" ? args.tls : typeof settings.tn5250Tls === "boolean" ? settings.tn5250Tls : undefined;
|
|
155
|
+
const portSetting = args.port ?? settings.tn5250Port;
|
|
156
|
+
const port = portSetting !== undefined ? requirePort(portSetting) : tlsSetting ? 992 : 23;
|
|
157
|
+
const useTls = tlsSetting ?? port === 992;
|
|
158
|
+
const deviceName = optionalDeviceName(args.deviceName ?? settings.tn5250DeviceName);
|
|
159
|
+
const terminalType = args.screenSize === "27x132" ? "IBM-3477-FC" : "IBM-3179-2";
|
|
160
|
+
const timeoutMs = clampNumber(args.timeoutMs, 20000, 1000, 120000);
|
|
161
|
+
let password;
|
|
162
|
+
if (args.signOn) {
|
|
163
|
+
const conn = await ctx.ensureActive(connectionName);
|
|
164
|
+
await enforceWritable(ctx, conn, "tn5250.signOn", { ...args, connectionName });
|
|
165
|
+
password = await tn5250Deps.getPassword(connectionName);
|
|
166
|
+
if (!password)
|
|
167
|
+
throw new Error(`No stored password for ${connectionName}; signOn needs the connection's saved password`);
|
|
168
|
+
}
|
|
169
|
+
const client = new Tn5250Client({
|
|
170
|
+
host: stored.host,
|
|
171
|
+
port,
|
|
172
|
+
tls: useTls,
|
|
173
|
+
terminalType,
|
|
174
|
+
deviceName,
|
|
175
|
+
// The password is only offered in the telnet negotiation when it is encrypted by TLS.
|
|
176
|
+
autoSignOn: password && useTls ? { user: stored.username, password } : undefined,
|
|
177
|
+
connectTimeoutMs: timeoutMs,
|
|
178
|
+
checkCertificate: info => tn5250Deps.checkCertificate(info)
|
|
179
|
+
});
|
|
180
|
+
await client.connect();
|
|
181
|
+
const session = { connectionName, host: stored.host, port, tls: useTls, client, createdAt: new Date().toISOString() };
|
|
182
|
+
sessions.set(connectionName, session);
|
|
183
|
+
try {
|
|
184
|
+
let settled = await client.waitForSettled(timeoutMs);
|
|
185
|
+
session.device = deviceFromScreen(client.screen) || deviceName;
|
|
186
|
+
let signOn;
|
|
187
|
+
if (password) {
|
|
188
|
+
signOn = { method: client.autoSignOnOffered ? "telnet-environment" : "sign-on-screen" };
|
|
189
|
+
const form = signOnFields(client.screen);
|
|
190
|
+
if (form && settled) {
|
|
191
|
+
if (client.screen.inputInhibited)
|
|
192
|
+
client.screen.reset();
|
|
193
|
+
client.screen.setFieldValue(form.user, stored.username);
|
|
194
|
+
client.screen.setFieldValue(form.password, password);
|
|
195
|
+
client.screen.cursor = form.password.start;
|
|
196
|
+
client.sendKey("ENTER");
|
|
197
|
+
session.lastKey = "ENTER";
|
|
198
|
+
settled = await client.waitForSettled(timeoutMs);
|
|
199
|
+
signOn.method = "sign-on-screen";
|
|
200
|
+
}
|
|
201
|
+
signOn.stillOnSignOnScreen = Boolean(signOnFields(client.screen));
|
|
202
|
+
session.signedOn = !signOn.stillOnSignOnScreen;
|
|
203
|
+
}
|
|
204
|
+
return json({ ...sessionView(session), settled, ...(signOn ? { signOn } : {}) });
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
closeSession(connectionName);
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** The user and password fields of a sign-on style screen, if this is one. */
|
|
212
|
+
function signOnFields(screen) {
|
|
213
|
+
const inputs = screen.inputFields();
|
|
214
|
+
const password = inputs.find(f => f.hidden);
|
|
215
|
+
if (!password)
|
|
216
|
+
return undefined;
|
|
217
|
+
const user = inputs.filter(f => !f.hidden && f.start < password.start).pop();
|
|
218
|
+
return user ? { user, password } : undefined;
|
|
219
|
+
}
|
|
220
|
+
async function setField(ctx, args) {
|
|
221
|
+
const session = requireOpenSession(ctx, args);
|
|
222
|
+
const screen = session.client.screen;
|
|
223
|
+
const field = resolveField(screen, args);
|
|
224
|
+
if (!field)
|
|
225
|
+
throw new Error(`TN5250 field not found: ${describeFieldRef(args)}`);
|
|
226
|
+
if (field.bypass)
|
|
227
|
+
throw new Error(`TN5250 field ${screen.fieldId(field)} is protected`);
|
|
228
|
+
let value;
|
|
229
|
+
if (args.useStoredPassword) {
|
|
230
|
+
if (!field.hidden)
|
|
231
|
+
throw new Error("useStoredPassword can only fill a hidden (non-display) field");
|
|
232
|
+
const password = await tn5250Deps.getPassword(session.connectionName);
|
|
233
|
+
if (!password)
|
|
234
|
+
throw new Error(`No stored password for ${session.connectionName}`);
|
|
235
|
+
value = password;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
if (typeof args.value !== "string")
|
|
239
|
+
throw new Error("value is required (or useStoredPassword=true)");
|
|
240
|
+
value = args.value;
|
|
241
|
+
}
|
|
242
|
+
if (screen.inputInhibited)
|
|
243
|
+
screen.reset();
|
|
244
|
+
screen.setFieldValue(field, value);
|
|
245
|
+
return json(sessionView(session));
|
|
246
|
+
}
|
|
247
|
+
async function sendKeys(ctx, args) {
|
|
248
|
+
const session = requireOpenSession(ctx, args);
|
|
249
|
+
const { client } = session;
|
|
250
|
+
const screen = client.screen;
|
|
251
|
+
const keys = parseKeys(args.keys);
|
|
252
|
+
const text = typeof args.text === "string" ? args.text : undefined;
|
|
253
|
+
if (keys.length === 0 && !text)
|
|
254
|
+
throw new Error("keys or text is required");
|
|
255
|
+
const aidIndex = keys.findIndex(isAidKey);
|
|
256
|
+
for (const key of keys) {
|
|
257
|
+
if (!LOCAL_KEYS.has(key) && !isAidKey(key))
|
|
258
|
+
throw new Error(`Unsupported key: ${key}`);
|
|
259
|
+
}
|
|
260
|
+
if (aidIndex >= 0 && aidIndex !== keys.length - 1) {
|
|
261
|
+
throw new Error("Only one AID key (Enter, F1-F24, PageUp, ...) is allowed, and it must be the last key");
|
|
262
|
+
}
|
|
263
|
+
const timeoutMs = clampNumber(args.timeoutMs, 30000, 250, 300000);
|
|
264
|
+
const autoReset = screen.inputInhibited;
|
|
265
|
+
if (autoReset)
|
|
266
|
+
screen.reset();
|
|
267
|
+
if (args.cursorRow !== undefined || args.cursorCol !== undefined) {
|
|
268
|
+
screen.setCursor(Number(args.cursorRow ?? screen.rowCol(screen.cursor).row), Number(args.cursorCol ?? screen.rowCol(screen.cursor).col));
|
|
269
|
+
}
|
|
270
|
+
if (text)
|
|
271
|
+
screen.typeText(text);
|
|
272
|
+
const localKeys = aidIndex >= 0 ? keys.slice(0, aidIndex) : keys;
|
|
273
|
+
for (const key of localKeys)
|
|
274
|
+
applyLocalKey(screen, key);
|
|
275
|
+
let settled;
|
|
276
|
+
const aidKey = aidIndex >= 0 ? keys[aidIndex] : undefined;
|
|
277
|
+
if (aidKey) {
|
|
278
|
+
if (screen.keyboardLocked && !screen.inputInhibited && !["ATTN", "SYSREQ"].includes(aidKey)) {
|
|
279
|
+
throw new Error("The keyboard is locked (the host has not finished); use ibmi.tn5250.waitFor first, or Attn/SysReq");
|
|
280
|
+
}
|
|
281
|
+
const conn = await ctx.ensureActive(session.connectionName);
|
|
282
|
+
const gateArgs = { ...args, connectionName: session.connectionName };
|
|
283
|
+
const aid = aidForKey(aidKey);
|
|
284
|
+
const commands = aid !== undefined && !AID_WITHOUT_DATA.has(aid) ? commandLineEntries(screen) : [];
|
|
285
|
+
if (commands.length > 0) {
|
|
286
|
+
// Text on a command line is run as CL by the host, so it gets the cl.run checks.
|
|
287
|
+
for (const command of commands)
|
|
288
|
+
await enforceWritable(ctx, conn, "cl.run", { ...gateArgs, command, environment: "ile" });
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
await enforceWritable(ctx, conn, "tn5250.sendKeys", gateArgs);
|
|
292
|
+
}
|
|
293
|
+
client.sendKey(aidKey);
|
|
294
|
+
session.lastKey = aidKey;
|
|
295
|
+
settled = await client.waitForSettled(timeoutMs);
|
|
296
|
+
}
|
|
297
|
+
return json({
|
|
298
|
+
...sessionView(session),
|
|
299
|
+
...(aidKey ? { sent: aidKey, settled } : {}),
|
|
300
|
+
...(autoReset ? { autoReset: true } : {})
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
async function waitFor(ctx, args) {
|
|
304
|
+
const session = requireSession(ctx, args);
|
|
305
|
+
const { client } = session;
|
|
306
|
+
const timeoutMs = clampNumber(args?.timeoutMs, 5000, 100, 300000);
|
|
307
|
+
const text = typeof args?.text === "string" && args.text.length > 0 ? args.text.toLowerCase() : undefined;
|
|
308
|
+
const needUnlock = typeof args?.keyboardUnlocked === "boolean" ? args.keyboardUnlocked : !text;
|
|
309
|
+
const matched = await client.waitFor(() => {
|
|
310
|
+
if (text && !client.screen.renderRows().some(row => row.toLowerCase().includes(text)))
|
|
311
|
+
return false;
|
|
312
|
+
return !needUnlock || client.isReady();
|
|
313
|
+
}, timeoutMs);
|
|
314
|
+
return json({ matched, text: args?.text, keyboardUnlocked: needUnlock, timeoutMs, screen: sessionView(session) });
|
|
315
|
+
}
|
|
316
|
+
function applyLocalKey(screen, key) {
|
|
317
|
+
switch (key) {
|
|
318
|
+
case "TAB":
|
|
319
|
+
screen.tab();
|
|
320
|
+
break;
|
|
321
|
+
case "BACKTAB":
|
|
322
|
+
screen.backTab();
|
|
323
|
+
break;
|
|
324
|
+
case "HOME":
|
|
325
|
+
screen.home();
|
|
326
|
+
break;
|
|
327
|
+
case "RESET":
|
|
328
|
+
screen.reset();
|
|
329
|
+
break;
|
|
330
|
+
case "ERASEEOF":
|
|
331
|
+
screen.eraseToEndOfField();
|
|
332
|
+
break;
|
|
333
|
+
case "FIELDEXIT":
|
|
334
|
+
screen.fieldExit();
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function isAidKey(key) {
|
|
339
|
+
return key === "ATTN" || key === "SYSREQ" || aidForKey(key) !== undefined;
|
|
340
|
+
}
|
|
341
|
+
function parseKeys(value) {
|
|
342
|
+
if (value === undefined || value === null)
|
|
343
|
+
return [];
|
|
344
|
+
return String(value)
|
|
345
|
+
.split(/[\s,+]+/)
|
|
346
|
+
.map(part => part.trim().toUpperCase().replace(/[_-]/g, ""))
|
|
347
|
+
.filter(Boolean);
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Text in command lines (fields labelled ===>). Unmodified ones are included
|
|
351
|
+
* too, since a Read Input Fields response sends every field.
|
|
352
|
+
*/
|
|
353
|
+
function commandLineEntries(screen) {
|
|
354
|
+
const rows = screen.renderRows();
|
|
355
|
+
return screen.fields
|
|
356
|
+
.filter(f => screen.isCommandLine(f, rows))
|
|
357
|
+
.map(f => screen.fieldText(f).trim())
|
|
358
|
+
.filter(Boolean);
|
|
359
|
+
}
|
|
360
|
+
function resolveField(screen, args) {
|
|
361
|
+
if (args?.fieldId !== undefined && args.fieldId !== null && String(args.fieldId).trim() !== "") {
|
|
362
|
+
const id = String(args.fieldId).trim().toLowerCase();
|
|
363
|
+
if (id === "command") {
|
|
364
|
+
const rows = screen.renderRows();
|
|
365
|
+
return screen.fields.find(f => screen.isCommandLine(f, rows));
|
|
366
|
+
}
|
|
367
|
+
if (/^\d+$/.test(id))
|
|
368
|
+
return screen.fields[Number(id) - 1];
|
|
369
|
+
const match = /^r(\d+)c(\d+)$/.exec(id);
|
|
370
|
+
if (match)
|
|
371
|
+
return screen.fields.find(f => screen.fieldId(f) === `r${Number(match[1])}c${Number(match[2])}`);
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
if (args?.row !== undefined && args?.col !== undefined) {
|
|
375
|
+
return screen.fieldAt(screen.address(Number(args.row), Number(args.col)));
|
|
376
|
+
}
|
|
377
|
+
throw new Error("fieldId or row and col are required");
|
|
378
|
+
}
|
|
379
|
+
function describeFieldRef(args) {
|
|
380
|
+
return args?.fieldId !== undefined ? String(args.fieldId) : `row ${args?.row} column ${args?.col}`;
|
|
381
|
+
}
|
|
382
|
+
function sessionView(session) {
|
|
383
|
+
const { client } = session;
|
|
384
|
+
return {
|
|
385
|
+
connectionName: session.connectionName,
|
|
386
|
+
host: session.host,
|
|
387
|
+
port: session.port,
|
|
388
|
+
tls: session.tls,
|
|
389
|
+
terminalType: client.terminalType,
|
|
390
|
+
connected: !client.closed,
|
|
391
|
+
...(client.closed ? { closeReason: client.closeReason } : {}),
|
|
392
|
+
lastKey: session.lastKey,
|
|
393
|
+
...client.screen.view(),
|
|
394
|
+
...(client.screen.warnings.length > 0 ? { warnings: client.screen.warnings.slice(-5) } : {})
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function requireSession(ctx, args) {
|
|
398
|
+
const connectionName = getConnectionName(ctx, args);
|
|
399
|
+
const session = sessions.get(connectionName);
|
|
400
|
+
if (!session)
|
|
401
|
+
throw new Error(`TN5250 session for ${connectionName} not found. Call ibmi.tn5250.connect first.`);
|
|
402
|
+
return session;
|
|
403
|
+
}
|
|
404
|
+
function requireOpenSession(ctx, args) {
|
|
405
|
+
const session = requireSession(ctx, args);
|
|
406
|
+
if (session.client.closed) {
|
|
407
|
+
throw new Error(`TN5250 session for ${session.connectionName} is closed (${session.client.closeReason}). Call ibmi.tn5250.connect again.`);
|
|
408
|
+
}
|
|
409
|
+
return session;
|
|
410
|
+
}
|
|
411
|
+
/** The "Device:" value on a sign-on screen. */
|
|
412
|
+
function deviceFromScreen(screen) {
|
|
413
|
+
for (const line of screen.renderRows()) {
|
|
414
|
+
const match = /\bDevice[ .]*:\s+([A-Z#@$][A-Z0-9#@$_]{0,9})\b/.exec(line);
|
|
415
|
+
if (match)
|
|
416
|
+
return match[1];
|
|
417
|
+
}
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Closing the socket leaves a signed-on job disconnected (DSC) until the system
|
|
422
|
+
* ends it. End our own job for that device right away; best effort.
|
|
423
|
+
*/
|
|
424
|
+
async function endDisconnectedJob(ctx, connectionName, device) {
|
|
425
|
+
try {
|
|
426
|
+
const conn = await ctx.ensureActive(connectionName);
|
|
427
|
+
if (!/^[A-Z#@$][A-Z0-9#@$_]{0,9}$/.test(device))
|
|
428
|
+
return undefined;
|
|
429
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
430
|
+
// Only a disconnected job of the signed-on user on that device.
|
|
431
|
+
const rows = await conn.runSQL(`select job_name from table(qsys2.active_job_info(job_name_filter => '${device}')) x ` +
|
|
432
|
+
`where job_status = 'DSC' and job_name like '%/' concat current user concat '/%'`);
|
|
433
|
+
const job = rows?.[0]?.JOB_NAME;
|
|
434
|
+
if (job && /^\d{6}\/[A-Z0-9#@$_]{1,10}\/[A-Z0-9#@$_]{1,10}$/.test(job)) {
|
|
435
|
+
const result = await conn.runCL(`ENDJOB JOB(${job}) OPTION(*IMMED)`);
|
|
436
|
+
return result?.code === 0 ? job : undefined;
|
|
437
|
+
}
|
|
438
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
// the system ends disconnected jobs on its own (QDSCJOBITV)
|
|
443
|
+
}
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
function closeSession(connectionName) {
|
|
447
|
+
const session = sessions.get(connectionName);
|
|
448
|
+
if (!session)
|
|
449
|
+
return false;
|
|
450
|
+
session.client.close();
|
|
451
|
+
sessions.delete(connectionName);
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
function requirePort(value) {
|
|
455
|
+
const port = Number(value);
|
|
456
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
457
|
+
throw new Error(`Invalid TN5250 port: ${value}`);
|
|
458
|
+
return port;
|
|
459
|
+
}
|
|
460
|
+
function optionalDeviceName(value) {
|
|
461
|
+
if (value === undefined || value === null || String(value).trim() === "")
|
|
462
|
+
return undefined;
|
|
463
|
+
const name = String(value).trim().toUpperCase();
|
|
464
|
+
if (!/^[A-Z#@$][A-Z0-9#@$_]{0,9}$/.test(name))
|
|
465
|
+
throw new Error(`Invalid deviceName: ${value}`);
|
|
466
|
+
return name;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Hide what is typed into hidden (non-display) fields from logs and audit.
|
|
470
|
+
* When the target field cannot be determined the value is hidden too.
|
|
471
|
+
*/
|
|
472
|
+
function redactArgs(ctx, name, args) {
|
|
473
|
+
if (!args || typeof args !== "object")
|
|
474
|
+
return args;
|
|
475
|
+
if (name !== "ibmi.tn5250.setField" && name !== "ibmi.tn5250.sendKeys")
|
|
476
|
+
return args;
|
|
477
|
+
let hidden = true;
|
|
478
|
+
try {
|
|
479
|
+
const session = sessions.get(args.connectionName || ctx.activeName || "");
|
|
480
|
+
if (session) {
|
|
481
|
+
const screen = session.client.screen;
|
|
482
|
+
if (name === "ibmi.tn5250.setField") {
|
|
483
|
+
const field = resolveField(screen, args);
|
|
484
|
+
hidden = !field || field.hidden;
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
const cursor = args.cursorRow !== undefined || args.cursorCol !== undefined
|
|
488
|
+
? screen.address(Number(args.cursorRow ?? screen.rowCol(screen.cursor).row), Number(args.cursorCol ?? screen.rowCol(screen.cursor).col))
|
|
489
|
+
: screen.cursor;
|
|
490
|
+
const field = screen.fieldAt(cursor);
|
|
491
|
+
hidden = !field || field.hidden;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
hidden = true;
|
|
497
|
+
}
|
|
498
|
+
if (!hidden)
|
|
499
|
+
return args;
|
|
500
|
+
const copy = { ...args };
|
|
501
|
+
if (name === "ibmi.tn5250.setField" && "value" in copy)
|
|
502
|
+
copy.value = REDACTED;
|
|
503
|
+
if (name === "ibmi.tn5250.sendKeys" && "text" in copy)
|
|
504
|
+
copy.text = REDACTED;
|
|
505
|
+
return copy;
|
|
506
|
+
}
|
|
507
|
+
function onDisconnect(connectionName) {
|
|
508
|
+
if (connectionName) {
|
|
509
|
+
closeSession(connectionName);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
for (const name of [...sessions.keys()])
|
|
513
|
+
closeSession(name);
|
|
514
|
+
}
|
|
515
|
+
export const tn5250Module = { tools, handle, redactArgs, onDisconnect };
|